Skip to content

feat(demo): one-command Docker demo environment (API/Web/Worker + PrestaShop) - #1365

Merged
piotrswierzy merged 9 commits into
mainfrom
1352-docker-demo-environment
Jul 7, 2026
Merged

feat(demo): one-command Docker demo environment (API/Web/Worker + PrestaShop)#1365
piotrswierzy merged 9 commits into
mainfrom
1352-docker-demo-environment

Conversation

@jakubretajczykBD

@jakubretajczykBD jakubretajczykBD commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Add a dedicated demo overlay that boots the full OpenLinker stack in Docker with a single command, on top of the existing infra services.

  • Dockerfile: new worker build target; also fixes a pre-existing blocker where the ksef/infakt workspace manifests + dist were never copied, so the image build failed outright (unnoticed because CI does not build the image).
  • apps/web/Dockerfile + nginx.conf: static SPA build served by nginx with SPA-fallback routing; VITE_API_BASE_URL baked at build time.
  • package.json: demo:up / demo:down / demo:logs scripts.
  • README.md: demo section (URLs, credentials, manual PS<->OL connection).

Closes #1352

Summary

  • One-command Docker demo overlay (pnpm demo:up) that boots API/Worker/Web + PrestaShop on top of the existing infra compose services.
  • Addressed all /pr-review findings from @piotrswierzy (2026-07-06): committed .dockerignore, a CI docker build smoke job guarding the Dockerfile's per-package COPY lists, loopback-bound demo ports, nginx gzip/cache/security headers, plus the two remaining suggestions.

Related issues

Closes #1352

Test plan

