docs(orders,currency): order-time FX rate snapshot + reporting-currency stamping - #2050
Conversation
…cy stamping Adds ADR-040 and the implementation plan for stamping every order, at ingestion, with its total in a base currency plus an immutable reference to the rate used. ADR-039 (#1985) denormalizes the order's native currency and total, which leaves cross-currency totals un-summable. Converting at read time would move the reported figure whenever the rate moved, so the conversion is pinned at ingestion and never recomputed. The base currency is derived from the order's source connection via a three-step ladder (explicit config.currency, then the connection's dominant historical base, then the order's own currency) rather than from a global settlement setting, which was considered and rejected. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
194767a to
e7a61b8
Compare
Five review passes over ADR-040 and the #2049 implementation plan. The design changes, not just the prose. - Rename base -> reporting currency. In FX the base of a pair is the priced leg, i.e. what the registry stores as fromCurrency, so baseTotalAmount read as the native total. The analytics spec already said "reporting currency". - The resolution ladder gains a fourth rung and, critically, aggregates the order's NATIVE currency rather than the stamped one. Aggregating the stamp fed the ladder its own output: one atypical first order set a channel's reporting currency and then reinforced it forever, unable ever to flip. Adds a deterministic tie-break. - State rate direction as an invariant (rate = to-units per one from-unit; from is always the order's currency; a stamp is always total * rate, never a division) and pin it with an exact-value test. Nothing previously defined the column's direction at all. - Record derived-rate provenance: pivotCurrency + a derivation jsonb carrying each leg's document reference and effective date. A pivot whose legs disagree on effective date now raises. - Split rate unavailability into transient and terminal. An unsupported currency used to burn ~70 futile requests and then die; per ADR-007 it is a business failure. - Reuse @openlinker/shared/date's Polish working-day calendar instead of reinventing it, and correct the timezone edge case (23:30 UTC Sunday shifts the Warsaw day; 00:30 UTC does not). - Add the wiring the plan omitted: CurrencyModule into the orders and host module graphs, TypeOrmModule.forFeature, and the worker handler registration without which the job never dispatches. Move the handler to apps/worker/src/sync/handlers/, which is where handlers live. - Fix the raw stamp SQL to quoted camelCase; no namingStrategy is configured, so the snake_case form errored at runtime. - Move readFxConfig into the currency context so it stops creating a runtime identifier-mapping -> currency edge that the cross-context invariant default-allows. - Renumber the migration to 1834000000000 and require a manual run/revert/run, since no gate in the repo executes a migration's up(). - Correct four mis-cited precedents, add the missing toOrm regression spec, scope Allegro's currency field as the backend work it actually is, and record the honest bounds: no single grand total across differing reporting currencies, no operator-facing readout, and an analytics figure rather than a fiscal one. Refs #2049, #1976, #1985, #1987, #1988, #362 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Renumber to ADR-041 (039 belongs to #2014's analytics ADR, already referenced six times on main; 040 to #2050's FX ADR), and rework the decisions the review found factually wrong. - Cycle safety is structural, not a checklist: orders asks invoicing, not the router, so the only candidate cycle is three-node and is avoided by the caller passing the Order in (no forwardRef exists in the repo). - Split the one-document invariant into 3a (routing returns one pair) and 3b (the write path blocks pending / issued / failed-but-not-rejected on any connection), with corrections excluded as linked follow-ups so the ADR-026 corrective-re-issue allowance survives. - Correct decision 5's order facts (buyer type / tax id / payment method / source channel are not on the Order), make the tax id a blocking prerequisite of the engine, and require gross-or-unresolved plus a currency on thresholdRef. - Split router outcomes from gate preconditions, add capability + getSupportedDocumentTypes validation of the resolved target, and state that a missing tax id is not discovered in core at all today. - Key routing on its own neutral document kind so a receipt is never re-modelled as an InvoicingPort documentType (#1902 / #1908). - Add the exported-surface sketch, the aggregation representability limits, the mappings / config-helper placements to Alternatives, the ADR-026 refinement note, and Related PRs. - Replace the runtime diagram (self-routing now after resolution, unresolved routed through the gate, one predecessor for BLOCK). - Move the architecture-overview bullet after the sub-capability list so the context skeleton stays intact. Also replaces the ADR README's dead 500-word length rule with the norm the record actually holds itself to. Refs #2051 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
piotrswierzy
left a comment
There was a problem hiding this comment.
Tech-lead review — ❌ Request changes (bookkeeping, not substance)
Docs-only: three files, ~1,500 additions, zero production code — the new ADR (Proposed), the ADR README index row, and the implementation plan. No migration, no libs//apps/ changes, so I applied the backend checklist to the design the plan commits to rather than to code.
The FX design itself is unusually good — see the verification list at the bottom. The blocker is numbering.
BLOCKING
1. ADR-number collision on 040. I verified this independently of the review: main's highest ADR is 038, and two open PRs both claim 040:
- this PR →
040-order-time-fx-stamping-connection-derived-reporting-currency.md - #2056 →
040-fiscalisation-capability.md
Both also add an ADR-040 row at the same README table position, so whoever merges second silently overwrites the other's row and ships a duplicate number into an append-only record. This is the docs equivalent of the migration-timestamp collision that already bit #2022/#2037 this week — each PR passes in isolation, only the second is wrong, and nothing automated catches it.
The allocation is inconsistent across the whole cluster, not just here: #2056's README note says "039 is reserved … authored in #2051 / PR #2055 … Allocate 041 for the next new ADR", while #2055 actually ships 041-sales-document-routing-policy.md; meanwhile this PR's body attributes 039 to #1985's analytics read model. So 039 has two claimants and 040 has two claimants.
Please settle the allocation for #1985 / #2050 / #2055 / #2056 in one place — a reservation table in docs/architecture/adrs/README.md is the natural home, and #2056 already drafted one — then renumber. Mechanical, but it has to happen before merge: the README's own ADRs are append-only rule makes a post-hoc renumber the expensive option.
IMPORTANT
2. The PR body describes a different design from the merged artifacts. The body names …connection-derived-**base**-currency.md, a three-rung ladder starting at Connection.config.currency, baseCurrency columns, four nullable columns, and migration 1833000000000. The ADR ships a four-rung ladder (explicit config.fx.reportingCurrency first) with the reporting-currency rename (and a good rationale for it), and the plan pins migration 1834000000000 with five columns. The body is the first thing a reviewer reads and currently teaches the superseded design.
3. Rung 3 makes the stamped reporting currency depend on when stamping happened. The per-order figure is immutable, but the plan's own test matrix asserts "retry stamps a different reporting currency than the inline path would have" as expected behaviour. So two orders placed in the same minute on the same connection can carry different reporting currencies purely because one degraded to the retry path. Defensible — but it's a different trade-off from the one the ADR's Cons section describes, which frames drift as slow historical accumulation, not retry-timing nondeterminism. Either freeze the ladder result per connection once first resolved, or state the retry divergence explicitly. This is exactly the kind of thing a future analyst files as a data bug.
4. Write-once and append-only are conventions with no enforcement. The ADR is admirably plain about it ("append-only by convention — nothing enforces it at the database level"), and the ShipmentRepository.claimWaybillRelay precedent is the right in-repo shape, so not a blocker. But for a table whose entire purpose is that financial history cannot be rewritten, consider a DB-level guard, or at minimum an int-spec asserting no update path exists.
5. The consuming issues may contradict this model. The ADR notes #1987/#1988 currently specify "multiple currencies never sum into a single figure" and defers reconciliation to them. Worth confirming with those owners before this lands as Proposed, or the analytics epic ships two incompatible contracts.
SUGGESTION
- Length: ~1,866 words against the README's current "under 500 words" bar. #2055 proposes raising it to 1,500 — this still exceeds even that. Not worth cutting the load-bearing parts, but note the cross-PR dependency.
*.provider.tsisn't in theengineering-standards.mdsuffix table; sinceExchangeRateProviderPortexists and these implement it,*.adapter.tsis the documented name.- The plan leaves one decision open — whether
persistOrder's returnedOrderRecordmirrors the stamped FX fields or reportsnullafter a successful stamp. Pick it now; a stale returned record is precisely the class of silent-wrong-number this ADR exists to prevent. - Two adjacent findings worth their own issues:
fulfillmentState(#1108) is reset tonullby a re-poll on the upsert path, andprestashop-order.mapper.ts:247hardcodesconst conversionRate = 1.0;outbound. Correctly out of scope here.
Verified — the FX-specific failure modes are all properly closed
- Provenance/immutability: stamped at ingestion, never recomputed, written by a narrow conditional UPDATE deliberately outside
persistOrder's full-rowsave(). The JSONB-blob alternative was correctly dropped because re-ingestion would rewrite the snapshot. - Quote direction is an explicit invariant, not a convention:
rate=tounits per onefromunit,fromis always the order's currency, so the stamp is alwaystotal × rate, never a division — pinned by a unit test asserting an exact numeric result, with a worked cross-rate including a sanity check for the flipped-divide tell. This is the classic silent-inversion bug and it's genuinely closed. - As-of instant is unambiguous:
placedAt, prev-business-day, resolved inEurope/Warsaw, calendar owning weekends/holidays with the 404 walk-back only as defence — and the ADR states plainly that this is an analytics figure, not a fiscal one, because PL VAT anchors on the tax point rather than placement. That distinction is the thing most likely to be conflated later. - Precision/rounding: rate at 8 dp as a string;
reportingTotalAmountmatching #1985'snumeric(12,2)via the houseround2idiom; multiply-the-total chosen explicitly over converting lines. - Missing-rate is never a silent default: terminal (no stamp, no retry) for missing
placedAt/ unsupported pair / non-404 4xx / divergent pivot legs; transient (unstamped + retry) for 5xx/timeout; plus a reconcile sweep backstopping a dead retry job holding its idempotency key. - Same-currency: stamps
reportingTotalAmount = totals.totalwithexchangeRateId: nulland no I/O, and namesreportingCurrency— notexchangeRateId— as the unstamped discriminator. Right call for downstream aggregation.
Merge readiness: ❌ Blocked only on the ADR-040 collision (and the 039 ambiguity). Renumber, refresh the stale body, and settle item 3 in the ADR text — after that this is an approve. The design work is solid and the plan is implementation-ready.
piotrswierzy
left a comment
There was a problem hiding this comment.
Product decisions following a design discussion — ADR needs revision before implementation
The FX mechanics in this ADR are good and I don't want them changed: the direction invariant, the shared rate registry, the narrow conditional UPDATE, terminal-vs-transient failure handling, the same-currency short-circuit, and the analytics-not-fiscal framing all stand.
What changes is the reporting-currency model. Five decisions below, with reasoning, since otherwise they'll get re-litigated at implementation time.
1. The reporting currency is system-level, not derived per connection
Drop rung 1 (Connection.config.fx.reportingCurrency) and rung 3 (dominant native currency from history). Replace the ladder with a single system-level setting.
Reasoning: reporting currency is a property of the reporting entity — the business — not of a sales channel. A connection is a place orders arrive from; it has no view on what currency we keep books in. Three distinct things were being conflated:
| Level | What it is | Kind |
|---|---|---|
| Order | the currency the buyer paid in | fact |
| Connection | the currency that shop prices products in | fact |
| Business | the currency we report in | choice |
The ladder derives a choice from facts. That's the category error. Concretely it caused two problems the ADR already documents as costs: per-connection values produce an estate that by construction cannot be summed (the exact problem this feature exists to fix), and rung 3's history-derived value drifts silently as a connection's order mix changes — the "two reporting-currency eras" consequence. Both disappear when there's one authoritative answer.
Connection.config.currency (#362) stays and keeps its current meaning and readers — it's a genuine fact about a shop. It just stops being an input to "what do we report in."
I understand the original rejection was of a mandatory global setting, and that concern was right — "a configuration step that silently breaks analytics when skipped" is a real failure mode. Optional-with-a-default answers it: skipping it is the normal path.
2. Default is EUR
Resolution chain, mirroring the ai_provider_active_setting precedent exactly:
settings row → OL_REPORTING_CURRENCY → 'EUR'
3. Consequence: ECB becomes the shipped provider, not NBP
This follows from (2) and isn't optional. NBP table A quotes everything against PLN as the target (1 USD = 4.05 PLN), which is uniquely convenient for PLN reporting and structurally wrong for EUR:
| Order currency | → EUR via NBP | Legs |
|---|---|---|
| PLN | invert EUR→PLN |
1 inversion |
| USD | rate(USD→PLN) ÷ rate(EUR→PLN) |
2-leg pivot |
For a PL operator the dominant case is PLN orders, so with NBP every headline figure becomes a derived cross. ECB reference rates collapse every pair to a single documented inversion instead.
The ADR anticipates this as future work — "the provider interface takes a pivotCurrency so a second source (e.g. ECB) is additive" — it just assumed additive-later. With an EUR default it's required in this slice.
To be clear: inversion isn't a correctness problem, it's exact and the ADR already records how a derived rate was obtained. This is about leg count and citability.
4. Providers are adapters in libs/integrations/fx/, not implementations inside core
Decision 5 currently places NbpExchangeRateProvider in libs/core/src/currency/infrastructure/providers/ and concedes it makes NBP "the first outbound HTTP call in libs/core", bounded by this rule:
a rate provider that needs a credential or an SDK ships as
libs/integrations/fx-*
That criterion is the problem. It puts two implementations of one port in different packages based on whether the vendor happens to require an API key — an incidental property of someone else's auth policy. It isn't enforceable by check:invariants, and the day NBP or ECB adds a key the adapter has to move packages.
The ADR cites AiCompletionPort + @openlinker/integrations-ai as its precedent, but that split is port in core, every implementation in integrations. This does port in core, some implementations in core. It invokes the precedent to justify departing from it.
The kernel of the argument is right — "a published reference rate is a shared read, not a per-connection integration capability" — an FX rate has no connectionId and must not be a capability plugin resolved via getCapabilityAdapter. But that argues against a capability plugin, not for core. integrations-ai is exactly the third option: an integration package that is not a per-connection capability, implementing a core port, with a router dispatching to per-vendor adapters.
So:
ExchangeRateProviderPort, the registry, and the rate-date rules stay inlibs/core/src/currency/. No HTTP in core.libs/integrations/fx/shipsNbpExchangeRateProvider+EcbExchangeRateProvider.- Drop the credential-based split rule entirely — the boundary becomes the ordinary one, with no exception to memorise.
Resolution is by purpose, not a single active provider. This is a genuine difference from the AI precedent, where the setting picks one winner. Here NBP and ECB must be live simultaneously — see (5). The exchange_rates table is already keyed (source, from, to, rateDate), so multiple concurrent sources are designed into the data model; only the code layering assumed one.
Killing the in-core HTTP precedent matters independently: once it exists, the next person with a "simple unauthenticated GET" has a citable reason to add one.
5. Invoicing must compute its own rate — reverse the current instruction
This line no longer holds and will cause a wrong number on a fiscal document if left as-is:
whoever implements it must consume this stamp rather than compute a second, divergent rate for the same order
A Polish invoice's FA(3) KursWaluty needs an NBP rate to PLN on the statutory date. The analytics stamp will be an ECB rate to EUR on the placement-derived date. Different source, target and date rule — invoicing cannot consume it.
Please reverse the sentence: invoicing computes its own NBP/PLN rate, and the two figures legitimately coexist and differ. State it as deliberate, or the first implementer will follow the current text.
Guards to specify while revising
- Label every analytics figure with its currency at the point of consumption ("Revenue — reported in EUR"). With a converting default, this is what makes a wrong setting announce itself on the page instead of being found by an accountant months later.
- Restatement moves into scope, or the setting locks after first stamp. The ADR currently defers backfill entirely, which was fine when the default converted nothing. With a converting default, an operator who changes their mind in month three has three months of EUR stamps and no remedy. Everything needed is retained (native currency,
placedAt, the registry) so it's computable — but somebody has to write it. What to avoid is a changeable setting with no restatement path. - Validate the setting against provider coverage at save time rather than one order at a time.
- Show it as
EUR (default)until explicitly set, and putOL_REPORTING_CURRENCYin.env.exampleso a self-hoster discovers it before their first order.
Still standing from my earlier review
- ADR-040 is claimed twice (this and #2056) — renumber per the coordination table.
- The PR body and issue #2049 both describe the superseded design (
baseCurrency, three rungs, migration1833000000000). Since #2049 carries the acceptance criteria someone implements against, it needs the same refresh — and now a larger one.
Happy to talk any of these through before you revise.
Correction to decision 3 — NBP ships tooMy heading above ("ECB becomes the shipped provider, not NBP") was badly worded and could be read as dropping NBP. It isn't. The body of that section already listed Both providers ship in this slice. Three independent reasons, any one of which is sufficient:
This makes the demo the first real consumer of the env rung: the demo sets One thing to catch in the demo seedIf the demo reports in PLN and its orders are all PLN, the equal-currency short-circuit fires on every order and no conversion ever happens — the feature would be invisible in the demo despite being fully implemented. The demo seed should include a handful of foreign-currency orders (EUR and one non-EUR, so the pivot path shows too) so that Worth noting the two provider paths differ in shape, so exercising both is genuinely worth something:
|
Reply: taking all five decisions, three need your call before I rewriteAccepted as-is, not re-litigating: system-level reporting currency and the fact/choice framing, Three of the decisions do not survive contact with the code. Each one is a question for you, marked below - the rest of this comment is detail you can skip. ❓ Q1 - The system-level setting does not fix your review item 3. Add a first-attempt snapshot?Your item 3 (retry stamps a different currency than inline would have) and decision 1 are being read as one fix. Decision 1 removes the history-driven drift. It leaves the retry-timing one untouched, because the setting is still read at stamp time - and it widens the blast radius:
It also does not need rung 3: rung 4 ("no history, no conversion") is time-dependent too, so inline-vs-retry can flip an order between converted and not converted at all. Proposal: resolve once at the first stamp attempt and persist it ( The ask: confirm the snapshot is a precondition of decision 1, not an alternative to it. Without it, decision 1 hands an admin edit a deployment-wide reach the ladder never had. ❓ Q2 - Decision 5 is right, but the plan ships NBP-only, so the trap is worse. Fine to state the divergence on three axes, not one?You reasoned that invoicing cannot consume the stamp because it will be ECB/EUR while Three independent divergences, any one fatal: date (placement vs tax point - and under art. 19a ust. 8 that is the payment instant, which no shipped source persists, so placement is structurally never the tax point for a prepaid marketplace order), target (always PLN vs the reporting currency - dimensionally unusable on an EUR deployment), derivation (a statutory rate must be a directly published table-A quote). FA(3) draws the same line itself: The ask: OK to write the reversal as per-provider rather than flat? KSeF is the only path where OL builds the document and would own the rate; inFakt and Subiekt compute their own conversion server-side and must not be handed one. ❓ Q3 - With a converting default, does restatement ship here, or do we take the acknowledgement rail?Lock-after-first-stamp should be rejected, and the reason comes from your own decision 2: the default converts and nobody chose it. Lock makes an unchosen default permanent - a deployment that never opens the settings page takes one foreign-currency order and is locked into a currency it was never asked about. With NBP as the source it stings twice: there is no statutory PLN to EUR rate, so a locked-in EUR default converts a PL estate's PLN orders through an inverted quote that appears in no published table. Restatement is ~+0.5 to 1 day, because it is a third caller of the existing stamp primitive, not a new subsystem: native currency and The ask: pick (a) restatement in this PR, or (b) changeable + a hard rail - the Detail: 11 smaller decisions I am taking unless you object
Detail: consumers, labelling and the demo seed
Filing separately (out of scope here): Fixing on this PR: #2049's body still uses Answer Q1-Q3 and I will rewrite the ADR and plan in one pass. |
… reservation note Three open PRs were authored off the same base commit and 040 was claimed twice: this PR and #2050 (order-time FX stamping) both added an `040-*.md`. Because the filenames differ, git merges both cleanly with no conflict marker and `pnpm check:invariants` does not catch it, so the collision only surfaces as two ADR-040s on `main`. Verified independently against `origin/main` and the open PRs: the highest ADR on `main` is 038, and 039 is the order analytics read model (#2014, already referenced by filename from `docs/plans/implementation-plan-order-cancellation-record-state.md`), 040 is FX (#2050), 041 is sales-document routing (#2055). This PR therefore takes 042 - filename, title line, README index row, both `docs/architecture-overview.md` pointers, the ADR-026 annex cross-link and the spec's `**ADR:**` line. The README reservation note was wrong on both facts it asserted: it named 039 as the routing ADR (it is analytics) and told the next author to allocate 041 (which #2055 already claims), steering them straight into a second collision. It now lists all three reserved numbers with their claiming PRs, points at 043 as next-free, and names the failure mode so the next author checks the open-PR list rather than the file listing. Refs #2009 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Resolved - all five decisions taken, with three corrections. Revising the ADR now.Q1-Q3 from my previous comment are settled. Summary of what the ADR is being rewritten to, so the diff is not a surprise. Your decisions, taken
Three corrections that came out of the discussion(a) The provider is a function of the reporting currency, not a separate config key. Consequence worth stating: because NBP quotes every table-A currency against PLN and ECB quotes EUR against everything, every pair is either direct or a single inversion while the reporting currency is PLN or EUR. The pivot machinery stays in the code (open for extension), but save-time validation accepts only (b) The resolution is snapshotted at the first stamp attempt. Not a new requirement, just making the plan match the intent: the retry job and the reconcile sweep must stamp as it was at the time of the cron, so a config change mid-retry cannot move an order's reporting currency. One nullable column plus one guarded claim write in the existing (c) An order of EUR 1,000 placed Fri 7 Aug, goods delivered Mon 10 Aug. Same shop, same currency, same provider, same order.
Two legitimate numbers from one published table, differing only by date. Under art. 19a ust. 8 the tax point is the payment instant when the buyer paid first, which is the OL default for a marketplace order and a timestamp no shipped source persists. So placement is not merely a different date, it is structurally never the tax point for the most common order shape. Worth adding because it settles the argument from the schema side: FA(3) draws this line itself. The reversed sentence will also say that rate ownership is per-provider: KSeF is the only path where OL builds the document and would own the rate; inFakt and Subiekt compute their own conversion server-side and must not be handed one. On restatement: not shipping itYour review asked for a restatement path or a lock. Taking neither: the ADR will state plainly that changing the setting does not restate history and that a deployment can therefore carry two reporting-currency eras, the The WooCommerce caveat in the ADR is wrong, and it is a one-line fixThe ADR and the plan both record that a foreign-currency WooCommerce order can never be stamped because WC emits no order-placed timestamp. The data is there and simply is not mapped: So the caveat comes out of the ADR and a one-line Smaller decisions being taken in the revisionNine items, expand if you want to object to any
ConsumersThe conflict is three issues wide, not two: #1985 contradicts as well ("a query spanning multiple currencies cannot return a single summed figure", plus an Out-of-scope line excluding a single reporting currency). The disagreement is narrow but live - everyone groups by a currency, but #1985/#1987/#1988 group by the native column and this ADR by the stamped one, so their ACs as written forbid the behaviour the feature enables. An implementer following #1987 literally ships Proposal: amend #1987 and #1988 only (they are the query issues and the stamp's consumers), add For the labelling guard: currency goes per figure in the response ( Filing separately
Also fixing on this PR: #2049's body still uses ADR revision incoming; the plan follows in the same pass. |
Rewrites ADR-040 following the design discussion on PR #2050. The FX mechanics are unchanged; the reporting-currency model is not. - The reporting currency becomes ONE system-level setting resolved `settings row -> OL_REPORTING_CURRENCY -> 'EUR'`, replacing the connection-derived four-rung ladder. A connection reports facts; the currency we report in is a business choice, and per-connection values made a deployment-wide total impossible by construction. - The rate source becomes a function of the reporting currency (PLN -> NBP, EUR -> ECB), so `Connection.config.fx` disappears and no cross-rate pivot arises for either supported value. The setting is validated at save time and accepts PLN/EUR for now; the pivot path stays implemented so a third currency is additive. - Providers move out of core entirely: the port, registry and rate-date rules stay in `libs/core/src/currency/` with no outbound HTTP, and both adapters ship in `@openlinker/integrations-fx`. Drops the credential-based split rule, which keyed package placement on someone else's auth policy. - The resolution is snapshotted at the first stamp attempt, so the retry job and the reconcile sweep stamp as of ingestion rather than re-resolving. Without it, provider availability was a silent input to a financial figure and the sweep could reclassify arbitrary history. - Reverses the instruction that invoicing consume this stamp. The two figures differ on date, target and derivation; on a PLN-reporting deployment both come from NBP table A and differ only by date, so the rows look interchangeable and are not. Adds the FA(3) `KursWaluty` vs `KursUmowny` scoping as the schema-side argument. - Records that changing the setting does not restate history (with the stamped-row count reported on the PUT), and rejects lock-after-first-stamp: the default converts and nobody chose it. - Drops the WooCommerce caveat. `date_created_gmt` is the same fact PrestaShop's `date_add -> placedAt` mapping carries, so it is a one-line adapter mapping rather than an unstampable source. - Documents why no DB-level append-only guard ships: no migration in this repo creates a trigger, and the integration harness builds its schema with `synchronize` without ever running a migration, so the guard would first fire in production. Renames the file to match the decision and updates the README index row and the plan's links. The implementation plan itself still describes the superseded design and follows separately. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Second trim pass on the revised ADR: 2931 -> 2443 words with no decision, alternative or trade-off dropped. Shortens justifications, folds the persistOrder re-read detail into one clause, and removes the "converting at read time" alternative as a duplicate of the Context paragraph that already rejects it. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
piotrswierzy
left a comment
There was a problem hiding this comment.
Tech-lead re-review — ❌ Request changes (reasoning settled; docs haven't caught up, plus the renumber)
Your resolution comment lands all three questions and I agree with every one — including where you overturned me. Blocking items are the ADR number and the fact that the revision itself isn't written yet; on the substance we're aligned, so the rewrite should be uncontroversial.
Q1–Q3, settled
Q1 — snapshot at first stamp attempt: yes, precondition, not alternative. You're right that decision 1 doesn't close it. I conflated "removes history-driven drift" with "removes timing drift"; the setting is still read at stamp time, and your point that rung 4 can flip an order between converted and not converted at all is the part I missed entirely. The ~4.3h retry window plus an unbounded reconcile lag makes an admin edit reclassify an arbitrary slice of history — and under a deployment-wide setting that's strictly worse than the per-connection version, which is a real cost of decision 1 that I should have priced. fxIntendedCurrency in the claimWaybillRelay shape is right, and turning the residual into an explainable time boundary is the correct goal. Both mechanical follow-ons you name (group CHECK drops its fxRule clause; the test-matrix row inverts to assert the ladder is never re-consulted) look correct.
Q2 — per-provider, yes. And your version of the argument is better than mine. I reasoned from ECB/EUR vs NBP/PLN; you're right that the branch ships NBP-only, so both figures would come from the same published table — which removes the one cue that would make a future implementer suspicious, making the trap worse rather than better. The date-only divergence on a PLN-reporting install (placement vs art. 19a tax point, EUR 1,000 on 7 Aug giving 4.2810 against 4.2935 from one table) is the sharp version, and the art. 19a ust. 8 observation that the tax point is the payment instant — which no shipped source persists — means placement is structurally never the tax point for a prepaid marketplace order. That's a stronger claim than "different provider" and it survives any provider choice.
The FA(3) schema citation settles it from the other side: KursWaluty scoped to dział VI vs KursUmowny/WalutaUmowna scoped "nie dotyczy przypadków, o których mowa w dziale VI". Please keep that in the ADR — it's the detail that stops this being re-argued.
Q3 — third option accepted, and my lock suggestion was wrong. Your objection kills it: the default converts and nobody chose it, so a lock makes an unchosen default permanent, and on a PL estate an unchosen EUR lock converts PLN orders through an inverted quote that appears in no published table. Changeable + PUT reporting the stamped-row count + the two-era consequence stated plainly is the right shape.
One condition on that: the restatement issue must be filed and linked from § Migration path before this merges, not after. A filed-and-linked issue is what separates a documented trade-off from a silent one, and this is precisely the kind of follow-up that evaporates once the feature ships and looks finished.
Corrections you took that I want to endorse explicitly
Provider as a function of reporting currency is simpler than the "resolution by purpose" I proposed, and it's correct once invoicing owns its own rate (Q2) — "by purpose" collapses to analytics resolves by currency, invoicing computes its own. Dropping Connection.config.fx, readFxConfig and the ConnectionPort read in the FX path is real deletion, not just relocation.
I checked the coverage claim and it holds: with NBP quoting X→PLN and ECB quoting EUR→X, PLN reporting gives direct lookups on every pair and EUR reporting gives exactly one inversion on every pair. No pivots either way.
State the consequence as a limitation, though: save-time validation accepting only PLN and EUR makes the reporting currency an effective two-value set at launch. That's defensible and follows from provider coverage — it just needs saying in the ADR rather than being discovered by the first operator who wants GBP.
Direction invariant as a property of the stamp, not the registry — agreed, and it's the right catch. A future to = 'PLN' invoicing row must be legal in a table whose key was designed to hold it.
One thing to check before folding it in
The WooCommerce placedAt mapping has a side effect the ADR itself flagged. You're right that the data is there and unmapped — woocommerce-order-source.adapter.ts:178 sets only createdAt, while PrestaShop maps date_add → placedAt with a comment saying it's when the customer placed the order, and WC's date_created is the same fact. Good catch, and it does fix the demo for free.
But the current ADR gives a reason for keeping it separate: "Populating that field is a one-line adapter change but it also moves invoicing's saleDate for every WooCommerce order, so it is tracked separately rather than folded in here." Folding it in now silently changes saleDate on every existing WooCommerce order — a fiscal-document field, on already-issued documents. Either address that consequence in this PR, or keep the split the ADR argued for. What I'd avoid is deleting the caveat without answering the objection it recorded.
Blocking
1. ADR number. Still 040. #2056 vacated it, but #2066 has since claimed 039, 040 and 041, so this now collides with #2066 rather than #2056 — next free is 043. The renumber has second-order reach: the filename, the in-file # ADR-043: heading, the README row, ~8 ADR-040 references in the plan (§5, §8, checklist), and the PR body. Worth one mechanical pass so no stale ADR-040 survives.
I've filed #2082 for a check-adr-numbers.mjs invariant so this stops costing review rounds — it's now bitten three times in two days.
2. The PR body still describes the superseded design and is wrong on five points: "three-step ladder", baseCurrency rungs, "Four nullable columns", "pinned to 1833000000000", and a link to 040-…-base-currency.md — a filename that no longer exists in the branch. It also still says the stamp uses "the rate that applied on the day the buyer paid", which the ADR now explicitly disowns.
3. The design revision isn't in the documents yet. Decisions 1–5 and all four guards are absent, and on 1, 4 and 5 the ADR currently argues the opposite position — Context still says a global setting was "explicitly rejected by the operator", Alternatives still rejects it, decision 5 still says NBP is "the first outbound HTTP call in libs/core" with the credential-split rule intact, and both documents still say invoicing "must consume this stamp". Merging now would enshrine a rejected design as a Proposed ADR. Your sequencing is fine — I'm noting it so the state is on the record.
Smaller items
- The append-only reasoning (harness builds schema via TypeORM
synchronizeand never runs migrations, so a migration-only trigger would first fire in production;REVOKE UPDATEis a no-op under a superuser connection) belongs in the ADR, not only in a PR comment. It's exactly the reasoning someone will otherwise re-propose. - ADR § References forward-references a
docs/architecture-overview.md§ Currency section this PR doesn't add. Fine for the implementation PR to add — the ADR just shouldn't assert it exists. - Your amend-#1987-and-#1988-only proposal (leaving #1985's AC with an Out-of-scope clarification) is the right split, and the observation that an implementer following #1987 literally ships
GROUP BY currencyand never reads the FX columns is the concrete reason it matters. MoneyFigureDto { amount, currency, isReportingCurrency }per figure is better than the response header I suggested — the by-channel table can legitimately hold two reporting currencies, andisReportingCurrencycarrying the converted-vs-shown-natively distinction is the part a header couldn't express.- All nine smaller decisions in your details block look right to me; no objections.
Merge readiness: ❌ Blocked on the renumber to 043, the PR body refresh, and the rewrite itself. The reasoning is settled and I don't expect to re-open any of it — this is now mechanical. Please confirm the WooCommerce saleDate question either way before folding that mapping in.
* docs(adr): add ADR-039 sales-document routing policy Records where the "which sales document does this order get" decision lives, ahead of the second document type (#1908) that turns it into a real branch. Nothing decides it today: ADR-026 left the policy above the port without saying where, and AutoIssueTriggerService fans out to every Invoicing-capable connection unconditionally (#2047). The ADR answers every bullet of #2051 explicitly, including the deferred ones with reasons: - routing lives in a dedicated `sales-documents` core concern, not in `orders` and not in either document context - the `orders <-> sales-documents` cycle-safety condition, written as a checkable list (allow/deny import shapes enforced by check-cross-context-imports.mjs, `import type` for entities, values only via a module-free `types` sub-barrel following `@openlinker/core/orders/types`, Symbol-token injection only) - "one document per order, never two" as a contract-level invariant, with #2047 named as the current breach - AutoIssueTriggerService gating conditions enumerated at the real gating point (the capability filter + per-connection fan-out), including the tax-rate conflict state from #2009 - manual mode first; rule engine, suggest/auto modes and the localised legal matrix marked deferred - the rule shape (`thresholdRef` + comparison operator, priority ladder) evaluated on the gross amount already on the order - periodic aggregation as a non-issuing outcome, and the self-routing destination bypass - one sentence that the VAT rate arrives from the ProductMaster and OL does not compute it, referencing #2009 / #2054 rather than restating The stale runtime diagram from the unmerged draft is not carried over; a new mermaid flowchart shows the three branches it was missing (rule conflict / priority ladder, aggregation window, self-routing path). Registers ADR-039 in the ADR index and links it from docs/architecture-overview.md where document selection is first described (section 14, Invoicing). Closes #2051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HpwFwSVZYF7nopZ5S3Peet Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(adr): address review on the sales-document routing ADR Renumber to ADR-041 (039 belongs to #2014's analytics ADR, already referenced six times on main; 040 to #2050's FX ADR), and rework the decisions the review found factually wrong. - Cycle safety is structural, not a checklist: orders asks invoicing, not the router, so the only candidate cycle is three-node and is avoided by the caller passing the Order in (no forwardRef exists in the repo). - Split the one-document invariant into 3a (routing returns one pair) and 3b (the write path blocks pending / issued / failed-but-not-rejected on any connection), with corrections excluded as linked follow-ups so the ADR-026 corrective-re-issue allowance survives. - Correct decision 5's order facts (buyer type / tax id / payment method / source channel are not on the Order), make the tax id a blocking prerequisite of the engine, and require gross-or-unresolved plus a currency on thresholdRef. - Split router outcomes from gate preconditions, add capability + getSupportedDocumentTypes validation of the resolved target, and state that a missing tax id is not discovered in core at all today. - Key routing on its own neutral document kind so a receipt is never re-modelled as an InvoicingPort documentType (#1902 / #1908). - Add the exported-surface sketch, the aggregation representability limits, the mappings / config-helper placements to Alternatives, the ADR-026 refinement note, and Related PRs. - Replace the runtime diagram (self-routing now after resolution, unresolved routed through the gate, one predecessor for BLOCK). - Move the architecture-overview bullet after the sub-capability list so the context skeleton stays intact. Also replaces the ADR README's dead 500-word length rule with the norm the record actually holds itself to. Refs #2051 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(adr): define SalesDocumentKind and persist every block reason (ADR-041) Addresses the tech-lead review on #2055. - Decision 10 now defines SalesDocumentKind as an open string set (CoreSalesDocumentKindValues + `| string`), so it does not silently re-adopt the closed `invoice | receipt` union ADR-026 rejected. Spells the well-known receipt value `fiscal-receipt` to keep it greppably distinct from DocumentTypeValues' `receipt`. - Decision 7 marks its tax-id precondition inert until a buyer tax-id reaches the order contract, and names #2057 a prerequisite of the tax-rate-conflict precondition (an unknown rate is indistinguishable from a resolved zero until it lands). - Decision 11 gives gate refusals their own persisted `as const` reason union alongside the routing one, so neither block path is log-only, and renames both to the shipped `*Values` + derived-union convention. - README: the ADR length-rule rewrite is reverted out of this PR (it is a process change for all future authors and belongs on its own), and the index gains a reserved-numbers note covering the 039/040/042 gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> --------- Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Brings the implementation plan in line with the revised ADR. Roughly 60% of the document changed; the verified line-number references and in-repo precedents that were unaffected are left untouched. Removed: - The four-rung reporting-currency ladder (step 10), `readFxConfig` / `fx-config.types.ts`, the `ConnectionPort` read in the FX path, the `findDominantNativeCurrency` aggregate and its `, c ASC` tie-break rationale, and the four-rung / rung-2-ISO test tables. - The three FE connection-setup currency fields and the Allegro OAuth thread (Phase 4 steps 14-15), plus the `*.provider.ts` standards-table row and its documented naming deviation. - The "first outbound HTTP call in libs/core" section and the credential-based core-vs-integration split rule. Added: - A system reporting-currency setting in the `currency` context: singleton row, `settings row -> OL_REPORTING_CURRENCY -> 'EUR'` chain, admin GET/PUT, and three-layer save-time validation where reachability blocks (422) and coverage against already-ingested currencies only warns. - `@openlinker/integrations-fx` as a new workspace package holding both provider adapters plus a deterministic fake, registered into a new core provider registry at boot. Includes the guard additions this needs (`check-outbound-http.mjs` + its ESLint twin are opt-in per package) and the manifest-dependency requirement that prevents the #2011 TS2306 race. - Source selection derived from the reporting currency (PLN -> NBP, EUR -> ECB), with the setting restricted to those two for now. Records that no pivot arises for either, so the pivot code ships unexercised by any supported configuration. - A sixth `order_records` column, `fxIntendedCurrency`, for the first-attempt intent snapshot, and the group-CHECK correction that makes an intent row legal (its first arm must not require `fxRule IS NULL`). - A state table for the five FX row states, because `fxStampedAt IS NULL` stops being equivalent to "unstamped" once a deferred row exists. - The WooCommerce `placedAt` mapping, which turns "every foreign-currency WC order is permanently unstampable" into a one-line fix, and notes the invoicing `saleDate` side-effect it carries. - `OL_REPORTING_CURRENCY` in three `.env.example` files (the worker one matters: it runs the retry and the sweep) plus a demo compose passthrough, and the `write-guard-coverage.spec.ts` entry the new write endpoint needs. - An ECB research item flagged as a Phase 1b blocker: the daily XML feed carries only the latest day, so the historical endpoint's shape must be established rather than guessed. Corrected: - The migration-number justification. `1833000000000` is no longer free (it landed on main), and main's tail is now `1833000000001`. - `persistOrder`'s return value: decided in favour of the conditional re-read, and both post-upsert writers collapse into one refresh so they cannot stale each other. - Four of the five open questions are resolved; the remaining one is the #1985/#1987/#1988 native-vs-stamped grouping, with the proposed AC edit and its owner named. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Addresses both blockers from the PR review. B1 - ADR numbering. All three numbers were already taken: - 041 is MERGED on main as 041-sales-document-routing-policy. - 039 is claimed by #2014 and already referenced by name six times from implementation-plan-order-cancellation-record-state.md on main, so merging would have silently repointed a live link. - 040 is claimed by #2050 and is already cited inside the merged 041. Worse, this branch had DELETED main's 041 row from the README index and deleted the "Reserved numbers" note that named 039, 040 and 042 as claimed - the exact warning against the collision it then caused. The README is restored from main and the three rows re-added as 043/044/045. Renumbered 039->043, 040->044, 041->045, with every inbound reference repointed across the record, the readiness gate, the grain decision, the spec and the cross-references between the three ADRs. The repoint is scoped to files this PR authored, so the other plan's references to the real ADR-039 are untouched (verified: 6 references, file unmodified). B2 - the Gate D record described the programme this PR cut. It said "BUILD, scoped as a full OMS module" and "Waves 0-1 are justified independently of the bet" while the preceding commit is titled "cut Waves 0 and 1". Adds a Gate D outcome section recording the narrowing and explicitly withdrawing the Waves 0-1 justification, the dry-run by-product claim, and the reliance on ADR-043 as settled. The 2026-08-13 reasoning is retained verbatim underneath, since its demand basis and primary-source corrections are still accurate. Also from the review: - I3: ADR-044 drops to Proposed. order_changes does not exist anywhere in libs/ or apps/, so nothing may call it existing. - I4: ADR-043's "reverted from Accepted" framing removed. It never merged Accepted, so the append-only rule was never engaged and the wording read as a precedent for editing accepted ADRs. - I5: ADR-045 renamed to 045-pack-policy-per-connection-config, since the old filename asserted the flow entity its decision defers. - I6: the readiness gate renamed to READINESS-GATE-1032-oms-module, no longer a near-duplicate of the record's own filename. - I7: section 6C now states precedence explicitly. packedAt (#2072) is what ships; the order_pack_events ledger is line-grain, blocked on #2080, and derives packedAt rather than competing with it. This was the finding that could have caused wrong work. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
Resolves the ADR README index conflict by keeping both rows: ADR-040 (this PR) and ADR-041 (sales-document routing, landed on main). The two are independent additions at the same table position, which is the collision the reserved-numbers note exists to prevent. Also corrects that note. ADR-040 stays with this PR: main's own note already reserved it here, `main` carries 035-038 + 041 with 039/040/042 open, and #2066 claims 043/044/045 — so renumbering to 043 would create the collision rather than avoid one. The note now drops 040 (this PR carries its row), records #2066's three, and cites #2082 for the numbering invariant. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
… as #2096 Two review conditions from #2050. **WooCommerce `placedAt` stays out of this PR.** The previous revision folded the one-line mapping in and deleted the ADR caveat that argued for the split; that deleted the argument without answering it. Restored, with the objection sharpened rather than waved away: `saleDate` is set only when `placedAt` is present, so today a WooCommerce invoice carries no `saleDate` at all and the provider substitutes its own date. The change is therefore empty-to-populated, not A-to-B; already-issued documents are safe because `InvoiceRecord` is a persisted projection and `issuedLineSnapshot` (#1297) exists so nothing re-derives from live order state. The real exposure is a WC order placed in month N and invoiced in month N+1 after the mapping lands, whose `saleDate` can move it into a different VAT period. That is an invoicing decision with its own review. Accepted cost, now stated rather than implied: a foreign-currency WooCommerce order stays terminal-unstamped, including in the demo — whose only order seeder creates two USD orders inside WooCommerce, so the demo's first FX-visible artefact is a non-zero unstamped count rather than a conversion. The plan names both ways to fix that and puts both out of scope. **Restatement is filed as #2096** and linked from § Migration path, § Decision 8 and § References, so the trade-off is documented rather than silent. Also adds the two-value-set limitation Piotr asked for — the setting accepts only PLN and EUR because those are the currencies the shipped providers quote against, which is a limitation and not a temporary omission — and stops § References asserting that the architecture-overview § Currency section already exists. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Two conflicts, both from ADRs that landed on `main` while this branch was in review: - `docs/architecture/adrs/README.md` - ADR-041 (sales-document routing, #2055) took its index row and rewrote the reservation note. Both rows are kept and the two notes are reconciled: 041 is no longer reserved (it merged), 039 (#2014 analytics) and 040 (#2050 FX) still are, 042 is this PR, and 043 is next-free. - `docs/architecture-overview.md` - `main` added `### 15. Analytics Trust` while this branch added `### 15. Fiscalisation (planned)`. Analytics Trust keeps 15 (it is merged and describes shipped code); fiscalisation moves to **16**, with its two inbound pointers (§ 14 Invoicing "see § 15 below", the `FiscalizationPort` bullet under Future Capability Ports) renumbered. The invoicing section keeps all three bullets - `main`'s routing-policy bullet plus this branch's "not fiscalisation" and tax-rate ones. Now that the routing ADR has merged, the placeholder `#2051` text references become real `ADR-041` links, as the PR description said they would. Refs #2009 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
ECB research done — the Phase 1b blocker is cleared, and it found a bug in the plan's rate-date ruleRan against the live API today. Every command below is reproducible as-is; outputs are verbatim. Host: 1. The daily feed is unusable, as assumedOne day per document. Confirmed — a 2.
|
endPeriod |
What it is | Resolved TIME_PERIOD |
Rate |
|---|---|---|---|
2026-04-03 |
Good Friday | 2026-04-02 |
4.2855 |
2026-04-06 |
Easter Monday | 2026-04-02 |
4.2855 |
2026-05-01 |
Labour Day | 2026-04-30 |
4.2605 |
2026-01-01 |
New Year | 2025-12-31 |
4.2210 |
The Easter case skips four days and crosses a year boundary in one call.
3. A non-publication day is HTTP 200 with an empty body, not 404
$ curl -s -o /dev/null -w '%{http_code} bytes=%{size_download}' '…?startPeriod=2026-08-08&endPeriod=2026-08-08&format=csvdata'
200 bytes=0
Different from NBP, which 404s. So an adapter written on the NBP shape would treat "no publication" as success-with-no-data and fall through to whatever its parser does with an empty string. Worth pinning in a spec.
4. An unknown currency is a real 404, with a distinguishable body
$ curl -s '…/D.XYZ.EUR.SP00.A?endPeriod=2026-08-13&lastNObservations=1&format=csvdata'
{"type":"…","title":"Not Found","status":404,"detail":"No Series was returned for the query: …"}
So supports() has a clean discriminator, and it is not the same signal as (3): 404 = unsupported pair (terminal), empty 200 = no observation at or before that date (also terminal, but a different reason).
5. ⚠️ This invalidates the plan's shared-calendar rate-date rule
The plan has resolveRateDate derive the candidate day from the Polish working-day calendar and treats each provider's walk-back as mere defence. For ECB that produces a silently stale rate, because the two calendars genuinely diverge — proven twice:
$ …?startPeriod=2026-06-04&endPeriod=2026-06-04 -> 2026-06-04,4.2368 # Corpus Christi (PL holiday, Thu)
$ …?startPeriod=2026-01-06&endPeriod=2026-01-06 -> 2026-01-06,4.2105 # Epiphany (PL holiday, Tue)
ECB publishes on both. Concrete failure — an order placed Friday 2026-06-05, rule prev-business-day:
$ …?startPeriod=2026-06-03&endPeriod=2026-06-05&format=csvdata&detail=dataonly
…,2026-06-03,4.2383
…,2026-06-04,4.2368 <- correct: ECB's last publication before Fri 05 Jun
…,2026-06-05,4.2338
The PL calendar skips Thursday the 4th (Corpus Christi) and lands on Wednesday the 3rd → 4.2383 instead of 4.2368. Never throws, off by 0.0015 PLN per EUR (~0.035%), and wrong in the same direction every year on every PL-only holiday.
Fix, and it simplifies things: the rule yields a plain previous calendar day, and each adapter resolves the actual published day with its own mechanism — NBP keeps the PL calendar plus its 404 walk-back (so its common case stays one request), ECB uses lastNObservations=1 and needs no calendar at all. That deletes the "shared isPlWorkingDay helper serves both" assumption rather than papering over it.
6. The future-date clamp is load-bearing, not defensive
$ …?endPeriod=2026-12-24&lastNObservations=1 -> 2026-08-13,4.3048
A future endPeriod returns HTTP 200 with a rate four months stale. No error, no signal. The plan's min(resolved, todayInWarsaw) clamp is what stands between a source reporting a future date_add and a confidently wrong stamp.
7. Decimal places vary per currency — NBP's fixed 4 dp does not transfer
PLN 4.3048 DECIMALS=4
GBP 0.8549 DECIMALS=5
JPY 183.77 DECIMALS=2
HUF 362.85 DECIMALS=2
The series metadata carries its own DECIMALS column. numeric(18,8) holds all of them, so nothing changes in storage — but the plan's rounding-bounds paragraph is argued from "NBP publishes mid to 4 dp" and needs an ECB variant: a 2-dp JPY quote carries ~100× the relative error of a 4-dp one.
8. Smaller operational facts
- Reference time is 14:15 CET, not 16:00 — straight from the series metadata:
"ECB reference exchange rate, Polish zloty/Euro, 2.15 pm (C.E.T.)". Only matters if asame-dayrule is ever added. - No EUR/EUR identity series —
D.EUR.EUR.SP00.Ais a 404. The same-currency short-circuit must precede any provider call, which the plan already requires. - Multi-currency batching works:
D.PLN+USD+GBP+JPY.EUR.SP00.A?…&lastNObservations=1returns all four in one response. Not needed per-order, but it is what makes a restatement wave ([TASK] CORE - Restate already-stamped orders when the reporting currency changes #2096) cheap. detail=dataonlytrims the response from 32 columns to 8.format=jsondatais also available; CSV is simpler to parse and has no ambiguity here.- Headers:
content-type: text/csv,cache-control: max-age=30, no advertised rate-limit headers, no auth.
Plan updates pushed with this
§ 4 now carries a full ECB section on the same footing as NBP (verified, not flagged), § 6 step 2 and step 3b carry the split-calendar rule, the § 8 rounding paragraph gets its ECB variant, and § 10's "ECB research blocks Phase 1b" item is closed. Phase 1b is unblocked.
The one thing I have not verified is how far back the daily series goes (it clearly covers 2025-12-31, and EUR reference rates start 1999-01-04) — irrelevant for stamping at ingestion, but a restatement or backfill wave should confirm it rather than assume.
…eutral
Closes the Phase 1b research blocker with a live-API pass, and fixes a
defect the research exposed in the rule itself.
**Verified** (`data-api.ecb.europa.eu`; the legacy `sdw-wsrest` host no
longer resolves at all):
- `endPeriod={day}&lastNObservations=1` resolves the last publication on or
before the date, so the ECB adapter needs **no walk-back loop** — one
request. Saturday 2026-08-08 answers with 2026-08-07; the Easter cluster
2026-04-06 answers with 2026-04-02; 2026-01-01 answers with 2025-12-31.
`TIME_PERIOD` is the actual rate date and is what gets persisted.
- A non-publication day is HTTP 200 with a **zero-byte body**, not a 404 —
the opposite of NBP, so an adapter written on the NBP shape would treat
it as success-with-no-data.
- An unknown currency IS a real 404 with an RFC-7807 body, giving
`supports()` a discriminator distinct from the empty 200.
- A future `endPeriod` returns a months-stale rate with HTTP 200 and no
error, which makes the `min(candidate, todayInWarsaw)` clamp
load-bearing rather than defensive.
- `DECIMALS` varies per series (GBP 5, PLN 4, JPY/HUF 2), unlike NBP's
fixed 4, so the rounding-bound paragraph gains an ECB variant.
**Defect found and fixed**: the rule derived its candidate day from the
Polish working-day calendar for every source. ECB publishes on
Polish-only holidays — verified for Corpus Christi 2026-06-04 (4.2368) and
Epiphany 2026-01-06 (4.2105) — so for an order placed Friday 2026-06-05
the PL calendar skips Thursday and yields 4.2383 instead of the correct
4.2368: silently one day stale, never throwing, wrong the same way every
year.
`resolveRateDate` is therefore now **calendar-neutral** — it yields the
previous calendar day and knows about no weekends or holidays — and each
adapter resolves the published day: NBP keeps the PL calendar plus its 404
walk-back so its common case stays one request, ECB uses
`lastNObservations` and needs no calendar. That deletes the "one shared
`isPlWorkingDay` helper serves both sources" assumption instead of working
around it. `previousWorkingDay` is still added, now consumed by the NBP
adapter rather than the shared rule.
Test rows, the Phase 0 rationale, both adapter steps and the § 10
checklist are updated to match. Every phase is now unblocked.
Refs #2049
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
…o 2049-order-fx-rate-snapshot
#2032 landed, so main's tail is now 1833000000003. 1834000000000 still clears it, by one synthetic slot rather than three, and #1985 must now re-prefix past 1833000000003 rather than 1833000000001. Also records that main currently carries a DUPLICATE prefix: 1833000000002 is claimed by both -add-identifier-mappings-offer-created-index and -create-refund-records. Rule 4 of check-migration-timestamps.mjs only compares an unmerged migration against main, so two migrations that merge separately can collide without the invariant firing — the same class of gap as the ADR-numbering one (#2082). It does not affect this migration's number, but "the highest prefix on main" must not be read as implying each prefix there is unique. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
ADR-040 confirmed free — checked against every open PR, not from memoryYou asked me to settle the number rather than assert it, so I enumerated all 14 open PRs and diffed each one's file list for What
|
| PR | New ADR number(s) | Note |
|---|---|---|
| #2014 | 039-order-analytics-read-model-persistence-strategy.md |
new |
| #2050 (this) | 040-order-time-fx-stamping-against-a-system-reporting-currency.md |
new |
| #2056 | 042-fiscalisation-capability.md |
new; also edits 026 (VAT-rate annex) |
| #2058 | — | edits 014 only, no new number |
| #2066 | 043 / 044 / 045 |
new |
The other nine open PRs (#2099, #2098, #2060, #2057, #2048, #2018, #2008, #1769, #1396) touch no ADR file at all.
Post-merge union, computed rather than eyeballed
$ { git ls-tree --name-only origin/main docs/architecture/adrs/ | grep -oE '/[0-9]{3}-' | tr -d '/-';
for n in 2014 2050 2056 2058 2066; do
gh pr view $n --json files --jq '.files[].path' | grep -oE 'adrs/[0-9]{3}-' | grep -oE '[0-9]{3}';
done; } | sort | uniq -c | awk '$1>1'
2 014
2 026
The only two numbers appearing twice are 014 and 026, and in both cases the second occurrence is an edit to an ADR that already exists on main (#2058 amends 014, #2056 annexes 026) — not a second claim on a free number.
Every new number is claimed exactly once. After all 14 open PRs merge the sequence is 001-045 with no gap and no duplicate.
Why 043 would have been the collision
Your re-review asked for 043 on the basis that #2066 had taken 039/040/041. It hasn't:
$ gh pr view 2066 --json files --jq '[.files[].path] | map(select(test("adrs/0")))'
["docs/architecture/adrs/043-order-lifecycle-derived-from-fact-ledger.md",
"docs/architecture/adrs/044-order-changeset-proposed-then-confirmed.md",
"docs/architecture/adrs/045-pack-policy-per-connection-config.md"]
041 is already merged (#2055), and #2056 vacated 040 for 042 after your first review. So renumbering this PR to 043 would land on top of #2066 — creating exactly the collision the renumber was meant to prevent.
Keeping 040. The README row and the reserved-numbers note in this branch are already updated to match the table above (039 -> #2014, 042 -> #2056, 043/044/045 -> #2066, 040 no longer reserved because this PR now carries its row).
Note this is a point-in-time check: it is only stable until someone opens a new PR claiming a number. That is precisely the hole #2082 closes, and it is the argument for landing that invariant rather than repeating this by hand.
Adjacent findings now actually filedMy earlier comment listed these under "Filed separately", which overstated it — they were identified, not filed. They are now, one issue each, so nothing depends on a PR comment surviving:
All four carry the file:line evidence, a proposed shape, and acceptance criteria; all are explicitly independent of #2049 (#2102 and #2103 each state why they must not consume the ADR-040 stamp, matching § Consequences). Also recorded while tidying up: the That leaves the ADR number as the only open item from your re-review — evidence in the comment above. |
piotrswierzy
left a comment
There was a problem hiding this comment.
Tech-lead re-review — ✅ Approve
The rewrite landed as one pass, not a patch. ADR-040 (249 lines) argues the system-level setting from Context through Alternatives, and the plan deletes the ladder step outright rather than editing around it.
First: I was wrong about the number, and you were right to push back
My instruction to renumber to 043 was incorrect and would have created a collision. Verified directly against every branch:
| Number | Holder |
|---|---|
| 039 | #2014 |
| 040 | #2050 — free, correctly held |
| 041 | merged to main via #2055 |
| 042 | #2056 |
| 043 / 044 / 045 | #2066 (renumbered from its earlier 039/040/041) |
The "ADR-040" in #2066's body is the pre-renumber name of what is now its 043. So there was nothing to renumber here and no half-rename to find. Apologies for the churn — and your README reservation note is now more accurate than main's, which still lists the stale claims.
Checklist
| # | Item | |
|---|---|---|
| 1 | System-level currency; per-connection + history rungs dropped | ✅ |
| 2 | Default EUR on the ai_provider_active_setting chain |
✅ |
| 3 | Provider = f(reporting currency); both ship; config.fx gone; limitation stated |
✅ |
| 4 | Providers in libs/integrations/fx/, no HTTP in core, split rule dropped, *.adapter.ts |
✅ |
| 5 | Invoicing instruction reversed, per-provider, date + FA(3) argument | ✅ |
| 6 | First-attempt snapshot + both mechanical follow-ons | ✅ |
| 7 | Restatement stated, PUT count, issue filed and linked |
✅ |
| 8 | Guards | |
| 9 | Direction invariant as a property of the stamp | ✅ |
| 10 | WooCommerce placedAt kept split, saleDate addressed |
✅ |
| 11 | ADR number | ✅ |
| 12 | PR body refreshed | ✅ |
1. Context frames the fact/fact/choice table and concludes "one system-level answer" (:18-29); Decision 1 says config.currency "keeps its meaning and readers … and is explicitly not consulted" (:44-45); the ladder is the first rejected alternative with all four objections (:112-116). The defect I flagged as most likely — Decision 1 updated while Context/Alternatives still argue the opposite — did not occur. No surviving "explicitly rejected by the operator" text anywhere.
3. :47-54, and the two-value limitation is stated as a limitation in its own Cons bullet — "a real limitation and not a temporary omission" (:173-177). That's the honest framing.
4. :86-95 and :121-125. Notably plan :172 adds libs/integrations/fx to check-outbound-http.mjs SCAN_ROOTS — the no-HTTP-in-core rule is now enforced, not just asserted. That's better than what I asked for.
5. Per-provider at :161-163, three-axis argument with art. 19a ust. 8 at :150-156, and the FA(3) citation exact: KursWaluty at schemat_fa3_v1-0e.xsd:3199 scoped to dział VI, KursUmowny/WalutaUmowna at :3490 scoped "Nie dotyczy przypadków, o których mowa w dziale VI".
6. Both mechanical follow-ons landed, and the second in a stronger form than proposed: the test row now asserts a populated fxIntendedCurrency means the settings service is never called and the stamped currency equals the intent even after the live setting changed (:1641). That tests the property rather than the absence.
7. #2096 exists, is open, is linked by full URL from § Migration path, and its own AC includes amending § Decision 4 to the refined invariant. The condition I set is met.
10. Kept split, and the consequence is addressed head-on rather than dropped: saleDate is gated on placedAt, so a WC invoice carries none today and the provider substitutes; populating it moves saleDate empty → placement date and "can move an invoice into a different tax period". Already-issued documents are shown safe via InvoiceRecord + issuedLineSnapshot (#1297), narrowing exposure to a future invoice on an existing order. #2097 carries the VAT-period cutoff as an explicit AC. That's the right answer to the objection rather than a deletion of it.
IMPORTANT — the one partial
8 — the per-figure labelling shape is not specified anywhere. No MoneyFigureDto { amount, currency, isReportingCurrency }, no unstampedOrderCount in either document. What exists is the principle ("every analytics figure is labelled with its currency where it is consumed", :41-42) and a recommendation to amend #1987/#1988 to expose an unstamped count.
Since the consuming DTOs live in those issues and the body correctly defers the AC edit to the epic owner, this is placement rather than omission — but name the concrete shape on #1987/#1988 before they're implemented, or each will invent its own. isReportingCurrency is the load-bearing part: without it the FE cannot distinguish a converted figure from one that couldn't be stamped and is shown natively.
Save-time validation, EUR (default), and OL_REPORTING_CURRENCY across all three .env.example files plus the demo compose passthrough all landed, with the worker file's rationale spelled out.
Suggestion
Plan :1705-1706 notes #2049's issue body still describes the superseded design (baseCurrency, three rungs, migration 1833000000000, dead ADR filename). It carries the criteria someone implements against, and the plan flags it but nothing closes it. Worth editing before the implementation PR opens.
CI
7/8 green. Lint is red and inherited — main itself carries two migrations at 1833000000002 (add-identifier-mappings-offer-created-index and create-refund-records), so check:invariants fails on the trunk. This PR touches three files under docs/, so it cannot be the cause; your plan even documents the duplicate at :968. Being fixed separately.
Merge readiness: ✅ Approve — every committed item is in the documents, both follow-up issues exist and are linked, and the number is free. Merge resolves the README conflict by keeping both the 040 and 041 rows.
…nnex on ADR-026 (#2056) * docs(architecture): ADR for the fiscalisation capability + VAT-rate annex on ADR-026 Records, ahead of #1908's code, why fiscalisation is a capability of its own rather than an InvoicingPort document type, and where each of its contract-level guarantees lives. - ADR-040 (new): fiscalisation as a capability distinct from invoicing. Base contract taken from the published fiskaltrust / efsta shape (register a transaction, receive back what must be printed), trust anchor confined to the adapter, the device dependency expressed as a sub-capability (#1910), and exactly-once registration owned by core because a double fiscal registration is a legal event for the seller. Journal export, fiscal corrections, a second adapter and the legal-obligation determination are recorded as deferred with reasons. - ADR-026: annex recording where a line's tax rate comes from - the master, not a computation - with the rejected TaxCalculationPort alternative and the tax-rate-conflict state that blocks both invoice issue and fiscalisation. The ADR-014 supersession it requires is noted and left to #2054. - ADR index + architecture-overview: register ADR-040 and describe the planned fiscalisation capability where a reader first meets it. Numbering note: 038 was taken by the rate-limiting ADR (#2019, merged today), so fiscalisation is 040 and the sibling routing ADR (#2051) keeps 039. Refs #2009 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HpwFwSVZYF7nopZ5S3Peet Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(architecture): point the fiscalisation ADR at ADR-039 for document routing The sibling sales-document routing ADR is now numbered (039, #2051), so refer to it by number and link it, instead of citing the issue alone. Refs #2009 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HpwFwSVZYF7nopZ5S3Peet Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(architecture): correct ADR-040 fiscalisation + the ADR-026 tax-rate amendment Addresses the tech-lead review on PR #2056. Blocking: - eparagony.pl is a private e-receipt distribution hub, not a certified fiscaliser; say so, reword the capability to "performs or brokers the fiscal registration", and state that no v1 adapter implements FiscalDeviceOperator (the printer is driven by the vendor's software). - Rewrite decision 6's outcome clause: a repeat resumes status-aware (issued verbatim / live lease / in-doubt surfaced / terminal rejected re-attemptable) and exactly-once needs the unique index AND an atomic in-flight lease, matching InvoiceService.resumeExisting / claimForIssue. - Drop the five links to ADR-039, which does not exist yet (PR #2055), down to plain #2051 references, and record 039 as reserved in the index so the next contributor does not collide with it. - "The carrier is a code" -> "The tax rate is a code". Important: - Migration path now names the CoreCapabilityValues + FE-mirror additions #1908 needs, and Consequences says "no core *domain* PR". - ADR-005's delete-on-publish-failure step is explicitly NOT adopted. - Mandatory idempotencyKey means a plain, not partial, unique index. - Add a FiscalRegistrationLocator sub-capability and state that in-doubt is non-terminal, per ADR-035; declare an own failure-mode union instead of value-importing invoicing's. - Scope the amendment's "implies no migration" to the invoicing side; put the conflict flag on the Order projection AutoIssueTriggerService already receives, preserving its F3 one-way edge. - Legal accuracy: art. 111 ust. 6a + 6b and "potwierdzenie Prezesa GUM" (not homologation); carry the spec's not-legal-advice caveat into both documents; Czechia 2027 is pending legislation; the tax-code list adds np and drops intra-EU 0% as a separate code; fix three code pointers; the PL corrections deferral rests on paragony not being device-correctable, which makes receipt number + numer unikatowy a #1909 requirement. Also: Annex -> Amendment run-in, section vocabulary aligned with 12-14, the duplicated FiscalisationPort description trimmed to a pointer, the two invoicing policy bullets moved above the adapter roster, manifest posture for FiscalDeviceOperator, and the spelling choice recorded. Part of #2009 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(architecture): resolve the substantive review findings on the fiscalisation ADR Addresses the tech-lead review + industry-research pass on PR #2056. Numbering is handled in a separate commit. Contract corrections: - Decision 2 no longer defines the base operation as returning "what must be printed", which is the assumption decision 4 forbids 18 lines later. The result now carries a possibly-empty list of customer artefacts, each with an adapter-declared medium and disposition hint; an empty list is a successful registration, not a failure. - Decision 9 names the neutral shape for the two PL fields it makes a v1 requirement, so #1909 cannot put `numerUnikatowy` on the neutral record: a small neutral identity set plus one adapter-owned extras bag core never indexes. Decision 4's litmus test is extended to cover field names and core reads, not just prose. New decisions: - Decision 10 records why no degraded/offline mode ships, contrasting it with `in-doubt` - OL is never in the buyer-blocking path and can never mint the legally-recognised substitute, since it is categorically not the trust anchor. - Decision 11 promotes journal/audit export from an open deferral to the named first extension point, with the reasons a periodic export cannot sit on a per-transaction base port. Tax-rate rule hedged: - Decision 8 splits its settled negative half (fiscalisation never recomputes a rate) from the positive ProductMaster rule, which is marked proposed pending the ADR-014 reversal in #2058 - a draft that may be refused. The ADR-026 annex and the architecture-overview bullets carry the same caveat, so the record cannot read as decided while a live ADR contradicts it. Framing and facts: - Context states the boundary explicitly (OL is the cash register calling the middleware, not the middleware), which lets decisions 3 and 5 and two Alternatives bullets shorten. - Italy corrected: certified software was added alongside hardware RT, with no sunset in any instrument - which strengthens the anchor argument rather than weakening it. - Czechia date-stamped, with the Ministry of Finance statement that it mandates neither printing nor new cash equipment. - Consequences notes that from 1 Jan 2027 a PL low-value NIP receipt enters KSeF, so one order can need both a fiscal registration and a clearance. Naming: code identifiers, the capability value and the context path use the `-ization` spelling, matching the repository convention and the two implementation plans. The value is wire-visible and strict-`@IsIn`-gated, so choosing now is free and revisiting later is a breaking change; prose, spec and issue titles keep `-isation`. Refs #2009 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(architecture): renumber the fiscalisation ADR to 042 and fix the reservation note Three open PRs were authored off the same base commit and 040 was claimed twice: this PR and #2050 (order-time FX stamping) both added an `040-*.md`. Because the filenames differ, git merges both cleanly with no conflict marker and `pnpm check:invariants` does not catch it, so the collision only surfaces as two ADR-040s on `main`. Verified independently against `origin/main` and the open PRs: the highest ADR on `main` is 038, and 039 is the order analytics read model (#2014, already referenced by filename from `docs/plans/implementation-plan-order-cancellation-record-state.md`), 040 is FX (#2050), 041 is sales-document routing (#2055). This PR therefore takes 042 - filename, title line, README index row, both `docs/architecture-overview.md` pointers, the ADR-026 annex cross-link and the spec's `**ADR:**` line. The README reservation note was wrong on both facts it asserted: it named 039 as the routing ADR (it is analytics) and told the next author to allocate 041 (which #2055 already claims), steering them straight into a second collision. It now lists all three reserved numbers with their claiming PRs, points at 043 as next-free, and names the failure mode so the next author checks the open-PR list rather than the file listing. Refs #2009 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(architecture): normalise fiscalization to the repo's -ization house style Follow-up to the review on PR #2056, suggestion 11. The ADR previously kept `-isation` in prose while using `-ization` for code identifiers, which left the two halves of one vocabulary disagreeing. Settle on `-ization` everywhere: - rename 042-fiscalisation-capability.md -> 042-fiscalization-capability.md and update every inbound link (ADR index, architecture-overview, ADR-026, the #1902 spec header) - flip prose in ADR-042, ADR-026's tax-rate amendment, architecture-overview sections 14 and 16, and the #1902 spec - flip the four prose mentions in ADR-041, so the sibling routing ADR names the capability the way the capability is named. No decision text changed Matches the repository house style by a wide margin (normalize 464:40, authorize 244:3) and removes what would have been the tree's only -isation identifier. The GitHub issue titles (#1902, #1908, #1910, #1911) and this branch name keep the old spelling - renaming them would break inbound links for no gain, and the ADR now records that explicitly. The capability value is the reason to settle this before code: it is wire-visible in connections.enabledCapabilities behind a strict @isin DTO, so changing it once a connection exists is a breaking change. Part of #2009 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> --------- Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…g work filed as issues (#2066) * docs(orders): OMS module plan + ADR-039/040, reverse #1032 Gate D to BUILD Adds the plan of record for an OpenLinker OMS module, the two ADRs the rest of the design depends on, and an honest Gate D amendment on the #1032 spec (which currently reads DEFER and would otherwise contradict the plan). Grounded in seven research streams: competitor OMS anatomy, PL seller demand + OSS landscape, orchestration architecture review, returns/allocation codebase readiness, rules-engine design, Allegro/PL primary-source verification, and a Medusa entity-level spike + OSS state-machine comparison. Key decisions: - ADR-039 — the canonical order lifecycle is a derived projection over a per-axis fact ledger, never a stored contested scalar. Adopts (rather than overrides) the Phase C adversarial review's refutations of "orthogonal axes" and "guarded-monotonic single value". - ADR-040 — every order mutation is a proposed-then-confirmed changeset, because OL is never the executor. Yields dry-run, which no surveyed competitor offers, as a by-product of the model. - Do not embed Medusa: the spike confirms it boots standalone, but at 605 packages, a second ORM, a second DI container, and an unresolved Enterprise Edition carve-out on the mandatory framework tier. Steal the model instead. The Gate D amendment records plainly that none of the three documented un-defer triggers fired. The reversal is a maintainer strategic-bet decision on the back of a prospective agency's request; the CTO/CPO correctness-liability objection is accepted rather than retired, mitigated by making the end of Wave 2 an explicit gate. Docs only — no code, schema or behaviour change. Refs #1032, #827, #1030, #1031, #861 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): apply /pre-implement + /tech-review findings to the OMS plan Adds the readiness-gate report and folds every Critical, Warning and review finding back into the plan and the two ADRs. One BLOCKING finding, from /tech-review: - D12 — the plan specified pack-station authorization as permission strings, but permissions in this codebase are display-only: RolesGuard enforces roles, and role.types.ts documents that adding a permission does not open an endpoint. As written, the pack endpoints would have shipped with no backend authorization. Corrected in § 6F: extend the existing `operator` role rather than adding a fourth, declare @roles explicitly per endpoint, and keep the new permission strings for FE affordance visibility only. Three Critical contract findings, from /pre-implement: - C1 — no consumer of OrderLifecycleEventTypeValues uses an exhaustive switch; all five are two-branch if/else, so adding `returned` compiles cleanly and mis-routes at runtime (Erli would report a returned order as `sent`). Added as a blocking Wave 4 Step 0. - C2 (D11) — redefining VariantAvailability.totalAvailable as ATP is a silent semantic break across six consumers with zero compile errors; one of them surfaces the field to operators AS `masterStock`. ATP becomes a NEW field. - C3 — feeding ATP into applyStockSafetyBuffer double-subtracts reservations. Accepted deliberately (reservations cover known allocations, the buffer covers sync latency) with the required doc updates named. Also folded in: a § 4 conventions section (service naming to avoid the OrderLifecycleRelayService collision, repository ports staying intra-context, ORM entities on the orm-entities sub-barrel, synthetic-sequential migration prefixes above 1833000000000, event-stream MAXLEN + dedicated blocking Redis client, scaffold connection for connection-less jobs), a § 7 testing strategy naming the D9 atomic-reserve int-spec as the single most important test, the order_stages vs order_state_mapping boundary, the additive canonicalState DTO decision, and the station page as a documented responsive departure. Both ADRs trimmed to the template's word budget. Docs only — no code, schema or behaviour change. Refs #1032, #827, #1030, #1031, #861 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): resolve D7/D9, add the OMS extensibility model Applies the /pr-review findings plus a deep analysis of three questions the plan left open or contradictory. D7/D9 contradiction — resolved (§ 6I). D9's SQL was Medusa-shaped, borrowed from a schema where reserved_quantity is platform-owned; in OL it is written straight from the master on every sync. The mechanism was right, the column was wrong: keep the guarded UPDATE ... RETURNING, move the counter to an OL-owned `olReservedQuantity`. Section adds the exact DDL and SQL, the master-sync race analysis, the ATP read, and the four rejected alternatives (advisory lock, SERIALIZABLE, SELECT FOR UPDATE, exclusion constraint — the last is not expressible in Postgres, recorded so it is not re-litigated). Two findings from that analysis were not in the plan at all: - C3a, the publish feedback loop. Feeding ATP into published stock means an OL reservation lowers what OL publishes; when the order later lands at the master and the master decrements, the next sync deducts the SAME unit again while the reservation is still held. Mitigation is now a hard requirement: a reservation closes on the lifecycle event that also causes the master to decrement, never on an independent timer. - The sync's non-stomping of an OL-owned column currently holds only as an emergent property of TypeORM save() diffing. Item 26 makes the write explicitly column-scoped; an int-spec pins it. Extensibility model — new § 7. Decision: the canonical state axis stays core-owned; plugins may contribute actions. The prior art splits exactly on tenancy — every hosted platform (Saleor, Shopify, Medusa) keeps state core-owned; every self-hosted one (Vendure, Sylius, Spree) opened it and then spent years managing it. Decisive: you can open the state axis later, you cannot close it. Three boundaries copied: plugins supply triggers and actions but never conditions (Shopify Flow — which is also what keeps D5's purity constraint intact); enumerated named guards (Vendure); degrade-to-default on failure (Saleor/Shopify), avoiding Vendure's post-hook trap where state is assigned before the hook runs. Records that a plugin cannot register an OMS action today — HostServices has 14 registries, none for a unit of operator-invocable work — and the minimum additive change. Flags the two hazards: the #2019 limiter paces requests but does not cap fan-out, and an OMS action would be the first operator-authored artifact to trigger an outbound write; and loop prevention is unsolved in all prior art, so treat it as operator-facing observability. Also: § 13 trimmed to a link (the spec is the canonical record), testing strategy expanded with five reservation int-specs including the separate- connections requirement, sections renumbered. Docs only — no code, schema or behaviour change. Refs #1032, #827, #1030, #1031 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): narrow ADR-040, record split/merge as impossible, deepen returns A design stress-test asked whether the changeset model could express order split and merge. It cannot, and the blocker is structural rather than effort: identifier_mappings is a bijection per connection — UNIQUE(entityType, platformType, connectionId, externalId) and UNIQUE(entityType, connectionId, internalId). Split needs 1→N, merge needs N→1, and the second index is load-bearing (it is what lets one internal id carry different external ids across different connections, i.e. cross-destination routing). Worked concretely, both failures are worse than a constraint violation: - Split leaves the child permanently unmappable on its origin connection, so a marketplace cancellation resolves to the parent and leaves the child live and shippable — the split manufactures a ship-a-cancelled-order bug. - Merge fails on the second index; skipping the remap leaves the loser order mapped, so the next poll refreshes its snapshot with the lines that moved. The merge un-merges itself. Preview would not survive either: internalOrderId is minted as a side effect of the mapping INSERT, so previewing a new order would have to write. ADR-040 is therefore narrowed rather than defended. Kept: the proposal record (PENDING → CONFIRMED|DECLINED), requested_by/confirmed_by/declined_reason, and `applied` as the single at-most-once primitive replacing three hand-rolled claims. Deferred: the actions table, the action registry and replay. Every mutation OL can actually perform is a single action against a single reference; ordering and replay serve compositions that do not exist here. A correction to the earlier framing: split/merge was NOT the canonical use case for this pattern. Medusa's order_change ships 23 action types and includes neither — it exists to serve order editing, returns, claims and exchanges, all mutations of composition, which is precisely what OL is forbidden from. The pattern arrives without most of its use case. Dry-run moved out of ADR-040 into the rules-engine decision, where it lives: what operators mean by dry-run is "why didn't my rule fire", which is condition evaluation, not changeset replay. Also: - New § 6J records the split/merge disposition with the prior art (Shopify splits the fulfilment order, never the commercial one; BaseLinker splits the commercial order but appears to record no lineage back-pointer; Sterling escalates line-first). Sterling's ladder is the fit and OL sits on rung one. - shipment_lines keyed (shipmentId, orderId, lineId) — the orderId is what keeps consolidated shipping expressible, and costs nothing now. - Returns deepened from a two-table sketch to the model four systems converge on: own status enum including `declined` (a declined return leaves the order untouched — no order-status equivalent), per-line received/damaged quantities, per-line reason codes as their own vocabulary, an external return id, and refund intent separated from refund execution. - § 1 gains the sharper principle: OL cannot change an order's composition; it can only observe composition and attribute work to it. Docs only — no code, schema or behaviour change. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): close the partial-cancellation question, correct the returns model Primary-source verification against both OpenAPI specs (developer.allegro.pl swagger.yaml and erli.pl shop-api swagger.json, both downloaded unauthenticated) plus three codebase checks. Two open questions close, several assumptions were wrong. Partial cancellation: NOT supported by Allegro or Erli. Verified by exhaustive path and schema enumeration, not by absence of documentation. Allegro has no order-cancel endpoint at all — cancellation is a buyer/system event the seller observes; CheckoutFormLineItem has no status or cancelled flag; and RETURNED is documented as requiring the buyer return ALL items and the seller refund ALL of them. Erli's status write has no line parameter. So ADR-040's revisit condition does not fire and the narrow decision stands. What Allegro does have is line-scoped REFUNDS (RefundLineItem carries type AMOUNT|QUANTITY plus quantity), so a seller effects de-facto partial cancellation by refunding lines and shipping the rest. That does not reopen the actions table: a refund of N lines is one remote call carrying a line array, not N independently-applied actions. Recorded in § 6J, with the conclusion that OL models what sources report rather than inventing a "partially cancelled" state none of them can express. Returns model corrected against the real schemas: - Allegro's CustomerReturnItem carries offerId, NOT the checkout-form lineItems[].id. Attribution to an ordered line requires joining on offer.id and that join is NOT unique — one form can hold two lines for the same offer. ReturnLine now keys on the source reference with the order-line link as a best-effort resolution, and an unresolvable attribution is an operator-facing state rather than a silent guess. - ReturnLine carries no status: Allegro's rejection is whole-return. - Allegro's 11-value return status interleaves four axes (logistics, settlement, commission, warehouse) — the spec calls it a timeline. Modelling it as a state machine will mis-fire. - Erli returns have no status at all, so a cross-platform enum carries an unavoidable unknown. - Two verified adapter traps: Erli's quantity field is misspelled `quentity`, and its line reference is a positional index into items[] despite items[].id existing. - Allegro customer-returns is [BETA] and read + reject is the whole surface. Codebase verification: - OrderItem.id is stable on Allegro, Erli and WooCommerce, and UNSTABLE on PrestaShop: prestashop-order.mapper.ts:53 is String(row.id || index) — an array-index fallback, `||` instead of `??` so a legitimate row.id === 0 falls through, and an index-derived id can collide with a real row.id. Harmless today (error-message only) but structural if shipment_lines keys on it. - orderSnapshot.items survives OL_STORE_PII=false unconditionally, so line-grain backfill is viable for every order. One constraint removed. - Nothing in OL detects a line disappearing or shrinking between polls: ingestion reads the prior record only for its status, and persistence rewrites the snapshot wholesale. Noted as a gap independent of this plan. Docs only — no code, schema or behaviour change. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): remediation pass — adopt option C, close all eight open findings Closes every outstanding item from the adversarial review so the plan is implementable rather than merely coherent. Five new decisions (D13–D17) and the adoption of the fulfilment-grain decision. D17 — option C adopted. Wave 2 gains shipment_lines keyed (shipmentId, orderId, lineId), with three things that must ship in the same change: the PrestaShop mapper fix (String(row.id || index) is an array-index fallback with a `||`/`??` bug and can collide with a real row.id — harmless today, structural once lines key on it), the fulfillment-rollup.ts precedence fix ("any delivered ⇒ delivered" is wrong under partial coverage), and backfill written as ledger events rather than counters so the cancel-and-reissue double-count stays compensable. This gives shipped_quantity and delivered_quantity a real source for the first time — D2a was previously unimplementable. D13 — SLA leaves the canonical projection. deriveSlaState takes a wall clock, so a materialised column consuming it can never be invalidated: an untouched order crosses its deadline and stays stale forever, which is exactly the query the column exists to serve. slaState stays derived-on-read, as order-sla.types.ts already asserts. No sweeper, no clock writer, no cache. D14 — shipping writes a fact, not a column. OrderFulfillmentProjectionService routes through the ledger, which makes "the sole coordinating writer" true; its error-swallowing becomes acceptable because the sweep re-derives. D15 — transition identity becomes (internalOrderId, axis, causeType, causeId), all NOT NULL, with a documented causeId form per cause. The previous key left both columns NULL for every OL-origin fact, and Postgres NULLs do not conflict in a unique index — the central guardrail would not have applied to most of Wave 2's traffic. D16 — relay obligations move to order_relay_attempts, one row per (transition, target), with attempts/backoff/terminal-failed and a lease column. A single relayState enum could not represent an N-target fan-out, which is the error §1 and D10 both forbid. The four failure windows are now enumerated, including the requirement that the outbound call carry an idempotency key derived from (transitionId, targetConnectionId) or the sweep duplicates a marketplace write. C3a resolved. The old mitigation required closing a reservation on "the event that causes the master to decrement" — no such event exists; master stock arrives via a poll with no per-order causality. Reservations now close on OL's own dispatch, which is observable and durable, bounding the double-count window to one sync interval instead of leaving it unbounded. Also specified, previously referenced but never defined: the shortfall path (clamp + needs-attention, never auto-cancel), the reservation reconciler (the counter is denormalised over a ledger and drift silently oversells), the reservation key scoped by orderRecordId (source line ids collide across orders, so the old key rejected valid orders as out of stock), single-location as an explicit v1 scope with a sourcing rule named as the prerequisite, ledger retention as a pre-Wave-3 requirement, and multi-currency plus order editing as declared non-goals with reasons. Corrections: the station device session is authorised by its own guard with an endpoint allowlist, not by permissions (D12 said permissions are display-only, §6D contradicted it); the fast-path sequencing cost is a throwaway guard plus an operator-data migration, not "one column"; and Wave 0's standalone justification is narrowed to consolidating three claims — the lost-cancel bug is fixed by Wave 1's sweep, so 0 and 1 gate as one unit. Eight int-specs added to the testing table, including the two that pin the defects this pass found. Docs only — no code, schema or behaviour change. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(adrs): accept ADR-039 and ADR-040 Both were Proposed pending the design work that has now landed. ADR-039's model survived the adversarial review and its one real defect (SLA as a wall-clock input to a materialised projection) is fixed by D13. ADR-040 was narrowed to the proposal record after a stress test established that split and merge are structurally impossible against identifier_mappings' per-connection bijection. Accepting them unblocks Wave 0, which the plan gates on their status. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): model process variation as named order flows (ADR-041) Different clients — and different order types within one client — work differently. That variation is now a named OrderFlow rather than a pile of independent per-connection booleans. The shape comes from the enterprise tier: Fluent Commerce makes orderType part of the workflow identifier used at orchestration time, and Sterling does the same under process type. An order does not run 'the' workflow, it runs its workflow. The counter-example is BaseLinker, which made order handling broadly configurable and then had to add Status Groups and action groups purely to keep the configuration manageable. A flow owns the stage pipeline plus four policy axes: verification mode, dispatch gate, packing slip, pack grain. It is assigned at ingestion and stamped on order_records, resolved by a rule service mirroring FulfillmentRoutingService's shape and provenance, with a seeded Default flow so nothing needs configuring to work. Stamped rather than resolved-on-read deliberately: a config change must not silently re-route work in flight, and an auditor needs to know which process an order actually went through. scan-where-possible is the honest answer to catalogues with missing EANs: a line that carries an EAN must be scanned, a line without one may be ticked, and the UI distinguishes them. Otherwise 'fully verified' silently means 'verified except the bits we could not check'. Flexibility is bounded by an enumerated allowlist of named guards, borrowing Vendure's configureDefaultOrderProcess pattern — core decides in advance which invariants a flow may switch off. Not disableable ever: the canonical axis and its precedence, the guardrails, the identity constraints, and the counter validation ladder. A flow governs how an operator moves through the work; it never changes what OL believes happened. The test-matrix risk is contained structurally rather than by discipline: flow is a pure input to one resolveFlowPolicy function, never a branch through the pack service, so behaviour is tested once and resolution is tested per axis. Closes the three pack-station questions as questions and reopens them as defaults for the seeded flow — a smaller and better-posed ask. Docs only — no code, schema or behaviour change. Refs #1032, #827 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): narrow ADR-041 after a stress test — pack policy, not a flow entity The named-OrderFlow entity was stress-tested and rejected. Four findings, the first of which is decisive: - The containment claim was disproved by its own signature. The plan justified the flow abstraction by asserting flow is a pure input to resolveFlowPolicy(flow, line, order) — singular order. A multi-order batch has none. packGrain is not a policy value: it needs a different screen, an ambiguity-resolution algorithm (one scanned EAN matching lines on three orders), and a batch entity with claim/release semantics, none of which existed. - The guard allowlist duplicated the axes it sat beside. requireScanVerification disabled is identical to verificationMode: manual, and packingSlip: none with requirePackingSlipPrinted enabled is representable and means wait-forever. - No versioning. Flows carried name/isDefault/isActive only, so 'which process did this order go through' resolved to 'whatever flow 7 looks like now', destroying the auditability that justified stamping the id. PromptTemplate was cited as the precedent for the rules engine and not applied to the entity that needed it most. - orderType has zero occurrences in libs/core/src — Fluent's vocabulary imported without an OL referent, leaving the resolver key undecidable. What ships instead: two keys on Connection.config, verificationMode (manual|scan|scan-where-possible) and dispatchGate (off|warn|block), validated together because they are the one genuinely dependent pair. Stages stay global. This still delivers the adaptability asked for; what is deferred is the entity, not the flexibility. Two defects fixed along the way: - The gate was defined twice — per-connection in § 6E and per-flow in § 6K — with no precedence rule, and § 6E was the only place naming the enforcement point. Now one key, one enforcement site. - dispatchGate: block is unenforceable for ompFulfilled (the DEFAULT routing resolution) and sourceBrokered, because OL generates no label and only observes the remote dispatch. It is advisory there and the UI must say so; a gate that claims to block and silently does not is worse than no gate. scan-where-possible gains scannableAtPackTime on the pack event: ean is mutable, so an order ticked on Monday would become 'a line that carried an EAN was not scanned' on Tuesday. Same reasoning as § 6B denormalising sku/ean/name. Preconditions recorded for if the flow entity returns, from the platforms that actually ship configurable workflows: version it and snapshot the resolved definition onto the order (Fluent pins in-flight instances to their version; Temporal's whole versioning discipline exists for this invariant); refuse destructive edits (commercetools ReferenceExists) or require an explicit remap (Camunda rejects a migration with any unmapped active element); stamp the selection key at creation, as every surveyed platform does. The outcome all of them avoid is the one the first draft would have produced — an order sitting in a status the config no longer contains. Docs only — no code, schema or behaviour change. Refs #1032, #827 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): cut the rules engine from Wave 3 after a stress test Third abstraction to fall in this design pass, and the most clear-cut. Two passes: an empirical audit of what OpenLinker can actually emit, and an adversarial test of the design. The empirical finding is decisive. OL has THREE event streams in the entire repo — events.inbound.webhooks, events.master.deletion, and events.sync.jobs, the last of which has no consumer at all. Two of the three carry nothing an operator would rule on. After Waves 0-2 there is one genuinely useful new stream carrying at most six cause types. sync_jobs lifecycle is column writes with zero hook, and there is no in-process emitter to fall back on. Mapped against BaseLinker's ten published trigger categories: one servable, four only after new emission work, five not at all. The plan's own warning — 'parity on triggers you cannot emit is not parity' — indicted its own design. The adversarial pass found the differentiator had already been deleted. Wave 3 item 13 read 'dry-run ... reuses the changeset replay', and ADR-040 defers the replay function. The single Wave-3 test in the plan tested that deleted component. Dry-run did not survive its move out of ADR-040; only the word did. It is now recorded as not delivered by this plan at all, so it is not asserted a third time without a design. Three further defects, each fixed: - D13 contradicted D5 two decisions apart: D13 excludes SLA from the projection because it consumes a wall clock, while Wave 3 made SLA the headline rule subject. The headline rule also had no trigger, since D13 explicitly refuses the clock writer. - The loop cap was unimplementable as named. No correlation id survives the outbound boundary (order-ingestion.service.ts states outright that no correlationId exists) and D15 has no rule causeType, so a causation-depth cap cannot cover the marketplace round trip it was specified for. - The #2019 rate limiter does NOT mitigate fan-out, as § 12 claimed. MAX_TOTAL_WAIT_MS is 120s and acquire() throws past it, so under amplification it converts fan-out into mass job failure and a re-driving dead pile. Pacing is not capping. What ships instead is Wave 3's actual novel content: one cron and one notifier. SLA escalation as a named core feature (sweeper, per-connection thresholds, notification only, no outbound write); cancel propagation already delivered by Wave 1; auto-advance already in § 6C. The precedent is AutoIssueTriggerService, a shipped when-paid-or-shipped-then-issue-invoice orchestration in ~150 lines of core service plus a four-value config enum. The engine is deferred behind a falsifiable premise — a second customer requesting a third automation config cannot express — with ten preconditions recorded, including one scheduled day-one incident: Wave 2 backfills by writing ledger events, so without an explicit rule that rules never fire on backfilled or replayed transitions, a backfill would chase thousands of orders at the marketplace. Also: § 9's checkpoint could not fail. 'Fresh justification' is not a criterion. Every deferred item now carries a premise that either was or was not observed. Docs only — no code, schema or behaviour change. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): cut Wave 5 and narrow Wave 4 after stress tests Five abstractions stress-tested, five narrowed or cut. Same disease each time: mechanism specified to the SQL, premise unexamined. Wave 4 -> a projection plus one action. - order_returns_projection carries rawStatus verbatim (closes open Q6a); no Return aggregate, no ReturnLine, no reason vocabulary. - reject-refund routes through the existing ADR-040 proposal record. - The status enum had one derivable value of six; damagedQuantity is unobservable from both sources and is warehouse mechanics, a declared non-goal; Erli has no return id, so the aggregate key was synthesised over an unstable read-only array. - CorrectionIssuer mapper is BLOCKED, not deferred: InvoiceLine carries no line id, so two lines of the same offer are byte-identical in the snapshot that originalLineNumber indexes positionally. Wrong by the price delta on a KSeF document whose issue date is not retractable. - Restock is unbuildable: PrestaShop rejects adjustInventory, Woo admits a race, and D7 forbids the OL-side shortcut. Relocated to PrestaShop. - Step 0 exhaustiveness moves to Wave 2 (live latent bug, independent). Wave 5 -> cut as scoped. - C3a closes reservations on OL dispatch, but omp_fulfilled is the DEFAULT and OL never dispatches there. On the reference topology the master decrements its own stock while olReserved is still held: a seller with 3 units and buffer 1 publishes 0 after selling 1. Worse than shipping nothing. - Reserve-on-ingestion cannot run for awaiting_mapping orders, and source_deleted hits isStale=false -> permanent rejection of a paid order. The retry loop double-reserves via the unstable PrestaShop line id (open Q6c is not harmless). - The reconciler is ledger-authoritative, so it entrenches ledger wrongness and reads green during the incident it was built for. - ATP has seven consumers, not six; the seventh is outside core. - Kept: column-scoped upsert and the Q6c line-id fix, both cheap and independently valuable. Port removal dropped; deprecate in place. Both section-9 gates were decorative and are rewritten. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): cut Waves 0 and 1, split Wave 2 after three more stress tests Eight adversarial passes have now run against this plan. All eight found disqualifying defects. Adds section 0 recording the outcome and what is actually worth building. Wave 0 -> cut. - canonicalState reduces to a pure function of fulfillmentState and cancelledAt, both already indexed and already sorted in SQL via FULFILLMENT_ORDINAL / HEALTH_ORDINAL / applySlaFilter. ADR-039 rejects the one alternative OL has shipped three times. - The axis and canonical-state vocabularies are never enumerated, yet order_stages.canonicalState would be operator-authored data keyed on them. recordStatus, syncStatus and OrderHealth appear zero times. - The ledger is keyed internalOrderId NOT NULL, so it cannot hold the lost-cancel fact that justifies it. - Item 7 replaces a releasable claim with an unreleasable one a wave before its replacement exists: a throwing relay would strand the waybill claim forever. Wave 1 -> cut. - The lost cancel produces zero obligation rows. The code comment says it: a cancel arriving before provisioning "finds no targets here". - order_relay_attempts plus the sweep is #861, a PRODUCT-DESIGN issue owed its own ADR. The plan never mentions it. - The waybill backfill fires on a tracking null->value transition independent of status, so it has no identity under D15 and no cause type. Both branches lose the waybill, reproducing #1947. - The mandatory idempotency key cannot be plumbed; the capability takes one parameter and neither remote API accepts a key. - Item 12 shipped already in #1947. Wave 2 -> split; only 2a survives. - shipped/delivered counters are permanently 0 on omp_fulfilled, the default routing kind, because branch-1 shipments carry nothing line-level. That is the failure D17 was adopted to fix. - lineId is unstable across re-polls and ?? does not fix it. - Return counters referenced ReturnLine, deleted when Wave 4 narrowed. Cut, with written_off_quantity. - Section 6D was a security hole: RolesGuard returns true when a route has no @roles, so a station principal on req.user would reach every undecorated route including customer PII. Corrected to require the @public plus dedicated verifier split, and names the shared-credential and CSRF hazards it omitted. - Section 6H's "Wave 2 can precede Wave 0" withdrawn: item 16 requires the backfill write ledger events, and the ledger is Wave 0. ADR-039 reverted to Proposed with four preconditions. Nothing depended on it, so no superseding ADR is warranted. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): close the order-authority question by scope, not by design A deployment uses OL's order workbench or an external OMS, never both in parallel. That is a product constraint, so there is no precedence to resolve and OrderAuthorityResolver is not built. Two findings from the analysis, both worth keeping because each killed a mechanism I had proposed: - The WooCommerce exclusivity precedent does not transfer. That is two capabilities on ONE connection, both adapter-served and resolved via getCapabilityAdapter. OL's own OMS is not a connection capability, so there is no connection to hang an exclusivity on. Copying the shape would be cargo-culting a precedent whose mechanism does not apply. - No enforcement is warranted at all, because packedAt has no dependent behaviour: nothing reads it to decide anything now that the dispatch gate is cut. Two writers cannot corrupt anything. A guard here would be mechanism ahead of requirement. Records the precondition instead: if an OL-owned fulfilment fact ever gates something, authority becomes load-bearing and exclusivity must be designed before that feature ships, at configuration time rather than by runtime arbitration. Also records that integrating an external OMS needs no new port. It is an ordinary fulfilling destination (OrderProcessorManagerPort.createOrder + FulfillmentStatusReader + OrderFulfillmentUpdater), routed omp_fulfilled per ADR-012, which is what PrestaShop and WooCommerce already do. Notes that supporting both postures is unusual in this market and should be a deliberate position. Also corrects an earlier worry: packing is orthogonal to routing. processorKind describes who generates the label, not whose warehouse packs the box, so packedAt is meaningful under every routing kind. Refs #1032, #2072 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): reclassify the OMS plan as a research record An implementation plan whose own section 0 says it should not be implemented is misfiled. Anyone grepping docs/plans/ for what to build would find a six-wave programme and might not read far enough to learn it was cut. - Moves it to docs/plans/analysis/ANALYSIS-1032-oms-module.md and retitles it a research record, alongside the readiness gate and the grain decision it already sits with. - Repoints every inbound reference: the spec, the grain decision, the readiness analysis, and ADRs 039/040/041. - Fixes the relative links inside the moved document. - ADR-041 is marked Proposed but NOT SCHEDULED: the dispatch gate was cut with Wave 2, so nothing implements verificationMode / dispatchGate. The decision stands as the right shape if pack policy is ever built. ADR-039 stays Proposed with its four preconditions. ADR-040 stays Accepted and is still live, since the returns reject-refund action routes through order_changes. The surviving work is filed as #2069-#2073 and #2076-#2081. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): fix ADR number collision and the stale Gate D record Addresses both blockers from the PR review. B1 - ADR numbering. All three numbers were already taken: - 041 is MERGED on main as 041-sales-document-routing-policy. - 039 is claimed by #2014 and already referenced by name six times from implementation-plan-order-cancellation-record-state.md on main, so merging would have silently repointed a live link. - 040 is claimed by #2050 and is already cited inside the merged 041. Worse, this branch had DELETED main's 041 row from the README index and deleted the "Reserved numbers" note that named 039, 040 and 042 as claimed - the exact warning against the collision it then caused. The README is restored from main and the three rows re-added as 043/044/045. Renumbered 039->043, 040->044, 041->045, with every inbound reference repointed across the record, the readiness gate, the grain decision, the spec and the cross-references between the three ADRs. The repoint is scoped to files this PR authored, so the other plan's references to the real ADR-039 are untouched (verified: 6 references, file unmodified). B2 - the Gate D record described the programme this PR cut. It said "BUILD, scoped as a full OMS module" and "Waves 0-1 are justified independently of the bet" while the preceding commit is titled "cut Waves 0 and 1". Adds a Gate D outcome section recording the narrowing and explicitly withdrawing the Waves 0-1 justification, the dry-run by-product claim, and the reliance on ADR-043 as settled. The 2026-08-13 reasoning is retained verbatim underneath, since its demand basis and primary-source corrections are still accurate. Also from the review: - I3: ADR-044 drops to Proposed. order_changes does not exist anywhere in libs/ or apps/, so nothing may call it existing. - I4: ADR-043's "reverted from Accepted" framing removed. It never merged Accepted, so the append-only rule was never engaged and the wording read as a precedent for editing accepted ADRs. - I5: ADR-045 renamed to 045-pack-policy-per-connection-config, since the old filename asserted the flow entity its decision defers. - I6: the readiness gate renamed to READINESS-GATE-1032-oms-module, no longer a near-duplicate of the record's own filename. - I7: section 6C now states precedence explicitly. packedAt (#2072) is what ships; the order_pack_events ledger is line-grain, blocked on #2080, and derives packedAt rather than competing with it. This was the finding that could have caused wrong work. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> * docs(orders): fix ADR-044's claim grain and add the missing expiry state Addresses I1 and I2 from the PR review. Both were real defects, not wording. I1 - grain. The draft scoped its partial unique index to ONE OPEN CHANGE PER ORDER and claimed `applied` subsumes three hand-rolled at-most-once claims. Both fail together: - Shipment.waybillRelayedAt is per SHIPMENT. UQ_shipments_branch_one_per_order_conn is partial on (orderId, connectionId), so an order legitimately carries several shipments. Per-order uniqueness would serialize an order's own shipments against each other - a liveness bug. - That marker is claimed CONDITIONALLY with release-on-failure (claimWaybillRelay is UPDATE ... WHERE waybillRelayedAt IS NULL, and releaseWaybillRelay undoes it so a later tick retries). It is also the serialization point between the status-sync poll and the carrier webhook. A one-way `applied` boolean has no release path. Uniqueness is now scoped to (orderId, targetRef), and the consolidation claim is WITHDRAWN rather than weakened. The two conditional-with-release shipping claims stay. Consequences updated to say so plainly: the remaining benefit is the declined outcome and the audit trail, which is smaller than the draft claimed. I2 - expiry. With a uniqueness index and no terminal path for an unanswered request, one hung remote call left that target permanently unmutable. EXPIRED is now part of the state set rather than a follow-up, terminalised by the driving sync job reaching `dead` per ADR-007, which is exactly the "this will not be answered" signal. Confirmation is specified idempotent, since `applied` guards application and not double-confirm. Notes the residual: a mutation requested outside a job has no terminalising signal and needs an explicit TTL first. Also removes the last dry-run claim. The draft said dry-run falls out of the rules engine's pure condition evaluation; that engine was cut, so nothing planned delivers a dry-run and the ADR now says so. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com> --------- Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
…fail-open trap (#2116) Two empirical gotchas from the #1032 OMS review, written forward. 1. ADR numbers. #2066 numbered three ADRs from the last row of the README index table, which lists only MERGED ADRs. All three were taken: 041 already merged, 039 claimed by #2014 and already referenced by name six times from a plan on main, 040 claimed by #2050. The branch had also deleted main's 041 row and the "Reserved numbers" note that named the collision - removing the warning, then making the mistake. Third collision in two days. Also records the renumber hazard: a blanket find-replace of ADR-0NN across docs/ corrupts other plans' legitimate references to the real ADR at that number. 2. RolesGuard. canActivate returns true when a route has no @roles() decorator, and it is a global APP_GUARD. So a new principal placed on req.user is authorized on every undecorated route, including the customers controller. The planned pack-station device principal would have shipped exactly that, with an endpoint allowlist described as "the security boundary". Latent today only because every principal is currently an OL user with a role. The rule is the split MCP already uses: @public() plus a dedicated verifier, never req.user. Both point at their tracking issues (#2082, #2079) rather than duplicating the fix. Refs #1032 Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
Two small corrections to the ADR index note, both consequences of #2056 landing as ADR-042: - 042 is no longer reserved. The note still listed it as claimed-but-pending while the row for it sits in the table directly above, so a reader checking the note against the table saw a contradiction. 040 was in the same state after #2050. Both move to the claimed-and-carried list, leaving 039 (#2014) as the only genuine gap, and the note now states the next free number outright ("Allocate 046") the way it did before. - "fiscalisation" -> "fiscalization". #2056 normalised this vocabulary to the repository's -ization house style, and this line was the sole remaining occurrence of the old spelling anywhere in the tree. Docs only. No production code, no schema, no runtime behaviour change. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
The page claimed ECB's historical endpoint was an unresolved Phase 1b blocker. That came from PR #2050's description, which described an earlier draft rather than what merged - the plan's ECB reference rates subsection in main is verified against the live API, and an independent re-verification reproduced every claim in it. Replaces the claim with the eight facts that re-verification did add, including the includeHistory + lastNObservations phantom-row bug now recorded on #2123. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
The page claimed ECB's historical endpoint was an unresolved Phase 1b blocker. That came from PR #2050's description, which described an earlier draft rather than what merged - the plan's ECB reference rates subsection in main is verified against the live API, and an independent re-verification reproduced every claim in it. Replaces the claim with the eight facts that re-verification did add, including the includeHistory + lastNObservations phantom-row bug now recorded on #2123. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
…now that #2049 has landed This PR's own scope table deferred currency-mixing detection to #2049/ADR-040, summing totalAmount as-is. #2049 shipped (PR #2050) while this PR was still open, stamping order_records.reportingCurrency/ reportingTotalAmount - so the fix lands here rather than as a follow-up issue. - getDailyOrderAggregates / getMedianOrderValue: revenue, orderCount and medianOrderValue now sum/percentile reportingTotalAmount restricted to reportingCurrency IS NOT NULL - one comparable currency, never a naive cross-currency sum. - The complementary unstamped slice (pre-#2049 history, or a stamp still in flight) is surfaced explicitly via new unconvertedCount/ unconvertedValue fields (native totalAmount, informational, may itself mix currencies) rather than silently folded into revenue or silently dropped. - New `currency` field (headline + per channel) reports which reporting currency revenue/AOV/median are expressed in; null when nothing in range is stamped yet. - cancelledCount/cancelledValue deliberately left on native totalAmount, unchanged - a secondary figure, out of scope for this pass. - Threaded through the pure aggregation function, the response DTOs, and every affected test fixture (repository, service, aggregation, controller specs). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
The page claimed ECB's historical endpoint was an unresolved Phase 1b blocker. That came from PR #2050's description, which described an earlier draft rather than what merged - the plan's ECB reference rates subsection in main is verified against the live API, and an independent re-verification reproduced every claim in it. Replaces the claim with the eight facts that re-verification did add, including the includeHistory + lastNObservations phantom-row bug now recorded on #2123. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
…urrency stamping (#2135) * docs(mockups): UI mockups for the order-time FX stamp surfaces Every surface ADR-040's reporting-currency stamp touches, built against the real design system (tokens transcribed from apps/web/src/index.css, primitives from apps/web/src/shared/ui/): the Platform/Currency settings tile in its three resolution states, the /analytics layout per the Design 1 'Ledger' cut, the orders list money cell, the order-detail audit panel, the two new job types, the invoicing boundary, and the five-state model behind every badge. Also records the four design decisions taken outside ADR-040 and the work breakdown across the six sub-issues. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(mockups): correct the ECB blocker claim in the work breakdown The page claimed ECB's historical endpoint was an unresolved Phase 1b blocker. That came from PR #2050's description, which described an earlier draft rather than what merged - the plan's ECB reference rates subsection in main is verified against the live API, and an independent re-verification reproduced every claim in it. Replaces the claim with the eight facts that re-verification did add, including the includeHistory + lastNObservations phantom-row bug now recorded on #2123. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(shared): add previousWorkingDay to the Polish working-day calendar The FX rate-date rule resolves a candidate calendar day back to a day NBP actually published on, which means walking backwards over Polish weekends and public holidays. `pl-working-days.ts` already owns that calendar but only counted forwards (`addWorkingDays`), so a caller would have had to re-implement it. `previousWorkingDay` mirrors `addWorkingDays` exactly - same Europe/Warsaw civil anchoring, same date-only UTC proxy cursor, same holiday set and weekend predicate, same wall-clock time-of-day preservation. The source instant is never counted; the walk starts from the previous day. Refs #2122 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ktirW7dvWqN42TJRMdwuD Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * test(shared): make the Warsaw-anchoring cases actually discriminate Both timezone tests picked instants where the UTC-anchored and Warsaw-anchored walks happen to agree, so neither could detect the anchoring being dropped. Replacing toWarsawCivil with plain getUTC* left both green. Swapped for instants where the two diverge - addWorkingDays now starts from an instant that is Sunday in UTC and Monday in Warsaw (2026-06-23 vs 2026-06-22), previousWorkingDay from one that is Friday in UTC and Saturday in Warsaw (2026-06-19 vs 2026-06-18). Both expectations verified by execution. Adds the two backwards cases the forward suite already had counterparts for: a walk crossing a year boundary (movable holidays rebuilt mid-walk) and the Wigilia/Christmas chain, the longest real run of non-working days. Also documents the composition a publication-calendar walk-back needs - previousWorkingDay always steps back, so resolving a candidate to the nearest working day at or before it requires guarding with isPlWorkingDay first. The NBP adapter in #2123 is the caller that would otherwise skip a valid publication day and stamp the wrong rate. Refs #2122 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(currency): add the currency context, rate port, registry and reporting-currency setting A new leaf core context owning everything about an order-time FX stamp that is not HTTP: the ExchangeRateProviderPort contract, the provider registry, the shared append-only exchange_rates registry, the pure rule -> rate-date and reporting-currency -> source derivations, and the system-level reporting-currency setting. The context imports no sibling core context and makes no outbound call, so the providers cannot live here - they ship in @openlinker/integrations-fx. That split is ADR-040 Decision 7 and is deliberately not conditioned on whether a source needs a credential today, so nothing moves packages if NBP or ECB adds a key. Three decisions worth calling out, because each has a plausible-looking wrong answer: - resolveRateDate is CALENDAR-NEUTRAL. It yields a candidate calendar day and knows about neither weekends nor any country's holidays; each adapter absorbs its own publication calendar. A shared Polish calendar would silently stale every ECB rate on a Polish-only holiday - ECB publishes on Corpus Christi and Epiphany, Poland does not, and the resulting figure is wrong by ~0.035% with no error anywhere. The today-in-Warsaw clamp is likewise load-bearing rather than defensive: a future endPeriod makes ECB answer with a months-stale rate at HTTP 200 and no signal of any kind. - Direction is an invariant. `rate` is the number of `to` units per one `from` unit, so a consumer always multiplies. An inverted or pivoted rate records its derivation NOT NULL - a direct rate stores {"kind":"direct","legs":[...]} - so the column is never a "sometimes populated" field and a derived figure stays auditable. - The rate registry is append-only BY CONSTRUCTION. The port declares only findByKey and insertIfAbsent; there is no update, upsert, delete, or save carrying an id. A stamped order points at a registry row as evidence, so an editable rate would make every figure derived from it unverifiable. A spec pins the absence, including that the single save() carries no id. The setting lives here rather than in orders because save-time coverage validation needs the provider list; putting it in orders would create an orders -> currency value dependency for validation alone. Validation is three layers and zero HTTP: ISO shape (400), reachability against SUPPORTED_REPORTING_CURRENCIES narrowed by the registered providers (422, the hard gate, a pure array test), and a coverage advisory that warns and never blocks - composed by the caller so no currency -> orders edge appears. CurrencyModule is a static @module, never forRoot: core and the fx package must resolve ONE registry instance, exactly as AdapterRegistryService does. The migration for exchange_rates and reporting_currency_setting is Phase 2 of the epic and is not in this change. Refs #2123 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(fx): add @openlinker/integrations-fx with the NBP and ECB rate adapters Both providers of ExchangeRateProviderPort, in a new workspace package, plus FxIntegrationModule which registers them into the core registry at boot - byte-for-byte the mechanism integration modules already use for AdapterRegistryService. Nothing in libs/core imports this package. It is NOT a plugin: no adapter manifest, no capability, no getCapabilityAdapter path. A published reference rate is a shared read of a public source, not a per-connection capability. The two adapters are near-mirror images and each is written around a trap the other does not have: NBP (quotes X -> PLN) owns the Polish working-day calendar. It resolves the calendar candidate to the nearest working day AT OR BEFORE it - `isPlWorkingDay(c) ? c : previousWorkingDay(c)`, never a bare previousWorkingDay, which always steps back at least one day and would skip a perfectly good publication day to stamp yesterday's rate. The 404 walk-back that follows is defence in depth, not the mechanism. Any non-404 4xx is terminal rather than just 400, since NBP's malformed-date response is documented but unverified. ECB (quotes EUR -> X) has no walk-back at all: endPeriod + lastNObservations=1 makes the API resolve "the last publication on or before this date" server-side, correct across clusters a walk-back-by-one gets wrong. includeHistory is deliberately never set - combined with lastNObservations=1 it injects a phantom ACTION=Delete row with an empty OBS_VALUE and an unrelated historical TIME_PERIOD. A non-publication day is a 200 with a ZERO-BYTE body, not a 404, so the body is length-checked before any parsing; a 404 means the series does not exist; a 400 returns HTML while 404/406 return problem+json, so a 4xx body is never JSON.parse'd. CSV columns are indexed by header name, never by position. A 10-day observation lag is asserted as a cheap backstop - the real maximum non-publication run is 4 days, so it can only fire on a clamp regression. ECB assigns no document identifier (header.id is a fresh UUID per request, Last-Modified is not data-dependent), so sourceRef persists an OpenLinker-constructed re-executable locator, ECB:EXR(1.0):<key>@<period>. That is stated in the code rather than passed off as an ECB reference. Both adapters take an injected FetchLike, so every spec fakes HTTP without touching globalThis and no tier makes a live call. The package is added to the outbound-http scan roots and the matching ESLint glob; the single exemption is the FX_FETCH_TOKEN default factory, where ADR-038's connection-bound transport is structurally unusable because it keys its cache and rate-limit bucket on connection.id and a reference-rate read has no connection. The @openlinker/* edges are declared in package.json, not only in tsconfig references - pnpm never reads tsconfig, and omitting the manifest edge lands the package in the same `pnpm -r` chunk as its sibling (#2011). Refs #2123 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * chore(hosts): register FxIntegrationModule in the api and worker plugin lists The binding crosses from the integration package into core at the host, so nothing in libs/core imports @openlinker/integrations-fx: the module is added to apiPlugins / workerPlugins, PluginRegistryModule.forRoot re-exports it, and its onModuleInit populates the core exchange-rate registry. The worker is the load-bearing registration - order ingestion and the FX retry / reconcile-sweep handlers all run there. The API is registered too, matching the dual registration WooCommerce, InPost, Subiekt and AI already have, so a future API-side restamp endpoint fails at boot rather than at runtime against an empty registry. Refs #2123 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(fx,currency): apply the #2123 review findings Three IMPORTANT findings and eight suggestions from the /pr-review pass. NBP errors named a pair the caller never requested. Every raise inside the fetch path reported the LEG currency rather than the requested pair, so fetchRate({from:'PLN',to:'EUR'}) failed as 'EUR/EUR' and a 503 on the same request logged as 'EUR/PLN'. A RateUnsupportedPairError is a terminal business_failure with no retry, so that log line is the only signal an operator gets. The requested from/to are now threaded through fetchQuotesForNearestPublishedDay -> tryFetchQuotesFor -> fetchQuote -> parseQuote, matching what the ECB adapter already did. The registry get-or-create had no integration test, which the issue's acceptance criteria and the plan's section 9 scenario 5 both require - and the plan states the concurrency claim is not unit-testable. The 23505 -> DuplicateExchangeRateError -> re-select chain was exercised only against a jest.fn() told to reject, so the real unique index, the real error code and what two concurrent callers observe were untested at every tier. Adds exchange-rate-registry.int-spec.ts covering byte-identical re-read, two concurrent calls resolving to one row, the domain error crossing the port boundary, and one row per distinct rate date. It stubs the transport under the real service, registry, adapter and repository rather than substituting a fake provider, so no network call is made. Both new tables join the harness truncate list. The registry's cost was understated. The pre-fetch read is keyed on the candidate day while the write is keyed on the published day, so a candidate that resolves by walk-back is never memoised and every order carrying it re-fetches - roughly 2 days in 7, not 'one extra call per candidate day'. The behaviour is correct (no duplicate row, no wrong-dated rate, no loop); only the claim was wrong. Header and comment now state it, and a spec pins that a weekend candidate re-fetches while a publication-day candidate does not. Memoising the candidate-to-published mapping needs its own table and is left to the persistence phase. Also: append-only source-text guard now blocks createQueryBuilder( and manager.; the ECB pivot uses allSettled with terminal-beats-transient precedence instead of all, whose rejection order was timing-dependent; both adapters use the exported RateDerivationKind instead of re-declaring the union; the NBP date formatter is hoisted to module scope; the MAX_OBSERVATION_LAG_DAYS boundary is pinned at 10 and 11 and its reason string names the reconcile sweep as the recovery route; NBP_TABLE_A_CURRENCIES explains why PLN heads a list of table-A rows; and the fake adapter's reset() restores its constructor seed instead of emptying the map. Refs #2123 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(fx): source-map integrations-fx for the integration harness Re-review caught three small things, one of which made the new int-spec unrunnable outside CI. @openlinker/integrations-fx entered both apps' plugin graphs without a moduleNameMapper pair in apps/api/test/jest-integration.cjs or the worker's, which check-jest-integration-mappers.mjs exists to catch (#916, #786). The package's main is ./dist/index.js, so in a fresh un-built worktree the new exchange-rate-registry int-spec - and every other apps/api and apps/worker int-spec - failed at module resolution. CI masked it by building dist first. The gap was not caught earlier because check:invariants is an && chain and check-repo-urls sits ahead of the mapper guard; its known failure on the untracked .worktrees directory short-circuited everything after it. Every check past that point has now been run individually and passes. Also drops a redundant `| null` from pickLegFailure's return type, which tripped no-redundant-type-constituents and failed pnpm lint, and a redundant type assertion on a query() result in the int-spec. Refs #2123 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(orders): persist the per-order FX snapshot columns and their stamp-once writes Adds the six nullable FX columns to `order_records` plus the DDL that #2123 deliberately deferred: this migration creates `exchange_rates` and `reporting_currency_setting` as well, so the three tables land as one schema unit. `reportingCurrency IS NULL` is the canonical "unstamped" test - `exchangeRateId` is legitimately NULL on the same-currency path, and `fxIntendedCurrency` is a separate column from `reportingCurrency` because an intent exists on a row that is still unstamped, which is also why the group CHECK's first arm deliberately omits `"fxRule" IS NULL`. Two conditional writes own the columns, both in the `claimWaybillRelay` shape (`IsNull()` in the WHERE, `affected > 0` as the answer): `claimFxIntentIfAbsent` pins the currency + rule at the first attempt, and `stampFxIfAbsent` writes all five stamp columns in one statement so the group cannot half-apply. `toOrm` maps none of the six - `upsert` is a full-row `save()` on an update-or-create ingestion path, so mapping them would let a re-poll write `null` over a reported financial figure; a regression spec asserts each key is absent from the entity passed to `save()`. `listDistinctNativeCurrencies` feeds the coverage advisory, reading `orderSnapshot.totals.currency` through the same `jsonb_typeof`-guarded form the migration's expression index uses. The group CHECK is verified by parsing the emitted constraint and evaluating it against all five legal FX states plus the illegal combinations, because nothing in CI runs a migration - the Testcontainers schema is built by `synchronize`, so no int-spec can observe the constraint. The live run/revert/run round-trip remains a manual gate. Refs #2124 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(architecture): document the Currency bounded context Adds a § 17 Currency section to docs/architecture-overview.md, the forward reference ADR-040 leaves open, and the `orders -> currency` edge to the cross-context dependency graph. The section records the reporting-currency resolution chain, the code-constant rate-source map, the multiply-never-divide direction invariant as a property of the stamp rather than of the consumer-neutral registry, the first-attempt intent snapshot and why provider availability must not become an input to a financial figure, the port-in-core / adapters-in-@openlinker/integrations-fx split with providers deliberately not being capability adapters, the calendar-neutral rate-date rule, and the five persisted states together with the two predicates a consumer gets wrong. It also states positively that the stamp is analytics-only and must never supply FA(3) `KursWaluty`: an earlier draft of the plan asserted the opposite, and the stamp differs from a statutory conversion on date, target and derivation, so leaving the reversal as an absence would leave the nearest persisted rate as the one a future implementation reaches for. Refs #2127 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(orders,worker): stamp orders in the reporting currency at ingestion Phase 3 of the order-time FX stamp (#2125, ADR-040). OrderFxStampService.stamp(internalOrderId) is the one seam every attempt goes through - the inline call from persistOrder, the marketplace.order.fxStamp retry job, and the hourly marketplace.order.fxStampSweep reconcile. One signature for all three: placedAt lives only in orderSnapshot JSONB and an unparseable value is silently dropped on rehydration, so two signatures would let the inline and retry paths disagree about whether it exists. The persisted intent (fxIntendedCurrency + fxRule) is read and pinned before anything else. A row that already carries one skips the settings service entirely; otherwise the resolved value is claimed with a conditional write and a losing concurrent attempt adopts the winner's. Without this an order degraded to the retry job could stamp a different currency than the same order stamped inline, making provider availability a silent input to a financial figure. Same-currency orders stamp with no rate lookup and no I/O. A converting order multiplies - ExchangeRate.rate is `to` units per one `from` unit by contract - and rounds with the house round2 idiom, never pricing-rule.types.ts's round2dp, which clamps negatives to zero and would turn a refund into a fact. The service never throws: every failure folds into a stamped/terminal/deferred outcome, so a rate provider being down cannot fail an ingestion that already persisted the order. A transient failure enqueues fx:{internalOrderId} in its own nested try/catch, logged distinctly from the stamp failure, because a lost enqueue leaves the hourly sweep as the only remaining route to a stamp. persistOrder collapses its two post-upsert writers - cancellation and the FX stamp - into one refresh. Each writer now reports whether it wrote rather than re-reading itself, so the returned record reflects both instead of the second writer's effect being silently dropped by the first's re-read. The sweep reads order_records directly on fxStampedAt IS NULL AND reportingCurrency IS NULL, scheduled hourly per OrderSource-capable connection - the guarantee that survives a dead retry job, since a job's idempotency key is globally unique with no TTL and the ~4.3h retry window means a longer outage would otherwise lose the stamp permanently. Refs #2125 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(api,orders): currency-settings API surface Phase 4 (backend half) of the order-time FX stamp (#2126, ADR-040). GET /currency-settings and PUT /currency-settings/reporting-currency, both admin-only, mirroring /ai-provider-settings' route naming and its withDomainExceptionMapping boundary split: the ISO-shape failure is 400, an unreachable-but-well-formed code is 422 carrying the accepted set. The coverage advisory and the stamped-row counts are composed in the controller, the one layer allowed to combine currency with orders - doing it inside CurrencyRateService would create a currency -> orders edge and cost that context its leaf property. IOrderFxReadService is the narrow cross-context seam: listDistinctNativeCurrencies (already published) plus the new countStampedByReportingCurrency, grouped by reporting currency rather than totalled because the era breakdown - not a bare total - is the operator-facing fact behind "changing this setting splits history." Refs #2126 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(web): Platform/Currency settings tile, env passthrough, guard coverage Completes Phase 4 (#2126, ADR-040) - the backend controller/DTOs/module and the orders-side aggregate read landed in an earlier commit; this finishes the frontend tile, the mandatory write-guard entry, the three .env.example files and the demo compose passthrough the issue also calls for. The tile is named Platform / Currency, not Analytics / Reporting currency. The value is a property of the deployment, not a setting owned by one module - analytics is merely its first consumer, and invoices compute their own rate and never read this. An Analytics eyebrow would under-claim and a title like Instance currency would over-claim, so scope lives in the body copy instead of the name. Renders three source states, not the plan's two: EUR (default), PLN (from env), and a bare PLN once an operator has saved a value. "Nobody has decided" and "an operator pinned this in configuration" are different facts and only one of them is a problem - source is already on the response, so the split costs nothing. The dialog's coverage-gap checkbox gates the Save button client-side rather than the backend rejecting an unacknowledged submit, matching ADR-040's warn-never-block contract: one junk currency in old order history must never make a legitimate reporting currency permanently unselectable. CurrencySettingsController is added to write-guard-coverage.spec.ts's CONTROLLERS - the issue calls this not optional, since a write endpoint absent from that list ships without guard coverage and nothing fails. OL_REPORTING_CURRENCY is documented in all three .env.example files (the worker one matters because it runs the retry job and the sweep) and passed through docker-compose.demo.yml, defaulting to PLN to match the demo shop's own currency. Refs #2126 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(orders): value-import OrderRecordRepositoryPort in OrderFxReadService An interface injected via @Inject on a decorated constructor parameter must be a value import, not import type — emitDecoratorMetadata needs the symbol resolvable per-file, and a type-only import can erase to a dangling reference under isolatedModules-style single-file transpilation (ts-jest, esbuild, swc). Same pattern already established for IIntegrationsService in invoice.service.ts and applied to OrderFxStampService's own constructor in an earlier commit on this branch — this was the one file the #2126 branch had not yet matched to it. Caught by pnpm -r lint's --fix pass. Refs #2126 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(docker): add libs/integrations/fx to the Dockerfile's manifest COPY lists The base and production stages hand-enumerate every @openlinker/* workspace package for layer-caching COPY, with the Dockerfile's own comment warning this is exactly the #1365 review class of bug: a package missing from the list makes pnpm install fail to resolve its workspace:* reference and breaks the image build. @openlinker/integrations-fx (#2123) was never added to any of the three lists (package.json x2, dist x1), so the demo/production image failed to build for the whole epic. Caught while booting the epic branch for E2E verification. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(demo): match OL_REPORTING_CURRENCY across api and worker services The final /pr-review pass caught it: only the api service's environment block set OL_REPORTING_CURRENCY: PLN. Order ingestion, the fxStamp retry job and the hourly reconcile sweep all run in the WORKER process, and ReportingCurrencySettingsService.resolve() falls back to this env var per-process before any settings row exists - so a fresh demo deployment would have silently stamped orders in EUR (the code-constant default) until an operator manually visited /currency-settings, contradicting the compose comment's own stated PLN intent. apps/worker/.env.example already names this exact hazard class for the api/worker pair generally; this carries the same reasoning into the demo compose file specifically. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(api): move the FX migration spec out of the TypeORM migrations glob Live E2E boot caught it immediately: data-source.ts's migrations glob (migrations/**/*{.ts,.js}) feeds every matched file straight into migration:run, so the colocated migrations/__tests__/1834000000000-add- order-fx-stamp.spec.ts was require()'d by the CLI itself and crashed on its first bare describe() - a jest global that does not exist in that ts-node process. `migrate` exited 1 on every boot; nothing in CI or the test harness runs a migration, so this was never exercised before now. Moved to database/__tests__/, beside data-source.ts (the file that owns the glob) and outside its reach; jest's repo-wide testRegex picks it up regardless of location, so no test-discovery change. Fixed the relative import to the migration class and left a note explaining why this specific directory, since it is the first migration to ship a colocated unit spec and the next one will want the same shape without the same landmine. Refs #2124 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(mockups): live E2E verification report for the FX stamp epic Boots the epic branch on a real stack, hand-verifies one live NBP-sourced conversion (19.99 EUR at 4.342 = 86.80 PLN), and documents the three deploy-only bugs a live boot found that no review pass could have: the Dockerfile's manifest COPY lists never learned about @openlinker/integrations-fx, OL_REPORTING_CURRENCY was set on the demo compose's api service but not the worker (the process that actually runs ingestion), and the migration's own unit spec crashed migration:run because TypeORM's CLI globs and require()s every file under migrations/ directly. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(web/currency-settings): stop showing a bare stamped-orders count on the tile `Stamped orders: 0` read as an alarm ("0 problems") instead of the coverage fact it is, and the per-currency grouping only ever produces a real breakdown when the deployment has changed its reporting currency before — otherwise it's one bucket, not a breakdown. Move it behind a secondary "Coverage" action with copy that explains what's being counted and why 0 is normal right after this ships. Signed-off-by: Norbert Kulus <norbert.kulus@blockydevs.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(orders): repair rebase merge-artifact regressions onto main Rebasing onto main's sales-document-block work (#2100) silently dropped CurrencyApiModule from app.module.ts's imports (import statement survived, array entry didn't), and shifted OrderRecord's constructor arg order so positional test calls needed 3 extra nulls for the salesDocument fields that now precede the FX fields. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(currency,sync): fix CI failures on FX rate snapshot PR The FX stamp sweep task's OL_ORDER_FX_STAMP_SWEEP_CRON key was missing from the scheduler spec's cron-key allowlist, so the mocked ConfigService fell through to 'true' for that key and CronJob rejected it ("Unknown alias: tru"), aborting onApplicationBootstrap and failing every other registered task's test in the suite. Separately, buildCoverage always set rateSource from resolveSourceKey regardless of whether a provider was actually registered, so an unregistered candidate reported a rateSource instead of null. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(currency,fx,orders): address the #2135 tech-lead review (2 IMPORTANT, 7 SUGGESTION) IMPORTANT 1 - a 429/408 was classified TERMINAL, and terminal was permanent. `isTransientFxStatus` (one choke point in `fx-http.client`) now folds 429 and 408 into the transient arm of both adapters: NBP and ECB are public unauthenticated APIs and the sweep's sequential page walk can earn a throttle unaided, which previously wrote the row's permanent `fxStampedAt` marker and cost those orders their reported figure. The review also asked for a recovery path, since `no-rate-source` had the same permanence: the sweep frontier gains a second OR'd arm that re-admits a terminal row whose marker has aged past a cooldown (payload `terminalRetryDays`, default 7) and that still carries NO figure. `markFxTerminalIfAbsent` becomes `markFxTerminal`, guarded on `reportingCurrency IS NULL` alone, so a re-answer moves the marker forward instead of the row being re-tried on every tick. The stamp itself stays immutable - `reportingCurrency IS NULL` sits in both frontier arms and in the marker guard. IMPORTANT 2 - no `User-Agent` on outbound FX requests. `FX_USER_AGENT` now identifies OpenLinker on every `fxGet`; undici sends no default UA at all, and this package deliberately carries no local retry loop to absorb a filter. SUGGESTIONS 3. `EcbExchangeRateAdapter` had no logger at all - added, with a debug on the resolved observation and on the empty-200 non-publication branch (the ECB analogue of NBP's walk-back line). The core terminal log now also carries the provider's own message, which previously died in the catch. 4. Migration header said the tail on `main` was `1833000000005`; it is `1833000000006`. Also records that slot `1834000000000` is uncontested. 5. `NBP_MAX_WALK_BACK_DAYS = 7` with a `<=` loop made 8 requests. Renamed to `NBP_MAX_WALK_BACK_ATTEMPTS = 8` with `<`, matching what the spec asserts. 6. ECB accepted a 10-day-stale observation silently. Above 3 days it is still accepted (4 is a real TARGET run) but WARN-logged. 7. `enqueueRetry`'s wave-less key is intended - documented why, and why #2039's `refreshSnapshot` fix does not apply (the sweep is an unconditional backstop). 8. A11y: `Alert` already carries `role="status"`, but a live region inserted together with its content is not reliably announced. The dialog now mounts one persistent visually-hidden region that names whichever warning materialised and the acknowledgement it requires. 9. `CurrencyModule.onApplicationBootstrap` refuses to finish boot with an empty provider registry (`NoExchangeRateProvidersRegisteredError`), converting two silent opposite degradations into one loud startup failure. Tests: 429/408 per adapter, the UA header + the transient-status table, the ECB staleness warn band on both sides of the threshold, the re-armable marker, the two-arm frontier, the sweep cooldown default/clamp/pass-through, the boot assertion, and four dialog live-region cases. `docs/architecture-overview.md` § Currency restates the sweep predicate. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * test(api/sync): keep the scheduler spec's cronKeys arrays alphabetical `OL_ORDER_FX_STAMP_SWEEP_CRON` was appended to the end of all four `cronKeys` arrays. Those arrays exist so a missing key cannot silently fall through to `'true'` and abort `onApplicationBootstrap` for every task, and they carry a comment telling the next author to register there - which only keeps its diff-scan value while the list is ordered. Sorts all four alphabetically (the pre-existing entries were unordered too, so sorting only the new key would not have restored the property), states the convention in the comment, and repeats the comment in the fourth array, which did not carry it. Test-only; `scheduler.service.spec.ts` passes 35/35. Refs #2135 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> --------- Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> Signed-off-by: Norbert Kulus <norbert.kulus@blockydevs.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2151) * feat(orders,analytics): add sales & channel aggregates endpoint (#1987) Adds GET /analytics/sales: revenue, order count, AOV, median order value, units sold, and cancelled count/value for a date range, plus a 7-day daily trend (revenue + order count), at headline level and broken down per source connection with a revenue share and a coverage-completeness signal (reusing #2083's getEarliestOrderDateByConnection so a channel that can't possibly cover the full requested range is identifiable in the response). Built entirely on top of the #1985 order analytics read model (order_records.placedAt/totalAmount/cancelledAt, order_line_items) - one new pure aggregation function in the orders domain layer, two new OrderRecordRepositoryPort methods (daily FILTER-clause aggregates, PERCENTILE_CONT median), one new OrderLineItemRepositoryPort method (units sold per connection), and one new IOrderRecordService method composing them - entirely intra-context, no new cross-context edge. Currency-mixing detection and gross/net tax-treatment normalization are deliberately out of scope - tracked under #2049/ADR-040 (currency) and a separate, not-yet-scoped tax-normalization effort respectively. totalAmount is summed as-is; this is called out explicitly in code comments so the omission reads as a scoping decision, not a gap. Includes the implementation plan doc this PR follows. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): report cancelled count/value per channel too (#1987) The issue's own follow-up comment asks for cancelled count/value "headline and, if feasible, per channel". It's feasible at zero extra cost: DailyOrderAggregateRow already carries cancelledCount/ cancelledValue per (day, connection), so this only sums data the existing query already returns - no new query, no new repository method. Adds ChannelSalesAnalytics.cancelledCount/cancelledValue, threads them through the aggregation function, the response DTO, and adds a test asserting the per-channel totals sum back to the headline figure. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): wire sales aggregates to reportingTotalAmount now that #2049 has landed This PR's own scope table deferred currency-mixing detection to #2049/ADR-040, summing totalAmount as-is. #2049 shipped (PR #2050) while this PR was still open, stamping order_records.reportingCurrency/ reportingTotalAmount - so the fix lands here rather than as a follow-up issue. - getDailyOrderAggregates / getMedianOrderValue: revenue, orderCount and medianOrderValue now sum/percentile reportingTotalAmount restricted to reportingCurrency IS NOT NULL - one comparable currency, never a naive cross-currency sum. - The complementary unstamped slice (pre-#2049 history, or a stamp still in flight) is surfaced explicitly via new unconvertedCount/ unconvertedValue fields (native totalAmount, informational, may itself mix currencies) rather than silently folded into revenue or silently dropped. - New `currency` field (headline + per channel) reports which reporting currency revenue/AOV/median are expressed in; null when nothing in range is stamped yet. - cancelledCount/cancelledValue deliberately left on native totalAmount, unchanged - a secondary figure, out of scope for this pass. - Threaded through the pure aggregation function, the response DTOs, and every affected test fixture (repository, service, aggregation, controller specs). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): label the unconverted-currency evidence per channel The by-channel table needs to show each market's own native-currency total for orders not yet FX-stamped, not just a single potentially mixed-currency number - the mockup this scope was designed against (03b · Two currencies) shows per-channel figures split by their own currency, with only the reporting-currency footer pooled. unconvertedCount/unconvertedValue already existed (#2049/ADR-040 follow-up) but carried no currency label and could legitimately mix currencies per the type's own doc comment. This is #1987's own scope, not an FX-epic deliverable: order_records.currency is the pre-existing native-currency column from #1985, untouched by the FX epic's reportingCurrency/reportingTotalAmount stamp - labelling the unconverted evidence is purely an aggregation-query addition. - getDailyOrderAggregates: adds unconverted_currency, the single native currency shared by every unconverted, non-cancelled order this day/connection, NULL when that set mixes currencies. - resolveUniformUnconvertedCurrency (aggregation layer): rolls the per-day label up to headline/channel, treating a day with zero unconverted orders as "nothing to report" rather than letting it poison the whole set to null. - Threaded through DailyOrderAggregateRow, SalesAnalyticsHeadline, ChannelSalesAnalytics, the response DTOs, and every affected test fixture (repository, service, aggregation, controller specs). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): guard mixed-currency labels + UTC day buckets (#1987 review) IMPORTANT 1: getDailyOrderAggregates labelled a (day, connection) bucket's revenue with (array_agg(reportingCurrency))[1] — the first value happened to sort first — even though reportingCurrency isn't guaranteed single-valued within a bucket (an in-flight #2096 restatement can leave two live at once). Guarded it with the same COUNT(DISTINCT ...) <= 1 pattern unconvertedCurrency already uses, and gave the domain-layer pickCurrency the matching cross-row disagreement check (resolveUniformReportingCurrency) rather than "first non-null wins". IMPORTANT 2: date_trunc('day', placedAt) truncates at local midnight per the Postgres session TimeZone GUC, since placedAt is timestamptz — on a non-UTC server every bucket would land on the wrong calendar day and silently mismatch enumerateDayKeys's UTC keys, zeroing every trend point beneath a correct headline. Made the boundary explicit: date_trunc('day', placedAt AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'. SUGGESTIONS: medianOrderValue no longer flattens "no stamped order in range" to the same 0 as a genuine zero median (now number | null, plumbed through the DTO); documented the units-vs-orderCount scoping mismatch on OrderLineItemRepositoryPort; added sales-analytics-aggregates.int-spec.ts against Testcontainers Postgres to pin both IMPORTANT fixes against a real server (the reviewer's own root-cause note: the mocked query builder could never have caught either). Also merges in the latest 1985-order-analytics-read-model (this PR's base branch), which had picked up its own review fixes since this branch last merged from it. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): make the UTC day-bucket int-spec actually guard the regression Testcontainers Postgres boots with session TimeZone = UTC, so the existing assertion passed identically with or without the AT TIME ZONE 'UTC' pair in getDailyOrderAggregates — it documented intent but couldn't fail on a regression (#2151 review, SUGGESTION). Force the session TimeZone to Europe/Warsaw for this one read (and restore it afterward), so a regression to a bare date_trunc('day', placedAt) actually flips the bucket to the following day and fails the test. SET TIME ZONE is session-scoped; dataSource.query and the repository read run back-to-back with nothing else contending for the pool, so the same just-released client is reused. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): address #2151 re-review — currency-era scoping, null flattening, units population - IMPORTANT 1: getUnitsSoldByConnection now splits into unitsSold/ unconvertedUnitsSold on the SAME reportingCurrency = current-era-stamped population orderCount/revenue use, instead of summing every non-cancelled line regardless of stamp state. - IMPORTANT 2: averageOrderValue and revenueShare now report null (not 0) when there is nothing to report, matching medianOrderValue's existing null-vs-zero distinction. - Notes (ported from #2172's getTopProductRanking fix): getDailyOrderAggregates and getMedianOrderValue now scope orderCount/revenue/median to reportingCurrency = currentReportingCurrency (resolved once per read via IReportingCurrencySettingsService.resolve()) instead of a bare IS NOT NULL, so a reporting-currency setting change folds prior-era stamps into the unconverted bucket rather than silently mixing two currencies into one sum. - Suggestion 3: GET /analytics/sales now rejects a range wider than 400 days. - Suggestion 4: unconverted_currency's uniformity guard now also fails when the unconverted set contains a row with no recorded native currency. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(orders,analytics): top-products endpoint with inline per-channel split (#1988) (#2172) * feat(orders,analytics): top-products endpoint with inline per-channel split (#1988) Adds GET /analytics/top-products - products ranked by revenue or units for a date range, each row carrying its own per-channel breakdown, catalog metadata, and a listing-coverage-gap flag. Stacked on #1987's currency- correctness pattern (FILTER (WHERE reportingCurrency IS NOT NULL) / SUM via each order's own implicit FX multiplier), never silently summing across currencies and always disclosing what's unstamped/cancelled. - OrderLineItemRepositoryPort +getTopProductRanking, +getProductChannelBreakdown - buildTopProducts pure aggregation + IOrderRecordService.getTopProducts - TopProductsController/DTOs + apps/api-layer TopProductsService composing orders + products + listings (coverage-gap flag, O(connections) fan-out, degrades gracefully on failure - mirrors NeedsAttentionService) - Fixes a pre-existing gap: order_line_items was missing from the integration-test harness's tablesToTruncate list (no DB FK to cascade from order_records), which would leak rows between test files Built following docs/plans/implementation-plan-top-products-analytics.md (pre-implement gate: READY, included in this PR). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1988) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. (cherry picked from commit 5a39290) Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(orders,analytics): disclose the native currency behind unconverted top-products evidence (#1988) getTopProductRanking already folded a prior reporting-currency era (or a never-stamped order) into unconvertedRevenue/unconvertedOrderCount, but gave the frontend no way to label that figure — unlike the #1987 by-channel read, which already carries unconvertedCurrency for the identical situation. Adds unconvertedCurrency end to end (repository SQL, ProductRankingRow, TopProductView, TopProductRowDto): the one native currency shared by every order contributing to unconvertedRevenue, or null when that set mixes currencies, mirroring DailyOrderAggregateRow's existing rule. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics,products): address #2172 review findings on top-products ranking Two IMPORTANT correctness issues and three SUGGESTIONS from the #2172 tech review, all still open on this branch: - IMPORTANT 1: ORDER BY revenue/units had no tiebreaker, so pagination over a non-unique sort was non-deterministic in Postgres (ties could repeat on one page and be skipped on the next). Add addOrderBy('product_id', 'ASC'). - IMPORTANT 2: a stamped order with totalAmount = 0 (fully discounted/free) silently vanished from both revenue and unconvertedRevenue, since the FX multiplier (reportingTotalAmount / totalAmount) is NULL via NULLIF(totalAmount, 0). It now folds into the unconverted bucket instead, same as a never-stamped order, in both getTopProductRanking and getProductChannelBreakdown. - SUGGESTION 3: documented, in the endpoint's @apioperation description, that ranking by revenue is blind to unconverted revenue for a product whose orders are all unstamped. - SUGGESTION 4: resolveCoverageGaps fired up to `limit` concurrent getVariantsByProductId calls. Added a batch getVariantsByProductIds (ProductVariantRepositoryPort -> IProductsService) so the page's variant ids resolve in one query instead of one per product. - SUGGESTION 5: a coverage-gap enrichment failure degraded every row to missingFromConnectionIds: [], indistinguishable from "listed everywhere". Added TopProductsResponseDto.coverageGapAvailable so the FE can tell the two apart. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): label unconvertedCurrency per channel on top-products (#2172 review) The ranking row's unconvertedRevenue gained a currency label in an earlier fix, but the per-channel breakdown row didn't, so ProductChannelBreakdownDto.unconvertedRevenue stayed a bare number with no unit. Inheriting the parent's label isn't sound either: the parent goes null on a mixed set, but an individual channel's own subset is routinely single-currency even then — a channel is strictly more labelable than the product as a whole, never less. Lifts the same MAX(currency) FILTER (...) / COUNT(DISTINCT ...) <= 1 shape getTopProductRanking already uses, computed per (product, connection) in getProductChannelBreakdown, threaded through ProductChannelBreakdownRow and ProductChannelBreakdownDto. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): address remaining #2172 review findings - applyTopProductsScope now requires rec."totalAmount" IS NOT NULL, matching applySalesAnalyticsScope; the doc comment no longer claims byte-for-byte alignment it didn't hold (IMPORTANT 1). - unconvertedCurrency's label guard now also requires zero NULL rec."currency" rows in the filtered set, since COUNT(DISTINCT ...) alone ignores NULLs and could mislabel a {NULL, 'PLN'} mix as 'PLN' (SUGGESTION 3, same fix needed on both getTopProductRanking and getProductChannelBreakdown). - TopProductRowDto.revenue now documents that it is LINE revenue, not a per-product slice of order revenue, and that ranking is blind to unconvertedRevenue (IMPORTANT 2). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): parenthesize unconvertedOrZeroTotal in top-products currency guard The unconverted_currency CASE guard concatenated the OR-joined unconvertedOrZeroTotal predicate with `AND rec."currency" IS NULL` without parentheses. Since SQL AND binds tighter than OR, this parsed as `A OR (B AND C)` instead of the intended `(A OR B) AND C`, so the "no NULL currency in the unconverted set" guard was satisfied whenever the reporting-currency mismatch term alone was true — the common case for any unstamped row — causing a single-currency channel/product to mislabel unconvertedCurrency as null. Same bug in both getTopProductRanking and getProductChannelBreakdown; fixed by wrapping the shared predicate in parens at both call sites. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(analytics): #1986 route shell — trust header, date-range toolbar, page (#2098) * docs(analytics): implementation plan for #1986 route shell Plan for the /analytics route shell (date-range control, trust header), branched off the current #1985 order-analytics-read-model state per the user's request, since two of its decisions (coverage-window row, degradation-banner rule) explicitly track #1985 and its follow-up #2083. Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics route shell — date-range toolbar, trust header (#2115) * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. 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> * fix(web): bump the lazy-route contract count to 52 for /settings/mcp-tokens An earlier merge (feat(mcp): Resource-Server auth via user-issued Personal Access Tokens, #1486/#1912) added the /settings/mcp-tokens page as a lazy route, but the parameterized route-lazy contract test's expected count was never bumped, failing CI on this branch with "expected 52 to be 51". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2098 tech review + trust-header single-line layout - Sync docs/plans/implementation-plan-analytics-page-shell.md with the now-resolved Decision 3 (real earliestOrderDate coverage row) and Decision 4 (hasSalesInRange dropped), and note the Reusable Components divergences. - Replace the analytics-date-range-toolbar's Tooltip-based "Order date" caveat with a Popover on a real <button>, matching AnalyticsTrustHeader's pattern — Radix Tooltip ignores pointerType === 'touch', making the old trigger unreachable on mobile. - Trust header renders a single-line "data from X · synced Y" fact string with a per-channel colored dot, replacing the prior two-column label/value layout; adds TimeDisplay's 'time' format and formatAbsoluteTime helper it depends on. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): drop dangling "data from" prefix on the no-orders-yet fact The "data from" prefix was rendered unconditionally, so a connection with no earliestOrderDate read "data from no orders yet" instead of just "no orders yet". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address remaining #2098 review findings - date-range.lib.ts: add toUtcRangeInstants — the single conversion point future /analytics/* consumers must use to turn this toolbar's local-day, inclusive from/to into the backend's UTC, to-exclusive range contract (SalesAnalyticsQueryDto.to). Fixes the inclusive/ exclusive and local/UTC mismatches flagged in the /pr-review pass, pinned with tests. - ingestion-trust.lib.ts: rename shouldShowDegradationBanner to selectDegradedConnections (it returns the degraded subset, not a boolean) and type DEGRADED_STATUSES as Set<ConnectionIngestionStatus> with a comment on why 'unknown' is deliberately excluded. - analytics-page.tsx: adopt PageLayout instead of hand-rolled page-header markup; document the frozen `today` ref decision. - Sync the implementation plan doc with all of the above. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#2120) * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): sales KPI strip + by-channel table (#1990) Adds GET /analytics/sales client, view-model helpers, and the two FE sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order value w/ median, Units, Cancellations, Returns & refunds) and a by-channel DataTable, mounted into the #1986 route shell. Currency-aware per #1987/#2049/ADR-040: every money figure carries its currency (headline.reportingCurrency), and a channel's revenueBasis ('reporting' | 'native' | 'unavailable') drives whether its revenue/ share render as plain values, a same-currency-but-incomparable caveat, or an explicit empty value — never a blended or falsely-comparable number. taxTreatment 'mixed' surfaces an inline chip so gross/net incomparability is stated, not implied. A channel whose earliest order postdates the range start renders a "Partial history" flag. Fixes an exclusive-end date bug found in a prior implementation attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but the endpoint treats `to` as exclusive — toExclusiveEndInstant converts it so the selected range's last day isn't silently dropped. Also fixes a pre-existing test race in orders-list-page.test.tsx: a synchronous assertion on empty-state text that depends on an async query, following an await on a chip that mounts synchronously from a URL param — now awaited with findByText. Closes #1990 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests CI's `tsc -b` (project-references build) caught what a plain `tsc --noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>` return type. Pin the generic on the never-resolving Promise, matching the existing analytics-trust test precedent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract The frontend types were drafted ahead of the backend and assumed a shape it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring (reportingTotalAmount stamp + unconvertedCurrency labelling) has been merged in, rewrite the frontend to match it exactly: one nullable system-wide currency, unconvertedCount/Value/Currency per channel, and revenueShare always a number. - Cancellations KPI now leads with the rate (%), value/count as qualifiers. - By-channel table: a channel with no FX-stamped revenue yet falls back to its own unconverted-currency evidence instead of showing an empty cell, flagged with an "Awaiting FX stamp" chip. - Total rows: one reporting-currency total (real KPI aggregate) plus one informational unconverted-currency subtotal per distinct native currency — only emitted when more than one channel contributes, so a lone channel never gets a redundant duplicate total. - Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now count every placed order (stamped + unconverted), not just the stamped subset. - Share and Trend columns reordered so Share sits immediately before Trend (previously Share was misplaced next to Revenue). - Fixed a CSS specificity bug where the Phase-6 dashboard-triage `.status-strip` rule silently won over `.status-strip--analytics` at >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * docs(analytics): implementation plan for /analytics needs-attention section Plans issue #1989 — three actionable categories (coverage gaps, stock at risk, failed-sync value) consuming the already-shipped GET /analytics/needs-attention (#1983), mounted into the #1986 shell. No backend changes; resolves link targets, the mixedCurrency interim (tracked against #2049), and the ambiguous multi-connection copy case. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#1989) Renders the three needs-attention categories — coverage gaps, stock at risk, value stuck in failed syncs — mounted into the #1986 shell. Consumes the already-shipped GET /analytics/needs-attention (#1983) as-is; no backend changes. Either the open rows render or a single all-clear line does, never both, per the design mockup's rule. Each open row deep-links into the flow that resolves it: the unified publish wizard, the product detail page, or the orders list filtered to the needs_attention health bucket. Ambiguous multi-connection cases fall back to a connection-agnostic headline; the failed-sync total renders currency-neutral since the DTO carries no currency field in either the mixed or non-mixed case (interim pending #2049). Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): match needs-attention section to the #2003 mockup The plan (implementation-plan-analytics-needs-attention.md) required a client-side "checked HH:MM" timestamp in the panel header and a neutral-tone Clear badge, mirroring frame 02 of the design mockup (docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018 branch). Both were dropped in the original implementation. Adds the checked-at timestamp (TimeDisplay driven by the query's own dataUpdatedAt, since the DTO carries no such field) and switches the all-clear badge from success to neutral, per spec. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture Rebase fallout from the earliestOrderDate swap (#2083): the #1989-cherry-picked "keep the trust header rendered when needs-attention fails" test fixture predates that field and failed type-check. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 tech review — sample-vs-total headline defect - BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a connection when the preview sample IS the total (items.length === totalCount); otherwise fall through to the connection-agnostic headline, so a headline never asserts something only a 20-item sample verified. - "Publish now" sub now discloses when it only seeds the sampled variants ("showing the first N of M"). - Fix the "1 variant have a listing gap" grammar bug to a verb-free form, updating the test that had locked it in. - Thread a BCP 47 locale into deriveFailedSyncHeadline instead of hardcoding toLocaleString('en-US'). - Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds from the same item list instead of two independently sliced arrays. - Render AnalyticsNeedsAttention regardless of order-ingestion status — coverage gaps and stock-at-risk are listing facts, not order facts. - Render .attention-list as <ul>/<li> for list semantics. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide - toExclusiveEndInstant now anchors on UTC midnight instead of local midnight, matching the controller's UTC-parsed `from` (was silently dropping/adding hours off UTC). - Sparkline aria-labels derive from the actual selected range instead of a hardcoded "last 7 days". - Register the analytics KPI card's 152px/3-col geometry as a documented carve-out in the style guide (Density table + parity matrix), per the "never introduce an undocumented row height" rule. - Fix "Data order" planned-tag typo -> "Planned"; section-infotip font-size to the rem token equivalent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): emit currency total for single-contributing-channel groups groupChannelTotalsByCurrency skipped a currency's Total row whenever only one channel contributed to it, so a deployment with exactly one connection per currency (e.g. one EUR shop) silently lost that row. No spec basis for the threshold — the by-channel currency total should render for every distinct currency present, regardless of how many channels contribute. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 re-review — deep-link connection resolution Reuses deriveCoverageHeadline's own connection resolution for the coverage deep link's connectionId param instead of re-deriving it with a weaker predicate, so the bulk-wizard link can never name a channel the headline declined to name. Also derives the all-clear checkedCount from the evaluated categories rather than a hardcoded literal. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import in sales-analytics.api.test.ts Pre-existing lint error surfaced while validating the 1986 merge — vi was imported but never used in this file. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop rendering a currency-neutral total on the failed-sync row `deriveFailedSyncHeadline` formatted `totalValue` with no currency symbol ("6,120.64 of orders never reached a destination"), which reads as a real monetary figure to an operator even though the DTO carries no currency at all — the same misrepresentation risk the mixedCurrency branch already guarded against, just less obviously so. Both branches now render the same count-only shape; `totalValue` stays on the wire for future consumers, this headline just stops reading it. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop rendering a duplicate/colliding unconverted Total row groupChannelTotalsByCurrency emitted a `Total · {currency} (unconverted)` row per distinct unconvertedCurrency found across channels, with no regard for whether that currency string collided with the real reporting-currency Total row's label — a domestic-currency channel simply awaiting its first FX-stamp pass produced a second, same-labelled "Total · PLN" row computed from unrelated fields, reading as a contradiction rather than two distinct facts. Drop that row entirely. countUnconvertedOrders reports the currency-agnostic total count as a single footnote sentence under the table instead ("N orders not yet converted to the reporting currency — excluded from the figures above"), never a competing Total row and never a bare currency-neutral amount. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop the coverage deep-link from naming a channel the headline declined to deriveCoverageHeadline's connection-naming rule requires items.length === totalCount, every item missing from exactly one connection, and one distinct id. The "Publish now" deep link recomputed its own, weaker predicate (only the last condition), so a partial-but-uniform sample could pin a connectionId into the wizard link while the headline right next to it correctly fell back to the connection-agnostic copy — sending the operator into a wizard pre-scoped to a channel the row never actually asserted (#2120 re-review, IMPORTANT). deriveCoverageHeadline now returns connectionId (string | null) alongside the copy, computed by the same predicate; the component reads it instead of re-deriving one. Added a regression test pinning the exact partial-sample/uniform-connection case from the review. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): top products table with per-channel breakdown (#1991) (#2191) * feat(web,analytics): top products table with per-channel breakdown (#1991) Adds the /analytics top-products table: one row per product, per-channel units split, revenue/units sort toggle, and a Publish affordance for channels the product isn't listed on. Fixes a labeling gap found while manually testing against seeded data: a channel absent from the sales breakdown was always rendered "Not listed", even when the product was genuinely listed there and simply had no sale in the selected date range — now only a channel actually missing from `missingFromConnectionIds` gets the "Not listed" + Publish treatment; a listed-but-quiet channel renders the same real, full-weight `0` a channel with sales would. Closes #1991 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)" This reverts commit 5a39290. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991) A product whose only orders in range were stamped under a PREVIOUS reporting-currency setting (or never stamped at all) rendered a bare "No FX-stamped order" empty value, even though the backend already exposed the native-currency figure as unconvertedRevenue/unconvertedCurrency (#1988). The Revenue column now falls back to that evidence when there is no current-era stamp, marked informational via a title tooltip — mirroring ChannelSalesTable's identical fallback for the #1987 by-channel read. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(web): remove unused vi import breaking tsc build" This reverts commit b915a03. * fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug - Units column now reads row.units (server-ranked figure) instead of re-summing row.channels[], which could silently disagree with the sort order the header arrow claims. - The Publish action is gated on listings:write via useWriteAccess + ReadOnlyLock: hidden for an unauthorized non-demo session, rendered disabled with the read-only tooltip for a demo viewer. - Swapped the Chip (aria-pressed toggle) for a real Link styled as a button, so the one-shot publish navigation carries link semantics (middle-click, open-in-new-tab) instead of misrepresenting itself as a permanently-unpressed toggle to assistive tech. - @media (hover: none) now stacks the "Not listed" label and the Publish action, both visible, instead of hiding the label on touch — the #1991 AC's label-vs-action distinction was desktop-only before. - Added the analytics feature slug to both no-restricted-imports pattern groups in .eslintrc.js. Closes review findings on #2191 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): SQL precedence bug + surface coverageGapAvailable/unresolvedProductCount (#2172/#2191 review) Root cause of the failing top-products-ranking int-spec: `unconvertedOrZeroTotal` was a bare, unparenthesized `X OR Y` string spliced into `${unconvertedOrZeroTotal} AND rec."currency" IS NULL`. SQL's AND-before-OR precedence turned that into `X OR (Y AND Z)` instead of the intended `(X OR Y) AND Z` — since X (reportingCurrency mismatch) was true for nearly every unstamped row, the guard fired unconditionally and `unconverted_currency` fell to NULL far more often than the data warranted. Fixed by parenthesizing the constant at its definition (both getTopProductRanking and getProductChannelBreakdown); pinned by the existing int-spec against real Postgres (a mocked unit spec cannot observe operator precedence) and recorded in docs/lessons.md. Also addresses the two still-open review IMPORTANT findings on the FE table: - `coverageGapAvailable: false` now suppresses "Not listed"/Publish on every channel cell (the enrichment failure makes missingFromConnectionIds unreliable for the whole response), rendering the real 0 instead, with a footnote explaining the check is unavailable. - `unresolvedProductCount > 0` is now disclosed via a footnote rather than silently absorbed. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(analytics): period-over-period delta on the sales KPI strip Adds a "vs previous period" delta to Orders, Order value, Units and Cancellation rate on the /analytics KPI strip — a second GET /analytics/sales call over the immediately-preceding period of the same length, refused outright (GapMark) unless the entire previous window is covered by ingested order history (per-connection earliest-order-date, #2083). Matches the design mockup's delta anatomy (docs/plans/mockups/ analytics-ledger-2003.html): an aria-hidden ↑/↓/→ glyph, a sr-only spoken sentence, and count/amount deltas rendered as a relative "%" while rate deltas (cancellation rate) render as an absolute "pp" — a rate moves in points, not percent. Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): mount top-products table and reveal Publish on hover ProductSalesTable (#1991) was fully built end-to-end but never mounted on AnalyticsPage, so the top-products section never rendered. Also add the .cell-not-listed hover/focus CSS the component's own doc comment already described but that was never written — the "Not listed" label now swaps for a Publish action on hover/focus (with a light warning-yellow glow), staying permanently visible on touch pointers. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> * fix(web,analytics): make GapMark's caveat reachable without a mouse A native `title` on a non-interactive, non-focusable `<span>` whose entire content is a dagger glyph never surfaces for a keyboard user, and a screen reader has no accessible name to announce beyond "dagger". Add role="img" + aria-label={title} alongside the existing title so the caveat is announced regardless of input modality (#2120 review). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * docs(orders): explain why getTopProducts reads are sequential, not parallel getProductChannelBreakdown is scoped to the current page's productIds, which only exist once getTopProductRanking has returned — unlike the three independent Promise.all reads in getSalesAndChannelAnalytics above it. Note the distinction so a reader doesn't mistake the missing parallelisation for an oversight (#2172 review, SUGGESTION 2). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…s reporting (#2440) (#2442) * feat(orders,analytics): add sales & channel aggregates endpoint (#1987) Adds GET /analytics/sales: revenue, order count, AOV, median order value, units sold, and cancelled count/value for a date range, plus a 7-day daily trend (revenue + order count), at headline level and broken down per source connection with a revenue share and a coverage-completeness signal (reusing #2083's getEarliestOrderDateByConnection so a channel that can't possibly cover the full requested range is identifiable in the response). Built entirely on top of the #1985 order analytics read model (order_records.placedAt/totalAmount/cancelledAt, order_line_items) - one new pure aggregation function in the orders domain layer, two new OrderRecordRepositoryPort methods (daily FILTER-clause aggregates, PERCENTILE_CONT median), one new OrderLineItemRepositoryPort method (units sold per connection), and one new IOrderRecordService method composing them - entirely intra-context, no new cross-context edge. Currency-mixing detection and gross/net tax-treatment normalization are deliberately out of scope - tracked under #2049/ADR-040 (currency) and a separate, not-yet-scoped tax-normalization effort respectively. totalAmount is summed as-is; this is called out explicitly in code comments so the omission reads as a scoping decision, not a gap. Includes the implementation plan doc this PR follows. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): report cancelled count/value per channel too (#1987) The issue's own follow-up comment asks for cancelled count/value "headline and, if feasible, per channel". It's feasible at zero extra cost: DailyOrderAggregateRow already carries cancelledCount/ cancelledValue per (day, connection), so this only sums data the existing query already returns - no new query, no new repository method. Adds ChannelSalesAnalytics.cancelledCount/cancelledValue, threads them through the aggregation function, the response DTO, and adds a test asserting the per-channel totals sum back to the headline figure. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * docs(analytics): implementation plan for #1986 route shell Plan for the /analytics route shell (date-range control, trust header), branched off the current #1985 order-analytics-read-model state per the user's request, since two of its decisions (coverage-window row, degradation-banner rule) explicitly track #1985 and its follow-up #2083. Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): wire sales aggregates to reportingTotalAmount now that #2049 has landed This PR's own scope table deferred currency-mixing detection to #2049/ADR-040, summing totalAmount as-is. #2049 shipped (PR #2050) while this PR was still open, stamping order_records.reportingCurrency/ reportingTotalAmount - so the fix lands here rather than as a follow-up issue. - getDailyOrderAggregates / getMedianOrderValue: revenue, orderCount and medianOrderValue now sum/percentile reportingTotalAmount restricted to reportingCurrency IS NOT NULL - one comparable currency, never a naive cross-currency sum. - The complementary unstamped slice (pre-#2049 history, or a stamp still in flight) is surfaced explicitly via new unconvertedCount/ unconvertedValue fields (native totalAmount, informational, may itself mix currencies) rather than silently folded into revenue or silently dropped. - New `currency` field (headline + per channel) reports which reporting currency revenue/AOV/median are expressed in; null when nothing in range is stamped yet. - cancelledCount/cancelledValue deliberately left on native totalAmount, unchanged - a secondary figure, out of scope for this pass. - Threaded through the pure aggregation function, the response DTOs, and every affected test fixture (repository, service, aggregation, controller specs). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): sales KPI strip + by-channel table (#1990) Adds GET /analytics/sales client, view-model helpers, and the two FE sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order value w/ median, Units, Cancellations, Returns & refunds) and a by-channel DataTable, mounted into the #1986 route shell. Currency-aware per #1987/#2049/ADR-040: every money figure carries its currency (headline.reportingCurrency), and a channel's revenueBasis ('reporting' | 'native' | 'unavailable') drives whether its revenue/ share render as plain values, a same-currency-but-incomparable caveat, or an explicit empty value — never a blended or falsely-comparable number. taxTreatment 'mixed' surfaces an inline chip so gross/net incomparability is stated, not implied. A channel whose earliest order postdates the range start renders a "Partial history" flag. Fixes an exclusive-end date bug found in a prior implementation attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but the endpoint treats `to` as exclusive — toExclusiveEndInstant converts it so the selected range's last day isn't silently dropped. Also fixes a pre-existing test race in orders-list-page.test.tsx: a synchronous assertion on empty-state text that depends on an async query, following an await on a chip that mounts synchronously from a URL param — now awaited with findByText. Closes #1990 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(orders,analytics): top-products endpoint with inline per-channel split (#1988) Adds GET /analytics/top-products - products ranked by revenue or units for a date range, each row carrying its own per-channel breakdown, catalog metadata, and a listing-coverage-gap flag. Stacked on #1987's currency- correctness pattern (FILTER (WHERE reportingCurrency IS NOT NULL) / SUM via each order's own implicit FX multiplier), never silently summing across currencies and always disclosing what's unstamped/cancelled. - OrderLineItemRepositoryPort +getTopProductRanking, +getProductChannelBreakdown - buildTopProducts pure aggregation + IOrderRecordService.getTopProducts - TopProductsController/DTOs + apps/api-layer TopProductsService composing orders + products + listings (coverage-gap flag, O(connections) fan-out, degrades gracefully on failure - mirrors NeedsAttentionService) - Fixes a pre-existing gap: order_line_items was missing from the integration-test harness's tablesToTruncate list (no DB FK to cascade from order_records), which would leak rows between test files Built following docs/plans/implementation-plan-top-products-analytics.md (pre-implement gate: READY, included in this PR). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests CI's `tsc -b` (project-references build) caught what a plain `tsc --noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>` return type. Pin the generic on the never-resolving Promise, matching the existing analytics-trust test precedent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): top products table with per-channel breakdown (#1991) Adds the /analytics top-products table: one row per product, per-channel units split, revenue/units sort toggle, and a Publish affordance for channels the product isn't listed on. Fixes a labeling gap found while manually testing against seeded data: a channel absent from the sales breakdown was always rendered "Not listed", even when the product was genuinely listed there and simply had no sale in the selected date range — now only a channel actually missing from `missingFromConnectionIds` gets the "Not listed" + Publish treatment; a listed-but-quiet channel renders the same real, full-weight `0` a channel with sales would. Closes #1991 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): label the unconverted-currency evidence per channel The by-channel table needs to show each market's own native-currency total for orders not yet FX-stamped, not just a single potentially mixed-currency number - the mockup this scope was designed against (03b · Two currencies) shows per-channel figures split by their own currency, with only the reporting-currency footer pooled. unconvertedCount/unconvertedValue already existed (#2049/ADR-040 follow-up) but carried no currency label and could legitimately mix currencies per the type's own doc comment. This is #1987's own scope, not an FX-epic deliverable: order_records.currency is the pre-existing native-currency column from #1985, untouched by the FX epic's reportingCurrency/reportingTotalAmount stamp - labelling the unconverted evidence is purely an aggregation-query addition. - getDailyOrderAggregates: adds unconverted_currency, the single native currency shared by every unconverted, non-cancelled order this day/connection, NULL when that set mixes currencies. - resolveUniformUnconvertedCurrency (aggregation layer): rolls the per-day label up to headline/channel, treating a day with zero unconverted orders as "nothing to report" rather than letting it poison the whole set to null. - Threaded through DailyOrderAggregateRow, SalesAnalyticsHeadline, ChannelSalesAnalytics, the response DTOs, and every affected test fixture (repository, service, aggregation, controller specs). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract The frontend types were drafted ahead of the backend and assumed a shape it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring (reportingTotalAmount stamp + unconvertedCurrency labelling) has been merged in, rewrite the frontend to match it exactly: one nullable system-wide currency, unconvertedCount/Value/Currency per channel, and revenueShare always a number. - Cancellations KPI now leads with the rate (%), value/count as qualifiers. - By-channel table: a channel with no FX-stamped revenue yet falls back to its own unconverted-currency evidence instead of showing an empty cell, flagged with an "Awaiting FX stamp" chip. - Total rows: one reporting-currency total (real KPI aggregate) plus one informational unconverted-currency subtotal per distinct native currency — only emitted when more than one channel contributes, so a lone channel never gets a redundant duplicate total. - Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now count every placed order (stamped + unconverted), not just the stamped subset. - Share and Trend columns reordered so Share sits immediately before Trend (previously Share was misplaced next to Revenue). - Fixed a CSS specificity bug where the Phase-6 dashboard-triage `.status-strip` rule silently won over `.status-strip--analytics` at >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics route shell — date-range toolbar, trust header (#2115) * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. 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> * docs(analytics): implementation plan for /analytics needs-attention section Plans issue #1989 — three actionable categories (coverage gaps, stock at risk, failed-sync value) consuming the already-shipped GET /analytics/needs-attention (#1983), mounted into the #1986 shell. No backend changes; resolves link targets, the mixedCurrency interim (tracked against #2049), and the ambiguous multi-connection copy case. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#1989) Renders the three needs-attention categories — coverage gaps, stock at risk, value stuck in failed syncs — mounted into the #1986 shell. Consumes the already-shipped GET /analytics/needs-attention (#1983) as-is; no backend changes. Either the open rows render or a single all-clear line does, never both, per the design mockup's rule. Each open row deep-links into the flow that resolves it: the unified publish wizard, the product detail page, or the orders list filtered to the needs_attention health bucket. Ambiguous multi-connection cases fall back to a connection-agnostic headline; the failed-sync total renders currency-neutral since the DTO carries no currency field in either the mixed or non-mixed case (interim pending #2049). Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): match needs-attention section to the #2003 mockup The plan (implementation-plan-analytics-needs-attention.md) required a client-side "checked HH:MM" timestamp in the panel header and a neutral-tone Clear badge, mirroring frame 02 of the design mockup (docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018 branch). Both were dropped in the original implementation. Adds the checked-at timestamp (TimeDisplay driven by the query's own dataUpdatedAt, since the DTO carries no such field) and switches the all-clear badge from success to neutral, per spec. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture Rebase fallout from the earliestOrderDate swap (#2083): the #1989-cherry-picked "keep the trust header rendered when needs-attention fails" test fixture predates that field and failed type-check. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1988) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. (cherry picked from commit 5a39290a44878d1f6a45d405f75a2ad8cc9fbe9f) Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)" This reverts commit 5a39290a44878d1f6a45d405f75a2ad8cc9fbe9f. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(orders,analytics): disclose the native currency behind unconverted top-products evidence (#1988) getTopProductRanking already folded a prior reporting-currency era (or a never-stamped order) into unconvertedRevenue/unconvertedOrderCount, but gave the frontend no way to label that figure — unlike the #1987 by-channel read, which already carries unconvertedCurrency for the identical situation. Adds unconvertedCurrency end to end (repository SQL, ProductRankingRow, TopProductView, TopProductRowDto): the one native currency shared by every order contributing to unconvertedRevenue, or null when that set mixes currencies, mirroring DailyOrderAggregateRow's existing rule. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991) A product whose only orders in range were stamped under a PREVIOUS reporting-currency setting (or never stamped at all) rendered a bare "No FX-stamped order" empty value, even though the backend already exposed the native-currency figure as unconvertedRevenue/unconvertedCurrency (#1988). The Revenue column now falls back to that evidence when there is no current-era stamp, marked informational via a title tooltip — mirroring ChannelSalesTable's identical fallback for the #1987 by-channel read. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): guard mixed-currency labels + UTC day buckets (#1987 review) IMPORTANT 1: getDailyOrderAggregates labelled a (day, connection) bucket's revenue with (array_agg(reportingCurrency))[1] — the first value happened to sort first — even though reportingCurrency isn't guaranteed single-valued within a bucket (an in-flight #2096 restatement can leave two live at once). Guarded it with the same COUNT(DISTINCT ...) <= 1 pattern unconvertedCurrency already uses, and gave the domain-layer pickCurrency the matching cross-row disagreement check (resolveUniformReportingCurrency) rather than "first non-null wins". IMPORTANT 2: date_trunc('day', placedAt) truncates at local midnight per the Postgres session TimeZone GUC, since placedAt is timestamptz — on a non-UTC server every bucket would land on the wrong calendar day and silently mismatch enumerateDayKeys's UTC keys, zeroing every trend point beneath a correct headline. Made the boundary explicit: date_trunc('day', placedAt AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'. SUGGESTIONS: medianOrderValue no longer flattens "no stamped order in range" to the same 0 as a genuine zero median (now number | null, plumbed through the DTO); documented the units-vs-orderCount scoping mismatch on OrderLineItemRepositoryPort; added sales-analytics-aggregates.int-spec.ts against Testcontainers Postgres to pin both IMPORTANT fixes against a real server (the reviewer's own root-cause note: the mocked query builder could never have caught either). Also merges in the latest 1985-order-analytics-read-model (this PR's base branch), which had picked up its own review fixes since this branch last merged from it. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): bump the lazy-route contract count to 52 for /settings/mcp-tokens An earlier merge (feat(mcp): Resource-Server auth via user-issued Personal Access Tokens, #1486/#1912) added the /settings/mcp-tokens page as a lazy route, but the parameterized route-lazy contract test's expected count was never bumped, failing CI on this branch with "expected 52 to be 51". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics,products): address #2172 review findings on top-products ranking Two IMPORTANT correctness issues and three SUGGESTIONS from the #2172 tech review, all still open on this branch: - IMPORTANT 1: ORDER BY revenue/units had no tiebreaker, so pagination over a non-unique sort was non-deterministic in Postgres (ties could repeat on one page and be skipped on the next). Add addOrderBy('product_id', 'ASC'). - IMPORTANT 2: a stamped order with totalAmount = 0 (fully discounted/free) silently vanished from both revenue and unconvertedRevenue, since the FX multiplier (reportingTotalAmount / totalAmount) is NULL via NULLIF(totalAmount, 0). It now folds into the unconverted bucket instead, same as a never-stamped order, in both getTopProductRanking and getProductChannelBreakdown. - SUGGESTION 3: documented, in the endpoint's @ApiOperation description, that ranking by revenue is blind to unconverted revenue for a product whose orders are all unstamped. - SUGGESTION 4: resolveCoverageGaps fired up to `limit` concurrent getVariantsByProductId calls. Added a batch getVariantsByProductIds (ProductVariantRepositoryPort -> IProductsService) so the page's variant ids resolve in one query instead of one per product. - SUGGESTION 5: a coverage-gap enrichment failure degraded every row to missingFromConnectionIds: [], indistinguishable from "listed everywhere". Added TopProductsResponseDto.coverageGapAvailable so the FE can tell the two apart. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): make the UTC day-bucket int-spec actually guard the regression Testcontainers Postgres boots with session TimeZone = UTC, so the existing assertion passed identically with or without the AT TIME ZONE 'UTC' pair in getDailyOrderAggregates — it documented intent but couldn't fail on a regression (#2151 review, SUGGESTION). Force the session TimeZone to Europe/Warsaw for this one read (and restore it afterward), so a regression to a bare date_trunc('day', placedAt) actually flips the bucket to the following day and fails the test. SET TIME ZONE is session-scoped; dataSource.query and the repository read run back-to-back with nothing else contending for the pool, so the same just-released client is reused. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): label unconvertedCurrency per channel on top-products (#2172 review) The ranking row's unconvertedRevenue gained a currency label in an earlier fix, but the per-channel breakdown row didn't, so ProductChannelBreakdownDto.unconvertedRevenue stayed a bare number with no unit. Inheriting the parent's label isn't sound either: the parent goes null on a mixed set, but an individual channel's own subset is routinely single-currency even then — a channel is strictly more labelable than the product as a whole, never less. Lifts the same MAX(currency) FILTER (...) / COUNT(DISTINCT ...) <= 1 shape getTopProductRanking already uses, computed per (product, connection) in getProductChannelBreakdown, threaded through ProductChannelBreakdownRow and ProductChannelBreakdownDto. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2098 tech review + trust-header single-line layout - Sync docs/plans/implementation-plan-analytics-page-shell.md with the now-resolved Decision 3 (real earliestOrderDate coverage row) and Decision 4 (hasSalesInRange dropped), and note the Reusable Components divergences. - Replace the analytics-date-range-toolbar's Tooltip-based "Order date" caveat with a Popover on a real <button>, matching AnalyticsTrustHeader's pattern — Radix Tooltip ignores pointerType === 'touch', making the old trigger unreachable on mobile. - Trust header renders a single-line "data from X · synced Y" fact string with a per-channel colored dot, replacing the prior two-column label/value layout; adds TimeDisplay's 'time' format and formatAbsoluteTime helper it depends on. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 tech review — sample-vs-total headline defect - BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a connection when the preview sample IS the total (items.length === totalCount); otherwise fall through to the connection-agnostic headline, so a headline never asserts something only a 20-item sample verified. - "Publish now" sub now discloses when it only seeds the sampled variants ("showing the first N of M"). - Fix the "1 variant have a listing gap" grammar bug to a verb-free form, updating the test that had locked it in. - Thread a BCP 47 locale into deriveFailedSyncHeadline instead of hardcoding toLocaleString('en-US'). - Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds from the same item list instead of two independently sliced arrays. - Render AnalyticsNeedsAttention regardless of order-ingestion status — coverage gaps and stock-at-risk are listing facts, not order facts. - Render .attention-list as <ul>/<li> for list semantics. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): drop dangling "data from" prefix on the no-orders-yet fact The "data from" prefix was rendered unconditionally, so a connection with no earliestOrderDate read "data from no orders yet" instead of just "no orders yet". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * chore(tax): open the per-line tax rate epic branch Tracking branch for epic #2245. Children merge here; this branch merges to main only when every child has landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(tax): ADR-052 per-line tax-rate resolution, provenance and rounding ownership Records the rule the #2245 epic implements: the rate arrives from the ProductMaster with the product, the marketplace is a fallback when the master does not know, and when neither knows the document is held rather than guessed. OpenLinker never computes a rate and never computes an amount excluding tax. Seven decisions: the shop-then-channel resolution chain, three answer states (0 is an answer, 'not yet checked' is a fourth read state that suggests a sync rather than blocking), percent-as-string representation with provenance-only country, projection-at-sync storage with the order snapshot as the only place a rate is settled, adapter-owned rounding, gate semantics (a missing rate blocks and also refuses the manual paths; a shop-versus-channel mismatch does not block and is not a SalesDocumentGateBlockReason at all), and the non-goals. Numbered 052, not the 050 the issue names: 050 and 051 are reserved by the ADR README for the #2162 async-work-layer epic and the index directs the next new ADR to 052. Also settles the two edits ADR-014's amendment owed: its 'proposal, not a recorded refinement' preamble is dropped and its Proposed-while-its-decisions-shipped status resolves to Accepted. Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(invoicing): settle the tax-rate notation on percent-as-string InvoiceLine.taxRate had no single reading. Core divided by 100 unconditionally, so '0.23' meant 0.23%; the inFakt adapter switched on n > 1, so '23' and '0.23' both resolved to 23% while a genuine 1% rate resolved to 100%. On a 123 PLN invoice the two readings diverge by 22.72 PLN. The blast radius is narrow only because the mapper still emits an empty taxRate and each adapter substitutes its own default. Once #2054 lands, every invoice line travels through this field, so the notation is pinned first and on its own. A guess is the wrong shape here: the writer knows which notation it used, so an ambiguous value is a defect upstream and must surface. A new pure module, tax-rate-notation.types, states the contract once and every reader goes through it. Fractional notation (numeric, strictly between 0 and 1) raises FractionalTaxRateNotationError rather than being multiplied by 100 - '0.23' is indistinguishable from a genuine 0.23% rate, so normalising it would invent a value nobody stated. '0' is deliberately not fractional: a zero rate is a real answer, and PrestaShop already distinguishes it from unknown. Readers aligned: core rateFraction, the inFakt tax_symbol map and gross-to-net split, the Subiekt stawkaVAT passthrough. KSeF already rejected a fractional code, since FA3_TAX_RATE_MAP has no such key - that is now pinned by a test rather than left incidental. The empty-string path is untouched on every adapter; #2257 removes those defaults. Closes #2247 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(web): shared-UI primitives for the tax-rate states Three additions the per-line tax-rate surfaces need, and nothing else. StatusBadgeTone gains 'conflict' - two sources disagreeing about the same fact, which needs attention but is not an error because the document still issued. The family has no -fg member, so the rule reads --status-conflict-strong; the ramp base gives roughly 2.5:1 and fails contrast, and nothing would have caught it since the token checker is one-directional and a missing token resolves to nothing silently. The family already had a consumer through a className override (.status-badge.delivery-rider-chip--not-connected), so that override is migrated onto the tone in the same change rather than left as a second owner with a second text token. The className survives as a layout hook, so the existing delivery-chip assertions still hold. AlertTone gains the same member. Alert picks role=alert only for 'error', so a conflict alert lands on role=status - the right politeness level for a non-blocking advisory, now following from the tone rather than from a per-call choice. SalesDocumentBlockCopy['tone'] widens to match, since its value is passed straight into Alert. AbsentValue moves out of listings-list-page into shared/ui. Absence versus zero is the central claim of this epic - a rate of 0 is a real answer and 'no rate' holds the document - so it needs one implementation. It renders the wording visually hidden rather than as an aria-label, because aria-label on a bare span is prohibited and commonly dropped. Adding the tone member compile-forced BLOCK_TONE_FOR_BADGE and TONE_CLASS, the timeline's two exhaustive records; both gain the member together with the TimelineEvent tone and the .order-activity__dot--conflict rule. Closes #2253 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(products): per-line tax rate on the order contract and the master reads Core had nothing to carry a tax rate in. OrderItem and IncomingOrderItem have no tax field, and the only signals on an order are one aggregate OrderTotals.tax and an order-wide taxTreatment - neither of which can describe a mixed-rate basket. So the mapper emits an empty rate and each provider adapter guesses. This gives core the field, the vocabulary, a way to ask the two masters, and a place to keep the answer. The rate is a string code (23, 8, 5, 0, zw, np, oo), not a number: 0, exempt, reverse charge and intra-EU zero are four different things on a document and all look like zero. Notation is percent-as-string, settled in #2247. Storage is a projection pulled at product sync onto products and product_variants, exactly as price and currency already are. Issuance must not depend on the shop being reachable, and 'which products lack a rate' has to be a query rather than a crawl - both partial indexes exist for that. Three distinctions are load-bearing and each has a test. Zero is an answer, never an absence. A read that establishes nothing reports kind: 'unknown' and is stored as a null code, never as '0'. Never-checked is not no-rate. taxRateReadAt separates them: null timestamp means nobody has asked, a timestamp with a null code means the master answered and has none. Without that split, the day this ships the whole catalogue reads as incomplete and the pre-rollout coverage count measures nothing. A variant override always wins where the shop carries one - the open question the epic left for this child. It is not a conflict to arbitrate: a variant value is the more specific statement of the same fact, and the shop had to be edited deliberately for the two to differ. An absent override means 'no opinion', so a product-keyed master resolves through the product row unchanged. A third resolution arm, 'inherited', exists for a WooCommerce variation whose tax_class is 'parent': storing nothing is honest, where recording unknown would show the variant as rate-less and copying the product's code down would leave a duplicate that goes stale. PrestaShop delegates to the existing PrestashopTaxRateResolver rather than re-walking the three-hop chain, so the sync path and the order-create path cannot disagree about one shop. Its transport unknown is re-raised rather than reported as unknown - a failed call says nothing about the shop's configuration, and recording it would freeze a false 'no rate' onto the row. WooCommerce resolves class name to the store's rate table for the store's own country; tax_status 'none' is a resolved zero, no row is not-configured, and several different rates is ambiguous rather than a pick, because picking would be OpenLinker computing tax. recordTaxRate is a separate writer from upsert on both repositories, and the columns are deliberately absent from toOrm - the sync upsert carries no rate, so round-tripping them would blank a value the tax read just wrote, and a blanked rate holds documents. Same single-writer rule as order_records.cancelledAt. Order ingestion settles the rate onto the stored snapshot, shop first and channel second, and carries taxSource plus taxRateReadAt alongside it so a reader can tell no rate, never read and pre-rollout apart (#2245 F3). One additive migration, nullable, no backfill. Closes #2054 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide - toExclusiveEndInstant now anchors on UTC midnight instead of local midnight, matching the controller's UTC-parsed `from` (was silently dropping/adding hours off UTC). - Sparkline aria-labels derive from the actual selected range instead of a hardcoded "last 7 days". - Register the analytics KPI card's 152px/3-col geometry as a documented carve-out in the style guide (Density table + parity matrix), per the "never introduce an undocumented row height" rule. - Fix "Data order" planned-tag typo -> "Planned"; section-infotip font-size to the rem token equivalent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(invoicing): carry the tax rate onto the document and hold when it is absent The mapper stops emitting an empty taxRate and carries the code the order line was settled with. It stays a passthrough - no derivation, no default - and an empty value still reaches the adapter unchanged, because a mapper that threw would turn an operator-fixable data gap into a failed job. The gate is what refuses. missing-tax-rate joins SalesDocumentGateBlockReasonValues on both sides of the mirror. It is the first reason for which 'this cannot be issued' is literally true: every other one means 'auto-issue did not happen', and issuing by hand past those is a legitimate operator action. Issuing past this one means a provider substituting a guessed rate onto a real fiscal document, so it closes the manual paths too - InvoiceService.issueInvoice refuses before the lock and before any persisted state is touched, POST /invoices answers 422 with the reason and retryable:false, and bulk issue reports a named ineligibility rather than attempting it. The check runs on the COMMAND rather than on the order, so no caller can bypass it by composing lines itself, and the correction path - which composes its lines from an already-issued document - is unaffected. It runs BEFORE the trigger-model gate, because on a manual connection both apply and reporting the weaker reason would leave an operator clicking a button that refuses. A zero rate passes everywhere. Export, intra-EU and exempt goods are legitimately zero, and blocking on them would hold documents for a correctly configured catalogue. Shipping now splits across the rates in a mixed basket, in proportion to line gross, with the rounding remainder on the largest part so the parts sum exactly to what the buyer paid. A single-rate basket still yields one line. This is division, not tax computation: core groups an amount it was given and cuts it into parts that add back up to it. A single line with no rate makes the mix unknowable, so the split refuses and the whole document waits. Two instants record when a hold started and ended. The reason column is level-triggered and nulled the moment it clears, so without them the operator-facing age has no clock and the 'the rate arrived, the invoice issued' timeline entry has no instant to hang on. They are derived from the TRANSITION inside the same UPDATE, because only that statement knows both the old and the new value; blockedAt is stamped on none-to-blocked alone so a change of reason inside one episode does not reset an age somebody is watching. Closes #2248 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(web): remove unused vi import breaking tsc build" This reverts commit b915a030ac81bac74c483b45472cb613cec194c8. * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(products): append-only tax-rate provenance journal A mutable 'rate source' field only says how things stand now. It cannot answer when the shop changed the rate, what OpenLinker last wrote onto a channel, or whether somebody overwrote it afterwards - and that last question is what makes a shop-versus-channel disagreement attributable rather than mysterious. So provenance is a journal: one row per CHANGE, never one per read. The catalogue sweep runs every twenty minutes and most rates never move, so writing unconditionally would grow the table by the size of the catalogue per tick and bury the handful of rows that matter. isNewTaxRateObservation owns that rule in one pure place. The dedup compares the value, the origin AND the frozen flag. A seller freezing a field without changing its value is a real change in what the value means - it is now something a person set - and losing it would leave the disagreement surface unable to say so. Append-only by construction, not by convention: the port declares append, findLatest and findLatestPerConnection, and no update, upsert or delete. A journal whose rows can be edited cannot answer the question it exists for, so adding a mutating method is not a refactor. Same discipline as ExchangeRateRepositoryPort. Origin distinguishes shop, channel and written-by-us. The third is the reason the journal exists at all: it records OpenLinker's own write onto a channel, so a later channel observation carrying a different value proves somebody changed it after we did. Master product sync records every rate it observes. The write is best-effort and separate from the catalogue write - the journal is provenance, so losing an entry costs an audit trail rather than a rate, and failing the sync over it would trade the thing that matters for the thing that explains it. One index serves both reads, since the latest-for-one-connection lookup and the latest-per-connection listing walk the same prefix. Note on scope: taxSource and taxRateReadAt already reach the ORDER SNAPSHOT line (#2054, epic F3). The order_line_items half of this issue is not implemented here because that table does not exist on main - it is defined in #2014, which is still open. Closes #2250 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(integrations): read the channel's tax rate and propagate the shop's onto offers The channel half of the resolution chain, and the write back. READS. Erli reports a required per-line taxRate on every order line and OL's own type already modelled it while the mapper discarded it - it now reaches the order contract. Allegro's lineItems[].tax has been live since March 2024 and the OL type had no field for it at all; it is modelled and read. Both map their platform vocabulary to the neutral code inside the adapter, tested in both directions, with a round-trip test over every value the Erli mapper claims to support. An unreadable value maps to absent, never to '0'. A rate OL cannot read is not a zero-rated sale, and Erli's enum is category-dependent and may gain values. WRITES. The shop's rate is stamped onto CreateOfferCommand by OfferBuilderService in the same pass that already carries price and stock, read from OL's own catalogue projection so publishing does not depend on the shop being reachable and the offer carries the same rate an invoice for it would. There is no OL-side rate field to type into, deliberately: a rate entered in OL would be a fourth source no master or channel could be corrected from. Nothing is published with the rate omitted. That is precisely how the rate-less offers this epic exists to fix were produced, and the failure surfaces months later on somebody's invoice rather than at publish time. Allegro refuses on no rate, on an exemption code (its rates array carries numbers), and on a rate the category does not allow - the last naming the permitted values, because when Allegro says the category allows 23% and OL sent 5% the shop record is almost certainly the wrong one. Erli refuses on no rate and on 'oo', which its enum cannot express; omitting would publish a product Erli then marks not-buyable with missingTaxRate, which nobody sees. The permitted-values read is best-effort. A failure to LIST is not a failure to publish, so it warns and proceeds with the shop's value; Allegro validates the body itself, and refusing because a secondary discovery call was unavailable would be worse than the check. The update path propagates too, so an offer's rate follows the catalogue instead of freezing at first publish. There an unmappable code is dropped rather than raised: an update that cannot state its tax is a partial update of an offer that already sells, and raising would take a title or price fix down with it. A frozen Erli taxRate is skipped, force is never sent, and it is reported once per connection at info level rather than as a recurring publish error - a seller freezing the field is a deliberate decision, and it is exactly the signal that makes a later disagreement attributable to a person. Closes #2249 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(orders): mark pre-rollout orders and measure catalogue rate coverage Two rollout chores that keep the first day honest. HISTORICAL ORDERS. An order ingested before per-line rates existed carries none on any line, and the document issued for it used whatever the provider adapter defaulted to. It is MARKED rather than blocked: blocking would stop history nobody is going to retrofit, and nothing about it can be corrected after the fact. The marker's only job is to keep a net-revenue figure honest - such an order is excluded from one rather than presented as a confirmed rate, because there is nothing to back-compute from. Recorded per RECORD, not per line. The lines live in a jsonb snapshot, so a per-line marker would rewrite every snapshot in the table for a value that is uniform across an order and that no surface renders per line. The frontend deliberately shows nothing for it - it appeared in one place with no action attached, so it is analytics data rather than a badge. This backfill is not the one the epic forbids. What must never be invented is a tax RATE; recording that an order predates the feature is a fact about OpenLinker's own history, and it is exactly what stops a later reader mistaking a defaulted rate for a stated one. The migration is idempotent by construction: it marks only rows where no line has ever carried a rate, so an order ingested between two runs is not retroactively called historical. There is no cutover instant to get wrong. COVERAGE. Counts are per shop connection, because that is the unit an operator fixes - 'the catalogue has no rates' is not actionable when three shops feed it and only one is incomplete. A product mapped on two connections counts under both, which is the honest answer since both shops would have to carry the rate. The grouping joins identifier_mappings by table name, the read-model posture the stock aggregation in findMany already uses, rather than importing another context's ORM entity. docs/operations/tax-rate-coverage.md carries the query, what the three states mean, how to read the answer, and why this gates #2257: until the defaults come out a rate-less product still produces a document with a guessed rate, and after it produces nothing at all. Running the two in the wrong order turns a slow, visible data problem into an immediate outage. The 2026-08-21 baseline measured zero coverage and is recorded on the issue. Closes #2256 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(fiscalization): hold a receipt when a line has no tax rate The same rule as the invoice, on the fiscalization path. FiscalRegistrationService.register refuses before the read gate and before any row is written, so a held sale leaves no pending record to reconcile. The order-to-command mapper stops emitting an empty rate and carries the line's own, and shipping splits across a mixed-rate basket exactly as it does on an invoice - a receipt has to state a rate per line too. The accepted cost is LATE registration, and it is chosen deliberately. The alternative is a receipt carrying a tax letter nobody confirmed, which reaches the buyer and the daily report and cannot be recalled. A late registration can be completed; a wrong one has to be corrected. THE REVERSAL POINT IS ONE BRANCH: the assertEveryLineHasATaxRate call in register. Nothing else in this context consults the rate, and the exception's docblock says so, so reversing this is a one-line change rather than a redesign. The eparagony adapter's own empty-rate arm is kept for the same reason. The per-connection tax letter stays supported but stops being a fallback. Its docs no longer describe it as "what we use when we do not know": core refuses one step earlier now, so that arm is unreachable while the gate stands, and presenting it as a safety net would describe a trade OpenLinker no longer makes. splitShippingAcrossRates moves from invoicing into sales-documents. Both document contexts need it and a fiscal receipt is not an invoice, so neither could own it for the other - sales-documents is the dependency-free leaf that exists for exactly this case, and the barrel-purity spec already enforces that it stays one. The module imports nothing, so the property holds. On the elapsed-time signal: a held sale has no fiscal record to hang a clock on, and it does not need one. The block is recorded on the ORDER as missing-tax-rate with salesDocumentBlockedAt (#2248), and that reason is document-kind agnostic - it says the order has no fiscal document, whichever kind was due. A second clock would be a second answer to one question. Closes #2252 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug - Units column now reads row.units (server-ranked figure) instead of re-summing row.channels[], which could silently disagree with the sort order the header arrow claims. - The Publish action is gated on listings:write via useWriteAccess + ReadOnlyLock: hidden for an unauthorized non-demo session, rendered disabled with the read-only tooltip for a demo viewer. - Swapped the Chip (aria-pressed toggle) for a real Link styled as a button, so the one-shot publish navigation carries link semantics (middle-click, open-in-new-tab) instead of misrepresenting itself as a permanently-unpressed toggle to assistive tech. - @media (hover: none) now stacks the "Not listed" label and the Publish action, both visible, instead of hiding the label on touch — the #1991 AC's label-vs-action distinction was desktop-only before. - Added the analytics feature slug to both no-restricted-imports pattern groups in .eslintrc.js. Closes review findings on https://github.com/openlinker-project/openlinker/pull/2191 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(invoicing): copy the per-line net back from the issued document OpenLinker computes no net amount (ADR-052), so a stored per-line net has to be the document's own figure rather than a recomputation. Otherwise the record disagrees with the paper by a grosz here and there and no reader can tell which is right. Nothing carried a provider-computed net before this. documentContent held core's own recomputation, marked NON-AUTHORITATIVE in the code, and issuedLineSnapshot carried only unitPriceGross plus taxRate. So this is mostly an adapter-contract change: IssueInvoiceResult gains an optional documentLines, matched to the command's lines by 1-based line number. The fallback is per LINE, not per document, so a provider that reports some lines and not others still contributes what it has. Shipping lines are part of the numbering because they are real document lines; what "skip shipping" means is that they have no ORDER line to transcribe onto, not that they shift the mapping. A correction reports its own amounts and they overwrite the stored ones, so the record follows the latest effective document rather than keeping pre-correction figures the paper no longer states. inFakt reads the created invoice's own services[] - it is the calculator on that path - converting integer groszy back to PLN on the adapter side where that wire detail belongs. KSeF has nothing external to copy, so the adapter reports what it wrote. The figures come from the same lineNet the XML uses, so the reported net cannot drift from P_11, and the offline pending-submission path reports them too: that window is about transmission, not about what the document says. The KSeF rounding bug is fixed and the rule is stated once. P_9A is TKwotowy2, which permits EIGHT fraction digits, and the builder was rendering it through the 2dp money(). On 100 x 1.99 at 23% the line net is 161.79, but a unit net rounded to 1.62 multiplies back to 162.00 - the document contradicted itself by 21 grosze and a reader checking P_9A x P_8B == P_11 was right to complain. The rule: the LINE is the unit of rounding and the unit price is derived from it at the schema's full precision, never the other way round. The buyer paid a gross line amount, so the line's net is what anchors to a real figure; a unit net is a derived display value that only sometimes has an exact 2dp form. Closes #2251 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(web): tax-rate states on the orders list, order detail and invoice panel Every tax-rate state on the orders surfaces, plus the backend contract two of them needed. BACKEND. A rate conflict gets its OWN field, its own count and its own filter, not a gate reason (epic F1). invoicingBlockedBadge returns null whenever an invoice plausibly exists, and a conflict does not stop the invoice, so routing it through the block machinery would make the badge unreachable on exactly the rows it describes - and SalesDocumentAttentionReasonValues would have counted it inside salesDocumentBlocked, against its own chip. The evidence is taxRateChannel on the order line, written ONLY when the channel disagreed with the shop. Its presence is the conflict, so no reader compares two fields and nothing goes wrong when only one system answered. The summary also reports the oldest still-held instant, so the blocked chip can carry an age. ORDERS LIST. A Rate conflict chip driven by its own count, mounting on filterActive || count for the nine-line reason the invoicing chip already documents: gating on the count alone unmounts the only way to clear the filter the moment remediation succeeds. No tone on it - .chip.chip--active overrides every .chip--{tone}, so an inactive conflict chip would read as pressed beside an active accent one. The age folds into the blocked chip's own label rather than becoming a third dotted badge in a row that already carries two SLA badges. The conflict badge uses the listings page's RowBadge shape - visible label plus the wording in a visually-hidden span - because the hint is the only statement of the fact on this surface and aria-label on a bare span is prohibited and commonly dropped. A new empty arm sits ABOVE both single-filter arms: with two filters active and no rows, "nothing is blocked from invoicing" would be a statement about a set the other filter narrowed. liveRegion stays polite, like the neighbour it sits beside. ORDER DETAIL. A tax-rate column on the line-items panel, following the epic's central claim: an answer is text, only an exception is a badge. A rate, a zero and an exemption all read the way the money beside them reads, with a provenance caption; only no-rate and conflict get colour. No hideBelow - this panel passes no cardView, so the class would simply delete the blocking state on a phone, on the one screen that diagnoses it. Only flagged and Jump to next flagged are borrowed from the bulk review step for long orders. The totals panel's Tax row keeps its snapshot value and gains a caption naming whose number it is, because Allegro and Erli report zero there and it will visibly disagree with the line rates. INVOICE PANEL. Three remedy branches, not one sentence: a blank rate on a mapped product, an item in no catalogue (fixing the offer will not release this order - the marketplace stamped the rate at purchase), and an ambiguous shop tax class. Plural-safe with a count, because a forty-line order with six rate-less lines cannot be told about one product. The Issue invoice button is disabled with the reason ON the control (epic F2). It renders on invoiceSettled && not-issued independently of the block copy, so until now a red "will not be issued" alert sat above a live button that issues it - and the backend now answers 422. The conflict alert is informational and lands on role="status" via the conflict tone. The shipping split preview lives here rather than beside the line items, because shipping has no order line: it exists only once a document is composed. One unknown line rate collapses it to a single waiting row rather than showing a proportion OpenLinker cannot compute. Fix and re-check names the latency and links to the products list rather than opening a sync dialog here: the connection to sync is the SHOP that owns the product, which this panel does not know. TIMELINE. The block entry is dated from the persisted instant instead of timestamp: null, and a release entry exists at all - by the time an order is released the reason is gone, so nothing else records that it was ever held. Closes #2254 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(web): tax-rate states on products, publish wizar…
…p-products endpoints + per-line tax rate + net tax basis (#2014) * docs(mockups): UI mockups for the order-time FX stamp surfaces Every surface ADR-040's reporting-currency stamp touches, built against the real design system (tokens transcribed from apps/web/src/index.css, primitives from apps/web/src/shared/ui/): the Platform/Currency settings tile in its three resolution states, the /analytics layout per the Design 1 'Ledger' cut, the orders list money cell, the order-detail audit panel, the two new job types, the invoicing boundary, and the five-state model behind every badge. Also records the four design decisions taken outside ADR-040 and the work breakdown across the six sub-issues. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(mockups): correct the ECB blocker claim in the work breakdown The page claimed ECB's historical endpoint was an unresolved Phase 1b blocker. That came from PR #2050's description, which described an earlier draft rather than what merged - the plan's ECB reference rates subsection in main is verified against the live API, and an independent re-verification reproduced every claim in it. Replaces the claim with the eight facts that re-verification did add, including the includeHistory + lastNObservations phantom-row bug now recorded on #2123. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(shared): add previousWorkingDay to the Polish working-day calendar The FX rate-date rule resolves a candidate calendar day back to a day NBP actually published on, which means walking backwards over Polish weekends and public holidays. `pl-working-days.ts` already owns that calendar but only counted forwards (`addWorkingDays`), so a caller would have had to re-implement it. `previousWorkingDay` mirrors `addWorkingDays` exactly - same Europe/Warsaw civil anchoring, same date-only UTC proxy cursor, same holiday set and weekend predicate, same wall-clock time-of-day preservation. The source instant is never counted; the walk starts from the previous day. Refs #2122 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ktirW7dvWqN42TJRMdwuD Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * test(shared): make the Warsaw-anchoring cases actually discriminate Both timezone tests picked instants where the UTC-anchored and Warsaw-anchored walks happen to agree, so neither could detect the anchoring being dropped. Replacing toWarsawCivil with plain getUTC* left both green. Swapped for instants where the two diverge - addWorkingDays now starts from an instant that is Sunday in UTC and Monday in Warsaw (2026-06-23 vs 2026-06-22), previousWorkingDay from one that is Friday in UTC and Saturday in Warsaw (2026-06-19 vs 2026-06-18). Both expectations verified by execution. Adds the two backwards cases the forward suite already had counterparts for: a walk crossing a year boundary (movable holidays rebuilt mid-walk) and the Wigilia/Christmas chain, the longest real run of non-working days. Also documents the composition a publication-calendar walk-back needs - previousWorkingDay always steps back, so resolving a candidate to the nearest working day at or before it requires guarding with isPlWorkingDay first. The NBP adapter in #2123 is the caller that would otherwise skip a valid publication day and stamp the wrong rate. Refs #2122 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(currency): add the currency context, rate port, registry and reporting-currency setting A new leaf core context owning everything about an order-time FX stamp that is not HTTP: the ExchangeRateProviderPort contract, the provider registry, the shared append-only exchange_rates registry, the pure rule -> rate-date and reporting-currency -> source derivations, and the system-level reporting-currency setting. The context imports no sibling core context and makes no outbound call, so the providers cannot live here - they ship in @openlinker/integrations-fx. That split is ADR-040 Decision 7 and is deliberately not conditioned on whether a source needs a credential today, so nothing moves packages if NBP or ECB adds a key. Three decisions worth calling out, because each has a plausible-looking wrong answer: - resolveRateDate is CALENDAR-NEUTRAL. It yields a candidate calendar day and knows about neither weekends nor any country's holidays; each adapter absorbs its own publication calendar. A shared Polish calendar would silently stale every ECB rate on a Polish-only holiday - ECB publishes on Corpus Christi and Epiphany, Poland does not, and the resulting figure is wrong by ~0.035% with no error anywhere. The today-in-Warsaw clamp is likewise load-bearing rather than defensive: a future endPeriod makes ECB answer with a months-stale rate at HTTP 200 and no signal of any kind. - Direction is an invariant. `rate` is the number of `to` units per one `from` unit, so a consumer always multiplies. An inverted or pivoted rate records its derivation NOT NULL - a direct rate stores {"kind":"direct","legs":[...]} - so the column is never a "sometimes populated" field and a derived figure stays auditable. - The rate registry is append-only BY CONSTRUCTION. The port declares only findByKey and insertIfAbsent; there is no update, upsert, delete, or save carrying an id. A stamped order points at a registry row as evidence, so an editable rate would make every figure derived from it unverifiable. A spec pins the absence, including that the single save() carries no id. The setting lives here rather than in orders because save-time coverage validation needs the provider list; putting it in orders would create an orders -> currency value dependency for validation alone. Validation is three layers and zero HTTP: ISO shape (400), reachability against SUPPORTED_REPORTING_CURRENCIES narrowed by the registered providers (422, the hard gate, a pure array test), and a coverage advisory that warns and never blocks - composed by the caller so no currency -> orders edge appears. CurrencyModule is a static @Module, never forRoot: core and the fx package must resolve ONE registry instance, exactly as AdapterRegistryService does. The migration for exchange_rates and reporting_currency_setting is Phase 2 of the epic and is not in this change. Refs #2123 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(fx): add @openlinker/integrations-fx with the NBP and ECB rate adapters Both providers of ExchangeRateProviderPort, in a new workspace package, plus FxIntegrationModule which registers them into the core registry at boot - byte-for-byte the mechanism integration modules already use for AdapterRegistryService. Nothing in libs/core imports this package. It is NOT a plugin: no adapter manifest, no capability, no getCapabilityAdapter path. A published reference rate is a shared read of a public source, not a per-connection capability. The two adapters are near-mirror images and each is written around a trap the other does not have: NBP (quotes X -> PLN) owns the Polish working-day calendar. It resolves the calendar candidate to the nearest working day AT OR BEFORE it - `isPlWorkingDay(c) ? c : previousWorkingDay(c)`, never a bare previousWorkingDay, which always steps back at least one day and would skip a perfectly good publication day to stamp yesterday's rate. The 404 walk-back that follows is defence in depth, not the mechanism. Any non-404 4xx is terminal rather than just 400, since NBP's malformed-date response is documented but unverified. ECB (quotes EUR -> X) has no walk-back at all: endPeriod + lastNObservations=1 makes the API resolve "the last publication on or before this date" server-side, correct across clusters a walk-back-by-one gets wrong. includeHistory is deliberately never set - combined with lastNObservations=1 it injects a phantom ACTION=Delete row with an empty OBS_VALUE and an unrelated historical TIME_PERIOD. A non-publication day is a 200 with a ZERO-BYTE body, not a 404, so the body is length-checked before any parsing; a 404 means the series does not exist; a 400 returns HTML while 404/406 return problem+json, so a 4xx body is never JSON.parse'd. CSV columns are indexed by header name, never by position. A 10-day observation lag is asserted as a cheap backstop - the real maximum non-publication run is 4 days, so it can only fire on a clamp regression. ECB assigns no document identifier (header.id is a fresh UUID per request, Last-Modified is not data-dependent), so sourceRef persists an OpenLinker-constructed re-executable locator, ECB:EXR(1.0):<key>@<period>. That is stated in the code rather than passed off as an ECB reference. Both adapters take an injected FetchLike, so every spec fakes HTTP without touching globalThis and no tier makes a live call. The package is added to the outbound-http scan roots and the matching ESLint glob; the single exemption is the FX_FETCH_TOKEN default factory, where ADR-038's connection-bound transport is structurally unusable because it keys its cache and rate-limit bucket on connection.id and a reference-rate read has no connection. The @openlinker/* edges are declared in package.json, not only in tsconfig references - pnpm never reads tsconfig, and omitting the manifest edge lands the package in the same `pnpm -r` chunk as its sibling (#2011). Refs #2123 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * chore(hosts): register FxIntegrationModule in the api and worker plugin lists The binding crosses from the integration package into core at the host, so nothing in libs/core imports @openlinker/integrations-fx: the module is added to apiPlugins / workerPlugins, PluginRegistryModule.forRoot re-exports it, and its onModuleInit populates the core exchange-rate registry. The worker is the load-bearing registration - order ingestion and the FX retry / reconcile-sweep handlers all run there. The API is registered too, matching the dual registration WooCommerce, InPost, Subiekt and AI already have, so a future API-side restamp endpoint fails at boot rather than at runtime against an empty registry. Refs #2123 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(fx,currency): apply the #2123 review findings Three IMPORTANT findings and eight suggestions from the /pr-review pass. NBP errors named a pair the caller never requested. Every raise inside the fetch path reported the LEG currency rather than the requested pair, so fetchRate({from:'PLN',to:'EUR'}) failed as 'EUR/EUR' and a 503 on the same request logged as 'EUR/PLN'. A RateUnsupportedPairError is a terminal business_failure with no retry, so that log line is the only signal an operator gets. The requested from/to are now threaded through fetchQuotesForNearestPublishedDay -> tryFetchQuotesFor -> fetchQuote -> parseQuote, matching what the ECB adapter already did. The registry get-or-create had no integration test, which the issue's acceptance criteria and the plan's section 9 scenario 5 both require - and the plan states the concurrency claim is not unit-testable. The 23505 -> DuplicateExchangeRateError -> re-select chain was exercised only against a jest.fn() told to reject, so the real unique index, the real error code and what two concurrent callers observe were untested at every tier. Adds exchange-rate-registry.int-spec.ts covering byte-identical re-read, two concurrent calls resolving to one row, the domain error crossing the port boundary, and one row per distinct rate date. It stubs the transport under the real service, registry, adapter and repository rather than substituting a fake provider, so no network call is made. Both new tables join the harness truncate list. The registry's cost was understated. The pre-fetch read is keyed on the candidate day while the write is keyed on the published day, so a candidate that resolves by walk-back is never memoised and every order carrying it re-fetches - roughly 2 days in 7, not 'one extra call per candidate day'. The behaviour is correct (no duplicate row, no wrong-dated rate, no loop); only the claim was wrong. Header and comment now state it, and a spec pins that a weekend candidate re-fetches while a publication-day candidate does not. Memoising the candidate-to-published mapping needs its own table and is left to the persistence phase. Also: append-only source-text guard now blocks createQueryBuilder( and manager.; the ECB pivot uses allSettled with terminal-beats-transient precedence instead of all, whose rejection order was timing-dependent; both adapters use the exported RateDerivationKind instead of re-declaring the union; the NBP date formatter is hoisted to module scope; the MAX_OBSERVATION_LAG_DAYS boundary is pinned at 10 and 11 and its reason string names the reconcile sweep as the recovery route; NBP_TABLE_A_CURRENCIES explains why PLN heads a list of table-A rows; and the fake adapter's reset() restores its constructor seed instead of emptying the map. Refs #2123 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(fx): source-map integrations-fx for the integration harness Re-review caught three small things, one of which made the new int-spec unrunnable outside CI. @openlinker/integrations-fx entered both apps' plugin graphs without a moduleNameMapper pair in apps/api/test/jest-integration.cjs or the worker's, which check-jest-integration-mappers.mjs exists to catch (#916, #786). The package's main is ./dist/index.js, so in a fresh un-built worktree the new exchange-rate-registry int-spec - and every other apps/api and apps/worker int-spec - failed at module resolution. CI masked it by building dist first. The gap was not caught earlier because check:invariants is an && chain and check-repo-urls sits ahead of the mapper guard; its known failure on the untracked .worktrees directory short-circuited everything after it. Every check past that point has now been run individually and passes. Also drops a redundant `| null` from pickLegFailure's return type, which tripped no-redundant-type-constituents and failed pnpm lint, and a redundant type assertion on a query() result in the int-spec. Refs #2123 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(orders): persist the per-order FX snapshot columns and their stamp-once writes Adds the six nullable FX columns to `order_records` plus the DDL that #2123 deliberately deferred: this migration creates `exchange_rates` and `reporting_currency_setting` as well, so the three tables land as one schema unit. `reportingCurrency IS NULL` is the canonical "unstamped" test - `exchangeRateId` is legitimately NULL on the same-currency path, and `fxIntendedCurrency` is a separate column from `reportingCurrency` because an intent exists on a row that is still unstamped, which is also why the group CHECK's first arm deliberately omits `"fxRule" IS NULL`. Two conditional writes own the columns, both in the `claimWaybillRelay` shape (`IsNull()` in the WHERE, `affected > 0` as the answer): `claimFxIntentIfAbsent` pins the currency + rule at the first attempt, and `stampFxIfAbsent` writes all five stamp columns in one statement so the group cannot half-apply. `toOrm` maps none of the six - `upsert` is a full-row `save()` on an update-or-create ingestion path, so mapping them would let a re-poll write `null` over a reported financial figure; a regression spec asserts each key is absent from the entity passed to `save()`. `listDistinctNativeCurrencies` feeds the coverage advisory, reading `orderSnapshot.totals.currency` through the same `jsonb_typeof`-guarded form the migration's expression index uses. The group CHECK is verified by parsing the emitted constraint and evaluating it against all five legal FX states plus the illegal combinations, because nothing in CI runs a migration - the Testcontainers schema is built by `synchronize`, so no int-spec can observe the constraint. The live run/revert/run round-trip remains a manual gate. Refs #2124 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(architecture): document the Currency bounded context Adds a § 17 Currency section to docs/architecture-overview.md, the forward reference ADR-040 leaves open, and the `orders -> currency` edge to the cross-context dependency graph. The section records the reporting-currency resolution chain, the code-constant rate-source map, the multiply-never-divide direction invariant as a property of the stamp rather than of the consumer-neutral registry, the first-attempt intent snapshot and why provider availability must not become an input to a financial figure, the port-in-core / adapters-in-@openlinker/integrations-fx split with providers deliberately not being capability adapters, the calendar-neutral rate-date rule, and the five persisted states together with the two predicates a consumer gets wrong. It also states positively that the stamp is analytics-only and must never supply FA(3) `KursWaluty`: an earlier draft of the plan asserted the opposite, and the stamp differs from a statutory conversion on date, target and derivation, so leaving the reversal as an absence would leave the nearest persisted rate as the one a future implementation reaches for. Refs #2127 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(orders,worker): stamp orders in the reporting currency at ingestion Phase 3 of the order-time FX stamp (#2125, ADR-040). OrderFxStampService.stamp(internalOrderId) is the one seam every attempt goes through - the inline call from persistOrder, the marketplace.order.fxStamp retry job, and the hourly marketplace.order.fxStampSweep reconcile. One signature for all three: placedAt lives only in orderSnapshot JSONB and an unparseable value is silently dropped on rehydration, so two signatures would let the inline and retry paths disagree about whether it exists. The persisted intent (fxIntendedCurrency + fxRule) is read and pinned before anything else. A row that already carries one skips the settings service entirely; otherwise the resolved value is claimed with a conditional write and a losing concurrent attempt adopts the winner's. Without this an order degraded to the retry job could stamp a different currency than the same order stamped inline, making provider availability a silent input to a financial figure. Same-currency orders stamp with no rate lookup and no I/O. A converting order multiplies - ExchangeRate.rate is `to` units per one `from` unit by contract - and rounds with the house round2 idiom, never pricing-rule.types.ts's round2dp, which clamps negatives to zero and would turn a refund into a fact. The service never throws: every failure folds into a stamped/terminal/deferred outcome, so a rate provider being down cannot fail an ingestion that already persisted the order. A transient failure enqueues fx:{internalOrderId} in its own nested try/catch, logged distinctly from the stamp failure, because a lost enqueue leaves the hourly sweep as the only remaining route to a stamp. persistOrder collapses its two post-upsert writers - cancellation and the FX stamp - into one refresh. Each writer now reports whether it wrote rather than re-reading itself, so the returned record reflects both instead of the second writer's effect being silently dropped by the first's re-read. The sweep reads order_records directly on fxStampedAt IS NULL AND reportingCurrency IS NULL, scheduled hourly per OrderSource-capable connection - the guarantee that survives a dead retry job, since a job's idempotency key is globally unique with no TTL and the ~4.3h retry window means a longer outage would otherwise lose the stamp permanently. Refs #2125 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(api,orders): currency-settings API surface Phase 4 (backend half) of the order-time FX stamp (#2126, ADR-040). GET /currency-settings and PUT /currency-settings/reporting-currency, both admin-only, mirroring /ai-provider-settings' route naming and its withDomainExceptionMapping boundary split: the ISO-shape failure is 400, an unreachable-but-well-formed code is 422 carrying the accepted set. The coverage advisory and the stamped-row counts are composed in the controller, the one layer allowed to combine currency with orders - doing it inside CurrencyRateService would create a currency -> orders edge and cost that context its leaf property. IOrderFxReadService is the narrow cross-context seam: listDistinctNativeCurrencies (already published) plus the new countStampedByReportingCurrency, grouped by reporting currency rather than totalled because the era breakdown - not a bare total - is the operator-facing fact behind "changing this setting splits history." Refs #2126 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(web): Platform/Currency settings tile, env passthrough, guard coverage Completes Phase 4 (#2126, ADR-040) - the backend controller/DTOs/module and the orders-side aggregate read landed in an earlier commit; this finishes the frontend tile, the mandatory write-guard entry, the three .env.example files and the demo compose passthrough the issue also calls for. The tile is named Platform / Currency, not Analytics / Reporting currency. The value is a property of the deployment, not a setting owned by one module - analytics is merely its first consumer, and invoices compute their own rate and never read this. An Analytics eyebrow would under-claim and a title like Instance currency would over-claim, so scope lives in the body copy instead of the name. Renders three source states, not the plan's two: EUR (default), PLN (from env), and a bare PLN once an operator has saved a value. "Nobody has decided" and "an operator pinned this in configuration" are different facts and only one of them is a problem - source is already on the response, so the split costs nothing. The dialog's coverage-gap checkbox gates the Save button client-side rather than the backend rejecting an unacknowledged submit, matching ADR-040's warn-never-block contract: one junk currency in old order history must never make a legitimate reporting currency permanently unselectable. CurrencySettingsController is added to write-guard-coverage.spec.ts's CONTROLLERS - the issue calls this not optional, since a write endpoint absent from that list ships without guard coverage and nothing fails. OL_REPORTING_CURRENCY is documented in all three .env.example files (the worker one matters because it runs the retry job and the sweep) and passed through docker-compose.demo.yml, defaulting to PLN to match the demo shop's own currency. Refs #2126 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(orders): value-import OrderRecordRepositoryPort in OrderFxReadService An interface injected via @Inject on a decorated constructor parameter must be a value import, not import type — emitDecoratorMetadata needs the symbol resolvable per-file, and a type-only import can erase to a dangling reference under isolatedModules-style single-file transpilation (ts-jest, esbuild, swc). Same pattern already established for IIntegrationsService in invoice.service.ts and applied to OrderFxStampService's own constructor in an earlier commit on this branch — this was the one file the #2126 branch had not yet matched to it. Caught by pnpm -r lint's --fix pass. Refs #2126 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(docker): add libs/integrations/fx to the Dockerfile's manifest COPY lists The base and production stages hand-enumerate every @openlinker/* workspace package for layer-caching COPY, with the Dockerfile's own comment warning this is exactly the #1365 review class of bug: a package missing from the list makes pnpm install fail to resolve its workspace:* reference and breaks the image build. @openlinker/integrations-fx (#2123) was never added to any of the three lists (package.json x2, dist x1), so the demo/production image failed to build for the whole epic. Caught while booting the epic branch for E2E verification. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(demo): match OL_REPORTING_CURRENCY across api and worker services The final /pr-review pass caught it: only the api service's environment block set OL_REPORTING_CURRENCY: PLN. Order ingestion, the fxStamp retry job and the hourly reconcile sweep all run in the WORKER process, and ReportingCurrencySettingsService.resolve() falls back to this env var per-process before any settings row exists - so a fresh demo deployment would have silently stamped orders in EUR (the code-constant default) until an operator manually visited /currency-settings, contradicting the compose comment's own stated PLN intent. apps/worker/.env.example already names this exact hazard class for the api/worker pair generally; this carries the same reasoning into the demo compose file specifically. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(api): move the FX migration spec out of the TypeORM migrations glob Live E2E boot caught it immediately: data-source.ts's migrations glob (migrations/**/*{.ts,.js}) feeds every matched file straight into migration:run, so the colocated migrations/__tests__/1834000000000-add- order-fx-stamp.spec.ts was require()'d by the CLI itself and crashed on its first bare describe() - a jest global that does not exist in that ts-node process. `migrate` exited 1 on every boot; nothing in CI or the test harness runs a migration, so this was never exercised before now. Moved to database/__tests__/, beside data-source.ts (the file that owns the glob) and outside its reach; jest's repo-wide testRegex picks it up regardless of location, so no test-discovery change. Fixed the relative import to the migration class and left a note explaining why this specific directory, since it is the first migration to ship a colocated unit spec and the next one will want the same shape without the same landmine. Refs #2124 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(mockups): live E2E verification report for the FX stamp epic Boots the epic branch on a real stack, hand-verifies one live NBP-sourced conversion (19.99 EUR at 4.342 = 86.80 PLN), and documents the three deploy-only bugs a live boot found that no review pass could have: the Dockerfile's manifest COPY lists never learned about @openlinker/integrations-fx, OL_REPORTING_CURRENCY was set on the demo compose's api service but not the worker (the process that actually runs ingestion), and the migration's own unit spec crashed migration:run because TypeORM's CLI globs and require()s every file under migrations/ directly. Refs #2049 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(web/currency-settings): stop showing a bare stamped-orders count on the tile `Stamped orders: 0` read as an alarm ("0 problems") instead of the coverage fact it is, and the per-currency grouping only ever produces a real breakdown when the deployment has changed its reporting currency before — otherwise it's one bucket, not a breakdown. Move it behind a secondary "Coverage" action with copy that explains what's being counted and why 0 is normal right after this ships. Signed-off-by: Norbert Kulus <norbert.kulus@blockydevs.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(orders): repair rebase merge-artifact regressions onto main Rebasing onto main's sales-document-block work (#2100) silently dropped CurrencyApiModule from app.module.ts's imports (import statement survived, array entry didn't), and shifted OrderRecord's constructor arg order so positional test calls needed 3 extra nulls for the salesDocument fields that now precede the FX fields. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(currency,sync): fix CI failures on FX rate snapshot PR The FX stamp sweep task's OL_ORDER_FX_STAMP_SWEEP_CRON key was missing from the scheduler spec's cron-key allowlist, so the mocked ConfigService fell through to 'true' for that key and CronJob rejected it ("Unknown alias: tru"), aborting onApplicationBootstrap and failing every other registered task's test in the suite. Separately, buildCoverage always set rateSource from resolveSourceKey regardless of whether a provider was actually registered, so an unregistered candidate reported a rateSource instead of null. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(orders): persist the per-order FX snapshot columns and their stamp-once writes Adds the six nullable FX columns to `order_records` plus the DDL that #2123 deliberately deferred: this migration creates `exchange_rates` and `reporting_currency_setting` as well, so the three tables land as one schema unit. `reportingCurrency IS NULL` is the canonical "unstamped" test - `exchangeRateId` is legitimately NULL on the same-currency path, and `fxIntendedCurrency` is a separate column from `reportingCurrency` because an intent exists on a row that is still unstamped, which is also why the group CHECK's first arm deliberately omits `"fxRule" IS NULL`. Two conditional writes own the columns, both in the `claimWaybillRelay` shape (`IsNull()` in the WHERE, `affected > 0` as the answer): `claimFxIntentIfAbsent` pins the currency + rule at the first attempt, and `stampFxIfAbsent` writes all five stamp columns in one statement so the group cannot half-apply. `toOrm` maps none of the six - `upsert` is a full-row `save()` on an update-or-create ingestion path, so mapping them would let a re-poll write `null` over a reported financial figure; a regression spec asserts each key is absent from the entity passed to `save()`. `listDistinctNativeCurrencies` feeds the coverage advisory, reading `orderSnapshot.totals.currency` through the same `jsonb_typeof`-guarded form the migration's expression index uses. The group CHECK is verified by parsing the emitted constraint and evaluating it against all five legal FX states plus the illegal combinations, because nothing in CI runs a migration - the Testcontainers schema is built by `synchronize`, so no int-spec can observe the constraint. The live run/revert/run round-trip remains a manual gate. Refs #2124 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(architecture): document the Currency bounded context Adds a § 17 Currency section to docs/architecture-overview.md, the forward reference ADR-040 leaves open, and the `orders -> currency` edge to the cross-context dependency graph. The section records the reporting-currency resolution chain, the code-constant rate-source map, the multiply-never-divide direction invariant as a property of the stamp rather than of the consumer-neutral registry, the first-attempt intent snapshot and why provider availability must not become an input to a financial figure, the port-in-core / adapters-in-@openlinker/integrations-fx split with providers deliberately not being capability adapters, the calendar-neutral rate-date rule, and the five persisted states together with the two predicates a consumer gets wrong. It also states positively that the stamp is analytics-only and must never supply FA(3) `KursWaluty`: an earlier draft of the plan asserted the opposite, and the stamp differs from a statutory conversion on date, target and derivation, so leaving the reversal as an absence would leave the nearest persisted rate as the one a future implementation reaches for. Refs #2127 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(orders,worker): stamp orders in the reporting currency at ingestion Phase 3 of the order-time FX stamp (#2125, ADR-040). OrderFxStampService.stamp(internalOrderId) is the one seam every attempt goes through - the inline call from persistOrder, the marketplace.order.fxStamp retry job, and the hourly marketplace.order.fxStampSweep reconcile. One signature for all three: placedAt lives only in orderSnapshot JSONB and an unparseable value is silently dropped on rehydration, so two signatures would let the inline and retry paths disagree about whether it exists. The persisted intent (fxIntendedCurrency + fxRule) is read and pinned before anything else. A row that already carries one skips the settings service entirely; otherwise the resolved value is claimed with a conditional write and a losing concurrent attempt adopts the winner's. Without this an order degraded to the retry job could stamp a different currency than the same order stamped inline, making provider availability a silent input to a financial figure. Same-currency orders stamp with no rate lookup and no I/O. A converting order multiplies - ExchangeRate.rate is `to` units per one `from` unit by contract - and rounds with the house round2 idiom, never pricing-rule.types.ts's round2dp, which clamps negatives to zero and would turn a refund into a fact. The service never throws: every failure folds into a stamped/terminal/deferred outcome, so a rate provider being down cannot fail an ingestion that already persisted the order. A transient failure enqueues fx:{internalOrderId} in its own nested try/catch, logged distinctly from the stamp failure, because a lost enqueue leaves the hourly sweep as the only remaining route to a stamp. persistOrder collapses its two post-upsert writers - cancellation and the FX stamp - into one refresh. Each writer now reports whether it wrote rather than re-reading itself, so the returned record reflects both instead of the second writer's effect being silently dropped by the first's re-read. The sweep reads order_records directly on fxStampedAt IS NULL AND reportingCurrency IS NULL, scheduled hourly per OrderSource-capable connection - the guarantee that survives a dead retry job, since a job's idempotency key is globally unique with no TTL and the ~4.3h retry window means a longer outage would otherwise lose the stamp permanently. Refs #2125 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(api,orders): currency-settings API surface Phase 4 (backend half) of the order-time FX stamp (#2126, ADR-040). GET /currency-settings and PUT /currency-settings/reporting-currency, both admin-only, mirroring /ai-provider-settings' route naming and its withDomainExceptionMapping boundary split: the ISO-shape failure is 400, an unreachable-but-well-formed code is 422 carrying the accepted set. The coverage advisory and the stamped-row counts are composed in the controller, the one layer allowed to combine currency with orders - doing it inside CurrencyRateService would create a currency -> orders edge and cost that context its leaf property. IOrderFxReadService is the narrow cross-context seam: listDistinctNativeCurrencies (already published) plus the new countStampedByReportingCurrency, grouped by reporting currency rather than totalled because the era breakdown - not a bare total - is the operator-facing fact behind "changing this setting splits history." Refs #2126 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(adr): propose order analytics read-model persistence strategy (#1985) Records the persistence-strategy decision for #1985's order analytics substrate: denormalized order_records scalars + a new order_line_items table, live-queried (no materialized view). Serves as the ADR the issue's own acceptance criteria requires before implementation starts. Refs #1985 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(orders): add order analytics read model (#1985) Makes order data analytically queryable without JSON expansion: - 4 new denormalized scalar columns on order_records (placedAt, currency, taxTreatment, totalAmount), mirroring the existing dispatchByAt/ fulfillmentState precedent (ADR-039). - New order_line_items table, one row per order line, written transactionally alongside order_records in OrderRecordRepository. upsertWithLineItems (delete-then-reinsert, idempotent under re-ingestion). - OrderRecordService.persistOrder derives both via the new pure order-analytics-projection helpers and persists them together. - Migration adds the schema additively and backfills existing rows idempotently. No new HTTP endpoint — this is the substrate #1987/#1988 will build aggregate reads on top of. Cancellation exclusion is deliberately left to Refs #1985 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): resolve migration timestamp collision and merge-broken tests (#1985) - Re-timestamp the order-analytics migration 1832000000008 -> 1833000000004: it collided with #1984's add-order-record-cancelled-at.ts (same prefix) and sorted before origin/main's current tail. check-migration-timestamps.mjs now passes. - Fix order-record.entity.spec.ts's makeRecordWithCancelledAt: the merge with #1984 inserted 4 new positional constructor params before cancelledAt, so the helper was silently passing its argument into placedAt instead. - Fix order-record.service.spec.ts's markCancelled describe block: persistOrder now calls repository.upsertWithLineItems, not repository.upsert; the old mocks were never hit. - Document the order_line_items table + new OrderRecord scalars in architecture-overview.md Orders section (ADR-039 reference), matching the existing dispatchByAt/fulfillmentState documentation precedent. Refs #1985 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(analytics-trust): real per-connection earliest-order-date read (#2121) * feat(analytics-trust): real per-connection earliest-order-date read (#2083) Replace connectionCreatedAt's coverage-window role with a real MIN(COALESCE(placedAt, createdAt)) read over order_records, batched once across all enumerated connections rather than per-connection. Adds OrderRecordRepositoryPort.findEarliestPlacedAtByConnection and the IOrderRecordService cross-context seam analytics-trust consumes it through. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): document earliest-order-date is unfiltered by recordStatus (#2083) Tech review of PR #2121 flagged that findEarliestPlacedAtByConnection's MIN(COALESCE(placedAt, createdAt)) had no stated scope for source_deleted / awaiting_mapping / failed rows, unlike getFailedSyncValueSummary's explicit NOT_MAPPING_OR_DELETED gate. Make the (deliberate) inclusion explicit in the port, service interface, and repository JSDoc, and pin it with a regression test asserting no andWhere/recordStatus predicate is applied. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics-trust): isolate earliest-order-date lookup failures (#2083) Wraps the batched getEarliestOrderDateByConnection call so a transient DB error degrades to an empty Map (every connection reports earliestOrderDate: null) instead of throwing out of the whole /analytics/trust snapshot - restoring the per-connection isolation guarantee this service documents about itself (PR #2121 review finding 1). Also adds a Testcontainers integration test for the real MIN(COALESCE(placedAt, createdAt)) GROUP BY query (finding 2). 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> * fix(core/orders): stop the order upsert wiping syncStatus and syncAttempts (#2141) * fix(core/orders): stop the order upsert wiping syncStatus and syncAttempts `OrderRecordRepository.toOrm` mapped `syncStatus` and `syncAttempts` unconditionally while `persistOrder` / `persistIncomingSnapshot` pass `[]` for both, so every re-ingestion of an order - a poll re-read, a webhook-triggered sync, a manual re-sync - wrote those empty arrays over what `updateSyncStatus` had committed out-of-band. Same mechanism as #2101, for the two columns that fix did not cover; its exclusion comment sat directly below the offending assignments. For `syncAttempts` the loss is irreversible: the JSONB array is the store, and nothing rebuilds it. The worst case is the operator-retry path the column was built for (#456) - the retry appends a `pending` attempt, enqueues `marketplace.order.sync`, and the resulting re-ingestion erases both that entry and the original `failed` one, so the activity timeline renders a bare `synced` and the failed -> retried -> synced narrative is silently gone. For `syncStatus` the gap lasts as long as the destination order-create calls take. In it the retry action 404s (`OrderDestinationNotFoundException`) and fulfillment tracking skips the order, because neither can resolve a destination row. It is permanent whenever the writeback never runs at all: no destination resolves, a previously-synced destination dropped out of the fan-out, or a throw or process death lands in between. Exclude both columns from the upsert's write set, exactly as `fulfillmentState` (#2101) and `cancelledAt` (#1984) already are, leaving `updateSyncStatus` as their sole writer. Reading the row first and carrying the values forward was the alternative, but an unlocked save still loses an append that commits between that read and the write; omitting the columns is race-free. No migration: both columns are already `NOT NULL DEFAULT '[]'` in Postgres (`1770000000000-add-order-records-table`, `1793000000000-add-order-record-sync-attempts`, neither altered since), and TypeORM emits `DEFAULT` for an undefined column value on Postgres, so an insert that omits them resolves to an empty array. Only the `syncStatus` ORM decorator was missing the matching `default`, which this adds - metadata drift, not a schema gap. `toDomain` now reads `syncStatus` through `?? []`. The update path carries no RETURNING clause, so the entity `save()` hands back still has the property unset; `syncAttempts` was already guarded, `fulfillmentState` and `cancelledAt` are nullable scalars, which is why #2101 never hit this. Adds unit coverage that neither property reaches `save()` (including when a domain record carries values) and that the upsert's return reads both as empty, plus an integration test proving a committed `syncStatus` / `syncAttempts` survives a second `persistOrder`, that a first-time persist still inserts and reaches the DB default, and that the operator-retry flow keeps its earlier `failed` attempt and its retryable destination row across the re-ingestion. Closes #2140 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(api,core/orders): guarantee the syncStatus DB default the upsert now relies on Review follow-up to #2140. syncStatus was excluded from the upsert's write set, so an INSERT that omits it emits the literal DEFAULT and the column must supply the empty array itself. That default is not guaranteed: 1770000000000 wraps its CREATE TABLE in `if (!table)`, so a database whose order_records was first built by TypeORM synchronize took the early-out and got the column from the ORM decorator, which carried no default before #2140. There, DEFAULT resolves to NULL against a NOT NULL column and breaks all order ingestion. Adds an idempotent, metadata-only ALTER COLUMN ... SET DEFAULT '[]'. syncAttempts needs no counterpart: 1793000000000 adds it as an unconditional ADD COLUMN ... NOT NULL DEFAULT '[]' that cannot have been skipped, and its decorator has always carried the default. Also corrects two comments that misstated what is proven where. The integration harness builds its schema with synchronize, not migrations, so the first-insert assertion exercises the ORM decorator default - which makes that decorator load-bearing for the suite rather than cosmetic drift removal, and means nothing in CI covers the migration-built schema. Extends the retry int-spec through to synced so the failed -> retried -> synced timeline of #2140 AC 5 is asserted literally, and consolidates the three interleaved exclusion-rationale blocks in toOrm into one block at the top of the method - the interleaving is what let a fresh assignment land in the gap between two of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013dbAZYEDfwdssQeaPahn1j Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> --------- Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(invoicing,orders,web): persist and surface the auto-issue block reason (#2100) (#2129) * fix(invoicing,web): lock an order to one invoicing connection (#2047) One sale is one invoice. KSeF, inFakt and Subiekt are alternative routes for that one document to reach the authority, not complementary steps, but OL treated them as complementary at three layers. Auto-issue fan-out: `AutoIssueTriggerService.onOrderTransition` iterated EVERY active connection with the `Invoicing` capability and enqueued an issuance job for each, keyed `invoice:{connectionId}:{orderId}` so it could never dedup across connections. It now resolves EXACTLY ONE connection via the pure `selectPrimaryInvoicingConnection` over the new operator-set `config.invoicing.isPrimary` (read with `parseIsPrimaryInvoicing`, mirroring the `parseTriggerModel` coercion precedent). A lone candidate still issues regardless of the flag, so a single-connection install is unchanged. With several candidates and no unambiguous primary it issues NOTHING and logs an error naming the ambiguity: a missing invoice is fixable by hand, two issued documents for one sale need a correction of a document that should never have existed. Write-path guard: `InvoiceService.issueInvoice` now refuses, before the idempotency gate and before any row is created, when the order carries a BLOCKING record on a different connection - `OrderAlreadyInvoicedException`, mapped to 409 with the issuing connection and blocking invoice id in the body. Blocking is the new pure entity derivation `blocksIssuanceElsewhere`: it covers `pending`, `issuing` (lease-independent), `issued`, AND `failed` with any `failureMode` other than `rejected`. That last arm is the point: `in-doubt` means the provider MAY have created a document, so issuing elsewhere is the duplicate this guard exists to prevent - the FE's `canRetryInvoice` has treated it that way since #1240. Records on the requested connection are untouched, so per-connection replay/retry semantics are unchanged. Connection-agnostic read: `connectionId` becomes optional on `GET /invoicing/orders/:orderId/invoice`. With it, behaviour is byte-identical; without it the endpoint answers "is this order invoiced anywhere?" via the existing `getLatestInvoiceForOrder`. Requiring it was the root cause of the FE defect. Frontend lock: the panel reads the invoice without a connection (query key is `forOrder(orderId)`, no longer per-connection) and, once a record exists, renders the issuing connection as a read-only `InvoiceConnectionLock` instead of a `Select`. Switching that picker used to read `(order, other connection)`, get a 404 that the hook maps to null, render "not issued", and offer an Issue button for an already-invoiced order. The picker survives only for an order with no record and more than one candidate, where the primary is preselected and labelled and a missing primary is surfaced as the warning that explains why auto-issue did nothing. A record whose connection is disabled or deleted still renders the invoice with actions disabled and no alternative connection offered. A `failed` + `rejected` record is the one state where moving providers is fiscally safe, so it sits behind an explicit disclosure that names the consequence, never as a side effect of Retry. Closes #2047 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HpwFwSVZYF7nopZ5S3Peet Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(invoicing,web): address review findings on the connection lock (#2047) Seven follow-ups from the review of the one-invoice-per-order change. The primary flag gets an editor. Without one, an install with two invoicing connections and no primary stops auto-invoicing entirely and the panel's "Set a primary" link pointed at a page carrying no such control - the only remedy was a hand-written config PATCH. `InvoicingPrimarySection` is CAPABILITY-gated rather than platform-gated (KSeF / inFakt / Subiekt are alternative routes for one document, so the rule cannot live inside any one provider's section), writes NESTED `config.invoicing.isPrimary` through the same merge seam `subiektTriggerModel` uses, and deletes rather than persists `false` because the backend reads absence and explicit `false` identically. The panel's link now deep-links to a real candidate connection. Pre-existing cross-connection duplicates stay visible. The panel renders only the latest record, so an order that already carried documents on two providers - exactly the population this issue exists for - lost the older one from view. The connection-agnostic GET now reports `otherInvoicingConnectionIds` (omitted entirely when there is nothing to report, and never computed for a caller that named a connection), backed by `listInvoiceConnectionIdsForOrder` over the `findAllByOrderId` read the guard already performs. The panel names them. The lock warning no longer disappears at the moment it matters. It was gated on a primary existing, so on an install with none, picking a connection cleared the "auto-issue is off" warning and rendered no lock warning in its place. A `manual` primary is now diagnosable. Selection resolves the connection before the trigger model is read, so a primary on a `manual` connection turns auto-issue off for the whole install while a sibling `auto-on-paid` connection is never consulted. That is the operator's call, but it was indistinguishable from "the trigger never fired"; warned once per connection, PII-clean. Bulk-issue stops claiming an `invoiceId` it did not produce. The DTO documents the field as this batch's own record; on a cross-connection block the id belongs to another connection, so it moves into the neutral `reason`. Also: the unreachable "selected connection vanished" branch logs instead of returning silently, in a method whose contract is "never quietly do nothing"; and the failed+rejected row renders the retry-safety hint alongside the provider-switch button instead of treating them as alternatives, so an operator with a second connection still learns why Retry is safe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(web/invoicing): point "Set a primary" at the edit form, not the detail page (#2047) Caught by driving the fix on a live stack rather than by unit test: the deep-link landed on `/connections/:id`, which renders Overview + Enabled roles and carries no config form at all. The primary toggle lives on `/connections/:id/edit`, so the link still dead-ended - it just dead-ended one page further along than `/connections` did. The panel test now pins the full path, so a future route change fails here rather than being discovered by an operator hunting for a setting that is one click away and unlabelled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * test(api,worker/invoicing): update the integration suites to the #2047 lock contract Three integration expectations still described the pre-#2047 world: - GET /orders/:orderId/invoice asserted 400 when `connectionId` is absent, but #2047 deliberately made the param optional so a caller can ask "is this order invoiced ANYWHERE?" before it knows the issuing connection. Replaced with coverage of the new branch (newest record across connections + otherInvoicingConnectionIds) and its 404. - findAllByOrderId seeded 'conn-a' / 'conn-b' into `connection_id`, a real `uuid` column, so Postgres rejected the insert before the assertion ran. - The auto-issue "per-connection isolation" case asserted the old fan-out across every matching trigger model; several eligible connections now resolve to ONE primary, and an unresolved primary issues nothing. Rewritten as three cases covering the lock: primary wins, no primary issues nothing, manual primary disables the whole install. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(invoicing): serialize originating-document issuance per order (#2047) Addresses the review on PR #2060. BLOCKING — the one-invoice-per-order guard was read-then-act. `assertNotInvoicedElsewhere` is a plain `findAllByOrderId` -> `find`, so two concurrent attempts on DIFFERENT connections for a not-yet-invoiced order both read `[]`, both passed, and both created a row: the `(connectionId, idempotencyKey)` unique index cannot collide across connections, so both then crossed the provider boundary and one sale got two real fiscal documents — the exact outcome #2047 exists to prevent. The PR body claimed the guard survived "two tabs racing"; it did not. `issueInvoice` now holds a per-ORDER `SyncLockPort` lock around guard-through- create (`invoice:issue:{orderId}`, TTL `OL_INVOICE_ISSUE_LOCK_TTL_MS`), keyed per order rather than per (order, connection) for the same reason `shipmentDispatchLockKey` is (#1917): two operators picking different providers for one order is precisely what a per-connection key would let through. A contended attempt answers from PERSISTED STATE ONLY, in the order the locked path would — truthful already-invoiced refusal, then an `issued` same-key row replayed verbatim, else the new retryable `InvoiceIssueContendedException` (409) — so it can never be the second document. TTL expiry is not a correctness cliff: the covered window is two DB round-trips, past which a `pending` row exists that a peer's own guard sees. `issueCorrection` is deliberately not locked — a correction is a linked follow-up of an `issued` original, outside the ADR-041 3b invariant. Tests: (n) is the regression itself — two different-connection attempts, a real in-test store behind `findAllByOrderId`, asserting one create + one provider call + one row. (n2)-(n6) pin each contended branch, release on both paths, and release-failure not masking the result. (m2) updated: same-key concurrency now refuses at the outer lock before reaching the CAS, which remains the defence in the window the lock cannot cover. Also from the review: - name the deferred follow-up for the log-only auto-issue block (#2100) in `auto-issue-trigger.service.ts`, per ADR-041 54/105 - drop the features -> features `Connection` import in the FE resolver for a local structural type, with every returning helper generic over it so the panel keeps its concrete type - document why `assertNotInvoicedElsewhere` logs at `warn` (the guard working, and raised to the caller) vs the auto-issue ambiguity's `error` (nothing is raised — the install silently stops issuing) - assert `INVOICE_SERVICE_TOKEN` resolves in the worker DI boot gate, so the new `SYNC_LOCK_TOKEN` injection cannot regress unnoticed - record the invariant + lock in `docs/architecture-overview.md` Invoicing and add the real `invoicing -> sync|integrations|identifier-mapping` edges to the cross-context dependency map Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(invoicing,orders,web): persist and surface the auto-issue block reason (#2100) When OpenLinker decided not to issue a fiscal document for a qualifying order, that decision existed only in a log line. ADR-041 §54/§105 state the contrary twice: a block is never log-only, because "OL silently declined to issue" is as opaque to an operator as a wrong pick would be dangerous. An install where auto-invoicing had silently stopped for every order looked completely normal on /orders and /invoices. This lands decision 11's first implementing slice. - New `libs/core/src/sales-documents/` concern (ADR-041 decision 1, "module now, context later") holding the two reason unions verbatim, kept separate because they answer different questions, with `'unresolved-routing'` as the one bridge value. A dependency-free leaf, so any context can value-import it without closing a module-load cycle. - `AutoIssueTriggerService.onOrderTransition` now RETURNS a `SalesDocumentBlock` instead of persisting one. That split is load-bearing: persisting in place would need an OrdersModule token inside InvoicingModule, closing the runtime DI cycle its ONE-WAY EDGE property (F3) exists to prevent. The caller already lives in `orders` and owns the write. Every existing log line is kept — the reason is additive. - Three reasons are reachable: the #2047 ambiguity (as `unresolved-routing` + `ambiguous-connection-no-primary`), `trigger-model-manual` and `trigger-model-batched`. `missing-required-tax-id` and `tax-rate-conflict` ship declared but never written, with their prerequisites named in code. - Persisted on `order_records` in three nullable columns, deliberately omitted from `toOrm` (the `cancelledAt` single-writer precedent): `persistOrder` runs before the gate on every ingestion, so round-tripping them would null-then-reset the value and let a stale read stomp a reason a peer transition just wrote. - The write is level-triggered, not sticky. `null` is written through as the answer "nothing is blocking this any more", which is what clears the badge — plus an explicit best-effort clear on both manual-issue paths, because fixing the config and issuing by hand fires no transition. - Operator surface follows #1689's `source_deleted` treatment: a row badge on /orders replacing the "Issue invoice" CTA (an order OL already refused is not one waiting for a click; manual keeps the CTA because issuing by hand IS its configured workflow), a counted filter chip, an undated timeline entry, and the order-detail panel reading the persisted reason instead of re-deriving the ambiguity client-side. Two deliberate deviations from a literal reading of the acceptance criteria, both recorded in the plan and the PR body: 1. The count ships as a non-partitioning `salesDocumentBlocked` field plus a filter chip, NOT a sixth `OrderHealth` bucket. `deriveOrderHealth` returns exactly one bucket and its SQL twins partition the set, so a sixth value would either double-count or hide a sync failure behind an invoicing one — a blocked order is usually also `synced`. 2. Blocked orders are NOT excluded from bulk issuance. `POST /invoices/bulk-issue` names its connection explicitly, so every reachable reason means "auto-issue did not happen", never "this order cannot be invoiced"; excluding them would break the primary remediation path for the state this surfacing exists to reveal. The FE mirror of the reason union is enforced by a new `scripts/check-sales-document-reason-mirror.mjs` under `pnpm check:invariants`, not by a "keep in sync" comment. Refs #2100 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * test(api/orders): add the block-reason mock to the refunds controller spec `refunds.controller.spec.ts` arrived with #2046 on main and mocks `IOrderRecordService`, which gained `markSalesDocumentBlock` on this branch. Only the full `pnpm type-check` catches this class of merge gap — the package-scoped check had already passed before the catch-up merge. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(invoicing,orders,web): make the block invoice-aware and stop it self-contradicting (#2100 review) Review round 1 found two BLOCKING defects that were the same mistake seen from two ends, plus 16 IMPORTANT/SUGGESTION items. Every one is addressed here. BLOCKING 1 — the gate was not idempotent against its own effect. `manual` (and any reason derived from configuration rather than from the order) stays true after the document exists, so the gate re-reported it on the next routine transition and the block landed back on an order the operator had already invoiced by hand. The aggregate count included invoiced orders, the filtered rows rendered with no badge (the list suppresses on the invoice projection), and the order-detail timeline claimed "No invoice issued" directly under the panel showing the invoice. `AutoIssueTriggerService` now reads the order's own document projection before reporting any block. `INVOICE_SERVICE_TOKEN` is a SAME-context dependency — InvoicingModule provides both services — so it forms no module cycle and does not touch the F3 one-way edge, which is specifically about OrdersModule tokens. The read happens only on the would-be-blocked paths, so the happy path is unchanged, and a read failure yields `indeterminate` rather than inventing or erasing. BLOCKING 2 — the filter chip was count-gated, so it unmounted the moment the remediation succeeded, stranding `?invoicing=blocked` with no control to clear it and an empty state that claimed no orders had ever synced. The chip now renders whenever the filter is active, and the empty state has an arm for this param whose recovery button clears it. Three contract changes came out of the round: - `onOrderTransition` returns a three-armed `SalesDocumentBlockOutcome` (`none` / `blocked` / `indeterminate`) instead of `SalesDocumentBlock | null`. Collapsing "nothing is blocking" and "could not tell" into one value is what let a deterministic compose error erase a legitimate reason and replace it with nothing at all — no invoice, no badge, no count, no job row, i.e. the exact silent decline ADR-041 §54 forbids. Three of the four errors the trigger allow-lists as deterministic reach that path. - The aggregate counts only `SalesDocumentAttentionReasonValues` — everything except `trigger-model-manual`, which is `parseTriggerModel`'s DEFAULT. On a manual install every uninvoiced order carries it, so the previous `IS NOT NULL` predicate put a red "Invoicing blocked 4,312" on a healthy install. The per-order badge still renders manual, neutral. The IN-list also stops counting a stored reason this build cannot label, which previously produced a number with no reachable explanation. - `OrderIngestionService` skips the write when the outcome matches what is already persisted. The gate is level-evaluated and the common answer is `none` on an already-unblocked order; writing it anyway cost a second UPDATE and an `updatedAt` bump per ingestion, and `updatedAt` is a live filter axis. The comparison uses the pre-persist record already in hand. Also fixed: - `POST /invoices/retry` and `issueCorrection` now clear the block (only the single and bulk issue paths did). - The invoice-suppression rule moved into `invoicingBlockedBadge` as a parameter, so the list AND the timeline share one rule; the timeline had none, which is what produced the contradiction above. The page-local `useCallback` that closed over nothing is gone. - `?salesDocumentBlocked=yes` now 400s instead of silently returning the unfiltered list while the chip renders as applied — matching both in-repo boolean-query precedents. - `BLOCK_REASON_BY_TRIGGER_MODEL` links the two vocabularies, so renaming a trigger model is a compile error rather than a silently stale reason string. - The badge table is `satisfies Record<SalesDocumentGateBlockReasonValue, …>`, so a new reason is a compile error rather than an unlabelled row. - `barrel-purity.spec.ts` gained `sales-documents` plus an assertion that the concern has no import statements at all — the "dependency-free leaf" property three docblocks call load-bearing was previously unenforced. - `.chip.chip--active` raises specificity so `active` wins over the tone modifier; before this a toned filter chip differed only by font-weight between on and off. - `aria-label` alongside `title` on the badge, matching the "est." marker in the same file — the hint was the only statement of why on that surface and was unreachable by keyboard. - `resolveSalesDocumentBlockCopy` moved to `features/invoicing/lib/` with a table-driven test covering all seven branches; three were reachable before, only through a component render. - Prose corrected where six docblocks said "two columns" for three. - Docs: `sales-documents` is now § Core Bounded Contexts 17 with both edges in the dependency map (the § Invoicing bullet had promised exactly that "when the code lands"), the tokens-file exemption is recorded in engineering-standards, and ADR-041's implementation note carries the two lessons for the #1908 router. New coverage: gate outcome per arm inclu…
Docs-only: ADR-040 + the implementation plan for #2049. No production code, no migration, no
libs/orapps/change — the ADR isProposedand the plan is what the implementation PR executes.What the ADR decides
At ingestion, stamp every order with the amount it represents in a reporting currency, plus an immutable reference to the rate used — so analytics can report one figure per period instead of an un-summable pile of per-currency totals. Read-time conversion is rejected: only the rate that applied at placement is defensible, and it must survive every re-ingestion.
The substantive question is converted into what?, and the answer changed during review. Three things were being conflated — the currency the buyer paid in (a fact about the order), the currency a shop prices in (a fact about a connection,
config.currency/ #362), and the currency we report in (a choice made by the business). Deriving the third from the first two is a category error, and per-connection values produce an estate with no single total, which is the problem the feature exists to solve. So:settings row → OL_REPORTING_CURRENCY → 'EUR', on theai_provider_active_settingsingleton shape. Validated at save time; renders asEUR (default)until explicitly set.config.currencykeeps its meaning and is not consulted.PLN → NBP,EUR → ECB, one call per order that needs one. Because NBP quotes everything against PLN and ECB quotes EUR against everything, every pair is direct or a single documented inversion; no pivot arises for either supported value.exchange_ratesregistry keyed(source, from, to, rateDate)— 500 EUR orders on one day resolve to one row. Direction is a stated invariant: a stamp is alwaystotal × rate, never a division.persistOrderupsert path, so re-ingestion cannot move a reported figure.libs/core/src/currency/with no outbound HTTP; every adapter in a new@openlinker/integrations-fxpackage — theAiCompletionPort/@openlinker/integrations-aisplit, and not conditioned on whether a source needs a credential.PUTreports how many rows already carry a stamp so the two-era split is accepted knowingly; restatement is filed as [TASK] CORE - Restate already-stamped orders when the reporting currency changes #2096.Invoicing computes its own rate and does not consume this stamp — they differ on date (placement vs the art. 19a tax point, which for a prepaid marketplace order is the payment instant no shipped source persists), target (always PLN statutorily) and derivation (a statutory rate must be a directly published table-A quote). FA(3) draws the same line:
KursWalutyis scoped to dział VI ustawy,KursUmowny/WalutaUmownato "nie dotyczy przypadków, o których mowa w dziale VI".Changed from the first draft of this PR
The original ADR argued the opposite on three of these. It has been rewritten, not patched:
Connection.config.fx+readFxConfig+ aConnectionPortread in the FX pathlibs/core, "the first outbound HTTP call inlibs/core", bounded by a credential-based split rule@openlinker/integrations-fx; the split rule is dropped entirelyKursWalutymust consume this stamp"order_recordscolumnsfxIntendedCurrencycarries the first-attempt snapshotplacedAtmapping folded inThe plan follows the same shape: the ladder step is deleted, Phase 1 splits into a core half and the new package, a settings stack and the intent snapshot are added, and Phase 4's three connection-setup fields and Allegro OAuth thread are replaced by a settings tile. Roughly 60% of the document changed; the verified line-number references and in-repo precedents that were unaffected are untouched.
Two things a reviewer should look at directly
The group-integrity CHECK had to change. Its first arm required
fxRule IS NULL, but the intent claim writesfxRulewhilereportingCurrencyis stillNULL— so the pre-revision constraint would have rejected every intent row and made the snapshot unimplementable.fxStampedAt IS NULLstops meaning "unstamped" once a deferred row exists. The plan carries a five-state table for the column group, because a consumer using the wrong predicate silently conflates "in flight" with "unstampable".Numbering
ADR-040 stays.
maincarries 035–038 + 041 with 039 / 040 / 042 open;main's own reserved-numbers note already reads "040 by #2050"; and #2066 claims 043 / 044 / 045, so renumbering to 043 would create the collision rather than avoid one. This PR's merge resolves the README index conflict by keeping both rows (040 here, 041 frommain) and corrects the reserved-numbers note, which was stale in three places. Mechanical guarding is #2082.Not in this PR
placedAtmapping — [BUG] Integration - WooCommerce order source does not populate placedAt (blocks FX stamping, leaves invoices without a saleDate) #2097. Consequence accepted and documented: a foreign-currency WooCommerce order stays terminal-unstamped, and the demo therefore shows an unstamped count rather than a conversion.docs/architecture-overview.md§ Currency section — added by the implementation PR; the ADR now says so rather than asserting it exists.Known blocker for the implementation, not for this PR
ECB's daily XML feed carries only the latest day, so it cannot serve a
prev-business-dayrule. The historical/SDMX endpoint's response shape and its non-publication-day behaviour (404 vs empty series vs nearest prior day) need the same treatment the NBP section got beforeEcbExchangeRateAdapteris written. Flagged in the plan as a Phase 1b blocker; every other phase is unblocked.Test plan
Docs-only.
prettier --checkpasses on all three files. No code, solint/type-check/testhave nothing to exercise; the plan's own § 9 carries the test contract the implementation PR is held to, including the manualmigration:run → revert → runround-trip that CI cannot cover (nothing in CI or the harness runs a migration).Refs #2049