feat(orders): capture order cancellation as first-class record state - #2022
Conversation
Previously handleSourceCancellation only relayed a source cancellation to destinations and returned, leaving no queryable trace of it on the order record itself. Add a first-write-wins cancelledAt column, written atomically via a COALESCE update (markCancelled) so a redelivered cancel event or a later re-poll can never overwrite an already-recorded instant. - OrderRecord.cancelledAt + isCancelled getter; OrderRecordRepositoryPort and OrderRecordRepository.markCancelled (atomic COALESCE update). - handleSourceCancellation calls markCancelled before relaying to destinations; the write is best-effort (logged, not rethrown) so a transient DB error can never block the pre-existing relay behaviour. - persistOrder/persistIncomingSnapshot record a cancellation observed via the ordinary ingestion path (source reports status: 'cancelled' directly) through the same atomic markCancelled, called after upsert() rather than folded into it — upsert() never touches cancelledAt, so two ingestion paths (webhook + reconciliation poll) racing for the same order can't stomp each other's write. - OrderRecordFilters.cancelled + a findMany predicate make the fact queryable without parsing orderSnapshot; not yet wired into any HTTP filter or FE control — that's the aggregate endpoints' job (#1987/#1988). - cancelledAt surfaced on OrderRecordResponseDto. - Additive migration with a best-effort backfill (cancelledAt := updatedAt) for historical rows whose snapshot already reports 'cancelled'. Closes #1984 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Addresses the tech-review gap on PR #2022: the implementation plan called for an integration test exercising handleSourceCancellation and the persistOrder preservation path against real Postgres, but none was added. - New int-spec covers: durable cancellation + redelivery no-op via handleSourceCancellation, the ADR-017 destination-echo guard skipping a destination-reported cancel, and persistOrder's upsert() never clobbering an already-recorded cancelledAt on a later re-poll. - Update handleSourceCancellation's JSDoc to mention the markCancelled write instead of only the pre-#1984 relay behaviour. - Retitle/re-comment two tautological unit-test assertions in order-record.service.spec.ts to state what they actually guard against. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
piotrswierzy
left a comment
There was a problem hiding this comment.
Tech-lead review — ✅ Approve
Adds order_records.cancelledAt so a source-reported cancellation leaves a queryable trace instead of being visible only by parsing orderSnapshot. Both observation paths are covered — the dedicated cancel-event handler and the ordinary ingestion path where a feed simply reports status: 'cancelled'. The concurrency reasoning here is the strongest part of the PR and it's correct.
Verified
The central design call is right: cancellation is a new column, not a new recordStatus value. recordStatus tracks item-mapping resolution (ready / awaiting_mapping / source_deleted), and an order can legitimately be ready and cancelled at once. Modelling it as a status value would have been lossy and would have broken every exhaustive switch, health bucket, and list filter that consumes recordStatus. The order-record.entity.ts:67-74 comment says exactly this. This is also why the diff touches so few call sites for a change of this reach.
Single-writer discipline for the new column is genuinely airtight, and the non-obvious half is the omission. order-record.repository.ts:732-744 deliberately does not map cancelledAt in toOrm, so TypeORM omits the column from the generated statement entirely (the ORM property is declared cancelledAt!: Date | null with no initializer, so it stays undefined, and TypeORM skips undefined — unlike null). That matters because upsert() is an unlocked full-object save(), and per docs/architecture-overview.md § "Webhook = trigger, poll = reconciliation backstop" the webhook and reconciliation poll paths legitimately race for the same order. Mapping the column there would have let a stale in-memory read stomp a concurrently-committed value. Instead markCancelled is the sole writer, via COALESCE("cancelledAt", $1) (repository.ts:558-565) — atomic, first-write-wins, no read-before-write. A redelivered cancel event is a genuine no-op, not a last-write-wins overwrite of the first-observed instant.
Ordering in recordCancellationIfNeeded is deliberate and correct (order-record.service.ts:270-278): called after upsert(), never before, precisely because upsert() cannot carry the column. The follow-up findById is needed because upsert()'s return value can't reflect a column it never sent, and the extra read is paid only on the rare cancelled path. Both facts are stated in the comment rather than left for a reader to rediscover.
Migration is correct on every axis I checked:
- Timestamp
1832000000008is strictly greater thanorigin/main's current tail (1832000000007), per the synthetic-sequential rule indocs/migrations.md § Timestamp uniqueness invariantrule 3 — verified againstgit ls-tree origin/main, not assumed. - Class suffix
AddOrderRecordCancelledAt1832000000008matches the filename prefix. up()anddown()both present, both fullyIF [NOT] EXISTS-guarded, so a re-run or from-scratch replay is a no-op.- The backfill is idempotent (
WHERE "cancelledAt" IS NULL) and — importantly — honest about its own imprecision: it usesupdatedAtas an explicitly-labelled proxy and says in the header that the true cancellation instant is unreconstructable from data OL holds. That's the right way to ship a lossy backfill. - Index on
cancelledAtsupports theIS [NOT] NULLpredicate the aggregate endpoints will need.
Error handling on the relay path is the right trade-off (order-ingestion.service.ts:517-534): markCancelled is attempted before the relay so a throwing relay can't skip the record write, and its own failure is logged-not-rethrown so a transient DB error cannot stop the cancel reaching the destination shop. The pre-existing behaviour is the one that must not regress, and the comment names that reasoning.
Test coverage is proportionate — unit specs at the entity, service, and repository layers plus a dedicated order-cancellation-record-state.int-spec.ts for the vertical slice. isCancelled is a pure getter over an already-loaded field, which is inside the ADR-011 allowance.
SUGGESTION
- Merge-order note, not a defect: #2046 (return/refund/withdrawal records) is in flight and will likely also claim a
1832000000008/…009prefix. Whichever lands second needs a re-prefix —pnpm lintwill catch it viacheck-migration-timestamps.mjs, but worth coordinating so it isn't discovered at the pre-commit hook. cancelledAtis set-once and never cleared by design. If any source can un-cancel an order (Allegro can't, but a shop source conceivably could), the record would stay cancelled permanently. Worth one line in the entity doc stating that no un-cancel path exists yet, so a future reader knows it's an unhandled case rather than an oversight.- The swallowed
markCancelledfailure leaves the record queryable as non-cancelled with only a log line, self-healing on the next re-poll. Fine today; once #1987/#1988 surface cancellation counts to operators, that silent divergence becomes worth a metric rather than just a log. OrderRecordFilters.cancelledships unwired to any HTTP param — correctly scoped to #1987/#1988 and documented as such. No action, just noting the deliberate dead-until-consumed surface.
Merge readiness: ✅ Approve — correct model, airtight single-writer concurrency story, and a properly guarded idempotent migration.
) CI's type-check job failed because the PR branch was 3 commits behind main, which merged the order-cancellation feature (#2022) and its markCancelled addition to IOrderRecordService. The synthetic PR merge commit GitHub Actions checks out therefore resolved IOrderRecordService with markCancelled, while refunds.controller.spec.ts's local mock (written against the pre-#2022 interface) didn't implement it, producing a jest.Mocked<IOrderRecordService> type mismatch. Merged origin/main and added markCancelled: jest.fn() to the mock. Re-verified: type-check (core+api) clean, lint clean, migration ordering still correct against the new 1832000000008 migration, and all refund unit tests (22/22) still pass. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
…2022 merge - Re-timestamp the new sync_jobs composite index migration from 1832000000008 to 1832000000009 after merging main, since #2022's add-order-record-cancelled-at migration already claimed 1832000000008. - Drop the stale 'coverage window' wording from IAnalyticsTrustService's doc comment - the field was renamed to connectionCreatedAt precisely because it never supported that claim. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Resolves conflicts introduced by main's order-cancellation feature (#2022/#1984), which added OrderRecordRepositoryPort.markCancelled / IOrderRecordService.markCancelled alongside this branch's own additive getFailedSyncValueSummary — both land on the same interface/mock files. Kept both methods everywhere: the interface, and the five test mocks (shipment.controller.spec.ts, fulfillment-status-sync.service.spec.ts, shipment-dispatch-notification.service.spec.ts, shipment-dispatch.service.spec.ts) that construct a full IOrderRecordService/OrderRecordRepositoryPort mock. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
* feat(analytics-trust,sync): add analytics data-trust reads (#1982) Adds a new analytics-trust core context plus a GET /analytics/trust endpoint reporting, for every active OrderSource connection, its last successful ingestion time, coverage-window start, and whether ingestion appears stalled (staleness threshold derived from the connection's own registered poll cadence, not a fixed constant). Read-only — no ingestion behaviour changes. Extends SyncJobRepositoryPort with findLastSucceededByConnectionAndJobType (ordered by completion time, not enqueue time) and threads it through the existing ISyncJobsService cross-context seam rather than injecting the repository port directly. Closes #1982 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics-trust): address tech-review findings on #1982 - Clean up the malformed import block in AnalyticsTrustService (typescript-eslint's consistent-type-imports and prettier were fighting on repeated --fix runs; hand-fixed and verified both tools converge cleanly now). - Extract the catch-branch's degraded entry into buildDegradedEntry to remove duplication with the success-path object literal. - Clarify estimateCronIntervalMs's docstring on the irregular-cron-expression limitation. - Add the integration test that was flagged as a known gap in the PR: exercises the real IIntegrationsService -> ISyncJobsService -> SchedulerTaskRegistryService wiring and the real Postgres updatedAt-DESC ordering. Registers a synthetic orders-poll scheduler task directly (mirroring how plugins self-register at boot) since the shared test harness disables every plugin's real scheduler tasks to avoid external calls during tests. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics-trust,sync): address blocking/important tech-review findings on #2037 - Decouple the sync-job lookup from scheduler-task presence (finding 1): AnalyticsTrustService now always reads marketplace.orders.poll / marketplace.order.sync job history, regardless of whether a matching poll task is registered. A disabled/absent poll task previously made a webhook-fed connection read 'never-ingested' forever. - Derive the cadence threshold only from an *enabled* scheduler task (finding 2): ISyncJobsService.findEnabledPollTask encapsulates the same enabledEnvVar/enabledDefault check SchedulerService applies, so a registered-but-disabled task can no longer produce a false 'stalled'. This also removes AnalyticsTrustService's direct injection of the concrete SchedulerTaskRegistryService class across a context boundary. - Report order-ingestion recency independently of poll-pipe liveness (finding 3): ConnectionIngestionTrust now carries both lastPollAt (pipe liveness, thresholded) and lastOrderIngestedAt (data recency, not thresholded) so a healthy poll with zero real orders no longer reads as unconditionally 'fresh'. - Add an 'unknown' status for a per-connection build failure (finding 4): a transient error no longer asserts the false claim 'never-ingested'. - Rename coverageStartAt -> connectionCreatedAt and drop the "coverage window" claim from its description (finding 5) — connection.createdAt is not an earliest-data-point signal. - Add a supporting composite index on sync_jobs(connectionId, jobType, status, updatedAt), a deterministic updatedAt/id DESC tiebreaker, and an outcome != 'business_failure' filter per ADR-007 (finding 6). - Add an integration-test case with no enabled scheduler task registered for the connection's platform, regression-testing finding 1 (finding 7). - Floor the staleness threshold at 30 minutes so a slow backstop poll (e.g. PrestaShop's reconciliation sweep) doesn't false-positive a healthy webhook-fed connection into 'stalled' (suggestion). - Add a top-level worstStatus roll-up to the snapshot/DTO so the FE doesn't have to re-encode the status severity ordering (suggestion). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics-trust,sync): fix CI lint failures on #2037 - Remove unnecessary async on jest mockImplementation callbacks that had no await expression (@typescript-eslint/require-await) in analytics-trust.service.spec.ts. - Import SchedulerTaskRegistryService as a value (not type-only) in SyncJobsService — its constructor parameter is decorator-metadata-visible via @Inject, so a type-only import breaks emitDecoratorMetadata (@typescript-eslint/consistent-type-imports). - Fix the same type-only-import-under-decorator issue in the analytics-trust DTO/controller (pre-existing from the original PR commit, never reached by CI because libs/core's earlier failure short-circuited the fail-fast `pnpm -r lint` run before apps/api's lint task started). - Type response.body in the integration test via a local AnalyticsTrustTestResponse/ConnectionIngestionTrustTestEntry interface instead of letting it stay `any` — fixes 44 no-unsafe-* errors that were likewise never reached by the failed CI run for the same fail-fast reason. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(sync): default outcome to 'ok' for succeeded jobs in sync-job test fixture findLastSucceededByConnectionAndJobType filters outcome: Not('business_failure'), which excludes NULL outcomes in SQL (NULL != x is NULL, not true). The createTestSyncJob fixture left outcome unset when overriding status to 'succeeded', so every fixture-seeded succeeded job was silently excluded, causing all 5 analytics-trust-read.int-spec.ts failures on CI. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics-trust,sync): resolve migration timestamp collision from #2022 merge - Re-timestamp the new sync_jobs composite index migration from 1832000000008 to 1832000000009 after merging main, since #2022's add-order-record-cancelled-at migration already claimed 1832000000008. - Drop the stale 'coverage window' wording from IAnalyticsTrustService's doc comment - the field was renamed to connectionCreatedAt precisely because it never supported that claim. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics-trust,integrations,sync): address blocking tech-lead re-review findings on #2037 - Enumerate OrderSource-capable connections regardless of status (listCapabilityAdapters gains an opt-in includeAllStatuses passthrough, default-off so every other caller is unaffected). A connection the auth-failure classifier flips to needs_reauth was previously omitted from the snapshot entirely, so worstStatus rolled up 'fresh' while ingestion was dead (BLOCKING 1). - Add a 'disconnected' ingestion status: a non-active connection is always classified 'disconnected', overriding whatever its poll history would otherwise say. ConnectionIngestionTrust now carries the connection's raw status too, so the operator can see why. - Fall back staleAfterMs to the 30-minute floor instead of leaving it null when no enabled scheduler task is registered, so a webhook-first connection with an ancient last poll eventually reads 'stalled' rather than 'fresh' forever (IMPORTANT 2). - Fix findLastSucceededByConnectionAndJobType's outcome filter: OR in an explicit IsNull() branch alongside Not('business_failure'), since 1790000000003 added the column nullable with no backfill and a plain != comparison silently drops every pre-#400 succeeded row (IMPORTANT 5). Corrected the comment that asserted outcome is always set. Closes the gap flagged in the tech-lead re-review: the endpoint's whole purpose is telling an operator whether to trust a number, so a false green during the most common real ingestion failure (an expired token) was the one bug that had to land before anything else. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(sync,docs): name sync_jobs composite index, document analytics-trust context Addresses the two remaining tech-review suggestions on #2037 that carried across all review rounds without a code/doc change: - Explicitly name the new (connectionId, jobType, status, updatedAt) index on SyncJobOrmEntity to match the migration's hand-picked name. An unnamed @Index decorator gets a TypeORM-derived hash name, which would make a future migration:generate propose a duplicate index. - Add an "Analytics Trust" bounded-context entry to docs/architecture-overview.md (§ Core Bounded Contexts and the cross-context dependency mermaid map), flagged by both reviewers in the first review round and never addressed in any follow-up commit. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
#2046) * feat(orders): capture return/refund/withdrawal as a first-class record (#2036) Adds a capture-only RefundRecord entity/port/repository/service in the orders context, a manual operator-facing write path (POST /orders/:internalOrderId/refunds), a read path (GET /orders/:internalOrderId/refunds) plus an in-process batch summary seam (getRefundSummariesForOrders) for future analytics consumers (#1987/#1988/#1990) without reaching into orders internals. Purely additive: new refund_records table, no change to OrderStatusValues or PaymentStatusValues. Closes #2036 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): address tech-review findings on refund records (#2036) Piotr's review flagged four IMPORTANT gaps and one SUGGESTION on the refund-record capture slice: - No idempotency guard: a retried POST could silently insert a second row and inflate RefundSummary.totalAmount. Add an optional idempotencyKey column + partial unique index (mirrors InvoiceRecord's (connectionId, idempotencyKey) dedup guard), converted to a 409 Conflict via DuplicateRefundRecordException. - amount stored as text, aggregated via CAST(... AS numeric): one bad row (reachable through the barrel-exported IOrderRefundService, which performs no validation) could fail the whole batch summary. Changed the column to numeric(12,2) so the DB itself enforces the shape; dropped the now-unnecessary CAST. - No logging on a money-adjacent manual write: added a Logger to both RefundsController and OrderRefundService. - Mixed-currency refunds on one order could silently produce a wrong summed total: OrderRefundService.recordRefund now rejects a currency mismatch against an order's prior refunds (RefundCurrencyMismatchException -> 409), making the aggregate's MIN(currency) safe by construction. - SUGGESTION: the ORM->domain reason cast is no longer a blind `as`; RefundRecordRepository.toRefundReason narrows against RefundReasonValues with a warn-log + 'other' fallback, mirroring OrderRecord.paymentStatus. Also folds in the earlier pass's naming/DTO nits (refund.controller.ts renamed to refunds.controller.ts to match RefundsController; response DTO's reason typed as RefundReason; GET route documents that it doesn't verify order existence). Closes #2036 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): merge main and update stale IOrderRecordService mock (#2036) CI's type-check job failed because the PR branch was 3 commits behind main, which merged the order-cancellation feature (#2022) and its markCancelled addition to IOrderRecordService. The synthetic PR merge commit GitHub Actions checks out therefore resolved IOrderRecordService with markCancelled, while refunds.controller.spec.ts's local mock (written against the pre-#2022 interface) didn't implement it, producing a jest.Mocked<IOrderRecordService> type mismatch. Merged origin/main and added markCancelled: jest.fn() to the mock. Re-verified: type-check (core+api) clean, lint clean, migration ordering still correct against the new 1832000000008 migration, and all refund unit tests (22/22) still pass. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): add missing getFailedSyncValueSummary mock in refunds controller spec Post-merge with main, IOrderRecordService gained getFailedSyncValueSummary (#1983) but the refunds controller spec's mock wasn't updated, breaking type-check on the merge commit. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(api): resolve migration timestamp collision on refund records 1833000000000-create-refund-records.ts collided with 1833000000000-add-destination-categories-table.ts, which merged into main after this branch diverged. Bump to 1833000000002, after the current latest migration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Summary
handleSourceCancellationpreviously relayed a source cancellation to destinations and returned — nothing was written to the order record, so cancellation was invisible to SQL and only present inorderSnapshot.statusif a later poll happened to re-ingest the order.cancelledAtcolumn onorder_records, written once and preserved via an atomicCOALESCEupdate (markCancelled) — a redelivered cancel event or a later re-poll can never overwrite an already-recorded instant.handleSourceCancellationnow callsmarkCancelledbefore relaying to destinations; the write is best-effort (logged, not rethrown) so a transient DB error can never block the pre-existing relay behaviour.persistOrder/persistIncomingSnapshot(the ordinary polling/ingestion path, for sources that reportstatus: 'cancelled'directly) record it through the same atomicmarkCancelled, called afterupsert()rather than folded into it —upsert()never touchescancelledAt, so two ingestion paths (webhook + reconciliation poll) racing for the same order can't stomp each other's write. Verified empirically against a live Postgres that TypeORM'ssave()omits anundefined-left column from the generatedUPDATE.OrderRecordFilters.cancelled+ afindManypredicate make the fact queryable without parsingorderSnapshot; not wired into any HTTP filter or FE control yet — that's the aggregate endpoints' job ([IMPL] Backend — sales & channel aggregates endpoint (revenue, orders, AOV + median, units) #1987/[IMPL] Backend — top-products endpoint with inline per-channel split #1988).cancelledAtsurfaced onOrderRecordResponseDto.cancelledAt := updatedAt) for historical rows whose snapshot already reports'cancelled'.Context
docs/plans/implementation-plan-order-cancellation-record-state.mddocs/plans/analysis/ANALYSIS-order-cancellation-record-state.md/analyticsepic ([PRODUCT-DESIGN] Business analytics surface at /analytics — sales layer + cross-channel layer #1976) — this is dependency [C], blocking the order analytics read model ([IMPL] Backend — order analytics read model (queryable line items, placed-at, currency, tax treatment) #1985) from excluding cancelled orders.Test plan
pnpm --filter @openlinker/core type-check/pnpm --filter @openlinker/api type-check— clean.pnpm --filter @openlinker/core lint— clean (0 errors, pre-existing warnings only).pnpm check:invariants— all invariants pass, including migration-timestamp ordering vsorigin/main.apps/apifull lint run was interrupted locally mid-session — CI will confirm.pnpm test/pnpm test:integrationCloses #1984
🤖 Generated with Claude Code