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:
co-authored by
Claude Opus 4.8
parent
331ae11867
commit
c74b19df1b
@@ -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
@@ -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 · 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
})),
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user