feat(core,currency,orders): order-time FX rate snapshot + reporting-currency stamping - #2135
Conversation
Live E2E verification doneBooted the full epic branch (all six phases merged, plus the one final integration 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
Live proofOne real PrestaShop demo order (19.99 EUR) was converted through a genuine NBP API call and stamped 86.80 PLN — hand-verified: StatusPR #2135 stays draft pending your own final check, per plan. |
2aee5d3 to
242c161
Compare
piotrswierzy
left a comment
There was a problem hiding this comment.
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
ecb-exchange-rate.adapter.tshas no logger at all while NBP debug-logs its walk-back — an ECB failure is currently silent at the adapter layer.- Migration header
:17says the tail onmainwas1833000000005; it's1833000000006. The claimed slot is still strictly greater so the invariant holds — but the note is what the next author will trust. Slot1834000000000is free and no longer contested, since the fiscalization stack moved to1835000000000. NBP_MAX_WALK_BACK_DAYS = 7with a<=loop yields 8 attempts; the spec asserts<= 8, so intent is pinned but the constant name understates by one.- 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.
enqueueRetry's key isfx:{internalOrderId}with no wave component — the shape #2039 had to fix forrefreshSnapshot. 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.- A11y — the two reactive warnings in the edit dialog (
currency-settings-dialog.tsx:114era-split,:150coverage-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. Addrole="status"to at least the coverage-gap Alert. - 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, whilelistSelectableCurrenciesreturns[]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:
insertIfAbsentbuilds an entity with noid, sosave()can only INSERT. PG23505matched on the code, never the message. ratestays a string end-to-end intonumeric(18,8)and is deliberately notNumber()-ed intoDomain, 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 reusepricing-rule.types.ts'sround2dpbecause itsMath.max(0, …)clamp would turn a refund into0.00is exactly the reasoning that prevents a silent financial defect.toRateStringthrowsRangeErroron any non-positive or non-finite value, so the silent-0-or-1 fallback failure mode is structurally unreachable.resolveRateDatereturningnullrather than throwing for a missingplacedAtis concretely motivated (WooCommerce orders arrive without it — cf. #2114), and the clamp-to-today is load-bearing against ECB answering a futureendPeriodwith 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
deferredcorrectly 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-importsclean (1996 imports, 2555 files) — the newcurrencycontext is a true leaf with noordersback-edge. Controller@Roles('admin')on both verbs, bothCache-Control: no-store, andwrite-guard-coverage.spec.tsupdated.- 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/settingrather 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.
…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>
Review addressed — all 9 findings, in
|
piotrswierzy
left a comment
There was a problem hiding this comment.
Delta re-review (242c1613 → f42c4459) — ❌ 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 >= 400 → RateUnsupportedPairError, 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:327→recordTerminal(:391) →order-record.repository.ts:828markFxTerminalIfAbsentwrites{ fxStampedAt }; - the sweep frontier (
:853-854) filtersfxStampedAt: IsNull(), reportingCurrency: IsNull(), so a terminal row leaves the frontier permanently, andgit grep -E "clearFx|resetFx|unmarkFx"still finds no re-open path; - transient →
enqueueRetry(:195) never touchesfxStampedAt, 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.
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>
e2a61ed to
08fc2f6
Compare
Re-review targeted a superseded head - all 9 findings are implemented, plus the minor and the rebaseThe delta re-review reads New head is The nine findings, re-evidenced at
|
| 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.
CI green on
|
| 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
left a comment
There was a problem hiding this comment.
Delta re-review (f42c4459/e2a61ed4 → 08e2c7f3) — ✅ 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 | 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 direction —
rateistoper onefrom; 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 viaaffected > 0.stampFxIfAbsentkeepsreportingCurrency: 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 EXISTSthroughout, fulldown(), both CHECKs dropped-then-added idempotently, and theck_order_records_fx_groupfirst arm's deliberate omission offxRule IS NULLis documented againstclaimFxIntentIfAbsent's intent row. The expression index matchesNATIVE_CURRENCY_EXPRverbatim (kept as a shared constant precisely so it can't drift). - Idempotency — registry get-or-create is insert-then-recover on PG
23505matched by code; the stamp is stamp-once at the SQL level; the sweep walks sequentially and eachstampswallows its own failure; the ingestion seam never fails the persist. - Boundaries — no HTTP anywhere in
libs/core/src/{currency,orders};libs/integrations/fxis registered incheck-outbound-http.mjs; the newcurrencycontext is a leaf with noordersback-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.
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.previousWorkingDayin@openlinker/shared/datelibs/core/src/currency/+ the new@openlinker/integrations-fxpackageorder_recordscolumns, stamp-once repository methods, the migrationOrderFxStampService,marketplace.order.fxStamp,marketplace.order.fxStampSweep/currency-settingsAPI, the Platform/Currency settings tile,OL_REPORTING_CURRENCYdocs/architecture-overview.md§ CurrencyUI 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@Entityclasses -exchange_ratesandreporting_currency_setting- before their migration, which lands with #2124. They materialise only viasynchronizeuntil then, which is fine for dev and the integration harness.The consequence to watch:
apps/api/src/database/data-source.tsdiscovers entities by filesystem glob, so anyone runningmigration:generateon 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