Skip to content

feat(core,currency,orders): order-time FX rate snapshot + reporting-currency stamping - #2135

Merged
norbert-kulus-blockydevs merged 28 commits into
mainfrom
2049-order-fx-rate-snapshot
Aug 20, 2026
Merged

feat(core,currency,orders): order-time FX rate snapshot + reporting-currency stamping#2135
norbert-kulus-blockydevs merged 28 commits into
mainfrom
2049-order-fx-rate-snapshot

Conversation

@norbert-kulus-blockydevs

@norbert-kulus-blockydevs norbert-kulus-blockydevs commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Implements ADR-040 (merged in #2050) end to end: every order is stamped at ingestion with the amount it represents in a system-wide reporting currency, plus an immutable reference to the rate used.

Executes docs/plans/implementation-plan-2049-order-fx-rate-snapshot.md, phases 0-5.

Sub-issues

Each lands on its own branch, merged into this epic branch with --no-ff, and is reviewed before it merges.

UI reference

docs/plans/mockups/order-fx-stamping-2049.html (added in this PR) covers every surface the stamp touches, built against the real design system, and records the four design decisions taken outside ADR-040.

Warning while this branch is in flight

libs/core/src/currency/ ships two @Entity classes - exchange_rates and reporting_currency_setting - before their migration, which lands with #2124. They materialise only via synchronize until then, which is fine for dev and the integration harness.

The consequence to watch: apps/api/src/database/data-source.ts discovers entities by filesystem glob, so anyone running migration:generate on this branch for an unrelated change will silently fold both tables into their migration. If you need a migration here before #2124 merges, check the generated DDL and strip anything currency-related.

Status

Draft. Un-drafts only after: every sub-issue merged and reviewed to approval, then a live end-to-end run with Playwright screenshots posted as a comment here.

Closes #2049
Closes #2122
Closes #2123
Closes #2124
Closes #2125
Closes #2126
Closes #2127

@norbert-kulus-blockydevs

Copy link
Copy Markdown
Collaborator Author

Live E2E verification done

Booted the full epic branch (all six phases merged, plus the one final integration /pr-review pass) on a real stack — fresh Docker images, real Postgres, real PrestaShop, 81 real demo orders.

Report with screenshots: https://claude.ai/code/artifact/653b0213-91c2-4910-a909-e120051bf0dd

Three deploy-only bugs found and fixed — none catchable by lint, type-check, or code review

  1. Dockerfile never learned @openlinker/integrations-fx exists. The image build hand-enumerates every workspace package for layer-caching COPY; the new FX package was missing from all three lists across all six phases. docker build failed outright.
  2. OL_REPORTING_CURRENCY was set on the demo compose's api service but not worker. Order ingestion and the retry/sweep jobs all run in the worker process, and the settings resolution's env fallback is per-process — a fresh deployment would have silently stamped in EUR (the code default) instead of the intended PLN. Caught by the final review pass; confirmed live.
  3. The migration's own unit spec crashed migration:run. TypeORM's CLI globs and require()s every file under migrations/ directly; a colocated __tests__/*.spec.ts hit a bare describe() with no Jest runtime present. Every migration attempt exited 1. Only a real boot could ever have found this — nothing in CI or the Testcontainers harness runs a migration.

Live proof

One real PrestaShop demo order (19.99 EUR) was converted through a genuine NBP API call and stamped 86.80 PLN — hand-verified: 19.99 × 4.342 = 86.79658 → 86.80. Rate: NBP table 137/A/NBP/2026, direct derivation. The exchange_rates registry deduplicated correctly live: 8 orders stamped, only 4 distinct rate rows.

Status

PR #2135 stays draft pending your own final check, per plan.

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Tech Lead review — 🔄 Approve with changes

Unusually strong work. The highest-risk parts of this feature — quote direction, snapshot immutability, missing-rate handling — aren't merely correct, they're defended: the direction invariant is stated in the type, encoded structurally as from/to rather than by naming convention, and pinned by tests that assert an exact real-world magnitude together with the derivation kind. No BLOCKING defect. One IMPORTANT issue — a transient 429 classified as terminal, combined with a terminal marker that has no operator recovery path — is cheap to fix and I'd fix it before merge.

Scope note: no ADR file is in this diff. ADR-040 already landed on main separately; heading matches filename, no collision with 041–045.

The settled design decisions

# Decision Status Evidence
1 System-level, no per-connection override Singleton reporting_currency_setting; no Connection.config read anywhere in libs/core/src/currency/
2 Chain is exactly row → env → 'EUR' reporting-currency-settings.service.ts:71-74, :88-93. No history-derived rung exists
3 The default converts; no post-first-stamp lock No lock guard in setReportingCurrency — validates shape + coverage only
4 Both providers in libs/integrations/fx/; no HTTP in core git grep -nE "fetch\(|axios|HttpService|undici" over libs/core/src/{currency,orders}zero hits. libs/integrations/fx added to check-outbound-http.mjs:58
5 Invoicing computes its own rate No invoicing file touched at all

Quote direction

Direction is a contract, not a convention: rate is to units per one from unit (exchange-rate.types.ts:6-11, :88-102), so a consumer always multiplies — and the single conversion site does (order-fx-stamp.service.ts:168).

The part I most wanted to check is the pivot: the numerator/denominator order is deliberately opposite between the two adapters, because NBP and ECB publish mirror-image quote directions (nbp:183-191 vs ecb:245, documented at rate-arithmetic.ts:61-67). That's exactly where an inversion would hide, and it's right.

Tests would catch an inversion — exact value + kind + leg pair asserted together, at magnitudes where an inversion is unmistakable (4.25000000; 0.23529412 with kind:'inverted'; pivot 1.08974359 with the flipped-divide tell 0.917… named in the comment). Plus an explicit negative test: "should never divide — the rate is applied as a multiplier, not an inverse". That's the standard I'd want on every future FX consumer.

Snapshot immutability

The strongest instance of the toOrm rule so far — fifth application after #1984 / #2107 / #2141 / #2129. All six FX columns are excluded from the ingestion write set with the reason stated at the assignment block and in the upsert doc. The sole writers are two guarded single-statement conditional updates (claimFxIntentIfAbsent, stampFxIfAbsent), both answering via affected > 0. All five stamp columns move in one statement so the group can't half-apply, with the DB ck_order_records_fx_group CHECK as backstop, and applyStamp treats a false return as a normal outcome and adopts the winner rather than retrying.

Worth calling out: the intent-pinning design makes the currency immune to a setting change between the inline attempt and a sweep hours later. That's the subtle failure this feature would otherwise have shipped with, and it's pinned by two tests.

IMPORTANT

1. A 429 or 408 is classified TERMINAL, and terminal is permanent with no recovery path. nbp-exchange-rate.adapter.ts:291-305 throws RateUnsupportedPairError for any non-404 4xx; order-fx-stamp.service.ts:327-328 maps that to terminal; recordTerminal writes fxStampedAt; and findUnstampedFxOrderIds filters on fxStampedAt: IsNull() — so the row leaves the sweep frontier forever. There's no re-open (git grep "clearFx\|resetFx\|unmarkFx" → nothing).

NBP is a public unauthenticated API and 429 is exactly what it returns under a burst, which the sweep's sequential page walk can reach. Net effect: a transient throttle permanently costs those orders their reported figure, silently. Add 429 and 408 to the >= 500 transient arm in both adapters. The comment at :291-296 is honest that the 4xx class was never verified against the live API — that uncertainty is itself the argument for excluding the two codes that are unambiguously transient. Separately, consider an admin re-open, or letting the sweep revisit a terminal row older than N days, since no-rate-source has the same permanence.

2. No User-Agent on outbound FX requests. fx-http.client.ts:68-72 sends only accept. Public central-bank APIs commonly throttle or filter anonymous UA-less clients, and there's no local backoff to absorb the result — which is what makes finding 1 more than theoretical.

SUGGESTION

  1. ecb-exchange-rate.adapter.ts has no logger at all while NBP debug-logs its walk-back — an ECB failure is currently silent at the adapter layer.
  2. Migration header :17 says the tail on main was 1833000000005; it's 1833000000006. The claimed slot is still strictly greater so the invariant holds — but the note is what the next author will trust. Slot 1834000000000 is free and no longer contested, since the fiscalization stack moved to 1835000000000.
  3. NBP_MAX_WALK_BACK_DAYS = 7 with a <= loop yields 8 attempts; the spec asserts <= 8, so intent is pinned but the constant name understates by one.
  4. ECB accepts an observation stale by up to 10 days without a log line. Correct as a backstop, but a 6-day-stale rate stores silently; a warn above ~3 days would surface a degrading source.
  5. enqueueRetry's key is fx:{internalOrderId} with no wave component — the shape #2039 had to fix for refreshSnapshot. Defensible here since the sweep is an unconditional backstop, but if a first retry dead-letters the key is spent and the sweep is the only remaining route. Worth saying that's intended.
  6. A11y — the two reactive warnings in the edit dialog (currency-settings-dialog.tsx:114 era-split, :150 coverage-gap) appear on <select> change but sit in no live region, so a screen-reader user isn't told a warning materialised — and the second gates Save via a required acknowledgement. Add role="status" to at least the coverage-gap Alert.
  7. No boot-time assertion that the provider registry is non-empty. This reinforces finding 1: if a host ever omits FxIntegrationModule, two things degrade silently and in opposite directions — every stamp classifies terminal and permanently marks the row answered, while listSelectableCurrencies returns [] so every settings PUT 422s. Both plugin lists carry the module today, so this is fragility rather than a live defect — but the assertion converts silent unrecoverable data loss into a loud startup failure.

Positive observations

  • The registry is append-only by construction, not convention: insertIfAbsent builds an entity with no id, so save() can only INSERT. PG 23505 matched on the code, never the message.
  • rate stays a string end-to-end into numeric(18,8) and is deliberately not Number()-ed in toDomain, against the repo's usual money convention, with the reason given. The stored artefact is the rate, so amounts stay recomputable rather than lossily pre-converted. Rounding happens once. And declining to reuse pricing-rule.types.ts's round2dp because its Math.max(0, …) clamp would turn a refund into 0.00 is exactly the reasoning that prevents a silent financial defect.
  • toRateString throws RangeError on any non-positive or non-finite value, so the silent-0-or-1 fallback failure mode is structurally unreachable.
  • resolveRateDate returning null rather than throwing for a missing placedAt is concretely motivated (WooCommerce orders arrive without it — cf. #2114), and the clamp-to-today is load-bearing against ECB answering a future endPeriod with a stale rate at HTTP 200.
  • Keeping the Polish working-day calendar out of core and inside the NBP adapter, verified against the live API with a worked counter-example (Corpus Christi 2026-06-04), is the right layering backed by the right evidence.
  • ADR-007 mapping is exhaustively switched with deferred correctly re-thrown as retryable rather than reported 'ok' — a new outcome kind fails type-check rather than falling through.
  • The env rung ignores malformed and well-formed-but-unsupported values with a once-latched warning and never throws, which matters because resolve() runs on every stamp.
  • check-cross-context-imports clean (1996 imports, 2555 files) — the new currency context is a true leaf with no orders back-edge. Controller @Roles('admin') on both verbs, both Cache-Control: no-store, and write-guard-coverage.spec.ts updated.
  • Nothing in the FE renders a converted figure yet, so the "converted without provenance" risk doesn't arise — and the settings tile already distinguishes default / env / setting rather than collapsing them, and states that invoices compute their own rate.

CI: Lint, Type Check, Build, Docker smoke, PHP, Scaffolded Adapter Builds green on 242c1613. Test and Integration Tests were still in_progress — confirm both before merge. (The earlier run's cancelled jobs are the force-push superseding an in-flight run, not failures.)

Merge readiness: 🔄 ready pending findings 1 and 2 — two lines in each adapter plus a UA header — and green Test / Integration Tests. Everything the review set out to verify hard checks out.

norbert-kulus-blockydevs added a commit that referenced this pull request Aug 19, 2026
…ANT, 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>
@norbert-kulus-blockydevs

Copy link
Copy Markdown
Collaborator Author

Review addressed — all 9 findings, in e2a61ed

Both IMPORTANT and all seven SUGGESTIONs are implemented. Two went further than the literal ask, and one turned out to be already satisfied in the shipped code but genuinely broken for a different reason — both are called out below rather than folded in silently.


IMPORTANT 1 — a 429/408 was terminal, and terminal was permanent

Fixed in two layers, because the finding really describes two defects.

The classification. isTransientFxStatus(status) now lives in fx-http.client.ts and is status >= 500 || [408, 429].includes(status). Both adapters call it in place of their >= 500 arm, ordered before their >= 400 terminal arm, so the walk-back 404 and the "everything else is our bug" branch are untouched. Kept in the client rather than duplicated per adapter because both providers reach the same conclusion from these codes, and the cost of the two arms is asymmetric: a wrongly-transient status costs a retry, a wrongly-terminal one costs the order its figure forever. The >= 400 comment now says the two codes are excluded because the class was never verified against the live API — which is your argument, and it belongs next to the code that relies on it.

The permanence. You flagged that no-rate-source has the same shape and suggested an admin re-open or a sweep revisit. I took the sweep revisit, because it needs no new surface and it recovers the case where nobody is watching:

  • findUnstampedFxOrderIds is now two OR'd arms — fxStampedAt IS NULL (unchanged) and fxStampedAt < terminalRetryBefore. reportingCurrency IS NULL sits in both, so a row that carries a figure is never re-entered whatever its marker says. That predicate, not the timestamp, is what makes a stamp immutable.
  • markFxTerminalIfAbsentmarkFxTerminal, guarded on reportingCurrency IS NULL alone. Dropping the fxStampedAt IS NULL predicate is required by the above: without it a re-answer writes nothing, the stale instant stays, and the row is re-tried on every tick instead of once per cooldown. The rename is deliberate — "if absent" would now be false about the marker.
  • The cooldown is terminalRetryDays, default 7, payload-overridable per scheduler descriptor and clamped at 365. Shorter and a genuinely unstampable order burns provider calls; longer and a cleared condition outlives an operator's reaction time.

The reason a terminal answer had to become recoverable at all is the sentence worth keeping: it is terminal about the classification, not about the world. no-rate-source clears the moment a host is rewired; a throttle-induced unsupported-pair clears by itself.

IMPORTANT 2 — no User-Agent

FX_USER_AGENT (OpenLinker/1.0 (+repo URL; exchange-rate reader)) is sent on every fxGet alongside accept. Worth noting undici sends no default UA, so this is the only one either provider ever saw — and, as you said, there is no local backoff to absorb a filter, which is exactly what made finding 1 reachable.


SUGGESTIONS

3. ECB had no logger. Added, mirroring NBP: debug on the resolved observation, and debug on the empty-200 non-publication branch — the ECB analogue of NBP's walk-back line, i.e. the one branch that reports the API working correctly. Following the same thread one layer up: recordTerminal now takes the caught error's message, so ECB responded 400 for EXR.D… reaches the log. Previously the reason enum was the entire record of a permanent answer — an operator could see that an order was refused, never why.

4. Migration header. Corrected to 1833000000006 (add-order-record-sales-document-block), and it now states that 1834000000000 is uncontested since the fiscalization stack moved to 1835000000000. The correction is noted in place rather than quietly swapped, since the next author reads the note instead of re-deriving it.

5. Walk-back off-by-one. NBP_MAX_WALK_BACK_DAYS = 7 with <=NBP_MAX_WALK_BACK_ATTEMPTS = 8 with <. Same 8 requests as before, now named for what it counts and matching the spec's <= 8. The doc comment says why it counts attempts.

6. Silent ECB staleness. OBSERVATION_LAG_WARN_DAYS = 3: above it the observation is still accepted (4 days is a real TARGET closing run, so refusing would be strictly worse) and WARN-logged naming the lag. MAX_OBSERVATION_LAG_DAYS = 10 stays the hard assertion. The band between them is where "legal" and "the source has stopped publishing" are indistinguishable.

7. Wave-less enqueueRetry key. Confirmed intended, and now documented at the method with the distinction from #2039 spelled out: there the delayed chain was the only route, so a spent key lost the read; here the hourly sweep reads the frontier predicate directly and needs no key. A wave component would add a second inline ladder on top of a mechanism that already covers the case.

8. A11y — this one was already half-true, and broken for a different reason. Alert (shared/ui/alert.tsx) already sets role="status" for every non-error tone, so the literal fix was in place. The real gap is that a live region inserted together with its content is not reliably announced — screen readers watch an existing region for mutations, so a conditionally-mounted Alert can appear silently regardless of its role. The dialog therefore mounts one persistent sr-only role="status" aria-live="polite" region that is empty until a warning applies. Its text is derived rather than duplicated: it names the warning and, for the coverage gap, the acknowledgement that gates Save — otherwise a screen-reader user meets a disabled button with no stated cause. Four tests cover it, including the region existing while still empty and returning to silence.

9. Boot assertion. CurrencyModule.onApplicationBootstrap throws NoExchangeRateProvidersRegisteredError (naming FxIntegrationModule) on an empty registry, and logs the registered sources otherwise. onApplicationBootstrap, not onModuleInit, because Nest runs every module's onModuleInit first — that is the earliest point at which "still empty" means "nobody will ever fill it". It lives on the core module on purpose: a host missing FxIntegrationModule is precisely the case that module cannot assert anything from. Core still learns nothing about which providers exist, only that the count is not zero. Both hosts and both integration harnesses boot the full AppModule, so nothing legitimately boots without providers.


Tests

New or extended, all passing locally:

  • fx-http.client.spec.ts (new) — UA on every request, accept preserved, and the transient/terminal status table (408/429 and 5xx transient; 400/401/403/404/406/422 left to the adapter).
  • Both adapter specs — it.each([429, 408]) transient.
  • ECB spec — the warn band asserted at 3d (silent), 4d and 6d (warned), all three still resolving, so the warn can never be mistaken for a refusal.
  • order-record.repository.spec.tsmarkFxTerminal guards on reportingCurrency only and carries no fxStampedAt predicate; the frontier renders two arms with the invariant in both.
  • marketplace-order-fx-stamp-sweep.handler.spec.ts (new) — both window bounds derived, cooldown default on four bad inputs, the 365-day clamp, payload rejection, retryable wrap.
  • currency.module.spec.ts (new) — boot fails empty, names the module, passes with one provider.
  • currency-settings-dialog.test.tsx (new) — the four live-region cases.

docs/architecture-overview.md § Currency restates the sweep predicate (it documented the old two-column one) and records why 429/408 are transient and why an empty registry fails the boot.

Gate: pnpm lint (incl. check:invariants) and pnpm type-check both clean. Targeted suites green: libs/integrations/fx 106, core currency + orders FX 167, worker sweep handler 8, api orders/currency controllers 42, web currency-settings 17. Full Test / Integration Tests are CI's to confirm on this commit.

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Delta re-review (242c1613f42c4459) — ❌ Request changes

To be clear about the change in label: my previous review said "ready pending findings 1 and 2", so those were always merge prerequisites rather than optional. The new head is two files (+7/−3) and touches neither, so I'm making the gate explicit rather than leaving it as a comment someone could read as approval. No new problems — the delta is fine as far as it goes.

Prior finding Status Evidence
I1 429/408 classified terminal nbp-exchange-rate.adapter.ts:281 still >= 500 → transient, :290 still >= 400RateUnsupportedPairError, with the "ANY non-404 4xx is terminal, deliberately" comment intact. ecb- identical. No 429/408 literal in either adapter
I2 no User-Agent fx-http.client.ts:71 still sends accept as its only header
S3 ECB has no logger grep -c "new Logger" → ECB 0, NBP 1
S4 migration header comment ❌ (comment-only) Slot re-verified free: main's tail is now 1833000000006, so 1834000000000 remains valid
S5 NBP_MAX_WALK_BACK_DAYS = 7 yields 8 attempts And the log at :220 renders attempt + 1/7, so attempt 8 prints "8/7"
S6 ECB 10-day staleness unlogged Blocked on S3 — no logger in the file
S7 enqueueRetry key has no wave component order-fx-stamp.service.ts:424
S8 reactive Alerts in no live region No web file in the delta
S9 no boot assertion on an empty FX registry Not present

I1, traced end to end — and the good news

I re-traced the whole chain, because the fix is only worth asking for if the consequence is real. It is:

  • terminal → order-fx-stamp.service.ts:327recordTerminal (:391) → order-record.repository.ts:828 markFxTerminalIfAbsent writes { fxStampedAt };
  • the sweep frontier (:853-854) filters fxStampedAt: IsNull(), reportingCurrency: IsNull(), so a terminal row leaves the frontier permanently, and git grep -E "clearFx|resetFx|unmarkFx" still finds no re-open path;
  • transient → enqueueRetry (:195) never touches fxStampedAt, so a transiently-classified row correctly stays on the frontier.

That last point is the good news: the one-line move of 429/408 into the >= 500 arm is sufficient and needs no new recovery machinery. The surrounding design is already right — only the classification is wrong. And NBP is public and unauthenticated with no User-Agent (I2), which makes a burst-triggered 429 the single most likely real failure, currently costing those orders their reported figure forever.

Please land it with a spec asserting a 429 classifies transient and leaves fxStampedAt NULL — the second half is what actually pins the behaviour.

Highest-risk invariants: untouched, verified structurally. Neither adapter, the repository, nor the ORM entity appears in git diff 242c1613..f42c4459 --name-only, so the from/to quote-direction contract with its opposite pivot ordering, the six FX columns excluded from toOrm, and all three guarded conditional updates (claimFxIntentIfAbsent, stampFxIfAbsent, markFxTerminalIfAbsent) are byte-identical to the head I reviewed. No re-verification needed.

SUGGESTION

The rateSource change is a behaviour change, not a nit — worth saying so it isn't mistaken for a refactor in the merge log. currency-settings.controller.ts:142-159 now returns rateSource: null on the no-provider branch instead of resolveSourceKey(candidate). It's the more honest answer (no provider ⇒ no source) and it is covered — currency-settings.controller.spec.ts:135 asserts null when no provider is registered. No action.

Minor: the new cron key is appended out of alphabetical order in all four cronKeys arrays in scheduler.service.spec.ts, while 'order-fx-stamp-sweep' was correctly inserted alphabetically in the task-name list. Those arrays carry a comment telling the next author to register there, so keeping them sorted preserves their diff-scan value.

CI: fresh run on f42c4459 not yet conclusive — Docker smoke in progress; Lint, Build, Test, Integration Tests, Type Check all queued. Only PHP, path detect and Scaffolded Adapters have passed. Note the Test / Integration Tests pair is now unobserved across two consecutive reviews — and the scheduler-spec change in this very delta is exactly the class of fix whose correctness only CI proves. Please let it finish.

Merge readiness: ❌ two small changes away — 429/408 into the transient arm in both adapters, plus a User-Agent header. The machinery around them is already correct, so this should be a short round trip.

norbert-kulus-blockydevs and others added 19 commits August 19, 2026 11:38
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>
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 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>
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>
…orting-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>
…dapters

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>
…in 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>
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>
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>
…mp-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>
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>
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>
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>
…verage

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>
…vice

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>
…PY 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>
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>
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>
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>
…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>
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>
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>
…ANT, 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>
`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>
@norbert-kulus-blockydevs

Copy link
Copy Markdown
Collaborator Author

Re-review targeted a superseded head - all 9 findings are implemented, plus the minor and the rebase

The delta re-review reads 242c1613 -> f42c4459. f42c4459 stopped being the head at 09:04:44Z, when e2a61ed landed with all nine findings; the re-review submitted at 09:09:19Z. So the nine ❌ describe a head that was four minutes stale, not the code as it stands. Nothing in the review was wrong about f42c4459 - it simply is not what the branch contains.

New head is 08fc2f6. Two things happened since the re-review: the branch is rebased onto main (it was BEHIND), and the one genuinely open item - the cronKeys ordering minor - is fixed.

The nine findings, re-evidenced at 08fc2f6

Finding Where it lives now
I1 429/408 terminal fx-http.client.ts:57 FX_TRANSIENT_STATUS_CODES = [408, 429], :64-65 isTransientFxStatus = >= 500 || 408/429. Called at nbp:294 and ecb:307, so it is one choke point rather than two parallel ladders
I1 recovery path The review asked whether a terminal marker should ever re-open, since no-rate-source had the same permanence. It does now: the frontier gained a second OR'd arm (order-record.repository.ts:873, fxStampedAt < terminalRetryBefore) that re-admits a terminal row still carrying no figure, cooldown terminalRetryDays default 7. markFxTerminalIfAbsent became markFxTerminal (:835), guarded on reportingCurrency IS NULL alone, so a re-answer moves the marker forward instead of the row re-entering on every tick. The stamp itself stays immutable - reportingCurrency IS NULL sits in both frontier arms and in the marker guard
I1 spec, both halves it.each([429, 408]) in nbp-…spec.ts:300 and ecb-…spec.ts:366 for the transient half; order-fx-stamp.service.spec.ts:293 asserts markFxTerminal is not called on a transient failure, which is the half that pins fxStampedAt staying NULL
I2 User-Agent fx-http.client.ts:40 FX_USER_AGENT, sent at :114 on every fxGet. undici sends no default UA at all, and this package deliberately carries no local retry loop to absorb a filter, so it is the only thing identifying the caller
S3 ECB logger ecb-…adapter.ts:168. 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 also carries the provider's own message now, which previously died in the catch
S4 migration header 1834000000000-add-order-fx-stamp.ts:17-18 states the tail is 1833000000006 and that 1834000000000 is uncontested since the fiscalization stack moved to 1835000000000. Re-verified post-rebase by check-migration-timestamps against the current origin/main
S5 = 7 yields 8 attempts NBP_MAX_WALK_BACK_ATTEMPTS = 8 with attempt < (nbp:67, :226). The 8/7 log is gone with it
S6 ECB staleness silent OBSERVATION_LAG_WARN_DAYS = 3 (ecb:105), warn at :422. Above 3 days the observation is still accepted, because a 4-day run is a real TARGET closure, but it is no longer silent
S7 wave-less retry key Confirmed intended and documented at order-fx-stamp.service.ts:436-446, including why #2039's refreshSnapshot fix does not transfer: there the delayed chain was the only route, here the hourly sweep re-reads straight from the frontier predicate and needs no key
S8 a11y live region currency-settings-dialog.tsx:140. Alert already carried role="status", but a live region inserted together with its content is not reliably announced, so the dialog now mounts one persistent visually-hidden region naming whichever warning materialised and the acknowledgement it requires. Four tests
S9 empty FX registry currency.module.ts:102 onApplicationBootstrap throws NoExchangeRateProvidersRegisteredError. onApplicationBootstrap rather than onModuleInit so plugin registration has run

New in 08fc2f6

The cronKeys minor, taken one step further than asked. The four arrays in scheduler.service.spec.ts were not alphabetical to begin with (INVENTORY, PRODUCT, PICKUP_POINT, REGULATORY, OFFLINE, PENDING, STALE_OFFER, TAXONOMY), so inserting only the new key would have left the diff-scan property the comment promises still broken. All four are sorted, the convention is stated in the comment, and the fourth array - which carried no comment at all - now has it. Test-only; the suite passes 35/35.

Rebase onto main. 23 commits replayed with no conflicts, all signed. The five commits picked up are #2144 inventory upsert column-scope, #2149 orders filtered-empty-state, the 0.7.0 release, the analytics design package and #2150 identity cells. Diffing the pre-rebase head against the new one shows exactly those five and nothing else, and the Dockerfile still carries its three libs/integrations/fx lines.

Local gate on the rebased tree: build, type-check, lint including all 22 invariant checks, all green.

CI

Point taken that Test and Integration Tests went unobserved across two reviews. Both were queued, not skipped - runner backlog on run 32235793483, which sat at queued for the four heavy jobs from 09:05 while Type Check, Docker smoke and PHP completed. A fresh run is now going on 08fc2f6. I will not ask for a re-review until Test and Integration Tests are both green on that run.

@norbert-kulus-blockydevs

Copy link
Copy Markdown
Collaborator Author

CI green on 08fc2f6, including the pair that went unobserved twice

All nine checks pass on the rebased head:

Check
Build pass
Lint pass
Type Check pass
Test pass
Integration Tests pass
Docker Build Smoke Test pass
PHP Unit Tests pass
Scaffolded Adapter Builds pass
Detect changed paths pass

That closes the last item from the re-review. Test and Integration Tests had been queued rather than skipped on the previous run, and both are now observed green on the head that carries the nine findings, the cronKeys ordering fix and the rebase onto main.

The branch is no longer BEHIND. Ready for a re-review whenever you have a slot, @piotrswierzy.

Resolves the conflicts introduced by #2137 (eparagony fiscalization). Both
sides are kept in every shared composition point - api/worker plugin lists,
tsconfig paths, package manifests, jest integration mappers, integration-test
truncation lists, the web api-client and test utils, and the core orders
barrel.

The FX stamp migration is renumbered 1834000000000 -> 1836000000000: main took
the 1835 slot for create-fiscal-registration-records, so the FX DDL now sorts
strictly after it. Migration class, name field and its spec were renamed with
it.

Gate: pnpm build, pnpm type-check and pnpm lint (incl. check:invariants) all
pass on the merge result.

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Delta re-review (f42c4459/e2a61ed408e2c7f3) — ✅ Approve

Both merge prerequisites from the last round have landed, and the fix I did not ask for — a recovery path out of a terminal answer — is the one that most improves the feature. Lifting the gate.

Prior findings

# Status Evidence
I1 429/408 terminal fx-http.client.ts:57 FX_TRANSIENT_STATUS_CODES = [408, 429], isTransientFxStatus = >= 500 || includes(status); both adapters route through it (nbp:295, ecb:308) with the reasoning inline
I1b terminal had no recovery beyond ask findUnstampedFxOrderIds is now two OR'd branches sharing reportingCurrency IS NULL — unanswered, or fxStampedAt < terminalRetryBefore. markFxTerminal deliberately drops the fxStampedAt IS NULL guard so a re-answer moves the marker forward instead of re-entering every tick. Default cooldown 7 days, clamped at 365
I2 no User-Agent fx-http.client.ts:114
S3 ECB had no logger ecb:168
S4 migration header timestamp claim Re-verified against origin/main: tail is 1835000000000 (fiscalization, #2137); 1836000000000 is strictly greater and uncontested. Header now says exactly that, including why it moved
S5 MAX_WALK_BACK_DAYS = 7 / "8/7" log Renamed NBP_MAX_WALK_BACK_ATTEMPTS = 8, loop is attempt < N, log renders /8
S6 ECB 10-day staleness silent ecb:411-422 warns, with the "max real non-publication run is 4 days" reasoning
S7 fx:{id} key has no wave ✅ (documented, as asked) The enqueueRetry doc now states it is intended and why the #2039 precedent doesn't transfer: the sweep reads the frontier directly and needs no key
S8 reactive Alerts in no live region currency-settings-dialog.tsx:140 role="status" aria-live="polite"
S9 no boot assertion on an empty registry ⚠️ accepted Still absent, but I1b removes its teeth: a host booted without FxIntegrationModule now produces terminal rows that the sweep re-admits after the cooldown once the wiring is fixed, instead of permanent silent loss. Fine as-is
cronKeys ordering 08fc2f629

What I re-verified structurally

The invariants I care most about are unchanged or strengthened:

  • Quote directionrate is to per one from; the single conversion site multiplies (order-fx-stamp.service.ts), the opposite pivot ordering between NBP and ECB is intact, and the "should never divide" negative test still stands.
  • Snapshot immutability — all six FX columns remain outside toOrm's write set; the three writers are still guarded single-statement conditional updates answering via affected > 0. stampFxIfAbsent keeps reportingCurrency: IsNull(), so the figure is untouchable regardless of the new marker semantics — which is exactly the right thing to have relaxed and the right thing to have kept.
  • Migration — hand-authored with reasons; IF NOT EXISTS throughout, full down(), both CHECKs dropped-then-added idempotently, and the ck_order_records_fx_group first arm's deliberate omission of fxRule IS NULL is documented against claimFxIntentIfAbsent's intent row. The expression index matches NATIVE_CURRENCY_EXPR verbatim (kept as a shared constant precisely so it can't drift).
  • Idempotency — registry get-or-create is insert-then-recover on PG 23505 matched by code; the stamp is stamp-once at the SQL level; the sweep walks sequentially and each stamp swallows its own failure; the ingestion seam never fails the persist.
  • Boundaries — no HTTP anywhere in libs/core/src/{currency,orders}; libs/integrations/fx is registered in check-outbound-http.mjs; the new currency context is a leaf with no orders back-edge.

Minor, non-blocking: orders imports the pure helpers resolveRateDate / resolveRateSource from @openlinker/core/currency. They're pure, sit on the barrel, and the invariant checker is green — but they're plain functions rather than one of the enumerated cross-context shapes (I*Service / *Port / is* / entity / UPPER_SNAKE_CASE). Worth a line in architecture-overview.md § Cross-context dependencies if this becomes a pattern; not worth a round trip now.

CI: Type Check, Build, Docker smoke, PHP, Scaffolded Adapters green on 08e2c7f3. Lint, Test and Integration Tests are still in progress — that pair has now been unobserved across three reviews, so please confirm all three green before merging. Approving on the code; the CI gate is yours to hold.

Merge readiness: ✅ pending green Lint / Test / Integration Tests. Everything I asked for landed, and the terminal-recovery work exceeded the ask.

@norbert-kulus-blockydevs
norbert-kulus-blockydevs merged commit c6c1e4f into main Aug 20, 2026
9 checks passed
@norbert-kulus-blockydevs
norbert-kulus-blockydevs deleted the 2049-order-fx-rate-snapshot branch August 20, 2026 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment