Skip to content

fix(woocommerce): map placedAt from date_created_gmt on order source - #2114

Merged
piotrswierzy merged 5 commits into
mainfrom
2097-woocommerce-placed-at
Aug 18, 2026
Merged

fix(woocommerce): map placedAt from date_created_gmt on order source#2114
piotrswierzy merged 5 commits into
mainfrom
2097-woocommerce-placed-at

Conversation

@jakubretajczykBD

Copy link
Copy Markdown
Collaborator

Summary

VAT-period decision (per issue acceptance criteria)

Recommend no cutoff: a WooCommerce order invoiced after this change gets saleDate = placement date instead of the provider-substituted date, which is more correct under art. 19a/31a — no saleDate was ever written for WooCommerce orders before, so there's no already-committed value being changed, only a previously-missing fact becoming available. Flagging explicitly per the issue's request since this is a fiscal-policy call.

Doc-cleanup criterion (deferred)

The issue also asks to remove a WooCommerce caveat from ADR-040 and the #2049 implementation plan. Neither file exists on main yet — both live on the still-open, unmerged #2049/#2050 branch. Deferring that cleanup to whichever branch merges second/rebases; noted in the implementation plan (docs/plans/implementation-plan-woocommerce-placed-at.md).

Test plan

  • Added unit tests: placedAt matches createdAt from date_created_gmt, and falls back to date_created when date_created_gmt is absent.
  • pnpm --filter @openlinker/integrations-woocommerce type-check — clean.
  • pnpm --filter @openlinker/integrations-woocommerce lint — clean (pre-existing unrelated warning only).
  • Full pnpm test / CI (not run locally per policy).

Closes #2097

🤖 Generated with Claude Code

