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
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { autoReconcileTransactionForLinkedVoucher } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
|
||||
// Queue-based Supabase mock: every awaited chain consumes the next enqueued
|
||||
// result regardless of the builder methods called, so we enqueue results in the
|
||||
// exact order the function under test awaits them. Mirrors the manualLink suite
|
||||
// in bank-reconciliation.test.ts.
|
||||
function createQueueMockSupabase() {
|
||||
const resultQueue: { data: unknown; error: unknown }[] = []
|
||||
|
||||
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
||||
for (const r of results) {
|
||||
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
||||
}
|
||||
}
|
||||
|
||||
const buildChain = (): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const next = resultQueue.shift() ?? { data: null, error: null }
|
||||
return (resolve: (v: unknown) => void) => resolve(next)
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain()
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => buildChain()),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
||||
}
|
||||
|
||||
return { supabase, enqueue }
|
||||
}
|
||||
|
||||
const POSTED_VOUCHER = { id: 'je-1', entry_date: '2026-05-10', status: 'posted' }
|
||||
// cash_accounts.id is a UUID — scopeTransactionsToAccount asserts that shape.
|
||||
const CA_1930 = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||
const CA_1932 = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
const SEK_PRIMARY = { id: CA_1930, ledger_account: '1930', currency: 'SEK', is_primary: true }
|
||||
|
||||
/**
|
||||
* Enqueue the manualLink sub-call results (tx fetch → entry fetch → 1930 line
|
||||
* → update). cash_account_id is null on the fetched tx so manualLink skips the
|
||||
* cross-account check (one fewer query).
|
||||
*/
|
||||
function enqueueManualLinkSuccess(
|
||||
enqueue: ReturnType<typeof createQueueMockSupabase>['enqueue'],
|
||||
txAmount: number,
|
||||
) {
|
||||
enqueue({ data: makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: null, amount: txAmount, currency: 'SEK' }) })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: [{ debit_amount: Math.max(txAmount, 0), credit_amount: Math.max(-txAmount, 0), account_number: '1930' }] })
|
||||
enqueue({ data: null, error: null }) // update
|
||||
}
|
||||
|
||||
describe('autoReconcileTransactionForLinkedVoucher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('links the single matching unbooked transaction for a customer payment (income)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
enqueue({ data: [] }) // 1. no tx already reconciled to the voucher
|
||||
enqueue({ data: POSTED_VOUCHER }) // 2. voucher
|
||||
enqueue({ data: [ // 3. lines: one 1930 debit + the 1510 credit
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
|
||||
] })
|
||||
enqueue({ data: [SEK_PRIMARY] }) // 4. cash accounts
|
||||
enqueue({ data: [{ id: 'tx-1', amount: 1000 }] }) // 5. exactly one candidate
|
||||
enqueueManualLinkSuccess(enqueue, 1000)
|
||||
|
||||
const result = await autoReconcileTransactionForLinkedVoucher(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ linkedTransactionId: 'tx-1' })
|
||||
})
|
||||
|
||||
it('links the single matching unbooked transaction for a supplier payment (expense)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: POSTED_VOUCHER })
|
||||
enqueue({ data: [ // 2440 debit + 1930 credit (money out)
|
||||
{ account_number: '2440', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
|
||||
] })
|
||||
enqueue({ data: [SEK_PRIMARY] })
|
||||
enqueue({ data: [{ id: 'tx-1', amount: -1000 }] }) // expense tx
|
||||
enqueueManualLinkSuccess(enqueue, -1000)
|
||||
|
||||
const result = await autoReconcileTransactionForLinkedVoucher(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', { supplierInvoiceId: 'si-1' },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ linkedTransactionId: 'tx-1' })
|
||||
})
|
||||
|
||||
it('does nothing when a transaction is already reconciled to the voucher', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
enqueue({ data: [{ id: 'tx-existing' }] }) // voucher already has a bank line
|
||||
|
||||
const result = await autoReconcileTransactionForLinkedVoucher(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when the voucher has no cash-account line (AR/AP reclass)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: POSTED_VOUCHER })
|
||||
enqueue({ data: [ // no 19xx line at all
|
||||
{ account_number: '1510', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
] })
|
||||
enqueue({ data: [SEK_PRIMARY] })
|
||||
|
||||
const result = await autoReconcileTransactionForLinkedVoucher(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when the voucher touches two cash accounts (a transfer)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: POSTED_VOUCHER })
|
||||
enqueue({ data: [
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
|
||||
{ account_number: '1932', debit_amount: 1000, credit_amount: 0 },
|
||||
] })
|
||||
enqueue({ data: [SEK_PRIMARY, { id: CA_1932, ledger_account: '1932', currency: 'EUR', is_primary: false }] })
|
||||
|
||||
const result = await autoReconcileTransactionForLinkedVoucher(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when two unbooked transactions match the amount (ambiguous)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: POSTED_VOUCHER })
|
||||
enqueue({ data: [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
|
||||
] })
|
||||
enqueue({ data: [SEK_PRIMARY] })
|
||||
enqueue({ data: [{ id: 'tx-1', amount: 1000 }, { id: 'tx-2', amount: 1000 }] }) // two hits
|
||||
|
||||
const result = await autoReconcileTransactionForLinkedVoucher(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when no candidate matches the bank movement amount', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: POSTED_VOUCHER })
|
||||
enqueue({ data: [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
|
||||
] })
|
||||
enqueue({ data: [SEK_PRIMARY] })
|
||||
enqueue({ data: [{ id: 'tx-9', amount: 5000 }] }) // wrong amount
|
||||
|
||||
const result = await autoReconcileTransactionForLinkedVoucher(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when the voucher is not posted', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: { id: 'je-1', entry_date: '2026-05-10', status: 'draft' } })
|
||||
|
||||
const result = await autoReconcileTransactionForLinkedVoucher(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -640,6 +640,206 @@ export async function unlinkReconciliation(
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/** Float tolerance for matching a bank line to a verifikat (0.5 öre). */
|
||||
const VOUCHER_LINK_AMOUNT_TOLERANCE = 0.005
|
||||
|
||||
/** A bank line settling a verifikat sits within a few days of the voucher's
|
||||
* entry_date. Kept tight so the single-candidate rule below stays meaningful. */
|
||||
const VOUCHER_LINK_DATE_WINDOW_DAYS = 7
|
||||
|
||||
/** Shift an ISO 'YYYY-MM-DD' date by ±days, returning the same string shape. */
|
||||
function shiftIsoDate(date: string, days: number): string {
|
||||
const d = new Date(date)
|
||||
if (Number.isNaN(d.getTime())) return date
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
interface CashAccountInfo {
|
||||
id: string | null
|
||||
currency: string
|
||||
isPrimary: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the single unbooked bank transaction that corresponds to a verifikat
|
||||
* the user just linked to an invoice from the invoice page — the symmetric move
|
||||
* to the transactions-side match, closing the gap where linkInvoiceToVoucher /
|
||||
* linkSupplierInvoiceToVoucher advanced the invoice but left the bank line
|
||||
* sitting in the Transactions inbox (still journal_entry_id = null).
|
||||
*
|
||||
* Deliberately conservative — it only acts when the link is unambiguous:
|
||||
* • the voucher has NO bank transaction reconciled to it yet (never adds a
|
||||
* second one automatically — that N:1 case must be an explicit choice in
|
||||
* Bankavstämning),
|
||||
* • the voucher touches exactly ONE cash-account line (a transfer hitting two
|
||||
* bank accounts, or an AR/AP reclass with none, is left alone), and
|
||||
* • exactly ONE unbooked, non-ignored transaction on that account matches the
|
||||
* bank movement (same amount within tolerance, same direction) inside a
|
||||
* tight date window.
|
||||
* Anything else is left untouched: the user can still match it by hand from the
|
||||
* Transactions list. The link itself uses manualLink — no new journal entry, no
|
||||
* JE mutation — so this is a reconciliation link, never a second booking.
|
||||
*
|
||||
* Best-effort by contract: returns the linked transaction id or null, and the
|
||||
* caller treats a throw as "nothing linked" because the invoice link has already
|
||||
* committed.
|
||||
*/
|
||||
export async function autoReconcileTransactionForLinkedVoucher(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
journalEntryId: string,
|
||||
options: {
|
||||
invoiceId?: string
|
||||
supplierInvoiceId?: string
|
||||
dateWindowDays?: number
|
||||
} = {},
|
||||
): Promise<{ linkedTransactionId: string } | null> {
|
||||
const windowDays = options.dateWindowDays ?? VOUCHER_LINK_DATE_WINDOW_DAYS
|
||||
|
||||
// 1. If a bank transaction already points at this voucher, the bank side is
|
||||
// settled — don't attach another one behind the user's back.
|
||||
const { data: alreadyLinked } = await supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
.limit(1)
|
||||
if (alreadyLinked && alreadyLinked.length > 0) return null
|
||||
|
||||
// 2. Load the voucher (must be posted) and its lines.
|
||||
const { data: entry } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, entry_date, status')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (!entry || entry.status !== 'posted' || !entry.entry_date) return null
|
||||
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
if (!lines || lines.length === 0) return null
|
||||
|
||||
// 3. Which BAS codes carry a bank feed? cash_accounts is the source of truth.
|
||||
const { data: cashAccounts } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, ledger_account, currency, is_primary')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
const cashByAccount = new Map<string, CashAccountInfo>()
|
||||
for (const raw of (cashAccounts ?? []) as Array<{
|
||||
id: string
|
||||
ledger_account: string | null
|
||||
currency: string | null
|
||||
is_primary: boolean | null
|
||||
}>) {
|
||||
if (raw.ledger_account) {
|
||||
cashByAccount.set(raw.ledger_account, {
|
||||
id: raw.id,
|
||||
currency: raw.currency ?? 'SEK',
|
||||
isPrimary: raw.is_primary ?? false,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Companies created before cash_accounts seeding reconcile against 1930/SEK.
|
||||
if (cashByAccount.size === 0) {
|
||||
cashByAccount.set('1930', { id: null, currency: 'SEK', isPrimary: true })
|
||||
}
|
||||
|
||||
const cashLines = (lines as Array<{
|
||||
account_number: string
|
||||
debit_amount: number | null
|
||||
credit_amount: number | null
|
||||
}>).filter((l) => cashByAccount.has(l.account_number))
|
||||
|
||||
// Exactly one bank movement → exactly one bank transaction to attach.
|
||||
if (cashLines.length !== 1) return null
|
||||
|
||||
const cashLine = cashLines[0]
|
||||
const accountNumber = cashLine.account_number
|
||||
const cashAccount = cashByAccount.get(accountNumber)!
|
||||
const debit = Number(cashLine.debit_amount ?? 0)
|
||||
const credit = Number(cashLine.credit_amount ?? 0)
|
||||
const movement = debit > 0 ? debit : -credit // + money in, − money out
|
||||
if (Math.abs(movement) <= VOUCHER_LINK_AMOUNT_TOLERANCE) return null
|
||||
|
||||
// 4. Unbooked, non-ignored candidate transactions on that account, scoped the
|
||||
// same way Bankavstämning scopes (handles legacy NULL cash_account_id rows).
|
||||
const fromDate = shiftIsoDate(entry.entry_date, -windowDays)
|
||||
const toDate = shiftIsoDate(entry.entry_date, windowDays)
|
||||
let candQuery = supabase
|
||||
.from('transactions')
|
||||
.select('id, amount')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_ignored', false)
|
||||
.gte('date', fromDate)
|
||||
.lte('date', toDate)
|
||||
candQuery = scopeTransactionsToAccount(
|
||||
candQuery,
|
||||
cashAccount.id ?? undefined,
|
||||
cashAccount.currency,
|
||||
cashAccount.isPrimary,
|
||||
)
|
||||
const { data: candidates } = await candQuery
|
||||
|
||||
const matches = ((candidates ?? []) as Array<{ id: string; amount: number }>).filter((tx) => {
|
||||
const amt = Number(tx.amount)
|
||||
if (Math.abs(Math.abs(amt) - Math.abs(movement)) > VOUCHER_LINK_AMOUNT_TOLERANCE) return false
|
||||
return Math.sign(amt) === Math.sign(movement)
|
||||
})
|
||||
|
||||
// Two same-amount unbooked lines near the same date → don't guess.
|
||||
if (matches.length !== 1) return null
|
||||
const transactionId = matches[0].id
|
||||
|
||||
// 5. Reconcile via the exact path Bankavstämning uses (manualLink re-validates
|
||||
// posted status, the cash-account line, and the not-already-linked guard,
|
||||
// then sets journal_entry_id + reconciliation_method + is_business). No new
|
||||
// journal entry is created.
|
||||
const linkResult = await manualLink(
|
||||
supabase,
|
||||
companyId,
|
||||
transactionId,
|
||||
journalEntryId,
|
||||
userId,
|
||||
accountNumber,
|
||||
)
|
||||
if (!linkResult.success) return null
|
||||
|
||||
// Tag the transaction with the (supplier) invoice for traceability + parity
|
||||
// with the transactions-side match. is_business is already set by manualLink,
|
||||
// so the row has already dropped out of the inbox regardless of this update.
|
||||
const tag: Record<string, unknown> = { potential_invoice_id: null }
|
||||
if (options.invoiceId) tag.invoice_id = options.invoiceId
|
||||
if (options.supplierInvoiceId) {
|
||||
tag.supplier_invoice_id = options.supplierInvoiceId
|
||||
tag.potential_supplier_invoice_id = null
|
||||
}
|
||||
if (Object.keys(tag).length > 1) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update(tag)
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
|
||||
logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', {
|
||||
invoiceId: options.invoiceId,
|
||||
supplierInvoiceId: options.supplierInvoiceId,
|
||||
matchMethod: 'invoice_voucher_link',
|
||||
newState: {
|
||||
journal_entry_id: journalEntryId,
|
||||
reconciliation_method: 'manual',
|
||||
},
|
||||
})
|
||||
|
||||
return { linkedTransactionId: transactionId }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user