* docs(api): ship 4 cookbook recipes (close docs polish backlog)
Promotes the four placeholder cookbook entries to full narrative recipes
matching the Stripe-grade quality bar set by quickstart + webhooks.
Closes the docs follow-up bucket from the PR-500 description's deferred
list.
Recipes:
- ingest-bank-transactions: bank-file upload (CSV / CAMT.053 auto-detect)
→ async poll → list uncategorised → suggest-categories → categorize
(single + batch) → match-invoice / match-supplier-invoice. Multicurrency
notes covering Riksbanken FX lookup and the kontantmetoden partial-
payment guard.
- file-vat-declaration: GET /reports/vat-declaration → rutor 05–62
walkthrough → GL reconciliation block → 2026-04-01 livsmedel 12% → 6%
transition explicitly covered (delivery_date supply-date rule) → voucher-
gap pre-flight → period lock workflow → manual Skatteverket Mina Sidor
submission with confirmation-reference capture → EU / reverse-charge
/ import handling.
- run-payroll-and-agi: draft → calculate → approve → mark-paid → book →
generate-agi state machine. Per-step idempotency, strict-mode book
failure semantics, förmånsbeskattning + bilförmån + bruttolöneavdrag
vs nettolöneavdrag ordering. AGI XML download for manual Mina Sidor
upload (direct API submission requires BankID via the Skatteverket
extension, not the public REST surface).
- year-end-closing: IB/UB continuity check per BFL 5 kap → voucher-gap
pre-flight → missing-documents pre-flight → lock (reversible) → year-
end async operation (resultatdisposition + periodiseringsfond +
överavskrivningar + bolagsskatt + opening-balance batch) → close
(irreversible per BFL 5 kap 8 §, typed-phrase confirmation) →
årsredovisning + INK2/NE generation. Brutet räkenskapsår variant
documented.
Each cookbook follows the same shape as the existing quickstart and
webhooks recipes — concrete curl commands, response samples, common
pitfalls, next-steps cross-links. Lengths are deliberately uneven: the
year-end recipe is longest because the consequences of getting it
wrong are most severe (BFL violations, irreversible close).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): V16 audit_log entries for webhook lifecycle + secret rotation endpoint
Two intertwined changes that together close the "real audit attribution
gap in actively-used routes" item from the PR description.
1. POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret
New endpoint that issues a fresh HMAC signing secret and invalidates
the previous one immediately. Returns the new secret EXACTLY ONCE in
the response, mirroring the create-time contract. Required scope:
webhooks:manage. Idempotency-Key mandatory.
Rotation is instant — no grace period. Documented workflow: stage the
new secret on the receiver side (separate config slot, not yet active)
→ POST /rotate-secret → activate the new secret on the receiver →
POST /webhooks/{id}/test to verify. A "previous_secret" column with
TTL-based grace window (Stripe-style) is the natural follow-up; the
instant-rotation shape ships first because it closes the "secret
leaked, need to rotate now" use case with minimum new surface.
The route is wired into load-routes.ts and lib/auth/scopes.ts. Spec
snapshot updated.
2. V16 audit_log entries on every webhook lifecycle mutation
The audit_log column shape (user_id, company_id, action, table_name,
record_id, actor_id, old_state, new_state, description) is exactly
what V16 / Art.32(1)(b) / A.8.24 audit-trail requirements call for.
Wired entries on:
- POST /webhooks (create) — action INSERT, new_state captures the
row WITHOUT the secret (signing material must not land in the
audit trail; only secret-event metadata).
- PATCH /webhooks/:id (update) — action UPDATE, before/after pair so
reviewers can reconstruct exactly what changed.
- DELETE /webhooks/:id (delete) — action DELETE, old_state snapshot
so the row's prior state survives the delete.
- POST /webhooks/:id/rotate-secret — action SECURITY_EVENT, new_state
carries the event marker only (no secret value).
- dispatcher.disableWebhook (auto-disable on HTTP 410 / redirect /
url_unsafe) — action SECURITY_EVENT, before/after capturing the
disable cause for SIEM correlation.
actor_id is set to ctx.apiKeyId on caller-driven entries so the
audit row points back to the specific API key that triggered the
change (PR-500 round-1 CC6.3 finding: actor attribution via
created_by_api_key_id alone leaves a gap if a key is deleted —
keeping the actor_id in audit_log closes that).
4 new integration tests cover the rotate-secret happy path, 404,
401 unauthorized, and Idempotency-Key required. The existing
webhook integration tests continue to pass because the audit_log
inserts fall through to the default mock response (no-op) without
disturbing the per-table queues.
39 integration tests pass on the webhook surface (+4 vs round-2).
Total: 3588 unit tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-501 review round 1 — correctness + Swedish compliance
Round 1 of review fixes. Two real correctness bugs Greptile caught, two
audit-trail gaps, and four Swedish-compliance errors in the cookbook
prose. Compliance Swarm has 17 findings (0 blocking); the 4 architectural
items (secret-at-rest encryption, dedicated rotate scope, rate-limit on
rotation, URL redaction) remain deferred with rationale.
Greptile (3 / 3 — all addressed):
1. rotate-secret silent 0-row UPDATE — fixed by adding
`.select('id').maybeSingle()` to the UPDATE and returning NOT_FOUND
when no row was touched. Closes the TOCTOU window between the
existence check and the secret update; a concurrent DELETE no
longer hands the caller a freshly-generated secret that no webhook
in the database matches.
2. DELETE handler audit_log silently skipped when prior snapshot is
null — fixed by writing the audit row UNCONDITIONALLY with
`old_state: prior ?? null` and a degraded description when the
snapshot is unavailable. A successful DELETE now always produces
exactly one audit row (CC6.3 attribution contract).
3. Typo "bookslut" → "bokslut" in year-end-closing.ts.
Compliance Swarm code-quality items addressed:
4. PATCH new_state now derived from the DB-confirmed returned `data`
with an explicit field allowlist, not from the request-body-derived
`update` object (A.8.11 / V16.1.1). Closes the gap where a future
trigger that rejects a field would leave the audit trail out of
sync with the actual stored state.
5. All four route-side audit_log inserts (create, update, delete,
rotate-secret) now capture the insert error and emit a structured
warning via ctx.log; mirrors the dispatcher pattern (CC7.2).
6. Dispatcher null-user_id path now emits a structured warning instead
of silently skipping the audit_log entry — SIEM can alert on the
gap (CC7.2 / V16.1.1 / A.8.15).
Swedish compliance (cookbook content fixes — all real errors):
7. VAT cookbook ruta 06 label corrected: "Övrig försäljning (ej
skattepliktig)" → "Momspliktig försäljning som inte ingår i ruta 05"
(Skatteverket's verbatim label). The old label conflated exempt vs
zero-rated supplies and would cause integrators to omit export /
EU zero-rated sales from box 06.
8. Livsmedel rate-change framing rewritten: leads with the supply-date
rule (ML 1 kap 3 §) as the decisive date, not invoice_date. The
old opening sentence ("invoices created with invoice_date >=
2026-04-01 book to 2631") was wrong on its face — a copy-paste
reader would mis-book pre-cutover deliveries invoiced in April at
the new 6% rate.
9. Reverse-charge EU 2645 note adds the blandad-verksamhet caveat:
"Net zero impact on cash flow" only holds when full avdragsrätt
applies; partial avdragsrätt requires proportional restriction
per HFD 2023 ref. 45.
10. Payroll cookbook age bounds corrected: "under-25 / over-66" →
"18-22 years old (born 2003-2007) / 67+ from 2026", per Prop.
2025/26:66. The old bounds would cause integrators to apply the
reduced rate (20.81%) to 23-24-year-olds who must pay 31.42%,
producing non-compliant AGI files.
11. Payroll cookbook BAS 2615 corrected to 2731 (Avräkning sociala
avgifter). 2615 is "Utgående moms vid import" in BAS 2026 — using
it for the payroll liability would misclassify a payroll payable
as an import-VAT payable and break moms reconciliation.
12. Year-end cookbook periodiseringsfond cap base corrected: IL 30
kap 5 § cap is on taxable profit BEFORE the periodiseringsfond
deduction itself (and after schablonintäkt is added back). Note
on materiellt samband (BFNAR 2016:10 kap 13) added — the
reservation is BOOKED on 2110-2139, not declaration-only.
Deferred to follow-ups (architectural / out of scope for round 1):
- Secret-at-rest encryption (CC6.1 / Art.5(1)(f)): PR-1 architectural
carryover, applies to existing webhooks.secret column too.
- Dedicated `webhooks:rotate` scope (CC6.3 informational): introduces
friction without closing a real gap when the only caller-driven
action gated by `webhooks:manage` is the rotation itself.
- Per-route rate-limit on :rotate-secret (Art.32 abuse case): part
of the wider per-route rate-limit pass already on the deferred list.
- webhook_url redaction in audit_log (Art.5(1)(c)): URLs are admin-
supplied configuration values with no expected sensitive params;
truncation would degrade audit value for legitimate review.
23 webhook integration tests pass locally (no regressions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-501 review round 2 — atomic mutations + audit completeness + cookbook compliance
Round 2 of review fixes. Compliance Swarm flagged refinements to the
round-1 fixes; Swedish-compliance had a fresh batch of cookbook items
(including a self-contradiction in payroll pitfalls I missed last
round). All addressed.
Code changes — atomicity + audit completeness:
1. rotate-secret collapsed to a single UPDATE … RETURNING (V8.2.1).
The preflight existence-check SELECT was redundant after round 1
added .select().maybeSingle() on the UPDATE — the same null-row
signal indicates non-existence, but in one round trip with no
TOCTOU window. RETURNING `name` so the audit_log description still
carries a human identifier without a second read.
2. DELETE handler collapsed to atomic .delete().select().maybeSingle()
(V8.2.1). Eliminates the pre-read TOCTOU window entirely. A 0-row
delete (already-deleted webhook) still returns 204 — idempotent
DELETE — and the audit entry captures the attempt with old_state:
null. Description discriminates the two cases ("deleted: name" vs
"delete attempted on missing id").
3. Cache-Control: no-store, no-cache, must-revalidate, private on
the rotate-secret response (Art.25). The HMAC secret is sensitive
credential material returned exactly once; this header prevents
any intermediary (CDN, proxy, gateway access log, browser cache)
from persisting the response body in a store with a different
retention policy than intended.
4. Dispatcher auto-disable now writes the audit_log entry
UNCONDITIONALLY (A.8.15 / V16.1.1 / CC7.2). Previously a null
prior snapshot or a legacy null user_id caused the audit row to
be silently skipped — only a warn log was emitted. Now writes
user_id=NULL when unavailable (post-multi-tenant-refactor schema
allows it; row is invisible under user RLS but queryable under
service-role review, which is correct for system-initiated
SECURITY_EVENT records). Description discriminates the snapshot-
available / snapshot-unavailable cases.
Swedish compliance — cookbook content fixes (all real errors):
5. VAT cookbook rounding rule corrected: SFL 22 kap 1 § mandates
TRUNCATION of öre (Math.floor for positive amounts), not half-up
rounding. Last round mislabeled this as "Math.round (half-up)";
the SRU filing skill is canonical and uses truncation. Using
Math.round would produce values that differ from Skatteverket's
expectations and cause GL-reconciliation mismatches at the öre
level.
6. VAT reconciliation block now includes 2614 (Utgående moms vid
omvänd skattskyldighet, matches ruta 30). The previous list of
2611/2621/2631/2641/2645 omitted 2614; a reconciliation that
skips it would show rutor_match_gl: true even when the 2614
balance is non-zero and un-reconciled.
7. Livsmedel rate-change adds a one-sentence caveat for continuous/
subscription supplies — the supply-date framing in round 1 was
too tight for cases where multiple deliveries roll up into a
subscription. Confirms against ML 1 kap 3 § rather than
assuming a single delivery date is decisive.
8. Payroll pitfalls bullet contradicted step 2 — "Employees under 26
(2024 rule for 2026 birth year ≥ 2001)" rewritten to match step 2:
"18–22 years old at the start of 2026 (born 2003–2007) AND 67+
from 2026". An integrator reading only the pitfalls section
would have applied the reduced rate too broadly, producing
underpaid arbetsgivaravgifter and a non-compliant AGI.
9. Year-end periodiseringsfond cap now states schablonintäkt explicitly:
1.94% × outstanding prior-year balance (SLR + 1% for 2026) is
ADDED to taxable income before the 25% cap is computed. Last
round mentioned the "BEFORE the periodiseringsfond deduction"
ordering but elided the schablonintäkt step; omitting it
produces a cap that's too low when prior-year reserves exist.
10. Year-end SRU format characterization corrected: SRU is plain text
encoded in ISO 8859-1, NOT XML. iXBRL (XML-based) is the
Bolagsverket digital annual-report format — a separate artefact
for a separate authority. Round 1 conflated them.
Deferred (architectural / out of scope, documented in commit):
- Audit-log dead-letter queue / SIEM alert escalation (Art.32 /
A.8.15): infra setup, not code-PR scope. The warn-on-failure path
is the in-process surface; durable delivery is a SRE/SIEM concern.
- Secret encryption at rest (CC6.1): PR-1 architectural carryover.
- webhook_url + description redaction in audit_log (Art.5(1)(c)):
URLs are admin-supplied configuration values; redaction would
degrade audit reconstructibility without closing a real PII gap.
- PATCH old_state TOCTOU via Postgres function (CC6.3): the read-
then-write pattern produces an append-only audit row capturing
the read state; the small race window is non-load-bearing for
audit purposes and a stored-procedure refactor exceeds the
cost/value.
23 webhook integration tests pass locally (no regressions). Type-check
clean for all changed files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-501 review round 3 — real cookbook tax errors + cache-control on create
Round 3 closes two tax-impact errors in the cookbooks plus the
consistency gap on the create response. Compliance Swarm's remaining
findings are recurring architectural carryovers or oscillation against
prior rounds.
Real cookbook errors (would mislead integrators):
1. Schablonintäkt rate corrected. Round 2 hardcoded 1.94% — that's the
2024 rate (SLR 0.94% + 1%). For 2026 SLR is 2.55%, so the rate is
3.55%. A wrong rate produces a too-low add-back, a too-high
periodiseringsfond cap, and an IL 30 kap compliance error for any
integrator copying the cookbook number. Rewrite to describe the
formula (SLR + 1%, where SLR is the Riksbank statslåneränta on
30 Nov of the preceding year) with the 2026 figure as an example,
and note the engine reads the canonical rate from `tax_rates`.
2. SRU format is a TWO-file pair, not one. Round 2 correctly said
"plain text encoded in ISO 8859-1 (NOT XML)" but described it as a
single file. Skatteverket requires both INFO.SRU (metadata header)
AND BLANKETTER.SRU (declaration body) uploaded together — a
single-file upload is rejected by their validation. Fix the prose
to describe the two-file pair explicitly.
Code consistency:
3. POST /webhooks (create) now returns the same
`Cache-Control: no-store, no-cache, must-revalidate, private` +
`Pragma: no-cache` headers as the rotate-secret endpoint (A.8.12).
Both endpoints return the HMAC secret exactly once; both need the
same intermediary-cache prevention.
Smaller cookbook refinements (round 3 bot follow-ups):
4. VAT reconciliation block now includes 2615 (Utgående moms vid
import, matches ruta 60) — the previous list covered 2611-2645
but omitted import VAT. A reconciliation that skips 2615 would
show rutor_match_gl: true falsely for any importer.
5. Service supply-date fallback statement qualified to "one-off
service supplies where delivery and invoice coincide" — long-
running service contracts (subscriptions, maintenance) have
per-delprestation skattskyldighet and need an explicit
delivery_date per billing cycle.
6. Payroll elder-reduction boundary clarified: "67 years or older
AT THE START OF the income year (1 January 2026)" — a 66-year-
old whose 67th birthday falls in February does NOT qualify in
2026. Prevents misreading the pithy "67+ from 2026" as a
birthday-during-year rule.
Bot oscillation (skipping with rationale documented here for posterity):
- Compliance Swarm Art.25 now asks to REMOVE webhook_url from DELETE
old_state — direct contradiction with CC6.3's round-1 ask for
complete attribution. webhook_url is admin-supplied configuration,
not PII; keeping it preserves audit reconstructibility.
- Swedish-compliance flags the unconditional re-delete audit row as
"polluting" the behandlingshistorik — direct contradiction with
Compliance Swarm V8.2.1 + CC6.3 round-1 / round-2 asks for
unconditional writes. The audit_log is operational, not BFL
räkenskapsinformation (which lives on journal_entries and
related tables under explicit immutability triggers). Audit
trail completeness wins over BFL purity for this table.
Architectural carryovers (already documented in earlier commit
bodies as deferred to follow-up PRs):
- Secret encryption at rest (CC6.1, recurring)
- Audit-log dead-letter / SIEM alerting (Art.32 / A.8.15, infra)
- webhook_url userinfo stripping (A.8.11 low — URLs are admin-
configured, no expected credentials; validating at registration
would be a registration-time concern, not audit-time)
23 webhook integration tests pass. Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>