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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user