* feat(bokslut): Phase 8 — make årsredovisning Bolagsverket-fileable
Closes most of the deferred items from PR #509 review. The ÅR PDF is now
an honest draft a user can take to Bolagsverket: it includes the fastställe-
intyg page ÅRL 8 kap 3 § requires, the K2-mandatory aktiekapital note, and
the narrative edits actually survive a refresh.
Narrative persistence (replaces the round-1 URL-query-param carry)
- New table arsredovisning_narratives (UNIQUE per fiscal_period_id, length
caps matching the API schema, RLS + updated_at trigger).
- narrative-service.ts: getNarrative / upsertNarrative.
- /api/.../arsredovisning/narrative GET + POST. POST does an explicit
period-ownership pre-check before the upsert.
- buildArsredovisningData loads persisted narrative as override layer
(caller-supplied overrides → persisted → boilerplate).
- ÅR page replaces the URL-query-param hack with a Spara button + saved
indicator. The PDF download URL is plain again — no narrative content
in access logs, browser history, or CDN logs.
- PDF route stops parsing description/events/disposition query params.
Also closes the GDPR Art.25(1) finding the bot flagged in PR #509.
Fastställelseintyg PDF page
- New 7th page in ArsredovisningPDF after Underskrifter. Carries the ÅRL
8 kap 3 § attestation text + the resultatdisposition + a signature slot.
- Without this page Bolagsverket rejects the filing — flagged in the round-2
Swedish review on PR #509.
K2 aktiekapital note + framework guard
- buildK2Noter now takes entityType. Note 1 only claims K2 when the
company is an AB; non-AB gets a generic principles statement so we
don't falsely assert a framework. Future K3 election will flip this
branch when it lands.
- New aktiekapital note (required K2 note for AB per BFNAR 2016:10 ch.18).
Reads aktiekapital / antal_aktier / kvotvärde from company_settings;
emits a "saknas — komplettera under Inställningar" placeholder when
missing.
Manual "Mark as signed" PATCH + UI button
- New PATCH /signatures/[signatureId] — flips pending → signed (manual
/ paper flow) or pending → declined. Real BankID wiring is Phase 9 and
will use the same markSignatureSigned helper with the BankID callback
as the trigger.
- ÅR page renders a "Markera som signerad" button on every pending row.
Small cleanups all flagged in PR #509 reviews
- AccrualProposal.reverses_on type: '' → null. The future accrual-reversal
cron will filter `reverses_on IS NOT NULL`; an empty string would
silently match.
- ArsredovisningData.company.sate → city. The typo carried into the type
in earlier phases; renaming now before any external consumer takes a
dependency.
- signer_name CHECK length 200 at storage layer (matches the API .max(200)
added in PR #509 round-2 — GDPR Art.25.2 belt-and-braces).
- Soliditet equity filter now has a code comment explaining the K2 vs K3
branch the bot wanted documented for the future K3 migration.
Explicit follow-ups (each merits its own focused PR):
- Real BankID signing — needs provider choice + polling + QR. Phase 9.
- Accrual reversal cron — auto-flip 17xx/29xx accruals on Jan 1 of next FY.
- Medelantal anställda proper annual average — needs salary-run aggregation.
- Vacation avgifter age-tier split (10.21 % for 67+) — needs upstream
vacation-liability report to expose age.
- K2 noter expansion (lån till närstående, eventualförpliktelser detail).
Verification
- 94 unit tests pass (bokslut + MCP subsets)
- Zero typecheck errors on any touched file
- Zero lint errors on any touched file
- Migration 20260517140000 applied to remote Supabase via MCP
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(bokslut): address PR #511 round-1 — 3 P1s + 3 real concerns
3 P1s from Greptile (all real bugs):
- entityType default reintroduced the K2 false-assertion. build-data.ts
defaulted `entity_type ?? 'aktiebolag'`, which means every unconfigured
company would still claim K2 in Note 1 — exactly the false-assertion the
framework guard was added to prevent. Now defaults to 'unknown' and the
guard treats that as not-K2. New warning surfaces in the data so the UI
can prompt the user to fill in företagsform.
- Signatures PATCH ignored the URL fiscal-period id. The route destructured
`id` from params but never used it as a filter, so PATCH /periods/A/
signatures/SIG_FROM_B succeeded silently — broken REST contract + IDOR
across periods. Rewrote the handler to do a single UPDATE with all four
filters: id, company_id, fiscal_period_id, status='pending'. Missing row
returns 409 SIGNATURE_INVALID_TRANSITION instead of silent 200.
- Signatures state-machine guard was missing. Without status='pending' in
the WHERE clause, an already-signed signature could be flipped back to
declined (or vice-versa). Now part of the consolidated UPDATE above.
3 real concerns:
- Narrative GET lacked ownership pre-check. POST already had it; mirroring
on GET so a valid JWT for company A can't probe / enumerate company B's
period IDs through the narrative endpoint.
- Narrative POST lacked period-lock check. BFL 5 kap 5 § makes
räkenskapsinformation immutable after filing — editing the
förvaltningsberättelse on a closed/locked period now returns
PERIOD_LOCKED.
- Aktiekapital placeholder text would land in Bolagsverket-filed PDF body.
When aktiekapital fields are missing, the note now omits entirely and a
warning surfaces in the ArsredovisningData.warnings array — the UI flags
it pre-download with a "Innan inlämning till Bolagsverket" list. Same
surface picks up the entityType=unknown and entityType=non-AB warnings.
Plus 2 schema improvements from Swedish review:
- AGM date persistence. Fastställelseintyg date was a literal "____" blank,
defeating the point of a generated PDF. New agm_date column on
arsredovisning_narratives + UI date input + PDF now renders the saved
date. When missing, the warning surface flags it.
- Composite UNIQUE constraint on (company_id, fiscal_period_id) instead of
just fiscal_period_id. UUIDs don't collide across tenants in practice
but the constraint should match the tenant boundary so a logic error in
onConflict resolution can't write to another company's row. Migration
20260517160000 drops the old constraint and adds the composite.
Verification
- 94 unit tests pass
- Zero typecheck errors on any touched file
- Zero lint errors on any touched file
- Migration 20260517160000 applied to remote Supabase via MCP
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(bokslut): address PR #511 round-2 — 5 real concerns + BFL/GDPR conflict
5 real concerns from the round-1 bot re-eval:
- Narrative SELECT * leaked user_id to the frontend. getNarrative and
upsertNarrative now project an explicit NARRATIVE_API_COLUMNS list
(id, company_id, fiscal_period_id, narrative fields, agm_date, updated_at).
user_id and created_at stay server-side. NarrativeRow type updated to
match. Closes Art.25.2 + 2× A.8.3.
- agm_date validated only as YYYY-MM-DD regex. '2024-13-99' passed Zod
and surfaced as a Postgres 500 instead of a 400. Added a refine() that
parses with new Date() and confirms ISO round-trip equality, so invalid
calendar dates return a clean structured-error.
- agm_date had no range check. ÅRL 8:3 → 7:10 §§ requires the AGM to be
held after period end and within 6 months for privat AB; build-data
warnings now flag agm_date <= period_end (impossible) and agm_date >
period_end + 6 months (deadline). Warning surface in the UI already
picks these up from the existing list.
- Fastställelseintyg signer label "Styrelseledamot / VD" conflated
legally distinct roles per ÅRL 8:3 → 6:6-7 §§ — a VD without board
membership cannot sign. Label is now "Styrelseledamot (närvarande vid
stämman)" and the body text references the AGM's resolution
("stämmobeslutet") rather than the board's proposal — the AGM votes,
and it is the vote that must be certified.
- Aktiekapital warning suppressed for entityType='unknown'. The maybeAb
branch in buildK2Noter now fires for both 'aktiebolag' and 'unknown'
so an unconfigured company that's actually an AB still gets prompted
to fill in aktiekapital before filing. Note body stays omitted when
fields are missing; only the warning surfaces.
BFL × GDPR conflict (new migration 20260517180000):
- Both arsredovisning_narratives and arsredovisning_signature_requests had
user_id with ON DELETE CASCADE → auth.users. BFL 7 kap 1 § requires
räkenskapsinformation to be retained for 7 years; GDPR Art.17 erasure
or membership revocation would silently delete filed årsredovisning
narrative + BankID signature evidence. BFL wins for filed financial
records — user_id is now nullable with ON DELETE SET NULL on both
tables. The company FK keeps its CASCADE (company deletion takes its
räkenskapsinformation with it; that's a separate workflow).
Deliberately not chasing on this round:
- ISO A.8.12 historical PDF query-param logs — process item for the risk
register, not code (the leak path is closed in this PR's first commit).
- "Collapse the two narrative migrations" — both already shipped to
remote and merged; the interim window is in the past.
- "user_id on row vs separate audit log" — architectural debate; tracked
but out of scope for this PR.
- Multi-signer fastställelseintyg + DB-level period-lock trigger — bigger
scope, each merits a focused follow-up.
Verification
- 89 unit tests pass
- Zero typecheck errors on any touched file
- Zero lint errors on any touched file
- Migration 20260517180000 applied to remote Supabase via MCP
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>