diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx index 8c515dc7..0837aa31 100644 --- a/app/(dashboard)/supplier-invoices/new/page.tsx +++ b/app/(dashboard)/supplier-invoices/new/page.tsx @@ -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. diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index 8fe15ce0..0ff81eed 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -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, diff --git a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts index 6a781842..9dcd7590 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 007e4064..80015042 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -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 }, diff --git a/app/api/v1/companies/[companyId]/journal-entries/route.ts b/app/api/v1/companies/[companyId]/journal-entries/route.ts index 5669898d..391b36c1 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/route.ts @@ -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', diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts index 63c68e2b..339fd3ba 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts @@ -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, }) diff --git a/lib/bookkeeping/__tests__/account-backfill.test.ts b/lib/bookkeeping/__tests__/account-backfill.test.ts new file mode 100644 index 00000000..3c62523d --- /dev/null +++ b/lib/bookkeeping/__tests__/account-backfill.test.ts @@ -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 = {} + const handler: ProxyHandler = { + 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 = { + 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[] + 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[] + expect(rows.map((r) => r.account_number)).toEqual(['3740']) + }) +}) diff --git a/lib/bookkeeping/__tests__/cancel-orphaned-entry.test.ts b/lib/bookkeeping/__tests__/cancel-orphaned-entry.test.ts new file mode 100644 index 00000000..b1651cc9 --- /dev/null +++ b/lib/bookkeeping/__tests__/cancel-orphaned-entry.test.ts @@ -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 = {} + + 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[] + 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[] + 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() + }) +}) diff --git a/lib/bookkeeping/__tests__/engine.pg.test.ts b/lib/bookkeeping/__tests__/engine.pg.test.ts index b5de22b5..0e2a134a 100644 --- a/lib/bookkeeping/__tests__/engine.pg.test.ts +++ b/lib/bookkeeping/__tests__/engine.pg.test.ts @@ -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) + }) }) diff --git a/lib/bookkeeping/__tests__/engine.test.ts b/lib/bookkeeping/__tests__/engine.test.ts index 6e99c230..e9af113d 100644 --- a/lib/bookkeeping/__tests__/engine.test.ts +++ b/lib/bookkeeping/__tests__/engine.test.ts @@ -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) + }) +}) diff --git a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts index 335c60cb..6df54919 100644 --- a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts +++ b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts @@ -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 () => { diff --git a/lib/bookkeeping/account-backfill.ts b/lib/bookkeeping/account-backfill.ts new file mode 100644 index 00000000..e15056d8 --- /dev/null +++ b/lib/bookkeeping/account-backfill.ts @@ -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 { + 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> } => + 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) +} diff --git a/lib/bookkeeping/cancel-orphaned-entry.ts b/lib/bookkeeping/cancel-orphaned-entry.ts new file mode 100644 index 00000000..0efa7ab7 --- /dev/null +++ b/lib/bookkeeping/cancel-orphaned-entry.ts @@ -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 { + 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, + }) + } +} diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index df01f0df..750509f3 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -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 diff --git a/lib/bookkeeping/supplier-invoice-entries.ts b/lib/bookkeeping/supplier-invoice-entries.ts index a778ed59..a72f44be 100644 --- a/lib/bookkeeping/supplier-invoice-entries.ts +++ b/lib/bookkeeping/supplier-invoice-entries.ts @@ -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) diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 75fffc58..5be4fe7b 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -519,6 +519,14 @@ const MATCH_SI: Record = { 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.', diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts index 45cda80d..54269723 100644 --- a/lib/transactions/__tests__/ingest.test.ts +++ b/lib/transactions/__tests__/ingest.test.ts @@ -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 = {} + // Same for .update() payloads (e.g. supplier-invoice suggestion linking). + const updates: Record = {} /** * 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[] + 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) // ----------------------------------------------------------------------- diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index ed0b3869..d3389cfe 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -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 diff --git a/messages/en.json b/messages/en.json index 9451406e..f2edcf44 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}", diff --git a/messages/sv.json b/messages/sv.json index ee0a985e..e60a338a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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}", diff --git a/supabase/migrations/20260611130000_next_voucher_number_user_id_fallback.sql b/supabase/migrations/20260611130000_next_voucher_number_user_id_fallback.sql new file mode 100644 index 00000000..9a5236c8 --- /dev/null +++ b/supabase/migrations/20260611130000_next_voucher_number_user_id_fallback.sql @@ -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';