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>
This commit is contained in:
Jakob Wennberg
2026-06-03 10:52:01 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 331ae11867
commit c74b19df1b
183 changed files with 19621 additions and 4175 deletions
+4 -4
View File
@@ -18,7 +18,7 @@ export default async function SelectCompanyPage() {
redirect('/login')
}
// Existing gnubok memberships.
// Existing Accounted memberships.
const { data: memberships } = await supabase
.from('company_members')
.select(`
@@ -123,14 +123,14 @@ export default async function SelectCompanyPage() {
(r) => r.positionEnd == null,
)
// Drop TIC roles that already appear in the user's gnubok memberships —
// those render via the "Your gnubok companies" section above instead.
// Drop TIC roles that already appear in the user's Accounted memberships —
// those render via the "Your Accounted companies" section above instead.
const rolesNotAlreadyMine = activeRoles.filter(
(r) => !memberOrgNumbers.has(r.companyRegistrationNumber.replace(/[\s-]/g, '')),
)
// Cross-reference remaining TIC org numbers against the global companies
// table to detect "exists in gnubok, user not a member" cases. Use the
// table to detect "exists in Accounted, user not a member" cases. Use the
// service client — RLS filters out companies the user isn't a member of,
// which is exactly the data we need. Scoped to the specific org numbers.
let externallyOwnedOrgs = new Set<string>()
+81 -17
View File
@@ -12,20 +12,20 @@ export function generateMetadata(): Metadata {
export default function DPAPage() {
const { appName, legalEntity, privacyEmail } = getBranding()
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white py-12 px-4">
<div className="max-w-3xl mx-auto space-y-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
<div className="min-h-screen bg-background py-12 px-4">
<div className="max-w-3xl mx-auto space-y-8">
<div className="text-center space-y-2">
<h1 className="font-display text-3xl md:text-4xl tracking-tight text-foreground">
Personuppgiftsbitradesavtal (DPA)
</h1>
<p className="text-muted-foreground">
Enligt GDPR Art. 28 | Senast uppdaterad: 2026-06-01
<p className="text-sm text-muted-foreground">
Enligt GDPR Art. 28 &middot; Senast uppdaterad: 2026-06-03
</p>
</div>
<Card>
<CardHeader>
<CardTitle>1. Roller</CardTitle>
<CardTitle className="text-base">1. Roller</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
@@ -43,7 +43,26 @@ export default function DPAPage() {
<Card>
<CardHeader>
<CardTitle>2. Behandlingens syfte och omfattning</CardTitle>
<CardTitle className="text-base">2. Behandling enligt instruktioner</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Biträdet behandlar personuppgifter endast enligt den Ansvariges dokumenterade
instruktioner, inklusive vid överföring av personuppgifter till tredjeland eller en
internationell organisation, om inte unionsrätten eller svensk rätt ålägger Biträdet
att göra det. I sådant fall informerar Biträdet den Ansvarige om det rättsliga kravet
innan behandlingen sker, om inte sådan information är förbjuden enligt lag.
</p>
<p>
Om Biträdet anser att en instruktion strider mot GDPR eller andra
dataskyddsbestämmelser ska Biträdet omedelbart informera den Ansvarige.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">3. Behandlingens syfte och omfattning</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Biträdet behandlar personuppgifter för följande ändamål:</p>
@@ -65,7 +84,21 @@ export default function DPAPage() {
<Card>
<CardHeader>
<CardTitle>3. Tekniska och organisatoriska åtgärder</CardTitle>
<CardTitle className="text-base">4. Konfidentialitet</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Biträdet säkerställer att de personer som har behörighet att behandla
personuppgifterna har åtagit sig att iaktta konfidentialitet eller omfattas av en
lämplig lagstadgad tystnadsplikt. Åtkomst till personuppgifter begränsas till personal
som behöver uppgifterna för att fullgöra Biträdets åtaganden.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">5. Tekniska och organisatoriska åtgärder</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Biträdet vidtar följande åtgärder för att skydda personuppgifterna:</p>
@@ -73,7 +106,10 @@ export default function DPAPage() {
<li><strong>Kryptering:</strong> All data krypteras i transit (TLS 1.3) och i vila (AES-256)</li>
<li><strong>Åtkomstkontroll:</strong> Row Level Security (RLS) säkerställer att varje användare
enbart kan komma åt sina egna uppgifter</li>
<li><strong>Autentisering:</strong> Säkra inloggningsmetoder (magic link, inga lösenord lagrade)</li>
<li><strong>Autentisering:</strong> Inloggning med e-post och lösenord (lösenord lagras
endast som saltad hash, aldrig i klartext), tvåfaktorsautentisering (2FA via TOTP)
samt BankID (på den hostade tjänsten). Tvåfaktorsautentisering kan krävas för
åtkomst</li>
<li><strong>Integritetskontroll:</strong> SHA-256 checksummor för alla dokument, med
regelbunden verifiering</li>
<li><strong>Revisionslogg:</strong> Alla ändringshandelser loggas automatiskt av databasen
@@ -82,7 +118,7 @@ export default function DPAPage() {
raderas (databasutlösare)</li>
<li><strong>Säkerhetskopior:</strong> Kontinuerliga databaskopior med point-in-time-recovery</li>
<li><strong>EU-lagring och EU-inferens:</strong> All primär datalagring sker i EU
(Supabase, eu-central-1). AI-inferens sker, när AI-funktioner är aktiverade, inom
(Supabase, eu-north-1, Stockholm). AI-inferens sker, när AI-funktioner är aktiverade, inom
EU via Amazon Bedrock (eu-north-1, Stockholm) — ingen överföring till tredje land</li>
</ul>
</CardContent>
@@ -90,7 +126,7 @@ export default function DPAPage() {
<Card>
<CardHeader>
<CardTitle>4. Underbiträden</CardTitle>
<CardTitle className="text-base">6. Underbiträden</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
@@ -105,12 +141,36 @@ export default function DPAPage() {
Biträdet kommer att informera den Ansvarige minst 30 dagar i förväg innan
en ny underbiträde anlitas, så att den Ansvarige har möjlighet att invända.
</p>
<p>
Biträdet ålägger genom skriftligt avtal varje underbiträde samma
dataskyddsskyldigheter som anges i detta avtal. Biträdet förblir fullt ansvarigt
gentemot den Ansvarige för att underbiträdet fullgör sina skyldigheter.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>5. Dataintrångsnotifiering</CardTitle>
<CardTitle className="text-base">7. Bistånd med registrerades rättigheter</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Biträdet bistår den Ansvarige, genom lämpliga tekniska och organisatoriska åtgärder
och i den mån det är möjligt, med att fullgöra den Ansvariges skyldighet att besvara
begäran från registrerade om utövande av sina rättigheter enligt GDPR kapitel III
(art. 12–23), däribland rätt till tillgång, rättelse, radering, begränsning,
dataportabilitet och invändning.
</p>
<p>
Tjänsten tillhandahåller självbetjäningsfunktioner för export (SIE4, JSON, CSV) och
radering som stöd för detta.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">8. Dataintrångsnotifiering och bistånd enligt art. 32–36</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
@@ -125,15 +185,19 @@ export default function DPAPage() {
<li>Åtgärder som vidtagits eller föreslås för att hantera incidenten</li>
</ul>
<p>
Biträdet ska bistå den Ansvarige med den information som behövs för att den
Ansvarige ska kunna uppfylla sin anmälningsplikt till IMY (Integritetsskyddsmyndigheten).
Biträdet bistår den Ansvarige med att säkerställa att skyldigheterna enligt
art. 32–36 i GDPR fullgörs, med beaktande av behandlingens art och den information
som Biträdet har tillgång till. Detta omfattar säkerhet i behandlingen (art. 32),
anmälan av personuppgiftsincidenter (art. 33–34), konsekvensbedömningar avseende
dataskydd (art. 35, DPIA) samt förhandssamråd med Integritetsskyddsmyndigheten
(IMY) (art. 36).
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>6. Revisionsrätt</CardTitle>
<CardTitle className="text-base">9. Revisionsrätt</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
@@ -152,7 +216,7 @@ export default function DPAPage() {
<Card>
<CardHeader>
<CardTitle>7. Radering vid avslut</CardTitle>
<CardTitle className="text-base">10. Radering vid avslut</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
+4 -4
View File
@@ -19,7 +19,7 @@ export default function PrivacyPolicyPage() {
Integritetspolicy
</h1>
<p className="text-muted-foreground">
Senast uppdaterad: 2026-06-01
Senast uppdaterad: 2026-06-03
</p>
</div>
@@ -43,7 +43,7 @@ export default function PrivacyPolicyPage() {
<CardContent className="prose prose-sm max-w-none">
<p>Vi behandlar följande kategorier av personuppgifter:</p>
<ul>
<li><strong>Kontouppgifter:</strong> E-postadress (för inloggning via magic link)</li>
<li><strong>Kontouppgifter:</strong> E-postadress (för inloggning)</li>
<li><strong>Företagsuppgifter:</strong> Företagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
<li><strong>Bokföringsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
<li><strong>Bankdata:</strong> Kontosaldon och transaktioner (via PSD2-koppling)</li>
@@ -112,7 +112,7 @@ export default function PrivacyPolicyPage() {
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Supabase</td>
<td className="py-2 pr-4">Databas, autentisering, fillagring</td>
<td className="py-2 pr-4">EU (eu-central-1)</td>
<td className="py-2 pr-4">EU (eu-north-1, Stockholm)</td>
<td className="py-2">EU-baserad lagring</td>
</tr>
<tr className="border-b">
@@ -181,7 +181,7 @@ export default function PrivacyPolicyPage() {
<p>
Vissa underbiträden är baserade i USA. För dessa överföringar används EU-kommissionens
standardavtalsklausuler (SCCs) som skyddsmekanism i enlighet med GDPR kapitel V.
All primär datalagring (databas, filer) sker inom EU via Supabase (eu-central-1).
All primär datalagring (databas, filer) sker inom EU via Supabase (eu-north-1, Stockholm).
Även AI-inferens sker inom EU (Amazon Bedrock, eu-north-1) och innebär ingen
överföring till tredje land.
</p>
+1 -1
View File
@@ -27,7 +27,7 @@ export async function GET(_request: Request) {
summary: s.summary,
tags: s.tags,
/** MCP resource URI; load via the MCP server's resources/read with an authenticated key. */
uri: `gnubok://skill/${s.slug}`,
uri: `Accounted://skill/${s.slug}`,
})),
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
* GET /api/company/check-org-number?org_number=XXXXXXXXXX
*
* Returns `{ data: { exists: boolean } }` indicating whether the given
* organisation number is already registered in any non-archived gnubok
* organisation number is already registered in any non-archived Accounted
* company. Used by the onboarding wizard to warn users before they try to
* create a duplicate.
*
@@ -53,6 +53,7 @@ export const POST = withRouteContext(
payment_amount: outcome.result.paymentAmount,
payment_id: outcome.result.paymentId,
journal_entry_id: outcome.result.journalEntryId,
reconciled_transaction_id: outcome.result.reconciledTransactionId,
},
})
},
@@ -0,0 +1,62 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { MarkOpeningBalanceSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
/**
* Re-tag a posted manual/import voucher on a bank account as an opening balance
* (source_type='opening_balance') so bank reconciliation stops counting it as a
* phantom difference. Delegates to the mark_entry_as_opening_balance RPC, which
* enforces owner/admin role, the manual/import precondition, a bank-line check,
* and the period lock. We only translate its errors to Swedish here.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, MarkOpeningBalanceSchema)
if (!validation.success) return validation.response
const { journal_entry_id } = validation.data
const { data, error } = await supabase.rpc('mark_entry_as_opening_balance', {
p_company_id: companyId,
p_entry_id: journal_entry_id,
})
if (error) {
const raw = error.message || ''
let message = 'Kunde inte markera verifikationen som ingående balans.'
if (/owners and admins/i.test(raw)) {
message = 'Endast ägare och administratörer kan markera en ingående balans.'
} else if (/not found/i.test(raw)) {
message = 'Verifikationen kunde inte hittas.'
} else if (/manual\/import/i.test(raw)) {
message = 'Bara manuellt eller importerat bokförda verifikationer kan markeras som ingående balans.'
} else if (/posted entries/i.test(raw)) {
message = 'Bara bokförda verifikationer kan markeras som ingående balans.'
} else if (/bank\/cash account/i.test(raw)) {
message = 'Verifikationen saknar rad på ett bankkonto (19xx) och kan inte vara en ingående balans.'
} else if (/closed fiscal period/i.test(raw)) {
message = 'Perioden är stängd. Öppna perioden innan du ändrar verifikationen.'
} else if (/locked fiscal period/i.test(raw)) {
message = 'Perioden är låst. Lås upp perioden innan du ändrar verifikationen.'
}
return NextResponse.json({ error: message }, { status: 400 })
}
return NextResponse.json({ data })
}
@@ -53,6 +53,7 @@ export const POST = withRouteContext(
payment_amount: outcome.result.paymentAmount,
payment_id: outcome.result.paymentId,
journal_entry_id: outcome.result.journalEntryId,
reconciled_transaction_id: outcome.result.reconciledTransactionId,
},
})
},
@@ -46,7 +46,7 @@ registerEndpoint({
'Fetching balances — use the trial-balance report. Creating new accounts — this endpoint is read-only in v1 (use the dashboard).',
pitfalls: [
'account_number is a STRING — "1930", not 1930. The leading character can be 0 in non-BAS plans.',
'is_system_account=true means the account was seeded by gnubok and cannot be archived or renamed.',
'is_system_account=true means the account was seeded by Accounted and cannot be archived or renamed.',
'Default filter excludes archived accounts; pass ?active=false to include them.',
],
example: {
@@ -1,7 +1,7 @@
/**
* GET /api/v1/companies/{companyId}/compliance/check?type=...
*
* gnubok's defensible edge: a single, structured pre-flight endpoint that
* Accounted's defensible edge: a single, structured pre-flight endpoint that
* surfaces the same compliance checks the MCP / dashboard run, in a form
* an agent can act on programmatically.
*
@@ -204,7 +204,7 @@ registerEndpoint({
path: '/api/v1/companies/:companyId/compliance/check',
summary: 'Run a structured compliance pre-flight check.',
description:
'Generalised pre-flight that consolidates the gnubok pre-close validators under one envelope. Supported check types: year_end_readiness (BFNAR 2017:3 + ÅRL 2:1 blockers), voucher_gaps (BFNAR 2013:2 kap 8 § series continuity). vat_close is planned for a follow-up PR (the underlying function currently lives in the MCP extension and core routes cannot import from extensions; it will be extracted into lib/reports/ then exposed here). New types can be added without changing the response shape.',
'Generalised pre-flight that consolidates the Accounted pre-close validators under one envelope. Supported check types: year_end_readiness (BFNAR 2017:3 + ÅRL 2:1 blockers), voucher_gaps (BFNAR 2013:2 kap 8 § series continuity). vat_close is planned for a follow-up PR (the underlying function currently lives in the MCP extension and core routes cannot import from extensions; it will be extracted into lib/reports/ then exposed here). New types can be added without changing the response shape.',
useWhen:
'Before committing to an irreversible action (VAT close, year-end close), or as a periodic audit sweep to surface blockers before they become urgent.',
doNotUseFor:
@@ -62,7 +62,7 @@ registerEndpoint({
description:
'Accepts a SIE4 file (CP437 / Windows-1252 / UTF-8 auto-detected, up to 50 MB) as the request body, parses it, checks for duplicate imports by file-hash, and replays every #VER + #TRANS into the company\'s bookkeeping. Returns an `operation_id` immediately — poll `GET /api/v1/operations/{id}` for status + final result. The byte-equivalent dashboard route at /api/import/sie/execute backs the same lib helper, so a SIE imported via v1 matches what the dashboard would produce.',
useWhen:
'Migrating bookkeeping data from another system (Fortnox, Bokio, Visma) into gnubok, restoring from a backup .se file, or recreating a period from an archive.',
'Migrating bookkeeping data from another system (Fortnox, Bokio, Visma) into Accounted, restoring from a backup .se file, or recreating a period from an archive.',
doNotUseFor:
'Bank transaction CSV/XML imports (use POST /imports/bank). Single-voucher creation (use POST /journal-entries). Importing into a period that already has posted entries — SIE imports run on a fresh period.',
pitfalls: [
@@ -69,11 +69,11 @@ registerEndpoint({
path: '/api/v1/companies/:companyId/invoices/:id/mark-sent',
summary: 'Transition a draft invoice to sent (without emailing).',
description:
'Marks a draft invoice as sent — for invoices delivered outside gnubok (Peppol, postal, manual email). Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). On accounting_method=accrual, also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.',
'Marks a draft invoice as sent — for invoices delivered outside Accounted (Peppol, postal, manual email). Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). On accounting_method=accrual, also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.',
useWhen:
'You delivered the invoice through a channel other than gnubok\'s email (Peppol, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted.',
'You delivered the invoice through a channel other than Accounted\'s email (Peppol, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted.',
doNotUseFor:
'Sending the invoice via gnubok email — use :send (PR-B-2b-3) for that. Marking an already-sent invoice as paid — use :mark-paid (PR-B-2b-2).',
'Sending the invoice via Accounted email — use :send (PR-B-2b-3) for that. Marking an already-sent invoice as paid — use :mark-paid (PR-B-2b-2).',
pitfalls: [
'Only invoices in `status=draft` can be marked sent. Other states return 409 INVOICE_UPDATE_NOT_DRAFT (re-used; the action is structurally an update).',
'Allocation is atomic. If a concurrent transition beats the agent\'s request to the same draft, the runner-up gets 409 INVOICE_UPDATE_NOT_DRAFT and no number is consumed.',
@@ -46,13 +46,13 @@ registerEndpoint({
description:
'Returns the invoice as application/pdf. The filename in Content-Disposition reflects the document type: faktura-<number>.pdf for sent invoices, kreditfaktura-<number>.pdf for credit notes, utkast-<id-slice>.pdf for drafts. This endpoint is byte-equivalent to the dashboard download.',
useWhen:
'You need to fetch an invoice PDF for archival, forwarding to a customer outside the gnubok send flow, or attaching to an external workflow.',
'You need to fetch an invoice PDF for archival, forwarding to a customer outside the Accounted send flow, or attaching to an external workflow.',
doNotUseFor:
'Sending the invoice to the customer — use POST /invoices/{id}/send, which renders the PDF, emails it, and archives it as a verifikationsunderlag in one atomic step.',
pitfalls: [
'Drafts (no invoice_number yet) render with an "utkast" filename. The PDF carries no F-series number — do not treat it as a finalized invoice.',
'PDF rendering can take several hundred milliseconds for invoices with many line items. Cache on the client if requesting repeatedly.',
'Credit notes embed the original invoice\'s löpnummer per ML 17 kap 22–23§ — if the original was hard-deleted (not possible via gnubok but theoretically via a manual DB edit), the reference is omitted.',
'Credit notes embed the original invoice\'s löpnummer per ML 17 kap 22–23§ — if the original was hard-deleted (not possible via Accounted but theoretically via a manual DB edit), the reference is omitted.',
],
example: {
response: {
@@ -82,7 +82,7 @@ registerEndpoint({
description:
'The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via Resend (PDF attachment, copy to company) → flip status to sent → post journal entry (accrual + real invoice) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent.',
useWhen:
'You want gnubok to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead.',
'You want Accounted to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead.',
doNotUseFor:
'Re-sending an already-sent invoice (returns 409 INVOICE_UPDATE_NOT_DRAFT). Sending a delivery note (no F-series lifecycle). Sending a credit note (use the :credit endpoint to issue the kreditfaktura; subsequent re-send of the credit note via :mark-sent is the supported path).',
pitfalls: [
@@ -354,7 +354,7 @@ describe('GET /reports/sie-export', () => {
},
}),
)
mocks.generateSIEExport.mockResolvedValue('#FLAGGA 0\n#PROGRAM gnubok\n')
mocks.generateSIEExport.mockResolvedValue('#FLAGGA 0\n#PROGRAM Accounted\n')
const res = await sieExport(
makeReq(
@@ -70,7 +70,7 @@ registerEndpoint({
gap_start: 142,
gap_end: 145,
explanation:
'Migration from previous bookkeeping system on 2026-05-12 — series A148-onwards corresponds to the new gnubok numbering; numbers A142-A145 were assigned in the legacy system to manual paper vouchers archived offline (BFL 7 kap retention applies). Paper vouchers are stored in the company archive under reference 2026-PAPER-Q2.',
'Migration from previous bookkeeping system on 2026-05-12 — series A148-onwards corresponds to the new Accounted numbering; numbers A142-A145 were assigned in the legacy system to manual paper vouchers archived offline (BFL 7 kap retention applies). Paper vouchers are stored in the company archive under reference 2026-PAPER-Q2.',
},
response: {
data: { id: '0e9c…', voucher_series: 'A', gap_start: 142, gap_end: 145 },
@@ -84,9 +84,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Data minimisation (Art.25(2)): the test payload deliberately omits
// any internal identifier that has no value to the receiver. The
// X-Gnubok-Delivery header on the outbound request already correlates
// to the audit trail on the gnubok side.
// to the audit trail on the Accounted side.
const payload = {
hello: 'from gnubok',
hello: 'from Accounted',
tested_at: new Date().toISOString(),
}
@@ -354,7 +354,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
}
// Secret returned exactly once. Caller must persist it on the receiver
// side — gnubok will not surface it on any subsequent endpoint.
// side — Accounted will not surface it on any subsequent endpoint.
// Cache-Control: no-store mirrors the rotate-secret response (A.8.12 /
// Art.25) so no intermediary (CDN / proxy / gateway log / browser
// cache) persists the secret beyond the direct response chain.
+4 -4
View File
@@ -1,5 +1,5 @@
/**
* /llms.txt — agent-discoverable index of the gnubok API.
* /llms.txt — agent-discoverable index of the Accounted API.
*
* Convention adopted by Stripe, Anthropic, and other agent-facing platforms:
* a plain-text Markdown file at the doc root that points LLM crawlers and
@@ -15,17 +15,17 @@ import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url'
export async function GET(_request: Request) {
const base = getCanonicalBaseUrl()
const body = `# gnubok API
const body = `# Accounted API
> Swedish double-entry bookkeeping as a public REST API. API version ${API_V1_VERSION}.
This API lets agents and integrations do anything the gnubok dashboard can do —
This API lets agents and integrations do anything the Accounted dashboard can do —
read transactions, create invoices, mark them paid, run VAT reports, file year-end
declarations, ingest SIE files, and subscribe to webhooks for state changes.
## Quickstart
1. Create an API key in the gnubok dashboard at /settings/api.
1. Create an API key in the Accounted dashboard at /settings/api.
2. Authenticate with \`Authorization: Bearer gnubok_sk_<live|test>_<random>\`.
3. List companies the key can access: \`GET ${base}/api/v1/companies\`.
4. Use the returned \`id\` as \`{companyId}\` in subsequent paths.