Fresh clean-volume boot (docker compose … down -v then pnpm demo:up), verified live:

  • docker build --target base -f Dockerfile . and docker build -f apps/web/Dockerfile . both succeed from a clean checkout (no host .git/node_modules/dist/*.tsbuildinfo leaking into the build context via the new .dockerignore) — this also caught and fixed a real bug: a host tsconfig.tsbuildinfo copied into the image made tsc -b fail with TS6305 Output file has not been built from source, since the corresponding host dist/ was (correctly) excluded. Adding **/*.tsbuildinfo to .dockerignore fixed it.
  • pnpm demo:up on an empty Postgres volume: migrate container ran every migration from scratch and exited 0.
  • openlinker-api, openlinker-worker, openlinker-web all reached Up state.
  • POST http://localhost:3000/v1/auth/login with admin/admin200 with an access token.
  • CORS preflight from Origin: http://localhost:8090Access-Control-Allow-Origin: http://localhost:8090.
  • docker port openlinker-api / openlinker-web → both 127.0.0.1:* only (not 0.0.0.0), confirming the loopback-bind fix.
  • Web image: nginx -t passes; GET / returns Cache-Control: no-cache + X-Content-Type-Options/X-Frame-Options/Referrer-Policy; GET /assets/*.js returns Cache-Control: public, immutable + the same security headers (nginx doesn't inherit server-level add_header into a location that sets its own, so they're re-stated per location block — verified by curl, not just config review).
  • docker compose … config (with a dummy encryption key) confirms the ports: !override on api resolves to exactly one loopback binding, not two conflicting ones (the naive ports: list under a Compose overlay merges/appends rather than replacing).
  • Worker log shows SyncJobRunner starting cleanly with no errors on a fresh DB (no connections configured yet, so no jobs to run — expected for a from-scratch boot).
  • Stack torn down afterward (down -v); dev-stack docker-compose.yml itself is untouched, so pnpm dev:stack:up is unaffected by this PR.

Quality gate

  • pnpm lint passes (zero errors) — no TypeScript touched by these fixes
  • pnpm type-check passes (zero errors) — no TypeScript touched by these fixes
  • pnpm test passes (all unit tests green) — no TypeScript touched by these fixes
  • Optional, Docker required: pnpm test:integration passes — needed
    only if you touched apps/api/test/integration/** or any plugin's
    infrastructure/adapters/.

Migrations

  • If this PR changes an ORM entity, a migration is included under
    apps/api/src/migrations/ (or the plugin package), pnpm --filter @openlinker/api migration:show confirms it's listed, and both
    up() and down() were tested locally. See
    docs/migrations.md. Tick this box for
    PRs that don't touch schemas too — it's trivially satisfied.

ADR

  • If this PR makes a non-trivial architectural decision (affects
    multiple contexts, the plugin contract, or has alternatives worth
    documenting), an ADR should be included under
    docs/architecture/adrs/ or referenced in the PR description.
    ADRs are a recommendation, not a hard gate — see
    docs/architecture/adrs/README.md
    § "When to write an ADR" for the decision criteria. Tick this box
    for PRs that don't make architectural decisions too — it's
    trivially satisfied.

DCO sign-off

Sign off every commit with git commit -s. OpenLinker uses the
Developer Certificate of Origin
as its contributor attestation (see
CONTRIBUTING.md → Commits). Automated
enforcement is deferred until after the org transfer; sign off anyway
so the history is consistent when enforcement starts.

Adding a new integration adapter? (expand)

If this PR introduces a new package under libs/integrations/<x>/,
declare its status so reviewers know what bar to apply:

  • alpha — experimental; API may change, no production traffic
    yet
  • beta — stable contract, real-world use ongoing, breaking
    changes flagged in PR title
  • stable — production-ready; breaking changes follow semver

See docs/architecture-overview.md
for the adapter / capability contract and
GOVERNANCE.md for the policy on plugin authors
co-maintaining their own adapter.

UI changes? Attach screenshots at three widths (expand)

Per docs/frontend-ui-style-guide.md § Responsive,
mobile and tablet are first-class. Capture after-shots at all three
breakpoints:

  • Mobile (360 × 812):
  • Tablet (768 × 1024):
  • Desktop (1440 × 900):

By submitting this pull request, I confirm that my contributions
are made under the terms of the Apache License 2.0, and I
certify the Developer Certificate of Origin
by signing off my commits.

…staShop)

Add a dedicated demo overlay that boots the full OpenLinker stack in Docker with a single command, on top of the existing infra services.

- Dockerfile: new worker build target; also fixes a pre-existing blocker where the ksef/infakt workspace manifests + dist were never copied, so the image build failed outright (unnoticed because CI does not build the image).
- apps/web/Dockerfile + nginx.conf: static SPA build served by nginx with SPA-fallback routing; VITE_API_BASE_URL baked at build time.
- package.json: demo:up / demo:down / demo:logs scripts.
- README.md: demo section (URLs, credentials, manual PS<->OL connection).

Closes #1352

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
…staShop)

Add a dedicated demo overlay that boots the full OpenLinker stack in Docker with a single command, on top of the existing infra services.

- Dockerfile: new worker build target + apk add bash in base (migrate uses the repo migration:run script, which invokes typeorm via bash); also fixes a pre-existing blocker where the ksef/infakt workspace manifests + dist were never copied, so the image build failed outright (unnoticed because CI does not build the image).
- apps/web/Dockerfile + nginx.conf: static SPA build served by nginx with SPA-fallback routing; VITE_API_BASE_URL baked at build time.
- package.json: demo:up / demo:down / demo:logs scripts (demo:up includes phpmyadmin).
- README.md: demo section (URLs incl. phpMyAdmin, credentials, manual PS<->OL connection).

Closes #1352

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

/pr-review — systematic review (draft)

Reviewed with an infra/DX lens against the live tree. A well-reasoned, correctly-wired demo overlay — compose merge semantics, boot ordering, the migrate-from-base choice, worker env, and the admin/admin bootstrap short-circuit are all sound. Security sweep is clean (headline check): every credential is demo-scoped and pre-existing in pattern (OL_BOOTSTRAP_ADMIN_PASSWORD=admin short-circuits the prod random-password path deliberately; OL_PII_HASH_SALT is identical to base's; JWT_SECRET is inherited from base, not newly baked; VITE_API_BASE_URL=http://localhost:3000 bakes no private endpoint; no .env tracked). The gaps are DX/reproducibility, not happy-path correctness.

🟡 IMPORTANT — .dockerignore is gitignored, so it never ships

An on-disk .dockerignore exists but .gitignore:64 excludes it, so a fresh git clone (the exact git clone … && pnpm demo:up flow the README headlines) has none. Consequences: (a) every image layer bakes .git + all source; (b) for existing contributors who already ran the dev flow, base's COPY . . (after pnpm install) copies host node_modules/dist over the container tree — host-OS native binaries (esbuild/rollup/bcrypt) break pnpm build/vite build; (c) a local .env gets baked into layers. This PR promotes docker build from an untested future path to the headline UX, so the ignore file becomes necessary. Fix: commit .dockerignore (drop it from .gitignore).

🟡 IMPORTANT — the Dockerfile plugin-copy fix is hardcoded per-package, re-arming the trap it fixes

The unblocking fix adds infakt/ksef to the enumerated per-package COPY package.json + COPY --from=base … dist lists (Dockerfile:27-38, 61-72, 84-95) — it doesn't generalize. The 11th plugin will silently break base's pnpm install again (unresolved workspace:*), the same drift class as #917/#916. CI doesn't build the image, which is why this blocker reached main unnoticed. Fix: add a CI smoke job running docker build --target base . (cheap, catches manifest drift immediately); at minimum comment each list tying it to apps/{api,worker}/package.json deps.

🟡 IMPORTANT — draft with an empty body / no test plan

For infra work whose only validation is manual, the test plan is the evidence of correctness. Fill the body with the executed Phase-4 run log (clean-volume boot, migrate Exited (0), admin/admin login hitting :3000, worker started + one job processed, dev-flow-unaffected check) before marking ready.

🟢 SUGGESTIONS

  • docker-compose.demo.yml:66-68 — the demo web (and inherited api) publish to 0.0.0.0, while base compose deliberately binds dev services to loopback. With default admin/admin and no redaction, a multi-homed/LAN host exposes it. Bind 127.0.0.1:8090:80 to match the existing loopback pattern (non-blocking for pure-localhost).
  • Dockerfile:67-72 — the worker stage inherits the full API image (apps/api/dist + node_modules it never runs) plus a dev-inclusive worker install. Correct, but bloated; branch from a leaner stage or accept it for a demo.
  • docker-compose.demo.yml:4-6 — header example omits phpmyadmin but the real demo:up script includes it; align the comment.
  • apps/web/nginx.conftry_files … /index.html is correct for React Router; no gzip/cache/security headers (fine for a local demo, worth a follow-up if reused for the hosted demo).

Verdict: 🔄 Request changes (draft — expected). Resolve the two IMPORTANT code items (commit .dockerignore, add a docker build CI smoke job) and fill the body with the Phase-4 run log before marking ready. No BLOCKING defects; no security or architecture-boundary issues.

…ng (#1368) (#1369)

Clean-checkout boot of the one-command demo (#1352, PR #1365) surfaced
several infra/docs gaps. This is infra + docs only, no domain code.

- A2: add start_period (180s) to the mysql healthcheck so first-boot init
  is not counted against the retry window on slower hosts.
- A3: set OL_CORS_ORIGIN=http://localhost:8090 on the demo api service so
  the web UI (published on :8090) logs in without a CORS NetworkError.
- A1: source OPENLINKER_CREDENTIALS_ENCRYPTION_KEY from the environment on
  migrate/api/worker with a required-var (:?) guard that fails the boot
  with a clear message; add a root .env.example and README pre-step
  documenting it (generate with openssl rand -base64 32).
- B1: add a PrestaShop post-install step (40-configure-container-network)
  that registers a prestashop-domain ps_shop_url row and disables the
  canonical redirect, so the app-tier containers can reach the shop by its
  compose service name; also document the container-reachable Shop URL.
- B2: document the container-reachable Storefront URL requirement for
  server-side offer image upload, with the browser-thumbnail trade-off
  noted as a tracked follow-up.
- Docs: correct the PrestaShop admin path (/admin -> /admin-dev) and add a
  louder warning about the demo sharing Compose project + volumes with
  dev:stack:up.

Closes #1368

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
norbert-kulus-blockydevs and others added 3 commits July 6, 2026 22:16
Detailed walkthrough for the #1352/#1365 Docker demo: prerequisites,
example .env (required OPENLINKER_CREDENTIALS_ENCRYPTION_KEY), boot,
service URLs, manual PrestaShop + Allegro connection wiring, end-to-end
verification, and a troubleshooting table. Linked from the README demo
section.

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
#1372)

* fix(demo): fix PrestaShop container-network post-install fatal on PS 9

Live re-test of #1369 on a fresh PrestaShop 9.0.2 install surfaced two bugs
in 40-configure-container-network.php that broke the container's boot
(set -e in the wrapper turned the PHP fatal into a hard container exit):

1. ShopUrl::getShopUrls() returns ShopUrl objects under PS 9, not arrays —
   $row['domain'] threw "Cannot use object of type ShopUrl as array",
   aborting post-install and exiting the prestashop container (255).
   Replaced with a direct Db::getInstance()->getValue() existence check
   (matches the ObjectModel/legacy-bootstrap style already used in this
   script and its 20-set-default-currency.php sibling).

2. Db::getValue() appends its own trailing `LIMIT 1` internally (via
   getRow()); the query's own explicit `LIMIT 1` doubled it into
   `... LIMIT 1 LIMIT 1`, a SQL syntax error (exit 255 again).

Verified end-to-end on a from-scratch PrestaShop 9.0.2 install (fresh
volume + fresh database): post-install now logs "ShopUrl added for domain
'prestashop'", ps_shop_url carries both the main localhost row and the new
non-main 'prestashop' row, PS_CANONICAL_REDIRECT=0, the container stays up
(exit 0), and the API container's webservice probe to http://prestashop/api/
returns 401 (reached, no redirect) instead of the prior 301/302 to the
canonical localhost domain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* docs(demo): fix .env key recipe (duplicate line, sed portability)

The documented .env recipe (README + the setup guide) did
`cp .env.example .env` then `echo "KEY=..." >> .env`. .env.example
already ships an empty `OPENLINKER_CREDENTIALS_ENCRYPTION_KEY=` line, so
appending leaves TWO lines with the same key — harmless (env-file parsing
is last-wins) but confusing to anyone editing .env by hand, and flagged in
the #1369 review.

The guide's proposed fix (sed -i '...') introduced a second problem: GNU
sed and BSD sed (macOS) have incompatible -i syntax (BSD requires
-i '' <script>), so the documented command would fail outright on macOS.

Replaced both with a portable, dependency-free two-liner that drops the
example's empty key line and appends the generated one:

  grep -v '^OPENLINKER_CREDENTIALS_ENCRYPTION_KEY=' .env.example > .env
  echo "OPENLINKER_CREDENTIALS_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* fix(demo): use ShopUrl object property access instead of raw SQL

Addresses PR #1372 review suggestions: revert the idempotency check to
ShopUrl::getShopUrls() with property access ($row->domain) instead of a
hand-built Db::getInstance()->getValue() raw SQL string. Same idempotency
semantics, no manual SQL/pSQL() escaping, and stays consistent with the
sibling script's ObjectModel-only convention (also sidesteps the flagged
double-quoted SQL string literal / ANSI_QUOTES portability risk, since
there's no raw SQL left in this block at all).

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

---------

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Addresses every /pr-review finding from @piotrswierzy, including
suggestions: commit .dockerignore (was gitignored, so a fresh clone
built with no ignore rules at all — this also uncovered and fixed a
real tsc -b failure from a stray host tsconfig.tsbuildinfo leaking
into the build context), a CI docker-build smoke job guarding the
Dockerfile's per-package COPY lists, loopback-bound demo ports for
api/web (default admin/admin ships with no redaction), nginx
gzip/cache/security headers, and comments tying the enumerated COPY
lists to their source of truth.

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

Copy link
Copy Markdown
Collaborator

Addressed all findings from the /pr-review above (commit 6fc0919), including the suggestions, not just the IMPORTANT items:

IMPORTANT

  • .dockerignore was gitignored (.gitignore:64), so a fresh clone shipped no ignore rules at all. Committed it (and removed it from .gitignore). While validating this with a real docker build, the fix surfaced a genuine bug: a stray host tsconfig.tsbuildinfo copied into the build context made tsc -b fail with TS6305: Output file has not been built from source, because the corresponding host dist/ is (correctly) excluded but the incremental build state pointing at it wasn't. Added **/*.tsbuildinfo to .dockerignore and re-verified — docker build --target base -f Dockerfile . now succeeds clean.
  • Added a docker-build-smoke CI job (.github/workflows/ci.yml) that runs docker build --target base -f Dockerfile . and docker build -f apps/web/Dockerfile . on every code-touching PR, so a plugin added to apps/api/apps/worker without a matching Dockerfile COPY entry fails CI immediately instead of reaching main unnoticed. Also added comments in the Dockerfile tying each enumerated COPY list back to the apps/{api,worker}/package.json deps it mirrors.
  • Filled in the PR body's Test plan with the actual clean-volume boot log: migrate ran every migration from scratch and exited 0, api/worker/web all reached Up, admin/admin login returned a 200 with a token, and the stack was torn down afterward.

SUGGESTIONS

  • docker-compose.demo.yml: bound both api (3000) and web (8090) to 127.0.0.1 instead of 0.0.0.0, since the demo ships default admin/admin with no redaction. Note: a plain ports: list under a Compose overlay merges/appends rather than replaces, so api needed ports: !override — otherwise the old unbound 3000:3000 and the new loopback binding would coexist and fail to bind the same host port twice (caught this via docker compose config before it reached a real boot).
  • Dockerfile: added a comment on the worker stage documenting the accepted bloat (inherits the full API image + a dev-inclusive worker install) as a demo-only tradeoff.
  • The compose-header/phpmyadmin comment drift was already fixed by [BUG] Infrastructure — Docker demo (#1352) clean-checkout boot blockers + PrestaShop connection networking gaps #1368's follow-up commits — verified current state matches demo:up's actual service list.
  • apps/web/nginx.conf: added gzip, baseline security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy), and cache headers (immutable for hashed /assets/*, no-cache for index.html). Verified live with curl — nginx does not inherit server-level add_headers into a location block that sets its own, so each location re-states them explicitly (a real gotcha I hit and fixed, not just a copy-paste from docs).

All of the above was verified against live containers (not just config review): full clean-volume pnpm demo:up, curl against /v1/auth/login, docker port output confirming the loopback binding, and nginx -t + header inspection on the built web image. Full details in the updated Test plan section of the PR description.

@norbert-kulus-blockydevs
norbert-kulus-blockydevs marked this pull request as ready for review July 7, 2026 07:07
Wires OL_CORS_ORIGIN, VITE_API_BASE_URL, PS_DOMAIN,
OL_BOOTSTRAP_ADMIN_PASSWORD, JWT_SECRET, JWT_EXPIRES_IN, and
OL_PII_HASH_SALT through ${VAR:-default} interpolation instead of
hardcoded literals, so a devops team can point the demo at a real public
domain (reverse proxy + TLS) and rotate the throwaway dev secrets purely
via .env, without editing the compose files.

Unlike OPENLINKER_CREDENTIALS_ENCRYPTION_KEY (${VAR:?required} — no safe
default exists for a credentials-encryption key), every variable here
defaults to today's exact literal value, so a plain `pnpm demo:up` /
`pnpm dev:stack:up` with no .env changes needs no behaviour change.

VITE_API_BASE_URL is a Vite build-time arg — .env.example calls out that
overriding it only takes effect on the next --build, not a plain restart.
PS_DOMAIN is documented as the PUBLIC browser-facing domain only; it does
not affect how api/worker reach PrestaShop internally (always the compose
service name, http://prestashop).

.env.example gains a clearly-separated OPTIONAL section (commented out by
default) documenting each variable's purpose and default.

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.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.

/pr-review — delta re-review (56744330543ab3d2, now ready, base = main)

All three IMPORTANT items from my 2026-07-06 pass are genuinely resolved with committed, CI-verifiable evidence, and both suggestions landed. Clearing my earlier request-changes. (Author's own delta = 2 commits; GitHub's true PR diff is 16 files, all infra/DX.)

✅ Prior IMPORTANTs

  1. .dockerignore never shipped → RESOLVED. Now tracked (git ls-tree blob cda5916); excludes .git/, **/node_modules/, **/dist/, **/coverage/, .env+.env.* (with !.env.example), and **/*.tsbuildinfo (the TS6305 catch). The # Docker exclusion block was deleted from .gitignore.
  2. Hardcoded plugin-COPY drift trap / no CI guard → RESOLVED. New docker-build-smoke job (ci.yml:326-346) runs docker build --target base and apps/web/Dockerfile — genuinely builds (base runs pnpm install+pnpm build), so a plugin added without a matching COPY fails workspace:* and the job. Gated to run on this PR (Dockerfile changed → code filter true). Source-of-truth comments added to the enumerated lists.
  3. Empty body / no test plan → RESOLVED. Substantive from-scratch run log (clean-volume demo:up, migrate exits 0, containers Up, /v1/auth/login→200, CORS echo, docker port confirming loopback-only, nginx -t+per-location header curl, compose config proving !override).

✅ Suggestions confirmed

  • Loopback ports: api uses ports: !override127.0.0.1:3000:3000 (replaces base rather than appends; also volumes: !reset []); web overlay-only 127.0.0.1:8090:80; OL_CORS_ORIGIN/VITE_API_BASE_URL line up.
  • nginx headers: gzip on; X-Content-Type-Options/X-Frame-Options/Referrer-Policy re-stated per-location (correct — server-level add_header isn't inherited once a location sets its own); /assets/ immutable 1y vs / no-cache.

✅ Regression sweep clean

Security: no tracked .env (only placeholder .env.example), demo-scoped throwaway literals now ${VAR:-default}, encryption key required via ${…:?} (fail-closed). Scope: all 16 files infra/DX, no src behavior code. Migrate-then-start gating intact (service_completed_successfully, one-shot migrate), no synchronize.

🟢 SUGGESTION (non-blocking)

The CI smoke build only exercises --target base; the production/worker stages carry a separate enumerated manifest + COPY --from=base .../dist list (Dockerfile:90-103, 125-126) that --target base never runs, so drift there stays uncaught until a full image build. Fine for a demo overlay (the sync comments mitigate) — worth a follow-up.

Verdict: ✅ Approve. Branch is currently behind main — update it before merge.

@piotrswierzy
piotrswierzy merged commit 0792f07 into main Jul 7, 2026
8 checks passed
norbert-kulus-blockydevs added a commit that referenced this pull request Jul 22, 2026
…staShop) (#1365)

* feat(demo): one-command Docker demo environment (API/Web/Worker + PrestaShop)

Add a dedicated demo overlay that boots the full OpenLinker stack in Docker with a single command, on top of the existing infra services.

- Dockerfile: new worker build target; also fixes a pre-existing blocker where the ksef/infakt workspace manifests + dist were never copied, so the image build failed outright (unnoticed because CI does not build the image).
- apps/web/Dockerfile + nginx.conf: static SPA build served by nginx with SPA-fallback routing; VITE_API_BASE_URL baked at build time.
- package.json: demo:up / demo:down / demo:logs scripts.
- README.md: demo section (URLs, credentials, manual PS<->OL connection).

Closes #1352

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* feat(demo): one-command Docker demo environment (API/Web/Worker + PrestaShop)

Add a dedicated demo overlay that boots the full OpenLinker stack in Docker with a single command, on top of the existing infra services.

- Dockerfile: new worker build target + apk add bash in base (migrate uses the repo migration:run script, which invokes typeorm via bash); also fixes a pre-existing blocker where the ksef/infakt workspace manifests + dist were never copied, so the image build failed outright (unnoticed because CI does not build the image).
- apps/web/Dockerfile + nginx.conf: static SPA build served by nginx with SPA-fallback routing; VITE_API_BASE_URL baked at build time.
- package.json: demo:up / demo:down / demo:logs scripts (demo:up includes phpmyadmin).
- README.md: demo section (URLs incl. phpMyAdmin, credentials, manual PS<->OL connection).

Closes #1352

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(demo): unblock one-command Docker demo boot + PrestaShop networking (#1368) (#1369)

Clean-checkout boot of the one-command demo (#1352, PR #1365) surfaced
several infra/docs gaps. This is infra + docs only, no domain code.

- A2: add start_period (180s) to the mysql healthcheck so first-boot init
  is not counted against the retry window on slower hosts.
- A3: set OL_CORS_ORIGIN=http://localhost:8090 on the demo api service so
  the web UI (published on :8090) logs in without a CORS NetworkError.
- A1: source OPENLINKER_CREDENTIALS_ENCRYPTION_KEY from the environment on
  migrate/api/worker with a required-var (:?) guard that fails the boot
  with a clear message; add a root .env.example and README pre-step
  documenting it (generate with openssl rand -base64 32).
- B1: add a PrestaShop post-install step (40-configure-container-network)
  that registers a prestashop-domain ps_shop_url row and disables the
  canonical redirect, so the app-tier containers can reach the shop by its
  compose service name; also document the container-reachable Shop URL.
- B2: document the container-reachable Storefront URL requirement for
  server-side offer image upload, with the browser-thumbnail trade-off
  noted as a tracked follow-up.
- Docs: correct the PrestaShop admin path (/admin -> /admin-dev) and add a
  louder warning about the demo sharing Compose project + volumes with
  dev:stack:up.

Closes #1368

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* docs(demo): add one-command demo setup guide (#1371)

Detailed walkthrough for the #1352/#1365 Docker demo: prerequisites,
example .env (required OPENLINKER_CREDENTIALS_ENCRYPTION_KEY), boot,
service URLs, manual PrestaShop + Allegro connection wiring, end-to-end
verification, and a troubleshooting table. Linked from the README demo
section.

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(demo): fix PrestaShop container-network post-install fatal on PS 9 (#1372)

* fix(demo): fix PrestaShop container-network post-install fatal on PS 9

Live re-test of #1369 on a fresh PrestaShop 9.0.2 install surfaced two bugs
in 40-configure-container-network.php that broke the container's boot
(set -e in the wrapper turned the PHP fatal into a hard container exit):

1. ShopUrl::getShopUrls() returns ShopUrl objects under PS 9, not arrays —
   $row['domain'] threw "Cannot use object of type ShopUrl as array",
   aborting post-install and exiting the prestashop container (255).
   Replaced with a direct Db::getInstance()->getValue() existence check
   (matches the ObjectModel/legacy-bootstrap style already used in this
   script and its 20-set-default-currency.php sibling).

2. Db::getValue() appends its own trailing `LIMIT 1` internally (via
   getRow()); the query's own explicit `LIMIT 1` doubled it into
   `... LIMIT 1 LIMIT 1`, a SQL syntax error (exit 255 again).

Verified end-to-end on a from-scratch PrestaShop 9.0.2 install (fresh
volume + fresh database): post-install now logs "ShopUrl added for domain
'prestashop'", ps_shop_url carries both the main localhost row and the new
non-main 'prestashop' row, PS_CANONICAL_REDIRECT=0, the container stays up
(exit 0), and the API container's webservice probe to http://prestashop/api/
returns 401 (reached, no redirect) instead of the prior 301/302 to the
canonical localhost domain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* docs(demo): fix .env key recipe (duplicate line, sed portability)

The documented .env recipe (README + the setup guide) did
`cp .env.example .env` then `echo "KEY=..." >> .env`. .env.example
already ships an empty `OPENLINKER_CREDENTIALS_ENCRYPTION_KEY=` line, so
appending leaves TWO lines with the same key — harmless (env-file parsing
is last-wins) but confusing to anyone editing .env by hand, and flagged in
the #1369 review.

The guide's proposed fix (sed -i '...') introduced a second problem: GNU
sed and BSD sed (macOS) have incompatible -i syntax (BSD requires
-i '' <script>), so the documented command would fail outright on macOS.

Replaced both with a portable, dependency-free two-liner that drops the
example's empty key line and appends the generated one:

  grep -v '^OPENLINKER_CREDENTIALS_ENCRYPTION_KEY=' .env.example > .env
  echo "OPENLINKER_CREDENTIALS_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* fix(demo): use ShopUrl object property access instead of raw SQL

Addresses PR #1372 review suggestions: revert the idempotency check to
ShopUrl::getShopUrls() with property access ($row->domain) instead of a
hand-built Db::getInstance()->getValue() raw SQL string. Same idempotency
semantics, no manual SQL/pSQL() escaping, and stays consistent with the
sibling script's ObjectModel-only convention (also sidesteps the flagged
double-quoted SQL string literal / ANSI_QUOTES portability risk, since
there's no raw SQL left in this block at all).

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

---------

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(demo): address PR #1365 review findings

Addresses every /pr-review finding from @piotrswierzy, including
suggestions: commit .dockerignore (was gitignored, so a fresh clone
built with no ignore rules at all — this also uncovered and fixed a
real tsc -b failure from a stray host tsconfig.tsbuildinfo leaking
into the build context), a CI docker-build smoke job guarding the
Dockerfile's per-package COPY lists, loopback-bound demo ports for
api/web (default admin/admin ships with no redaction), nginx
gzip/cache/security headers, and comments tying the enumerated COPY
lists to their source of truth.

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

* feat(demo): parameterize domain/secret env vars via .env (#1375)

Wires OL_CORS_ORIGIN, VITE_API_BASE_URL, PS_DOMAIN,
OL_BOOTSTRAP_ADMIN_PASSWORD, JWT_SECRET, JWT_EXPIRES_IN, and
OL_PII_HASH_SALT through ${VAR:-default} interpolation instead of
hardcoded literals, so a devops team can point the demo at a real public
domain (reverse proxy + TLS) and rotate the throwaway dev secrets purely
via .env, without editing the compose files.

Unlike OPENLINKER_CREDENTIALS_ENCRYPTION_KEY (${VAR:?required} — no safe
default exists for a credentials-encryption key), every variable here
defaults to today's exact literal value, so a plain `pnpm demo:up` /
`pnpm dev:stack:up` with no .env changes needs no behaviour change.

VITE_API_BASE_URL is a Vite build-time arg — .env.example calls out that
overriding it only takes effect on the next --build, not a plain restart.
PS_DOMAIN is documented as the PUBLIC browser-facing domain only; it does
not affect how api/worker reach PrestaShop internally (always the compose
service name, http://prestashop).

.env.example gains a clearly-separated OPTIONAL section (commented out by
default) documenting each variable's purpose and default.

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
norbert-kulus-blockydevs added a commit that referenced this pull request Aug 17, 2026
…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>
norbert-kulus-blockydevs added a commit that referenced this pull request Aug 18, 2026
…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>
norbert-kulus-blockydevs added a commit that referenced this pull request Aug 19, 2026
…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>
norbert-kulus-blockydevs added a commit that referenced this pull request Aug 20, 2026
…urrency stamping (#2135)

* docs(mockups): UI mockups for the order-time FX stamp surfaces

Every surface ADR-040's reporting-currency stamp touches, built against the
real design system (tokens transcribed from apps/web/src/index.css, primitives
from apps/web/src/shared/ui/): the Platform/Currency settings tile in its three
resolution states, the /analytics layout per the Design 1 'Ledger' cut, the
orders list money cell, the order-detail audit panel, the two new job types,
the invoicing boundary, and the five-state model behind every badge.

Also records the four design decisions taken outside ADR-040 and the work
breakdown across the six sub-issues.

Refs #2049

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

* docs(mockups): correct the ECB blocker claim in the work breakdown

The page claimed ECB's historical endpoint was an unresolved Phase 1b
blocker. That came from PR #2050's description, which described an earlier
draft rather than what merged - the plan's ECB reference rates subsection
in main is verified against the live API, and an independent re-verification
reproduced every claim in it.

Replaces the claim with the eight facts that re-verification did add,
including the includeHistory + lastNObservations phantom-row bug now
recorded on #2123.

Refs #2049

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

* feat(shared): add previousWorkingDay to the Polish working-day calendar

The FX rate-date rule resolves a candidate calendar day back to a day NBP
actually published on, which means walking backwards over Polish weekends
and public holidays. `pl-working-days.ts` already owns that calendar but
only counted forwards (`addWorkingDays`), so a caller would have had to
re-implement it.

`previousWorkingDay` mirrors `addWorkingDays` exactly - same Europe/Warsaw
civil anchoring, same date-only UTC proxy cursor, same holiday set and
weekend predicate, same wall-clock time-of-day preservation. The source
instant is never counted; the walk starts from the previous day.

Refs #2122

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ktirW7dvWqN42TJRMdwuD
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* test(shared): make the Warsaw-anchoring cases actually discriminate

Both timezone tests picked instants where the UTC-anchored and Warsaw-anchored
walks happen to agree, so neither could detect the anchoring being dropped.
Replacing toWarsawCivil with plain getUTC* left both green.

Swapped for instants where the two diverge - addWorkingDays now starts from an
instant that is Sunday in UTC and Monday in Warsaw (2026-06-23 vs 2026-06-22),
previousWorkingDay from one that is Friday in UTC and Saturday in Warsaw
(2026-06-19 vs 2026-06-18). Both expectations verified by execution.

Adds the two backwards cases the forward suite already had counterparts for:
a walk crossing a year boundary (movable holidays rebuilt mid-walk) and the
Wigilia/Christmas chain, the longest real run of non-working days.

Also documents the composition a publication-calendar walk-back needs -
previousWorkingDay always steps back, so resolving a candidate to the nearest
working day at or before it requires guarding with isPlWorkingDay first. The
NBP adapter in #2123 is the caller that would otherwise skip a valid
publication day and stamp the wrong rate.

Refs #2122

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

* feat(currency): add the currency context, rate port, registry and reporting-currency setting

A new leaf core context owning everything about an order-time FX stamp that
is not HTTP: the ExchangeRateProviderPort contract, the provider registry,
the shared append-only exchange_rates registry, the pure rule -> rate-date
and reporting-currency -> source derivations, and the system-level
reporting-currency setting.

The context imports no sibling core context and makes no outbound call, so
the providers cannot live here - they ship in @openlinker/integrations-fx.
That split is ADR-040 Decision 7 and is deliberately not conditioned on
whether a source needs a credential today, so nothing moves packages if NBP
or ECB adds a key.

Three decisions worth calling out, because each has a plausible-looking
wrong answer:

- resolveRateDate is CALENDAR-NEUTRAL. It yields a candidate calendar day
  and knows about neither weekends nor any country's holidays; each adapter
  absorbs its own publication calendar. A shared Polish calendar would
  silently stale every ECB rate on a Polish-only holiday - ECB publishes on
  Corpus Christi and Epiphany, Poland does not, and the resulting figure is
  wrong by ~0.035% with no error anywhere. The today-in-Warsaw clamp is
  likewise load-bearing rather than defensive: a future endPeriod makes ECB
  answer with a months-stale rate at HTTP 200 and no signal of any kind.

- Direction is an invariant. `rate` is the number of `to` units per one
  `from` unit, so a consumer always multiplies. An inverted or pivoted rate
  records its derivation NOT NULL - a direct rate stores
  {"kind":"direct","legs":[...]} - so the column is never a "sometimes
  populated" field and a derived figure stays auditable.

- The rate registry is append-only BY CONSTRUCTION. The port declares only
  findByKey and insertIfAbsent; there is no update, upsert, delete, or save
  carrying an id. A stamped order points at a registry row as evidence, so
  an editable rate would make every figure derived from it unverifiable. A
  spec pins the absence, including that the single save() carries no id.

The setting lives here rather than in orders because save-time coverage
validation needs the provider list; putting it in orders would create an
orders -> currency value dependency for validation alone. Validation is
three layers and zero HTTP: ISO shape (400), reachability against
SUPPORTED_REPORTING_CURRENCIES narrowed by the registered providers (422,
the hard gate, a pure array test), and a coverage advisory that warns and
never blocks - composed by the caller so no currency -> orders edge appears.

CurrencyModule is a static @module, never forRoot: core and the fx package
must resolve ONE registry instance, exactly as AdapterRegistryService does.

The migration for exchange_rates and reporting_currency_setting is Phase 2
of the epic and is not in this change.

Refs #2123

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

* feat(fx): add @openlinker/integrations-fx with the NBP and ECB rate adapters

Both providers of ExchangeRateProviderPort, in a new workspace package, plus
FxIntegrationModule which registers them into the core registry at boot -
byte-for-byte the mechanism integration modules already use for
AdapterRegistryService. Nothing in libs/core imports this package.

It is NOT a plugin: no adapter manifest, no capability, no
getCapabilityAdapter path. A published reference rate is a shared read of a
public source, not a per-connection capability.

The two adapters are near-mirror images and each is written around a trap
the other does not have:

NBP (quotes X -> PLN) owns the Polish working-day calendar. It resolves the
calendar candidate to the nearest working day AT OR BEFORE it -
`isPlWorkingDay(c) ? c : previousWorkingDay(c)`, never a bare
previousWorkingDay, which always steps back at least one day and would skip
a perfectly good publication day to stamp yesterday's rate. The 404
walk-back that follows is defence in depth, not the mechanism. Any non-404
4xx is terminal rather than just 400, since NBP's malformed-date response is
documented but unverified.

ECB (quotes EUR -> X) has no walk-back at all: endPeriod +
lastNObservations=1 makes the API resolve "the last publication on or before
this date" server-side, correct across clusters a walk-back-by-one gets
wrong. includeHistory is deliberately never set - combined with
lastNObservations=1 it injects a phantom ACTION=Delete row with an empty
OBS_VALUE and an unrelated historical TIME_PERIOD. A non-publication day is
a 200 with a ZERO-BYTE body, not a 404, so the body is length-checked before
any parsing; a 404 means the series does not exist; a 400 returns HTML while
404/406 return problem+json, so a 4xx body is never JSON.parse'd. CSV
columns are indexed by header name, never by position. A 10-day observation
lag is asserted as a cheap backstop - the real maximum non-publication run
is 4 days, so it can only fire on a clamp regression.

ECB assigns no document identifier (header.id is a fresh UUID per request,
Last-Modified is not data-dependent), so sourceRef persists an
OpenLinker-constructed re-executable locator, ECB:EXR(1.0):<key>@<period>.
That is stated in the code rather than passed off as an ECB reference.

Both adapters take an injected FetchLike, so every spec fakes HTTP without
touching globalThis and no tier makes a live call. The package is added to
the outbound-http scan roots and the matching ESLint glob; the single
exemption is the FX_FETCH_TOKEN default factory, where ADR-038's
connection-bound transport is structurally unusable because it keys its
cache and rate-limit bucket on connection.id and a reference-rate read has
no connection.

The @openlinker/* edges are declared in package.json, not only in tsconfig
references - pnpm never reads tsconfig, and omitting the manifest edge lands
the package in the same `pnpm -r` chunk as its sibling (#2011).

Refs #2123

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

* chore(hosts): register FxIntegrationModule in the api and worker plugin lists

The binding crosses from the integration package into core at the host, so
nothing in libs/core imports @openlinker/integrations-fx: the module is
added to apiPlugins / workerPlugins, PluginRegistryModule.forRoot re-exports
it, and its onModuleInit populates the core exchange-rate registry.

The worker is the load-bearing registration - order ingestion and the FX
retry / reconcile-sweep handlers all run there. The API is registered too,
matching the dual registration WooCommerce, InPost, Subiekt and AI already
have, so a future API-side restamp endpoint fails at boot rather than at
runtime against an empty registry.

Refs #2123

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

* fix(fx,currency): apply the #2123 review findings

Three IMPORTANT findings and eight suggestions from the /pr-review pass.

NBP errors named a pair the caller never requested. Every raise inside the
fetch path reported the LEG currency rather than the requested pair, so
fetchRate({from:'PLN',to:'EUR'}) failed as 'EUR/EUR' and a 503 on the same
request logged as 'EUR/PLN'. A RateUnsupportedPairError is a terminal
business_failure with no retry, so that log line is the only signal an
operator gets. The requested from/to are now threaded through
fetchQuotesForNearestPublishedDay -> tryFetchQuotesFor -> fetchQuote ->
parseQuote, matching what the ECB adapter already did.

The registry get-or-create had no integration test, which the issue's
acceptance criteria and the plan's section 9 scenario 5 both require - and
the plan states the concurrency claim is not unit-testable. The 23505 ->
DuplicateExchangeRateError -> re-select chain was exercised only against a
jest.fn() told to reject, so the real unique index, the real error code and
what two concurrent callers observe were untested at every tier. Adds
exchange-rate-registry.int-spec.ts covering byte-identical re-read, two
concurrent calls resolving to one row, the domain error crossing the port
boundary, and one row per distinct rate date. It stubs the transport under
the real service, registry, adapter and repository rather than substituting
a fake provider, so no network call is made. Both new tables join the
harness truncate list.

The registry's cost was understated. The pre-fetch read is keyed on the
candidate day while the write is keyed on the published day, so a candidate
that resolves by walk-back is never memoised and every order carrying it
re-fetches - roughly 2 days in 7, not 'one extra call per candidate day'.
The behaviour is correct (no duplicate row, no wrong-dated rate, no loop);
only the claim was wrong. Header and comment now state it, and a spec pins
that a weekend candidate re-fetches while a publication-day candidate does
not. Memoising the candidate-to-published mapping needs its own table and
is left to the persistence phase.

Also: append-only source-text guard now blocks createQueryBuilder( and
manager.; the ECB pivot uses allSettled with terminal-beats-transient
precedence instead of all, whose rejection order was timing-dependent; both
adapters use the exported RateDerivationKind instead of re-declaring the
union; the NBP date formatter is hoisted to module scope; the
MAX_OBSERVATION_LAG_DAYS boundary is pinned at 10 and 11 and its reason
string names the reconcile sweep as the recovery route; NBP_TABLE_A_CURRENCIES
explains why PLN heads a list of table-A rows; and the fake adapter's reset()
restores its constructor seed instead of emptying the map.

Refs #2123

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

* fix(fx): source-map integrations-fx for the integration harness

Re-review caught three small things, one of which made the new int-spec
unrunnable outside CI.

@openlinker/integrations-fx entered both apps' plugin graphs without a
moduleNameMapper pair in apps/api/test/jest-integration.cjs or the worker's,
which check-jest-integration-mappers.mjs exists to catch (#916, #786). The
package's main is ./dist/index.js, so in a fresh un-built worktree the new
exchange-rate-registry int-spec - and every other apps/api and apps/worker
int-spec - failed at module resolution. CI masked it by building dist first.

The gap was not caught earlier because check:invariants is an && chain and
check-repo-urls sits ahead of the mapper guard; its known failure on the
untracked .worktrees directory short-circuited everything after it. Every
check past that point has now been run individually and passes.

Also drops a redundant `| null` from pickLegFailure's return type, which
tripped no-redundant-type-constituents and failed pnpm lint, and a redundant
type assertion on a query() result in the int-spec.

Refs #2123

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

* feat(orders): persist the per-order FX snapshot columns and their stamp-once writes

Adds the six nullable FX columns to `order_records` plus the DDL that #2123
deliberately deferred: this migration creates `exchange_rates` and
`reporting_currency_setting` as well, so the three tables land as one schema
unit.

`reportingCurrency IS NULL` is the canonical "unstamped" test - `exchangeRateId`
is legitimately NULL on the same-currency path, and `fxIntendedCurrency` is a
separate column from `reportingCurrency` because an intent exists on a row that
is still unstamped, which is also why the group CHECK's first arm deliberately
omits `"fxRule" IS NULL`.

Two conditional writes own the columns, both in the `claimWaybillRelay` shape
(`IsNull()` in the WHERE, `affected > 0` as the answer):
`claimFxIntentIfAbsent` pins the currency + rule at the first attempt, and
`stampFxIfAbsent` writes all five stamp columns in one statement so the group
cannot half-apply. `toOrm` maps none of the six - `upsert` is a full-row
`save()` on an update-or-create ingestion path, so mapping them would let a
re-poll write `null` over a reported financial figure; a regression spec asserts
each key is absent from the entity passed to `save()`.

`listDistinctNativeCurrencies` feeds the coverage advisory, reading
`orderSnapshot.totals.currency` through the same `jsonb_typeof`-guarded form the
migration's expression index uses.

The group CHECK is verified by parsing the emitted constraint and evaluating it
against all five legal FX states plus the illegal combinations, because nothing
in CI runs a migration - the Testcontainers schema is built by `synchronize`, so
no int-spec can observe the constraint. The live run/revert/run round-trip
remains a manual gate.

Refs #2124

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

* docs(architecture): document the Currency bounded context

Adds a § 17 Currency section to docs/architecture-overview.md, the
forward reference ADR-040 leaves open, and the `orders -> currency`
edge to the cross-context dependency graph.

The section records the reporting-currency resolution chain, the
code-constant rate-source map, the multiply-never-divide direction
invariant as a property of the stamp rather than of the consumer-neutral
registry, the first-attempt intent snapshot and why provider
availability must not become an input to a financial figure, the
port-in-core / adapters-in-@openlinker/integrations-fx split with
providers deliberately not being capability adapters, the
calendar-neutral rate-date rule, and the five persisted states together
with the two predicates a consumer gets wrong.

It also states positively that the stamp is analytics-only and must
never supply FA(3) `KursWaluty`: an earlier draft of the plan asserted
the opposite, and the stamp differs from a statutory conversion on
date, target and derivation, so leaving the reversal as an absence
would leave the nearest persisted rate as the one a future
implementation reaches for.

Refs #2127

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

* feat(orders,worker): stamp orders in the reporting currency at ingestion

Phase 3 of the order-time FX stamp (#2125, ADR-040).

OrderFxStampService.stamp(internalOrderId) is the one seam every attempt
goes through - the inline call from persistOrder, the marketplace.order.fxStamp
retry job, and the hourly marketplace.order.fxStampSweep reconcile. One
signature for all three: placedAt lives only in orderSnapshot JSONB and an
unparseable value is silently dropped on rehydration, so two signatures would
let the inline and retry paths disagree about whether it exists.

The persisted intent (fxIntendedCurrency + fxRule) is read and pinned before
anything else. A row that already carries one skips the settings service
entirely; otherwise the resolved value is claimed with a conditional write
and a losing concurrent attempt adopts the winner's. Without this an order
degraded to the retry job could stamp a different currency than the same
order stamped inline, making provider availability a silent input to a
financial figure.

Same-currency orders stamp with no rate lookup and no I/O. A converting order
multiplies - ExchangeRate.rate is `to` units per one `from` unit by contract -
and rounds with the house round2 idiom, never pricing-rule.types.ts's
round2dp, which clamps negatives to zero and would turn a refund into a fact.

The service never throws: every failure folds into a stamped/terminal/deferred
outcome, so a rate provider being down cannot fail an ingestion that already
persisted the order. A transient failure enqueues fx:{internalOrderId} in its
own nested try/catch, logged distinctly from the stamp failure, because a
lost enqueue leaves the hourly sweep as the only remaining route to a stamp.

persistOrder collapses its two post-upsert writers - cancellation and the FX
stamp - into one refresh. Each writer now reports whether it wrote rather
than re-reading itself, so the returned record reflects both instead of the
second writer's effect being silently dropped by the first's re-read.

The sweep reads order_records directly on fxStampedAt IS NULL AND
reportingCurrency IS NULL, scheduled hourly per OrderSource-capable
connection - the guarantee that survives a dead retry job, since a job's
idempotency key is globally unique with no TTL and the ~4.3h retry window
means a longer outage would otherwise lose the stamp permanently.

Refs #2125

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

* feat(api,orders): currency-settings API surface

Phase 4 (backend half) of the order-time FX stamp (#2126, ADR-040).

GET /currency-settings and PUT /currency-settings/reporting-currency, both
admin-only, mirroring /ai-provider-settings' route naming and its
withDomainExceptionMapping boundary split: the ISO-shape failure is 400, an
unreachable-but-well-formed code is 422 carrying the accepted set.

The coverage advisory and the stamped-row counts are composed in the
controller, the one layer allowed to combine currency with orders - doing it
inside CurrencyRateService would create a currency -> orders edge and cost
that context its leaf property.

IOrderFxReadService is the narrow cross-context seam: listDistinctNativeCurrencies
(already published) plus the new countStampedByReportingCurrency, grouped by
reporting currency rather than totalled because the era breakdown - not a
bare total - is the operator-facing fact behind "changing this setting splits
history."

Refs #2126

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

* feat(web): Platform/Currency settings tile, env passthrough, guard coverage

Completes Phase 4 (#2126, ADR-040) - the backend controller/DTOs/module and
the orders-side aggregate read landed in an earlier commit; this finishes
the frontend tile, the mandatory write-guard entry, the three .env.example
files and the demo compose passthrough the issue also calls for.

The tile is named Platform / Currency, not Analytics / Reporting currency.
The value is a property of the deployment, not a setting owned by one
module - analytics is merely its first consumer, and invoices compute their
own rate and never read this. An Analytics eyebrow would under-claim and a
title like Instance currency would over-claim, so scope lives in the body
copy instead of the name.

Renders three source states, not the plan's two: EUR (default), PLN (from
env), and a bare PLN once an operator has saved a value. "Nobody has
decided" and "an operator pinned this in configuration" are different
facts and only one of them is a problem - source is already on the
response, so the split costs nothing.

The dialog's coverage-gap checkbox gates the Save button client-side rather
than the backend rejecting an unacknowledged submit, matching ADR-040's
warn-never-block contract: one junk currency in old order history must
never make a legitimate reporting currency permanently unselectable.

CurrencySettingsController is added to write-guard-coverage.spec.ts's
CONTROLLERS - the issue calls this not optional, since a write endpoint
absent from that list ships without guard coverage and nothing fails.
OL_REPORTING_CURRENCY is documented in all three .env.example files (the
worker one matters because it runs the retry job and the sweep) and passed
through docker-compose.demo.yml, defaulting to PLN to match the demo shop's
own currency.

Refs #2126

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

* fix(orders): value-import OrderRecordRepositoryPort in OrderFxReadService

An interface injected via @Inject on a decorated constructor parameter must
be a value import, not import type — emitDecoratorMetadata needs the symbol
resolvable per-file, and a type-only import can erase to a dangling
reference under isolatedModules-style single-file transpilation (ts-jest,
esbuild, swc). Same pattern already established for IIntegrationsService in
invoice.service.ts and applied to OrderFxStampService's own constructor in
an earlier commit on this branch — this was the one file the #2126 branch
had not yet matched to it. Caught by pnpm -r lint's --fix pass.

Refs #2126

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

* fix(docker): add libs/integrations/fx to the Dockerfile's manifest COPY lists

The base and production stages hand-enumerate every @openlinker/* workspace
package for layer-caching COPY, with the Dockerfile's own comment warning
this is exactly the #1365 review class of bug: a package missing from the
list makes pnpm install fail to resolve its workspace:* reference and
breaks the image build. @openlinker/integrations-fx (#2123) was never added
to any of the three lists (package.json x2, dist x1), so the demo/production
image failed to build for the whole epic. Caught while booting the epic
branch for E2E verification.

Refs #2049

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

* fix(demo): match OL_REPORTING_CURRENCY across api and worker services

The final /pr-review pass caught it: only the api service's environment
block set OL_REPORTING_CURRENCY: PLN. Order ingestion, the fxStamp retry
job and the hourly reconcile sweep all run in the WORKER process, and
ReportingCurrencySettingsService.resolve() falls back to this env var
per-process before any settings row exists - so a fresh demo deployment
would have silently stamped orders in EUR (the code-constant default)
until an operator manually visited /currency-settings, contradicting the
compose comment's own stated PLN intent. apps/worker/.env.example already
names this exact hazard class for the api/worker pair generally; this
carries the same reasoning into the demo compose file specifically.

Refs #2049

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

* fix(api): move the FX migration spec out of the TypeORM migrations glob

Live E2E boot caught it immediately: data-source.ts's migrations glob
(migrations/**/*{.ts,.js}) feeds every matched file straight into
migration:run, so the colocated migrations/__tests__/1834000000000-add-
order-fx-stamp.spec.ts was require()'d by the CLI itself and crashed on
its first bare describe() - a jest global that does not exist in that
ts-node process. `migrate` exited 1 on every boot; nothing in CI or the
test harness runs a migration, so this was never exercised before now.

Moved to database/__tests__/, beside data-source.ts (the file that owns
the glob) and outside its reach; jest's repo-wide testRegex picks it up
regardless of location, so no test-discovery change. Fixed the relative
import to the migration class and left a note explaining why this specific
directory, since it is the first migration to ship a colocated unit spec
and the next one will want the same shape without the same landmine.

Refs #2124

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

* docs(mockups): live E2E verification report for the FX stamp epic

Boots the epic branch on a real stack, hand-verifies one live NBP-sourced
conversion (19.99 EUR at 4.342 = 86.80 PLN), and documents the three
deploy-only bugs a live boot found that no review pass could have: the
Dockerfile's manifest COPY lists never learned about
@openlinker/integrations-fx, OL_REPORTING_CURRENCY was set on the demo
compose's api service but not the worker (the process that actually runs
ingestion), and the migration's own unit spec crashed migration:run because
TypeORM's CLI globs and require()s every file under migrations/ directly.

