Skip to content

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

Merged
jakubretajczykBD merged 3 commits into
1985-order-analytics-read-modelfrom
2083-analytics-earliest-order-date-plan
Aug 17, 2026
Merged

feat(analytics-trust): real per-connection earliest-order-date read#2121
jakubretajczykBD merged 3 commits into
1985-order-analytics-read-modelfrom
2083-analytics-earliest-order-date-plan

Conversation

@jakubretajczykBD

Copy link
Copy Markdown
Collaborator

Summary

  • Adds OrderRecordRepositoryPort.findEarliestPlacedAtByConnection — one batched GROUP BY query (MIN(COALESCE(placedAt, createdAt)) per connection), not a per-connection fan-out.
  • Adds the IOrderRecordService.getEarliestOrderDateByConnection cross-context seam analytics-trust consumes it through (mirrors the existing getFailedSyncValueSummary pattern).
  • AnalyticsTrustService.getIngestionTrustSnapshot calls it exactly once, batched across every enumerated connection, before the existing per-connection job-lookup fan-out.
  • New ConnectionIngestionTrust.earliestOrderDate field, threaded through GET /analytics/trust's DTO/controller.
  • docs/architecture-overview.md dependency map gains the analytics-trust --> orders edge.

Implements #2083. Full implementation plan: docs/plans/implementation-plan-analytics-trust-earliest-order-date.md.

Base branch note: targets 1985-order-analytics-read-model, not main#2083 is explicitly blocked by #1985 (needs order_records.placedAt, which only exists on that branch today). Rebase onto main once #1985 merges.

Test plan

  • pnpm --filter @openlinker/core type-check — clean
  • pnpm --filter @openlinker/api type-check — clean
  • pnpm --filter @openlinker/worker type-check — clean
  • Targeted unit tests (repository, service, AnalyticsTrustService, controller DTO mapping) — green
  • ESLint on all changed files — 0 errors/warnings
  • check-cross-context-imports, check-service-interfaces, check-workspace-dep-declarations — pass
  • pnpm test:integration — not run in this environment; no int-spec added per plan's rationale (matches getFailedSyncValueSummary/countByHealth, unit-tested only)

Known pre-existing issue, not introduced here: pnpm lint's check:invariants chain currently fails on a migration-timestamp collision already present on 1985-order-analytics-read-model (1833000000004-add-order-analytics-read-model.ts vs. main's 1833000000004-add-identifier-mappings-offer-created-index.ts), confirmed via git stash to exist independent of this PR's changes. Also pre-existing and unrelated: OrdersController › listOrders › should serialize cancelledAt as an ISO string fails on the base branch too.

jakubretajczykBD and others added 2 commits August 14, 2026 15:05
…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>
…us (#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>
@jakubretajczykBD
jakubretajczykBD marked this pull request as ready for review August 14, 2026 13:52

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Tech-lead review — 🔄 Close; one behavioural regression to fix first

Scope assessed: diffed against 1985-order-analytics-read-model — this PR's own base, not main. That isolates 21 files: the findEarliestPlacedAtByConnection port/repo/service chain, its consumption in analytics-trust, the DTO/controller field, specs and a plan doc. The 1985 read-model work itself isn't assessed here.

Two things I went in expecting to find aren't there. This is not a stub replacement — it's purely additive; on the base branch ConnectionIngestionTrust had no earliest-order-date field at all, and connectionCreatedAt was already documented as explicitly not a coverage claim. So no caller depended on an old shape. And the N+1 across connections isn't present — it's actively defended against.

IMPORTANT

1. The batched lookup sits outside the per-connection isolation boundary. analytics-trust.service.ts:88-90

This service's own file header states the contract: "a single connection's build failure is caught and degraded to an 'unknown' entry rather than failing the whole snapshot." The new getEarliestOrderDateByConnection call sits before the Promise.all, unguarded — so a transient DB error on this one supplementary field now throws out of getIngestionTrustSnapshot and 500s the entire /analytics/trust endpoint.

Before this PR, no single data read could do that. That's a real regression in the availability posture of a page whose whole job is to still tell you something when things are broken — and it's the read #2115 gates its page render on, so the blast radius is the analytics page going blank rather than degrading.

The fix matches the design already in the file: wrap it, log, fall back to an empty Map. Every connection then reports earliestOrderDate: null, which the type and DTO already model as unknown. Note the existing comment at :181 reasons carefully about this lookup being independent of the job lookup's failure — it just doesn't consider the lookup itself failing.

2. No integration test for the actual SQL. order-record.repository.spec.ts:190-261 asserts where/groupBy/addSelect were called with the right strings — a change-detector, not a correctness check. MIN(COALESCE("placedAt", "createdAt")) with GROUP BY over a mixed null/non-null placedAt population is exactly what one Testcontainers assertion is for, including that the pg driver hands back a Date for the raw earliest_at alias (the code types it Date at :102 on faith). Confirmed nothing under apps/api/test or apps/worker/test touches earliestOrderDate.

Suggestions

  • Index support is adequate, not ideal. @Index(['sourceConnectionId']) and the separate placedAt index mean the IN (...) filter is index-supported and this isn't a seq scan — correctly, no migration was needed. But neither index covers the aggregate, so Postgres still heap-fetches every matching row to evaluate the MIN(COALESCE(...)). On a connection with hundreds of thousands of orders that's a real cost on a page-load-blocking read. A composite (sourceConnectionId, placedAt) would let it index-scan, though COALESCE defeats a pure index-only min. Not worth a migration today — worth a note if this endpoint ever shows up slow.
  • :88 computes connectionIds even when entries is empty — harmless, since the repository short-circuits [] without querying, which is the right call and is tested.

Worth calling out

  • One aggregate, not N queries — a single GROUP BY hoisted above the Promise.all fan-out, with a spec asserting toHaveBeenCalledTimes(1) and pinning the exact id array, so a future refactor pushing it into buildTrustEntry fails the build. That's the defect being pre-empted rather than shipped and then found in review.
  • Absent data is reported as unknown, never defaulted to healthy — verified end to end: a connection with zero orders is omitted from the Map rather than defaulted, the service resolves ?? null, the type is Date | null, the DTO is nullable: true, and the controller null-guards the toISOString(). Three specs cover present / absent / degraded. The DTO description even warns "do not confuse with connectionCreatedAt" — the exact conflation #2037 was blocked on.
  • The recordStatus non-filter is a documented decision, not an omission — the port JSDoc argues it against getFailedSyncValueSummary's NOT_MAPPING_OR_DELETED gate (coverage fact vs value figure), and a spec asserts andWhere is never called so nobody "helpfully" adds a filter later. Encoding the decision as a test is the right move.
  • Cross-context contract holds: only IOrderRecordService + its token cross the boundary, OrdersModule for imports: only, no repository port leaking. No any; the raw row shape is typed via getRawMany<{…}>.

CI: only Scaffolded Adapter Builds has reported (success) on 71706cf6; everything else is pending with total_count: 0. Nothing red, nothing meaningful run.

Merge readiness: 🔄 Close. Finding 1 is a ~5-line fix to a resilience property this service documents about itself, and I'd want it before merge; 2 is cheap now and expensive later. Everything else is advisory. Targets 1985-order-analytics-read-model, so it lands after that.

)

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>
@jakubretajczykBD

Copy link
Copy Markdown
Collaborator Author

Summary

