fix(woocommerce): map placedAt from date_created_gmt on order source - #2114
Conversation
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>
piotrswierzy
left a comment
There was a problem hiding this comment.
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 placedAt → saleDate 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:
adapter → OrderIngestionService:638 → Order.placedAt → order-to-issue-invoice-command.mapper.ts:112-113 (placedAt !== undefined ⇒ saleDate = 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
mainyet. 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.
placedAtis read only by the invoicing mapper, the order snapshot/record,order-from-ready-snapshot.ts, and FE display; all additive. Ship-by/dispatch SLA isdispatchTime-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
left a comment
There was a problem hiding this comment.
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
:185computesnormGmt(...)twice (once in the spread, once forcreatedAt). Pure function, so hoisting to a local is behaviour-neutral.- The truthiness guard treats a whitespace or malformed non-empty string as present, and
normGmtwill emit"garbage" + "Z". Identical to existingcreatedAtbehaviour, 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 explicitplacedAt: undefinedkey 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
createdAtmay keep the sentinel whileplacedAtmay 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
createdAtsentinel 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.
…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>
Summary
WoocommerceOrderSourceAdapter.getOrdernow populatesIncomingOrder.placedAtfromdate_created_gmt(falling back todate_created), identical in value tocreatedAt— mirroring the PrestaShop adapter's existingdate_addmapping (feat(orders): capture buyer-placed timestamp from source #926).saleDatefor WooCommerce orders (order-to-issue-invoice-command.mapper.tsgatessaleDateonplacedAt), and order-time FX rate stamping (Order-time FX rate snapshot + reporting-currency stamping #2049) cannot resolve a rate date without it.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 — nosaleDatewas 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
#2049implementation plan. Neither file exists onmainyet — 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
placedAtmatchescreatedAtfromdate_created_gmt, and falls back todate_createdwhendate_created_gmtis absent.pnpm --filter @openlinker/integrations-woocommerce type-check— clean.pnpm --filter @openlinker/integrations-woocommerce lint— clean (pre-existing unrelated warning only).pnpm test/ CI (not run locally per policy).Closes #2097
🤖 Generated with Claude Code