Refs #2049

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

* fix(web/currency-settings): stop showing a bare stamped-orders count on the tile

`Stamped orders: 0` read as an alarm ("0 problems") instead of the coverage
fact it is, and the per-currency grouping only ever produces a real
breakdown when the deployment has changed its reporting currency before —
otherwise it's one bucket, not a breakdown. Move it behind a secondary
"Coverage" action with copy that explains what's being counted and why 0
is normal right after this ships.

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

* fix(orders): repair rebase merge-artifact regressions onto main

Rebasing onto main's sales-document-block work (#2100) silently dropped
CurrencyApiModule from app.module.ts's imports (import statement survived,
array entry didn't), and shifted OrderRecord's constructor arg order so
positional test calls needed 3 extra nulls for the salesDocument fields
that now precede the FX fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* fix(currency,sync): fix CI failures on FX rate snapshot PR

The FX stamp sweep task's OL_ORDER_FX_STAMP_SWEEP_CRON key was missing
from the scheduler spec's cron-key allowlist, so the mocked ConfigService
fell through to 'true' for that key and CronJob rejected it
("Unknown alias: tru"), aborting onApplicationBootstrap and failing
every other registered task's test in the suite.

Separately, buildCoverage always set rateSource from resolveSourceKey
regardless of whether a provider was actually registered, so an
unregistered candidate reported a rateSource instead of null.

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

