Recapt shuts down in four days, taking product analytics and session
replay with it. This adds PostHog Cloud EU alongside it; the Recapt
removal follows separately so events can be confirmed landing first.
Wiring choices that are not the tutorial defaults:
- Same-origin reverse proxy (/rl -> eu.i.posthog.com) instead of adding
PostHog hosts to the CSP. connect-src 'self' and script-src 'self'
already cover it, tracking blockers have no third-party host to match,
and the Recapt allowlist entries in next.config.ts get replaced by
nothing at all when they go. Needs skipTrailingSlashRedirect, since
PostHog sends trailing-slash API requests; verified that trailing-slash
URLs on normal routes still resolve 200 rather than 404.
- /rl is excluded from the proxy.ts matcher. Middleware runs BEFORE
next.config rewrites, so without this updateSession() treats an
ingestion POST as an unknown protected path and 307s it to /login.
Verified with a control: /zz/flags/ -> 307 /login, /rl/flags/ -> 200
from PostHog. This fails silently otherwise, because asset loads keep
working through the rewrite while no events arrive.
- persistence: 'memory' so nothing is written to the device and no
cookie-consent banner is required. Everything post-login is unaffected:
AnalyticsIdentify re-identifies on each dashboard load.
- session_recording.maskTextSelector: '*'. PostHog masks inputs but not
text by default, and this app renders org numbers (which for an
enskild firma ARE the owner's personnummer), customer names and
balances as ordinary text. Replays show where a user gets stuck, never
what their books say. buildGroupProperties() also refuses to send
org_number at all, with a test pinning it.
- Error tracking registers through the existing lib/observability sink
rather than bypassing it, so every error-level createLogger() line is
captured already redacted. instrumentation.ts onRequestError covers
what escapes uncaught.
Analytics is hosted-only: isAnalyticsEnabled() short-circuits on
NEXT_PUBLIC_SELF_HOSTED and no Docker sentinel is added, so self-hosted
runs with zero third-party runtime code. Recapt got that outcome only by
accident, via a missing sentinel; here it is explicit and tested.
vitest.config.ts aliases 'server-only' to a stub: it is a build-time
guard whose real entry point always throws, which broke 48 test files the
moment a server-only module entered the graph. request-context.ts was
already carrying the same latent trap.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.
Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.
Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.
Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Whole-krona Bankgiro/Swish payments of öre-bearing invoices were stranded
as partially_paid forever (e.g. 11 231 paid on an 11 231,25 invoice left
0,25 kr open). Book the sub-krona residual to BAS 3740 (Öres- och
kronutjämning) and settle the invoice in full, on both the supplier- and
customer-invoice match flows.
New shared pure helpers buildSupplierPaymentClearingLines +
planSupplierPayment mirror the customer-side primitives; routing preview
and commit through the same builder also fixes two pre-existing
preview↔commit drifts (payment account + line descriptions). Öre
absorption is accrual-only — cash entries book the full invoice, so
absorbing there would hide a 1930 discrepancy.
Also improves supplier-invoice ↔ bank matching:
- Pass-3 date window now spans [invoice_date-5, due_date+5] instead of
due_date ±5, so early payments auto-match; an ambiguity guard demotes
non-unique amount matches to suggestions.
- New retroactive matcher (on supplier_invoice.registered/.approved)
surfaces the settling bank payment when the invoice is registered after
the payment was imported. Matches are written as suggestions for
one-click confirm-to-book, never silently auto-booked.
Tests: new unit tests for both pure helpers; extended matching, handler,
customer öre, and route suites. Full suite green (407 files / 5364 tests).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: add real-Postgres smoke gate (pg-real)
Mocked Supabase tests cannot exercise triggers, RPCs, or RLS policies —
a migration that drops enforce_period_lock, mangles user_company_ids(),
or weakens an RLS policy ships green today. Closes that gap with a
small Vitest project `pg-real` running 5 smoke tests against a real
supabase/postgres:15 container in CI.
Covers: closed-period INSERT rejection, commit_journal_entry voucher
atomicity under concurrency, posted-entry immutability, RLS tenant
isolation on journal_entries, and audit_log UPDATE/DELETE rejection.
Also lands the bankid anonymization migration that was sitting
untracked from a prior task.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(pg-real): fix storage schema bootstrap + de-scope + PR review fixes
- Drop bankid anonymization migration from this PR. That change is
separate scope (and has open compliance questions flagged by the
Swedish review bot on #357); it will land in its own PR.
- Add tests/pg/bootstrap.sql to align storage.buckets/objects/foldername
with what migrations expect before the replay loop. The supabase/postgres
image ships only a partial storage schema; the rest comes from the
storage-api service at runtime, which CI does not run. First pg-real run
failed at migration 24 on "column public of relation buckets does not exist".
- Add concurrency group to the workflow so stacked PR commits cancel
in-progress runs instead of queueing.
- Gate the pg-real vitest project on DATABASE_URL so a bare `vitest run`
with no DB configured runs only the unit project. npm run test:pg is
the opt-in entry point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(pg-real): widen JWT claim setup so auth.uid() resolves under RLS
The rls.pg test came back with 0 rows instead of 1 — user_company_ids()
returned empty because auth.uid() didn't resolve to the seeded user.
Two fixes:
- Set both request.jwt.claims (whole object) and request.jwt.claim.sub
(individual claim). Different Supabase auth.uid() versions read one or
the other.
- Assert auth.uid() = expected userId immediately after the context
switch, so the next failure points at the right layer instead of an
unrelated empty-result assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>