fix(supplier-invoices): payment-match integrity — no more paid-without-voucher half-states (#711)
* fix(transactions): abort supplier-invoice match when payment voucher fails The match route caught a payment-JE creation failure and proceeded anyway: invoice marked paid with payment_journal_entry_id NULL, a payments row with no voucher, and the bank line linked but unbooked. That half-state is unrecoverable from the UI — mark-paid rejects 'paid' invoices and the match route rejects already-linked transactions (the "user can re-book" comment was wrong). The v1 route was already strict; this aligns the cookie route. A failed voucher now fails the whole match before any state mutation, with bookkeeping errors mapped to their structured codes and a new MATCH_SI_JE_FAILED fallback. Incident: Arcim 2026-06-11 — invoice 20250928 marked paid with no payment voucher because account 3740 was missing from the chart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): bank-sync supplier-invoice match is a suggestion, not a hard link A high-confidence (>=0.85, unambiguous) supplier-invoice hit at sync time set transactions.supplier_invoice_id directly — without booking a payment or touching the invoice. The half-link then BLOCKED the match route (MATCH_SI_TX_ALREADY_LINKED), stranding the bank line with no path to a payment voucher and the invoice stuck on 'registered'. Sync now always writes potential_supplier_invoice_id; the hard link is reserved for completed matches where the payment voucher is booked. High-confidence hits still drain the matching pool and skip the mapping engine. Incident: Arcim 2026-06-11 — RosholmDell 18299 (29 890 kr) auto-linked at sync, unmatchable afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): seed standard BAS accounts on demand in the engine A minimal company chart routinely lacks accounts that legitimate engine flows reach — 3740 (öres- och kronutjämning) the first time a Bankgiro payment lands a sub-krona off the invoice, 6580 on a first legal invoice. createDraftEntry threw AccountsNotInChartError and turned a standard account into a dead end. The engine now backfills missing accounts from BAS_REFERENCE (full metadata incl. SRU code) before failing. Conservative by design: unknown numbers still throw, and deactivated accounts are never resurrected — deactivation is a deliberate user choice. Concurrent seeding (23505) counts as success. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): require explicit expense account, drop the 5010 seed Every new line item (and every AI-prefilled line) was silently seeded with account 5010 Lokalhyra. AI extraction deliberately never suggests accounts, so any invoice saved without touching the field was misbooked as premises rent — legally wrong verifikat that need rättelse to fix. Lines now start with an empty account: the supplier's default_expense_account fills empty rows when set, and submit blocks with a clear toast until every row has an account. Incident: Arcim 2026-06-11 — a legal-services invoice (should be 6580) and a SaaS subscription (should be 5420) both posted to 5010. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(bookkeeping): clarify voucher description suffix to (ankomstnr N) "(ankomst 2)" read as "arrived twice" / a duplicate marker; it is the company-internal sequential arrival counter for supplier invoices. "(ankomstnr 2)" says what the number is. Existing posted vouchers keep their old description (immutable). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): cancel orphaned payment voucher when match loses the CAS race When the payment JE posts but the invoice CAS update matches 0 rows (a concurrent request settled it first), both match routes returned MATCH_SI_NOT_OPEN and left the voucher orphaned in the ledger. mark-paid has always compensated for exactly this case; the compensation is now a shared helper (cancelOrphanedPaymentEntry: cancel + voucher-gap explanation per BFNAR 2013:2) used by all three routes. Flagged by the compliance swarm and the Swedish compliance review on PR #711 — the one finding both converged on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): next_voucher_number user_id fallback for service-role contexts Mirrors 20260421170500 (commit_journal_entry got this fix; its twin did not). Under a service-role client auth.uid() is NULL and the voucher_sequences upsert fails its user_id NOT NULL check before ON CONFLICT can arbitrate — even when the sequence row exists. Every non-interactive caller of the storno/correction path (getNextVoucherNumber → correctEntry) was broken. Fallback: companies.created_by (same source seed_chart_of_accounts uses). Interactive flows still record auth.uid(); DO UPDATE never touches user_id on existing rows. Also restores SET search_path = public, lost when 20260330 recreated the function after the 20260304 hardening. pg-real: new test exercises the RPC on the superuser connection (auth.uid() IS NULL) and asserts sequential numbers + owner attribution. Found live: the Arcim repair script booked payment vouchers fine (commit_journal_entry) but failed on corrections (next_voucher_number). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): harden cancelOrphanedPaymentEntry — never throw, breadcrumb before mutating Two hardenings from the PR #711 review round: - Whole body wrapped in try/catch: the caller is returning the correct CAS-conflict response, so an unexpected client rejection must not replace it with a 500 (best-effort is now a hard guarantee). - The gap-recovery data (series, number, period, explanation) is logged BEFORE the cancel: the cancel and gap insert are separate statements, and a crash between them would otherwise leave a cancelled voucher with no BFNAR 2013:2 gap explanation and no way to reconstruct it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -313,7 +313,11 @@ export default function NewSupplierInvoicePage() {
|
||||
payment_reference: '',
|
||||
notes: '',
|
||||
paid_with_private_funds: false,
|
||||
items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25, reverse_charge_rate: 0.25 }],
|
||||
// account_number is deliberately empty — a silent prefilled expense
|
||||
// account (the old '5010' Lokalhyra seed) produced legally wrong
|
||||
// verifikat whenever the user didn't notice it. An explicit choice is
|
||||
// required; the supplier's default_expense_account fills it when set.
|
||||
items: [{ description: '', amount: 0, account_number: '', vat_rate: 0.25, reverse_charge_rate: 0.25 }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -439,7 +443,10 @@ export default function NewSupplierInvoicePage() {
|
||||
extracted.lineItems.map((li) => ({
|
||||
description: li.description || '',
|
||||
amount: typeof li.lineTotal === 'number' ? li.lineTotal : 0,
|
||||
account_number: '5010',
|
||||
// Extraction never suggests accounts (forcibly nulled at parse
|
||||
// time) and a silent default misbooks — leave empty so the user
|
||||
// (or the supplier default) makes the call.
|
||||
account_number: '',
|
||||
vat_rate: vatRateFromAi(li.vatRate),
|
||||
})),
|
||||
)
|
||||
@@ -483,11 +490,14 @@ export default function NewSupplierInvoicePage() {
|
||||
setValue('due_date', due.toISOString().split('T')[0])
|
||||
}
|
||||
if (supplier.default_expense_account && fields.length > 0) {
|
||||
// Only override the first row if it's still the seeded default (5010 with empty desc)
|
||||
const firstRow = watch('items.0')
|
||||
if (firstRow && (firstRow.account_number === '5010' || !firstRow.account_number) && !firstRow.description) {
|
||||
setValue('items.0.account_number', supplier.default_expense_account)
|
||||
}
|
||||
// Fill every row the user hasn't assigned yet — an empty account is the
|
||||
// only signal needed (rows start empty by design, no seeded default).
|
||||
const items = getValues('items')
|
||||
items.forEach((row, i) => {
|
||||
if (!row.account_number) {
|
||||
setValue(`items.${i}.account_number`, supplier.default_expense_account!)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (supplier.default_currency && watch('currency') === 'SEK') {
|
||||
setValue('currency', supplier.default_currency)
|
||||
@@ -801,6 +811,15 @@ export default function NewSupplierInvoicePage() {
|
||||
toast({ title: t('invoice_number_missing_title'), description: t('invoice_number_missing_description'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const rowWithoutAccount = data.items.findIndex((item) => !item.account_number)
|
||||
if (rowWithoutAccount !== -1) {
|
||||
toast({
|
||||
title: t('account_missing_title'),
|
||||
description: t('account_missing_description', { row: rowWithoutAccount + 1 }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (submitModeRef.current === 'register_and_match') {
|
||||
// Open the bank-transaction picker; actual create happens on pick.
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
@@ -234,27 +235,10 @@ export const POST = withRouteContext(
|
||||
// CAS guard: another request paid the invoice between our read and write.
|
||||
// Cancel the orphaned JE and document the voucher gap.
|
||||
if (journalEntryId) {
|
||||
const { data: orphan } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('fiscal_period_id, voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.single()
|
||||
|
||||
await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', journalEntryId)
|
||||
|
||||
if (orphan) {
|
||||
await supabase.from('voucher_gap_explanations').insert({
|
||||
company_id: companyId,
|
||||
fiscal_period_id: orphan.fiscal_period_id,
|
||||
voucher_series: orphan.voucher_series || 'A',
|
||||
gap_number: orphan.voucher_number,
|
||||
explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
created_by: user.id,
|
||||
})
|
||||
}
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase, companyId!, user.id, journalEntryId,
|
||||
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
)
|
||||
}
|
||||
return errorResponseFromCode('SI_PAID_ALREADY', opLog, {
|
||||
requestId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
@@ -383,3 +384,54 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — cash method + F
|
||||
expect(body.remaining_amount).toBe(0.25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/transactions/[id]/match-supplier-invoice — payment JE failure aborts', () => {
|
||||
// Regression: the route used to catch a JE-creation failure and proceed —
|
||||
// marking the invoice paid with NO payment voucher. That half-state is
|
||||
// unrecoverable (mark-paid rejects 'paid', match rejects linked txs), so a
|
||||
// failed voucher must now fail the whole match before any state mutation.
|
||||
|
||||
it('returns 500 MATCH_SI_JE_FAILED and mutates nothing when the engine throws (pure-SEK path)', async () => {
|
||||
// Only the 3 reads enqueued — if the route (incorrectly) proceeded to the
|
||||
// invoice update, the empty queue would surface as MATCH_SI_NOT_OPEN.
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -29890, currency: 'SEK' },
|
||||
invoice: { currency: 'SEK', remaining_amount: 29890 },
|
||||
})
|
||||
mockCreateJournalEntry.mockRejectedValue(new Error('boom'))
|
||||
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('MATCH_SI_JE_FAILED')
|
||||
})
|
||||
|
||||
it('maps a bookkeeping error (missing account) to its structured code and aborts', async () => {
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -11231, currency: 'SEK' },
|
||||
invoice: { currency: 'SEK', remaining_amount: 11231.25 },
|
||||
})
|
||||
mockCreateJournalEntry.mockRejectedValue(new AccountsNotInChartError(['3740']))
|
||||
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { account_numbers?: string[] } }
|
||||
}>(res)
|
||||
expect(status).toBeGreaterThanOrEqual(400)
|
||||
expect(body.error.code).toBe(new AccountsNotInChartError(['3740']).code)
|
||||
expect(body.error.details?.account_numbers).toEqual(['3740'])
|
||||
})
|
||||
|
||||
it('returns MATCH_SI_JE_FAILED when the engine resolves without an entry', async () => {
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -100, currency: 'SEK' },
|
||||
invoice: { currency: 'SEK', remaining_amount: 100 },
|
||||
})
|
||||
mockCreateJournalEntry.mockResolvedValue(null)
|
||||
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('MATCH_SI_JE_FAILED')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { planSupplierPayment } from '@/lib/invoices/apply-supplier-payment'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
@@ -219,7 +219,6 @@ export const POST = withRouteContext(
|
||||
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
|
||||
try {
|
||||
if (customLines) {
|
||||
@@ -298,13 +297,21 @@ export const POST = withRouteContext(
|
||||
}
|
||||
} catch (err) {
|
||||
txLog.error('failed to create supplier invoice payment journal entry', err as Error)
|
||||
// Bookkeeping errors with structured codes get a Swedish translation;
|
||||
// otherwise pass-through. Match still proceeds — the user can re-book.
|
||||
// A failed payment voucher must fail the whole match. Proceeding used to
|
||||
// mark the invoice paid with NO voucher — an unrecoverable half-state:
|
||||
// mark-paid rejects 'paid' invoices and this route rejects linked
|
||||
// transactions, so no flow could ever complete the booking afterwards.
|
||||
if (isBookkeepingError(err)) {
|
||||
journalEntryError = getErrorMessage(err, { context: 'supplier_invoice' })
|
||||
} else {
|
||||
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
|
||||
return errorResponse(err, txLog, { requestId })
|
||||
}
|
||||
return errorResponseFromCode('MATCH_SI_JE_FAILED', txLog, {
|
||||
requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
|
||||
if (!journalEntryId) {
|
||||
return errorResponseFromCode('MATCH_SI_JE_FAILED', txLog, { requestId })
|
||||
}
|
||||
|
||||
// Ledger update from the plan computed up front. An öre-absorbed settlement
|
||||
@@ -332,6 +339,13 @@ export const POST = withRouteContext(
|
||||
}
|
||||
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
// CAS guard: the invoice was settled by a concurrent request between
|
||||
// our read and write. The payment voucher we just posted belongs to no
|
||||
// payment — cancel it and document the gap (mirrors mark-paid).
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase, companyId!, user.id, journalEntryId,
|
||||
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
)
|
||||
return errorResponseFromCode('MATCH_SI_NOT_OPEN', txLog, { requestId })
|
||||
}
|
||||
|
||||
@@ -391,19 +405,12 @@ export const POST = withRouteContext(
|
||||
txLog.warn('supplier_invoice.match_confirmed event emission failed', err as Error)
|
||||
}
|
||||
|
||||
if (journalEntryError) {
|
||||
txLog.warn('supplier invoice match recorded but payment JE failed', {
|
||||
message: journalEntryError,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
invoice_status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
journal_entry_id: journalEntryId,
|
||||
...(journalEntryError ? { journal_entry_error: journalEntryError } : {}),
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
@@ -97,7 +97,7 @@ registerEndpoint({
|
||||
voucher_series: 'A',
|
||||
voucher_number: 142,
|
||||
entry_date: '2026-05-12',
|
||||
description: 'Levfaktura 2026-1234, Office Depot AB (ankomst 42)',
|
||||
description: 'Levfaktura 2026-1234, Office Depot AB (ankomstnr 42)',
|
||||
status: 'posted',
|
||||
source_type: 'supplier_invoice_registered',
|
||||
created_at: '2026-05-13T15:00:00Z',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
@@ -350,6 +351,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
.select('id')
|
||||
if (updateInvErr) return v1ErrorResponse(updateInvErr, txLog, { requestId: ctx.requestId })
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
// CAS guard: the invoice was settled by a concurrent request between
|
||||
// our read and write. The payment voucher we just posted belongs to no
|
||||
// payment — cancel it and document the gap (mirrors mark-paid).
|
||||
if (journalEntryId) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId,
|
||||
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
)
|
||||
}
|
||||
return v1ErrorResponseFromCode('MATCH_SI_NOT_OPEN', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { backfillStandardBASAccounts } from '../account-backfill'
|
||||
|
||||
/**
|
||||
* Flexible supabase mock: every chain method returns the chain; awaiting it
|
||||
* resolves the queued result for that table+operation. Inserts are captured.
|
||||
*/
|
||||
function createMockSupabase(opts: {
|
||||
existingRows?: { account_number: string }[]
|
||||
insertError?: { code?: string; message: string } | null
|
||||
}) {
|
||||
const inserts: unknown[] = []
|
||||
const makeChain = (result: { data?: unknown; error?: unknown }) => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
}
|
||||
return (..._args: unknown[]) => new Proxy(chain, handler)
|
||||
},
|
||||
}
|
||||
return new Proxy(chain, handler)
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => {
|
||||
const base: Record<string, unknown> = {
|
||||
select: () => makeChain({ data: opts.existingRows ?? [] }),
|
||||
insert: (rows: unknown) => {
|
||||
inserts.push(rows)
|
||||
return makeChain({ error: opts.insertError ?? null })
|
||||
},
|
||||
}
|
||||
return base
|
||||
}),
|
||||
}
|
||||
return { supabase, inserts }
|
||||
}
|
||||
|
||||
describe('backfillStandardBASAccounts', () => {
|
||||
it('seeds a standard BAS account with full reference metadata', async () => {
|
||||
const { supabase, inserts } = createMockSupabase({ existingRows: [] })
|
||||
|
||||
const result = await backfillStandardBASAccounts(
|
||||
supabase as never, 'company-1', 'user-1', ['3740'],
|
||||
)
|
||||
|
||||
expect(result).toEqual(['3740'])
|
||||
expect(inserts).toHaveLength(1)
|
||||
const rows = inserts[0] as Record<string, unknown>[]
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
account_number: '3740',
|
||||
account_name: 'Öres- och kronutjämning',
|
||||
account_class: 3,
|
||||
account_group: '37',
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
plan_type: 'full_bas',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips numbers that are not standard BAS accounts', async () => {
|
||||
const { supabase, inserts } = createMockSupabase({ existingRows: [] })
|
||||
|
||||
const result = await backfillStandardBASAccounts(
|
||||
supabase as never, 'company-1', 'user-1', ['9999'],
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(inserts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('never resurrects an existing (deactivated) account', async () => {
|
||||
// The caller saw 3740 as missing because it is INACTIVE — deactivation is
|
||||
// a deliberate user choice, so the backfill must not touch the row.
|
||||
const { supabase, inserts } = createMockSupabase({
|
||||
existingRows: [{ account_number: '3740' }],
|
||||
})
|
||||
|
||||
const result = await backfillStandardBASAccounts(
|
||||
supabase as never, 'company-1', 'user-1', ['3740'],
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(inserts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('treats a concurrent duplicate insert (23505) as success', async () => {
|
||||
const { supabase } = createMockSupabase({
|
||||
existingRows: [],
|
||||
insertError: { code: '23505', message: 'duplicate key value' },
|
||||
})
|
||||
|
||||
const result = await backfillStandardBASAccounts(
|
||||
supabase as never, 'company-1', 'user-1', ['3740'],
|
||||
)
|
||||
|
||||
expect(result).toEqual(['3740'])
|
||||
})
|
||||
|
||||
it('returns [] on a non-duplicate insert error', async () => {
|
||||
const { supabase } = createMockSupabase({
|
||||
existingRows: [],
|
||||
insertError: { code: '42501', message: 'permission denied' },
|
||||
})
|
||||
|
||||
const result = await backfillStandardBASAccounts(
|
||||
supabase as never, 'company-1', 'user-1', ['3740'],
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('seeds only the missing standard accounts from a mixed list', async () => {
|
||||
const { supabase, inserts } = createMockSupabase({
|
||||
existingRows: [{ account_number: '6580' }],
|
||||
})
|
||||
|
||||
const result = await backfillStandardBASAccounts(
|
||||
supabase as never, 'company-1', 'user-1', ['3740', '6580', 'XYZ1'],
|
||||
)
|
||||
|
||||
expect(result).toEqual(['3740'])
|
||||
const rows = inserts[0] as Record<string, unknown>[]
|
||||
expect(rows.map((r) => r.account_number)).toEqual(['3740'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { cancelOrphanedPaymentEntry } from '../cancel-orphaned-entry'
|
||||
|
||||
function createMockSupabase(opts: {
|
||||
orphan?: { fiscal_period_id: string; voucher_series: string | null; voucher_number: number } | null
|
||||
cancelError?: { message: string } | null
|
||||
}) {
|
||||
const updates: unknown[] = []
|
||||
const inserts: Record<string, unknown[]> = {}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation((table: string) => ({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
single: vi.fn().mockResolvedValue({
|
||||
data: opts.orphan ?? null,
|
||||
error: opts.orphan ? null : { message: 'not found' },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockImplementation((payload: unknown) => {
|
||||
updates.push(payload)
|
||||
return {
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({ error: opts.cancelError ?? null }),
|
||||
}),
|
||||
}
|
||||
}),
|
||||
insert: vi.fn().mockImplementation((payload: unknown) => {
|
||||
;(inserts[table] ??= []).push(payload)
|
||||
return Promise.resolve({ error: null })
|
||||
}),
|
||||
})),
|
||||
}
|
||||
return { supabase, updates, inserts }
|
||||
}
|
||||
|
||||
describe('cancelOrphanedPaymentEntry', () => {
|
||||
it('cancels the voucher and records a gap explanation', async () => {
|
||||
const { supabase, updates, inserts } = createMockSupabase({
|
||||
orphan: { fiscal_period_id: 'fp-1', voucher_series: 'A', voucher_number: 66 },
|
||||
})
|
||||
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', 'Automatiskt makulerad: test',
|
||||
)
|
||||
|
||||
expect(updates).toEqual([{ status: 'cancelled' }])
|
||||
const gaps = inserts['voucher_gap_explanations'] as Record<string, unknown>[]
|
||||
expect(gaps).toHaveLength(1)
|
||||
expect(gaps[0]).toMatchObject({
|
||||
company_id: 'company-1',
|
||||
fiscal_period_id: 'fp-1',
|
||||
voucher_series: 'A',
|
||||
gap_number: 66,
|
||||
explanation: 'Automatiskt makulerad: test',
|
||||
created_by: 'user-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults the gap series to A when the voucher has none', async () => {
|
||||
const { supabase, inserts } = createMockSupabase({
|
||||
orphan: { fiscal_period_id: 'fp-1', voucher_series: null, voucher_number: 12 },
|
||||
})
|
||||
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', 'x',
|
||||
)
|
||||
|
||||
const gaps = inserts['voucher_gap_explanations'] as Record<string, unknown>[]
|
||||
expect(gaps[0]).toMatchObject({ voucher_series: 'A' })
|
||||
})
|
||||
|
||||
it('still cancels when the orphan lookup fails, but records no gap', async () => {
|
||||
const { supabase, updates, inserts } = createMockSupabase({ orphan: null })
|
||||
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', 'x',
|
||||
)
|
||||
|
||||
expect(updates).toEqual([{ status: 'cancelled' }])
|
||||
expect(inserts['voucher_gap_explanations']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never throws, even when the client rejects unexpectedly', async () => {
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => {
|
||||
throw new Error('network blip')
|
||||
}),
|
||||
}
|
||||
|
||||
await expect(
|
||||
cancelOrphanedPaymentEntry(supabase as never, 'company-1', 'user-1', 'je-1', 'x'),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not record a gap when the cancel itself fails', async () => {
|
||||
const { supabase, inserts } = createMockSupabase({
|
||||
orphan: { fiscal_period_id: 'fp-1', voucher_series: 'A', voucher_number: 9 },
|
||||
cancelError: { message: 'period locked' },
|
||||
})
|
||||
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase as never, 'company-1', 'user-1', 'je-1', 'x',
|
||||
)
|
||||
|
||||
// The voucher is still live — a gap explanation would be a lie.
|
||||
expect(inserts['voucher_gap_explanations']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -69,4 +69,33 @@ describe('engine.pg — triggers & RPCs that mocks cannot catch', () => {
|
||||
),
|
||||
).rejects.toThrow(/Cannot modify a posted journal entry/i)
|
||||
})
|
||||
|
||||
it('next_voucher_number falls back to the company owner when auth.uid() is NULL', async () => {
|
||||
// The superuser pg connection has no Supabase JWT, so auth.uid() IS NULL —
|
||||
// exactly the service-role shape (repair scripts, cron) that used to fail
|
||||
// the voucher_sequences user_id NOT NULL check before ON CONFLICT could
|
||||
// arbitrate (commit_journal_entry got the fallback in 20260421170500;
|
||||
// next_voucher_number — the storno/correction path — did not until
|
||||
// 20260611130000).
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
|
||||
const first = await getPool().query<{ n: number }>(
|
||||
`SELECT public.next_voucher_number($1::uuid, $2::uuid) AS n`,
|
||||
[companyId, fiscalPeriodId],
|
||||
)
|
||||
const second = await getPool().query<{ n: number }>(
|
||||
`SELECT public.next_voucher_number($1::uuid, $2::uuid) AS n`,
|
||||
[companyId, fiscalPeriodId],
|
||||
)
|
||||
expect(first.rows[0]!.n).toBe(1)
|
||||
expect(second.rows[0]!.n).toBe(2)
|
||||
|
||||
// Attribution on the sequence row falls back to companies.created_by.
|
||||
const seq = await getPool().query<{ user_id: string }>(
|
||||
`SELECT user_id FROM public.voucher_sequences
|
||||
WHERE company_id = $1::uuid AND fiscal_period_id = $2::uuid AND voucher_series = 'A'`,
|
||||
[companyId, fiscalPeriodId],
|
||||
)
|
||||
expect(seq.rows[0]!.user_id).toBe(userId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { validateBalance, getSwedishLocalDate, createDraftEntry, reverseEntry } from '../engine'
|
||||
import { BookkeepingDatabaseError } from '../errors'
|
||||
import { BookkeepingDatabaseError, AccountsNotInChartError } from '../errors'
|
||||
import type { CreateJournalEntryLineInput, JournalEntryStatus } from '@/types'
|
||||
|
||||
// Mock Supabase client for createDraftEntry/reverseEntry tests
|
||||
@@ -26,6 +26,13 @@ vi.mock('@/lib/events', () => ({
|
||||
eventBus: { emit: vi.fn().mockResolvedValue([]) },
|
||||
}))
|
||||
|
||||
// Mock the on-demand BAS backfill — default: nothing seedable. Individual
|
||||
// tests override per scenario.
|
||||
const mockBackfill = vi.fn().mockResolvedValue([])
|
||||
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
|
||||
backfillStandardBASAccounts: (...args: unknown[]) => mockBackfill(...args),
|
||||
}))
|
||||
|
||||
describe('validateBalance', () => {
|
||||
it('balanced entry (debit == credit) → valid: true', () => {
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
@@ -354,3 +361,125 @@ describe('JournalEntryStatus type includes cancelled', () => {
|
||||
expect(['draft', 'posted', 'reversed', 'cancelled']).toContain(status)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDraftEntry — on-demand BAS account backfill', () => {
|
||||
// Engine seeds standard BAS accounts missing from the chart instead of
|
||||
// failing (June 2026 incident: 3740 öresavrundning missing → payment
|
||||
// voucher dead end). Non-seedable numbers still throw.
|
||||
|
||||
beforeEach(() => {
|
||||
mockBackfill.mockClear()
|
||||
})
|
||||
|
||||
function buildSupabase(opts: { chartByCall: { account_number: string; id: string }[][] }) {
|
||||
let chartCall = 0
|
||||
return {
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
if (table === 'fiscal_periods') {
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
single: vi.fn().mockResolvedValue({
|
||||
data: { name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'chart_of_accounts') {
|
||||
const result = opts.chartByCall[Math.min(chartCall++, opts.chartByCall.length - 1)]
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
in: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({ data: result, error: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'journal_entries') {
|
||||
return {
|
||||
insert: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
single: vi.fn().mockResolvedValue({
|
||||
data: { id: 'entry-1', status: 'draft' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
single: vi.fn().mockResolvedValue({
|
||||
data: { id: 'entry-1', status: 'draft', lines: [] },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'journal_entry_lines') {
|
||||
return { insert: vi.fn().mockResolvedValue({ error: null }) }
|
||||
}
|
||||
return createMockChain()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const LINES: CreateJournalEntryLineInput[] = [
|
||||
{ account_number: '2440', debit_amount: 11231.25, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 11231 },
|
||||
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
|
||||
]
|
||||
|
||||
it('seeds a missing standard BAS account and proceeds', async () => {
|
||||
mockBackfill.mockResolvedValue(['3740'])
|
||||
const supabase = buildSupabase({
|
||||
chartByCall: [
|
||||
// First resolution: 3740 missing
|
||||
[{ account_number: '2440', id: 'acc-1' }, { account_number: '1930', id: 'acc-2' }],
|
||||
// Re-resolution after backfill: all present
|
||||
[
|
||||
{ account_number: '2440', id: 'acc-1' },
|
||||
{ account_number: '1930', id: 'acc-2' },
|
||||
{ account_number: '3740', id: 'acc-3' },
|
||||
],
|
||||
],
|
||||
})
|
||||
|
||||
const entry = await createDraftEntry(supabase as never, 'company-1', 'user-1', {
|
||||
fiscal_period_id: 'period-1',
|
||||
entry_date: '2026-06-08',
|
||||
description: 'Utbetalning leverantörsfaktura',
|
||||
source_type: 'supplier_invoice_paid',
|
||||
lines: LINES,
|
||||
})
|
||||
|
||||
expect(entry.id).toBe('entry-1')
|
||||
expect(mockBackfill).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', ['3740'])
|
||||
})
|
||||
|
||||
it('still throws AccountsNotInChartError when the account is not seedable', async () => {
|
||||
mockBackfill.mockResolvedValue([])
|
||||
const supabase = buildSupabase({
|
||||
chartByCall: [
|
||||
[{ account_number: '2440', id: 'acc-1' }, { account_number: '1930', id: 'acc-2' }],
|
||||
],
|
||||
})
|
||||
|
||||
await expect(
|
||||
createDraftEntry(supabase as never, 'company-1', 'user-1', {
|
||||
fiscal_period_id: 'period-1',
|
||||
entry_date: '2026-06-08',
|
||||
description: 'Utbetalning leverantörsfaktura',
|
||||
source_type: 'supplier_invoice_paid',
|
||||
lines: LINES,
|
||||
})
|
||||
).rejects.toThrow(AccountsNotInChartError)
|
||||
|
||||
expect(mockBackfill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -672,7 +672,7 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.description).toBe('Leverantörsfaktura LF-100, Leverantör AB (ankomst 5)')
|
||||
expect(input.description).toBe('Leverantörsfaktura LF-100, Leverantör AB (ankomstnr 5)')
|
||||
})
|
||||
|
||||
it('description falls back without supplier name', async () => {
|
||||
@@ -687,7 +687,7 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.description).toBe('Leverantörsfaktura LF-100 (ankomst 5)')
|
||||
expect(input.description).toBe('Leverantörsfaktura LF-100 (ankomstnr 5)')
|
||||
})
|
||||
|
||||
it('handles non-EU reverse charge (services)', async () => {
|
||||
@@ -953,7 +953,7 @@ describe('createSupplierInvoicePaymentEntry', () => {
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.description).toBe('Utbetalning leverantörsfaktura LF-200, Leverantör AB (ankomst 10)')
|
||||
expect(input.description).toBe('Utbetalning leverantörsfaktura LF-200, Leverantör AB (ankomstnr 10)')
|
||||
})
|
||||
|
||||
it('credits the provided paymentAccount instead of 1930', async () => {
|
||||
@@ -1506,7 +1506,7 @@ describe('createSupplierCreditNoteEntry', () => {
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.description).toBe('Kreditfaktura leverantör LF-400, Leverantör AB (ankomst 7)')
|
||||
expect(input.description).toBe('Kreditfaktura leverantör LF-400, Leverantör AB (ankomstnr 7)')
|
||||
})
|
||||
|
||||
it('sets source_type to supplier_credit_note', async () => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
|
||||
|
||||
const log = createLogger('account-backfill')
|
||||
|
||||
/**
|
||||
* Seed missing standard BAS accounts into a company's chart on demand.
|
||||
*
|
||||
* A company chart starts minimal, and legitimate engine flows routinely reach
|
||||
* accounts that exist in BAS 2026 but were never added — öresavrundning on
|
||||
* 3740 the first time a Bankgiro payment lands a sub-krona off, a first legal
|
||||
* invoice on 6580. Failing the whole entry for that (AccountsNotInChartError)
|
||||
* turns a standard account into a dead end, so the engine backfills instead.
|
||||
*
|
||||
* Deliberately conservative:
|
||||
* - Only accounts present in BAS_REFERENCE are seeded — unknown numbers stay
|
||||
* missing and surface as AccountsNotInChartError in the caller.
|
||||
* - An account that exists but is INACTIVE is never touched: deactivation is
|
||||
* a deliberate user choice, and silently reactivating would override it.
|
||||
* - A concurrent insert (unique violation) counts as success.
|
||||
*
|
||||
* Returns the account numbers that are now present and active.
|
||||
*/
|
||||
export async function backfillStandardBASAccounts(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
accountNumbers: string[],
|
||||
): Promise<string[]> {
|
||||
if (accountNumbers.length === 0) return []
|
||||
|
||||
// Only standard BAS accounts qualify.
|
||||
const candidates = accountNumbers
|
||||
.map((num) => ({ num, basRef: getBASReference(num) }))
|
||||
.filter((c): c is { num: string; basRef: NonNullable<ReturnType<typeof getBASReference>> } =>
|
||||
Boolean(c.basRef),
|
||||
)
|
||||
if (candidates.length === 0) return []
|
||||
|
||||
// Never resurrect rows that already exist (active or inactive) — the caller
|
||||
// saw them as missing because they are inactive, and that stays their state.
|
||||
const { data: existing, error: existingError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('account_number', candidates.map((c) => c.num))
|
||||
if (existingError) {
|
||||
log.error('failed to check existing accounts before backfill', existingError, { companyId })
|
||||
return []
|
||||
}
|
||||
const existingNumbers = new Set((existing ?? []).map((r) => r.account_number))
|
||||
const toInsert = candidates.filter((c) => !existingNumbers.has(c.num))
|
||||
if (toInsert.length === 0) return []
|
||||
|
||||
const rows = toInsert.map(({ num, basRef }) => ({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
account_number: num,
|
||||
account_name: basRef.account_name,
|
||||
account_class: basRef.account_class,
|
||||
account_group: basRef.account_group,
|
||||
account_type: basRef.account_type,
|
||||
normal_balance: basRef.normal_balance,
|
||||
sru_code: basRef.sru_code ?? computeSRUCode(num),
|
||||
k2_excluded: basRef.k2_excluded,
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
description: basRef.description,
|
||||
sort_order: /^\d+$/.test(num) ? parseInt(num, 10) : null,
|
||||
}))
|
||||
|
||||
const { error: insertError } = await supabase.from('chart_of_accounts').insert(rows)
|
||||
if (insertError) {
|
||||
// Unique violation = another request seeded it concurrently — that's fine,
|
||||
// the account exists now. Anything else: log and let the caller's
|
||||
// re-resolution decide what is still missing.
|
||||
if (insertError.code !== '23505' && !insertError.message?.includes('duplicate')) {
|
||||
log.error('failed to backfill standard BAS accounts', insertError, {
|
||||
companyId,
|
||||
accountNumbers: toInsert.map((c) => c.num),
|
||||
})
|
||||
return []
|
||||
}
|
||||
} else {
|
||||
log.info('seeded standard BAS accounts on demand', {
|
||||
companyId,
|
||||
accountNumbers: toInsert.map((c) => c.num),
|
||||
})
|
||||
}
|
||||
|
||||
return toInsert.map((c) => c.num)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('cancel-orphaned-entry')
|
||||
|
||||
/**
|
||||
* Compensation for the payment-flow CAS guard: a payment voucher was posted,
|
||||
* but the invoice row was settled by a concurrent request between our read
|
||||
* and write, so the voucher belongs to no payment. Cancel it and document
|
||||
* the voucher-number gap (BFNAR 2013:2 requires gaps to be explained).
|
||||
*
|
||||
* Mirrors the inline compensation the mark-paid route has always had; the
|
||||
* match routes previously returned MATCH_SI_NOT_OPEN and left the voucher
|
||||
* orphaned in the ledger.
|
||||
*
|
||||
* Best-effort by design: the CAS conflict response is already correct for
|
||||
* the caller, so failures here are logged loudly rather than thrown.
|
||||
*/
|
||||
export async function cancelOrphanedPaymentEntry(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
journalEntryId: string,
|
||||
explanation: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { data: orphan, error: fetchError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('fiscal_period_id, voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
log.error('failed to load orphaned payment voucher for cancellation', fetchError, {
|
||||
companyId,
|
||||
journalEntryId,
|
||||
})
|
||||
}
|
||||
|
||||
// Recovery breadcrumb BEFORE mutating: the cancel and the gap insert are
|
||||
// separate statements, so a crash between them would leave a cancelled
|
||||
// voucher with no gap explanation (BFNAR 2013:2 requires one). This line
|
||||
// carries everything an operator needs to write it manually.
|
||||
if (orphan) {
|
||||
log.info('cancelling orphaned payment voucher', {
|
||||
companyId,
|
||||
journalEntryId,
|
||||
voucherSeries: orphan.voucher_series || 'A',
|
||||
voucherNumber: orphan.voucher_number,
|
||||
fiscalPeriodId: orphan.fiscal_period_id,
|
||||
explanation,
|
||||
})
|
||||
}
|
||||
|
||||
const { error: cancelError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (cancelError) {
|
||||
log.error('failed to cancel orphaned payment voucher (manual cleanup needed)', cancelError, {
|
||||
companyId,
|
||||
journalEntryId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (orphan) {
|
||||
const { error: gapError } = await supabase.from('voucher_gap_explanations').insert({
|
||||
company_id: companyId,
|
||||
fiscal_period_id: orphan.fiscal_period_id,
|
||||
voucher_series: orphan.voucher_series || 'A',
|
||||
gap_number: orphan.voucher_number,
|
||||
explanation,
|
||||
created_by: userId,
|
||||
})
|
||||
if (gapError) {
|
||||
log.error('failed to record voucher gap explanation for cancelled orphan', gapError, {
|
||||
companyId,
|
||||
journalEntryId,
|
||||
voucherNumber: orphan.voucher_number,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Hard never-throw guarantee: the caller is about to return the correct
|
||||
// CAS-conflict response, and an unexpected rejection here (network blip,
|
||||
// driver error) must not replace it with a 500. The orphan stays posted
|
||||
// and visible; the breadcrumb above covers manual recovery.
|
||||
log.error('unexpected failure while cancelling orphaned payment voucher', err as Error, {
|
||||
companyId,
|
||||
journalEntryId,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
JournalEntryNotFoundError,
|
||||
} from '@/lib/bookkeeping/errors'
|
||||
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
|
||||
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
|
||||
import { getActor } from '@/lib/bookkeeping/actor-context'
|
||||
import type {
|
||||
@@ -239,11 +240,24 @@ export async function createDraftEntry(
|
||||
// Resolve account IDs
|
||||
const accountIdMap = await resolveAccountIds(supabase, companyId, input.lines)
|
||||
|
||||
// Validate all account numbers resolved to IDs
|
||||
// Validate all account numbers resolved to IDs. Standard BAS accounts are
|
||||
// seeded on demand before failing: a minimal chart routinely lacks accounts
|
||||
// legitimate flows reach (3740 öresavrundning on the first sub-krona
|
||||
// Bankgiro diff, 6580 on a first legal invoice), and throwing here turned
|
||||
// those into dead ends. Non-BAS numbers and deliberately deactivated
|
||||
// accounts still throw.
|
||||
const allAccountNumbers = [...new Set(input.lines.map(l => l.account_number))]
|
||||
const missingAccounts = allAccountNumbers.filter(num => !accountIdMap.has(num))
|
||||
let missingAccounts = allAccountNumbers.filter(num => !accountIdMap.has(num))
|
||||
if (missingAccounts.length > 0) {
|
||||
throw new AccountsNotInChartError(missingAccounts)
|
||||
const seeded = await backfillStandardBASAccounts(supabase, companyId, userId, missingAccounts)
|
||||
if (seeded.length > 0) {
|
||||
const refreshed = await resolveAccountIds(supabase, companyId, input.lines)
|
||||
for (const [num, id] of refreshed) accountIdMap.set(num, id)
|
||||
missingAccounts = allAccountNumbers.filter(num => !accountIdMap.has(num))
|
||||
}
|
||||
if (missingAccounts.length > 0) {
|
||||
throw new AccountsNotInChartError(missingAccounts)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve voucher_series: explicit input wins; otherwise look up the
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function createSupplierInvoiceRegistrationEntry(
|
||||
}
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomst ${invoice.arrival_number})`)
|
||||
const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
|
||||
const isForeign = invoice.currency !== 'SEK'
|
||||
|
||||
// Aggregate expense amounts by account number and convert to SEK
|
||||
@@ -195,7 +195,7 @@ export async function createSupplierInvoicePaymentEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
const desc = buildSupplierDescription('Utbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomst ${invoice.arrival_number})`)
|
||||
const desc = buildSupplierDescription('Utbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
if (exchangeRateDifference && exchangeRateDifference !== 0) {
|
||||
@@ -458,7 +458,7 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
|
||||
}
|
||||
|
||||
const ownerAccount = entityType === 'aktiebolag' ? '2893' : '2018'
|
||||
const desc = buildSupplierDescription('Eget utlägg', invoice.supplier_invoice_number, supplierName, `(ankomst ${invoice.arrival_number})`)
|
||||
const desc = buildSupplierDescription('Eget utlägg', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Debit: Expense accounts (in SEK), aggregated per account
|
||||
@@ -535,7 +535,7 @@ export async function createSupplierCreditNoteEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomst ${creditNote.arrival_number})`)
|
||||
const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomstnr ${creditNote.arrival_number})`)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Credit: Expense accounts (reverse, in SEK)
|
||||
|
||||
@@ -519,6 +519,14 @@ const MATCH_SI: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Den här transaktionen är redan matchad mot leverantörsfakturan.',
|
||||
message_en: 'This transaction is already matched to this supplier invoice.',
|
||||
},
|
||||
MATCH_SI_JE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv:
|
||||
'Betalningsverifikationen kunde inte skapas. Matchningen avbröts — inga ändringar har sparats.',
|
||||
message_en:
|
||||
'Failed to create the payment voucher. The match was aborted — no changes were saved.',
|
||||
retryable: true,
|
||||
},
|
||||
MATCH_SI_RECORD_PAYMENT_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Kunde inte registrera leverantörsfakturabetalningen.',
|
||||
|
||||
@@ -42,6 +42,8 @@ function createQueueMockSupabase() {
|
||||
// Captures .insert() payloads keyed by table, so tests can assert what was
|
||||
// written (e.g. cash_account_id stamping).
|
||||
const inserts: Record<string, unknown[]> = {}
|
||||
// Same for .update() payloads (e.g. supplier-invoice suggestion linking).
|
||||
const updates: Record<string, unknown[]> = {}
|
||||
|
||||
/**
|
||||
* Push one or more results onto the queue.
|
||||
@@ -66,6 +68,12 @@ function createQueueMockSupabase() {
|
||||
return buildChain(table)
|
||||
}
|
||||
}
|
||||
if (prop === 'update') {
|
||||
return (payload: unknown) => {
|
||||
;(updates[table] ??= []).push(payload)
|
||||
return buildChain(table)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
@@ -77,7 +85,7 @@ function createQueueMockSupabase() {
|
||||
rpc: vi.fn().mockImplementation(() => buildChain('rpc')),
|
||||
}
|
||||
|
||||
return { supabase, enqueue, inserts }
|
||||
return { supabase, enqueue, inserts, updates }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -640,6 +648,58 @@ describe('ingestTransactions', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4b. Supplier-invoice match at sync is ALWAYS a suggestion, never a hard
|
||||
// link. Regression: a high-confidence hit used to set
|
||||
// supplier_invoice_id directly — with no payment voucher booked — which
|
||||
// then BLOCKED the match route (MATCH_SI_TX_ALREADY_LINKED), stranding
|
||||
// the bank line with no path to a payment booking (June 2026 incident:
|
||||
// RosholmDell 18299).
|
||||
// -----------------------------------------------------------------------
|
||||
it('demotes a high-confidence supplier-invoice match to potential_supplier_invoice_id', async () => {
|
||||
const { supabase, enqueue, updates } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2026-06-08',
|
||||
amount: -29890,
|
||||
description: 'RosholmDell Advo BG 0000007746514 Bg-bet. via internet',
|
||||
})
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-rd',
|
||||
amount: -29890,
|
||||
date: '2026-06-08',
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
// One unpaid invoice, exact amount, tx date inside the credit window →
|
||||
// Pass-3 amount_date match at 0.85, unambiguous (previously: hard link).
|
||||
const supplierInvoice = {
|
||||
id: 'si-rd',
|
||||
status: 'registered',
|
||||
total: 29890,
|
||||
remaining_amount: 29890,
|
||||
invoice_date: '2026-06-05',
|
||||
due_date: '2026-07-05',
|
||||
payment_reference: null,
|
||||
supplier: { name: 'RosholmDell Advokatbyrå AB' },
|
||||
}
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map
|
||||
enqueue({ data: [], error: null }) // unbooked bank-synced map
|
||||
enqueue({ data: [supplierInvoice], error: null }) // supplier invoices pool
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: inserted, error: null }) // insert
|
||||
enqueue({ data: null, error: null }) // suggestion update
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.auto_matched_invoices).toBe(1)
|
||||
const txUpdates = (updates['transactions'] ?? []) as Record<string, unknown>[]
|
||||
expect(txUpdates).toHaveLength(1)
|
||||
expect(txUpdates[0]).toEqual({ potential_supplier_invoice_id: 'si-rd' })
|
||||
// The hard link is reserved for completed matches (payment voucher booked).
|
||||
expect(txUpdates.some((u) => 'supplier_invoice_id' in u)).toBe(false)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Does not attempt invoice matching for expenses (amount < 0)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
+23
-31
@@ -449,46 +449,38 @@ export async function ingestTransactions(
|
||||
)
|
||||
|
||||
if (match && !matchedSupplierInvoiceIds.has(match.supplierInvoice.id)) {
|
||||
// Ambiguous amount_date hits (several same-amount invoices in-window)
|
||||
// are demoted to suggestions — auto-linking the wrong one is worse
|
||||
// than asking the user to pick.
|
||||
// ALWAYS a suggestion (potential_supplier_invoice_id), never a hard
|
||||
// link. supplier_invoice_id is reserved for completed matches — the
|
||||
// match route books the payment voucher when it sets it. A sync-time
|
||||
// hard link booked nothing, left the invoice open, and then BLOCKED
|
||||
// the match route (MATCH_SI_TX_ALREADY_LINKED), stranding the
|
||||
// transaction with no path to a payment voucher.
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
supplierInvoiceId: match.supplierInvoice.id,
|
||||
matchConfidence: match.confidence,
|
||||
matchMethod: match.matchMethod,
|
||||
})
|
||||
|
||||
if (match.confidence >= 0.85 && !match.ambiguous) {
|
||||
// Auto-link at high confidence
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
// Log the match THEN drain the pool (captures which invoice was matched)
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
supplierInvoiceId: match.supplierInvoice.id,
|
||||
matchConfidence: match.confidence,
|
||||
matchMethod: match.matchMethod,
|
||||
})
|
||||
|
||||
// Drain the pool — prevents next transaction from matching same invoice
|
||||
// High-confidence unambiguous hit: drain the pool so the next
|
||||
// transaction can't claim the same invoice, and skip the mapping
|
||||
// engine — auto-categorization would create an orphaned journal
|
||||
// entry that conflicts with the eventual payment booking.
|
||||
unpaidSupplierInvoices = unpaidSupplierInvoices.filter(
|
||||
inv => inv.id !== match.supplierInvoice.id
|
||||
)
|
||||
matchedSupplierInvoiceIds.add(match.supplierInvoice.id)
|
||||
|
||||
result.auto_matched_invoices++
|
||||
// Skip mapping engine — transaction has a supplier invoice match
|
||||
continue
|
||||
} else {
|
||||
// Store as suggestion at lower confidence (0.70–0.85)
|
||||
// Do NOT drain pool for suggestions — they are tentative
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
supplierInvoiceId: match.supplierInvoice.id,
|
||||
matchConfidence: match.confidence,
|
||||
matchMethod: match.matchMethod,
|
||||
})
|
||||
}
|
||||
// Lower confidence (0.70–0.85) or ambiguous: tentative — do NOT
|
||||
// drain the pool.
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — continue processing
|
||||
|
||||
@@ -2682,6 +2682,8 @@
|
||||
"supplier_missing_description": "Select or create a supplier.",
|
||||
"invoice_number_missing_title": "Invoice number missing",
|
||||
"invoice_number_missing_description": "Enter the supplier's invoice number.",
|
||||
"account_missing_title": "Account missing",
|
||||
"account_missing_description": "Select an expense account for line {row}.",
|
||||
"expense_registered_title": "Expense registered",
|
||||
"invoice_registered_title": "Invoice registered",
|
||||
"arrival_number_label": "Arrival number: {number}",
|
||||
|
||||
@@ -2682,6 +2682,8 @@
|
||||
"supplier_missing_description": "Välj eller skapa en leverantör.",
|
||||
"invoice_number_missing_title": "Fakturanummer saknas",
|
||||
"invoice_number_missing_description": "Ange leverantörens fakturanummer.",
|
||||
"account_missing_title": "Konto saknas",
|
||||
"account_missing_description": "Välj ett bokföringskonto för rad {row}.",
|
||||
"expense_registered_title": "Utlägg registrerat",
|
||||
"invoice_registered_title": "Faktura registrerad",
|
||||
"arrival_number_label": "Ankomstnummer: {number}",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- next_voucher_number: fall back to the company owner when auth.uid() is NULL.
|
||||
--
|
||||
-- Mirrors 20260421170500 (commit_journal_entry user_id fallback). The same
|
||||
-- failure mode survived here: under a service-role client (repair scripts,
|
||||
-- cron, internal maintenance) auth.uid() is NULL, and the INSERT into
|
||||
-- voucher_sequences fails its user_id NOT NULL check *before* ON CONFLICT
|
||||
-- can resolve to DO UPDATE (PostgreSQL evaluates NOT NULL on the candidate
|
||||
-- tuple ahead of conflict arbitration) — even when the sequence row already
|
||||
-- exists. commit_journal_entry was fixed; the storno/correction path
|
||||
-- (getNextVoucherNumber → correctEntry) still called this unfixed twin and
|
||||
-- failed from any non-interactive context.
|
||||
--
|
||||
-- next_voucher_number has no journal entry to read attribution from, so the
|
||||
-- fallback is the company owner (companies.created_by) — same source
|
||||
-- seed_chart_of_accounts uses. Interactive flows still record auth.uid();
|
||||
-- existing sequence rows keep their original owner (DO UPDATE never touches
|
||||
-- user_id).
|
||||
--
|
||||
-- Also sets search_path = public: the 20260304 hardening targeted the old
|
||||
-- (p_user_id …) signature that 20260330 dropped, so the current function had
|
||||
-- lost it.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.next_voucher_number(
|
||||
p_company_id uuid,
|
||||
p_fiscal_period_id uuid,
|
||||
p_series text DEFAULT 'A'
|
||||
)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_next integer;
|
||||
v_user_id uuid;
|
||||
BEGIN
|
||||
v_user_id := auth.uid();
|
||||
IF v_user_id IS NULL THEN
|
||||
SELECT created_by INTO v_user_id
|
||||
FROM public.companies
|
||||
WHERE id = p_company_id;
|
||||
END IF;
|
||||
|
||||
IF v_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'next_voucher_number: no attributable user for company %', p_company_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number)
|
||||
VALUES (p_company_id, v_user_id, p_fiscal_period_id, p_series, 1)
|
||||
ON CONFLICT (company_id, fiscal_period_id, voucher_series)
|
||||
DO UPDATE SET
|
||||
last_number = public.voucher_sequences.last_number + 1,
|
||||
updated_at = now()
|
||||
RETURNING last_number INTO v_next;
|
||||
|
||||
RETURN v_next;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user