* fix(currency,fx,orders): address the #2135 tech-lead review (2 IMPORTANT, 7 SUGGESTION)

IMPORTANT 1 - a 429/408 was classified TERMINAL, and terminal was permanent.
`isTransientFxStatus` (one choke point in `fx-http.client`) now folds 429 and
408 into the transient arm of both adapters: NBP and ECB are public
unauthenticated APIs and the sweep's sequential page walk can earn a throttle
unaided, which previously wrote the row's permanent `fxStampedAt` marker and cost
those orders their reported figure. The review also asked for a recovery path,
since `no-rate-source` had the same permanence: the sweep frontier gains a second
OR'd arm that re-admits a terminal row whose marker has aged past a cooldown
(payload `terminalRetryDays`, default 7) and that still carries NO figure.
`markFxTerminalIfAbsent` becomes `markFxTerminal`, guarded on
`reportingCurrency IS NULL` alone, so a re-answer moves the marker forward
instead of the row being re-tried on every tick. The stamp itself stays immutable
- `reportingCurrency IS NULL` sits in both frontier arms and in the marker guard.

IMPORTANT 2 - no `User-Agent` on outbound FX requests. `FX_USER_AGENT` now
identifies OpenLinker on every `fxGet`; undici sends no default UA at all, and
this package deliberately carries no local retry loop to absorb a filter.