This is a tightly-scoped, well-executed PR that adds a real MIN(COALESCE(placedAt, createdAt)) earliest-order-date read to the analytics-trust snapshot, replacing the misleading connectionCreatedAt proxy. It follows the getFailedSyncValueSummary port→service→cross-context-consumer precedent almost verbatim, respects the cross-context contract (IOrderRecordService, never the repository port), batches the query correctly (no N+1), and — across its three commits — proactively fixed two review findings itself (documenting the unfiltered recordStatus scope, and isolating the batch-lookup failure so one bad read doesn't kill the whole /analytics/trust snapshot). Test coverage is thorough at every layer including a new Testcontainers int-spec for the aggregate query. No blocking issues found.

Issues

[SUGGESTION]libs/core/src/analytics-trust/analytics-trust.module.ts

Importing the full OrdersModule pulls in its entire provider graph (IntegrationsModule, IdentifierMappingModule, SyncModule, ProductsModule, MappingsModule, OrderSyncService, OrderIngestionService, etc.) just to obtain IOrderRecordService. This is explicitly flagged and accepted in the PR's own implementation plan as a pre-existing NestJS all-or-nothing-module cost, not a new problem — no narrower "orders read-only" sub-module exists today. Not asking for a fix here, just flagging for anyone auditing module-boot cost later.

[SUGGESTION]libs/core/src/orders/infrastructure/persistence/repositories/order-record.repository.ts:87

The raw MIN(COALESCE(rec."placedAt", rec."createdAt")) fragment hardcodes quoted column names rather than using TypeORM's own identifier resolution (as .select/.groupBy do via rec.sourceConnectionId). No injection risk (no interpolated user input) and it matches the existing getFailedSyncValueSummary raw-aggregate style in the same file, so this is consistent with house style — just noting it as the one place a future column rename could silently drift from the entity's @Column mapping without a compile error.

[SUGGESTION]apps/api/src/orders/http/refunds.controller.spec.ts / shipping/*.spec.ts

The new getEarliestOrderDateByConnection: jest.fn() mock entries were inserted at slightly different positions across the five updated spec files relative to their sibling mock properties. Purely cosmetic — TS/jest don't care about property order — but worth a quick pass if these files get touched again.

None of the above block merge.

Verdict

Approve — architecture-compliant (cross-context seam via IOrderRecordService, batched query, no repository-port leakage), correctly threaded through domain type → DTO → controller, comprehensive unit + integration test coverage, and the PR history shows the two most important correctness concerns (unfiltered recordStatus semantics, batch-failure isolation) were already caught and fixed pre-review.


🤖 Generated with Claude Code tech-review

@jakubretajczykBD
jakubretajczykBD merged commit 2cb9852 into 1985-order-analytics-read-model Aug 17, 2026
1 check passed
@jakubretajczykBD
jakubretajczykBD deleted the 2083-analytics-earliest-order-date-plan branch August 17, 2026 09:28
jakubretajczykBD added a commit that referenced this pull request Aug 18, 2026
…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>
jakubretajczykBD added a commit that referenced this pull request Aug 18, 2026
…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>
jakubretajczykBD added a commit that referenced this pull request Aug 18, 2026
…shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
piotrswierzy pushed a commit that referenced this pull request Aug 20, 2026
…st header (#2115)

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner

Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md:
- New /analytics route (PageLayout, Operations nav item)
- Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) +
  From/To fields with a draft-buffered Apply action (Decision 1)
- Trust header (per-connection freshness + "Connected since" + status,
  Decision 3 — real "data from" coverage deferred to #2083/#1985) with a
  click-triggered info popover (touch-safe, unlike a hover-only Tooltip)
- Degradation banner on stalled/disconnected connections — status-only
  for v1 (Decision 4); the mockup's range-gated "sold in this selected
  range" refinement is deferred until #1990 makes that fact honest rather
  than an approximation
- Fresh-instance / still-arriving / loading / error states
- New analyticsTrust API-client namespace consuming the already-shipped
  GET /analytics/trust (#1982)

Post-review fixes (tech-review pass):
- Order-date disclaimer is now a static span (chip+dagger, matches the
  design mockup verbatim) instead of the interactive Chip primitive,
  which rendered a toggle button with no effect
- Banner timestamp uses the shared formatDateTime helper instead of a
  hand-rolled toLocaleString(), matching AnalyticsTrustHeader
- Added a page-level loading-state test

Ref #1986

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

* fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup

- Namespace .gap-mark/.info-popover-trigger to .analytics-* and document
  .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row
  Heights, per the tech-lead review's documentation-obligation findings.
- Drop code-comment claims of verbatim conformance to
  docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018).
- Replace inline style on analytics-trust-header.tsx with a real CSS class;
  drop the phantom trailing grid column.
- Remove the dead toUtcRangeInstants export (no consumer yet).

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

* fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy

- Fix two failing tests: the 90d preset test asserted an off-by-one date
  (implementation was correct), and the disclaimer-chip test failed to
  match the tooltip-split text node. Also fixes a third, previously
  undetected failure in the degradation-banner "renders nothing" test,
  which asserted an empty DOM even though renderWithProviders always
  mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
  operator-facing aria-label/tooltip copy; move the rationale into a
  code comment and replace the bare `title` with a keyboard-reachable
  Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
  "has not ingested since" copy, both of which asserted data currency
  from `lastPollAt` — a pipe-liveness signal, not proof any order data
  arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
  date-range.lib.ts (the file only does local-time formatting).

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

* fix(web,analytics): render the real earliestOrderDate now that #2083 shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

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

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
jakubretajczykBD added a commit that referenced this pull request Aug 24, 2026
* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner

Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md:
- New /analytics route (PageLayout, Operations nav item)
- Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) +
  From/To fields with a draft-buffered Apply action (Decision 1)
- Trust header (per-connection freshness + "Connected since" + status,
  Decision 3 — real "data from" coverage deferred to #2083/#1985) with a
  click-triggered info popover (touch-safe, unlike a hover-only Tooltip)
- Degradation banner on stalled/disconnected connections — status-only
  for v1 (Decision 4); the mockup's range-gated "sold in this selected
  range" refinement is deferred until #1990 makes that fact honest rather
  than an approximation
- Fresh-instance / still-arriving / loading / error states
- New analyticsTrust API-client namespace consuming the already-shipped
  GET /analytics/trust (#1982)

Post-review fixes (tech-review pass):
- Order-date disclaimer is now a static span (chip+dagger, matches the
  design mockup verbatim) instead of the interactive Chip primitive,
  which rendered a toggle button with no effect
- Banner timestamp uses the shared formatDateTime helper instead of a
  hand-rolled toLocaleString(), matching AnalyticsTrustHeader
- Added a page-level loading-state test

Ref #1986

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

* fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup

- Namespace .gap-mark/.info-popover-trigger to .analytics-* and document
  .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row
  Heights, per the tech-lead review's documentation-obligation findings.
- Drop code-comment claims of verbatim conformance to
  docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018).
- Replace inline style on analytics-trust-header.tsx with a real CSS class;
  drop the phantom trailing grid column.
- Remove the dead toUtcRangeInstants export (no consumer yet).

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

* fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy

- Fix two failing tests: the 90d preset test asserted an off-by-one date
  (implementation was correct), and the disclaimer-chip test failed to
  match the tooltip-split text node. Also fixes a third, previously
  undetected failure in the degradation-banner "renders nothing" test,
  which asserted an empty DOM even though renderWithProviders always
  mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
  operator-facing aria-label/tooltip copy; move the rationale into a
  code comment and replace the bare `title` with a keyboard-reachable
  Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
  "has not ingested since" copy, both of which asserted data currency
  from `lastPollAt` — a pipe-liveness signal, not proof any order data
  arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
  date-range.lib.ts (the file only does local-time formatting).

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

* fix(web,analytics): render the real earliestOrderDate now that #2083 shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

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

* feat(web,analytics): sales KPI strip + by-channel table (#1990)

Adds GET /analytics/sales client, view-model helpers, and the two FE
sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order
value w/ median, Units, Cancellations, Returns & refunds) and a
by-channel DataTable, mounted into the #1986 route shell.

Currency-aware per #1987/#2049/ADR-040: every money figure carries its
currency (headline.reportingCurrency), and a channel's revenueBasis
('reporting' | 'native' | 'unavailable') drives whether its revenue/
share render as plain values, a same-currency-but-incomparable caveat,
or an explicit empty value — never a blended or falsely-comparable
number. taxTreatment 'mixed' surfaces an inline chip so gross/net
incomparability is stated, not implied. A channel whose earliest order
postdates the range start renders a "Partial history" flag.

Fixes an exclusive-end date bug found in a prior implementation
attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but
the endpoint treats `to` as exclusive — toExclusiveEndInstant converts
it so the selected range's last day isn't silently dropped.

Also fixes a pre-existing test race in orders-list-page.test.tsx: a
synchronous assertion on empty-state text that depends on an async
query, following an await on a chip that mounts synchronously from a
URL param — now awaited with findByText.

Closes #1990

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

* fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests

CI's `tsc -b` (project-references build) caught what a plain `tsc
--noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new
Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which
doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>`
return type. Pin the generic on the never-resolving Promise, matching
the existing analytics-trust test precedent.

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

* fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract

The frontend types were drafted ahead of the backend and assumed a shape
it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency
per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring
(reportingTotalAmount stamp + unconvertedCurrency labelling) has been
merged in, rewrite the frontend to match it exactly: one nullable
system-wide currency, unconvertedCount/Value/Currency per channel, and
revenueShare always a number.

- Cancellations KPI now leads with the rate (%), value/count as qualifiers.
- By-channel table: a channel with no FX-stamped revenue yet falls back to
  its own unconverted-currency evidence instead of showing an empty cell,
  flagged with an "Awaiting FX stamp" chip.
- Total rows: one reporting-currency total (real KPI aggregate) plus one
  informational unconverted-currency subtotal per distinct native currency
  — only emitted when more than one channel contributes, so a lone
  channel never gets a redundant duplicate total.
- Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now
  count every placed order (stamped + unconverted), not just the stamped
  subset.
- Share and Trend columns reordered so Share sits immediately before Trend
  (previously Share was misplaced next to Revenue).
- Fixed a CSS specificity bug where the Phase-6 dashboard-triage
  `.status-strip` rule silently won over `.status-strip--analytics` at
  >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2.

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

* docs(analytics): implementation plan for /analytics needs-attention section

Plans issue #1989 — three actionable categories (coverage gaps, stock at
risk, failed-sync value) consuming the already-shipped GET
/analytics/needs-attention (#1983), mounted into the #1986 shell. No
backend changes; resolves link targets, the mixedCurrency interim
(tracked against #2049), and the ambiguous multi-connection copy case.

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

* feat(web,analytics): /analytics needs-attention section (#1989)

Renders the three needs-attention categories — coverage gaps, stock at
risk, value stuck in failed syncs — mounted into the #1986 shell.
Consumes the already-shipped GET /analytics/needs-attention (#1983)
as-is; no backend changes.

Either the open rows render or a single all-clear line does, never
both, per the design mockup's rule. Each open row deep-links into the
flow that resolves it: the unified publish wizard, the product detail
page, or the orders list filtered to the needs_attention health
bucket. Ambiguous multi-connection cases fall back to a
connection-agnostic headline; the failed-sync total renders
currency-neutral since the DTO carries no currency field in either
the mixed or non-mixed case (interim pending #2049).

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

* fix(web,analytics): match needs-attention section to the #2003 mockup

The plan (implementation-plan-analytics-needs-attention.md) required a
client-side "checked HH:MM" timestamp in the panel header and a
neutral-tone Clear badge, mirroring frame 02 of the design mockup
(docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018
branch). Both were dropped in the original implementation.

Adds the checked-at timestamp (TimeDisplay driven by the query's own
dataUpdatedAt, since the DTO carries no such field) and switches the
all-clear badge from success to neutral, per spec.

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

* fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture

Rebase fallout from the earliestOrderDate swap (#2083): the
#1989-cherry-picked "keep the trust header rendered when needs-attention
fails" test fixture predates that field and failed type-check.

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

* fix(analytics): address #2120 tech review — sample-vs-total headline defect

- BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a
  connection when the preview sample IS the total (items.length ===
  totalCount); otherwise fall through to the connection-agnostic
  headline, so a headline never asserts something only a 20-item
  sample verified.
- "Publish now" sub now discloses when it only seeds the sampled
  variants ("showing the first N of M").
- Fix the "1 variant have a listing gap" grammar bug to a verb-free
  form, updating the test that had locked it in.
- Thread a BCP 47 locale into deriveFailedSyncHeadline instead of
  hardcoding toLocaleString('en-US').
- Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds
  from the same item list instead of two independently sliced arrays.
- Render AnalyticsNeedsAttention regardless of order-ingestion status —
  coverage gaps and stock-at-risk are listing facts, not order facts.
- Render .attention-list as <ul>/<li> for list semantics.

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

* fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide

- toExclusiveEndInstant now anchors on UTC midnight instead of local
  midnight, matching the controller's UTC-parsed `from` (was silently
  dropping/adding hours off UTC).
- Sparkline aria-labels derive from the actual selected range instead
  of a hardcoded "last 7 days".
- Register the analytics KPI card's 152px/3-col geometry as a
  documented carve-out in the style guide (Density table + parity
  matrix), per the "never introduce an undocumented row height" rule.
- Fix "Data order" planned-tag typo -> "Planned"; section-infotip
  font-size to the rem token equivalent.

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

* fix(web): remove unused vi import breaking tsc build

CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi`
import in sales-analytics.api.test.ts, blocking `pnpm build`.

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

* fix(analytics): emit currency total for single-contributing-channel groups

groupChannelTotalsByCurrency skipped a currency's Total row whenever
only one channel contributed to it, so a deployment with exactly one
connection per currency (e.g. one EUR shop) silently lost that row.
No spec basis for the threshold — the by-channel currency total
should render for every distinct currency present, regardless of how
many channels contribute.

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

* fix(analytics): address #2120 re-review — deep-link connection resolution

Reuses deriveCoverageHeadline's own connection resolution for the coverage
deep link's connectionId param instead of re-deriving it with a weaker
predicate, so the bulk-wizard link can never name a channel the headline
declined to name. Also derives the all-clear checkedCount from the
evaluated categories rather than a hardcoded literal.

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

* fix(web): remove unused vi import in sales-analytics.api.test.ts

Pre-existing lint error surfaced while validating the 1986 merge —
vi was imported but never used in this file.

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

* fix(analytics): stop rendering a currency-neutral total on the failed-sync row

`deriveFailedSyncHeadline` formatted `totalValue` with no currency
symbol ("6,120.64 of orders never reached a destination"), which reads
as a real monetary figure to an operator even though the DTO carries
no currency at all — the same misrepresentation risk the mixedCurrency
branch already guarded against, just less obviously so. Both branches
now render the same count-only shape; `totalValue` stays on the wire
for future consumers, this headline just stops reading it.

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

* fix(analytics): stop rendering a duplicate/colliding unconverted Total row

groupChannelTotalsByCurrency emitted a `Total · {currency} (unconverted)`
row per distinct unconvertedCurrency found across channels, with no
regard for whether that currency string collided with the real
reporting-currency Total row's label — a domestic-currency channel
simply awaiting its first FX-stamp pass produced a second, same-labelled
"Total · PLN" row computed from unrelated fields, reading as a
contradiction rather than two distinct facts.

Drop that row entirely. countUnconvertedOrders reports the
currency-agnostic total count as a single footnote sentence under the
table instead ("N orders not yet converted to the reporting currency —
excluded from the figures above"), never a competing Total row and
never a bare currency-neutral amount.

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

* fix(analytics): stop the coverage deep-link from naming a channel the headline declined to

deriveCoverageHeadline's connection-naming rule requires items.length ===
totalCount, every item missing from exactly one connection, and one
distinct id. The "Publish now" deep link recomputed its own, weaker
predicate (only the last condition), so a partial-but-uniform sample
could pin a connectionId into the wizard link while the headline right
next to it correctly fell back to the connection-agnostic copy —
sending the operator into a wizard pre-scoped to a channel the row
never actually asserted (#2120 re-review, IMPORTANT).

deriveCoverageHeadline now returns connectionId (string | null)
alongside the copy, computed by the same predicate; the component reads
it instead of re-deriving one. Added a regression test pinning the
exact partial-sample/uniform-connection case from the review.

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

* feat(web,analytics): top products table with per-channel breakdown (#1991) (#2191)

* feat(web,analytics): top products table with per-channel breakdown (#1991)

Adds the /analytics top-products table: one row per product, per-channel
units split, revenue/units sort toggle, and a Publish affordance for
channels the product isn't listed on. Fixes a labeling gap found while
manually testing against seeded data: a channel absent from the sales
breakdown was always rendered "Not listed", even when the product was
genuinely listed there and simply had no sale in the selected date range —
now only a channel actually missing from `missingFromConnectionIds` gets
the "Not listed" + Publish treatment; a listed-but-quiet channel renders
the same real, full-weight `0` a channel with sales would.

Closes #1991

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

* fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)

getTopProductRanking/getProductChannelBreakdown summed every stamped order's
reportingTotalAmount regardless of which reporting-currency era it was
pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick.
Since a settings change is forward-only (older orders keep their original
stamp), switching the reporting currency mixed two real currencies into one
number under a wrong label instead of surfacing the older era as unconverted
evidence like an unstamped order.

OrderRecordService now resolves the current reporting currency and both
queries filter revenue to reportingCurrency = current, folding any other
era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped
orders.

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

* Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)"

This reverts commit 5a39290.

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

* fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991)

A product whose only orders in range were stamped under a PREVIOUS
reporting-currency setting (or never stamped at all) rendered a bare
"No FX-stamped order" empty value, even though the backend already exposed
the native-currency figure as unconvertedRevenue/unconvertedCurrency
(#1988).

The Revenue column now falls back to that evidence when there is no
current-era stamp, marked informational via a title tooltip — mirroring
ChannelSalesTable's identical fallback for the #1987 by-channel read.

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

* fix(web): remove unused vi import breaking tsc build

CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi`
import in sales-analytics.api.test.ts, blocking `pnpm build`.

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

* Revert "fix(web): remove unused vi import breaking tsc build"

This reverts commit b915a03.

* fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug

- Units column now reads row.units (server-ranked figure) instead of
  re-summing row.channels[], which could silently disagree with the
  sort order the header arrow claims.
- The Publish action is gated on listings:write via useWriteAccess +
  ReadOnlyLock: hidden for an unauthorized non-demo session, rendered
  disabled with the read-only tooltip for a demo viewer.
- Swapped the Chip (aria-pressed toggle) for a real Link styled as a
  button, so the one-shot publish navigation carries link semantics
  (middle-click, open-in-new-tab) instead of misrepresenting itself as
  a permanently-unpressed toggle to assistive tech.
- @media (hover: none) now stacks the "Not listed" label and the
  Publish action, both visible, instead of hiding the label on touch —
  the #1991 AC's label-vs-action distinction was desktop-only before.
- Added the analytics feature slug to both no-restricted-imports
  pattern groups in .eslintrc.js.

Closes review findings on #2191

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

* fix(orders,analytics): SQL precedence bug + surface coverageGapAvailable/unresolvedProductCount (#2172/#2191 review)

Root cause of the failing top-products-ranking int-spec: `unconvertedOrZeroTotal`
was a bare, unparenthesized `X OR Y` string spliced into
`${unconvertedOrZeroTotal} AND rec."currency" IS NULL`. SQL's AND-before-OR
precedence turned that into `X OR (Y AND Z)` instead of the intended
`(X OR Y) AND Z` — since X (reportingCurrency mismatch) was true for nearly
every unstamped row, the guard fired unconditionally and `unconverted_currency`
fell to NULL far more often than the data warranted. Fixed by parenthesizing
the constant at its definition (both getTopProductRanking and
getProductChannelBreakdown); pinned by the existing int-spec against real
Postgres (a mocked unit spec cannot observe operator precedence) and recorded
in docs/lessons.md.

Also addresses the two still-open review IMPORTANT findings on the FE table:
- `coverageGapAvailable: false` now suppresses "Not listed"/Publish on every
  channel cell (the enrichment failure makes missingFromConnectionIds
  unreliable for the whole response), rendering the real 0 instead, with a
  footnote explaining the check is unavailable.
- `unresolvedProductCount > 0` is now disclosed via a footnote rather than
  silently absorbed.

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

* feat(analytics): period-over-period delta on the sales KPI strip

Adds a "vs previous period" delta to Orders, Order value, Units and
Cancellation rate on the /analytics KPI strip — a second GET
/analytics/sales call over the immediately-preceding period of the
same length, refused outright (GapMark) unless the entire previous
window is covered by ingested order history (per-connection
earliest-order-date, #2083).

Matches the design mockup's delta anatomy (docs/plans/mockups/
analytics-ledger-2003.html): an aria-hidden ↑/↓/→ glyph, a sr-only
spoken sentence, and count/amount deltas rendered as a relative "%"
while rate deltas (cancellation rate) render as an absolute "pp" —
a rate moves in points, not percent.

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

* fix(web,analytics): mount top-products table and reveal Publish on hover

ProductSalesTable (#1991) was fully built end-to-end but never mounted
on AnalyticsPage, so the top-products section never rendered. Also add
the .cell-not-listed hover/focus CSS the component's own doc comment
already described but that was never written — the "Not listed" label
now swaps for a Publish action on hover/focus (with a light
warning-yellow glow), staying permanently visible on touch pointers.

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

---------

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

* fix(web,analytics): make GapMark's caveat reachable without a mouse

A native `title` on a non-interactive, non-focusable `<span>` whose
entire content is a dagger glyph never surfaces for a keyboard user,
and a screen reader has no accessible name to announce beyond "dagger".
Add role="img" + aria-label={title} alongside the existing title so the
caveat is announced regardless of input modality (#2120 review).

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

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Signed-off-by: jakubret
Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
jakubretajczykBD added a commit that referenced this pull request Aug 24, 2026
…, page (#2098)

* docs(analytics): implementation plan for #1986 route shell

Plan for the /analytics route shell (date-range control, trust header),
branched off the current #1985 order-analytics-read-model state per the
user's request, since two of its decisions (coverage-window row,
degradation-banner rule) explicitly track #1985 and its follow-up #2083.

Ref #1986

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

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header (#2115)

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner

Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md:
- New /analytics route (PageLayout, Operations nav item)
- Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) +
  From/To fields with a draft-buffered Apply action (Decision 1)
- Trust header (per-connection freshness + "Connected since" + status,
  Decision 3 — real "data from" coverage deferred to #2083/#1985) with a
  click-triggered info popover (touch-safe, unlike a hover-only Tooltip)
- Degradation banner on stalled/disconnected connections — status-only
  for v1 (Decision 4); the mockup's range-gated "sold in this selected
  range" refinement is deferred until #1990 makes that fact honest rather
  than an approximation
- Fresh-instance / still-arriving / loading / error states
- New analyticsTrust API-client namespace consuming the already-shipped
  GET /analytics/trust (#1982)

Post-review fixes (tech-review pass):
- Order-date disclaimer is now a static span (chip+dagger, matches the
  design mockup verbatim) instead of the interactive Chip primitive,
  which rendered a toggle button with no effect
- Banner timestamp uses the shared formatDateTime helper instead of a
  hand-rolled toLocaleString(), matching AnalyticsTrustHeader
- Added a page-level loading-state test

Ref #1986

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

* fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup

- Namespace .gap-mark/.info-popover-trigger to .analytics-* and document
  .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row
  Heights, per the tech-lead review's documentation-obligation findings.
- Drop code-comment claims of verbatim conformance to
  docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018).
- Replace inline style on analytics-trust-header.tsx with a real CSS class;
  drop the phantom trailing grid column.
- Remove the dead toUtcRangeInstants export (no consumer yet).

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

* fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy

- Fix two failing tests: the 90d preset test asserted an off-by-one date
  (implementation was correct), and the disclaimer-chip test failed to
  match the tooltip-split text node. Also fixes a third, previously
  undetected failure in the degradation-banner "renders nothing" test,
  which asserted an empty DOM even though renderWithProviders always
  mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
  operator-facing aria-label/tooltip copy; move the rationale into a
  code comment and replace the bare `title` with a keyboard-reachable
  Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
  "has not ingested since" copy, both of which asserted data currency
  from `lastPollAt` — a pipe-liveness signal, not proof any order data
  arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
  date-range.lib.ts (the file only does local-time formatting).

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

* fix(web,analytics): render the real earliestOrderDate now that #2083 shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

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

---------

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

* fix(web): bump the lazy-route contract count to 52 for /settings/mcp-tokens

An earlier merge (feat(mcp): Resource-Server auth via user-issued Personal
Access Tokens, #1486/#1912) added the /settings/mcp-tokens page as a lazy
route, but the parameterized route-lazy contract test's expected count
was never bumped, failing CI on this branch with "expected 52 to be 51".

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

* fix(analytics): address #2098 tech review + trust-header single-line layout

- Sync docs/plans/implementation-plan-analytics-page-shell.md with the
  now-resolved Decision 3 (real earliestOrderDate coverage row) and
  Decision 4 (hasSalesInRange dropped), and note the Reusable
  Components divergences.
- Replace the analytics-date-range-toolbar's Tooltip-based "Order
  date" caveat with a Popover on a real <button>, matching
  AnalyticsTrustHeader's pattern — Radix Tooltip ignores
  pointerType === 'touch', making the old trigger unreachable on
  mobile.
- Trust header renders a single-line "data from X · synced Y" fact
  string with a per-channel colored dot, replacing the prior two-column
  label/value layout; adds TimeDisplay's 'time' format and
  formatAbsoluteTime helper it depends on.

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

* fix(analytics): drop dangling "data from" prefix on the no-orders-yet fact

The "data from" prefix was rendered unconditionally, so a connection
with no earliestOrderDate read "data from no orders yet" instead of
just "no orders yet".

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

* fix(analytics): address remaining #2098 review findings

- date-range.lib.ts: add toUtcRangeInstants — the single conversion
  point future /analytics/* consumers must use to turn this toolbar's
  local-day, inclusive from/to into the backend's UTC, to-exclusive
  range contract (SalesAnalyticsQueryDto.to). Fixes the inclusive/
  exclusive and local/UTC mismatches flagged in the /pr-review pass,
  pinned with tests.
- ingestion-trust.lib.ts: rename shouldShowDegradationBanner to
  selectDegradedConnections (it returns the degraded subset, not a
  boolean) and type DEGRADED_STATUSES as Set<ConnectionIngestionStatus>
  with a comment on why 'unknown' is deliberately excluded.
- analytics-page.tsx: adopt PageLayout instead of hand-rolled
  page-header markup; document the frozen `today` ref decision.
- Sync the implementation plan doc with all of the above.

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

* feat(web,analytics): /analytics needs-attention section (#2120)

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner

Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md:
- New /analytics route (PageLayout, Operations nav item)
- Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) +
  From/To fields with a draft-buffered Apply action (Decision 1)
- Trust header (per-connection freshness + "Connected since" + status,
  Decision 3 — real "data from" coverage deferred to #2083/#1985) with a
  click-triggered info popover (touch-safe, unlike a hover-only Tooltip)
- Degradation banner on stalled/disconnected connections — status-only
  for v1 (Decision 4); the mockup's range-gated "sold in this selected
  range" refinement is deferred until #1990 makes that fact honest rather
  than an approximation
- Fresh-instance / still-arriving / loading / error states
- New analyticsTrust API-client namespace consuming the already-shipped
  GET /analytics/trust (#1982)

Post-review fixes (tech-review pass):
- Order-date disclaimer is now a static span (chip+dagger, matches the
  design mockup verbatim) instead of the interactive Chip primitive,
  which rendered a toggle button with no effect
- Banner timestamp uses the shared formatDateTime helper instead of a
  hand-rolled toLocaleString(), matching AnalyticsTrustHeader
- Added a page-level loading-state test

Ref #1986

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

* fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup

- Namespace .gap-mark/.info-popover-trigger to .analytics-* and document
  .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row
  Heights, per the tech-lead review's documentation-obligation findings.
- Drop code-comment claims of verbatim conformance to
  docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018).
- Replace inline style on analytics-trust-header.tsx with a real CSS class;
  drop the phantom trailing grid column.
- Remove the dead toUtcRangeInstants export (no consumer yet).

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

* fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy

- Fix two failing tests: the 90d preset test asserted an off-by-one date
  (implementation was correct), and the disclaimer-chip test failed to
  match the tooltip-split text node. Also fixes a third, previously
  undetected failure in the degradation-banner "renders nothing" test,
  which asserted an empty DOM even though renderWithProviders always
  mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
  operator-facing aria-label/tooltip copy; move the rationale into a
  code comment and replace the bare `title` with a keyboard-reachable
  Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
  "has not ingested since" copy, both of which asserted data currency
  from `lastPollAt` — a pipe-liveness signal, not proof any order data
  arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
  date-range.lib.ts (the file only does local-time formatting).

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

* fix(web,analytics): render the real earliestOrderDate now that #2083 shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

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

* feat(web,analytics): sales KPI strip + by-channel table (#1990)

Adds GET /analytics/sales client, view-model helpers, and the two FE
sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order
value w/ median, Units, Cancellations, Returns & refunds) and a
by-channel DataTable, mounted into the #1986 route shell.

Currency-aware per #1987/#2049/ADR-040: every money figure carries its
currency (headline.reportingCurrency), and a channel's revenueBasis
('reporting' | 'native' | 'unavailable') drives whether its revenue/
share render as plain values, a same-currency-but-incomparable caveat,
or an explicit empty value — never a blended or falsely-comparable
number. taxTreatment 'mixed' surfaces an inline chip so gross/net
incomparability is stated, not implied. A channel whose earliest order
postdates the range start renders a "Partial history" flag.

Fixes an exclusive-end date bug found in a prior implementation
attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but
the endpoint treats `to` as exclusive — toExclusiveEndInstant converts
it so the selected range's last day isn't silently dropped.

Also fixes a pre-existing test race in orders-list-page.test.tsx: a
synchronous assertion on empty-state text that depends on an async
query, following an await on a chip that mounts synchronously from a
URL param — now awaited with findByText.

Closes #1990

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

* fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests

CI's `tsc -b` (project-references build) caught what a plain `tsc
--noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new
Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which
doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>`
return type. Pin the generic on the never-resolving Promise, matching
the existing analytics-trust test precedent.

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

* fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract

The frontend types were drafted ahead of the backend and assumed a shape
it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency
per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring
(reportingTotalAmount stamp + unconvertedCurrency labelling) has been
merged in, rewrite the frontend to match it exactly: one nullable
system-wide currency, unconvertedCount/Value/Currency per channel, and
revenueShare always a number.

- Cancellations KPI now leads with the rate (%), value/count as qualifiers.
- By-channel table: a channel with no FX-stamped revenue yet falls back to
  its own unconverted-currency evidence instead of showing an empty cell,
  flagged with an "Awaiting FX stamp" chip.
- Total rows: one reporting-currency total (real KPI aggregate) plus one
  informational unconverted-currency subtotal per distinct native currency
  — only emitted when more than one channel contributes, so a lone
  channel never gets a redundant duplicate total.
- Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now
  count every placed order (stamped + unconverted), not just the stamped
  subset.
- Share and Trend columns reordered so Share sits immediately before Trend
  (previously Share was misplaced next to Revenue).
- Fixed a CSS specificity bug where the Phase-6 dashboard-triage
  `.status-strip` rule silently won over `.status-strip--analytics` at
  >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2.

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

* docs(analytics): implementation plan for /analytics needs-attention section

Plans issue #1989 — three actionable categories (coverage gaps, stock at
risk, failed-sync value) consuming the already-shipped GET
/analytics/needs-attention (#1983), mounted into the #1986 shell. No
backend changes; resolves link targets, the mixedCurrency interim
(tracked against #2049), and the ambiguous multi-connection copy case.

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

* feat(web,analytics): /analytics needs-attention section (#1989)

Renders the three needs-attention categories — coverage gaps, stock at
risk, value stuck in failed syncs — mounted into the #1986 shell.
Consumes the already-shipped GET /analytics/needs-attention (#1983)
as-is; no backend changes.

Either the open rows render or a single all-clear line does, never
both, per the design mockup's rule. Each open row deep-links into the
flow that resolves it: the unified publish wizard, the product detail
page, or the orders list filtered to the needs_attention health
bucket. Ambiguous multi-connection cases fall back to a
connection-agnostic headline; the failed-sync total renders
currency-neutral since the DTO carries no currency field in either
the mixed or non-mixed case (interim pending #2049).

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

* fix(web,analytics): match needs-attention section to the #2003 mockup

The plan (implementation-plan-analytics-needs-attention.md) required a
client-side "checked HH:MM" timestamp in the panel header and a
neutral-tone Clear badge, mirroring frame 02 of the design mockup
(docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018
branch). Both were dropped in the original implementation.

Adds the checked-at timestamp (TimeDisplay driven by the query's own
dataUpdatedAt, since the DTO carries no such field) and switches the
all-clear badge from success to neutral, per spec.

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

* fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture

Rebase fallout from the earliestOrderDate swap (#2083): the
#1989-cherry-picked "keep the trust header rendered when needs-attention
fails" test fixture predates that field and failed type-check.

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

* fix(analytics): address #2120 tech review — sample-vs-total headline defect

- BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a
  connection when the preview sample IS the total (items.length ===
  totalCount); otherwise fall through to the connection-agnostic
  headline, so a headline never asserts something only a 20-item
  sample verified.
- "Publish now" sub now discloses when it only seeds the sampled
  variants ("showing the first N of M").
- Fix the "1 variant have a listing gap" grammar bug to a verb-free
  form, updating the test that had locked it in.
- Thread a BCP 47 locale into deriveFailedSyncHeadline instead of
  hardcoding toLocaleString('en-US').
- Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds
  from the same item list instead of two independently sliced arrays.
- Render AnalyticsNeedsAttention regardless of order-ingestion status —
  coverage gaps and stock-at-risk are listing facts, not order facts.
- Render .attention-list as <ul>/<li> for list semantics.

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

* fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide

- toExclusiveEndInstant now anchors on UTC midnight instead of local
  midnight, matching the controller's UTC-parsed `from` (was silently
  dropping/adding hours off UTC).
- Sparkline aria-labels derive from the actual selected range instead
  of a hardcoded "last 7 days".
- Register the analytics KPI card's 152px/3-col geometry as a
  documented carve-out in the style guide (Density table + parity
  matrix), per the "never introduce an undocumented row height" rule.
- Fix "Data order" planned-tag typo -> "Planned"; section-infotip
  font-size to the rem token equivalent.

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

* fix(web): remove unused vi import breaking tsc build

CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi`
import in sales-analytics.api.test.ts, blocking `pnpm build`.

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

* fix(analytics): emit currency total for single-contributing-channel groups

groupChannelTotalsByCurrency skipped a currency's Total row whenever
only one channel contributed to it, so a deployment with exactly one
connection per currency (e.g. one EUR shop) silently lost that row.
No spec basis for the threshold — the by-channel currency total
should render for every distinct currency present, regardless of how
many channels contribute.

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

* fix(analytics): address #2120 re-review — deep-link connection resolution

Reuses deriveCoverageHeadline's own connection resolution for the coverage
deep link's connectionId param instead of re-deriving it with a weaker
predicate, so the bulk-wizard link can never name a channel the headline
declined to name. Also derives the all-clear checkedCount from the
evaluated categories rather than a hardcoded literal.

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

* fix(web): remove unused vi import in sales-analytics.api.test.ts

Pre-existing lint error surfaced while validating the 1986 merge —
vi was imported but never used in this file.

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

* fix(analytics): stop rendering a currency-neutral total on the failed-sync row

`deriveFailedSyncHeadline` formatted `totalValue` with no currency
symbol ("6,120.64 of orders never reached a destination"), which reads
as a real monetary figure to an operator even though the DTO carries
no currency at all — the same misrepresentation risk the mixedCurrency
branch already guarded against, just less obviously so. Both branches
now render the same count-only shape; `totalValue` stays on the wire
for future consumers, this headline just stops reading it.

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

* fix(analytics): stop rendering a duplicate/colliding unconverted Total row

groupChannelTotalsByCurrency emitted a `Total · {currency} (unconverted)`
row per distinct unconvertedCurrency found across channels, with no
regard for whether that currency string collided with the real
reporting-currency Total row's label — a domestic-currency channel
simply awaiting its first FX-stamp pass produced a second, same-labelled
"Total · PLN" row computed from unrelated fields, reading as a
contradiction rather than two distinct facts.

Drop that row entirely. countUnconvertedOrders reports the
currency-agnostic total count as a single footnote sentence under the
table instead ("N orders not yet converted to the reporting currency —
excluded from the figures above"), never a competing Total row and
never a bare currency-neutral amount.

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

* fix(analytics): stop the coverage deep-link from naming a channel the headline declined to

deriveCoverageHeadline's connection-naming rule requires items.length ===
totalCount, every item missing from exactly one connection, and one
distinct id. The "Publish now" deep link recomputed its own, weaker
predicate (only the last condition), so a partial-but-uniform sample
could pin a connectionId into the wizard link while the headline right
next to it correctly fell back to the connection-agnostic copy —
sending the operator into a wizard pre-scoped to a channel the row
never actually asserted (#2120 re-review, IMPORTANT).

deriveCoverageHeadline now returns connectionId (string | null)
alongside the copy, computed by the same predicate; the component reads
it instead of re-deriving one. Added a regression test pinning the
exact partial-sample/uniform-connection case from the review.

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

* feat(web,analytics): top products table with per-channel breakdown (#1991) (#2191)

* feat(web,analytics): top products table with per-channel breakdown (#1991)

Adds the /analytics top-products table: one row per product, per-channel
units split, revenue/units sort toggle, and a Publish affordance for
channels the product isn't listed on. Fixes a labeling gap found while
manually testing against seeded data: a channel absent from the sales
breakdown was always rendered "Not listed", even when the product was
genuinely listed there and simply had no sale in the selected date range —
now only a channel actually missing from `missingFromConnectionIds` gets
the "Not listed" + Publish treatment; a listed-but-quiet channel renders
the same real, full-weight `0` a channel with sales would.

Closes #1991

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

* fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)

getTopProductRanking/getProductChannelBreakdown summed every stamped order's
reportingTotalAmount regardless of which reporting-currency era it was
pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick.
Since a settings change is forward-only (older orders keep their original
stamp), switching the reporting currency mixed two real currencies into one
number under a wrong label instead of surfacing the older era as unconverted
evidence like an unstamped order.

OrderRecordService now resolves the current reporting currency and both
queries filter revenue to reportingCurrency = current, folding any other
era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped
orders.

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

* Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)"

This reverts commit 5a39290.

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

* fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991)

A product whose only orders in range were stamped under a PREVIOUS
reporting-currency setting (or never stamped at all) rendered a bare
"No FX-stamped order" empty value, even though the backend already exposed
the native-currency figure as unconvertedRevenue/unconvertedCurrency
(#1988).

The Revenue column now falls back to that evidence when there is no
current-era stamp, marked informational via a title tooltip — mirroring
ChannelSalesTable's identical fallback for the #1987 by-channel read.

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

* fix(web): remove unused vi import breaking tsc build

CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi`
import in sales-analytics.api.test.ts, blocking `pnpm build`.

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

* Revert "fix(web): remove unused vi import breaking tsc build"

This reverts commit b915a03.

* fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug

- Units column now reads row.units (server-ranked figure) instead of
  re-summing row.channels[], which could silently disagree with the
  sort order the header arrow claims.
- The Publish action is gated on listings:write via useWriteAccess +
  ReadOnlyLock: hidden for an unauthorized non-demo session, rendered
  disabled with the read-only tooltip for a demo viewer.
- Swapped the Chip (aria-pressed toggle) for a real Link styled as a
  button, so the one-shot publish navigation carries link semantics
  (middle-click, open-in-new-tab) instead of misrepresenting itself as
  a permanently-unpressed toggle to assistive tech.
- @media (hover: none) now stacks the "Not listed" label and the
  Publish action, both visible, instead of hiding the label on touch —
  the #1991 AC's label-vs-action distinction was desktop-only before.
- Added the analytics feature slug to both no-restricted-imports
  pattern groups in .eslintrc.js.

Closes review findings on #2191

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

* fix(orders,analytics): SQL precedence bug + surface coverageGapAvailable/unresolvedProductCount (#2172/#2191 review)

Root cause of the failing top-products-ranking int-spec: `unconvertedOrZeroTotal`
was a bare, unparenthesized `X OR Y` string spliced into
`${unconvertedOrZeroTotal} AND rec."currency" IS NULL`. SQL's AND-before-OR
precedence turned that into `X OR (Y AND Z)` instead of the intended
`(X OR Y) AND Z` — since X (reportingCurrency mismatch) was true for nearly
every unstamped row, the guard fired unconditionally and `unconverted_currency`
fell to NULL far more often than the data warranted. Fixed by parenthesizing
the constant at its definition (both getTopProductRanking and
getProductChannelBreakdown); pinned by the existing int-spec against real
Postgres (a mocked unit spec cannot observe operator precedence) and recorded
in docs/lessons.md.

Also addresses the two still-open review IMPORTANT findings on the FE table:
- `coverageGapAvailable: false` now suppresses "Not listed"/Publish on every
  channel cell (the enrichment failure makes missingFromConnectionIds
  unreliable for the whole response), rendering the real 0 instead, with a
  footnote explaining the check is unavailable.
- `unresolvedProductCount > 0` is now disclosed via a footnote rather than
  silently absorbed.

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

* feat(analytics): period-over-period delta on the sales KPI strip

Adds a "vs previous period" delta to Orders, Order value, Units and
Cancellation rate on the /analytics KPI strip — a second GET
/analytics/sales call over the immediately-preceding period of the
same length, refused outright (GapMark) unless the entire previous
window is covered by ingested order history (per-connection
earliest-order-date, #2083).

Matches the design mockup's delta anatomy (docs/plans/mockups/
analytics-ledger-2003.html): an aria-hidden ↑/↓/→ glyph, a sr-only
spoken sentence, and count/amount deltas rendered as a relative "%"
while rate deltas (cancellation rate) render as an absolute "pp" —
a rate moves in points, not percent.

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

* fix(web,analytics): mount top-products table and reveal Publish on hover

ProductSalesTable (#1991) was fully built end-to-end but never mounted
on AnalyticsPage, so the top-products section never rendered. Also add
the .cell-not-listed hover/focus CSS the component's own doc comment
already described but that was never written — the "Not listed" label
now swaps for a Publish action on hover/focus (with a light
warning-yellow glow), staying permanently visible on touch pointers.

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

---------

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

* fix(web,analytics): make GapMark's caveat reachable without a mouse

A native `title` on a non-interactive, non-focusable `<span>` whose
entire content is a dagger glyph never surfaces for a keyboard user,
and a screen reader has no accessible name to announce beyond "dagger".
Add role="img" + aria-label={title} alongside the existing title so the
caveat is announced regardless of input modality (#2120 review).

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

---------

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

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Signed-off-by: jakubret
Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
jakubretajczykBD added a commit that referenced this pull request Aug 24, 2026
… split (#1988) (#2172)

* feat(orders,analytics): top-products endpoint with inline per-channel split (#1988)

Adds GET /analytics/top-products - products ranked by revenue or units for
a date range, each row carrying its own per-channel breakdown, catalog
metadata, and a listing-coverage-gap flag. Stacked on #1987's currency-
correctness pattern (FILTER (WHERE reportingCurrency IS NOT NULL) / SUM
via each order's own implicit FX multiplier), never silently summing
across currencies and always disclosing what's unstamped/cancelled.

- OrderLineItemRepositoryPort +getTopProductRanking, +getProductChannelBreakdown
- buildTopProducts pure aggregation + IOrderRecordService.getTopProducts
- TopProductsController/DTOs + apps/api-layer TopProductsService composing
  orders + products + listings (coverage-gap flag, O(connections) fan-out,
  degrades gracefully on failure - mirrors NeedsAttentionService)
- Fixes a pre-existing gap: order_line_items was missing from the
  integration-test harness's tablesToTruncate list (no DB FK to cascade
  from order_records), which would leak rows between test files

Built following docs/plans/implementation-plan-top-products-analytics.md
(pre-implement gate: READY, included in this PR).

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

* fix(orders,analytics): scope top-products revenue to the current reporting currency (#1988)

getTopProductRanking/getProductChannelBreakdown summed every stamped order's
reportingTotalAmount regardless of which reporting-currency era it was
pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick.
Since a settings change is forward-only (older orders keep their original
stamp), switching the reporting currency mixed two real currencies into one
number under a wrong label instead of surfacing the older era as unconverted
evidence like an unstamped order.

OrderRecordService now resolves the current reporting currency and both
queries filter revenue to reportingCurrency = current, folding any other
era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped
orders.

(cherry picked from commit 5a39290)
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* feat(orders,analytics): disclose the native currency behind unconverted top-products evidence (#1988)

getTopProductRanking already folded a prior reporting-currency era (or a
never-stamped order) into unconvertedRevenue/unconvertedOrderCount, but gave
the frontend no way to label that figure — unlike the #1987 by-channel read,
which already carries unconvertedCurrency for the identical situation.

Adds unconvertedCurrency end to end (repository SQL, ProductRankingRow,
TopProductView, TopProductRowDto): the one native currency shared by every
order contributing to unconvertedRevenue, or null when that set mixes
currencies, mirroring DailyOrderAggregateRow's existing rule.

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

* fix(orders,analytics,products): address #2172 review findings on top-products ranking

Two IMPORTANT correctness issues and three SUGGESTIONS from the #2172 tech
review, all still open on this branch:

- IMPORTANT 1: ORDER BY revenue/units had no tiebreaker, so pagination over
  a non-unique sort was non-deterministic in Postgres (ties could repeat on
  one page and be skipped on the next). Add addOrderBy('product_id', 'ASC').
- IMPORTANT 2: a stamped order with totalAmount = 0 (fully discounted/free)
  silently vanished from both revenue and unconvertedRevenue, since the FX
  multiplier (reportingTotalAmount / totalAmount) is NULL via
  NULLIF(totalAmount, 0). It now folds into the unconverted bucket instead,
  same as a never-stamped order, in both getTopProductRanking and
  getProductChannelBreakdown.
- SUGGESTION 3: documented, in the endpoint's @apioperation description,
  that ranking by revenue is blind to unconverted revenue for a product
  whose orders are all unstamped.
- SUGGESTION 4: resolveCoverageGaps fired up to `limit` concurrent
  getVariantsByProductId calls. Added a batch getVariantsByProductIds
  (ProductVariantRepositoryPort -> IProductsService) so the page's variant
  ids resolve in one query instead of one per product.
- SUGGESTION 5: a coverage-gap enrichment failure degraded every row to
  missingFromConnectionIds: [], indistinguishable from "listed everywhere".
  Added TopProductsResponseDto.coverageGapAvailable so the FE can tell the
  two apart.

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

* fix(orders,analytics): label unconvertedCurrency per channel on top-products (#2172 review)

The ranking row's unconvertedRevenue gained a currency label in an earlier
fix, but the per-channel breakdown row didn't, so
ProductChannelBreakdownDto.unconvertedRevenue stayed a bare number with no
unit. Inheriting the parent's label isn't sound either: the parent goes
null on a mixed set, but an individual channel's own subset is routinely
single-currency even then — a channel is strictly more labelable than the
product as a whole, never less.

Lifts the same MAX(currency) FILTER (...) / COUNT(DISTINCT ...) <= 1 shape
getTopProductRanking already uses, computed per (product, connection) in
getProductChannelBreakdown, threaded through ProductChannelBreakdownRow and
ProductChannelBreakdownDto.

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

* fix(orders,analytics): address remaining #2172 review findings

- applyTopProductsScope now requires rec."totalAmount" IS NOT NULL,
  matching applySalesAnalyticsScope; the doc comment no longer claims
  byte-for-byte alignment it didn't hold (IMPORTANT 1).
- unconvertedCurrency's label guard now also requires zero NULL
  rec."currency" rows in the filtered set, since COUNT(DISTINCT ...)
  alone ignores NULLs and could mislabel a {NULL, 'PLN'} mix as 'PLN'
  (SUGGESTION 3, same fix needed on both getTopProductRanking and
  getProductChannelBreakdown).
- TopProductRowDto.revenue now documents that it is LINE revenue, not
  a per-product slice of order revenue, and that ranking is blind to
  unconvertedRevenue (IMPORTANT 2).

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

* fix(orders): parenthesize unconvertedOrZeroTotal in top-products currency guard

The unconverted_currency CASE guard concatenated the OR-joined
unconvertedOrZeroTotal predicate with `AND rec."currency" IS NULL`
without parentheses. Since SQL AND binds tighter than OR, this parsed
as `A OR (B AND C)` instead of the intended `(A OR B) AND C`, so the
"no NULL currency in the unconverted set" guard was satisfied whenever
the reporting-currency mismatch term alone was true — the common case
for any unstamped row — causing a single-currency channel/product to
mislabel unconvertedCurrency as null. Same bug in both
getTopProductRanking and getProductChannelBreakdown; fixed by wrapping
the shared predicate in parens at both call sites.

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

* feat(analytics): #1986 route shell — trust header, date-range toolbar, page (#2098)

* docs(analytics): implementation plan for #1986 route shell

Plan for the /analytics route shell (date-range control, trust header),
branched off the current #1985 order-analytics-read-model state per the
user's request, since two of its decisions (coverage-window row,
degradation-banner rule) explicitly track #1985 and its follow-up #2083.

Ref #1986

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

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header (#2115)

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner

Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md:
- New /analytics route (PageLayout, Operations nav item)
- Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) +
  From/To fields with a draft-buffered Apply action (Decision 1)
- Trust header (per-connection freshness + "Connected since" + status,
  Decision 3 — real "data from" coverage deferred to #2083/#1985) with a
  click-triggered info popover (touch-safe, unlike a hover-only Tooltip)
- Degradation banner on stalled/disconnected connections — status-only
  for v1 (Decision 4); the mockup's range-gated "sold in this selected
  range" refinement is deferred until #1990 makes that fact honest rather
  than an approximation
- Fresh-instance / still-arriving / loading / error states
- New analyticsTrust API-client namespace consuming the already-shipped
  GET /analytics/trust (#1982)

Post-review fixes (tech-review pass):
- Order-date disclaimer is now a static span (chip+dagger, matches the
  design mockup verbatim) instead of the interactive Chip primitive,
  which rendered a toggle button with no effect
- Banner timestamp uses the shared formatDateTime helper instead of a
  hand-rolled toLocaleString(), matching AnalyticsTrustHeader
- Added a page-level loading-state test

Ref #1986

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

* fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup

- Namespace .gap-mark/.info-popover-trigger to .analytics-* and document
  .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row
  Heights, per the tech-lead review's documentation-obligation findings.
- Drop code-comment claims of verbatim conformance to
  docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018).
- Replace inline style on analytics-trust-header.tsx with a real CSS class;
  drop the phantom trailing grid column.
- Remove the dead toUtcRangeInstants export (no consumer yet).

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

* fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy

- Fix two failing tests: the 90d preset test asserted an off-by-one date
  (implementation was correct), and the disclaimer-chip test failed to
  match the tooltip-split text node. Also fixes a third, previously
  undetected failure in the degradation-banner "renders nothing" test,
  which asserted an empty DOM even though renderWithProviders always
  mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
  operator-facing aria-label/tooltip copy; move the rationale into a
  code comment and replace the bare `title` with a keyboard-reachable
  Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
  "has not ingested since" copy, both of which asserted data currency
  from `lastPollAt` — a pipe-liveness signal, not proof any order data
  arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
  date-range.lib.ts (the file only does local-time formatting).

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

* fix(web,analytics): render the real earliestOrderDate now that #2083 shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

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

---------

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

* fix(web): bump the lazy-route contract count to 52 for /settings/mcp-tokens

An earlier merge (feat(mcp): Resource-Server auth via user-issued Personal
Access Tokens, #1486/#1912) added the /settings/mcp-tokens page as a lazy
route, but the parameterized route-lazy contract test's expected count
was never bumped, failing CI on this branch with "expected 52 to be 51".

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

* fix(analytics): address #2098 tech review + trust-header single-line layout

- Sync docs/plans/implementation-plan-analytics-page-shell.md with the
  now-resolved Decision 3 (real earliestOrderDate coverage row) and
  Decision 4 (hasSalesInRange dropped), and note the Reusable
  Components divergences.
- Replace the analytics-date-range-toolbar's Tooltip-based "Order
  date" caveat with a Popover on a real <button>, matching
  AnalyticsTrustHeader's pattern — Radix Tooltip ignores
  pointerType === 'touch', making the old trigger unreachable on
  mobile.
- Trust header renders a single-line "data from X · synced Y" fact
  string with a per-channel colored dot, replacing the prior two-column
  label/value layout; adds TimeDisplay's 'time' format and
  formatAbsoluteTime helper it depends on.

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

* fix(analytics): drop dangling "data from" prefix on the no-orders-yet fact

The "data from" prefix was rendered unconditionally, so a connection
with no earliestOrderDate read "data from no orders yet" instead of
just "no orders yet".

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

* fix(analytics): address remaining #2098 review findings

- date-range.lib.ts: add toUtcRangeInstants — the single conversion
  point future /analytics/* consumers must use to turn this toolbar's
  local-day, inclusive from/to into the backend's UTC, to-exclusive
  range contract (SalesAnalyticsQueryDto.to). Fixes the inclusive/
  exclusive and local/UTC mismatches flagged in the /pr-review pass,
  pinned with tests.
- ingestion-trust.lib.ts: rename shouldShowDegradationBanner to
  selectDegradedConnections (it returns the degraded subset, not a
  boolean) and type DEGRADED_STATUSES as Set<ConnectionIngestionStatus>
  with a comment on why 'unknown' is deliberately excluded.
- analytics-page.tsx: adopt PageLayout instead of hand-rolled
  page-header markup; document the frozen `today` ref decision.
- Sync the implementation plan doc with all of the above.

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

* feat(web,analytics): /analytics needs-attention section (#2120)

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner

Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md:
- New /analytics route (PageLayout, Operations nav item)
- Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) +
  From/To fields with a draft-buffered Apply action (Decision 1)
- Trust header (per-connection freshness + "Connected since" + status,
  Decision 3 — real "data from" coverage deferred to #2083/#1985) with a
  click-triggered info popover (touch-safe, unlike a hover-only Tooltip)
- Degradation banner on stalled/disconnected connections — status-only
  for v1 (Decision 4); the mockup's range-gated "sold in this selected
  range" refinement is deferred until #1990 makes that fact honest rather
  than an approximation
- Fresh-instance / still-arriving / loading / error states
- New analyticsTrust API-client namespace consuming the already-shipped
  GET /analytics/trust (#1982)

Post-review fixes (tech-review pass):
- Order-date disclaimer is now a static span (chip+dagger, matches the
  design mockup verbatim) instead of the interactive Chip primitive,
  which rendered a toggle button with no effect
- Banner timestamp uses the shared formatDateTime helper instead of a
  hand-rolled toLocaleString(), matching AnalyticsTrustHeader
- Added a page-level loading-state test

Ref #1986

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

* fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup

- Namespace .gap-mark/.info-popover-trigger to .analytics-* and document
  .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row
  Heights, per the tech-lead review's documentation-obligation findings.
- Drop code-comment claims of verbatim conformance to
  docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018).
- Replace inline style on analytics-trust-header.tsx with a real CSS class;
  drop the phantom trailing grid column.
- Remove the dead toUtcRangeInstants export (no consumer yet).

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

* fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy

- Fix two failing tests: the 90d preset test asserted an off-by-one date
  (implementation was correct), and the disclaimer-chip test failed to
  match the tooltip-split text node. Also fixes a third, previously
  undetected failure in the degradation-banner "renders nothing" test,
  which asserted an empty DOM even though renderWithProviders always
  mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
  operator-facing aria-label/tooltip copy; move the rationale into a
  code comment and replace the bare `title` with a keyboard-reachable
  Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
  "has not ingested since" copy, both of which asserted data currency
  from `lastPollAt` — a pipe-liveness signal, not proof any order data
  arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
  date-range.lib.ts (the file only does local-time formatting).

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

* fix(web,analytics): render the real earliestOrderDate now that #2083 shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

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

* feat(web,analytics): sales KPI strip + by-channel table (#1990)

Adds GET /analytics/sales client, view-model helpers, and the two FE
sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order
value w/ median, Units, Cancellations, Returns & refunds) and a
by-channel DataTable, mounted into the #1986 route shell.

Currency-aware per #1987/#2049/ADR-040: every money figure carries its
currency (headline.reportingCurrency), and a channel's revenueBasis
('reporting' | 'native' | 'unavailable') drives whether its revenue/
share render as plain values, a same-currency-but-incomparable caveat,
or an explicit empty value — never a blended or falsely-comparable
number. taxTreatment 'mixed' surfaces an inline chip so gross/net
incomparability is stated, not implied. A channel whose earliest order
postdates the range start renders a "Partial history" flag.

Fixes an exclusive-end date bug found in a prior implementation
attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but
the endpoint treats `to` as exclusive — toExclusiveEndInstant converts
it so the selected range's last day isn't silently dropped.

Also fixes a pre-existing test race in orders-list-page.test.tsx: a
synchronous assertion on empty-state text that depends on an async
query, following an await on a chip that mounts synchronously from a
URL param — now awaited with findByText.

Closes #1990

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

* fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests

CI's `tsc -b` (project-references build) caught what a plain `tsc
--noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new
Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which
doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>`
return type. Pin the generic on the never-resolving Promise, matching
the existing analytics-trust test precedent.

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

* fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract

The frontend types were drafted ahead of the backend and assumed a shape
it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency
per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring
(reportingTotalAmount stamp + unconvertedCurrency labelling) has been
merged in, rewrite the frontend to match it exactly: one nullable
system-wide currency, unconvertedCount/Value/Currency per channel, and
revenueShare always a number.

- Cancellations KPI now leads with the rate (%), value/count as qualifiers.
- By-channel table: a channel with no FX-stamped revenue yet falls back to
  its own unconverted-currency evidence instead of showing an empty cell,
  flagged with an "Awaiting FX stamp" chip.
- Total rows: one reporting-currency total (real KPI aggregate) plus one
  informational unconverted-currency subtotal per distinct native currency
  — only emitted when more than one channel contributes, so a lone
  channel never gets a redundant duplicate total.
- Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now
  count every placed order (stamped + unconverted), not just the stamped
  subset.
- Share and Trend columns reordered so Share sits immediately before Trend
  (previously Share was misplaced next to Revenue).
- Fixed a CSS specificity bug where the Phase-6 dashboard-triage
  `.status-strip` rule silently won over `.status-strip--analytics` at
  >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2.

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

* docs(analytics): implementation plan for /analytics needs-attention section

Plans issue #1989 — three actionable categories (coverage gaps, stock at
risk, failed-sync value) consuming the already-shipped GET
/analytics/needs-attention (#1983), mounted into the #1986 shell. No
backend changes; resolves link targets, the mixedCurrency interim
(tracked against #2049), and the ambiguous multi-connection copy case.

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

* feat(web,analytics): /analytics needs-attention section (#1989)

Renders the three needs-attention categories — coverage gaps, stock at
risk, value stuck in failed syncs — mounted into the #1986 shell.
Consumes the already-shipped GET /analytics/needs-attention (#1983)
as-is; no backend changes.

Either the open rows render or a single all-clear line does, never
both, per the design mockup's rule. Each open row deep-links into the
flow that resolves it: the unified publish wizard, the product detail
page, or the orders list filtered to the needs_attention health
bucket. Ambiguous multi-connection cases fall back to a
connection-agnostic headline; the failed-sync total renders
currency-neutral since the DTO carries no currency field in either
the mixed or non-mixed case (interim pending #2049).

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

* fix(web,analytics): match needs-attention section to the #2003 mockup

The plan (implementation-plan-analytics-needs-attention.md) required a
client-side "checked HH:MM" timestamp in the panel header and a
neutral-tone Clear badge, mirroring frame 02 of the design mockup
(docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018
branch). Both were dropped in the original implementation.

Adds the checked-at timestamp (TimeDisplay driven by the query's own
dataUpdatedAt, since the DTO carries no such field) and switches the
all-clear badge from success to neutral, per spec.

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

* fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture

Rebase fallout from the earliestOrderDate swap (#2083): the
#1989-cherry-picked "keep the trust header rendered when needs-attention
fails" test fixture predates that field and failed type-check.

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

* fix(analytics): address #2120 tech review — sample-vs-total headline defect

- BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a
  connection when the preview sample IS the total (items.length ===
  totalCount); otherwise fall through to the connection-agnostic
  headline, so a headline never asserts something only a 20-item
  sample verified.
- "Publish now" sub now discloses when it only seeds the sampled
  variants ("showing the first N of M").
- Fix the "1 variant have a listing gap" grammar bug to a verb-free
  form, updating the test that had locked it in.
- Thread a BCP 47 locale into deriveFailedSyncHeadline instead of
  hardcoding toLocaleString('en-US').
- Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds
  from the same item list instead of two independently sliced arrays.
- Render AnalyticsNeedsAttention regardless of order-ingestion status —
  coverage gaps and stock-at-risk are listing facts, not order facts.
- Render .attention-list as <ul>/<li> for list semantics.

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

* fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide

- toExclusiveEndInstant now anchors on UTC midnight instead of local
  midnight, matching the controller's UTC-parsed `from` (was silently
  dropping/adding hours off UTC).
- Sparkline aria-labels derive from the actual selected range instead
  of a hardcoded "last 7 days".
- Register the analytics KPI card's 152px/3-col geometry as a
  documented carve-out in the style guide (Density table + parity
  matrix), per the "never introduce an undocumented row height" rule.
- Fix "Data order" planned-tag typo -> "Planned"; section-infotip
  font-size to the rem token equivalent.

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

* fix(web): remove unused vi import breaking tsc build

CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi`
import in sales-analytics.api.test.ts, blocking `pnpm build`.

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

* fix(analytics): emit currency total for single-contributing-channel groups

groupChannelTotalsByCurrency skipped a currency's Total row whenever
only one channel contributed to it, so a deployment with exactly one
connection per currency (e.g. one EUR shop) silently lost that row.
No spec basis for the threshold — the by-channel currency total
should render for every distinct currency present, regardless of how
many channels contribute.

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

* fix(analytics): address #2120 re-review — deep-link connection resolution

Reuses deriveCoverageHeadline's own connection resolution for the coverage
deep link's connectionId param instead of re-deriving it with a weaker
predicate, so the bulk-wizard link can never name a channel the headline
declined to name. Also derives the all-clear checkedCount from the
evaluated categories rather than a hardcoded literal.

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

* fix(web): remove unused vi import in sales-analytics.api.test.ts

Pre-existing lint error surfaced while validating the 1986 merge —
vi was imported but never used in this file.

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

* fix(analytics): stop rendering a currency-neutral total on the failed-sync row

`deriveFailedSyncHeadline` formatted `totalValue` with no currency
symbol ("6,120.64 of orders never reached a destination"), which reads
as a real monetary figure to an operator even though the DTO carries
no currency at all — the same misrepresentation risk the mixedCurrency
branch already guarded against, just less obviously so. Both branches
now render the same count-only shape; `totalValue` stays on the wire
for future consumers, this headline just stops reading it.

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

* fix(analytics): stop rendering a duplicate/colliding unconverted Total row

groupChannelTotalsByCurrency emitted a `Total · {currency} (unconverted)`
row per distinct unconvertedCurrency found across channels, with no
regard for whether that currency string collided with the real
reporting-currency Total row's label — a domestic-currency channel
simply awaiting its first FX-stamp pass produced a second, same-labelled
"Total · PLN" row computed from unrelated fields, reading as a
contradiction rather than two distinct facts.

Drop that row entirely. countUnconvertedOrders reports the
currency-agnostic total count as a single footnote sentence under the
table instead ("N orders not yet converted to the reporting currency —
excluded from the figures above"), never a competing Total row and
never a bare currency-neutral amount.

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

* fix(analytics): stop the coverage deep-link from naming a channel the headline declined to

deriveCoverageHeadline's connection-naming rule requires items.length ===
totalCount, every item missing from exactly one connection, and one
distinct id. The "Publish now" deep link recomputed its own, weaker
predicate (only the last condition), so a partial-but-uniform sample
could pin a connectionId into the wizard link while the headline right
next to it correctly fell back to the connection-agnostic copy —
sending the operator into a wizard pre-scoped to a channel the row
never actually asserted (#2120 re-review, IMPORTANT).

deriveCoverageHeadline now returns connectionId (string | null)
alongside the copy, computed by the same predicate; the component reads
it instead of re-deriving one. Added a regression test pinning the
exact partial-sample/uniform-connection case from the review.

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

* feat(web,analytics): top products table with per-channel breakdown (#1991) (#2191)

* feat(web,analytics): top products table with per-channel breakdown (#1991)

Adds the /analytics top-products table: one row per product, per-channel
units split, revenue/units sort toggle, and a Publish affordance for
channels the product isn't listed on. Fixes a labeling gap found while
manually testing against seeded data: a channel absent from the sales
breakdown was always rendered "Not listed", even when the product was
genuinely listed there and simply had no sale in the selected date range —
now only a channel actually missing from `missingFromConnectionIds` gets
the "Not listed" + Publish treatment; a listed-but-quiet channel renders
the same real, full-weight `0` a channel with sales would.

Closes #1991

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

* fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)

getTopProductRanking/getProductChannelBreakdown summed every stamped order's
reportingTotalAmount regardless of which reporting-currency era it was
pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick.
Since a settings change is forward-only (older orders keep their original
stamp), switching the reporting currency mixed two real currencies into one
number under a wrong label instead of surfacing the older era as unconverted
evidence like an unstamped order.

OrderRecordService now resolves the current reporting currency and both
queries filter revenue to reportingCurrency = current, folding any other
era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped
orders.

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

* Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)"

This reverts commit 5a39290.

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

* fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991)

A product whose only orders in range were stamped under a PREVIOUS
reporting-currency setting (or never stamped at all) rendered a bare
"No FX-stamped order" empty value, even though the backend already exposed
the native-currency figure as unconvertedRevenue/unconvertedCurrency
(#1988).

The Revenue column now falls back to that evidence when there is no
current-era stamp, marked informational via a title tooltip — mirroring
ChannelSalesTable's identical fallback for the #1987 by-channel read.

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

* fix(web): remove unused vi import breaking tsc build

CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi`
import in sales-analytics.api.test.ts, blocking `pnpm build`.

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

* Revert "fix(web): remove unused vi import breaking tsc build"

This reverts commit b915a03.

* fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug

- Units column now reads row.units (server-ranked figure) instead of
  re-summing row.channels[], which could silently disagree with the
  sort order the header arrow claims.
- The Publish action is gated on listings:write via useWriteAccess +
  ReadOnlyLock: hidden for an unauthorized non-demo session, rendered
  disabled with the read-only tooltip for a demo viewer.
- Swapped the Chip (aria-pressed toggle) for a real Link styled as a
  button, so the one-shot publish navigation carries link semantics
  (middle-click, open-in-new-tab) instead of misrepresenting itself as
  a permanently-unpressed toggle to assistive tech.
- @media (hover: none) now stacks the "Not listed" label and the
  Publish action, both visible, instead of hiding the label on touch —
  the #1991 AC's label-vs-action distinction was desktop-only before.
- Added the analytics feature slug to both no-restricted-imports
  pattern groups in .eslintrc.js.

Closes review findings on #2191

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

* fix(orders,analytics): SQL precedence bug + surface coverageGapAvailable/unresolvedProductCount (#2172/#2191 review)

Root cause of the failing top-products-ranking int-spec: `unconvertedOrZeroTotal`
was a bare, unparenthesized `X OR Y` string spliced into
`${unconvertedOrZeroTotal} AND rec."currency" IS NULL`. SQL's AND-before-OR
precedence turned that into `X OR (Y AND Z)` instead of the intended
`(X OR Y) AND Z` — since X (reportingCurrency mismatch) was true for nearly
every unstamped row, the guard fired unconditionally and `unconverted_currency`
fell to NULL far more often than the data warranted. Fixed by parenthesizing
the constant at its definition (both getTopProductRanking and
getProductChannelBreakdown); pinned by the existing int-spec against real
Postgres (a mocked unit spec cannot observe operator precedence) and recorded
in docs/lessons.md.

Also addresses the two still-open review IMPORTANT findings on the FE table:
- `coverageGapAvailable: false` now suppresses "Not listed"/Publish on every
  channel cell (the enrichment failure makes missingFromConnectionIds
  unreliable for the whole response), rendering the real 0 instead, with a
  footnote explaining the check is unavailable.
- `unresolvedProductCount > 0` is now disclosed via a footnote rather than
  silently absorbed.

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

* feat(analytics): period-over-period delta on the sales KPI strip

Adds a "vs previous period" delta to Orders, Order value, Units and
Cancellation rate on the /analytics KPI strip — a second GET
/analytics/sales call over the immediately-preceding period of the
same length, refused outright (GapMark) unless the entire previous
window is covered by ingested order history (per-connection
earliest-order-date, #2083).

Matches the design mockup's delta anatomy (docs/plans/mockups/
analytics-ledger-2003.html): an aria-hidden ↑/↓/→ glyph, a sr-only
spoken sentence, and count/amount deltas rendered as a relative "%"
while rate deltas (cancellation rate) render as an absolute "pp" —
a rate moves in points, not percent.

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

* fix(web,analytics): mount top-products table and reveal Publish on hover

ProductSalesTable (#1991) was fully built end-to-end but never mounted
on AnalyticsPage, so the top-products section never rendered. Also add
the .cell-not-listed hover/focus CSS the component's own doc comment
already described but that was never written — the "Not listed" label
now swaps for a Publish action on hover/focus (with a light
warning-yellow glow), staying permanently visible on touch pointers.

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

---------

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

* fix(web,analytics): make GapMark's caveat reachable without a mouse

A native `title` on a non-interactive, non-focusable `<span>` whose
entire content is a dagger glyph never surfaces for a keyboard user,
and a screen reader has no accessible name to announce beyond "dagger".
Add role="img" + aria-label={title} alongside the existing title so the
caveat is announced regardless of input modality (#2120 review).

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

---------

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

---------

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

* docs(orders): explain why getTopProducts reads are sequential, not parallel

getProductChannelBreakdown is scoped to the current page's productIds,
which only exist once getTopProductRanking has returned — unlike the
three independent Promise.all reads in getSalesAndChannelAnalytics
above it. Note the distinction so a reader doesn't mistake the missing
parallelisation for an oversight (#2172 review, SUGGESTION 2).

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

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Signed-off-by: jakubret
Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
jakubretajczykBD added a commit that referenced this pull request Aug 24, 2026
…2151)

* feat(orders,analytics): add sales & channel aggregates endpoint (#1987)

Adds GET /analytics/sales: revenue, order count, AOV, median order
value, units sold, and cancelled count/value for a date range, plus a
7-day daily trend (revenue + order count), at headline level and
broken down per source connection with a revenue share and a
coverage-completeness signal (reusing #2083's
getEarliestOrderDateByConnection so a channel that can't possibly
cover the full requested range is identifiable in the response).

Built entirely on top of the #1985 order analytics read model
(order_records.placedAt/totalAmount/cancelledAt, order_line_items) -
one new pure aggregation function in the orders domain layer, two new
OrderRecordRepositoryPort methods (daily FILTER-clause aggregates,
PERCENTILE_CONT median), one new OrderLineItemRepositoryPort method
(units sold per connection), and one new IOrderRecordService method
composing them - entirely intra-context, no new cross-context edge.

Currency-mixing detection and gross/net tax-treatment normalization
are deliberately out of scope - tracked under #2049/ADR-040 (currency)
and a separate, not-yet-scoped tax-normalization effort respectively.
totalAmount is summed as-is; this is called out explicitly in code
comments so the omission reads as a scoping decision, not a gap.

Includes the implementation plan doc this PR follows.

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

* fix(orders,analytics): report cancelled count/value per channel too (#1987)

The issue's own follow-up comment asks for cancelled count/value
"headline and, if feasible, per channel". It's feasible at zero extra
cost: DailyOrderAggregateRow already carries cancelledCount/
cancelledValue per (day, connection), so this only sums data the
existing query already returns - no new query, no new repository
method.

Adds ChannelSalesAnalytics.cancelledCount/cancelledValue, threads them
through the aggregation function, the response DTO, and adds a test
asserting the per-channel totals sum back to the headline figure.

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

* fix(orders,analytics): wire sales aggregates to reportingTotalAmount now that #2049 has landed

This PR's own scope table deferred currency-mixing detection to
#2049/ADR-040, summing totalAmount as-is. #2049 shipped (PR #2050)
while this PR was still open, stamping order_records.reportingCurrency/
reportingTotalAmount - so the fix lands here rather than as a
follow-up issue.

- getDailyOrderAggregates / getMedianOrderValue: revenue, orderCount and
  medianOrderValue now sum/percentile reportingTotalAmount restricted to
  reportingCurrency IS NOT NULL - one comparable currency, never a naive
  cross-currency sum.
- The complementary unstamped slice (pre-#2049 history, or a stamp
  still in flight) is surfaced explicitly via new unconvertedCount/
  unconvertedValue fields (native totalAmount, informational, may
  itself mix currencies) rather than silently folded into revenue or
  silently dropped.
- New `currency` field (headline + per channel) reports which reporting
  currency revenue/AOV/median are expressed in; null when nothing in
  range is stamped yet.
- cancelledCount/cancelledValue deliberately left on native totalAmount,
  unchanged - a secondary figure, out of scope for this pass.
- Threaded through the pure aggregation function, the response DTOs,
  and every affected test fixture (repository, service, aggregation,
  controller specs).

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

* fix(orders,analytics): label the unconverted-currency evidence per channel

The by-channel table needs to show each market's own native-currency
total for orders not yet FX-stamped, not just a single potentially
mixed-currency number - the mockup this scope was designed against
(03b · Two currencies) shows per-channel figures split by their own
currency, with only the reporting-currency footer pooled.

unconvertedCount/unconvertedValue already existed (#2049/ADR-040
follow-up) but carried no currency label and could legitimately mix
currencies per the type's own doc comment. This is #1987's own scope,
not an FX-epic deliverable: order_records.currency is the pre-existing
native-currency column from #1985, untouched by the FX epic's
reportingCurrency/reportingTotalAmount stamp - labelling the
unconverted evidence is purely an aggregation-query addition.

- getDailyOrderAggregates: adds unconverted_currency, the single
  native currency shared by every unconverted, non-cancelled order
  this day/connection, NULL when that set mixes currencies.
- resolveUniformUnconvertedCurrency (aggregation layer): rolls the
  per-day label up to headline/channel, treating a day with zero
  unconverted orders as "nothing to report" rather than letting it
  poison the whole set to null.
- Threaded through DailyOrderAggregateRow, SalesAnalyticsHeadline,
  ChannelSalesAnalytics, the response DTOs, and every affected test
  fixture (repository, service, aggregation, controller specs).

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

* fix(orders,analytics): guard mixed-currency labels + UTC day buckets (#1987 review)

IMPORTANT 1: getDailyOrderAggregates labelled a (day, connection) bucket's
revenue with (array_agg(reportingCurrency))[1] — the first value happened
to sort first — even though reportingCurrency isn't guaranteed
single-valued within a bucket (an in-flight #2096 restatement can leave
two live at once). Guarded it with the same COUNT(DISTINCT ...) <= 1
pattern unconvertedCurrency already uses, and gave the domain-layer
pickCurrency the matching cross-row disagreement check
(resolveUniformReportingCurrency) rather than "first non-null wins".

IMPORTANT 2: date_trunc('day', placedAt) truncates at local midnight per
the Postgres session TimeZone GUC, since placedAt is timestamptz — on a
non-UTC server every bucket would land on the wrong calendar day and
silently mismatch enumerateDayKeys's UTC keys, zeroing every trend point
beneath a correct headline. Made the boundary explicit:
date_trunc('day', placedAt AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'.

SUGGESTIONS: medianOrderValue no longer flattens "no stamped order in
range" to the same 0 as a genuine zero median (now number | null, plumbed
through the DTO); documented the units-vs-orderCount scoping mismatch on
OrderLineItemRepositoryPort; added sales-analytics-aggregates.int-spec.ts
against Testcontainers Postgres to pin both IMPORTANT fixes against a real
server (the reviewer's own root-cause note: the mocked query builder could
never have caught either).

Also merges in the latest 1985-order-analytics-read-model (this PR's base
branch), which had picked up its own review fixes since this branch last
merged from it.

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

* fix(orders): make the UTC day-bucket int-spec actually guard the regression

Testcontainers Postgres boots with session TimeZone = UTC, so the existing
assertion passed identically with or without the AT TIME ZONE 'UTC' pair in
getDailyOrderAggregates — it documented intent but couldn't fail on a
regression (#2151 review, SUGGESTION).

Force the session TimeZone to Europe/Warsaw for this one read (and restore it
afterward), so a regression to a bare date_trunc('day', placedAt) actually
flips the bucket to the following day and fails the test. SET TIME ZONE is
session-scoped; dataSource.query and the repository read run back-to-back
with nothing else contending for the pool, so the same just-released client
is reused.

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

* fix(orders,analytics): address #2151 re-review — currency-era scoping, null flattening, units population

- IMPORTANT 1: getUnitsSoldByConnection now splits into unitsSold/
  unconvertedUnitsSold on the SAME reportingCurrency = current-era-stamped
  population orderCount/revenue use, instead of summing every non-cancelled
  line regardless of stamp state.
- IMPORTANT 2: averageOrderValue and revenueShare now report null (not 0)
  when there is nothing to report, matching medianOrderValue's existing
  null-vs-zero distinction.
- Notes (ported from #2172's getTopProductRanking fix): getDailyOrderAggregates
  and getMedianOrderValue now scope orderCount/revenue/median to
  reportingCurrency = currentReportingCurrency (resolved once per read via
  IReportingCurrencySettingsService.resolve()) instead of a bare IS NOT NULL,
  so a reporting-currency setting change folds prior-era stamps into the
  unconverted bucket rather than silently mixing two currencies into one sum.
- Suggestion 3: GET /analytics/sales now rejects a range wider than 400 days.
- Suggestion 4: unconverted_currency's uniformity guard now also fails when
  the unconverted set contains a row with no recorded native currency.

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

* feat(orders,analytics): top-products endpoint with inline per-channel split (#1988) (#2172)

* feat(orders,analytics): top-products endpoint with inline per-channel split (#1988)

Adds GET /analytics/top-products - products ranked by revenue or units for
a date range, each row carrying its own per-channel breakdown, catalog
metadata, and a listing-coverage-gap flag. Stacked on #1987's currency-
correctness pattern (FILTER (WHERE reportingCurrency IS NOT NULL) / SUM
via each order's own implicit FX multiplier), never silently summing
across currencies and always disclosing what's unstamped/cancelled.

- OrderLineItemRepositoryPort +getTopProductRanking, +getProductChannelBreakdown
- buildTopProducts pure aggregation + IOrderRecordService.getTopProducts
- TopProductsController/DTOs + apps/api-layer TopProductsService composing
  orders + products + listings (coverage-gap flag, O(connections) fan-out,
  degrades gracefully on failure - mirrors NeedsAttentionService)
- Fixes a pre-existing gap: order_line_items was missing from the
  integration-test harness's tablesToTruncate list (no DB FK to cascade
  from order_records), which would leak rows between test files

Built following docs/plans/implementation-plan-top-products-analytics.md
(pre-implement gate: READY, included in this PR).

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

* fix(orders,analytics): scope top-products revenue to the current reporting currency (#1988)

getTopProductRanking/getProductChannelBreakdown summed every stamped order's
reportingTotalAmount regardless of which reporting-currency era it was
pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick.
Since a settings change is forward-only (older orders keep their original
stamp), switching the reporting currency mixed two real currencies into one
number under a wrong label instead of surfacing the older era as unconverted
evidence like an unstamped order.

OrderRecordService now resolves the current reporting currency and both
queries filter revenue to reportingCurrency = current, folding any other
era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped
orders.

(cherry picked from commit 5a39290)
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* feat(orders,analytics): disclose the native currency behind unconverted top-products evidence (#1988)

getTopProductRanking already folded a prior reporting-currency era (or a
never-stamped order) into unconvertedRevenue/unconvertedOrderCount, but gave
the frontend no way to label that figure — unlike the #1987 by-channel read,
which already carries unconvertedCurrency for the identical situation.

Adds unconvertedCurrency end to end (repository SQL, ProductRankingRow,
TopProductView, TopProductRowDto): the one native currency shared by every
order contributing to unconvertedRevenue, or null when that set mixes
currencies, mirroring DailyOrderAggregateRow's existing rule.

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

* fix(orders,analytics,products): address #2172 review findings on top-products ranking

Two IMPORTANT correctness issues and three SUGGESTIONS from the #2172 tech
review, all still open on this branch:

- IMPORTANT 1: ORDER BY revenue/units had no tiebreaker, so pagination over
  a non-unique sort was non-deterministic in Postgres (ties could repeat on
  one page and be skipped on the next). Add addOrderBy('product_id', 'ASC').
- IMPORTANT 2: a stamped order with totalAmount = 0 (fully discounted/free)
  silently vanished from both revenue and unconvertedRevenue, since the FX
  multiplier (reportingTotalAmount / totalAmount) is NULL via
  NULLIF(totalAmount, 0). It now folds into the unconverted bucket instead,
  same as a never-stamped order, in both getTopProductRanking and
  getProductChannelBreakdown.
- SUGGESTION 3: documented, in the endpoint's @apioperation description,
  that ranking by revenue is blind to unconverted revenue for a product
  whose orders are all unstamped.
- SUGGESTION 4: resolveCoverageGaps fired up to `limit` concurrent
  getVariantsByProductId calls. Added a batch getVariantsByProductIds
  (ProductVariantRepositoryPort -> IProductsService) so the page's variant
  ids resolve in one query instead of one per product.
- SUGGESTION 5: a coverage-gap enrichment failure degraded every row to
  missingFromConnectionIds: [], indistinguishable from "listed everywhere".
  Added TopProductsResponseDto.coverageGapAvailable so the FE can tell the
  two apart.

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

* fix(orders,analytics): label unconvertedCurrency per channel on top-products (#2172 review)

The ranking row's unconvertedRevenue gained a currency label in an earlier
fix, but the per-channel breakdown row didn't, so
ProductChannelBreakdownDto.unconvertedRevenue stayed a bare number with no
unit. Inheriting the parent's label isn't sound either: the parent goes
null on a mixed set, but an individual channel's own subset is routinely
single-currency even then — a channel is strictly more labelable than the
product as a whole, never less.

Lifts the same MAX(currency) FILTER (...) / COUNT(DISTINCT ...) <= 1 shape
getTopProductRanking already uses, computed per (product, connection) in
getProductChannelBreakdown, threaded through ProductChannelBreakdownRow and
ProductChannelBreakdownDto.

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

* fix(orders,analytics): address remaining #2172 review findings

- applyTopProductsScope now requires rec."totalAmount" IS NOT NULL,
  matching applySalesAnalyticsScope; the doc comment no longer claims
  byte-for-byte alignment it didn't hold (IMPORTANT 1).
- unconvertedCurrency's label guard now also requires zero NULL
  rec."currency" rows in the filtered set, since COUNT(DISTINCT ...)
  alone ignores NULLs and could mislabel a {NULL, 'PLN'} mix as 'PLN'
  (SUGGESTION 3, same fix needed on both getTopProductRanking and
  getProductChannelBreakdown).
- TopProductRowDto.revenue now documents that it is LINE revenue, not
  a per-product slice of order revenue, and that ranking is blind to
  unconvertedRevenue (IMPORTANT 2).

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

* fix(orders): parenthesize unconvertedOrZeroTotal in top-products currency guard

The unconverted_currency CASE guard concatenated the OR-joined
unconvertedOrZeroTotal predicate with `AND rec."currency" IS NULL`
without parentheses. Since SQL AND binds tighter than OR, this parsed
as `A OR (B AND C)` instead of the intended `(A OR B) AND C`, so the
"no NULL currency in the unconverted set" guard was satisfied whenever
the reporting-currency mismatch term alone was true — the common case
for any unstamped row — causing a single-currency channel/product to
mislabel unconvertedCurrency as null. Same bug in both
getTopProductRanking and getProductChannelBreakdown; fixed by wrapping
the shared predicate in parens at both call sites.

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

* feat(analytics): #1986 route shell — trust header, date-range toolbar, page (#2098)

* docs(analytics): implementation plan for #1986 route shell

Plan for the /analytics route shell (date-range control, trust header),
branched off the current #1985 order-analytics-read-model state per the
user's request, since two of its decisions (coverage-window row,
degradation-banner rule) explicitly track #1985 and its follow-up #2083.

Ref #1986

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

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header (#2115)

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner

Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md:
- New /analytics route (PageLayout, Operations nav item)
- Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) +
  From/To fields with a draft-buffered Apply action (Decision 1)
- Trust header (per-connection freshness + "Connected since" + status,
  Decision 3 — real "data from" coverage deferred to #2083/#1985) with a
  click-triggered info popover (touch-safe, unlike a hover-only Tooltip)
- Degradation banner on stalled/disconnected connections — status-only
  for v1 (Decision 4); the mockup's range-gated "sold in this selected
  range" refinement is deferred until #1990 makes that fact honest rather
  than an approximation
- Fresh-instance / still-arriving / loading / error states
- New analyticsTrust API-client namespace consuming the already-shipped
  GET /analytics/trust (#1982)

Post-review fixes (tech-review pass):
- Order-date disclaimer is now a static span (chip+dagger, matches the
  design mockup verbatim) instead of the interactive Chip primitive,
  which rendered a toggle button with no effect
- Banner timestamp uses the shared formatDateTime helper instead of a
  hand-rolled toLocaleString(), matching AnalyticsTrustHeader
- Added a page-level loading-state test

Ref #1986

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

* fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup

- Namespace .gap-mark/.info-popover-trigger to .analytics-* and document
  .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row
  Heights, per the tech-lead review's documentation-obligation findings.
- Drop code-comment claims of verbatim conformance to
  docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018).
- Replace inline style on analytics-trust-header.tsx with a real CSS class;
  drop the phantom trailing grid column.
- Remove the dead toUtcRangeInstants export (no consumer yet).

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

* fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy

- Fix two failing tests: the 90d preset test asserted an off-by-one date
  (implementation was correct), and the disclaimer-chip test failed to
  match the tooltip-split text node. Also fixes a third, previously
  undetected failure in the degradation-banner "renders nothing" test,
  which asserted an empty DOM even though renderWithProviders always
  mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
  operator-facing aria-label/tooltip copy; move the rationale into a
  code comment and replace the bare `title` with a keyboard-reachable
  Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
  "has not ingested since" copy, both of which asserted data currency
  from `lastPollAt` — a pipe-liveness signal, not proof any order data
  arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
  date-range.lib.ts (the file only does local-time formatting).

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

* fix(web,analytics): render the real earliestOrderDate now that #2083 shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

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

---------

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

* fix(web): bump the lazy-route contract count to 52 for /settings/mcp-tokens

An earlier merge (feat(mcp): Resource-Server auth via user-issued Personal
Access Tokens, #1486/#1912) added the /settings/mcp-tokens page as a lazy
route, but the parameterized route-lazy contract test's expected count
was never bumped, failing CI on this branch with "expected 52 to be 51".

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

* fix(analytics): address #2098 tech review + trust-header single-line layout

- Sync docs/plans/implementation-plan-analytics-page-shell.md with the
  now-resolved Decision 3 (real earliestOrderDate coverage row) and
  Decision 4 (hasSalesInRange dropped), and note the Reusable
  Components divergences.
- Replace the analytics-date-range-toolbar's Tooltip-based "Order
  date" caveat with a Popover on a real <button>, matching
  AnalyticsTrustHeader's pattern — Radix Tooltip ignores
  pointerType === 'touch', making the old trigger unreachable on
  mobile.
- Trust header renders a single-line "data from X · synced Y" fact
  string with a per-channel colored dot, replacing the prior two-column
  label/value layout; adds TimeDisplay's 'time' format and
  formatAbsoluteTime helper it depends on.

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

* fix(analytics): drop dangling "data from" prefix on the no-orders-yet fact

The "data from" prefix was rendered unconditionally, so a connection
with no earliestOrderDate read "data from no orders yet" instead of
just "no orders yet".

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

* fix(analytics): address remaining #2098 review findings

- date-range.lib.ts: add toUtcRangeInstants — the single conversion
  point future /analytics/* consumers must use to turn this toolbar's
  local-day, inclusive from/to into the backend's UTC, to-exclusive
  range contract (SalesAnalyticsQueryDto.to). Fixes the inclusive/
  exclusive and local/UTC mismatches flagged in the /pr-review pass,
  pinned with tests.
- ingestion-trust.lib.ts: rename shouldShowDegradationBanner to
  selectDegradedConnections (it returns the degraded subset, not a
  boolean) and type DEGRADED_STATUSES as Set<ConnectionIngestionStatus>
  with a comment on why 'unknown' is deliberately excluded.
- analytics-page.tsx: adopt PageLayout instead of hand-rolled
  page-header markup; document the frozen `today` ref decision.
- Sync the implementation plan doc with all of the above.

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

* feat(web,analytics): /analytics needs-attention section (#2120)

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner

Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md:
- New /analytics route (PageLayout, Operations nav item)
- Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) +
  From/To fields with a draft-buffered Apply action (Decision 1)
- Trust header (per-connection freshness + "Connected since" + status,
  Decision 3 — real "data from" coverage deferred to #2083/#1985) with a
  click-triggered info popover (touch-safe, unlike a hover-only Tooltip)
- Degradation banner on stalled/disconnected connections — status-only
  for v1 (Decision 4); the mockup's range-gated "sold in this selected
  range" refinement is deferred until #1990 makes that fact honest rather
  than an approximation
- Fresh-instance / still-arriving / loading / error states
- New analyticsTrust API-client namespace consuming the already-shipped
  GET /analytics/trust (#1982)

Post-review fixes (tech-review pass):
- Order-date disclaimer is now a static span (chip+dagger, matches the
  design mockup verbatim) instead of the interactive Chip primitive,
  which rendered a toggle button with no effect
- Banner timestamp uses the shared formatDateTime helper instead of a
  hand-rolled toLocaleString(), matching AnalyticsTrustHeader
- Added a page-level loading-state test

Ref #1986

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

* fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup

- Namespace .gap-mark/.info-popover-trigger to .analytics-* and document
  .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row
  Heights, per the tech-lead review's documentation-obligation findings.
- Drop code-comment claims of verbatim conformance to
  docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018).
- Replace inline style on analytics-trust-header.tsx with a real CSS class;
  drop the phantom trailing grid column.
- Remove the dead toUtcRangeInstants export (no consumer yet).

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

* fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy

- Fix two failing tests: the 90d preset test asserted an off-by-one date
  (implementation was correct), and the disclaimer-chip test failed to
  match the tooltip-split text node. Also fixes a third, previously
  undetected failure in the degradation-banner "renders nothing" test,
  which asserted an empty DOM even though renderWithProviders always
  mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
  operator-facing aria-label/tooltip copy; move the rationale into a
  code comment and replace the bare `title` with a keyboard-reachable
  Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
  "has not ingested since" copy, both of which asserted data currency
  from `lastPollAt` — a pipe-liveness signal, not proof any order data
  arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
  date-range.lib.ts (the file only does local-time formatting).

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

* fix(web,analytics): render the real earliestOrderDate now that #2083 shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

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

* feat(web,analytics): sales KPI strip + by-channel table (#1990)

Adds GET /analytics/sales client, view-model helpers, and the two FE
sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order
value w/ median, Units, Cancellations, Returns & refunds) and a
by-channel DataTable, mounted into the #1986 route shell.

Currency-aware per #1987/#2049/ADR-040: every money figure carries its
currency (headline.reportingCurrency), and a channel's revenueBasis
('reporting' | 'native' | 'unavailable') drives whether its revenue/
share render as plain values, a same-currency-but-incomparable caveat,
or an explicit empty value — never a blended or falsely-comparable
number. taxTreatment 'mixed' surfaces an inline chip so gross/net
incomparability is stated, not implied. A channel whose earliest order
postdates the range start renders a "Partial history" flag.

Fixes an exclusive-end date bug found in a prior implementation
attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but
the endpoint treats `to` as exclusive — toExclusiveEndInstant converts
it so the selected range's last day isn't silently dropped.

Also fixes a pre-existing test race in orders-list-page.test.tsx: a
synchronous assertion on empty-state text that depends on an async
query, following an await on a chip that mounts synchronously from a
URL param — now awaited with findByText.

Closes #1990

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

* fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests

CI's `tsc -b` (project-references build) caught what a plain `tsc
--noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new
Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which
doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>`
return type. Pin the generic on the never-resolving Promise, matching
the existing analytics-trust test precedent.

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

* fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract

The frontend types were drafted ahead of the backend and assumed a shape
it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency
per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring
(reportingTotalAmount stamp + unconvertedCurrency labelling) has been
merged in, rewrite the frontend to match it exactly: one nullable
system-wide currency, unconvertedCount/Value/Currency per channel, and
revenueShare always a number.

- Cancellations KPI now leads with the rate (%), value/count as qualifiers.
- By-channel table: a channel with no FX-stamped revenue yet falls back to
  its own unconverted-currency evidence instead of showing an empty cell,
  flagged with an "Awaiting FX stamp" chip.
- Total rows: one reporting-currency total (real KPI aggregate) plus one
  informational unconverted-currency subtotal per distinct native currency
  — only emitted when more than one channel contributes, so a lone
  channel never gets a redundant duplicate total.
- Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now
  count every placed order (stamped + unconverted), not just the stamped
  subset.
- Share and Trend columns reordered so Share sits immediately before Trend
  (previously Share was misplaced next to Revenue).
- Fixed a CSS specificity bug where the Phase-6 dashboard-triage
  `.status-strip` rule silently won over `.status-strip--analytics` at
  >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2.

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

* docs(analytics): implementation plan for /analytics needs-attention section

Plans issue #1989 — three actionable categories (coverage gaps, stock at
risk, failed-sync value) consuming the already-shipped GET
/analytics/needs-attention (#1983), mounted into the #1986 shell. No
backend changes; resolves link targets, the mixedCurrency interim
(tracked against #2049), and the ambiguous multi-connection copy case.

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

* feat(web,analytics): /analytics needs-attention section (#1989)

Renders the three needs-attention categories — coverage gaps, stock at
risk, value stuck in failed syncs — mounted into the #1986 shell.
Consumes the already-shipped GET /analytics/needs-attention (#1983)
as-is; no backend changes.

Either the open rows render or a single all-clear line does, never
both, per the design mockup's rule. Each open row deep-links into the
flow that resolves it: the unified publish wizard, the product detail
page, or the orders list filtered to the needs_attention health
bucket. Ambiguous multi-connection cases fall back to a
connection-agnostic headline; the failed-sync total renders
currency-neutral since the DTO carries no currency field in either
the mixed or non-mixed case (interim pending #2049).

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

* fix(web,analytics): match needs-attention section to the #2003 mockup

The plan (implementation-plan-analytics-needs-attention.md) required a
client-side "checked HH:MM" timestamp in the panel header and a
neutral-tone Clear badge, mirroring frame 02 of the design mockup
(docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018
branch). Both were dropped in the original implementation.

Adds the checked-at timestamp (TimeDisplay driven by the query's own
dataUpdatedAt, since the DTO carries no such field) and switches the
all-clear badge from success to neutral, per spec.

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

* fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture

Rebase fallout from the earliestOrderDate swap (#2083): the
#1989-cherry-picked "keep the trust header rendered when needs-attention
fails" test fixture predates that field and failed type-check.

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

* fix(analytics): address #2120 tech review — sample-vs-total headline defect

- BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a
  connection when the preview sample IS the total (items.length ===
  totalCount); otherwise fall through to the connection-agnostic
  headline, so a headline never asserts something only a 20-item
  sample verified.
- "Publish now" sub now discloses when it only seeds the sampled
  variants ("showing the first N of M").
- Fix the "1 variant have a listing gap" grammar bug to a verb-free
  form, updating the test that had locked it in.
- Thread a BCP 47 locale into deriveFailedSyncHeadline instead of
  hardcoding toLocaleString('en-US').
- Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds
  from the same item list instead of two independently sliced arrays.
- Render AnalyticsNeedsAttention regardless of order-ingestion status —
  coverage gaps and stock-at-risk are listing facts, not order facts.
- Render .attention-list as <ul>/<li> for list semantics.

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

* fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide

- toExclusiveEndInstant now anchors on UTC midnight instead of local
  midnight, matching the controller's UTC-parsed `from` (was silently
  dropping/adding hours off UTC).
- Sparkline aria-labels derive from the actual selected range instead
  of a hardcoded "last 7 days".
- Register the analytics KPI card's 152px/3-col geometry as a
  documented carve-out in the style guide (Density table + parity
  matrix), per the "never introduce an undocumented row height" rule.
- Fix "Data order" planned-tag typo -> "Planned"; section-infotip
  font-size to the rem token equivalent.

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

* fix(web): remove unused vi import breaking tsc build

CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi`
import in sales-analytics.api.test.ts, blocking `pnpm build`.

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

* fix(analytics): emit currency total for single-contributing-channel groups

groupChannelTotalsByCurrency skipped a currency's Total row whenever
only one channel contributed to it, so a deployment with exactly one
connection per currency (e.g. one EUR shop) silently lost that row.
No spec basis for the threshold — the by-channel currency total
should render for every distinct currency present, regardless of how
many channels contribute.

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

* fix(analytics): address #2120 re-review — deep-link connection resolution

Reuses deriveCoverageHeadline's own connection resolution for the coverage
deep link's connectionId param instead of re-deriving it with a weaker
predicate, so the bulk-wizard link can never name a channel the headline
declined to name. Also derives the all-clear checkedCount from the
evaluated categories rather than a hardcoded literal.

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

* fix(web): remove unused vi import in sales-analytics.api.test.ts

Pre-existing lint error surfaced while validating the 1986 merge —
vi was imported but never used in this file.

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

* fix(analytics): stop rendering a currency-neutral total on the failed-sync row

`deriveFailedSyncHeadline` formatted `totalValue` with no currency
symbol ("6,120.64 of orders never reached a destination"), which reads
as a real monetary figure to an operator even though the DTO carries
no currency at all — the same misrepresentation risk the mixedCurrency
branch already guarded against, just less obviously so. Both branches
now render the same count-only shape; `totalValue` stays on the wire
for future consumers, this headline just stops reading it.

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

* fix(analytics): stop rendering a duplicate/colliding unconverted Total row

groupChannelTotalsByCurrency emitted a `Total · {currency} (unconverted)`
row per distinct unconvertedCurrency found across channels, with no
regard for whether that currency string collided with the real
reporting-currency Total row's label — a domestic-currency channel
simply awaiting its first FX-stamp pass produced a second, same-labelled
"Total · PLN" row computed from unrelated fields, reading as a
contradiction rather than two distinct facts.

Drop that row entirely. countUnconvertedOrders reports the
currency-agnostic total count as a single footnote sentence under the
table instead ("N orders not yet converted to the reporting currency —
excluded from the figures above"), never a competing Total row and
never a bare currency-neutral amount.

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

* fix(analytics): stop the coverage deep-link from naming a channel the headline declined to

deriveCoverageHeadline's connection-naming rule requires items.length ===
totalCount, every item missing from exactly one connection, and one
distinct id. The "Publish now" deep link recomputed its own, weaker
predicate (only the last condition), so a partial-but-uniform sample
could pin a connectionId into the wizard link while the headline right
next to it correctly fell back to the connection-agnostic copy —
sending the operator into a wizard pre-scoped to a channel the row
never actually asserted (#2120 re-review, IMPORTANT).

deriveCoverageHeadline now returns connectionId (string | null)
alongside the copy, computed by the same predicate; the component reads
it instead of re-deriving one. Added a regression test pinning the
exact partial-sample/uniform-connection case from the review.

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

* feat(web,analytics): top products table with per-channel breakdown (#1991) (#2191)

* feat(web,analytics): top products table with per-channel breakdown (#1991)

Adds the /analytics top-products table: one row per product, per-channel
units split, revenue/units sort toggle, and a Publish affordance for
channels the product isn't listed on. Fixes a labeling gap found while
manually testing against seeded data: a channel absent from the sales
breakdown was always rendered "Not listed", even when the product was
genuinely listed there and simply had no sale in the selected date range —
now only a channel actually missing from `missingFromConnectionIds` gets
the "Not listed" + Publish treatment; a listed-but-quiet channel renders
the same real, full-weight `0` a channel with sales would.

Closes #1991

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

* fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)

getTopProductRanking/getProductChannelBreakdown summed every stamped order's
reportingTotalAmount regardless of which reporting-currency era it was
pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick.
Since a settings change is forward-only (older orders keep their original
stamp), switching the reporting currency mixed two real currencies into one
number under a wrong label instead of surfacing the older era as unconverted
evidence like an unstamped order.

OrderRecordService now resolves the current reporting currency and both
queries filter revenue to reportingCurrency = current, folding any other
era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped
orders.

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

* Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)"

This reverts commit 5a39290.

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

* fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991)

A product whose only orders in range were stamped under a PREVIOUS
reporting-currency setting (or never stamped at all) rendered a bare
"No FX-stamped order" empty value, even though the backend already exposed
the native-currency figure as unconvertedRevenue/unconvertedCurrency
(#1988).

The Revenue column now falls back to that evidence when there is no
current-era stamp, marked informational via a title tooltip — mirroring
ChannelSalesTable's identical fallback for the #1987 by-channel read.

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

* fix(web): remove unused vi import breaking tsc build

CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi`
import in sales-analytics.api.test.ts, blocking `pnpm build`.

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

* Revert "fix(web): remove unused vi import breaking tsc build"

This reverts commit b915a03.

* fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug

- Units column now reads row.units (server-ranked figure) instead of
  re-summing row.channels[], which could silently disagree with the
  sort order the header arrow claims.
- The Publish action is gated on listings:write via useWriteAccess +
  ReadOnlyLock: hidden for an unauthorized non-demo session, rendered
  disabled with the read-only tooltip for a demo viewer.
- Swapped the Chip (aria-pressed toggle) for a real Link styled as a
  button, so the one-shot publish navigation carries link semantics
  (middle-click, open-in-new-tab) instead of misrepresenting itself as
  a permanently-unpressed toggle to assistive tech.
- @media (hover: none) now stacks the "Not listed" label and the
  Publish action, both visible, instead of hiding the label on touch —
  the #1991 AC's label-vs-action distinction was desktop-only before.
- Added the analytics feature slug to both no-restricted-imports
  pattern groups in .eslintrc.js.

Closes review findings on #2191

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

* fix(orders,analytics): SQL precedence bug + surface coverageGapAvailable/unresolvedProductCount (#2172/#2191 review)

Root cause of the failing top-products-ranking int-spec: `unconvertedOrZeroTotal`
was a bare, unparenthesized `X OR Y` string spliced into
`${unconvertedOrZeroTotal} AND rec."currency" IS NULL`. SQL's AND-before-OR
precedence turned that into `X OR (Y AND Z)` instead of the intended
`(X OR Y) AND Z` — since X (reportingCurrency mismatch) was true for nearly
every unstamped row, the guard fired unconditionally and `unconverted_currency`
fell to NULL far more often than the data warranted. Fixed by parenthesizing
the constant at its definition (both getTopProductRanking and
getProductChannelBreakdown); pinned by the existing int-spec against real
Postgres (a mocked unit spec cannot observe operator precedence) and recorded
in docs/lessons.md.

Also addresses the two still-open review IMPORTANT findings on the FE table:
- `coverageGapAvailable: false` now suppresses "Not listed"/Publish on every
  channel cell (the enrichment failure makes missingFromConnectionIds
  unreliable for the whole response), rendering the real 0 instead, with a
  footnote explaining the check is unavailable.
- `unresolvedProductCount > 0` is now disclosed via a footnote rather than
  silently absorbed.

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

* feat(analytics): period-over-period delta on the sales KPI strip

Adds a "vs previous period" delta to Orders, Order value, Units and
Cancellation rate on the /analytics KPI strip — a second GET
/analytics/sales call over the immediately-preceding period of the
same length, refused outright (GapMark) unless the entire previous
window is covered by ingested order history (per-connection
earliest-order-date, #2083).

Matches the design mockup's delta anatomy (docs/plans/mockups/
analytics-ledger-2003.html): an aria-hidden ↑/↓/→ glyph, a sr-only
spoken sentence, and count/amount deltas rendered as a relative "%"
while rate deltas (cancellation rate) render as an absolute "pp" —
a rate moves in points, not percent.

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

* fix(web,analytics): mount top-products table and reveal Publish on hover

ProductSalesTable (#1991) was fully built end-to-end but never mounted
on AnalyticsPage, so the top-products section never rendered. Also add
the .cell-not-listed hover/focus CSS the component's own doc comment
already described but that was never written — the "Not listed" label
now swaps for a Publish action on hover/focus (with a light
warning-yellow glow), staying permanently visible on touch pointers.

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

---------

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

* fix(web,analytics): make GapMark's caveat reachable without a mouse

A native `title` on a non-interactive, non-focusable `<span>` whose
entire content is a dagger glyph never surfaces for a keyboard user,
and a screen reader has no accessible name to announce beyond "dagger".
Add role="img" + aria-label={title} alongside the existing title so the
caveat is announced regardless of input modality (#2120 review).

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

---------

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

---------

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

* docs(orders): explain why getTopProducts reads are sequential, not parallel

getProductChannelBreakdown is scoped to the current page's productIds,
which only exist once getTopProductRanking has returned — unlike the
three independent Promise.all reads in getSalesAndChannelAnalytics
above it. Note the distinction so a reader doesn't mistake the missing
parallelisation for an oversight (#2172 review, SUGGESTION 2).

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

---------

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

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Signed-off-by: jakubret
Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
jakubretajczykBD added a commit that referenced this pull request Aug 24, 2026
…s reporting (#2440) (#2442)

* feat(orders,analytics): add sales & channel aggregates endpoint (#1987)

Adds GET /analytics/sales: revenue, order count, AOV, median order
value, units sold, and cancelled count/value for a date range, plus a
7-day daily trend (revenue + order count), at headline level and
broken down per source connection with a revenue share and a
coverage-completeness signal (reusing #2083's
getEarliestOrderDateByConnection so a channel that can't possibly
cover the full requested range is identifiable in the response).

Built entirely on top of the #1985 order analytics read model
(order_records.placedAt/totalAmount/cancelledAt, order_line_items) -
one new pure aggregation function in the orders domain layer, two new
OrderRecordRepositoryPort methods (daily FILTER-clause aggregates,
PERCENTILE_CONT median), one new OrderLineItemRepositoryPort method
(units sold per connection), and one new IOrderRecordService method
composing them - entirely intra-context, no new cross-context edge.

Currency-mixing detection and gross/net tax-treatment normalization
are deliberately out of scope - tracked under #2049/ADR-040 (currency)
and a separate, not-yet-scoped tax-normalization effort respectively.
totalAmount is summed as-is; this is called out explicitly in code
comments so the omission reads as a scoping decision, not a gap.

Includes the implementation plan doc this PR follows.

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

* fix(orders,analytics): report cancelled count/value per channel too (#1987)

The issue's own follow-up comment asks for cancelled count/value
"headline and, if feasible, per channel". It's feasible at zero extra
cost: DailyOrderAggregateRow already carries cancelledCount/
cancelledValue per (day, connection), so this only sums data the
existing query already returns - no new query, no new repository
method.

Adds ChannelSalesAnalytics.cancelledCount/cancelledValue, threads them
through the aggregation function, the response DTO, and adds a test
asserting the per-channel totals sum back to the headline figure.

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

* docs(analytics): implementation plan for #1986 route shell

Plan for the /analytics route shell (date-range control, trust header),
branched off the current #1985 order-analytics-read-model state per the
user's request, since two of its decisions (coverage-window row,
degradation-banner rule) explicitly track #1985 and its follow-up #2083.

Ref #1986

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

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner

Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md:
- New /analytics route (PageLayout, Operations nav item)
- Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) +
  From/To fields with a draft-buffered Apply action (Decision 1)
- Trust header (per-connection freshness + "Connected since" + status,
  Decision 3 — real "data from" coverage deferred to #2083/#1985) with a
  click-triggered info popover (touch-safe, unlike a hover-only Tooltip)
- Degradation banner on stalled/disconnected connections — status-only
  for v1 (Decision 4); the mockup's range-gated "sold in this selected
  range" refinement is deferred until #1990 makes that fact honest rather
  than an approximation
- Fresh-instance / still-arriving / loading / error states
- New analyticsTrust API-client namespace consuming the already-shipped
  GET /analytics/trust (#1982)

Post-review fixes (tech-review pass):
- Order-date disclaimer is now a static span (chip+dagger, matches the
  design mockup verbatim) instead of the interactive Chip primitive,
  which rendered a toggle button with no effect
- Banner timestamp uses the shared formatDateTime helper instead of a
  hand-rolled toLocaleString(), matching AnalyticsTrustHeader
- Added a page-level loading-state test

Ref #1986

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

* fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup

- Namespace .gap-mark/.info-popover-trigger to .analytics-* and document
  .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row
  Heights, per the tech-lead review's documentation-obligation findings.
- Drop code-comment claims of verbatim conformance to
  docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018).
- Replace inline style on analytics-trust-header.tsx with a real CSS class;
  drop the phantom trailing grid column.
- Remove the dead toUtcRangeInstants export (no consumer yet).

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

* fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy

- Fix two failing tests: the 90d preset test asserted an off-by-one date
  (implementation was correct), and the disclaimer-chip test failed to
  match the tooltip-split text node. Also fixes a third, previously
  undetected failure in the degradation-banner "renders nothing" test,
  which asserted an empty DOM even though renderWithProviders always
  mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
  operator-facing aria-label/tooltip copy; move the rationale into a
  code comment and replace the bare `title` with a keyboard-reachable
  Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
  "has not ingested since" copy, both of which asserted data currency
  from `lastPollAt` — a pipe-liveness signal, not proof any order data
  arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
  date-range.lib.ts (the file only does local-time formatting).

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

* fix(web,analytics): render the real earliestOrderDate now that #2083 shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

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

* fix(orders,analytics): wire sales aggregates to reportingTotalAmount now that #2049 has landed

This PR's own scope table deferred currency-mixing detection to
#2049/ADR-040, summing totalAmount as-is. #2049 shipped (PR #2050)
while this PR was still open, stamping order_records.reportingCurrency/
reportingTotalAmount - so the fix lands here rather than as a
follow-up issue.

- getDailyOrderAggregates / getMedianOrderValue: revenue, orderCount and
  medianOrderValue now sum/percentile reportingTotalAmount restricted to
  reportingCurrency IS NOT NULL - one comparable currency, never a naive
  cross-currency sum.
- The complementary unstamped slice (pre-#2049 history, or a stamp
  still in flight) is surfaced explicitly via new unconvertedCount/
  unconvertedValue fields (native totalAmount, informational, may
  itself mix currencies) rather than silently folded into revenue or
  silently dropped.
- New `currency` field (headline + per channel) reports which reporting
  currency revenue/AOV/median are expressed in; null when nothing in
  range is stamped yet.
- cancelledCount/cancelledValue deliberately left on native totalAmount,
  unchanged - a secondary figure, out of scope for this pass.
- Threaded through the pure aggregation function, the response DTOs,
  and every affected test fixture (repository, service, aggregation,
  controller specs).

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

* feat(web,analytics): sales KPI strip + by-channel table (#1990)

Adds GET /analytics/sales client, view-model helpers, and the two FE
sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order
value w/ median, Units, Cancellations, Returns & refunds) and a
by-channel DataTable, mounted into the #1986 route shell.

Currency-aware per #1987/#2049/ADR-040: every money figure carries its
currency (headline.reportingCurrency), and a channel's revenueBasis
('reporting' | 'native' | 'unavailable') drives whether its revenue/
share render as plain values, a same-currency-but-incomparable caveat,
or an explicit empty value — never a blended or falsely-comparable
number. taxTreatment 'mixed' surfaces an inline chip so gross/net
incomparability is stated, not implied. A channel whose earliest order
postdates the range start renders a "Partial history" flag.

Fixes an exclusive-end date bug found in a prior implementation
attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but
the endpoint treats `to` as exclusive — toExclusiveEndInstant converts
it so the selected range's last day isn't silently dropped.

Also fixes a pre-existing test race in orders-list-page.test.tsx: a
synchronous assertion on empty-state text that depends on an async
query, following an await on a chip that mounts synchronously from a
URL param — now awaited with findByText.

Closes #1990

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

* feat(orders,analytics): top-products endpoint with inline per-channel split (#1988)

Adds GET /analytics/top-products - products ranked by revenue or units for
a date range, each row carrying its own per-channel breakdown, catalog
metadata, and a listing-coverage-gap flag. Stacked on #1987's currency-
correctness pattern (FILTER (WHERE reportingCurrency IS NOT NULL) / SUM
via each order's own implicit FX multiplier), never silently summing
across currencies and always disclosing what's unstamped/cancelled.

- OrderLineItemRepositoryPort +getTopProductRanking, +getProductChannelBreakdown
- buildTopProducts pure aggregation + IOrderRecordService.getTopProducts
- TopProductsController/DTOs + apps/api-layer TopProductsService composing
  orders + products + listings (coverage-gap flag, O(connections) fan-out,
  degrades gracefully on failure - mirrors NeedsAttentionService)
- Fixes a pre-existing gap: order_line_items was missing from the
  integration-test harness's tablesToTruncate list (no DB FK to cascade
  from order_records), which would leak rows between test files

Built following docs/plans/implementation-plan-top-products-analytics.md
(pre-implement gate: READY, included in this PR).

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

* fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests

CI's `tsc -b` (project-references build) caught what a plain `tsc
--noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new
Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which
doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>`
return type. Pin the generic on the never-resolving Promise, matching
the existing analytics-trust test precedent.

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

* feat(web,analytics): top products table with per-channel breakdown (#1991)

Adds the /analytics top-products table: one row per product, per-channel
units split, revenue/units sort toggle, and a Publish affordance for
channels the product isn't listed on. Fixes a labeling gap found while
manually testing against seeded data: a channel absent from the sales
breakdown was always rendered "Not listed", even when the product was
genuinely listed there and simply had no sale in the selected date range —
now only a channel actually missing from `missingFromConnectionIds` gets
the "Not listed" + Publish treatment; a listed-but-quiet channel renders
the same real, full-weight `0` a channel with sales would.

Closes #1991

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

* fix(orders,analytics): label the unconverted-currency evidence per channel

The by-channel table needs to show each market's own native-currency
total for orders not yet FX-stamped, not just a single potentially
mixed-currency number - the mockup this scope was designed against
(03b · Two currencies) shows per-channel figures split by their own
currency, with only the reporting-currency footer pooled.

unconvertedCount/unconvertedValue already existed (#2049/ADR-040
follow-up) but carried no currency label and could legitimately mix
currencies per the type's own doc comment. This is #1987's own scope,
not an FX-epic deliverable: order_records.currency is the pre-existing
native-currency column from #1985, untouched by the FX epic's
reportingCurrency/reportingTotalAmount stamp - labelling the
unconverted evidence is purely an aggregation-query addition.

- getDailyOrderAggregates: adds unconverted_currency, the single
  native currency shared by every unconverted, non-cancelled order
  this day/connection, NULL when that set mixes currencies.
- resolveUniformUnconvertedCurrency (aggregation layer): rolls the
  per-day label up to headline/channel, treating a day with zero
  unconverted orders as "nothing to report" rather than letting it
  poison the whole set to null.
- Threaded through DailyOrderAggregateRow, SalesAnalyticsHeadline,
  ChannelSalesAnalytics, the response DTOs, and every affected test
  fixture (repository, service, aggregation, controller specs).

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

* fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract

The frontend types were drafted ahead of the backend and assumed a shape
it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency
per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring
(reportingTotalAmount stamp + unconvertedCurrency labelling) has been
merged in, rewrite the frontend to match it exactly: one nullable
system-wide currency, unconvertedCount/Value/Currency per channel, and
revenueShare always a number.

- Cancellations KPI now leads with the rate (%), value/count as qualifiers.
- By-channel table: a channel with no FX-stamped revenue yet falls back to
  its own unconverted-currency evidence instead of showing an empty cell,
  flagged with an "Awaiting FX stamp" chip.
- Total rows: one reporting-currency total (real KPI aggregate) plus one
  informational unconverted-currency subtotal per distinct native currency
  — only emitted when more than one channel contributes, so a lone
  channel never gets a redundant duplicate total.
- Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now
  count every placed order (stamped + unconverted), not just the stamped
  subset.
- Share and Trend columns reordered so Share sits immediately before Trend
  (previously Share was misplaced next to Revenue).
- Fixed a CSS specificity bug where the Phase-6 dashboard-triage
  `.status-strip` rule silently won over `.status-strip--analytics` at
  >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2.

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

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header (#2115)

* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner

Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md:
- New /analytics route (PageLayout, Operations nav item)
- Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) +
  From/To fields with a draft-buffered Apply action (Decision 1)
- Trust header (per-connection freshness + "Connected since" + status,
  Decision 3 — real "data from" coverage deferred to #2083/#1985) with a
  click-triggered info popover (touch-safe, unlike a hover-only Tooltip)
- Degradation banner on stalled/disconnected connections — status-only
  for v1 (Decision 4); the mockup's range-gated "sold in this selected
  range" refinement is deferred until #1990 makes that fact honest rather
  than an approximation
- Fresh-instance / still-arriving / loading / error states
- New analyticsTrust API-client namespace consuming the already-shipped
  GET /analytics/trust (#1982)

Post-review fixes (tech-review pass):
- Order-date disclaimer is now a static span (chip+dagger, matches the
  design mockup verbatim) instead of the interactive Chip primitive,
  which rendered a toggle button with no effect
- Banner timestamp uses the shared formatDateTime helper instead of a
  hand-rolled toLocaleString(), matching AnalyticsTrustHeader
- Added a page-level loading-state test

Ref #1986

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

* fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup

- Namespace .gap-mark/.info-popover-trigger to .analytics-* and document
  .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row
  Heights, per the tech-lead review's documentation-obligation findings.
- Drop code-comment claims of verbatim conformance to
  docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018).
- Replace inline style on analytics-trust-header.tsx with a real CSS class;
  drop the phantom trailing grid column.
- Remove the dead toUtcRangeInstants export (no consumer yet).

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

* fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy

- Fix two failing tests: the 90d preset test asserted an off-by-one date
  (implementation was correct), and the disclaimer-chip test failed to
  match the tooltip-split text node. Also fixes a third, previously
  undetected failure in the degradation-banner "renders nothing" test,
  which asserted an empty DOM even though renderWithProviders always
  mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
  operator-facing aria-label/tooltip copy; move the rationale into a
  code comment and replace the bare `title` with a keyboard-reachable
  Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
  "has not ingested since" copy, both of which asserted data currency
  from `lastPollAt` — a pipe-liveness signal, not proof any order data
  arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
  date-range.lib.ts (the file only does local-time formatting).

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

* fix(web,analytics): render the real earliestOrderDate now that #2083 shipped

#2083 (real per-connection earliest-order-date read) landed as PR #2121
on this stack's base (1985-order-analytics-read-model), which Decision 3
in the plan flagged as making the "Connected since" coverage row's
connectionCreatedAt swap a trivial follow-up rather than a rewrite.

- Add earliestOrderDate to the FE ConnectionIngestionTrust type,
  mirroring the now-shipped ConnectionIngestionTrustResponseDto field.
- Trust header: "Connected since"/connectionCreatedAt -> "Data from"/
  earliestOrderDate (falls back to "No orders yet" when null), matching
  the mockup's actual coverage-window semantics instead of the
  connection-configured-since approximation.
- Update the info popover copy and file header comment accordingly.
- Add earliestOrderDate to every existing fixture; the never-ingested
  fixture in analytics-page.test.tsx gets null (no orders, consistent
  with its status), the rest get a fixed date. Add a "No orders yet"
  render test.
- Plan doc: mark Decision 3 and its risk-register entry resolved rather
  than rewriting the historical record.

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

---------

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

* docs(analytics): implementation plan for /analytics needs-attention section

Plans issue #1989 — three actionable categories (coverage gaps, stock at
risk, failed-sync value) consuming the already-shipped GET
/analytics/needs-attention (#1983), mounted into the #1986 shell. No
backend changes; resolves link targets, the mixedCurrency interim
(tracked against #2049), and the ambiguous multi-connection copy case.

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

* feat(web,analytics): /analytics needs-attention section (#1989)

Renders the three needs-attention categories — coverage gaps, stock at
risk, value stuck in failed syncs — mounted into the #1986 shell.
Consumes the already-shipped GET /analytics/needs-attention (#1983)
as-is; no backend changes.

Either the open rows render or a single all-clear line does, never
both, per the design mockup's rule. Each open row deep-links into the
flow that resolves it: the unified publish wizard, the product detail
page, or the orders list filtered to the needs_attention health
bucket. Ambiguous multi-connection cases fall back to a
connection-agnostic headline; the failed-sync total renders
currency-neutral since the DTO carries no currency field in either
the mixed or non-mixed case (interim pending #2049).

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

* fix(web,analytics): match needs-attention section to the #2003 mockup

The plan (implementation-plan-analytics-needs-attention.md) required a
client-side "checked HH:MM" timestamp in the panel header and a
neutral-tone Clear badge, mirroring frame 02 of the design mockup
(docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018
branch). Both were dropped in the original implementation.

Adds the checked-at timestamp (TimeDisplay driven by the query's own
dataUpdatedAt, since the DTO carries no such field) and switches the
all-clear badge from success to neutral, per spec.

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

* fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture

Rebase fallout from the earliestOrderDate swap (#2083): the
#1989-cherry-picked "keep the trust header rendered when needs-attention
fails" test fixture predates that field and failed type-check.

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

* fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)

getTopProductRanking/getProductChannelBreakdown summed every stamped order's
reportingTotalAmount regardless of which reporting-currency era it was
pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick.
Since a settings change is forward-only (older orders keep their original
stamp), switching the reporting currency mixed two real currencies into one
number under a wrong label instead of surfacing the older era as unconverted
evidence like an unstamped order.

OrderRecordService now resolves the current reporting currency and both
queries filter revenue to reportingCurrency = current, folding any other
era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped
orders.

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

* fix(orders,analytics): scope top-products revenue to the current reporting currency (#1988)

getTopProductRanking/getProductChannelBreakdown summed every stamped order's
reportingTotalAmount regardless of which reporting-currency era it was
pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick.
Since a settings change is forward-only (older orders keep their original
stamp), switching the reporting currency mixed two real currencies into one
number under a wrong label instead of surfacing the older era as unconverted
evidence like an unstamped order.

OrderRecordService now resolves the current reporting currency and both
queries filter revenue to reportingCurrency = current, folding any other
era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped
orders.

(cherry picked from commit 5a39290a44878d1f6a45d405f75a2ad8cc9fbe9f)
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)"

This reverts commit 5a39290a44878d1f6a45d405f75a2ad8cc9fbe9f.

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

* feat(orders,analytics): disclose the native currency behind unconverted top-products evidence (#1988)

getTopProductRanking already folded a prior reporting-currency era (or a
never-stamped order) into unconvertedRevenue/unconvertedOrderCount, but gave
the frontend no way to label that figure — unlike the #1987 by-channel read,
which already carries unconvertedCurrency for the identical situation.

Adds unconvertedCurrency end to end (repository SQL, ProductRankingRow,
TopProductView, TopProductRowDto): the one native currency shared by every
order contributing to unconvertedRevenue, or null when that set mixes
currencies, mirroring DailyOrderAggregateRow's existing rule.

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

* fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991)

A product whose only orders in range were stamped under a PREVIOUS
reporting-currency setting (or never stamped at all) rendered a bare
"No FX-stamped order" empty value, even though the backend already exposed
the native-currency figure as unconvertedRevenue/unconvertedCurrency
(#1988).

The Revenue column now falls back to that evidence when there is no
current-era stamp, marked informational via a title tooltip — mirroring
ChannelSalesTable's identical fallback for the #1987 by-channel read.

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

* fix(orders,analytics): guard mixed-currency labels + UTC day buckets (#1987 review)

IMPORTANT 1: getDailyOrderAggregates labelled a (day, connection) bucket's
revenue with (array_agg(reportingCurrency))[1] — the first value happened
to sort first — even though reportingCurrency isn't guaranteed
single-valued within a bucket (an in-flight #2096 restatement can leave
two live at once). Guarded it with the same COUNT(DISTINCT ...) <= 1
pattern unconvertedCurrency already uses, and gave the domain-layer
pickCurrency the matching cross-row disagreement check
(resolveUniformReportingCurrency) rather than "first non-null wins".

IMPORTANT 2: date_trunc('day', placedAt) truncates at local midnight per
the Postgres session TimeZone GUC, since placedAt is timestamptz — on a
non-UTC server every bucket would land on the wrong calendar day and
silently mismatch enumerateDayKeys's UTC keys, zeroing every trend point
beneath a correct headline. Made the boundary explicit:
date_trunc('day', placedAt AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'.

SUGGESTIONS: medianOrderValue no longer flattens "no stamped order in
range" to the same 0 as a genuine zero median (now number | null, plumbed
through the DTO); documented the units-vs-orderCount scoping mismatch on
OrderLineItemRepositoryPort; added sales-analytics-aggregates.int-spec.ts
against Testcontainers Postgres to pin both IMPORTANT fixes against a real
server (the reviewer's own root-cause note: the mocked query builder could
never have caught either).

Also merges in the latest 1985-order-analytics-read-model (this PR's base
branch), which had picked up its own review fixes since this branch last
merged from it.

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

* fix(web): bump the lazy-route contract count to 52 for /settings/mcp-tokens

An earlier merge (feat(mcp): Resource-Server auth via user-issued Personal
Access Tokens, #1486/#1912) added the /settings/mcp-tokens page as a lazy
route, but the parameterized route-lazy contract test's expected count
was never bumped, failing CI on this branch with "expected 52 to be 51".

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

* fix(orders,analytics,products): address #2172 review findings on top-products ranking

Two IMPORTANT correctness issues and three SUGGESTIONS from the #2172 tech
review, all still open on this branch:

- IMPORTANT 1: ORDER BY revenue/units had no tiebreaker, so pagination over
  a non-unique sort was non-deterministic in Postgres (ties could repeat on
  one page and be skipped on the next). Add addOrderBy('product_id', 'ASC').
- IMPORTANT 2: a stamped order with totalAmount = 0 (fully discounted/free)
  silently vanished from both revenue and unconvertedRevenue, since the FX
  multiplier (reportingTotalAmount / totalAmount) is NULL via
  NULLIF(totalAmount, 0). It now folds into the unconverted bucket instead,
  same as a never-stamped order, in both getTopProductRanking and
  getProductChannelBreakdown.
- SUGGESTION 3: documented, in the endpoint's @ApiOperation description,
  that ranking by revenue is blind to unconverted revenue for a product
  whose orders are all unstamped.
- SUGGESTION 4: resolveCoverageGaps fired up to `limit` concurrent
  getVariantsByProductId calls. Added a batch getVariantsByProductIds
  (ProductVariantRepositoryPort -> IProductsService) so the page's variant
  ids resolve in one query instead of one per product.
- SUGGESTION 5: a coverage-gap enrichment failure degraded every row to
  missingFromConnectionIds: [], indistinguishable from "listed everywhere".
  Added TopProductsResponseDto.coverageGapAvailable so the FE can tell the
  two apart.

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

* fix(orders): make the UTC day-bucket int-spec actually guard the regression

Testcontainers Postgres boots with session TimeZone = UTC, so the existing
assertion passed identically with or without the AT TIME ZONE 'UTC' pair in
getDailyOrderAggregates — it documented intent but couldn't fail on a
regression (#2151 review, SUGGESTION).

Force the session TimeZone to Europe/Warsaw for this one read (and restore it
afterward), so a regression to a bare date_trunc('day', placedAt) actually
flips the bucket to the following day and fails the test. SET TIME ZONE is
session-scoped; dataSource.query and the repository read run back-to-back
with nothing else contending for the pool, so the same just-released client
is reused.

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

* fix(orders,analytics): label unconvertedCurrency per channel on top-products (#2172 review)

The ranking row's unconvertedRevenue gained a currency label in an earlier
fix, but the per-channel breakdown row didn't, so
ProductChannelBreakdownDto.unconvertedRevenue stayed a bare number with no
unit. Inheriting the parent's label isn't sound either: the parent goes
null on a mixed set, but an individual channel's own subset is routinely
single-currency even then — a channel is strictly more labelable than the
product as a whole, never less.

Lifts the same MAX(currency) FILTER (...) / COUNT(DISTINCT ...) <= 1 shape
getTopProductRanking already uses, computed per (product, connection) in
getProductChannelBreakdown, threaded through ProductChannelBreakdownRow and
ProductChannelBreakdownDto.

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

* fix(analytics): address #2098 tech review + trust-header single-line layout

- Sync docs/plans/implementation-plan-analytics-page-shell.md with the
  now-resolved Decision 3 (real earliestOrderDate coverage row) and
  Decision 4 (hasSalesInRange dropped), and note the Reusable
  Components divergences.
- Replace the analytics-date-range-toolbar's Tooltip-based "Order
  date" caveat with a Popover on a real <button>, matching
  AnalyticsTrustHeader's pattern — Radix Tooltip ignores
  pointerType === 'touch', making the old trigger unreachable on
  mobile.
- Trust header renders a single-line "data from X · synced Y" fact
  string with a per-channel colored dot, replacing the prior two-column
  label/value layout; adds TimeDisplay's 'time' format and
  formatAbsoluteTime helper it depends on.

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

* fix(analytics): address #2120 tech review — sample-vs-total headline defect

- BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a
  connection when the preview sample IS the total (items.length ===
  totalCount); otherwise fall through to the connection-agnostic
  headline, so a headline never asserts something only a 20-item
  sample verified.
- "Publish now" sub now discloses when it only seeds the sampled
  variants ("showing the first N of M").
- Fix the "1 variant have a listing gap" grammar bug to a verb-free
  form, updating the test that had locked it in.
- Thread a BCP 47 locale into deriveFailedSyncHeadline instead of
  hardcoding toLocaleString('en-US').
- Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds
  from the same item list instead of two independently sliced arrays.
- Render AnalyticsNeedsAttention regardless of order-ingestion status —
  coverage gaps and stock-at-risk are listing facts, not order facts.
- Render .attention-list as <ul>/<li> for list semantics.

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

* fix(analytics): drop dangling "data from" prefix on the no-orders-yet fact

The "data from" prefix was rendered unconditionally, so a connection
with no earliestOrderDate read "data from no orders yet" instead of
just "no orders yet".

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

* chore(tax): open the per-line tax rate epic branch

Tracking branch for epic #2245. Children merge here; this branch merges to
main only when every child has landed.

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

* docs(tax): ADR-052 per-line tax-rate resolution, provenance and rounding ownership

Records the rule the #2245 epic implements: the rate arrives from the
ProductMaster with the product, the marketplace is a fallback when the master
does not know, and when neither knows the document is held rather than guessed.
OpenLinker never computes a rate and never computes an amount excluding tax.

Seven decisions: the shop-then-channel resolution chain, three answer states
(0 is an answer, 'not yet checked' is a fourth read state that suggests a sync
rather than blocking), percent-as-string representation with provenance-only
country, projection-at-sync storage with the order snapshot as the only place a
rate is settled, adapter-owned rounding, gate semantics (a missing rate blocks
and also refuses the manual paths; a shop-versus-channel mismatch does not block
and is not a SalesDocumentGateBlockReason at all), and the non-goals.

Numbered 052, not the 050 the issue names: 050 and 051 are reserved by the ADR
README for the #2162 async-work-layer epic and the index directs the next new
ADR to 052.

Also settles the two edits ADR-014's amendment owed: its 'proposal, not a
recorded refinement' preamble is dropped and its
Proposed-while-its-decisions-shipped status resolves to Accepted.

Refs #2245

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

* fix(invoicing): settle the tax-rate notation on percent-as-string

InvoiceLine.taxRate had no single reading. Core divided by 100
unconditionally, so '0.23' meant 0.23%; the inFakt adapter switched on
n > 1, so '23' and '0.23' both resolved to 23% while a genuine 1% rate
resolved to 100%. On a 123 PLN invoice the two readings diverge by 22.72 PLN.

The blast radius is narrow only because the mapper still emits an empty
taxRate and each adapter substitutes its own default. Once #2054 lands, every
invoice line travels through this field, so the notation is pinned first and
on its own.

A guess is the wrong shape here: the writer knows which notation it used, so
an ambiguous value is a defect upstream and must surface. A new pure module,
tax-rate-notation.types, states the contract once and every reader goes
through it. Fractional notation (numeric, strictly between 0 and 1) raises
FractionalTaxRateNotationError rather than being multiplied by 100 - '0.23' is
indistinguishable from a genuine 0.23% rate, so normalising it would invent a
value nobody stated. '0' is deliberately not fractional: a zero rate is a real
answer, and PrestaShop already distinguishes it from unknown.

Readers aligned: core rateFraction, the inFakt tax_symbol map and gross-to-net
split, the Subiekt stawkaVAT passthrough. KSeF already rejected a fractional
code, since FA3_TAX_RATE_MAP has no such key - that is now pinned by a test
rather than left incidental.

The empty-string path is untouched on every adapter; #2257 removes those
defaults.

Closes #2247
Refs #2245

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

* feat(web): shared-UI primitives for the tax-rate states

Three additions the per-line tax-rate surfaces need, and nothing else.

StatusBadgeTone gains 'conflict' - two sources disagreeing about the same
fact, which needs attention but is not an error because the document still
issued. The family has no -fg member, so the rule reads
--status-conflict-strong; the ramp base gives roughly 2.5:1 and fails
contrast, and nothing would have caught it since the token checker is
one-directional and a missing token resolves to nothing silently.

The family already had a consumer through a className override
(.status-badge.delivery-rider-chip--not-connected), so that override is
migrated onto the tone in the same change rather than left as a second owner
with a second text token. The className survives as a layout hook, so the
existing delivery-chip assertions still hold.

AlertTone gains the same member. Alert picks role=alert only for 'error', so
a conflict alert lands on role=status - the right politeness level for a
non-blocking advisory, now following from the tone rather than from a per-call
choice. SalesDocumentBlockCopy['tone'] widens to match, since its value is
passed straight into Alert.

AbsentValue moves out of listings-list-page into shared/ui. Absence versus
zero is the central claim of this epic - a rate of 0 is a real answer and
'no rate' holds the document - so it needs one implementation. It renders the
wording visually hidden rather than as an aria-label, because aria-label on a
bare span is prohibited and commonly dropped.

Adding the tone member compile-forced BLOCK_TONE_FOR_BADGE and TONE_CLASS, the
timeline's two exhaustive records; both gain the member together with the
TimelineEvent tone and the .order-activity__dot--conflict rule.

Closes #2253
Refs #2245

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

* feat(products): per-line tax rate on the order contract and the master reads

Core had nothing to carry a tax rate in. OrderItem and IncomingOrderItem have
no tax field, and the only signals on an order are one aggregate OrderTotals.tax
and an order-wide taxTreatment - neither of which can describe a mixed-rate
basket. So the mapper emits an empty rate and each provider adapter guesses.

This gives core the field, the vocabulary, a way to ask the two masters, and a
place to keep the answer.

The rate is a string code (23, 8, 5, 0, zw, np, oo), not a number: 0, exempt,
reverse charge and intra-EU zero are four different things on a document and
all look like zero. Notation is percent-as-string, settled in #2247.

Storage is a projection pulled at product sync onto products and
product_variants, exactly as price and currency already are. Issuance must not
depend on the shop being reachable, and 'which products lack a rate' has to be
a query rather than a crawl - both partial indexes exist for that.

Three distinctions are load-bearing and each has a test.

Zero is an answer, never an absence. A read that establishes nothing reports
kind: 'unknown' and is stored as a null code, never as '0'.

Never-checked is not no-rate. taxRateReadAt separates them: null timestamp
means nobody has asked, a timestamp with a null code means the master answered
and has none. Without that split, the day this ships the whole catalogue reads
as incomplete and the pre-rollout coverage count measures nothing.

A variant override always wins where the shop carries one - the open question
the epic left for this child. It is not a conflict to arbitrate: a variant
value is the more specific statement of the same fact, and the shop had to be
edited deliberately for the two to differ. An absent override means 'no
opinion', so a product-keyed master resolves through the product row unchanged.
A third resolution arm, 'inherited', exists for a WooCommerce variation whose
tax_class is 'parent': storing nothing is honest, where recording unknown would
show the variant as rate-less and copying the product's code down would leave a
duplicate that goes stale.

PrestaShop delegates to the existing PrestashopTaxRateResolver rather than
re-walking the three-hop chain, so the sync path and the order-create path
cannot disagree about one shop. Its transport unknown is re-raised rather than
reported as unknown - a failed call says nothing about the shop's
configuration, and recording it would freeze a false 'no rate' onto the row.
WooCommerce resolves class name to the store's rate table for the store's own
country; tax_status 'none' is a resolved zero, no row is not-configured, and
several different rates is ambiguous rather than a pick, because picking would
be OpenLinker computing tax.

recordTaxRate is a separate writer from upsert on both repositories, and the
columns are deliberately absent from toOrm - the sync upsert carries no rate,
so round-tripping them would blank a value the tax read just wrote, and a
blanked rate holds documents. Same single-writer rule as
order_records.cancelledAt.

Order ingestion settles the rate onto the stored snapshot, shop first and
channel second, and carries taxSource plus taxRateReadAt alongside it so a
reader can tell no rate, never read and pre-rollout apart (#2245 F3).

One additive migration, nullable, no backfill.

Closes #2054
Refs #2245

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

* fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide

- toExclusiveEndInstant now anchors on UTC midnight instead of local
  midnight, matching the controller's UTC-parsed `from` (was silently
  dropping/adding hours off UTC).
- Sparkline aria-labels derive from the actual selected range instead
  of a hardcoded "last 7 days".
- Register the analytics KPI card's 152px/3-col geometry as a
  documented carve-out in the style guide (Density table + parity
  matrix), per the "never introduce an undocumented row height" rule.
- Fix "Data order" planned-tag typo -> "Planned"; section-infotip
  font-size to the rem token equivalent.

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

* feat(invoicing): carry the tax rate onto the document and hold when it is absent

The mapper stops emitting an empty taxRate and carries the code the order line
was settled with. It stays a passthrough - no derivation, no default - and an
empty value still reaches the adapter unchanged, because a mapper that threw
would turn an operator-fixable data gap into a failed job. The gate is what
refuses.

missing-tax-rate joins SalesDocumentGateBlockReasonValues on both sides of the
mirror. It is the first reason for which 'this cannot be issued' is literally
true: every other one means 'auto-issue did not happen', and issuing by hand
past those is a legitimate operator action. Issuing past this one means a
provider substituting a guessed rate onto a real fiscal document, so it closes
the manual paths too - InvoiceService.issueInvoice refuses before the lock and
before any persisted state is touched, POST /invoices answers 422 with the
reason and retryable:false, and bulk issue reports a named ineligibility rather
than attempting it.

The check runs on the COMMAND rather than on the order, so no caller can bypass
it by composing lines itself, and the correction path - which composes its lines
from an already-issued document - is unaffected. It runs BEFORE the trigger-model
gate, because on a manual connection both apply and reporting the weaker reason
would leave an operator clicking a button that refuses.

A zero rate passes everywhere. Export, intra-EU and exempt goods are legitimately
zero, and blocking on them would hold documents for a correctly configured
catalogue.

Shipping now splits across the rates in a mixed basket, in proportion to line
gross, with the rounding remainder on the largest part so the parts sum exactly
to what the buyer paid. A single-rate basket still yields one line. This is
division, not tax computation: core groups an amount it was given and cuts it
into parts that add back up to it. A single line with no rate makes the mix
unknowable, so the split refuses and the whole document waits.

Two instants record when a hold started and ended. The reason column is
level-triggered and nulled the moment it clears, so without them the
operator-facing age has no clock and the 'the rate arrived, the invoice issued'
timeline entry has no instant to hang on. They are derived from the TRANSITION
inside the same UPDATE, because only that statement knows both the old and the
new value; blockedAt is stamped on none-to-blocked alone so a change of reason
inside one episode does not reset an age somebody is watching.

Closes #2248
Refs #2245

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

* fix(web): remove unused vi import breaking tsc build

CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi`
import in sales-analytics.api.test.ts, blocking `pnpm build`.

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

* Revert "fix(web): remove unused vi import breaking tsc build"

This reverts commit b915a030ac81bac74c483b45472cb613cec194c8.

* fix(web): remove unused vi import breaking tsc build

CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi`
import in sales-analytics.api.test.ts, blocking `pnpm build`.

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

* feat(products): append-only tax-rate provenance journal

A mutable 'rate source' field only says how things stand now. It cannot answer
when the shop changed the rate, what OpenLinker last wrote onto a channel, or
whether somebody overwrote it afterwards - and that last question is what makes
a shop-versus-channel disagreement attributable rather than mysterious.

So provenance is a journal: one row per CHANGE, never one per read. The
catalogue sweep runs every twenty minutes and most rates never move, so writing
unconditionally would grow the table by the size of the catalogue per tick and
bury the handful of rows that matter. isNewTaxRateObservation owns that rule in
one pure place.

The dedup compares the value, the origin AND the frozen flag. A seller freezing
a field without changing its value is a real change in what the value means -
it is now something a person set - and losing it would leave the disagreement
surface unable to say so.

Append-only by construction, not by convention: the port declares append,
findLatest and findLatestPerConnection, and no update, upsert or delete. A
journal whose rows can be edited cannot answer the question it exists for, so
adding a mutating method is not a refactor. Same discipline as
ExchangeRateRepositoryPort.

Origin distinguishes shop, channel and written-by-us. The third is the reason
the journal exists at all: it records OpenLinker's own write onto a channel, so
a later channel observation carrying a different value proves somebody changed
it after we did.

Master product sync records every rate it observes. The write is best-effort
and separate from the catalogue write - the journal is provenance, so losing an
entry costs an audit trail rather than a rate, and failing the sync over it
would trade the thing that matters for the thing that explains it.

One index serves both reads, since the latest-for-one-connection lookup and the
latest-per-connection listing walk the same prefix.

Note on scope: taxSource and taxRateReadAt already reach the ORDER SNAPSHOT line
(#2054, epic F3). The order_line_items half of this issue is not implemented
here because that table does not exist on main - it is defined in #2014, which
is still open.

Closes #2250
Refs #2245

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

* feat(integrations): read the channel's tax rate and propagate the shop's onto offers

The channel half of the resolution chain, and the write back.

READS. Erli reports a required per-line taxRate on every order line and OL's
own type already modelled it while the mapper discarded it - it now reaches the
order contract. Allegro's lineItems[].tax has been live since March 2024 and the
OL type had no field for it at all; it is modelled and read. Both map their
platform vocabulary to the neutral code inside the adapter, tested in both
directions, with a round-trip test over every value the Erli mapper claims to
support.

An unreadable value maps to absent, never to '0'. A rate OL cannot read is not
a zero-rated sale, and Erli's enum is category-dependent and may gain values.

WRITES. The shop's rate is stamped onto CreateOfferCommand by OfferBuilderService
in the same pass that already carries price and stock, read from OL's own
catalogue projection so publishing does not depend on the shop being reachable
and the offer carries the same rate an invoice for it would. There is no
OL-side rate field to type into, deliberately: a rate entered in OL would be a
fourth source no master or channel could be corrected from.

Nothing is published with the rate omitted. That is precisely how the rate-less
offers this epic exists to fix were produced, and the failure surfaces months
later on somebody's invoice rather than at publish time. Allegro refuses on no
rate, on an exemption code (its rates array carries numbers), and on a rate the
category does not allow - the last naming the permitted values, because when
Allegro says the category allows 23% and OL sent 5% the shop record is almost
certainly the wrong one. Erli refuses on no rate and on 'oo', which its enum
cannot express; omitting would publish a product Erli then marks not-buyable
with missingTaxRate, which nobody sees.

The permitted-values read is best-effort. A failure to LIST is not a failure to
publish, so it warns and proceeds with the shop's value; Allegro validates the
body itself, and refusing because a secondary discovery call was unavailable
would be worse than the check.

The update path propagates too, so an offer's rate follows the catalogue instead
of freezing at first publish. There an unmappable code is dropped rather than
raised: an update that cannot state its tax is a partial update of an offer that
already sells, and raising would take a title or price fix down with it.

A frozen Erli taxRate is skipped, force is never sent, and it is reported once
per connection at info level rather than as a recurring publish error - a
seller freezing the field is a deliberate decision, and it is exactly the signal
that makes a later disagreement attributable to a person.

Closes #2249
Refs #2245

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

* feat(orders): mark pre-rollout orders and measure catalogue rate coverage

Two rollout chores that keep the first day honest.

HISTORICAL ORDERS. An order ingested before per-line rates existed carries none
on any line, and the document issued for it used whatever the provider adapter
defaulted to. It is MARKED rather than blocked: blocking would stop history
nobody is going to retrofit, and nothing about it can be corrected after the
fact. The marker's only job is to keep a net-revenue figure honest - such an
order is excluded from one rather than presented as a confirmed rate, because
there is nothing to back-compute from.

Recorded per RECORD, not per line. The lines live in a jsonb snapshot, so a
per-line marker would rewrite every snapshot in the table for a value that is
uniform across an order and that no surface renders per line. The frontend
deliberately shows nothing for it - it appeared in one place with no action
attached, so it is analytics data rather than a badge.

This backfill is not the one the epic forbids. What must never be invented is a
tax RATE; recording that an order predates the feature is a fact about
OpenLinker's own history, and it is exactly what stops a later reader mistaking
a defaulted rate for a stated one.

The migration is idempotent by construction: it marks only rows where no line
has ever carried a rate, so an order ingested between two runs is not
retroactively called historical. There is no cutover instant to get wrong.

COVERAGE. Counts are per shop connection, because that is the unit an operator
fixes - 'the catalogue has no rates' is not actionable when three shops feed it
and only one is incomplete. A product mapped on two connections counts under
both, which is the honest answer since both shops would have to carry the rate.
The grouping joins identifier_mappings by table name, the read-model posture the
stock aggregation in findMany already uses, rather than importing another
context's ORM entity.

docs/operations/tax-rate-coverage.md carries the query, what the three states
mean, how to read the answer, and why this gates #2257: until the defaults come
out a rate-less product still produces a document with a guessed rate, and after
it produces nothing at all. Running the two in the wrong order turns a slow,
visible data problem into an immediate outage. The 2026-08-21 baseline measured
zero coverage and is recorded on the issue.

Closes #2256
Refs #2245

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

* feat(fiscalization): hold a receipt when a line has no tax rate

The same rule as the invoice, on the fiscalization path.

FiscalRegistrationService.register refuses before the read gate and before any
row is written, so a held sale leaves no pending record to reconcile. The
order-to-command mapper stops emitting an empty rate and carries the line's own,
and shipping splits across a mixed-rate basket exactly as it does on an invoice
- a receipt has to state a rate per line too.

The accepted cost is LATE registration, and it is chosen deliberately. The
alternative is a receipt carrying a tax letter nobody confirmed, which reaches
the buyer and the daily report and cannot be recalled. A late registration can
be completed; a wrong one has to be corrected.

THE REVERSAL POINT IS ONE BRANCH: the assertEveryLineHasATaxRate call in
register. Nothing else in this context consults the rate, and the exception's
docblock says so, so reversing this is a one-line change rather than a redesign.
The eparagony adapter's own empty-rate arm is kept for the same reason.

The per-connection tax letter stays supported but stops being a fallback. Its
docs no longer describe it as "what we use when we do not know": core refuses
one step earlier now, so that arm is unreachable while the gate stands, and
presenting it as a safety net would describe a trade OpenLinker no longer makes.

splitShippingAcrossRates moves from invoicing into sales-documents. Both
document contexts need it and a fiscal receipt is not an invoice, so neither
could own it for the other - sales-documents is the dependency-free leaf that
exists for exactly this case, and the barrel-purity spec already enforces that
it stays one. The module imports nothing, so the property holds.

On the elapsed-time signal: a held sale has no fiscal record to hang a clock on,
and it does not need one. The block is recorded on the ORDER as
missing-tax-rate with salesDocumentBlockedAt (#2248), and that reason is
document-kind agnostic - it says the order has no fiscal document, whichever
kind was due. A second clock would be a second answer to one question.

Closes #2252
Refs #2245

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

* fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug

- Units column now reads row.units (server-ranked figure) instead of
  re-summing row.channels[], which could silently disagree with the
  sort order the header arrow claims.
- The Publish action is gated on listings:write via useWriteAccess +
  ReadOnlyLock: hidden for an unauthorized non-demo session, rendered
  disabled with the read-only tooltip for a demo viewer.
- Swapped the Chip (aria-pressed toggle) for a real Link styled as a
  button, so the one-shot publish navigation carries link semantics
  (middle-click, open-in-new-tab) instead of misrepresenting itself as
  a permanently-unpressed toggle to assistive tech.
- @media (hover: none) now stacks the "Not listed" label and the
  Publish action, both visible, instead of hiding the label on touch —
  the #1991 AC's label-vs-action distinction was desktop-only before.
- Added the analytics feature slug to both no-restricted-imports
  pattern groups in .eslintrc.js.

Closes review findings on https://github.com/openlinker-project/openlinker/pull/2191

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

* feat(invoicing): copy the per-line net back from the issued document

OpenLinker computes no net amount (ADR-052), so a stored per-line net has to be
the document's own figure rather than a recomputation. Otherwise the record
disagrees with the paper by a grosz here and there and no reader can tell which
is right.

Nothing carried a provider-computed net before this. documentContent held core's
own recomputation, marked NON-AUTHORITATIVE in the code, and issuedLineSnapshot
carried only unitPriceGross plus taxRate. So this is mostly an adapter-contract
change: IssueInvoiceResult gains an optional documentLines, matched to the
command's lines by 1-based line number.

The fallback is per LINE, not per document, so a provider that reports some
lines and not others still contributes what it has. Shipping lines are part of
the numbering because they are real document lines; what "skip shipping" means
is that they have no ORDER line to transcribe onto, not that they shift the
mapping.

A correction reports its own amounts and they overwrite the stored ones, so the
record follows the latest effective document rather than keeping pre-correction
figures the paper no longer states.

inFakt reads the created invoice's own services[] - it is the calculator on that
path - converting integer groszy back to PLN on the adapter side where that wire
detail belongs.

KSeF has nothing external to copy, so the adapter reports what it wrote. The
figures come from the same lineNet the XML uses, so the reported net cannot
drift from P_11, and the offline pending-submission path reports them too: that
window is about transmission, not about what the document says.

The KSeF rounding bug is fixed and the rule is stated once. P_9A is TKwotowy2,
which permits EIGHT fraction digits, and the builder was rendering it through
the 2dp money(). On 100 x 1.99 at 23% the line net is 161.79, but a unit net
rounded to 1.62 multiplies back to 162.00 - the document contradicted itself by
21 grosze and a reader checking P_9A x P_8B == P_11 was right to complain.

The rule: the LINE is the unit of rounding and the unit price is derived from it
at the schema's full precision, never the other way round. The buyer paid a
gross line amount, so the line's net is what anchors to a real figure; a unit
net is a derived display value that only sometimes has an exact 2dp form.

Closes #2251
Refs #2245

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

* feat(web): tax-rate states on the orders list, order detail and invoice panel

Every tax-rate state on the orders surfaces, plus the backend contract two of
them needed.

BACKEND. A rate conflict gets its OWN field, its own count and its own filter,
not a gate reason (epic F1). invoicingBlockedBadge returns null whenever an
invoice plausibly exists, and a conflict does not stop the invoice, so routing
it through the block machinery would make the badge unreachable on exactly the
rows it describes - and SalesDocumentAttentionReasonValues would have counted
it inside salesDocumentBlocked, against its own chip.

The evidence is taxRateChannel on the order line, written ONLY when the channel
disagreed with the shop. Its presence is the conflict, so no reader compares two
fields and nothing goes wrong when only one system answered. The summary also
reports the oldest still-held instant, so the blocked chip can carry an age.

ORDERS LIST. A Rate conflict chip driven by its own count, mounting on
filterActive || count for the nine-line reason the invoicing chip already
documents: gating on the count alone unmounts the only way to clear the filter
the moment remediation succeeds. No tone on it - .chip.chip--active overrides
every .chip--{tone}, so an inactive conflict chip would read as pressed beside
an active accent one.

The age folds into the blocked chip's own label rather than becoming a third
dotted badge in a row that already carries two SLA badges. The conflict badge
uses the listings page's RowBadge shape - visible label plus the wording in a
visually-hidden span - because the hint is the only statement of the fact on
this surface and aria-label on a bare span is prohibited and commonly dropped.

A new empty arm sits ABOVE both single-filter arms: with two filters active and
no rows, "nothing is blocked from invoicing" would be a statement about a set
the other filter narrowed. liveRegion stays polite, like the neighbour it sits
beside.

ORDER DETAIL. A tax-rate column on the line-items panel, following the epic's
central claim: an answer is text, only an exception is a badge. A rate, a zero
and an exemption all read the way the money beside them reads, with a
provenance caption; only no-rate and conflict get colour. No hideBelow - this
panel passes no cardView, so the class would simply delete the blocking state
on a phone, on the one screen that diagnoses it. Only flagged and Jump to next
flagged are borrowed from the bulk review step for long orders.

The totals panel's Tax row keeps its snapshot value and gains a caption naming
whose number it is, because Allegro and Erli report zero there and it will
visibly disagree with the line rates.

INVOICE PANEL. Three remedy branches, not one sentence: a blank rate on a
mapped product, an item in no catalogue (fixing the offer will not release this
order - the marketplace stamped the rate at purchase), and an ambiguous shop tax
class. Plural-safe with a count, because a forty-line order with six rate-less
lines cannot be told about one product.

The Issue invoice button is disabled with the reason ON the control (epic F2).
It renders on invoiceSettled && not-issued independently of the block copy, so
until now a red "will not be issued" alert sat above a live button that issues
it - and the backend now answers 422.

The conflict alert is informational and lands on role="status" via the conflict
tone. The shipping split preview lives here rather than beside the line items,
because shipping has no order line: it exists only once a document is composed.
One unknown line rate collapses it to a single waiting row rather than showing
a proportion OpenLinker cannot compute.

Fix and re-check names the latency and links to the products list rather than
opening a sync dialog here: the connection to sync is the SHOP that owns the
product, which this panel does not know.

TIMELINE. The block entry is dated from the persisted instant instead of
timestamp: null, and a release entry exists at all - by the time an order is
released the reason is gone, so nothing else records that it was ever held.

Closes #2254
Refs #2245

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

* feat(web): tax-rate states on products, publish wizar…
piotrswierzy pushed a commit that referenced this pull request Aug 24, 2026
…p-products endpoints + per-line tax rate + net tax basis (#2014)

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

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

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

Refs #2049

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

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

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

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

Refs #2049

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

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

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

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

Refs #2122

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

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

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

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

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

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

Refs #2122

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

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

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

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

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

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

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

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

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

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

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

Refs #2123

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

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

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

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

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

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

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

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

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

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

Refs #2123

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

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

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

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

Refs #2123

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

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

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

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

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

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

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

Refs #2123

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

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

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

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

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

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

Refs #2123

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

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

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

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

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

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

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

Refs #2124

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

* docs(architecture): document the Currency bounded context

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

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

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

Refs #2127

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

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

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

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

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

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

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

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

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

Refs #2125

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

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

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

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

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

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

Refs #2126

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

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

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

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

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

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

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

Refs #2126

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

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

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

Refs #2126

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

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

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

Refs #2049

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

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

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

Refs #2049

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

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

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

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

Refs #2124

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

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

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

Refs #2049

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #2124

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

* docs(architecture): document the Currency bounded context

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

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

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

Refs #2127

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

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

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

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

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

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

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

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

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

Refs #2125

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

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

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

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

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

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

Refs #2126

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

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

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

Refs #1985

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

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

Makes order data analytically queryable without JSON expansion:

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

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

Refs #1985

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

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

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

Refs #1985

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Closes #2140

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

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

Review follow-up to #2140.

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

Closes #2047

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses the review on PR #2060.

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

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

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

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

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

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

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

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

This lands decision 11's first implementing slice.

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

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

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

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

Refs #2100

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

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

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

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

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

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

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

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

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

Three contract changes came out of the round:

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

Also fixed:

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants