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

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 Accounted 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)