SUGGESTIONS
3. `EcbExchangeRateAdapter` had no logger at all - added, with a debug on the
   resolved observation and on the empty-200 non-publication branch (the ECB
   analogue of NBP's walk-back line). The core terminal log now also carries the
   provider's own message, which previously died in the catch.
4. Migration header said the tail on `main` was `1833000000005`; it is
   `1833000000006`. Also records that slot `1834000000000` is uncontested.
5. `NBP_MAX_WALK_BACK_DAYS = 7` with a `<=` loop made 8 requests. Renamed to
   `NBP_MAX_WALK_BACK_ATTEMPTS = 8` with `<`, matching what the spec asserts.
6. ECB accepted a 10-day-stale observation silently. Above 3 days it is still
   accepted (4 is a real TARGET run) but WARN-logged.
7. `enqueueRetry`'s wave-less key is intended - documented why, and why #2039's
   `refreshSnapshot` fix does not apply (the sweep is an unconditional backstop).
8. A11y: `Alert` already carries `role="status"`, but a live region inserted
   together with its content is not reliably announced. The dialog now mounts one
   persistent visually-hidden region that names whichever warning materialised
   and the acknowledgement it requires.
9. `CurrencyModule.onApplicationBootstrap` refuses to finish boot with an empty
   provider registry (`NoExchangeRateProvidersRegisteredError`), converting two
   silent opposite degradations into one loud startup failure.

Tests: 429/408 per adapter, the UA header + the transient-status table, the ECB
staleness warn band on both sides of the threshold, the re-armable marker, the
two-arm frontier, the sweep cooldown default/clamp/pass-through, the boot
assertion, and four dialog live-region cases. `docs/architecture-overview.md`
§ Currency restates the sweep predicate.

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

* test(api/sync): keep the scheduler spec's cronKeys arrays alphabetical

`OL_ORDER_FX_STAMP_SWEEP_CRON` was appended to the end of all four `cronKeys`
arrays. Those arrays exist so a missing key cannot silently fall through to
`'true'` and abort `onApplicationBootstrap` for every task, and they carry a
comment telling the next author to register there - which only keeps its
diff-scan value while the list is ordered.

Sorts all four alphabetically (the pre-existing entries were unordered too, so
sorting only the new key would not have restored the property), states the
convention in the comment, and repeats the comment in the fourth array, which
did not carry it.

Test-only; `scheduler.service.spec.ts` passes 35/35.

Refs #2135

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

---------

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Signed-off-by: Norbert Kulus <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
piotrswierzy pushed a commit that referenced this pull request Aug 24, 2026
…p-products endpoints + per-line tax rate + net tax basis (#2014)

* docs(mockups): UI mockups for the order-time FX stamp surfaces

Every surface ADR-040's reporting-currency stamp touches, built against the
real design system (tokens transcribed from apps/web/src/index.css, primitives
from apps/web/src/shared/ui/): the Platform/Currency settings tile in its three
resolution states, the /analytics layout per the Design 1 'Ledger' cut, the
orders list money cell, the order-detail audit panel, the two new job types,
the invoicing boundary, and the five-state model behind every badge.

Also records the four design decisions taken outside ADR-040 and the work
breakdown across the six sub-issues.

Refs #2049

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

* docs(mockups): correct the ECB blocker claim in the work breakdown

The page claimed ECB's historical endpoint was an unresolved Phase 1b
blocker. That came from PR #2050's description, which described an earlier
draft rather than what merged - the plan's ECB reference rates subsection
in main is verified against the live API, and an independent re-verification
reproduced every claim in it.

Replaces the claim with the eight facts that re-verification did add,
including the includeHistory + lastNObservations phantom-row bug now
recorded on #2123.

Refs #2049

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

* feat(shared): add previousWorkingDay to the Polish working-day calendar

The FX rate-date rule resolves a candidate calendar day back to a day NBP
actually published on, which means walking backwards over Polish weekends
and public holidays. `pl-working-days.ts` already owns that calendar but
only counted forwards (`addWorkingDays`), so a caller would have had to
re-implement it.

`previousWorkingDay` mirrors `addWorkingDays` exactly - same Europe/Warsaw
civil anchoring, same date-only UTC proxy cursor, same holiday set and
weekend predicate, same wall-clock time-of-day preservation. The source
instant is never counted; the walk starts from the previous day.

Refs #2122

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ktirW7dvWqN42TJRMdwuD
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* test(shared): make the Warsaw-anchoring cases actually discriminate

Both timezone tests picked instants where the UTC-anchored and Warsaw-anchored
walks happen to agree, so neither could detect the anchoring being dropped.
Replacing toWarsawCivil with plain getUTC* left both green.

Swapped for instants where the two diverge - addWorkingDays now starts from an
instant that is Sunday in UTC and Monday in Warsaw (2026-06-23 vs 2026-06-22),
previousWorkingDay from one that is Friday in UTC and Saturday in Warsaw
(2026-06-19 vs 2026-06-18). Both expectations verified by execution.

Adds the two backwards cases the forward suite already had counterparts for:
a walk crossing a year boundary (movable holidays rebuilt mid-walk) and the
Wigilia/Christmas chain, the longest real run of non-working days.

Also documents the composition a publication-calendar walk-back needs -
previousWorkingDay always steps back, so resolving a candidate to the nearest
working day at or before it requires guarding with isPlWorkingDay first. The
NBP adapter in #2123 is the caller that would otherwise skip a valid
publication day and stamp the wrong rate.

Refs #2122

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

* feat(currency): add the currency context, rate port, registry and reporting-currency setting

A new leaf core context owning everything about an order-time FX stamp that
is not HTTP: the ExchangeRateProviderPort contract, the provider registry,
the shared append-only exchange_rates registry, the pure rule -> rate-date
and reporting-currency -> source derivations, and the system-level
reporting-currency setting.

The context imports no sibling core context and makes no outbound call, so
the providers cannot live here - they ship in @openlinker/integrations-fx.
That split is ADR-040 Decision 7 and is deliberately not conditioned on
whether a source needs a credential today, so nothing moves packages if NBP
or ECB adds a key.

Three decisions worth calling out, because each has a plausible-looking
wrong answer:

- resolveRateDate is CALENDAR-NEUTRAL. It yields a candidate calendar day
  and knows about neither weekends nor any country's holidays; each adapter
  absorbs its own publication calendar. A shared Polish calendar would
  silently stale every ECB rate on a Polish-only holiday - ECB publishes on
  Corpus Christi and Epiphany, Poland does not, and the resulting figure is
  wrong by ~0.035% with no error anywhere. The today-in-Warsaw clamp is
  likewise load-bearing rather than defensive: a future endPeriod makes ECB
  answer with a months-stale rate at HTTP 200 and no signal of any kind.

- Direction is an invariant. `rate` is the number of `to` units per one
  `from` unit, so a consumer always multiplies. An inverted or pivoted rate
  records its derivation NOT NULL - a direct rate stores
  {"kind":"direct","legs":[...]} - so the column is never a "sometimes
  populated" field and a derived figure stays auditable.

- The rate registry is append-only BY CONSTRUCTION. The port declares only
  findByKey and insertIfAbsent; there is no update, upsert, delete, or save
  carrying an id. A stamped order points at a registry row as evidence, so
  an editable rate would make every figure derived from it unverifiable. A
  spec pins the absence, including that the single save() carries no id.

The setting lives here rather than in orders because save-time coverage
validation needs the provider list; putting it in orders would create an
orders -> currency value dependency for validation alone. Validation is
three layers and zero HTTP: ISO shape (400), reachability against
SUPPORTED_REPORTING_CURRENCIES narrowed by the registered providers (422,
the hard gate, a pure array test), and a coverage advisory that warns and
never blocks - composed by the caller so no currency -> orders edge appears.

CurrencyModule is a static @Module, never forRoot: core and the fx package
must resolve ONE registry instance, exactly as AdapterRegistryService does.

The migration for exchange_rates and reporting_currency_setting is Phase 2
of the epic and is not in this change.

Refs #2123

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

* feat(fx): add @openlinker/integrations-fx with the NBP and ECB rate adapters

Both providers of ExchangeRateProviderPort, in a new workspace package, plus
FxIntegrationModule which registers them into the core registry at boot -
byte-for-byte the mechanism integration modules already use for
AdapterRegistryService. Nothing in libs/core imports this package.

It is NOT a plugin: no adapter manifest, no capability, no
getCapabilityAdapter path. A published reference rate is a shared read of a
public source, not a per-connection capability.

The two adapters are near-mirror images and each is written around a trap
the other does not have:

NBP (quotes X -> PLN) owns the Polish working-day calendar. It resolves the
calendar candidate to the nearest working day AT OR BEFORE it -
`isPlWorkingDay(c) ? c : previousWorkingDay(c)`, never a bare
previousWorkingDay, which always steps back at least one day and would skip
a perfectly good publication day to stamp yesterday's rate. The 404
walk-back that follows is defence in depth, not the mechanism. Any non-404
4xx is terminal rather than just 400, since NBP's malformed-date response is
documented but unverified.

ECB (quotes EUR -> X) has no walk-back at all: endPeriod +
lastNObservations=1 makes the API resolve "the last publication on or before
this date" server-side, correct across clusters a walk-back-by-one gets
wrong. includeHistory is deliberately never set - combined with
lastNObservations=1 it injects a phantom ACTION=Delete row with an empty
OBS_VALUE and an unrelated historical TIME_PERIOD. A non-publication day is
a 200 with a ZERO-BYTE body, not a 404, so the body is length-checked before
any parsing; a 404 means the series does not exist; a 400 returns HTML while
404/406 return problem+json, so a 4xx body is never JSON.parse'd. CSV
columns are indexed by header name, never by position. A 10-day observation
lag is asserted as a cheap backstop - the real maximum non-publication run
is 4 days, so it can only fire on a clamp regression.

ECB assigns no document identifier (header.id is a fresh UUID per request,
Last-Modified is not data-dependent), so sourceRef persists an
OpenLinker-constructed re-executable locator, ECB:EXR(1.0):<key>@<period>.
That is stated in the code rather than passed off as an ECB reference.

Both adapters take an injected FetchLike, so every spec fakes HTTP without
touching globalThis and no tier makes a live call. The package is added to
the outbound-http scan roots and the matching ESLint glob; the single
exemption is the FX_FETCH_TOKEN default factory, where ADR-038's
connection-bound transport is structurally unusable because it keys its
cache and rate-limit bucket on connection.id and a reference-rate read has
no connection.

The @openlinker/* edges are declared in package.json, not only in tsconfig
references - pnpm never reads tsconfig, and omitting the manifest edge lands
the package in the same `pnpm -r` chunk as its sibling (#2011).

Refs #2123

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

* chore(hosts): register FxIntegrationModule in the api and worker plugin lists

The binding crosses from the integration package into core at the host, so
nothing in libs/core imports @openlinker/integrations-fx: the module is
added to apiPlugins / workerPlugins, PluginRegistryModule.forRoot re-exports
it, and its onModuleInit populates the core exchange-rate registry.

The worker is the load-bearing registration - order ingestion and the FX
retry / reconcile-sweep handlers all run there. The API is registered too,
matching the dual registration WooCommerce, InPost, Subiekt and AI already
have, so a future API-side restamp endpoint fails at boot rather than at
runtime against an empty registry.

Refs #2123

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

* fix(fx,currency): apply the #2123 review findings

Three IMPORTANT findings and eight suggestions from the /pr-review pass.

NBP errors named a pair the caller never requested. Every raise inside the
fetch path reported the LEG currency rather than the requested pair, so
fetchRate({from:'PLN',to:'EUR'}) failed as 'EUR/EUR' and a 503 on the same
request logged as 'EUR/PLN'. A RateUnsupportedPairError is a terminal
business_failure with no retry, so that log line is the only signal an
operator gets. The requested from/to are now threaded through
fetchQuotesForNearestPublishedDay -> tryFetchQuotesFor -> fetchQuote ->
parseQuote, matching what the ECB adapter already did.

The registry get-or-create had no integration test, which the issue's
acceptance criteria and the plan's section 9 scenario 5 both require - and
the plan states the concurrency claim is not unit-testable. The 23505 ->
DuplicateExchangeRateError -> re-select chain was exercised only against a
jest.fn() told to reject, so the real unique index, the real error code and
what two concurrent callers observe were untested at every tier. Adds
exchange-rate-registry.int-spec.ts covering byte-identical re-read, two
concurrent calls resolving to one row, the domain error crossing the port
boundary, and one row per distinct rate date. It stubs the transport under
the real service, registry, adapter and repository rather than substituting
a fake provider, so no network call is made. Both new tables join the
harness truncate list.

The registry's cost was understated. The pre-fetch read is keyed on the
candidate day while the write is keyed on the published day, so a candidate
that resolves by walk-back is never memoised and every order carrying it
re-fetches - roughly 2 days in 7, not 'one extra call per candidate day'.
The behaviour is correct (no duplicate row, no wrong-dated rate, no loop);
only the claim was wrong. Header and comment now state it, and a spec pins
that a weekend candidate re-fetches while a publication-day candidate does
not. Memoising the candidate-to-published mapping needs its own table and
is left to the persistence phase.

Also: append-only source-text guard now blocks createQueryBuilder( and
manager.; the ECB pivot uses allSettled with terminal-beats-transient
precedence instead of all, whose rejection order was timing-dependent; both
adapters use the exported RateDerivationKind instead of re-declaring the
union; the NBP date formatter is hoisted to module scope; the
MAX_OBSERVATION_LAG_DAYS boundary is pinned at 10 and 11 and its reason
string names the reconcile sweep as the recovery route; NBP_TABLE_A_CURRENCIES
explains why PLN heads a list of table-A rows; and the fake adapter's reset()
restores its constructor seed instead of emptying the map.

Refs #2123

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

* fix(fx): source-map integrations-fx for the integration harness

Re-review caught three small things, one of which made the new int-spec
unrunnable outside CI.

@openlinker/integrations-fx entered both apps' plugin graphs without a
moduleNameMapper pair in apps/api/test/jest-integration.cjs or the worker's,
which check-jest-integration-mappers.mjs exists to catch (#916, #786). The
package's main is ./dist/index.js, so in a fresh un-built worktree the new
exchange-rate-registry int-spec - and every other apps/api and apps/worker
int-spec - failed at module resolution. CI masked it by building dist first.

The gap was not caught earlier because check:invariants is an && chain and
check-repo-urls sits ahead of the mapper guard; its known failure on the
untracked .worktrees directory short-circuited everything after it. Every
check past that point has now been run individually and passes.

Also drops a redundant `| null` from pickLegFailure's return type, which
tripped no-redundant-type-constituents and failed pnpm lint, and a redundant
type assertion on a query() result in the int-spec.

Refs #2123

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

* feat(orders): persist the per-order FX snapshot columns and their stamp-once writes

Adds the six nullable FX columns to `order_records` plus the DDL that #2123
deliberately deferred: this migration creates `exchange_rates` and
`reporting_currency_setting` as well, so the three tables land as one schema
unit.

`reportingCurrency IS NULL` is the canonical "unstamped" test - `exchangeRateId`
is legitimately NULL on the same-currency path, and `fxIntendedCurrency` is a
separate column from `reportingCurrency` because an intent exists on a row that
is still unstamped, which is also why the group CHECK's first arm deliberately
omits `"fxRule" IS NULL`.

Two conditional writes own the columns, both in the `claimWaybillRelay` shape
(`IsNull()` in the WHERE, `affected > 0` as the answer):
`claimFxIntentIfAbsent` pins the currency + rule at the first attempt, and
`stampFxIfAbsent` writes all five stamp columns in one statement so the group
cannot half-apply. `toOrm` maps none of the six - `upsert` is a full-row
`save()` on an update-or-create ingestion path, so mapping them would let a
re-poll write `null` over a reported financial figure; a regression spec asserts
each key is absent from the entity passed to `save()`.

`listDistinctNativeCurrencies` feeds the coverage advisory, reading
`orderSnapshot.totals.currency` through the same `jsonb_typeof`-guarded form the
migration's expression index uses.

The group CHECK is verified by parsing the emitted constraint and evaluating it
against all five legal FX states plus the illegal combinations, because nothing
in CI runs a migration - the Testcontainers schema is built by `synchronize`, so
no int-spec can observe the constraint. The live run/revert/run round-trip
remains a manual gate.

Refs #2124

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

* docs(architecture): document the Currency bounded context

Adds a § 17 Currency section to docs/architecture-overview.md, the
forward reference ADR-040 leaves open, and the `orders -> currency`
edge to the cross-context dependency graph.

The section records the reporting-currency resolution chain, the
code-constant rate-source map, the multiply-never-divide direction
invariant as a property of the stamp rather than of the consumer-neutral
registry, the first-attempt intent snapshot and why provider
availability must not become an input to a financial figure, the
port-in-core / adapters-in-@openlinker/integrations-fx split with
providers deliberately not being capability adapters, the
calendar-neutral rate-date rule, and the five persisted states together
with the two predicates a consumer gets wrong.

It also states positively that the stamp is analytics-only and must
never supply FA(3) `KursWaluty`: an earlier draft of the plan asserted
the opposite, and the stamp differs from a statutory conversion on
date, target and derivation, so leaving the reversal as an absence
would leave the nearest persisted rate as the one a future
implementation reaches for.

Refs #2127

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

* feat(orders,worker): stamp orders in the reporting currency at ingestion

Phase 3 of the order-time FX stamp (#2125, ADR-040).

OrderFxStampService.stamp(internalOrderId) is the one seam every attempt
goes through - the inline call from persistOrder, the marketplace.order.fxStamp
retry job, and the hourly marketplace.order.fxStampSweep reconcile. One
signature for all three: placedAt lives only in orderSnapshot JSONB and an
unparseable value is silently dropped on rehydration, so two signatures would
let the inline and retry paths disagree about whether it exists.

The persisted intent (fxIntendedCurrency + fxRule) is read and pinned before
anything else. A row that already carries one skips the settings service
entirely; otherwise the resolved value is claimed with a conditional write
and a losing concurrent attempt adopts the winner's. Without this an order
degraded to the retry job could stamp a different currency than the same
order stamped inline, making provider availability a silent input to a
financial figure.

Same-currency orders stamp with no rate lookup and no I/O. A converting order
multiplies - ExchangeRate.rate is `to` units per one `from` unit by contract -
and rounds with the house round2 idiom, never pricing-rule.types.ts's
round2dp, which clamps negatives to zero and would turn a refund into a fact.

The service never throws: every failure folds into a stamped/terminal/deferred
outcome, so a rate provider being down cannot fail an ingestion that already
persisted the order. A transient failure enqueues fx:{internalOrderId} in its
own nested try/catch, logged distinctly from the stamp failure, because a
lost enqueue leaves the hourly sweep as the only remaining route to a stamp.

persistOrder collapses its two post-upsert writers - cancellation and the FX
stamp - into one refresh. Each writer now reports whether it wrote rather
than re-reading itself, so the returned record reflects both instead of the
second writer's effect being silently dropped by the first's re-read.

The sweep reads order_records directly on fxStampedAt IS NULL AND
reportingCurrency IS NULL, scheduled hourly per OrderSource-capable
connection - the guarantee that survives a dead retry job, since a job's
idempotency key is globally unique with no TTL and the ~4.3h retry window
means a longer outage would otherwise lose the stamp permanently.

Refs #2125

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

* feat(api,orders): currency-settings API surface

Phase 4 (backend half) of the order-time FX stamp (#2126, ADR-040).

GET /currency-settings and PUT /currency-settings/reporting-currency, both
admin-only, mirroring /ai-provider-settings' route naming and its
withDomainExceptionMapping boundary split: the ISO-shape failure is 400, an
unreachable-but-well-formed code is 422 carrying the accepted set.

The coverage advisory and the stamped-row counts are composed in the
controller, the one layer allowed to combine currency with orders - doing it
inside CurrencyRateService would create a currency -> orders edge and cost
that context its leaf property.

IOrderFxReadService is the narrow cross-context seam: listDistinctNativeCurrencies
(already published) plus the new countStampedByReportingCurrency, grouped by
reporting currency rather than totalled because the era breakdown - not a
bare total - is the operator-facing fact behind "changing this setting splits
history."

Refs #2126

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

* feat(web): Platform/Currency settings tile, env passthrough, guard coverage

Completes Phase 4 (#2126, ADR-040) - the backend controller/DTOs/module and
the orders-side aggregate read landed in an earlier commit; this finishes
the frontend tile, the mandatory write-guard entry, the three .env.example
files and the demo compose passthrough the issue also calls for.

The tile is named Platform / Currency, not Analytics / Reporting currency.
The value is a property of the deployment, not a setting owned by one
module - analytics is merely its first consumer, and invoices compute their
own rate and never read this. An Analytics eyebrow would under-claim and a
title like Instance currency would over-claim, so scope lives in the body
copy instead of the name.

Renders three source states, not the plan's two: EUR (default), PLN (from
env), and a bare PLN once an operator has saved a value. "Nobody has
decided" and "an operator pinned this in configuration" are different
facts and only one of them is a problem - source is already on the
response, so the split costs nothing.

The dialog's coverage-gap checkbox gates the Save button client-side rather
than the backend rejecting an unacknowledged submit, matching ADR-040's
warn-never-block contract: one junk currency in old order history must
never make a legitimate reporting currency permanently unselectable.

CurrencySettingsController is added to write-guard-coverage.spec.ts's
CONTROLLERS - the issue calls this not optional, since a write endpoint
absent from that list ships without guard coverage and nothing fails.
OL_REPORTING_CURRENCY is documented in all three .env.example files (the
worker one matters because it runs the retry job and the sweep) and passed
through docker-compose.demo.yml, defaulting to PLN to match the demo shop's
own currency.

Refs #2126

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

* fix(orders): value-import OrderRecordRepositoryPort in OrderFxReadService

An interface injected via @Inject on a decorated constructor parameter must
be a value import, not import type — emitDecoratorMetadata needs the symbol
resolvable per-file, and a type-only import can erase to a dangling
reference under isolatedModules-style single-file transpilation (ts-jest,
esbuild, swc). Same pattern already established for IIntegrationsService in
invoice.service.ts and applied to OrderFxStampService's own constructor in
an earlier commit on this branch — this was the one file the #2126 branch
had not yet matched to it. Caught by pnpm -r lint's --fix pass.

Refs #2126

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

* fix(docker): add libs/integrations/fx to the Dockerfile's manifest COPY lists

The base and production stages hand-enumerate every @openlinker/* workspace
package for layer-caching COPY, with the Dockerfile's own comment warning
this is exactly the #1365 review class of bug: a package missing from the
list makes pnpm install fail to resolve its workspace:* reference and
breaks the image build. @openlinker/integrations-fx (#2123) was never added
to any of the three lists (package.json x2, dist x1), so the demo/production
image failed to build for the whole epic. Caught while booting the epic
branch for E2E verification.

Refs #2049

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

* fix(demo): match OL_REPORTING_CURRENCY across api and worker services

The final /pr-review pass caught it: only the api service's environment
block set OL_REPORTING_CURRENCY: PLN. Order ingestion, the fxStamp retry
job and the hourly reconcile sweep all run in the WORKER process, and
ReportingCurrencySettingsService.resolve() falls back to this env var
per-process before any settings row exists - so a fresh demo deployment
would have silently stamped orders in EUR (the code-constant default)
until an operator manually visited /currency-settings, contradicting the
compose comment's own stated PLN intent. apps/worker/.env.example already
names this exact hazard class for the api/worker pair generally; this
carries the same reasoning into the demo compose file specifically.

Refs #2049

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

* fix(api): move the FX migration spec out of the TypeORM migrations glob

Live E2E boot caught it immediately: data-source.ts's migrations glob
(migrations/**/*{.ts,.js}) feeds every matched file straight into
migration:run, so the colocated migrations/__tests__/1834000000000-add-
order-fx-stamp.spec.ts was require()'d by the CLI itself and crashed on
its first bare describe() - a jest global that does not exist in that
ts-node process. `migrate` exited 1 on every boot; nothing in CI or the
test harness runs a migration, so this was never exercised before now.

Moved to database/__tests__/, beside data-source.ts (the file that owns
the glob) and outside its reach; jest's repo-wide testRegex picks it up
regardless of location, so no test-discovery change. Fixed the relative
import to the migration class and left a note explaining why this specific
directory, since it is the first migration to ship a colocated unit spec
and the next one will want the same shape without the same landmine.

Refs #2124

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

* docs(mockups): live E2E verification report for the FX stamp epic

Boots the epic branch on a real stack, hand-verifies one live NBP-sourced
conversion (19.99 EUR at 4.342 = 86.80 PLN), and documents the three
deploy-only bugs a live boot found that no review pass could have: the
Dockerfile's manifest COPY lists never learned about
@openlinker/integrations-fx, OL_REPORTING_CURRENCY was set on the demo
compose's api service but not the worker (the process that actually runs
ingestion), and the migration's own unit spec crashed migration:run because
TypeORM's CLI globs and require()s every file under migrations/ directly.

