Commit Graph

7 Commits

Author SHA1 Message Date
Jakob Wennberg 0521c385d2 feat(transactions): underlag status badges + attach dialog; auto-expire stale pending ops (#712)
* feat(transactions): per-row underlag status + attach-document dialog

- New "Matcha mot underlag" dialog on /transactions (inbox pick or fresh
  upload), the tx→doc mirror of the Documents view's matcher
- Per-row Underlag/Underlag saknas badges on booked history rows, driven
  by computeJeUnderlagStatus — same posted-only, exemption-aware scope as
  the worklist count so badge and count never disagree
- attach-document route + commit dispatcher now propagate the doc onto
  the verifikation when the tx is already booked (BFL 5 kap 6 §), with a
  409 guard for docs consumed by a different verifikation, idempotent
  re-attach (no same-value rewrite under period lock), and an honest 409
  when the period-lock trigger blocks the propagation
- Booking-dialog doc links also pin the doc to the transaction row
  (first linked doc wins) via the link route's new transaction_id param

messages/{sv,en}.json also carries the strings for the pending-ops
expiry UI that lands in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(pending-operations): auto-expire stale staged operations after 30 days

- New daily cron (02:30 UTC, vercel.json + both docker crontabs) flips
  >30-day-old pending ops to rejected with the dispatcher's
  { auto_rejected: true, reason: 'expired' } result_data shape — rows are
  never deleted, the table is the audit trail
- /pending renders an "Utgick automatiskt" badge + detail line for these,
  orders terminal tabs by resolved_at so a fresh expiry sweep isn't
  buried, and adds a first-time-reviewer explainer
- Origin labels spell out where a proposal came from (AI chat, MCP key,
  API, cron) instead of the raw actor_label
- agent_chat actor type added to PendingOperationActorType/AuditLogEntry
  (DB CHECK already widened in 20260519090000) and to the agent filter
- ApprovalCard notes that ignoring a proposal is safe

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(mcp): surface the client telemetry marker in connect instructions

Tag the connector URLs shown in ApiKeysPanel, the connect-claude doc and
the gnubok-mcp README with ?client=<surface> (claude-connector /
claude-code) and GNUBOK_CLIENT=claude-desktop for the npm bridge.
Telemetry-only — the server already reads the param/header; this just
lets us measure which Claude surface connected.

The claude mcp add copy blocks quote the URL: an unquoted ? in the query
string trips zsh globbing ("no matches found").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review: fix stale-closure badge flip + zod-validate link route body (PR #712)

- handleDocumentAttached read journal_entry_id off the render-time
  transactions snapshot; if the list changed while the attach dialog was
  open the optimistic badge flip was silently skipped. Read it off the
  dialog's own subject (attachDocTx) instead.
- POST /api/documents/[id]/link now validates the body against the new
  LinkDocumentSchema (uuid-strict, all four fields) instead of a bare
  presence check on journal_entry_id — same canonical VALIDATION_ERROR
  envelope. Test fixtures switched to real UUIDs accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:13:51 +02:00
Jakob Wennberg 3c85082f3a fix(docs): replace dead gnubok.app domain with app.gnubok.se across public docs (#683)
gnubok.app has no DNS delegation (the .app TLD returns no NS records for
it), so every https://gnubok.app/... link in the public docs was dead —
including the OAuth connector URL a Claude Connectors Directory reviewer
would paste from the new Connect-with-Claude page (#682), and the docs
links in the gnubok-mcp npm README that ships in the tarball.

app.gnubok.se is the live production domain (the MCP endpoint there
correctly answers 401 unauthenticated) and was already the default
GNUBOK_URL in the npm bridge and the branding-service appUrl fallback.

Mechanical swap: 55 occurrences across 11 files, all https:// URL form
(verified no emails or identifiers). Docs content only — no code paths.
Also resolves the greptile domain-mismatch finding on #682.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:00:27 +02:00
Jakob Wennberg a894af11d0 feat(mcp): tool titles + Connect-with-Claude docs for Connectors Directory readiness (P0-4) (#682)
* feat(mcp): tool titles + Connect-with-Claude docs for Connectors Directory readiness

Add a top-level Tool.title (MCP spec 2025-06-18) to all 92 MCP tools and
flow it through the tools/list serializer for the Claude Connectors
Directory. Trim the ~10 longest tool descriptions toward 180-200 chars
(semantics + agent hints preserved) to partly offset the added bytes, and
raise the payload-size bench ceiling 32K → 36K with headroom reserved for
upcoming Skatteverket tools.

Ship a "Connect with Claude" docs page (OAuth 2.1 connector + npx
gnubok-mcp stdio bridge, sample sandbox prompts, 10-minute reviewer test,
support pointer), wire it into the docs nav, and add a README to the
packages/gnubok-mcp npm bridge. Bump one categorized sandbox business
expense above 4 000 kr so the VAT close-check demo surfaces a high-value
receipt-less expense.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): restore six spaces lost in the description-trimming pass

Review finding (Greptile P2): the trim deleted single spaces in six tool
descriptions ("Stages foruser", "länkatill", "kundfordran(1510)", …),
producing malformed text in tools/list. All six were pure space deletions
with no token gain — restored verbatim. Swept all 92 descriptions for
residual merge artifacts; the two remaining matches are pre-existing
function-call notation from main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(compliance): correct BFL citation in seed + mark 4000 kr as app heuristic

Swedish compliance review findings:
- The seed comment cited BFL 5 kap 6§ as requiring verifikat on high-value
  affärshändelser; 6§ governs verifikationens innehåll. Rephrased: BFL 5 kap
  6-7§ require every affärshändelse to be documented — the 4 000 kr cut-off
  is the tool's own heuristic, not a statutory threshold.
- The connect-claude docs read as though 4 000 kr were a legal floor
  (conflatable with the förenklad faktura ceiling, ML 17 kap 26-28§).
  Clarified that BFL requires underlag regardless of amount.

Dismissed as by-design: the gnubok_sk_test_... README placeholders
(standard practice; the prefix schema is already public in llms.txt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:47:21 +02:00
Jakob Wennberg c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +02:00
Jakob Wennberg 20989379bb feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand (#585)
* feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand

Sandbox now ships with seeded suppliers, supplier invoices, an asset,
a verified agent_profile, and pending operations so the demo company
exercises every prod surface. Server-side `guardSandbox()` short-
circuits any AI or paid-external API call (Bedrock chat/composer,
Resend invoice send, Riksbanken FX, VIES, etc.) and the AgentSheet
swaps in a SandboxAgentPreview that explains what's gated and offers
a register CTA. DashboardContent no longer mounts the
NewUserChecklist when the agent is already built, fixing the path
that let sandbox users still trigger /onboarding/agent.

Visible branding flips from Gnubok to Accounted: new BrandWordmark
component (Hedvig Letters Serif 700), new app/icon.png + PWA icons
generated from the accounted icon, default appName updated. URLs,
header names, API key prefixes, hostnames, and event/cookie/
localStorage keys keep `gnubok` — the rebrand is visual only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sandbox): hardcode supplier-invoice arrival numbers in seed

get_next_arrival_number is MAX(arrival_number) + 1 against the same
table we're about to insert into. Calling it twice before either row
lands made both calls return 1, which then violated the
(company_id, arrival_number) unique index — POST /api/sandbox/seed
500'd on first sandbox start.

The seeded company is brand new in this branch so 1 and 2 are
guaranteed unused; hardcoding side-steps the race entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sandbox): set paid_amount=0 on unpaid supplier invoice

PostgREST normalizes the column set across rows in a bulk insert, so
the second supplier invoice (Espresso House, status=registered) was
being sent with paid_amount=null because the first row (Telia, paid)
set it. supplier_invoices.paid_amount is NOT NULL DEFAULT 0; the
default only kicks in when the column is *absent* from the payload,
not when it's explicitly null. Set it inline to side-step the
normalization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sandbox): set actor_type=agent_chat on seeded pending_operations

pending_operations only allows user-scoped INSERTs via the
`pending_operations_chat_insert` policy, which requires
actor_type='agent_chat' alongside auth.uid()=user_id +
company membership. The seed was inserting with the default
actor_type='user', tripping the RLS check.

Also lift risk_level from preview_data (where it was unused) onto
the row itself, matching the column added in
20260430120000_pending_operations_actor_and_risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pr-review): address PR #585 review feedback

Fixes called out by the core-only CI check, Greptile, and the
compliance + Swedish-accounting bots:

- AGI Programnamn pinned back to 'gnubok' (CI blocker). The XML
  Skatteverket receives must keep the stable software identifier
  regardless of the visual rebrand — same rule as the v1 health
  endpoint's `service: 'gnubok'` literal.
- handleCreateAccount in SandboxAgentPreview + ChatEmptyState now
  wraps signOut() in try/catch so a transient Supabase failure
  doesn't strand the user on a dead button (greptile P2 × 2).
- /api/currency/rate hard-fails on missing companyId instead of
  conditionally skipping the sandbox guard (greptile P2 / compliance
  V8.2.1).
- topUpSandboxAdditions now delegates to ensureSandboxAgentProfile;
  the assistant persona lives in exactly one place across the seed,
  layout backfills, and top-up path (greptile P2 outside-diff /
  compliance SOC2 CC6.1).
- ensureSandboxAgentProfile drops the userId param and sets
  verified_by_user_id to NULL — synthetic seed data should not
  attribute verification to a real user (compliance V8.2.1 /
  GDPR Art. 25(2)). Errors now logged via the structured logger
  instead of being silently swallowed (V16).
- Sandbox seed swaps real-world company names (Telia, Espresso
  House) for clearly-synthetic Demo-prefixed brands using the
  5559... documentation org-number range (compliance A.8.33).
  Asset cost bumped 24 000 → 35 000 SEK so the demo clears the
  förbrukningsinventarier threshold and illustrates capitalization
  unambiguously (swedish-asset-accounting).
- Representation pending-operation preview corrected: VAT label
  fixed from 6% → 12%, and input VAT split between the avdragsgill
  (2641) and ej-avdragsgill (5811) portions to match
  swedish-vat / ML 8 kap rules (swedish-vat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pr-review): seed preview consistency + AGI Programnamn constant

Two last review-bot items before merge:

- Sandbox seed: the representation pending-operation preview was
  splitting the 240 SEK café meal 60/180 between 5810 and 5811,
  which is wrong for a single attendee under the 300 SEK / person
  avdragsgill cap (ML 8 kap) — the entire amount is fully
  avdragsgill in that case. Collapse the preview to a single 5810
  + 2641 + 2440 entry so it matches the supplier_invoice_items row
  1:1 and stops teaching demo users an incorrect bookkeeping
  pattern.
- Hoist the AGI Programnamn 'gnubok' literal into a named constant
  with a comment pointing to potential future Skatteverket vendor
  registration (per the swedish-compliance bot's nit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sandbox): avoid BFL duplicate-verification on pending op + fix VAT cap comment

Swedish-compliance bot caught two final nits:

- The pending operation for the Demokafé representation was using the
  same supplier_invoice_number as the already-seeded supplier_invoices
  row (88245). If the sandbox user approved the staged operation, the
  insert would have created (or attempted) a duplicate verification —
  BFL 5 kap. requires each affärshändelse be recorded exactly once.
  Swap the staged operation's invoice number to a distinct value
  (INKOMMANDE-2026-001) so approval cleanly creates a new row.
- The preview comment described the 300 SEK threshold as an
  "avdragsgill cap". The actual rule (ML 8 kap. 9 §) caps the
  deductible VAT at 25 % × 300 SEK × antal_personer = 75 SEK per
  person — the 300 SEK is the tax base, not the total. Math here is
  correct either way, but the comment now states the correct formula
  so future seed edits don't propagate the wrong understanding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:19:41 +02:00
Jakob Wennberg b94ed3bec2 feat(api): cookbooks + webhook audit_log + secret rotation (PR-500 carry-overs) (#501)
* 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öne­avdrag
  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>
2026-05-15 21:31:47 +02:00
Jakob Wennberg 3912c74a7b feat(api): Phase 6 PR-2 — docs polish (Stripe-inspired) (#497)
* feat(api): Phase 6 PR-2 — docs polish (Stripe-inspired)

Ships the developer-facing documentation surface for the v1 REST API.
Mirrors Stripe's structure (landing → cookbooks → concepts → reference
→ errors → changelog) at /docs/api with a sticky-sidebar layout in the
gnubok editorial-monochrome aesthetic. Every page is also served as
plain Markdown via a sibling .md URL so agents and LLM crawlers can
ingest the same content without HTML parsing — the existing /llms.txt
already promised /docs/api references that this PR makes real.

Single source of truth for endpoint metadata is the existing Zod
registry (lib/api/v1/registry.ts). The reference pages auto-generate
from it: adding a new endpoint surfaces in the docs on the next build
with no manual sync. The error reference pulls directly from
lib/errors/structured-errors.ts STRUCTURED_ERRORS.

CONTENT LAYER (lib/docs/):

- content/landing.ts — introduction, auth, base URL, response envelope,
  the four core principles (dry-run, idempotency, strict-mode, inline
  audit), pointers to every other section.
- content/versioning.ts — versioning + deprecation policy (Stripe dated
  format), idempotency, dry-run, strict-mode write semantics, inline
  audit blocks.
- content/webhooks.ts — webhook concept guide. Full Node.js (express +
  crypto) and Python (Flask + hmac) signature-verification samples that
  match lib/webhooks/signing.ts exactly. Lifecycle, event-type
  catalogue, payload shape, request headers, common pitfalls,
  auto-disable behaviour, audit + retention.
- content/errors.ts — generated from STRUCTURED_ERRORS. Groups by
  domain (generic, bookkeeping, periods, invoices, supplier-invoices,
  transactions, reports, imports, documents, salary, company, provider).
  Every code is anchorable so the docs_url field on every error
  envelope finally points somewhere real.
- content/reference.ts — generated from listEndpoints(). Groups by
  resource (companies, customers, invoices, suppliers,
  supplier-invoices, transactions, journal-entries, fiscal-periods,
  accounts, documents, employees, salary-runs, reports, imports,
  compliance, webhooks, operations, voucher-gap-explanations,
  reconciliation). Each endpoint section: summary, description,
  useWhen, doNotUseFor, pitfalls, scope, idempotent/reversible/dry-run
  flags, request + response examples.
- content/changelog.ts — initial entry for API version 2026-05-12
  covering every endpoint shipped in Phases 1-6. Lists what's coming
  in Phase 6 PR-3 (hardening + remaining cookbooks).
- content/cookbook/quickstart.ts — five-minute send-your-first-invoice
  guide. Demonstrates auth, dry-run, idempotency, audit-block patterns
  in one continuous narrative.
- content/cookbook/webhooks.ts — end-to-end webhook setup, sig
  verification, retry handling, idempotency on receiver side, replay
  patterns, auto-disable behaviour. Companion to the concept page.
- content/cookbook/index.ts — recipe registry. 4 placeholder recipes
  (ingest-bank-transactions, file-vat-declaration, run-payroll-and-agi,
  year-end-closing) link to their reference pages with a "coming after
  Phase 6 PR-3 hardening" note. Narrative cookbook quality benefits
  from a focused pass after the substrate stabilises.
- nav.ts — single source of truth for the sidebar nav, used by the
  layout AND the landing-page resource grid.
- markdown.tsx — shared <DocsMarkdown> component using react-markdown
  (already a dep) with Hedvig serif headlines, Geist mono code blocks,
  hairline section borders, paper-white surfaces — same editorial
  aesthetic as the dashboard.

LAYOUT (components/docs/DocsLayout.tsx):

Two-column sticky-sidebar layout. Top header carries the gnubok mark
+ section links (API reference, Cookbooks, Errors, Changelog,
openapi.json). Sidebar groups: Getting started, Cookbooks, Concepts,
API reference, Reference. Active page highlighted with the same
warm-beige bg the dashboard sidebar uses.

ROUTES (app/docs/api/, app/llms-full.txt/):

- /docs/api → landing
- /docs/api/errors → error reference
- /docs/api/webhooks → webhook concept
- /docs/api/versioning → versioning + idempotency + dry-run
- /docs/api/changelog → release notes
- /docs/api/reference → resource overview
- /docs/api/reference/[slug] → per-resource pages (19 resources, all
  generated from the registry; generateStaticParams listed)
- /docs/api/cookbook/[slug] → recipe pages (8 entries, 2 fully written
  + 6 aliases/placeholders)
- /llms-full.txt → everything concatenated for one-shot LLM ingestion

Every page has a sibling .md route (e.g. /docs/api/errors.md) serving
the raw Markdown for agents — same content, no HTML wrapper, same
5-min cache. Honours the existing /llms.txt promise that "every .md
URL under /docs/api is served as plain Markdown".

CI GUARD (lib/api/v1/__tests__/spec-snapshot.test.ts):

Vitest snapshot test that locks down (a) the endpoint count, (b) the
sorted set of method+path keys, (c) the set of distinct scopes
referenced. CI fails if any drift unexpectedly so a Zod-schema change
can't ship a silent API break — when you intentionally add/remove an
endpoint, run with -u to refresh the snapshot, review the diff, and
commit alongside the route change. The snapshot diff itself is a
self-describing changelog entry.

Initial snapshot: 100 endpoints, 17 distinct scopes, full key set
sorted. Fourth assertion in the test guarantees every endpoint
declares the agent-facing metadata (summary, description, useWhen,
doNotUseFor, pitfalls, example) the reference pages depend on — so a
registerEndpoint call that omits any of these fields is caught at CI
time rather than rendering an empty section in the docs.

INFRA TOUCH (lib/api/v1/load-routes.ts):

Added the 5 Phase 6 webhook route imports so the registry includes
them on the docs builders' path. Required for the /docs/api/reference/
webhooks page to render. The webhook routes' registerEndpoint calls
already exist; this just side-effect-imports them where the spec
generator can see them.

Coming in Phase 6 PR-3 (hardening — separate PR):

- 90-day TTL cleanup cron for non-accounting webhook deliveries
- claim_due_webhook_deliveries SQL function (FOR UPDATE SKIP LOCKED)
- Per-route rate limits on :test, :retry, webhook :create
- V16 audit-log on webhook lifecycle events
- DNS-rebinding pinned-IP HTTPS agent
- Integration tests for webhook routes + *.pg.test.ts for triggers
- Populated previous_attributes for update-style webhook events
- The remaining 4 cookbook recipes (ingest-bank-transactions,
  file-vat-declaration, run-payroll-and-agi, year-end-closing) once
  the engine surface is fully stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-497 review round 1 — CI fix + 5 small docs items

CI BLOCKER (the reason core-only failed):

1. **Type error on `[slug].md/route.ts` dynamic routes** — Next.js 16's
   route-type inference can't extract the dynamic segment from a
   directory whose name contains a literal suffix like `[slug].md/`. It
   types `params` as `Promise<{}>` and rejects our handler that
   declares `params: Promise<{ slug: string }>`. The framework still
   ROUTES requests correctly (URL `/docs/api/cookbook/quickstart.md`
   reaches the handler) — only the typed `params` is unusable.

   Fix: drop the typed `params` parameter on the two affected handlers
   (cookbook + reference) and parse the slug from `request.url.pathname`
   directly. Inline comment documents the workaround so the next person
   to touch these doesn't try to "fix" it back to the typed pattern.

GREPTILE INLINE (2 items):

2. **Python sample was missing `import json` and `import os`** — the
   webhook signature-verify sample uses both but only imported `hmac`,
   `hashlib`, `time`, and `flask`. Added the two missing imports.

3. **`buildResourcePages()` perf — called twice per request** (P2).
   Each call iterates every registered endpoint, groups by resource,
   sorts, and serialises Markdown for all 19 resource pages. Memoised
   at module level — the registry is populated once at module load and
   immutable for the process lifetime, so a single derivation is safe
   to cache. Halves the cost on the HTML routes' `generateMetadata` +
   page render pair, and the .md route handlers (which Next.js doesn't
   statically pre-render) are now constant-time after the first GET.

SWEDISH-COMPLIANCE PRECISION (3 items):

4. **`webhooks.ts`: behandlingshistorik vs räkenskapsinformation
   distinction.** The previous "Audit + retention" section conflated
   the two — webhook delivery rows are *behandlingshistorik* (system-
   event log) per BFNAR 2013:2 kap 8 §, NOT räkenskapsinformation
   themselves. The 7-year retention from BFL 7 kap 1 § attaches to the
   underlying verifikation/faktura/AGI XML in its own table, not to
   the delivery envelope. Updated the section to draw the distinction
   and clarify gnubok's 7-year retention on accounting-event delivery
   rows is an operational audit-trail policy, not a statutory
   obligation passed through to the integrator.

5. **`changelog.ts`: same distinction in the Phase 6 PR-1 entry** —
   replaced the "räkenskapsinformation" framing with the correct
   behandlingshistorik framing + the operational-policy note.

6. **Quickstart cookbook: ML 17 kap 24 § p.8 note about
   `beskattningsunderlag per skattesats`.** The "What just happened"
   section now explicitly notes that the rendered PDF contains every
   ML 17 kap 24 § field (including taxable amount per VAT rate) and
   that the JSON response's summary fields are convenience aggregates
   for the integration — the binding faktura content is the PDF.
   Forecloses the misreading that `subtotal + vat_total` is sufficient
   compliance.

7. **Changelog: BFL 7 kap caveat on SIE export.** The `/reports/
   sie-export` line now warns that a SIE4 export alone does NOT
   satisfy BFL 7 kap archiving obligations — SIE captures account
   positions and verifikationer but lacks system documentation and
   behandlingshistorik. Treat as a portability format
   (Fortnox/Visma/Bokio migration), not as a complete archive. Closes
   the misreading the swedish-sie-import-export skill flagged.

DEFENSIBLE DEFERS (round 1 final):

- **CM-8 SPDX-License-Identifier headers per file** (Compliance Swarm).
  The repo declares AGPL-3.0-or-later in the root LICENSE file, which
  satisfies licensing for the project as a whole. Per-file SPDX
  headers are a REUSE-conformance feature; we can address as a sweep
  across the entire codebase if/when REUSE conformance becomes a
  requirement. Out of scope for a docs PR.

- **`/llms-full.txt` exposes payroll endpoint metadata publicly**
  (A.8.12). By design — the entire point of the file is one-shot LLM
  ingestion of the public docs corpus. Endpoint METADATA (path, scope,
  description) is non-sensitive; actual payroll DATA is gated behind
  `payroll:read` scope and requires a real API key. Adding an auth
  gate would defeat the agent-discovery purpose.

- **Secret rotation endpoint** (Art.25(2)). Real product gap (delete +
  recreate is the current rotation path), but it's feature work, not
  docs. Tracked for Phase 6 PR-3 alongside the other webhook hardening.

- **DNS rebinding pinned-IP HTTPS agent** (Art.5(1)(f)). Already
  documented in this changelog as a Phase 6 PR-3 item; bot is just
  re-flagging that it's not yet shipped.

- **A.5.34 changelog cites GDPR Art.5(1)(c) for personnummer masking
  without linking privacy policy.** The citation is informational
  context for developers, not a privacy notice to data subjects. Data-
  subject notices live at /privacy. Adding a pointer is reasonable;
  adding it would consume real estate that's better spent on the
  technical detail. Defer.

- **Compliance Swarm A.5.21 third-party attribution in llms-full.txt**.
  False positive — all markdown content in this PR is original
  first-party text. No third-party snippets to attribute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-497 review round 2 — 6 small precision fixes

All CI green after round 1 (core-only fixed). Compliance Swarm: 7 → 10
findings is the documented oscillation pattern — net-new actionable
items are 6 small fixes; the rest are recurring defers
(plaintext-secret variants, SPDX, planned PR-3 features the changelog
already lists as "coming soon").

FIXED:

1. **Slug allow-list validation in `[slug].md` routes** (V1.2.5 ×2,
   medium). The cookbook + reference .md routes parse the slug from
   the URL pathname (round-1 workaround for Next.js 16's failed
   inference on `[slug].md/` directories) and pass it to a
   dictionary-based lookup. The lookup itself is safe — findRecipe /
   buildResourcePages can't reach SQL or filesystem from a bad
   slug — but the explicit allow-list gate keeps the contract safe
   if the lookup mechanism ever changes (file-load, RPC, etc.).
   Added `Set<string>(COOKBOOK_SLUGS)` + `Set<string>(RESOURCE_SLUGS)`
   guard before any lookup runs.

2. **Changelog: BFL 5 kap 5 § cited on both `/reverse` AND `/correct`**
   (swedish-compliance precision). The previous wording cited BFL 5:5
   only on `/reverse` (storno) and described `/correct` as plain
   "rättelse" — but BFL 5:5 governs rättelse generally, and storno is
   the canonical method of rättelse, so both endpoints satisfy 5:5.
   Updated to: "/{id}/reverse (storno) and /{id}/correct (rättelse) —
   both satisfy BFL 5 kap 5 § (storno is the canonical method of
   rättelse)".

3. **Changelog AGI: explicit that XML is for manual submission**
   (swedish-compliance / swedish-payroll). Previously said
   "/generate-agi produces AGI XML" — could be misread as
   auto-submission to Skatteverket. Now states explicitly that the
   response carries `data.xml` for the integrator to upload via
   Skatteverket Mina Sidor (or via the optional `skatteverket`
   extension), and that the AGI deadline (12th / 17th of the
   following month) is the integrator's responsibility. Aligns with
   the route file's existing doNotUseFor + pitfalls metadata.

4. **Quickstart: F-skatt note qualified**
   (swedish-invoice-compliance). The "What just happened" section
   previously said the PDF "contains the F-skatt note" — only valid
   if the seller actually holds F-skatt. Updated to: "The 'Godkänd
   för F-skatt' note is included automatically when
   company_settings.has_f_skatt is set — confirm this on the company
   settings page before sending invoices in production." Also
   tightened the beskattningsunderlag wording to mention "one line
   per distinct rate on multi-rate invoices" — closes the
   swedish-compliance note about the multi-rate claim in the landing
   needing explicit support in the cookbook.

5. **Cookbook placeholder VAT description: "compute and review" not
   "submit"** (swedish-vat). The placeholder previously said "Compute
   momsdeklaration rutor and submit to Skatteverket" — but no
   Skatteverket-submission endpoint exists in the v1 surface; the
   API only computes the rutor 05–62 values for manual filing.
   Updated description in BOTH cookbook/index.ts AND nav.ts (where
   the same string was duplicated): "Compute momsdeklaration rutor
   05–62 and reconcile against the GL before manual submission to
   Skatteverket." Title also flipped from "File a VAT declaration"
   to "Compute and review a VAT declaration".

6. **Cookbook nav for AGI: aligned to "generate" semantics**. nav.ts
   AGI summary used to say "file AGI" — same misreading risk as #3.
   Now: "Calculate, approve, mark paid, book, generate AGI XML for
   manual Skatteverket upload."

DEFERS (round 2 final — every remaining swarm finding is in one of
these buckets):

- **🟠 Art.32 plaintext webhook secret + 4 sibling framings** (V14,
  V11.1, A.8.24, CC6.1). Established defer per Stripe / GitHub /
  Slack precedent; documented inline in lib/webhooks/signing.ts.
  Bot is re-flagging via the docs surface this round; underlying
  position unchanged.
- **🟡 Art.5(1)(e) 90-day TTL non-accounting cron + V16 audit log +
  V2.4 rate limits**. All explicitly listed in the changelog as
  "Coming soon (Phase 6 PR-3 hardening)". Bot is reading the same
  text we wrote; not blocking.
- **🟠 A.8.11 personnummer masking lacks an automated test**. Real
  product-hardening request, but it's feature work in the employees
  test surface, not docs. Tracked.
- **🟡 CM-8 SPDX-License-Identifier headers per file**. Established
  defer from round 1 — root LICENSE covers AGPL-3.0-or-later for
  the project as a whole; per-file SPDX is a REUSE-conformance sweep
  that's its own effort.
- **🟡 SR-3 SBOM dependencies**. False positive — next/server, react,
  next/link, next/navigation are existing project deps, not new in
  this PR.
- **swedish-compliance "SIE disclaimer could note immutability
  requirement"** — the current disclaimer accurately calls out
  system documentation + behandlingshistorik as missing; adding
  immutability would over-stuff a one-line caveat. Defer with the
  understanding that the SIE skill itself documents the
  immutability requirement for any consumer that follows the
  reference.

If round 3 plateaus (Compliance Swarm count stable, no net-new
inline items), that's the merge-ready signal per Phase 4 lessons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-497 review round 3 — 4 small precision fixes

Compliance Swarm: 10 → 3 (down 70%) — net-new actionable items are
the 4 below; remaining 3 swarm findings are either trivial defense-
in-depth (URL decode, fixed here) or out-of-repo decisions
(personnummer disclosure DPO confirmation).

FIXED:

1. **URL-decode slug before allow-list check** in both .md route
   handlers (V1.2.5 ×2 low). The closed allow-list is pure ASCII so a
   percent-encoded value can't decode to a legitimate slug, but the
   explicit decode-then-check pattern keeps the contract correct under
   any future encoding-quirk runtime. try/catch around
   decodeURIComponent so a malformed % sequence (which throws) returns
   a clean 404 rather than a 500.

2. **Quickstart: explicit `delivery_date` requirement** (swedish-
   invoice-compliance / ML 17:24 field 7). The previous wording
   listed "supply date" as a covered field but didn't note that the
   API does NOT default delivery_date to invoice_date — integrators
   shipping invoices for goods delivered on a different date than the
   invoice date must pass delivery_date explicitly or the rendered
   PDF is non-compliant. Added explicit pass-it-yourself note.

3. **Quickstart: F-skatt strengthened from "verify" to "legal
   requirement"** (swedish-invoice-compliance / Peppol BIS 3.0
   SE-R-005). The previous "confirm on settings page" wording risked
   integrators treating the F-skatt note as optional UX. It's a legal
   requirement on every faktura issued by a company that holds
   F-skatt registration — and a FATAL Peppol BIS 3.0 validation
   failure (SE-R-005) for B2G invoices when missing. Reframed as a
   compliance assertion: the PDF includes it automatically when the
   setting is true; verifying the setting is correct before
   production is the integrator's responsibility.

4. **Changelog: SIE post-import VAT code reconfiguration warning**
   (swedish-sie-import-export). The /imports/sie line previously
   noted the file format support but didn't warn that SIE files do
   NOT carry VAT codes or tax-rate-to-account mappings. After
   migrating from Fortnox / Visma / BL / SpeedLedger / Bokio,
   integrators must manually reconfigure VAT codes before the first
   momsdeklaration — skipping this is the most common source of
   incorrect VAT submissions in migrated bookkeeping.

DEFERS (round 3 final — these are the architectural floor):

- **🟠 A.5.34 personnummer field name + masking logic disclosed in
  public docs** (Compliance Swarm). Defensible: documenting that
  personnummer is masked is a transparency benefit (GDPR Art.13/14
  intent), not a privacy disclosure risk. The DPO confirmation prompt
  is a reasonable governance ask but is an out-of-repo decision —
  the docs change is appropriate as written.
- **AGI penalty amounts (625 / 1,250 SEK)**. Operational guidance
  for integrators building deadline-tracking; not strictly API-doc
  material. The deadline (12th / 17th) is documented; integrators
  who automate compliance can read SFL for penalties.
- **VAT period thresholds in the cookbook placeholder**. Belongs in
  the actual cookbook content when written, not in the placeholder
  description.
- **`invoice.credited` event-naming verification**. False positive —
  the emitter uses `credit_note.created` (which IS in the docs);
  there is no `invoice.credited` event in the codebase. Naming is
  consistent.
- **Webhook retention sentence reordering** (swedish-compliance
  stylistic). Current wording leads with what delivery rows ARE
  (behandlingshistorik), then clarifies what they are NOT
  (räkenskapsinformation) — clean teaching arc, the qualifier is
  prominent. Reordering doesn't change clarity.

Round 3 stop signal hit (per Phase 4 lessons): swarm count plateauing
at the architectural floor with all remaining findings either
defers, false positives, or out-of-repo decisions. Greptile has
posted no inline comments since round 1's two items (both fixed).
This should be merge-ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-497 review round 4 — 5 small precision fixes

Compliance Swarm: 3 → 5 (slight uptick from oscillation, but 0
critical, 1 actionable; remaining 4 are recurring or philosophical).
swedish-compliance: 7 advisories — 3 actionable precision items
incorporated below; the others are forward-looking notes for cookbook
content that ships in Phase 6 follow-ups.

FIXED:

1. **Spec-snapshot test enforces ep.scope is explicitly defined**
   (CC6.3, real future-bug prevention). Previously the test asserted
   every endpoint declared the agent-facing metadata fields the docs
   depend on, but `scope` could be `undefined` — a registerEndpoint
   call that silently dropped the field would make the wrapper treat
   the route as unauthenticated. Added an assertion that
   `ep.scope !== undefined` (the literal sentinel `null` is allowed
   for genuinely public endpoints like /api/v1/health). The 4 spec
   tests still pass — confirming no current endpoint has undefined
   scope and the gate works prospectively.

2. **F-skatt: integrator responsibility for `has_f_skatt` accuracy**
   (swedish-invoice-compliance). The previous "verify on settings
   page" framing didn't connect the flag to the live Skatteverket
   registration. Now: "The integrator is responsible for keeping
   has_f_skatt in sync with the company's live Skatteverket
   registration status. Update via PATCH /api/v1/companies/{id}/
   settings or the settings page — a flag that's false while the
   company is actually F-skatt-registered produces non-compliant
   invoices, not merely a missing optional note."

3. **AGI deadline qualified by turnover** (swedish-payroll). The
   previous wording listed "12th / 17th of the following month" with
   no condition. Now: "12th of the following month for large
   employers, 17th for companies with annual turnover ≤ 40 MSEK."
   Aligns with the swedish-payroll skill's AGI filing deadline
   section.

4. **SIE import warning includes behandlingshistorik gap**
   (swedish-sie-import-export + swedish-accounting-compliance). The
   previous warning covered the VAT-code reconfiguration requirement
   but didn't note that SIE files also do NOT transfer
   behandlingshistorik (the source system's processing log per
   BFNAR 2013:2 kap 8 §) or systemdokumentation. Added: "The
   behandlingshistorik gap must either be preserved separately
   (export from the source system + archive alongside the SIE file)
   or accepted with documented justification — gnubok starts a fresh
   behandlingshistorik from the import date forward."

5. **Webhook 7-year retention: voluntary policy vs statutory
   obligation** (swedish-accounting-compliance). The previous wording
   said gnubok keeps delivery rows "for 7 years as an operational
   audit-trail policy" — the 7-year figure could be misread as
   statutory. Tightened in BOTH webhooks.ts and changelog.ts:
   the 7-year statutory retention under BFL 7 kap 1 § applies ONLY
   to the underlying verifikation/faktura/AGI XML; gnubok's 7-year
   policy on delivery rows is voluntary and chose the duration to
   align conveniently with the statutory horizon on the underlying
   records.

DEFERS (round 4 final, all in the architectural-floor bucket):

- **🟠 A.5.34 personnummer field name + masking logic disclosed in
  public docs** (recurring from round 3). Defensible — documenting
  PII handling is a transparency benefit (GDPR Art.13/14 intent),
  not a privacy disclosure risk. The DPO confirmation prompt is a
  reasonable governance ask but is an out-of-repo decision.
- **🟡 A.8.23 DNS-rebinding "coming soon" item**. The bot is reading
  the changelog's own deferral list. Already tracked for Phase 6
  PR-3 hardening.
- **🟠 CC6.6 SSRF protection details exposed in /llms-full.txt**.
  Stripe / GitHub / Slack publish their full webhook security
  posture publicly (signature format, rejected IP ranges, retry
  policy) — documenting protections IS the trust pattern. Obscurity
  is not security; the SSRF protection is enforced in code, not in
  the docs.
- **🟡 CC6.7 public CDN caching**. withPublicSecurityHeaders()
  already applies the appropriate headers (CSP, X-Content-Type-
  Options, X-Frame-Options). The 5-min cache is appropriate for
  static developer documentation; the alternative (no caching) is
  cost without security benefit since the content is intended to be
  public.
- swedish-compliance "VAT 2026-04-01 livsmedel rate change" —
  forward-looking; ships when the actual VAT cookbook is written.
- swedish-compliance "year-end IB/UB continuity" — forward-looking;
  ships when the year-end cookbook is written.
- 2 verify-only notes (rättelse implementation, future salary-
  journal/avgifter-basis masking sweep) — not actionable in this PR.

Round 4 stop signal: every remaining swarm finding is in the
deferred or recurring bucket; the actionable item (CC6.3) is
shipped. swedish-compliance is now in advisory mode (no errors,
just stylistic suggestions and future-cookbook notes). Per Phase 4
lessons, this is the merge-ready signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-497 review round 5 — 4 small precision fixes (last actionable items)

Compliance Swarm: 5 → 2 (down to architectural floor — 1 high + 1
medium). swedish-compliance: 7 advisories, 4 actionable precision
items addressed below; the others are forward-looking notes for
content that ships in Phase 6 follow-ups.

Trajectory: 7 → 10 → 3 → 5 → 2. Plateaued.

FIXED:

1. **Webhook secret storage guidance: secrets manager, not env file**
   (A.8.5 high). Added explicit instruction to the cookbook that the
   returned secret is signing material and must live in a secrets
   manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault,
   Doppler, 1Password Connect, ...) — not a plaintext .env file or
   config commit. Treated with the same care as a database password.

2. **AGI deadline correction: 17th = January and August only**
   (swedish-payroll). Round 4's wording said "12th (large employers) /
   17th (≤40 MSEK)" — but per the swedish-payroll skill the 17th
   applies in January and August specifically, not generally to all
   months for sub-40 MSEK companies. Other months are the 12th
   regardless of employer size. Fixed to: "the 12th of the following
   month for every reporting period EXCEPT January and August, where
   companies with annual turnover ≤ 40 MSEK get the 17th." This
   would've caused integrators automating sub-40 MSEK deadline
   tracking to misfile by 5 days from February through July and
   September through December.

3. **F-skatt SE-R-005 broader scope** (swedish-invoice-compliance /
   swedish-e-invoicing). The previous wording framed SE-R-005 as
   primarily a Peppol B2G validation rule. Reframed: the F-skatt
   note is a legal requirement on every faktura issued by a Swedish
   momsregistrerad seller that holds F-skatt registration — applies
   to PDF/paper AND Peppol/e-invoice formats. The buyer uses it to
   determine A-skatt withholding obligation (omitting it can shift
   tax liability onto the buyer); B2G is just where the validation
   is automated as a FATAL Peppol BIS 3.0 check.

4. **SIE behandlingshistorik gap: full räkenskapsår scope**
   (swedish-accounting-compliance). Round 4's wording said the
   integrator "must either preserve [behandlingshistorik] separately
   or accept the gap with documented justification" and that gnubok
   "starts a fresh behandlingshistorik from the import date forward."
   The "documented justification" framing implied the gap was
   acceptable as a default. Per BFNAR 2013:2 kap 8 §, the obligation
   attaches to the entire räkenskapsår, not from the import date.
   Reframed as: "must be preserved separately... best practice for a
   mid-year migration: export the source system's behandlingshistorik
   for the full fiscal year and archive it alongside the SIE file."

DEFERS (round 5 final — these are the architectural-floor items
that will recur indefinitely):

- **🟡 A.8.20 DNS-rebinding gap** (Compliance Swarm). Already
  documented in the changelog as a Phase 6 PR-3 deferral item; the
  bot is reading the same text we wrote.
- **swedish-compliance: VAT 2026-04-01 livsmedel rate change**.
  Forward-looking — for the actual VAT cookbook recipe content,
  which ships post-Phase-6.
- **swedish-compliance: year-end IB/UB continuity**. Forward-looking
  — same.
- **swedish-compliance: SIE warning placement note**. Forward-looking
  — for the imports reference page when authored.
- **swedish-compliance: BFNAR 2013:2 citation correct, webhook
  retention correct**. No-op confirmations.
- **swedish-compliance: delivery_date pre-payment scenario**. Real
  but extremely narrow edge case (faktura utfärdad före leverans).
  Defer with the understanding that anyone using the API for
  pre-payment invoicing will read the full invoice reference, not
  rely solely on the quickstart.

This is the merge-ready signal per Phase 4 lessons-learned: every
remaining swarm finding is in the deferred or recurring bucket;
swedish-compliance is in pure-advisory mode (forward-looking notes
for cookbook content that ships later); CI is fully green; Greptile
posted nothing past round 1's two items (both fixed). Ship it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:57:28 +02:00