jakubretajczykBD and others added 3 commits August 14, 2026 13:29
WoocommerceOrderSourceAdapter never populated IncomingOrder.placedAt,
so invoicing had no saleDate for WooCommerce orders and order-time FX
rate stamping (#2049) can never resolve a rate date for them. Map
placedAt from the same date_created_gmt/date_created fields already
used for createdAt, mirroring the PrestaShop adapter's date_add
mapping (#926).

Closes #2097

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
@jakubretajczykBD
jakubretajczykBD marked this pull request as ready for review August 14, 2026 13:39

@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 — ❌ Request changes (one real defect; everything else is merge-quality)

The mapping itself is correct and matches the PrestaShop pattern, the two test cases are the right ones, and — the part I most expected to be skipped — the PR does answer the fiscal question rather than deferring it: no cutoff, with a reasoned art. 19a/31a justification. I traced the full placedAtsaleDate chain and the already-issued-documents argument.

One defect remains, and it's the kind that only bites in production.

BLOCKING

normGmt's epoch fallback can write 1970-01-01 as an invoice saleDate. woocommerce-order-source.adapter.ts:180

normGmt (woocommerce-utils.ts:87-91) returns new Date(0).toISOString() when both date_created_gmt and date_created are falsy — documented as an intentional "detectable sentinel that sorts before real timestamps". Harmless for createdAt/updatedAt. Not harmless once the same helper feeds placedAt, because the value flows:

adapterOrderIngestionService:638Order.placedAtorder-to-issue-invoice-command.mapper.ts:112-113 (placedAt !== undefinedsaleDate = toIsoDate(...)) → IssueInvoiceCommand.saleDate → KSeF fa3-xml.builder.ts:469 as P_6 on a submitted FA(3).

Pre-change behaviour was "no saleDate, provider substitutes". Post-change it is silently 1970-01-01 on a cleared fiscal document.

The PrestaShop pattern this PR was asked to follow guards exactly this: prestashop-order-source.adapter.ts:235-236 leaves placedAt undefined when date_add is absent, precisely so the #1525 gate can do its job. One line:

const placedAtRaw = order.date_created_gmt || order.date_created;
...(placedAtRaw ? { placedAt: normGmt(order.date_created_gmt, order.date_created) } : {}),

plus a spec asserting placedAt is undefined while createdAt keeps its sentinel when both fields are absent.

Reachability is low — woocommerce-order.types.ts:15-16 types both as required string — but that's an unvalidated wire shape, and the sibling webhook decoder (woocommerce-inbound-webhook-decoder.adapter.ts:151-152) already treats these fields defensively with asNonEmptyString. A fiscal field should fail to empty, not to 1970.

IMPORTANT

The "already-issued documents are safe" conclusion is right, but the stated mechanism isn't. IssuedLineSnapshot (invoicing.types.ts:329-336) carries only buyer / currency / lines — it does not cover saleDate. What actually protects an issued document is (a) it already exists at the provider, and (b) InvoiceRecord.saleDate is a persisted column (invoicing.types.ts:444) with nothing re-deriving it. The conclusion holds; I'd correct the record so a future reader doesn't over-trust the snapshot for fields it doesn't carry.

Residual the PR doesn't mention: IssueCorrectionCommand (invoicing.types.ts:606) carries no saleDate, so a correction of a pre-change WooCommerce invoice can't acquire one. Plausibly fine — worth stating explicitly given the no-cutoff decision.

GMT-vs-local fallback mislabels a local time as UTC. normGmt appends Z to date_created (site-local) on the fallback path. Cosmetic for createdAt; for saleDate, toIsoDate truncates to a calendar date, so a near-midnight order on a UTC+2 shop can land on the wrong day — and at a month boundary, the wrong VAT period. Pre-existing helper behaviour, but this PR is what makes it fiscal, and the PR's own second test asserts that exact path without flagging the semantic.

Suggestions

  • The deferred doc cleanup (ADR-040 caveat / #2049 plan) has a clear reason — neither file is on main yet. Leave a comment on #2049 so it isn't orphaned when that branch rebases.
  • A 213-line implementation plan for a five-line change is heavier than needed, but harmless.

Worth calling out

  • The VAT-period question is answered in the body rather than deferred. That was the acceptance criterion most likely to be quietly skipped, and answering it with a reasoned position (rather than a cutoff nobody would maintain) is the right call.
  • No other consumer silently changes behaviour — confirmed. placedAt is read only by the invoicing mapper, the order snapshot/record, order-from-ready-snapshot.ts, and FE display; all additive. Ship-by/dispatch SLA is dispatchTime-derived and stays blank by design. That was my main worry when I asked for this to be handled carefully, and it checks out.
  • Layer placement correct (infrastructure adapter only, no CORE change), no any, and the comment explains why rather than what.

CI: Lint, Type Check, Test, Build, Docker Smoke, PHP Unit green on c35b82bd; Integration Tests in progress at review time.

Merge readiness: ❌ Add the placedAt-undefined guard plus its spec, and correct the issuedLineSnapshot claim in the record. Everything else is merge-quality — this should turn around quickly.

normGmt returns the 1970-01-01 epoch sentinel when both date_created_gmt
and date_created are absent — harmless for createdAt/updatedAt, but
placedAt now feeds IssueInvoiceCommand.saleDate (KSeF FA(3) P_6 on a
submitted document). Leave placedAt undefined in that case instead,
mirroring the PrestaShop adapter's date_add guard.

Also corrects the plan's stated reason already-issued invoices are safe:
IssuedLineSnapshot doesn't carry saleDate; the real reasons are that the
document already exists at the provider and InvoiceRecord.saleDate is a
persisted column nothing re-derives. Notes the residual correction gap
and the pre-existing GMT-vs-local fallback caveat as follow-ups.

Addresses review feedback on #2114.

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 re-review (head db1b7438) — ✅ Approve

Both prior blockers resolved. Detail, since I want the verification on record rather than just the verdict:

Blocker 1 — epoch sentinel: resolved. woocommerce-order-source.adapter.ts:178-186 guards the field with a conditional spread, so placedAt is omitted entirely when both date_created_gmt and date_created are empty. No new Date(0) / 1970 in the production delta — the only occurrence is the spec's assertion that createdAt still carries the pre-existing sentinel, which is a documented scope boundary rather than a new one.

Worth noting: no type widening was needed and none was done. IncomingOrder.placedAt?: string and Order.placedAt?: Date were already optional; the WC adapter was the one place that couldn't express absence. That's why the fix is three lines and touches nothing in core or the FE.

Downstream consumers traced — all null-safe (confirmed, not assumed):

Consumer Behaviour when absent
order-record.service.ts:107,201 conditional spread — key absent from snapshot
order-ingestion.service.ts:638 incoming.placedAt ? new Date(...) : undefined
order-from-ready-snapshot.ts:65-71 explicit asOptionalDate + presence check, own specs
invoicing order-to-issue-invoice-command.mapper.ts:110-113 saleDate left unset rather than 1970 — the field the adapter comment names as the motivation
FE header / row-detail / detail-page / snapshot schema ternary or length guard → empty, never Invalid Date

And the sort failure mode is closed twice over: order-record.repository.ts:375-416 sorts on createdAt / dispatchByAt / SQL expressions and never on the snapshot's placedAt, so no order could have sorted to the top of the list even with the sentinel. No FX-rate or date-range aggregation reads the field either.

Blocker 2 — timezone: resolved. normGmt(gmt, local) is gmt || local, so passing date_created_gmt first is a genuine preference rather than field-order luck — and it's now pinned by a spec that feeds divergent values (10:00 vs 12:00) and asserts 10:00:00Z. The local-field fallback still appends Z to a store-local timestamp, a real offset skew, but that's pre-existing, shared with createdAt/updatedAt, documented in normGmt's own docblock, and untouched here.

SUGGESTION

  • :185 computes normGmt(...) twice (once in the spread, once for createdAt). Pure function, so hoisting to a local is behaviour-neutral.
  • The truthiness guard treats a whitespace or malformed non-empty string as present, and normGmt will emit "garbage" + "Z". Identical to existing createdAt behaviour, so not a regression — only relevant if WC is ever seen emitting placeholder date strings.
  • The PrestaShop sibling (prestashop-order-source.adapter.ts:235) expresses the same intent as an explicit placedAt: undefined key rather than a conditional spread. Both correct; converging on one idiom would help whoever next reads the two adapters side by side.

Positive observations

  • Minimal and surgical — three lines, no signature or type churn, plugin-local.
  • The comment block earns its length by naming why createdAt may keep the sentinel while placedAt may not (KSeF FA(3) P_6). That's exactly the distinction a future editor would otherwise flatten into "make them consistent".
  • The both-absent spec asserts the createdAt sentinel too, which turns the deliberate asymmetry into documentation instead of leaving it to look like an oversight.

CI: all 8 checks green on db1b7438.

Merge readiness: ✅ ready. Suggestions are optional; none needs a follow-up issue.

@piotrswierzy
piotrswierzy merged commit bd642a0 into main Aug 18, 2026
8 checks passed
@piotrswierzy
piotrswierzy deleted the 2097-woocommerce-placed-at branch August 18, 2026 07:19
jakubretajczykBD added a commit that referenced this pull request Aug 18, 2026
…2114)

* fix(woocommerce): map placedAt from date_created_gmt on order source

WoocommerceOrderSourceAdapter never populated IncomingOrder.placedAt,
so invoicing had no saleDate for WooCommerce orders and order-time FX
rate stamping (#2049) can never resolve a rate date for them. Map
placedAt from the same date_created_gmt/date_created fields already
used for createdAt, mirroring the PrestaShop adapter's date_add
mapping (#926).

Closes #2097

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(woocommerce): guard placedAt against normGmt's epoch sentinel

normGmt returns the 1970-01-01 epoch sentinel when both date_created_gmt
and date_created are absent — harmless for createdAt/updatedAt, but
placedAt now feeds IssueInvoiceCommand.saleDate (KSeF FA(3) P_6 on a
submitted document). Leave placedAt undefined in that case instead,
mirroring the PrestaShop adapter's date_add guard.

Also corrects the plan's stated reason already-issued invoices are safe:
IssuedLineSnapshot doesn't carry saleDate; the real reasons are that the
document already exists at the provider and InvoiceRecord.saleDate is a
persisted column nothing re-derives. Notes the residual correction gap
and the pre-existing GMT-vs-local fallback caveat as follow-ups.

Addresses review feedback on #2114.

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.

[BUG] Integration - WooCommerce order source does not populate placedAt (blocks FX stamping, leaves invoices without a saleDate)

2 participants