Refs #2049

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

* fix(web/currency-settings): stop showing a bare stamped-orders count on the tile

`Stamped orders: 0` read as an alarm ("0 problems") instead of the coverage
fact it is, and the per-currency grouping only ever produces a real
breakdown when the deployment has changed its reporting currency before —
otherwise it's one bucket, not a breakdown. Move it behind a secondary
"Coverage" action with copy that explains what's being counted and why 0
is normal right after this ships.

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

* fix(orders): repair rebase merge-artifact regressions onto main

Rebasing onto main's sales-document-block work (#2100) silently dropped
CurrencyApiModule from app.module.ts's imports (import statement survived,
array entry didn't), and shifted OrderRecord's constructor arg order so
positional test calls needed 3 extra nulls for the salesDocument fields
that now precede the FX fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* fix(currency,sync): fix CI failures on FX rate snapshot PR

The FX stamp sweep task's OL_ORDER_FX_STAMP_SWEEP_CRON key was missing
from the scheduler spec's cron-key allowlist, so the mocked ConfigService
fell through to 'true' for that key and CronJob rejected it
("Unknown alias: tru"), aborting onApplicationBootstrap and failing
every other registered task's test in the suite.

Separately, buildCoverage always set rateSource from resolveSourceKey
regardless of whether a provider was actually registered, so an
unregistered candidate reported a rateSource instead of null.

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

* feat(orders): persist the per-order FX snapshot columns and their stamp-once writes

Adds the six nullable FX columns to `order_records` plus the DDL that #2123
deliberately deferred: this migration creates `exchange_rates` and
`reporting_currency_setting` as well, so the three tables land as one schema
unit.

`reportingCurrency IS NULL` is the canonical "unstamped" test - `exchangeRateId`
is legitimately NULL on the same-currency path, and `fxIntendedCurrency` is a
separate column from `reportingCurrency` because an intent exists on a row that
is still unstamped, which is also why the group CHECK's first arm deliberately
omits `"fxRule" IS NULL`.

Two conditional writes own the columns, both in the `claimWaybillRelay` shape
(`IsNull()` in the WHERE, `affected > 0` as the answer):
`claimFxIntentIfAbsent` pins the currency + rule at the first attempt, and
`stampFxIfAbsent` writes all five stamp columns in one statement so the group
cannot half-apply. `toOrm` maps none of the six - `upsert` is a full-row
`save()` on an update-or-create ingestion path, so mapping them would let a
re-poll write `null` over a reported financial figure; a regression spec asserts
each key is absent from the entity passed to `save()`.

`listDistinctNativeCurrencies` feeds the coverage advisory, reading
`orderSnapshot.totals.currency` through the same `jsonb_typeof`-guarded form the
migration's expression index uses.

The group CHECK is verified by parsing the emitted constraint and evaluating it
against all five legal FX states plus the illegal combinations, because nothing
in CI runs a migration - the Testcontainers schema is built by `synchronize`, so
no int-spec can observe the constraint. The live run/revert/run round-trip
remains a manual gate.

Refs #2124

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

* docs(architecture): document the Currency bounded context

Adds a § 17 Currency section to docs/architecture-overview.md, the
forward reference ADR-040 leaves open, and the `orders -> currency`
edge to the cross-context dependency graph.

The section records the reporting-currency resolution chain, the
code-constant rate-source map, the multiply-never-divide direction
invariant as a property of the stamp rather than of the consumer-neutral
registry, the first-attempt intent snapshot and why provider
availability must not become an input to a financial figure, the
port-in-core / adapters-in-@openlinker/integrations-fx split with
providers deliberately not being capability adapters, the
calendar-neutral rate-date rule, and the five persisted states together
with the two predicates a consumer gets wrong.

It also states positively that the stamp is analytics-only and must
never supply FA(3) `KursWaluty`: an earlier draft of the plan asserted
the opposite, and the stamp differs from a statutory conversion on
date, target and derivation, so leaving the reversal as an absence
would leave the nearest persisted rate as the one a future
implementation reaches for.

