feat(demo): one-command Docker demo environment (API/Web/Worker + PrestaShop) - #1365
Conversation
…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
left a comment
There was a problem hiding this comment.
/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 demoweb(and inheritedapi) publish to0.0.0.0, while base compose deliberately binds dev services to loopback. With defaultadmin/adminand no redaction, a multi-homed/LAN host exposes it. Bind127.0.0.1:8090:80to match the existing loopback pattern (non-blocking for pure-localhost).Dockerfile:67-72— theworkerstage 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 omitsphpmyadminbut the realdemo:upscript includes it; align the comment.apps/web/nginx.conf—try_files … /index.htmlis 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>
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>
|
Addressed all findings from the IMPORTANT
SUGGESTIONS
All of the above was verified against live containers (not just config review): full clean-volume |
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
left a comment
There was a problem hiding this comment.
/pr-review — delta re-review (56744330 → 543ab3d2, 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
.dockerignorenever shipped → RESOLVED. Now tracked (git ls-treeblobcda5916); excludes.git/,**/node_modules/,**/dist/,**/coverage/,.env+.env.*(with!.env.example), and**/*.tsbuildinfo(the TS6305 catch). The# Dockerexclusion block was deleted from.gitignore.- Hardcoded plugin-COPY drift trap / no CI guard → RESOLVED. New
docker-build-smokejob (ci.yml:326-346) runsdocker build --target baseandapps/web/Dockerfile— genuinely builds (base runspnpm install+pnpm build), so a plugin added without a matching COPY failsworkspace:*and the job. Gated to run on this PR (Dockerfile changed →codefilter true). Source-of-truth comments added to the enumerated lists. - 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 portconfirming loopback-only,nginx -t+per-location header curl,compose configproving!override).
✅ Suggestions confirmed
- Loopback ports:
apiusesports: !override→127.0.0.1:3000:3000(replaces base rather than appends; alsovolumes: !reset []);weboverlay-only127.0.0.1:8090:80;OL_CORS_ORIGIN/VITE_API_BASE_URLline up. - nginx headers:
gzip on;X-Content-Type-Options/X-Frame-Options/Referrer-Policyre-stated per-location (correct — server-leveladd_headerisn't inherited once a location sets its own);/assets/immutable1y 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.
…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>
…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>
…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>
…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>
…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>
…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…
Add a dedicated demo overlay that boots the full OpenLinker stack in Docker with a single command, on top of the existing infra services.
Closes #1352
Summary
pnpm demo:up) that boots API/Worker/Web + PrestaShop on top of the existing infra compose services./pr-reviewfindings from @piotrswierzy (2026-07-06): committed.dockerignore, a CIdocker buildsmoke 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 -vthenpnpm demo:up), verified live:docker build --target base -f Dockerfile .anddocker build -f apps/web/Dockerfile .both succeed from a clean checkout (no host.git/node_modules/dist/*.tsbuildinfoleaking into the build context via the new.dockerignore) — this also caught and fixed a real bug: a hosttsconfig.tsbuildinfocopied into the image madetsc -bfail withTS6305 Output file has not been built from source, since the corresponding hostdist/was (correctly) excluded. Adding**/*.tsbuildinfoto.dockerignorefixed it.pnpm demo:upon an empty Postgres volume:migratecontainer ran every migration from scratch and exited0.openlinker-api,openlinker-worker,openlinker-weball reachedUpstate.POST http://localhost:3000/v1/auth/loginwithadmin/admin→200with an access token.Origin: http://localhost:8090→Access-Control-Allow-Origin: http://localhost:8090.docker port openlinker-api/openlinker-web→ both127.0.0.1:*only (not0.0.0.0), confirming the loopback-bind fix.nginx -tpasses;GET /returnsCache-Control: no-cache+X-Content-Type-Options/X-Frame-Options/Referrer-Policy;GET /assets/*.jsreturnsCache-Control: public, immutable+ the same security headers (nginx doesn't inherit server-leveladd_headerinto a location that sets its own, so they're re-stated perlocationblock — verified by curl, not just config review).docker compose … config(with a dummy encryption key) confirms theports: !overrideonapiresolves to exactly one loopback binding, not two conflicting ones (the naiveports:list under a Compose overlay merges/appends rather than replacing).SyncJobRunnerstarting cleanly with no errors on a fresh DB (no connections configured yet, so no jobs to run — expected for a from-scratch boot).down -v); dev-stackdocker-compose.ymlitself is untouched, sopnpm dev:stack:upis unaffected by this PR.Quality gate
pnpm lintpasses (zero errors) — no TypeScript touched by these fixespnpm type-checkpasses (zero errors) — no TypeScript touched by these fixespnpm testpasses (all unit tests green) — no TypeScript touched by these fixespnpm test:integrationpasses — neededonly if you touched
apps/api/test/integration/**or any plugin'sinfrastructure/adapters/.Migrations
apps/api/src/migrations/(or the plugin package),pnpm --filter @openlinker/api migration:showconfirms it's listed, and bothup()anddown()were tested locally. Seedocs/migrations.md. Tick this box forPRs that don't touch schemas too — it's trivially satisfied.
ADR
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
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:
yet
changes flagged in PR title
See
docs/architecture-overview.mdfor the adapter / capability contract and
GOVERNANCE.mdfor the policy on plugin authorsco-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:
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.