Skip to content

feat(orders): capture order cancellation as first-class record state - #2022

Merged
piotrswierzy merged 2 commits into
mainfrom
1984-order-cancellation-plan
Aug 13, 2026
Merged

feat(orders): capture order cancellation as first-class record state#2022
piotrswierzy merged 2 commits into
mainfrom
1984-order-cancellation-plan

Conversation

@jakubretajczykBD

@jakubretajczykBD jakubretajczykBD commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • handleSourceCancellation previously relayed a source cancellation to destinations and returned — nothing was written to the order record, so cancellation was invisible to SQL and only present in orderSnapshot.status if a later poll happened to re-ingest the order.
  • Adds a nullable cancelledAt column on order_records, written once and preserved via an atomic COALESCE update (markCancelled) — a redelivered cancel event or a later re-poll can never overwrite an already-recorded instant.
  • handleSourceCancellation now 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 (the ordinary polling/ingestion path, for sources that report status: 'cancelled' directly) record it 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. Verified empirically against a live Postgres that TypeORM's save() omits an undefined-left column from the generated UPDATE.
  • OrderRecordFilters.cancelled + a findMany predicate make the fact queryable without parsing orderSnapshot; 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).
  • cancelledAt surfaced on OrderRecordResponseDto.
  • Additive migration with a documented best-effort backfill (cancelledAt := updatedAt) for historical rows whose snapshot already reports 'cancelled'.

Context

Test plan

  • Unit tests added across all touched layers (domain entity, pure helper removed in favor of the atomic-write redesign, repository, application service, ingestion service, controller) — all passing locally.
  • 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 vs origin/main.
  • Migration applied + reverted + re-applied against the local dev Postgres; backfill confirmed against real pre-existing data.
  • apps/api full lint run was interrupted locally mid-session — CI will confirm.
  • Full pnpm test / pnpm test:integration

Closes #1984

🤖 Generated with Claude Code

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>
@jakubretajczykBD
jakubretajczykBD marked this pull request as ready for review August 12, 2026 12:55
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 piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 1832000000008 is strictly greater than origin/main's current tail (1832000000007), per the synthetic-sequential rule in docs/migrations.md § Timestamp uniqueness invariant rule 3 — verified against git ls-tree origin/main, not assumed.
  • Class suffix AddOrderRecordCancelledAt1832000000008 matches the filename prefix.
  • up() and down() both present, both fully IF [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 uses updatedAt as 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 cancelledAt supports the IS [NOT] NULL predicate 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/…009 prefix. Whichever lands second needs a re-prefix — pnpm lint will catch it via check-migration-timestamps.mjs, but worth coordinating so it isn't discovered at the pre-commit hook.
  • cancelledAt is 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 markCancelled failure 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.cancelled ships 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.

@piotrswierzy
piotrswierzy merged commit 5e1443c into main Aug 13, 2026
9 checks passed
@piotrswierzy
piotrswierzy deleted the 1984-order-cancellation-plan branch August 13, 2026 09:02
jakubretajczykBD added a commit that referenced this pull request Aug 13, 2026
)

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>
jakubretajczykBD added a commit that referenced this pull request Aug 13, 2026
…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>
jakubretajczykBD added a commit that referenced this pull request Aug 13, 2026
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>
piotrswierzy pushed a commit that referenced this pull request Aug 13, 2026
* 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>
piotrswierzy pushed a commit that referenced this pull request Aug 14, 2026
#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[IMPL] Backend — capture order cancellation as first-class record state

2 participants