Refs #2127

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

* feat(orders,worker): stamp orders in the reporting currency at ingestion

Phase 3 of the order-time FX stamp (#2125, ADR-040).

OrderFxStampService.stamp(internalOrderId) is the one seam every attempt
goes through - the inline call from persistOrder, the marketplace.order.fxStamp
retry job, and the hourly marketplace.order.fxStampSweep reconcile. One
signature for all three: placedAt lives only in orderSnapshot JSONB and an
unparseable value is silently dropped on rehydration, so two signatures would
let the inline and retry paths disagree about whether it exists.

The persisted intent (fxIntendedCurrency + fxRule) is read and pinned before
anything else. A row that already carries one skips the settings service
entirely; otherwise the resolved value is claimed with a conditional write
and a losing concurrent attempt adopts the winner's. Without this an order
degraded to the retry job could stamp a different currency than the same
order stamped inline, making provider availability a silent input to a
financial figure.

Same-currency orders stamp with no rate lookup and no I/O. A converting order
multiplies - ExchangeRate.rate is `to` units per one `from` unit by contract -
and rounds with the house round2 idiom, never pricing-rule.types.ts's
round2dp, which clamps negatives to zero and would turn a refund into a fact.

The service never throws: every failure folds into a stamped/terminal/deferred
outcome, so a rate provider being down cannot fail an ingestion that already
persisted the order. A transient failure enqueues fx:{internalOrderId} in its
own nested try/catch, logged distinctly from the stamp failure, because a
lost enqueue leaves the hourly sweep as the only remaining route to a stamp.

persistOrder collapses its two post-upsert writers - cancellation and the FX
stamp - into one refresh. Each writer now reports whether it wrote rather
than re-reading itself, so the returned record reflects both instead of the
second writer's effect being silently dropped by the first's re-read.

The sweep reads order_records directly on fxStampedAt IS NULL AND
reportingCurrency IS NULL, scheduled hourly per OrderSource-capable
connection - the guarantee that survives a dead retry job, since a job's
idempotency key is globally unique with no TTL and the ~4.3h retry window
means a longer outage would otherwise lose the stamp permanently.

Refs #2125

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

* feat(api,orders): currency-settings API surface

Phase 4 (backend half) of the order-time FX stamp (#2126, ADR-040).

GET /currency-settings and PUT /currency-settings/reporting-currency, both
admin-only, mirroring /ai-provider-settings' route naming and its
withDomainExceptionMapping boundary split: the ISO-shape failure is 400, an
unreachable-but-well-formed code is 422 carrying the accepted set.

The coverage advisory and the stamped-row counts are composed in the
controller, the one layer allowed to combine currency with orders - doing it
inside CurrencyRateService would create a currency -> orders edge and cost
that context its leaf property.

IOrderFxReadService is the narrow cross-context seam: listDistinctNativeCurrencies
(already published) plus the new countStampedByReportingCurrency, grouped by
reporting currency rather than totalled because the era breakdown - not a
bare total - is the operator-facing fact behind "changing this setting splits
history."

Refs #2126

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

* docs(adr): propose order analytics read-model persistence strategy (#1985)

Records the persistence-strategy decision for #1985's order analytics
substrate: denormalized order_records scalars + a new order_line_items
table, live-queried (no materialized view). Serves as the ADR the
issue's own acceptance criteria requires before implementation starts.

Refs #1985

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* feat(orders): add order analytics read model (#1985)

Makes order data analytically queryable without JSON expansion:

- 4 new denormalized scalar columns on order_records (placedAt, currency,
  taxTreatment, totalAmount), mirroring the existing dispatchByAt/
  fulfillmentState precedent (ADR-039).
- New order_line_items table, one row per order line, written
  transactionally alongside order_records in OrderRecordRepository.
  upsertWithLineItems (delete-then-reinsert, idempotent under re-ingestion).
- OrderRecordService.persistOrder derives both via the new pure
  order-analytics-projection helpers and persists them together.
- Migration adds the schema additively and backfills existing rows
  idempotently.

No new HTTP endpoint — this is the substrate #1987/#1988 will build
aggregate reads on top of. Cancellation exclusion is deliberately left to

Refs #1985

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(orders): resolve migration timestamp collision and merge-broken tests (#1985)

- Re-timestamp the order-analytics migration 1832000000008 -> 1833000000004:
  it collided with #1984's add-order-record-cancelled-at.ts (same prefix)
  and sorted before origin/main's current tail. check-migration-timestamps.mjs
  now passes.
- Fix order-record.entity.spec.ts's makeRecordWithCancelledAt: the merge with
  #1984 inserted 4 new positional constructor params before cancelledAt,
  so the helper was silently passing its argument into placedAt instead.
- Fix order-record.service.spec.ts's markCancelled describe block: persistOrder
  now calls repository.upsertWithLineItems, not repository.upsert; the old
  mocks were never hit.
- Document the order_line_items table + new OrderRecord scalars in
  architecture-overview.md Orders section (ADR-039 reference), matching the
  existing dispatchByAt/fulfillmentState documentation precedent.

Refs #1985

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* feat(analytics-trust): real per-connection earliest-order-date read (#2121)

* feat(analytics-trust): real per-connection earliest-order-date read (#2083)

Replace connectionCreatedAt's coverage-window role with a real
MIN(COALESCE(placedAt, createdAt)) read over order_records, batched once
across all enumerated connections rather than per-connection. Adds
OrderRecordRepositoryPort.findEarliestPlacedAtByConnection and the
IOrderRecordService cross-context seam analytics-trust consumes it
through.

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(orders): document earliest-order-date is unfiltered by recordStatus (#2083)

Tech review of PR #2121 flagged that findEarliestPlacedAtByConnection's
MIN(COALESCE(placedAt, createdAt)) had no stated scope for source_deleted /
awaiting_mapping / failed rows, unlike getFailedSyncValueSummary's explicit
NOT_MAPPING_OR_DELETED gate. Make the (deliberate) inclusion explicit in the
port, service interface, and repository JSDoc, and pin it with a regression
test asserting no andWhere/recordStatus predicate is applied.

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

* fix(analytics-trust): isolate earliest-order-date lookup failures (#2083)

Wraps the batched getEarliestOrderDateByConnection call so a transient
DB error degrades to an empty Map (every connection reports
earliestOrderDate: null) instead of throwing out of the whole
/analytics/trust snapshot - restoring the per-connection isolation
guarantee this service documents about itself (PR #2121 review finding
1). Also adds a Testcontainers integration test for the real
MIN(COALESCE(placedAt, createdAt)) GROUP BY query (finding 2).

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

---------

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

* fix(core/orders): stop the order upsert wiping syncStatus and syncAttempts (#2141)

* fix(core/orders): stop the order upsert wiping syncStatus and syncAttempts

`OrderRecordRepository.toOrm` mapped `syncStatus` and `syncAttempts`
unconditionally while `persistOrder` / `persistIncomingSnapshot` pass `[]` for
both, so every re-ingestion of an order - a poll re-read, a webhook-triggered
sync, a manual re-sync - wrote those empty arrays over what `updateSyncStatus`
had committed out-of-band. Same mechanism as #2101, for the two columns that fix
did not cover; its exclusion comment sat directly below the offending
assignments.

For `syncAttempts` the loss is irreversible: the JSONB array is the store, and
nothing rebuilds it. The worst case is the operator-retry path the column was
built for (#456) - the retry appends a `pending` attempt, enqueues
`marketplace.order.sync`, and the resulting re-ingestion erases both that entry
and the original `failed` one, so the activity timeline renders a bare `synced`
and the failed -> retried -> synced narrative is silently gone.

For `syncStatus` the gap lasts as long as the destination order-create calls
take. In it the retry action 404s (`OrderDestinationNotFoundException`) and
fulfillment tracking skips the order, because neither can resolve a destination
row. It is permanent whenever the writeback never runs at all: no destination
resolves, a previously-synced destination dropped out of the fan-out, or a throw
or process death lands in between.

Exclude both columns from the upsert's write set, exactly as `fulfillmentState`
(#2101) and `cancelledAt` (#1984) already are, leaving `updateSyncStatus` as
their sole writer. Reading the row first and carrying the values forward was the
alternative, but an unlocked save still loses an append that commits between that
read and the write; omitting the columns is race-free.

No migration: both columns are already `NOT NULL DEFAULT '[]'` in Postgres
(`1770000000000-add-order-records-table`, `1793000000000-add-order-record-sync-attempts`,
neither altered since), and TypeORM emits `DEFAULT` for an undefined column
value on Postgres, so an insert that omits them resolves to an empty array. Only
the `syncStatus` ORM decorator was missing the matching `default`, which this
adds - metadata drift, not a schema gap.

`toDomain` now reads `syncStatus` through `?? []`. The update path carries no
RETURNING clause, so the entity `save()` hands back still has the property
unset; `syncAttempts` was already guarded, `fulfillmentState` and `cancelledAt`
are nullable scalars, which is why #2101 never hit this.

Adds unit coverage that neither property reaches `save()` (including when a
domain record carries values) and that the upsert's return reads both as empty,
plus an integration test proving a committed `syncStatus` / `syncAttempts`
survives a second `persistOrder`, that a first-time persist still inserts and
reaches the DB default, and that the operator-retry flow keeps its earlier
`failed` attempt and its retryable destination row across the re-ingestion.

Closes #2140

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

* fix(api,core/orders): guarantee the syncStatus DB default the upsert now relies on

Review follow-up to #2140.

syncStatus was excluded from the upsert's write set, so an INSERT that omits
it emits the literal DEFAULT and the column must supply the empty array
itself. That default is not guaranteed: 1770000000000 wraps its CREATE TABLE
in `if (!table)`, so a database whose order_records was first built by
TypeORM synchronize took the early-out and got the column from the ORM
decorator, which carried no default before #2140. There, DEFAULT resolves to
NULL against a NOT NULL column and breaks all order ingestion.

Adds an idempotent, metadata-only ALTER COLUMN ... SET DEFAULT '[]'.
syncAttempts needs no counterpart: 1793000000000 adds it as an unconditional
ADD COLUMN ... NOT NULL DEFAULT '[]' that cannot have been skipped, and its
decorator has always carried the default.

Also corrects two comments that misstated what is proven where. The
integration harness builds its schema with synchronize, not migrations, so
the first-insert assertion exercises the ORM decorator default - which makes
that decorator load-bearing for the suite rather than cosmetic drift removal,
and means nothing in CI covers the migration-built schema.

Extends the retry int-spec through to synced so the failed -> retried ->
synced timeline of #2140 AC 5 is asserted literally, and consolidates the
three interleaved exclusion-rationale blocks in toOrm into one block at the
top of the method - the interleaving is what let a fresh assignment land in
the gap between two of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013dbAZYEDfwdssQeaPahn1j
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

---------

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(invoicing,orders,web): persist and surface the auto-issue block reason (#2100) (#2129)

* fix(invoicing,web): lock an order to one invoicing connection (#2047)

One sale is one invoice. KSeF, inFakt and Subiekt are alternative routes for
that one document to reach the authority, not complementary steps, but OL
treated them as complementary at three layers.

Auto-issue fan-out: `AutoIssueTriggerService.onOrderTransition` iterated EVERY
active connection with the `Invoicing` capability and enqueued an issuance job
for each, keyed `invoice:{connectionId}:{orderId}` so it could never dedup
across connections. It now resolves EXACTLY ONE connection via the pure
`selectPrimaryInvoicingConnection` over the new operator-set
`config.invoicing.isPrimary` (read with `parseIsPrimaryInvoicing`, mirroring
the `parseTriggerModel` coercion precedent). A lone candidate still issues
regardless of the flag, so a single-connection install is unchanged. With
several candidates and no unambiguous primary it issues NOTHING and logs an
error naming the ambiguity: a missing invoice is fixable by hand, two issued
documents for one sale need a correction of a document that should never have
existed.

Write-path guard: `InvoiceService.issueInvoice` now refuses, before the
idempotency gate and before any row is created, when the order carries a
BLOCKING record on a different connection - `OrderAlreadyInvoicedException`,
mapped to 409 with the issuing connection and blocking invoice id in the body.
Blocking is the new pure entity derivation `blocksIssuanceElsewhere`: it covers
`pending`, `issuing` (lease-independent), `issued`, AND `failed` with any
`failureMode` other than `rejected`. That last arm is the point: `in-doubt`
means the provider MAY have created a document, so issuing elsewhere is the
duplicate this guard exists to prevent - the FE's `canRetryInvoice` has treated
it that way since #1240. Records on the requested connection are untouched, so
per-connection replay/retry semantics are unchanged.

Connection-agnostic read: `connectionId` becomes optional on
`GET /invoicing/orders/:orderId/invoice`. With it, behaviour is byte-identical;
without it the endpoint answers "is this order invoiced anywhere?" via the
existing `getLatestInvoiceForOrder`. Requiring it was the root cause of the FE
defect.

Frontend lock: the panel reads the invoice without a connection (query key is
`forOrder(orderId)`, no longer per-connection) and, once a record exists,
renders the issuing connection as a read-only `InvoiceConnectionLock` instead
of a `Select`. Switching that picker used to read `(order, other connection)`,
get a 404 that the hook maps to null, render "not issued", and offer an Issue
button for an already-invoiced order. The picker survives only for an order
with no record and more than one candidate, where the primary is preselected
and labelled and a missing primary is surfaced as the warning that explains why
auto-issue did nothing. A record whose connection is disabled or deleted still
renders the invoice with actions disabled and no alternative connection offered.
A `failed` + `rejected` record is the one state where moving providers is
fiscally safe, so it sits behind an explicit disclosure that names the
consequence, never as a side effect of Retry.

Closes #2047

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HpwFwSVZYF7nopZ5S3Peet
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* fix(invoicing,web): address review findings on the connection lock (#2047)

Seven follow-ups from the review of the one-invoice-per-order change.

The primary flag gets an editor. Without one, an install with two invoicing
connections and no primary stops auto-invoicing entirely and the panel's
"Set a primary" link pointed at a page carrying no such control - the only
remedy was a hand-written config PATCH. `InvoicingPrimarySection` is
CAPABILITY-gated rather than platform-gated (KSeF / inFakt / Subiekt are
alternative routes for one document, so the rule cannot live inside any one
provider's section), writes NESTED `config.invoicing.isPrimary` through the
same merge seam `subiektTriggerModel` uses, and deletes rather than persists
`false` because the backend reads absence and explicit `false` identically.
The panel's link now deep-links to a real candidate connection.

Pre-existing cross-connection duplicates stay visible. The panel renders only
the latest record, so an order that already carried documents on two providers
- exactly the population this issue exists for - lost the older one from view.
The connection-agnostic GET now reports `otherInvoicingConnectionIds` (omitted
entirely when there is nothing to report, and never computed for a caller that
named a connection), backed by `listInvoiceConnectionIdsForOrder` over the
`findAllByOrderId` read the guard already performs. The panel names them.

The lock warning no longer disappears at the moment it matters. It was gated on
a primary existing, so on an install with none, picking a connection cleared
the "auto-issue is off" warning and rendered no lock warning in its place.

A `manual` primary is now diagnosable. Selection resolves the connection before
the trigger model is read, so a primary on a `manual` connection turns
auto-issue off for the whole install while a sibling `auto-on-paid` connection
is never consulted. That is the operator's call, but it was indistinguishable
from "the trigger never fired"; warned once per connection, PII-clean.

Bulk-issue stops claiming an `invoiceId` it did not produce. The DTO documents
the field as this batch's own record; on a cross-connection block the id
belongs to another connection, so it moves into the neutral `reason`.

Also: the unreachable "selected connection vanished" branch logs instead of
returning silently, in a method whose contract is "never quietly do nothing";
and the failed+rejected row renders the retry-safety hint alongside the
provider-switch button instead of treating them as alternatives, so an
operator with a second connection still learns why Retry is safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* fix(web/invoicing): point "Set a primary" at the edit form, not the detail page (#2047)

Caught by driving the fix on a live stack rather than by unit test: the deep-link
landed on `/connections/:id`, which renders Overview + Enabled roles and carries
no config form at all. The primary toggle lives on `/connections/:id/edit`, so
the link still dead-ended - it just dead-ended one page further along than
`/connections` did.

The panel test now pins the full path, so a future route change fails here rather
than being discovered by an operator hunting for a setting that is one click away
and unlabelled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* test(api,worker/invoicing): update the integration suites to the #2047 lock contract

Three integration expectations still described the pre-#2047 world:

- GET /orders/:orderId/invoice asserted 400 when `connectionId` is absent,
  but #2047 deliberately made the param optional so a caller can ask "is
  this order invoiced ANYWHERE?" before it knows the issuing connection.
  Replaced with coverage of the new branch (newest record across
  connections + otherInvoicingConnectionIds) and its 404.
- findAllByOrderId seeded 'conn-a' / 'conn-b' into `connection_id`, a real
  `uuid` column, so Postgres rejected the insert before the assertion ran.
- The auto-issue "per-connection isolation" case asserted the old fan-out
  across every matching trigger model; several eligible connections now
  resolve to ONE primary, and an unresolved primary issues nothing.
  Rewritten as three cases covering the lock: primary wins, no primary
  issues nothing, manual primary disables the whole install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* fix(invoicing): serialize originating-document issuance per order (#2047)

Addresses the review on PR #2060.

BLOCKING — the one-invoice-per-order guard was read-then-act.
`assertNotInvoicedElsewhere` is a plain `findAllByOrderId` -> `find`, so two
concurrent attempts on DIFFERENT connections for a not-yet-invoiced order both
read `[]`, both passed, and both created a row: the
`(connectionId, idempotencyKey)` unique index cannot collide across
connections, so both then crossed the provider boundary and one sale got two
real fiscal documents — the exact outcome #2047 exists to prevent. The PR body
claimed the guard survived "two tabs racing"; it did not.

`issueInvoice` now holds a per-ORDER `SyncLockPort` lock around guard-through-
create (`invoice:issue:{orderId}`, TTL `OL_INVOICE_ISSUE_LOCK_TTL_MS`), keyed
per order rather than per (order, connection) for the same reason
`shipmentDispatchLockKey` is (#1917): two operators picking different providers
for one order is precisely what a per-connection key would let through. A
contended attempt answers from PERSISTED STATE ONLY, in the order the locked
path would — truthful already-invoiced refusal, then an `issued` same-key row
replayed verbatim, else the new retryable `InvoiceIssueContendedException`
(409) — so it can never be the second document. TTL expiry is not a
correctness cliff: the covered window is two DB round-trips, past which a
`pending` row exists that a peer's own guard sees.

`issueCorrection` is deliberately not locked — a correction is a linked
follow-up of an `issued` original, outside the ADR-041 3b invariant.

Tests: (n) is the regression itself — two different-connection attempts, a
real in-test store behind `findAllByOrderId`, asserting one create + one
provider call + one row. (n2)-(n6) pin each contended branch, release on both
paths, and release-failure not masking the result. (m2) updated: same-key
concurrency now refuses at the outer lock before reaching the CAS, which
remains the defence in the window the lock cannot cover.

Also from the review:
- name the deferred follow-up for the log-only auto-issue block (#2100) in
  `auto-issue-trigger.service.ts`, per ADR-041 54/105
- drop the features -> features `Connection` import in the FE resolver for a
  local structural type, with every returning helper generic over it so the
  panel keeps its concrete type
- document why `assertNotInvoicedElsewhere` logs at `warn` (the guard working,
  and raised to the caller) vs the auto-issue ambiguity's `error` (nothing is
  raised — the install silently stops issuing)
- assert `INVOICE_SERVICE_TOKEN` resolves in the worker DI boot gate, so the
  new `SYNC_LOCK_TOKEN` injection cannot regress unnoticed
- record the invariant + lock in `docs/architecture-overview.md` Invoicing
  and add the real `invoicing -> sync|integrations|identifier-mapping` edges to
  the cross-context dependency map

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>

* feat(invoicing,orders,web): persist and surface the auto-issue block reason (#2100)

When OpenLinker decided not to issue a fiscal document for a qualifying order,
that decision existed only in a log line. ADR-041 §54/§105 state the contrary
twice: a block is never log-only, because "OL silently declined to issue" is as
opaque to an operator as a wrong pick would be dangerous. An install where
auto-invoicing had silently stopped for every order looked completely normal on
/orders and /invoices.

This lands decision 11's first implementing slice.

- New `libs/core/src/sales-documents/` concern (ADR-041 decision 1, "module now,
  context later") holding the two reason unions verbatim, kept separate because
  they answer different questions, with `'unresolved-routing'` as the one bridge
  value. A dependency-free leaf, so any context can value-import it without
  closing a module-load cycle.
- `AutoIssueTriggerService.onOrderTransition` now RETURNS a `SalesDocumentBlock`
  instead of persisting one. That split is load-bearing: persisting in place
  would need an OrdersModule token inside InvoicingModule, closing the runtime DI
  cycle its ONE-WAY EDGE property (F3) exists to prevent. The caller already
  lives in `orders` and owns the write. Every existing log line is kept — the
  reason is additive.
- Three reasons are reachable: the #2047 ambiguity (as `unresolved-routing` +
  `ambiguous-connection-no-primary`), `trigger-model-manual` and
  `trigger-model-batched`. `missing-required-tax-id` and `tax-rate-conflict` ship
  declared but never written, with their prerequisites named in code.
- Persisted on `order_records` in three nullable columns, deliberately omitted
  from `toOrm` (the `cancelledAt` single-writer precedent): `persistOrder` runs
  before the gate on every ingestion, so round-tripping them would null-then-reset
  the value and let a stale read stomp a reason a peer transition just wrote.
- The write is level-triggered, not sticky. `null` is written through as the
  answer "nothing is blocking this any more", which is what clears the badge —
  plus an explicit best-effort clear on both manual-issue paths, because fixing
  the config and issuing by hand fires no transition.
- Operator surface follows #1689's `source_deleted` treatment: a row badge on
  /orders replacing the "Issue invoice" CTA (an order OL already refused is not
  one waiting for a click; manual keeps the CTA because issuing by hand IS its
  configured workflow), a counted filter chip, an undated timeline entry, and the
  order-detail panel reading the persisted reason instead of re-deriving the
  ambiguity client-side.

Two deliberate deviations from a literal reading of the acceptance criteria, both
recorded in the plan and the PR body:

1. The count ships as a non-partitioning `salesDocumentBlocked` field plus a
   filter chip, NOT a sixth `OrderHealth` bucket. `deriveOrderHealth` returns
   exactly one bucket and its SQL twins partition the set, so a sixth value would
   either double-count or hide a sync failure behind an invoicing one — a blocked
   order is usually also `synced`.
2. Blocked orders are NOT excluded from bulk issuance. `POST /invoices/bulk-issue`
   names its connection explicitly, so every reachable reason means "auto-issue
   did not happen", never "this order cannot be invoiced"; excluding them would
   break the primary remediation path for the state this surfacing exists to
   reveal.

The FE mirror of the reason union is enforced by a new
`scripts/check-sales-document-reason-mirror.mjs` under `pnpm check:invariants`,
not by a "keep in sync" comment.

Refs #2100

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

* test(api/orders): add the block-reason mock to the refunds controller spec

`refunds.controller.spec.ts` arrived with #2046 on main and mocks
`IOrderRecordService`, which gained `markSalesDocumentBlock` on this branch. Only
the full `pnpm type-check` catches this class of merge gap — the package-scoped
check had already passed before the catch-up merge.

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

* fix(invoicing,orders,web): make the block invoice-aware and stop it self-contradicting (#2100 review)

Review round 1 found two BLOCKING defects that were the same mistake seen from
two ends, plus 16 IMPORTANT/SUGGESTION items. Every one is addressed here.

BLOCKING 1 — the gate was not idempotent against its own effect. `manual` (and any
reason derived from configuration rather than from the order) stays true after the
document exists, so the gate re-reported it on the next routine transition and the
block landed back on an order the operator had already invoiced by hand. The
aggregate count included invoiced orders, the filtered rows rendered with no badge
(the list suppresses on the invoice projection), and the order-detail timeline
claimed "No invoice issued" directly under the panel showing the invoice.

`AutoIssueTriggerService` now reads the order's own document projection before
reporting any block. `INVOICE_SERVICE_TOKEN` is a SAME-context dependency —
InvoicingModule provides both services — so it forms no module cycle and does not
touch the F3 one-way edge, which is specifically about OrdersModule tokens. The
read happens only on the would-be-blocked paths, so the happy path is unchanged,
and a read failure yields `indeterminate` rather than inventing or erasing.

BLOCKING 2 — the filter chip was count-gated, so it unmounted the moment the
remediation succeeded, stranding `?invoicing=blocked` with no control to clear it
and an empty state that claimed no orders had ever synced. The chip now renders
whenever the filter is active, and the empty state has an arm for this param whose
recovery button clears it.

Three contract changes came out of the round:

- `onOrderTransition` returns a three-armed `SalesDocumentBlockOutcome`
  (`none` / `blocked` / `indeterminate`) instead of `SalesDocumentBlock | null`.
  Collapsing "nothing is blocking" and "could not tell" into one value is what let
  a deterministic compose error erase a legitimate reason and replace it with
  nothing at all — no invoice, no badge, no count, no job row, i.e. the exact
  silent decline ADR-041 §54 forbids. Three of the four errors the trigger
  allow-lists as deterministic reach that path.
- The aggregate counts only `SalesDocumentAttentionReasonValues` — everything
  except `trigger-model-manual`, which is `parseTriggerModel`'s DEFAULT. On a
  manual install every uninvoiced order carries it, so the previous `IS NOT NULL`
  predicate put a red "Invoicing blocked 4,312" on a healthy install. The per-order
  badge still renders manual, neutral. The IN-list also stops counting a stored
  reason this build cannot label, which previously produced a number with no
  reachable explanation.
- `OrderIngestionService` skips the write when the outcome matches what is already
  persisted. The gate is level-evaluated and the common answer is `none` on an
  already-unblocked order; writing it anyway cost a second UPDATE and an
  `updatedAt` bump per ingestion, and `updatedAt` is a live filter axis. The
  comparison uses the pre-persist record already in hand.

Also fixed:

- `POST /invoices/retry` and `issueCorrection` now clear the block (only the single
  and bulk issue paths did).
- The invoice-suppression rule moved into `invoicingBlockedBadge` as a parameter,
  so the list AND the timeline share one rule; the timeline had none, which is what
  produced the contradiction above. The page-local `useCallback` that closed over
  nothing is gone.
- `?salesDocumentBlocked=yes` now 400s instead of silently returning the
  unfiltered list while the chip renders as applied — matching both in-repo
  boolean-query precedents.
- `BLOCK_REASON_BY_TRIGGER_MODEL` links the two vocabularies, so renaming a
  trigger model is a compile error rather than a silently stale reason string.
- The badge table is `satisfies Record<SalesDocumentGateBlockReasonValue, …>`, so a
  new reason is a compile error rather than an unlabelled row.
- `barrel-purity.spec.ts` gained `sales-documents` plus an assertion that the
  concern has no import statements at all — the "dependency-free leaf" property
  three docblocks call load-bearing was previously unenforced.
- `.chip.chip--active` raises specificity so `active` wins over the tone modifier;
  before this a toned filter chip differed only by font-weight between on and off.
- `aria-label` alongside `title` on the badge, matching the "est." marker in the
  same file — the hint was the only statement of why on that surface and was
  unreachable by keyboard.
- `resolveSalesDocumentBlockCopy` moved to `features/invoicing/lib/` with a
  table-driven test covering all seven branches; three were reachable before, only
  through a component render.
- Prose corrected where six docblocks said "two columns" for three.
- Docs: `sales-documents` is now § Core Bounded Contexts 17 with both edges in the
  dependency map (the § Invoicing bullet had promised exactly that "when the code
  lands"), the tokens-file exemption is recorded in engineering-standards, and
  ADR-041's implementation note carries the two lessons for the #1908 router.

New coverage: gate outcome per arm inclu…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[TASK] Infrastructure — one-command Docker demo environment (PrestaShop + full OpenLinker: API/Web/Worker)

3 participants