Files
accounted/lib/docs/content/reference.ts
T
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

205 lines
9.7 KiB
TypeScript

/**
* Auto-generated API reference pages.
*
* Iterates lib/api/v1/registry.ts ENDPOINTS, groups by resource (derived
* from the URL path), and renders one Markdown page per resource. Stripe-
* style: each endpoint section has the description, useWhen, doNotUseFor,
* pitfalls, scope, idempotent/reversible/dryRun flags, and a worked example.
*
* To make this work, every v1 route file needs to import-side-effect call
* registerEndpoint() — which they all do at module load time. The doc
* builder triggers that load via lib/api/v1/load-routes.ts.
*
* Adding a new endpoint means editing the route file's registerEndpoint
* call; the docs then surface it on the next build with no manual sync.
*/
import { listEndpoints, type EndpointDefinition, type HttpMethod } from '@/lib/api/v1/registry'
// Side-effect import: every v1 route file's top-level registerEndpoint()
// call runs as a result of loading this module, populating the shared
// ENDPOINTS map that listEndpoints() reads from.
import '@/lib/api/v1/load-routes'
interface ResourceGroup {
/** URL slug, used in /docs/api/reference/{slug}. */
slug: string
/** Display label for headings + nav. */
label: string
/** One-line description for the resource landing card. */
description: string
/** URL pattern segment that identifies endpoints belonging to this resource. */
matcher: (path: string) => boolean
}
const RESOURCES: ResourceGroup[] = [
{ slug: 'companies', label: 'Companies', description: 'List and read companies the API key can access.', matcher: (p) => /\/companies(?:\/:companyId)?$/.test(p) },
{ slug: 'customers', label: 'Customers', description: 'CRM-side: who you invoice. Business and individual (sole-trader) customers with VIES validation.', matcher: (p) => /\/customers(\/|$)/.test(p) },
{ slug: 'invoices', label: 'Invoices', description: 'Outbound invoicing — draft, send, mark paid, credit, PDF download. Mixed-rate VAT supported.', matcher: (p) => /\/invoices(\/|$)/.test(p) },
{ slug: 'suppliers', label: 'Suppliers', description: 'AP-side counterparties. Mirrors customers on the supplier vertical.', matcher: (p) => /\/suppliers(\/|$)/.test(p) },
{ slug: 'supplier-invoices', label: 'Supplier invoices', description: 'AP lifecycle: register, approve, mark paid, credit. With ROT/RUT and reverse-charge support.', matcher: (p) => /\/supplier-invoices(\/|$)/.test(p) },
{ slug: 'transactions', label: 'Transactions', description: 'Bank transactions — ingest, categorise, match to invoices, reconcile.', matcher: (p) => /\/transactions(\/|$)/.test(p) },
{ slug: 'reconciliation', label: 'Reconciliation', description: 'Run bank-to-ledger reconciliation and read the current matching status.', matcher: (p) => /\/reconciliation(\/|$)/.test(p) },
{ slug: 'journal-entries', label: 'Journal entries', description: 'The bookkeeping engine surface — verifikation lifecycle (draft, commit, reverse, correct).', matcher: (p) => /\/journal-entries(\/|$)/.test(p) },
{ slug: 'voucher-gap-explanations', label: 'Voucher gap explanations', description: 'Documented explanations for gaps in the voucher series, per BFNAR 2013:2.', matcher: (p) => /\/voucher-gap/.test(p) },
{ slug: 'fiscal-periods', label: 'Fiscal periods', description: 'Period lifecycle — lock, close, year-end, opening balances, FX revaluation. Async via the operations substrate.', matcher: (p) => /\/fiscal-periods(\/|$)/.test(p) },
{ slug: 'accounts', label: 'Accounts', description: 'Read the chart of accounts (BAS).', matcher: (p) => /\/accounts(\/|$)/.test(p) },
{ slug: 'documents', label: 'Documents', description: 'Multipart upload, signed-URL download (15-min TTL), link to journal entries.', matcher: (p) => /\/documents(\/|$)/.test(p) },
{ slug: 'employees', label: 'Employees', description: 'Payroll roster — CRUD with personnummer masking on list endpoints.', matcher: (p) => /\/employees(\/|$)/.test(p) },
{ slug: 'salary-runs', label: 'Salary runs', description: 'Payroll lifecycle — create, calculate, approve, mark paid, book, generate AGI XML.', matcher: (p) => /\/salary-runs(\/|$)/.test(p) },
{ slug: 'reports', label: 'Reports', description: 'Read-only reports — trial balance, P&L, balance sheet, GL, VAT, salary journal, SIE export, +9 more.', matcher: (p) => /\/reports(\/|$)/.test(p) },
{ slug: 'imports', label: 'Imports', description: 'Bulk async ingest — SIE files (Fortnox/Visma/BL/SpeedLedger/Bokio migrations) and bank statements (11 formats).', matcher: (p) => /\/imports(\/|$)/.test(p) },
{ slug: 'compliance', label: 'Compliance check', description: 'Pre-flight verification — voucher gaps, year-end readiness, before submitting to Skatteverket.', matcher: (p) => /\/compliance(\/|$)/.test(p) },
{ slug: 'webhooks', label: 'Webhooks', description: 'Subscribe to events with HMAC-signed delivery, exponential retries, and dead-letter replay.', matcher: (p) => /\/webhooks|\/webhook-deliveries/.test(p) },
{ slug: 'operations', label: 'Operations', description: 'Poll long-running async operations (year-end closing, imports, currency revaluation).', matcher: (p) => /\/operations(\/|$)/.test(p) },
]
/** Discover the resource a given endpoint path belongs to. Returns null if it doesn't fit any. */
function classifyEndpoint(path: string): ResourceGroup | null {
for (const r of RESOURCES) {
if (r.matcher(path)) return r
}
return null
}
export interface BuiltResourcePage {
slug: string
label: string
description: string
endpoints: EndpointDefinition[]
markdown: string
}
const METHOD_ORDER: Record<HttpMethod, number> = { GET: 0, POST: 1, PATCH: 2, PUT: 3, DELETE: 4 }
function endpointAnchor(ep: EndpointDefinition): string {
return `${ep.method.toLowerCase()}-${ep.operation.replace(/\./g, '-')}`
}
function renderEndpoint(ep: EndpointDefinition): string {
const lines: string[] = []
const methodBadge = ep.method
lines.push(`### \`${methodBadge}\` ${ep.path} {#${endpointAnchor(ep)}}`)
lines.push('')
lines.push(`**\`${ep.operation}\`**${ep.scope ? ` · scope \`${ep.scope}\`` : ' · public'}`)
lines.push('')
lines.push(ep.summary)
lines.push('')
lines.push(ep.description)
lines.push('')
lines.push(`**Use when:** ${ep.useWhen}`)
lines.push('')
lines.push(`**Don't use for:** ${ep.doNotUseFor}`)
lines.push('')
if (ep.pitfalls.length > 0) {
lines.push('**Pitfalls**')
for (const p of ep.pitfalls) lines.push(`- ${p}`)
lines.push('')
}
const flags: string[] = []
flags.push(`**Risk:** ${ep.risk}`)
flags.push(`**Idempotent:** ${ep.idempotent ? 'yes' : 'no'}`)
flags.push(`**Reversible:** ${ep.reversible ? 'yes' : 'no'}`)
flags.push(`**Dry-run supported:** ${ep.dryRunSupported ? 'yes' : 'no'}`)
lines.push(flags.join(' · '))
lines.push('')
if (ep.example.request) {
lines.push('**Example request**')
lines.push('')
lines.push('```json')
lines.push(JSON.stringify(ep.example.request, null, 2))
lines.push('```')
lines.push('')
}
lines.push('**Example response**')
lines.push('')
lines.push('```json')
lines.push(JSON.stringify(ep.example.response, null, 2))
lines.push('```')
lines.push('')
return lines.join('\n')
}
// Module-level memoisation. The endpoint registry is populated once at
// module load (via the side-effect import of load-routes) and is then
// immutable for the process lifetime. The Markdown serialisation is
// pure derivation — reusing a single result avoids repeated work on the
// .md route handlers (which Next.js doesn't statically pre-render) AND
// halves the cost on each generateMetadata + page render pair on the
// HTML routes. (Greptile P2, round 1.)
let cachedPages: BuiltResourcePage[] | null = null
export function buildResourcePages(): BuiltResourcePage[] {
if (cachedPages) return cachedPages
const all = listEndpoints()
const byResource = new Map<string, EndpointDefinition[]>()
for (const ep of all) {
const r = classifyEndpoint(ep.path)
if (!r) continue
if (!byResource.has(r.slug)) byResource.set(r.slug, [])
byResource.get(r.slug)!.push(ep)
}
const pages = RESOURCES.map((r) => {
const endpoints = (byResource.get(r.slug) ?? []).sort((a, b) => {
const m = METHOD_ORDER[a.method] - METHOD_ORDER[b.method]
if (m !== 0) return m
return a.path.localeCompare(b.path)
})
const lines: string[] = []
lines.push(`# ${r.label}`)
lines.push('')
lines.push(`> ${r.description}`)
lines.push('')
if (endpoints.length === 0) {
lines.push('*No endpoints registered yet for this resource.*')
} else {
lines.push('## Endpoints')
lines.push('')
for (const ep of endpoints) {
lines.push(`- [\`${ep.method}\` \`${ep.path}\`](#${endpointAnchor(ep)}) — ${ep.summary}`)
}
lines.push('')
lines.push('---')
lines.push('')
for (const ep of endpoints) {
lines.push(renderEndpoint(ep))
lines.push('---')
lines.push('')
}
}
return {
slug: r.slug,
label: r.label,
description: r.description,
endpoints,
markdown: lines.join('\n'),
}
})
cachedPages = pages
return pages
}
export function buildReferenceOverviewMd(): string {
const lines: string[] = []
lines.push('# API reference')
lines.push('')
lines.push(`> Every endpoint exposed by the gnubok REST API, grouped by resource. Auto-generated from the same Zod registry that powers the [OpenAPI 3.1 spec](/api/v1/openapi.json), the MCP tool surface, and runtime validators — there is no separate doc-source to keep in sync.`)
lines.push('')
lines.push('## Resources')
lines.push('')
for (const r of RESOURCES) {
lines.push(`### [${r.label}](/docs/api/reference/${r.slug})`)
lines.push('')
lines.push(r.description)
lines.push('')
}
return lines.join('\n')
}
export const RESOURCE_SLUGS = RESOURCES.map((r) => r.slug)