diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index ea0a9bfa..7ca8ceea 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -10,10 +10,12 @@ import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info } from 'lucide-react' import AgentSparkleButton from '@/components/agent/AgentSparkleButton' +import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker' import { useCanWrite } from '@/lib/hooks/use-can-write' import { formatDate } from '@/lib/utils' import Link from 'next/link' @@ -45,6 +47,7 @@ export default function SupplierInvoiceDetailPage() { const [invoice, setInvoice] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isPayDialogOpen, setIsPayDialogOpen] = useState(false) + const [payTab, setPayTab] = useState<'new' | 'existing'>('new') const [payAmount, setPayAmount] = useState('') const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0]) const [isProcessing, setIsProcessing] = useState(false) @@ -586,45 +589,72 @@ export default function SupplierInvoiceDetailPage() { {/* Pay Dialog */} - + { + setIsPayDialogOpen(open) + if (!open) setPayTab('new') + }} + > {t('pay_dialog_title')} -
-
- - setPaymentDate(e.target.value)} - className="w-full sm:w-48" + setPayTab(v as 'new' | 'existing')}> + + {t('tab_new_payment')} + {t('tab_existing_voucher')} + + +
+
+ + setPaymentDate(e.target.value)} + className="w-full sm:w-48" + /> +
+
+ + setPayAmount(e.target.value)} + /> +

+ {t('remaining_to_pay', { amount: formatAmount(invoice.remaining_amount), currency: invoice.currency })} +

+
+
+ + +
+
+
+ + { + setIsPayDialogOpen(false) + setPayTab('new') + fetchInvoice() + }} + onCancel={() => setPayTab('new')} /> -
-
- - setPayAmount(e.target.value)} - /> -

- {t('remaining_to_pay', { amount: formatAmount(invoice.remaining_amount), currency: invoice.currency })} -

-
-
- - -
-
+ +
diff --git a/app/api/supplier-invoices/[id]/link-to-voucher/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/link-to-voucher/__tests__/route.test.ts new file mode 100644 index 00000000..051b3ae3 --- /dev/null +++ b/app/api/supplier-invoices/[id]/link-to-voucher/__tests__/route.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const mockLink = vi.fn() +vi.mock('@/lib/invoices/supplier-voucher-matching', () => ({ + linkSupplierInvoiceToVoucher: (...args: unknown[]) => mockLink(...args), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { POST } from '../route' + +const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' +const VALID_JE_UUID = '550e8400-e29b-41d4-a716-446655440001' +const mockUser = { id: 'user-1', email: 'test@test.se' } + +describe('POST /api/supplier-invoices/[id]/link-to-voucher', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 400 when journal_entry_id is missing', async () => { + const request = createMockRequest(`/api/supplier-invoices/${VALID_UUID}/link-to-voucher`, { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: VALID_UUID })) + expect(response.status).toBe(400) + }) + + it('returns 200 with the linked payment payload on success', async () => { + mockLink.mockResolvedValue({ + ok: true, + result: { + paymentId: 'sip-1', + invoiceStatus: 'paid', + paidAmount: 1000, + remainingAmount: 0, + paymentAmount: 1000, + journalEntryId: VALID_JE_UUID, + }, + }) + + const request = createMockRequest(`/api/supplier-invoices/${VALID_UUID}/link-to-voucher`, { + method: 'POST', + body: { journal_entry_id: VALID_JE_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: VALID_UUID })) + const { status, body } = await parseJsonResponse<{ + data: { + invoice_status: string + paid_amount: number + remaining_amount: number + payment_amount: number + payment_id: string + journal_entry_id: string + } + }>(response) + expect(status).toBe(200) + expect(body.data.invoice_status).toBe('paid') + expect(body.data.paid_amount).toBe(1000) + expect(body.data.remaining_amount).toBe(0) + expect(body.data.payment_id).toBe('sip-1') + expect(body.data.journal_entry_id).toBe(VALID_JE_UUID) + }) + + it('maps a structured failure code to the correct HTTP status', async () => { + mockLink.mockResolvedValue({ + ok: false, + code: 'LINK_SI_VOUCHER_NO_AP_DEBIT', + details: { source_type: 'opening_balance' }, + }) + + const request = createMockRequest(`/api/supplier-invoices/${VALID_UUID}/link-to-voucher`, { + method: 'POST', + body: { journal_entry_id: VALID_JE_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: VALID_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('LINK_SI_VOUCHER_NO_AP_DEBIT') + }) +}) diff --git a/app/api/supplier-invoices/[id]/link-to-voucher/route.ts b/app/api/supplier-invoices/[id]/link-to-voucher/route.ts new file mode 100644 index 00000000..5133e4d5 --- /dev/null +++ b/app/api/supplier-invoices/[id]/link-to-voucher/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { LinkSupplierInvoiceToVoucherSchema } from '@/lib/api/schemas' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching' +import { ensureInitialized } from '@/lib/init' + +ensureInitialized() + +/** + * POST /api/supplier-invoices/[id]/link-to-voucher + * + * Marks a supplier invoice as paid (or partially paid) by linking an existing + * posted verifikat whose lines already debit AP (2440). Creates no new journal + * entry — only a supplier_invoice_payments row + invoice status advance. + * + * Rejects with LINK_SI_VOUCHER_NO_AP_DEBIT for vouchers that book the expense + * directly without going through 2440 — those require gnubok_correct_entry first. + */ +export const POST = withRouteContext( + 'supplier_invoice.link_to_voucher', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ supplierInvoiceId: id }) + + const validation = await validateBody(request, LinkSupplierInvoiceToVoucherSchema, { + log: opLog, + operation: 'supplier_invoice.link_to_voucher', + }) + if (!validation.success) return validation.response + const { journal_entry_id, notes } = validation.data + + const outcome = await linkSupplierInvoiceToVoucher(supabase, user.id, companyId, { + supplierInvoiceId: id, + journalEntryId: journal_entry_id, + notes, + }) + + if (!outcome.ok) { + return errorResponseFromCode(outcome.code, opLog, { + requestId, + details: outcome.details, + }) + } + + return NextResponse.json({ + data: { + invoice_status: outcome.result.invoiceStatus, + paid_amount: outcome.result.paidAmount, + remaining_amount: outcome.result.remainingAmount, + payment_amount: outcome.result.paymentAmount, + payment_id: outcome.result.paymentId, + journal_entry_id: outcome.result.journalEntryId, + }, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/supplier-invoices/[id]/voucher-candidates/route.ts b/app/api/supplier-invoices/[id]/voucher-candidates/route.ts new file mode 100644 index 00000000..f04cbd26 --- /dev/null +++ b/app/api/supplier-invoices/[id]/voucher-candidates/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { findMatchingVouchersForSupplierInvoice } from '@/lib/invoices/supplier-voucher-matching' +import type { Supplier, SupplierInvoice } from '@/types' + +/** + * GET /api/supplier-invoices/[id]/voucher-candidates + * + * Returns posted verifikat candidates that could be linked as payment for + * this supplier invoice. Used by the "Befintlig verifikation" tab in the + * supplier-invoice mark-paid dialog to auto-suggest matches. + */ +export const GET = withRouteContext( + 'supplier_invoice.voucher_candidates', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + + // Project only the fields the matcher actually reads. Avoids leaking the + // full supplier row (bank details, contact info, etc.) into the response. + const { data: invoice, error } = await supabase + .from('supplier_invoices') + .select( + 'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, supplier:suppliers(id, name)', + ) + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (error || !invoice) { + return errorResponseFromCode('LINK_SI_VOUCHER_INVOICE_NOT_FOUND', log, { requestId }) + } + + if (!['registered', 'approved', 'overdue', 'partially_paid'].includes(invoice.status)) { + return NextResponse.json({ data: { candidates: [], invoice_status: invoice.status } }) + } + + const candidates = await findMatchingVouchersForSupplierInvoice( + supabase, + companyId, + // The narrow projection above means TS infers `supplier` as `{ id, name }[]` + // from the join shorthand. The matcher only reads `supplier?.name`, so + // cast through unknown to the runtime shape it expects. + invoice as unknown as SupplierInvoice & { supplier?: Supplier }, + ) + + return NextResponse.json({ data: { candidates } }) + }, +) diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index 6dfa154c..3d6d61b6 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -393,6 +393,40 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.remaining_amount).toBe(7500) }) + it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when tx amount exceeds invoice remaining', async () => { + // Tx is +12 000 SEK, invoice has 5 000 SEK remaining. Legacy code path + // would push paid_amount past invoice.total; the new guard rejects so + // the user routes the excess through the split-payment flow. + const tx = makeTransaction({ id: 'tx-1', amount: 12000, invoice_id: null, date: '2024-06-15' }) + const invoice = makeInvoice({ + id: VALID_UUID, + status: 'partially_paid', + total: 10000, + remaining_amount: 5000, + paid_amount: 5000, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + // Hard-duplicate check is skipped for partially_paid status — no enqueue needed. + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: unknown }>(response) + + expect(status).toBe(400) + expect((body.error as unknown as { code: string }).code).toBe( + 'MATCH_AMOUNT_EXCEEDS_REMAINING', + ) + const details = (body.error as unknown as { details: Record }).details + expect(details.transaction_amount).toBe(12000) + expect(details.remaining_amount).toBe(5000) + expect(details.excess).toBe(7000) + }) + it('cash method partial payment uses clearing entry with note', async () => { const tx = makeTransaction({ id: 'tx-1', amount: 5000, invoice_id: null, date: '2024-06-15' }) const invoice = makeInvoice({ diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index ed7c32b1..8a531f63 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -219,8 +219,25 @@ export const POST = withRouteContext( const now = new Date().toISOString() const paidAmount = transaction.amount - const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100 const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0)) + + // Overshoot guard: the single-tx match endpoint always books tx.amount in + // full against the invoice. If tx > remaining the legacy code path would + // push invoice.paid_amount past invoice.total — silently. Reject and + // point the user at the split-payment flow which can allocate the excess + // across additional invoices. + if (paidAmount > currentRemaining + 0.005) { + return errorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', txLog, { + requestId, + details: { + transaction_amount: paidAmount, + remaining_amount: Math.round(currentRemaining * 100) / 100, + excess: Math.round((paidAmount - currentRemaining) * 100) / 100, + }, + }) + } + + const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100 const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100) const isFullyPaid = newRemaining <= 0 const newStatus = isFullyPaid ? 'paid' : 'partially_paid' 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 d5c8bc95..ab6101af 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 @@ -195,4 +195,56 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths', expect(body.paid_amount).toBe(1000) expect(body.remaining_amount).toBe(0) }) + + it('returns 400 MATCH_SI_AMOUNT_EXCEEDS_REMAINING when tx exceeds invoice remaining (same currency)', async () => { + // Tx pays out 6 000 SEK, invoice has 5 000 SEK remaining. Legacy code path + // would push paid_amount past invoice.total. The new guard rejects so the + // user routes the excess through the split-payment flow. + enqueue({ + data: { + id: TX_UUID, + company_id: 'company-1', + amount: -6000, + currency: 'SEK', + amount_sek: null, + supplier_invoice_id: null, + date: '2026-05-12', + }, + error: null, + }) + enqueue({ + data: { + id: SI_UUID, + currency: 'SEK', + exchange_rate: null, + status: 'registered', + remaining_amount: 5000, + paid_amount: 0, + supplier: { supplier_type: 'swedish_business' }, + items: [], + }, + error: null, + }) + + const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: unknown }>(res) + expect(status).toBe(400) + expect((body.error as { code: string }).code).toBe('MATCH_SI_AMOUNT_EXCEEDS_REMAINING') + const details = (body.error as { details: Record }).details + expect(details.transaction_amount).toBe(6000) + expect(details.remaining_amount).toBe(5000) + expect(details.excess).toBe(1000) + }) + + it('does NOT trigger overshoot guard on currency mismatch (FX path clamps to remaining)', async () => { + // SEK transaction paying a EUR invoice. The currency-mismatch branch + // collapses paymentAmountInvoiceCurrency to invoice.remaining_amount and + // cannot overshoot, so the guard must not fire here. + enqueueHappyPath({ + transaction: { amount: -10000, currency: 'SEK' }, + invoice: { currency: 'EUR', remaining_amount: 200, exchange_rate: 11.5 }, + }) + const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID })) + expect(res.status).toBe(200) + }) }) diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 366612dc..e0cf9b05 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -81,6 +81,27 @@ export const POST = withRouteContext( const txAmountAbs = Math.abs(transaction.amount) + // Overshoot guard for the same-currency branch. The legacy code path used + // txAmountAbs wholesale and would push supplier_invoices.paid_amount past + // invoice.total whenever the bank transaction was larger than what was + // owed. Reject and direct the user at the split-payment flow which can + // allocate the excess to additional supplier invoices. + // FX branch (currency mismatch) is already clamped below to + // invoice.remaining_amount, so it cannot overshoot. + if ( + transaction.currency === invoice.currency && + txAmountAbs > invoice.remaining_amount + 0.005 + ) { + return errorResponseFromCode('MATCH_SI_AMOUNT_EXCEEDS_REMAINING', txLog, { + requestId, + details: { + transaction_amount: txAmountAbs, + remaining_amount: Math.round(invoice.remaining_amount * 100) / 100, + excess: Math.round((txAmountAbs - invoice.remaining_amount) * 100) / 100, + }, + }) + } + // Amount in the *invoice's* currency — used to update // supplier_invoices.paid_amount/remaining_amount and the // supplier_invoice_payments row (whose `currency` is the invoice's). diff --git a/components/invoices/LinkVoucherPicker.tsx b/components/invoices/LinkVoucherPicker.tsx index cbe8f28a..73e04945 100644 --- a/components/invoices/LinkVoucherPicker.tsx +++ b/components/invoices/LinkVoucherPicker.tsx @@ -17,19 +17,36 @@ interface VoucherCandidate { voucher_number: number | null entry_date: string description: string - ar_credit_amount: number + // Customer side returns ar_credit_amount; supplier side returns ap_debit_amount. + // The picker treats them interchangeably — same UX, opposite sign convention. + ar_credit_amount?: number + ap_debit_amount?: number currency: string - ar_line_currency: string | null + ar_line_currency?: string | null + ap_line_currency?: string | null period_locked: boolean confidence: number match_reason: string } +/** + * Linking mode determines which API surface the picker hits and which side + * of the BAS chart the candidates are searched against (151x credits vs 2440 + * debits). The user-facing UX is identical; only the data path differs. + */ +export type VoucherPickerMode = 'customer_invoice' | 'supplier_invoice' + interface LinkVoucherPickerProps { invoiceId: string invoiceCurrency: string onLinked: () => void onCancel: () => void + /** Defaults to 'customer_invoice' for back-compat with existing call sites. */ + mode?: VoucherPickerMode +} + +function candidateAmount(c: VoucherCandidate): number { + return c.ar_credit_amount ?? c.ap_debit_amount ?? 0 } function voucherLabel(c: VoucherCandidate): string { @@ -54,10 +71,18 @@ export default function LinkVoucherPicker({ invoiceCurrency, onLinked, onCancel, + mode = 'customer_invoice', }: LinkVoucherPickerProps) { const { toast } = useToast() const t = useTranslations('invoice_link_voucher') + const apiBase = + mode === 'supplier_invoice' + ? `/api/supplier-invoices/${invoiceId}` + : `/api/invoices/${invoiceId}` + const errorContext: 'invoice' | 'supplier_invoice' = + mode === 'supplier_invoice' ? 'supplier_invoice' : 'invoice' + const [candidates, setCandidates] = useState(null) const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) @@ -69,7 +94,7 @@ export default function LinkVoucherPicker({ async function load() { setLoading(true) try { - const response = await fetch(`/api/invoices/${invoiceId}/voucher-candidates`) + const response = await fetch(`${apiBase}/voucher-candidates`) if (!response.ok) { if (cancelled) return setCandidates([]) @@ -88,7 +113,7 @@ export default function LinkVoucherPicker({ return () => { cancelled = true } - }, [invoiceId]) + }, [apiBase]) const filtered = useMemo(() => { if (!candidates) return [] as VoucherCandidate[] @@ -110,7 +135,7 @@ export default function LinkVoucherPicker({ if (!selected) return setSubmitting(true) try { - const response = await fetch(`/api/invoices/${invoiceId}/link-to-voucher`, { + const response = await fetch(`${apiBase}/link-to-voucher`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: selected.journal_entry_id }), @@ -120,7 +145,7 @@ export default function LinkVoucherPicker({ toast({ title: t('link_failed_title'), description: getErrorMessage(body, { - context: 'invoice', + context: errorContext, statusCode: response.status, }), variant: 'destructive', @@ -132,7 +157,7 @@ export default function LinkVoucherPicker({ } catch (err) { toast({ title: t('link_failed_title'), - description: getErrorMessage(err, { context: 'invoice' }), + description: getErrorMessage(err, { context: errorContext }), variant: 'destructive', }) } finally { @@ -199,7 +224,7 @@ export default function LinkVoucherPicker({

- {formatCurrency(c.ar_credit_amount, invoiceCurrency)} + {formatCurrency(candidateAmount(c), invoiceCurrency)}

@@ -215,7 +240,7 @@ export default function LinkVoucherPicker({

{t('confirmation', { voucher: voucherLabel(selected), - amount: formatCurrency(selected.ar_credit_amount, invoiceCurrency), + amount: formatCurrency(candidateAmount(selected), invoiceCurrency), })}

{t('no_new_je_note')}

diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 9c98d919..b2e633cf 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -508,6 +508,16 @@ export const LinkInvoiceToVoucherSchema = z.object({ notes: z.string().max(2000).optional(), }) +/** + * Supplier-invoice mirror: link an existing posted verifikat as payment for a + * supplier invoice. No new JE — only a supplier_invoice_payments row pointing + * at the supplied journal_entry_id, plus the invoice's paid/remaining advance. + */ +export const LinkSupplierInvoiceToVoucherSchema = z.object({ + journal_entry_id: uuid, + notes: z.string().max(2000).optional(), +}) + export const LinkTransactionJournalEntrySchema = z.object({ journal_entry_id: uuid, // Optional invoice to settle alongside the link. When provided, the diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index f64190c8..27a4bd57 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -394,6 +394,13 @@ const MATCH_INVOICE: Record = { message_en: 'The candidate journal entry echoed in expected_journal_entry_id does not match the one detected at request time. Re-run the duplicate-payment pre-flight to obtain the current candidate, then retry.', }, + MATCH_AMOUNT_EXCEEDS_REMAINING: { + httpStatus: 400, + message_sv: + 'Transaktionsbeloppet är större än fakturans återstående belopp. Dela betalningen och fördela överskottet på en eller flera andra fakturor.', + message_en: + 'Transaction amount exceeds the invoice remaining amount. Use the split-payment flow to allocate the excess across one or more other invoices.', + }, } const LINK_TX_JE: Record = { @@ -479,6 +486,13 @@ const MATCH_SI: Record = { message_en: 'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.', }, + MATCH_SI_AMOUNT_EXCEEDS_REMAINING: { + httpStatus: 400, + message_sv: + 'Transaktionsbeloppet är större än leverantörsfakturans återstående belopp. Dela betalningen och fördela överskottet på en eller flera andra leverantörsfakturor.', + message_en: + 'Transaction amount exceeds the supplier invoice remaining amount. Use the split-payment flow to allocate the excess across one or more other supplier invoices.', + }, TX_UNCATEGORIZE_NOT_BOOKED: { httpStatus: 400, message_sv: 'Transaktionen är inte bokförd. Det finns inget att av-kategorisera.', @@ -1725,6 +1739,69 @@ const LINK_INVOICE_VOUCHER: Record = { }, } +// ───────────────────────────────────────────────────────────────── +// Link SUPPLIER invoice to an existing posted verifikat (no new JE) +// ───────────────────────────────────────────────────────────────── + +const LINK_SI_VOUCHER: Record = { + LINK_SI_VOUCHER_INVOICE_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Leverantörsfakturan kunde inte hittas.', + message_en: 'Supplier invoice not found.', + }, + LINK_SI_VOUCHER_VOUCHER_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Verifikationen kunde inte hittas.', + message_en: 'Journal entry not found.', + }, + LINK_SI_VOUCHER_NOT_POSTED: { + httpStatus: 409, + message_sv: + 'Verifikationen är inte bokförd. Endast bokförda verifikationer kan länkas som betalning.', + message_en: 'Journal entry is not posted. Only posted entries can be linked as a payment.', + }, + LINK_SI_VOUCHER_NO_AP_DEBIT: { + httpStatus: 400, + message_sv: + 'Verifikationen debiterar inget leverantörsskuldskonto (244x). Rätta bokföringen först med en stornoverifikation som debiterar t.ex. 2440 (SEK) eller 2441 (utländsk valuta), via gnubok_correct_entry.', + message_en: + 'The journal entry does not debit any accounts-payable account in the 244x range (e.g. 2440 SEK, 2441 foreign currency). Correct the booking first via a storno+correction (gnubok_correct_entry).', + remediation: { + description: + 'Use gnubok_correct_entry to storno the existing voucher and re-book the payment as Dr 244x / Cr 1930, then link the corrected voucher.', + tool: 'gnubok_correct_entry', + }, + }, + LINK_SI_VOUCHER_ALREADY_LINKED: { + httpStatus: 409, + message_sv: 'Verifikationen är redan länkad till den här leverantörsfakturan.', + message_en: 'This journal entry is already linked to this supplier invoice.', + }, + LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING: { + httpStatus: 400, + message_sv: + 'Verifikationens leverantörsskuldsdebitering är större än leverantörsfakturans återstående belopp. Verifikationen täcker fler fakturor — välj en annan verifikation eller rätta beloppet först.', + message_en: + 'The voucher\'s AP debit exceeds the supplier invoice\'s remaining balance. Split the voucher across multiple supplier invoices via gnubok_correct_entry first, or pick a different voucher.', + }, + LINK_SI_VOUCHER_CURRENCY_MISMATCH: { + httpStatus: 400, + message_sv: + 'Verifikationens valuta matchar inte leverantörsfakturans. Endast verifikationer i fakturans valuta kan länkas.', + message_en: 'The voucher\'s currency does not match the supplier invoice currency.', + }, + LINK_SI_VOUCHER_INVOICE_FULLY_PAID: { + httpStatus: 409, + message_sv: 'Leverantörsfakturan har redan slutbetalats. Inget mer behöver länkas.', + message_en: 'Supplier invoice is already fully paid.', + }, + LINK_SI_VOUCHER_DB_ERROR: { + httpStatus: 500, + message_sv: 'Databasfel under länkning. Försök igen.', + message_en: 'Database error while linking the voucher. Please retry.', + }, +} + // ───────────────────────────────────────────────────────────────── // Combined registry // ───────────────────────────────────────────────────────────────── @@ -1736,6 +1813,7 @@ const REGISTRY: Record = { ...MATCH_INVOICE, ...LINK_TX_JE, ...LINK_INVOICE_VOUCHER, + ...LINK_SI_VOUCHER, ...MATCH_SI, ...INVOICE, ...SUPPLIER_INVOICE, diff --git a/lib/invoices/__tests__/supplier-voucher-matching.test.ts b/lib/invoices/__tests__/supplier-voucher-matching.test.ts new file mode 100644 index 00000000..c20cf67d --- /dev/null +++ b/lib/invoices/__tests__/supplier-voucher-matching.test.ts @@ -0,0 +1,444 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + validateVoucherForSupplierInvoiceLink, + linkSupplierInvoiceToVoucher, +} from '../supplier-voucher-matching' +import { + makeSupplierInvoice, + createQueuedMockSupabase, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events/bus' + +// ============================================================ +// validateVoucherForSupplierInvoiceLink — happy path + rejects +// ============================================================ + +describe('validateVoucherForSupplierInvoiceLink', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function setup( + invoice = makeSupplierInvoice({ remaining_amount: 1000, total: 1000, currency: 'SEK' }), + ) { + return invoice + } + + it('rejects when the invoice has nothing remaining', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const invoice = setup( + makeSupplierInvoice({ + remaining_amount: 0, + paid_amount: 1000, + total: 1000, + currency: 'SEK', + }), + ) + enqueue({ data: null }) // unused — short-circuits before any query + const result = await validateVoucherForSupplierInvoiceLink( + supabase as never, + 'company-1', + invoice as never, + 'je-1', + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_INVOICE_FULLY_PAID') + }) + + it('rejects when the voucher is missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const invoice = setup() + enqueue({ data: null, error: null }) // journal_entries.maybeSingle → null + const result = await validateVoucherForSupplierInvoiceLink( + supabase as never, + 'company-1', + invoice as never, + 'je-missing', + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_VOUCHER_NOT_FOUND') + }) + + it('rejects when the voucher is not posted', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const invoice = setup() + enqueue({ + data: { + id: 'je-1', + voucher_series: 'B', + voucher_number: 12, + entry_date: '2024-06-15', + description: '', + status: 'draft', + source_type: 'manual', + fiscal_period_id: 'fp-1', + company_id: 'company-1', + }, + }) + const result = await validateVoucherForSupplierInvoiceLink( + supabase as never, + 'company-1', + invoice as never, + 'je-1', + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_NOT_POSTED') + }) + + it('rejects when the voucher has no AP debit on 2440', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const invoice = setup() + // journal_entries lookup + enqueue({ + data: { + id: 'je-1', + voucher_series: 'B', + voucher_number: 12, + entry_date: '2024-06-15', + description: '', + status: 'posted', + source_type: 'manual', + fiscal_period_id: 'fp-1', + company_id: 'company-1', + }, + }) + // journal_entry_lines — no 2440 line + enqueue({ + data: [ + { account_number: '1930', debit_amount: 0, credit_amount: 1000, currency: 'SEK' }, + { account_number: '4010', debit_amount: 1000, credit_amount: 0, currency: 'SEK' }, + ], + }) + const result = await validateVoucherForSupplierInvoiceLink( + supabase as never, + 'company-1', + invoice as never, + 'je-1', + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_NO_AP_DEBIT') + }) + + it('rejects when the AP debit exceeds invoice remaining', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const invoice = setup( + makeSupplierInvoice({ + remaining_amount: 1000, + paid_amount: 0, + total: 1000, + currency: 'SEK', + }), + ) + enqueue({ + data: { + id: 'je-1', + voucher_series: 'B', + voucher_number: 12, + entry_date: '2024-06-15', + description: '', + status: 'posted', + source_type: 'manual', + fiscal_period_id: 'fp-1', + company_id: 'company-1', + }, + }) + // 5 000 debit on 2440 — overshoots a 1 000 invoice + enqueue({ + data: [ + { account_number: '2440', debit_amount: 5000, credit_amount: 0, currency: 'SEK' }, + { account_number: '1930', debit_amount: 0, credit_amount: 5000, currency: 'SEK' }, + ], + }) + const result = await validateVoucherForSupplierInvoiceLink( + supabase as never, + 'company-1', + invoice as never, + 'je-1', + ) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe('LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING') + expect(result.details?.ap_debit).toBe(5000) + expect(result.details?.remaining).toBe(1000) + } + }) + + it('accepts an exact-amount match and reports paymentAmount + isFullyPaid', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const invoice = setup( + makeSupplierInvoice({ + remaining_amount: 1000, + paid_amount: 0, + total: 1000, + currency: 'SEK', + }), + ) + enqueue({ + data: { + id: 'je-1', + voucher_series: 'B', + voucher_number: 12, + entry_date: '2024-06-15', + description: '', + status: 'posted', + source_type: 'manual', + fiscal_period_id: 'fp-1', + company_id: 'company-1', + }, + }) + enqueue({ + data: [ + { account_number: '2440', debit_amount: 1000, credit_amount: 0, currency: 'SEK' }, + { account_number: '1930', debit_amount: 0, credit_amount: 1000, currency: 'SEK' }, + ], + }) + // existingLinks lookup — none + enqueue({ data: [], error: null }) + const result = await validateVoucherForSupplierInvoiceLink( + supabase as never, + 'company-1', + invoice as never, + 'je-1', + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.apDebitAmount).toBe(1000) + expect(result.paymentAmount).toBe(1000) + expect(result.isFullyPaid).toBe(true) + expect(result.remainingAfter).toBe(0) + } + }) + + it('accepts a partial-payment voucher (debit < remaining) and reports partially_paid math', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const invoice = setup( + makeSupplierInvoice({ + remaining_amount: 1000, + paid_amount: 0, + total: 1000, + currency: 'SEK', + }), + ) + enqueue({ + data: { + id: 'je-1', + voucher_series: 'B', + voucher_number: 12, + entry_date: '2024-06-15', + description: '', + status: 'posted', + source_type: 'manual', + fiscal_period_id: 'fp-1', + company_id: 'company-1', + }, + }) + enqueue({ + data: [ + { account_number: '2440', debit_amount: 400, credit_amount: 0, currency: 'SEK' }, + { account_number: '1930', debit_amount: 0, credit_amount: 400, currency: 'SEK' }, + ], + }) + enqueue({ data: [], error: null }) + const result = await validateVoucherForSupplierInvoiceLink( + supabase as never, + 'company-1', + invoice as never, + 'je-1', + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.paymentAmount).toBe(400) + expect(result.isFullyPaid).toBe(false) + expect(result.remainingAfter).toBe(600) + } + }) + + it('rejects currency mismatch', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const invoice = setup( + makeSupplierInvoice({ + remaining_amount: 200, + paid_amount: 0, + total: 200, + currency: 'EUR', + }), + ) + enqueue({ + data: { + id: 'je-1', + voucher_series: 'B', + voucher_number: 12, + entry_date: '2024-06-15', + description: '', + status: 'posted', + source_type: 'manual', + fiscal_period_id: 'fp-1', + company_id: 'company-1', + }, + }) + enqueue({ + data: [ + { account_number: '2440', debit_amount: 200, credit_amount: 0, currency: 'SEK' }, + { account_number: '1930', debit_amount: 0, credit_amount: 200, currency: 'SEK' }, + ], + }) + const result = await validateVoucherForSupplierInvoiceLink( + supabase as never, + 'company-1', + invoice as never, + 'je-1', + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_CURRENCY_MISMATCH') + }) +}) + +// ============================================================ +// linkSupplierInvoiceToVoucher — end-to-end advancement +// ============================================================ + +describe('linkSupplierInvoiceToVoucher', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The implementation now delegates the lock + validate + UPDATE + INSERT + // sequence to the link_supplier_invoice_to_voucher PL/pgSQL RPC (PR #602 + // review fix). The TS wrapper only translates the RPC's structured jsonb + // return into the lib's typed Result type and emits the paid event. These + // tests mock the RPC response directly. + + it('rejects with INVOICE_NOT_FOUND when the RPC reports the invoice is missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { ok: false, code: 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND' }, + error: null, + }) + const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', { + supplierInvoiceId: 'si-missing', + journalEntryId: 'je-1', + }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_INVOICE_NOT_FOUND') + }) + + it('rejects with INVOICE_FULLY_PAID when the RPC reports the invoice is already paid', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + ok: false, + code: 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID', + details: { status: 'paid' }, + }, + error: null, + }) + const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', { + supplierInvoiceId: 'si-1', + journalEntryId: 'je-1', + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe('LINK_SI_VOUCHER_INVOICE_FULLY_PAID') + expect(result.details?.status).toBe('paid') + } + }) + + it('returns LINK_SI_VOUCHER_DB_ERROR when the RPC raises an error', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'connection lost' } }) + const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', { + supplierInvoiceId: 'si-1', + journalEntryId: 'je-1', + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe('LINK_SI_VOUCHER_DB_ERROR') + expect(result.details?.reason).toBe('connection lost') + } + }) + + it('returns success + emits supplier_invoice.paid on the happy path (full payment)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const invoice = makeSupplierInvoice({ + status: 'paid', + paid_amount: 1000, + remaining_amount: 0, + total: 1000, + currency: 'SEK', + }) + + // 1. RPC returns the happy path + enqueue({ + data: { + ok: true, + payment_id: 'sip-1', + invoice_status: 'paid', + paid_amount: 1000, + remaining_amount: 0, + payment_amount: 1000, + journal_entry_id: 'je-1', + currency: 'SEK', + }, + error: null, + }) + // 2. Lightweight invoice re-fetch for the event payload + enqueue({ data: invoice, error: null }) + + const emitSpy = vi.spyOn(eventBus, 'emit').mockResolvedValue(undefined) + + const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', { + supplierInvoiceId: invoice.id, + journalEntryId: 'je-1', + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.result.invoiceStatus).toBe('paid') + expect(result.result.paidAmount).toBe(1000) + expect(result.result.remainingAmount).toBe(0) + expect(result.result.paymentAmount).toBe(1000) + expect(result.result.journalEntryId).toBe('je-1') + expect(result.result.paymentId).toBe('sip-1') + } + + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'supplier_invoice.paid', + payload: expect.objectContaining({ paymentAmount: 1000, userId: 'user-1' }), + }), + ) + }) + + it('still returns success even if the post-link invoice re-fetch is empty (event is best-effort)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + ok: true, + payment_id: 'sip-2', + invoice_status: 'partially_paid', + paid_amount: 400, + remaining_amount: 600, + payment_amount: 400, + journal_entry_id: 'je-1', + currency: 'SEK', + }, + error: null, + }) + enqueue({ data: null, error: null }) + + const emitSpy = vi.spyOn(eventBus, 'emit').mockResolvedValue(undefined) + + const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', { + supplierInvoiceId: 'si-2', + journalEntryId: 'je-1', + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.result.invoiceStatus).toBe('partially_paid') + expect(result.result.remainingAmount).toBe(600) + } + // Event NOT emitted when re-fetch found nothing + expect(emitSpy).not.toHaveBeenCalled() + }) +}) diff --git a/lib/invoices/supplier-voucher-matching.ts b/lib/invoices/supplier-voucher-matching.ts new file mode 100644 index 00000000..eb350161 --- /dev/null +++ b/lib/invoices/supplier-voucher-matching.ts @@ -0,0 +1,649 @@ +/** + * Link an existing posted verifikat to a supplier invoice as its payment row. + * + * Mirror of voucher-matching.ts but targets 2440 (Leverantörsskulder) debits + * instead of 151x credits. Used when the GL already contains a verifikat that + * pays down AP — e.g. an SIE-imported payment voucher, a manually entered + * bank-transfer voucher, or any flow where the bookkeeping landed without + * supplier-invoice linkage. No new journal entry is created. Only a + * supplier_invoice_payments row is inserted pointing at the existing + * journal_entry_id, plus the invoice's paid_amount / remaining_amount / + * status are advanced. + * + * Vouchers that book the supplier expense directly without going through 2440 + * (e.g. Dr 4010 / Cr 1930 for a non-invoiced purchase) are rejected with + * LINK_SI_VOUCHER_NO_AP_DEBIT. The proper fix for those is a storno+correction + * via gnubok_correct_entry — out of scope for V1. + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import { eventBus } from '@/lib/events/bus' +import { createLogger } from '@/lib/logger' +import { + CONFIDENCE, + amountsMatchExact, + amountsMatchFuzzy, + customerNameMatches, +} from './invoice-matching' +import type { SupplierInvoice, Supplier } from '@/types' + +const log = createLogger('supplier-voucher-matching') + +/** AP account class. BAS 2026 reserves 2440–2449 for Leverantörsskulder + * (2440 SEK, 2441 utländsk valuta, 2443 Skuldfakturor, 2448 övriga). The + * supplier sub-ledger lives in the supplier_invoices table, not in per- + * supplier accounts. A samlingsverifikat that pays mixed SEK + EUR + * suppliers will legitimately debit both 2440 and 2441 — summing across + * the 244x range catches that. PR #602 Swedish-compliance fix. */ +const AP_ACCOUNT_PREFIX = '244' + +/** ±90 days from the invoice's due_date as the default search window. */ +const DEFAULT_DATE_WINDOW_DAYS = 90 + +/** Tolerance for floating-point comparisons on monetary amounts (0.5 öre). */ +const AMOUNT_TOLERANCE = 0.005 + +/** Date-proximity bump applied when entry_date is within ±7 days of due_date. */ +const DATE_PROXIMITY_BUMP = 0.05 + +export interface SupplierVoucherCandidate { + journal_entry_id: string + voucher_series: string | null + voucher_number: number | null + entry_date: string + description: string + /** Total debit on the AP account (2440) on this voucher, always positive. */ + ap_debit_amount: number + currency: string + /** Currency of the AP-debit line; nullable when the line stores SEK only. */ + ap_line_currency: string | null + /** True when the voucher's fiscal period is closed or locked. */ + period_locked: boolean + /** Confidence score 0..1 (or 0.99 for OCR match). */ + confidence: number + /** Localized reason in Swedish. */ + match_reason: string +} + +interface JournalEntryLine { + id: string + journal_entry_id: string + account_number: string + debit_amount: number | null + credit_amount: number | null + currency: string | null +} + +interface VoucherRow { + id: string + voucher_series: string | null + voucher_number: number | null + entry_date: string + description: string + status: string + source_type: string | null + fiscal_period_id: string +} + +interface FiscalPeriodRow { + id: string + status: string +} + +interface CandidateContext { + invoice: SupplierInvoice & { supplier?: Supplier } + remainingAmount: number +} + +const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno'] + +/** + * Find posted journal entries whose lines debit 2440 and could plausibly be + * the payment for this supplier invoice. Ranking mirrors the customer side: + * exact amount + supplier match wins, then exact, then fuzzy (±1% capped at + * 500 SEK), with a small bump for date proximity to due_date. + */ +export async function findMatchingVouchersForSupplierInvoice( + supabase: SupabaseClient, + companyId: string, + invoice: SupplierInvoice & { supplier?: Supplier }, + options: { limit?: number; dateWindowDays?: number } = {}, +): Promise { + const limit = options.limit ?? 10 + const windowDays = options.dateWindowDays ?? DEFAULT_DATE_WINDOW_DAYS + + const remainingAmount = computeRemaining(invoice) + if (remainingAmount <= AMOUNT_TOLERANCE) return [] + + const dueDate = new Date(invoice.due_date) + const dateFrom = new Date(dueDate) + dateFrom.setDate(dateFrom.getDate() - windowDays) + const dateTo = new Date(dueDate) + dateTo.setDate(dateTo.getDate() + windowDays) + + const { data: lines, error } = await supabase + .from('journal_entry_lines') + .select( + ` + id, + journal_entry_id, + account_number, + debit_amount, + credit_amount, + currency, + journal_entries!inner ( + id, + voucher_series, + voucher_number, + entry_date, + description, + status, + source_type, + fiscal_period_id, + company_id + ) + `, + ) + .eq('journal_entries.company_id', companyId) + .eq('journal_entries.status', 'posted') + .like('account_number', `${AP_ACCOUNT_PREFIX}%`) + .gt('debit_amount', 0) + .gte('journal_entries.entry_date', dateFrom.toISOString().slice(0, 10)) + .lte('journal_entries.entry_date', dateTo.toISOString().slice(0, 10)) + .limit(limit * 10) + if (error || !lines) return [] + + // Sum the AP debit per voucher across multiple 2440 lines (a samlings- + // verifikation paying several supplier invoices in one shot will have one + // 2440 row per supplier). + const byEntry = new Map< + string, + { entry: VoucherRow; apDebitTotal: number; lineCurrency: string | null } + >() + + for (const raw of lines) { + const line = raw as unknown as JournalEntryLine & { + journal_entries: VoucherRow + } + const entry = line.journal_entries + if (!entry) continue + if (EXCLUDED_SOURCE_TYPES.includes(entry.source_type ?? '')) continue + + const debit = Number(line.debit_amount ?? 0) + if (debit <= 0) continue + + const existing = byEntry.get(entry.id) + if (existing) { + existing.apDebitTotal += debit + } else { + byEntry.set(entry.id, { + entry, + apDebitTotal: debit, + lineCurrency: line.currency, + }) + } + } + + if (byEntry.size === 0) return [] + + // Drop entries already fully linked to *this* supplier invoice. + const candidateEntryIds = Array.from(byEntry.keys()) + const { data: existingLinks } = await supabase + .from('supplier_invoice_payments') + .select('journal_entry_id') + .eq('company_id', companyId) + .eq('supplier_invoice_id', invoice.id) + .in('journal_entry_id', candidateEntryIds) + + const alreadyLinked = new Set( + (existingLinks ?? []) + .map((row) => (row as { journal_entry_id: string | null }).journal_entry_id) + .filter((id): id is string => !!id), + ) + for (const id of alreadyLinked) byEntry.delete(id) + if (byEntry.size === 0) return [] + + // Period-lock flags (informational — linking is allowed in locked periods + // because no JE is mutated). + const periodIds = Array.from( + new Set(Array.from(byEntry.values()).map((v) => v.entry.fiscal_period_id)), + ) + const { data: periods } = await supabase + .from('fiscal_periods') + .select('id, status') + .in('id', periodIds) + const lockedPeriods = new Set( + (periods ?? []) + .filter( + (p) => + (p as FiscalPeriodRow).status === 'closed' || + (p as FiscalPeriodRow).status === 'locked', + ) + .map((p) => (p as FiscalPeriodRow).id), + ) + + const ctx: CandidateContext = { invoice, remainingAmount } + const candidates: SupplierVoucherCandidate[] = [] + for (const { entry, apDebitTotal, lineCurrency } of byEntry.values()) { + const scored = scoreCandidate(entry, apDebitTotal, lineCurrency, ctx) + if (!scored) continue + candidates.push({ + journal_entry_id: entry.id, + voucher_series: entry.voucher_series, + voucher_number: entry.voucher_number, + entry_date: entry.entry_date, + description: entry.description, + ap_debit_amount: round2(apDebitTotal), + currency: invoice.currency, + ap_line_currency: lineCurrency, + period_locked: lockedPeriods.has(entry.fiscal_period_id), + confidence: scored.confidence, + match_reason: scored.match_reason, + }) + } + + candidates.sort( + (a, b) => b.confidence - a.confidence || a.entry_date.localeCompare(b.entry_date), + ) + return candidates.slice(0, limit) +} + +function scoreCandidate( + entry: VoucherRow, + apDebitTotal: number, + lineCurrency: string | null, + ctx: CandidateContext, +): { confidence: number; match_reason: string } | null { + // OCR-style: invoice number or arrival number appears in the entry description. + const invoiceNumberHit = + ctx.invoice.supplier_invoice_number && + descriptionMentionsToken(entry.description, ctx.invoice.supplier_invoice_number) + const arrivalHit = + ctx.invoice.arrival_number != null && + descriptionMentionsToken(entry.description, String(ctx.invoice.arrival_number)) + if (invoiceNumberHit || arrivalHit) { + return { + confidence: CONFIDENCE.OCR_REFERENCE_MATCH, + match_reason: invoiceNumberHit + ? `Fakturanummer ${ctx.invoice.supplier_invoice_number} omnämnt i verifikatets beskrivning` + : `Ankomstnummer ${ctx.invoice.arrival_number} omnämnt i verifikatets beskrivning`, + } + } + + // Currency check — 2440 line currency must match invoice currency (or be + // unset, which we treat as the invoice currency). + const lineCurrencyEffective = lineCurrency ?? ctx.invoice.currency + if (lineCurrencyEffective !== ctx.invoice.currency) { + return null + } + + const exactRemaining = amountsMatchExact(apDebitTotal, ctx.remainingAmount) + const exactTotal = + !exactRemaining && amountsMatchExact(apDebitTotal, ctx.invoice.total) + const fuzzyRemaining = + !exactRemaining && + !exactTotal && + amountsMatchFuzzy(apDebitTotal, ctx.remainingAmount) + + // Supplier name in description — reuse customer-side helper since the logic + // (significant tokens of the counterparty name appearing in free text) is + // identical regardless of AR vs AP. + const supplierMatch = customerNameMatches( + ctx.invoice.supplier?.name, + entry.description, + null, + ) + + let confidence = 0 + let reason = '' + if (exactRemaining && supplierMatch) { + confidence = CONFIDENCE.EXACT_AMOUNT_CUSTOMER + reason = `Exakt belopp (${formatNumber(apDebitTotal)} ${ctx.invoice.currency}) och leverantörsnamn matchar` + } else if (exactRemaining) { + confidence = CONFIDENCE.EXACT_AMOUNT_ONLY + reason = `Exakt belopp (${formatNumber(apDebitTotal)} ${ctx.invoice.currency})` + } else if (exactTotal && supplierMatch) { + confidence = CONFIDENCE.FUZZY_AMOUNT_CUSTOMER + reason = `Fakturans totalbelopp och leverantörsnamn matchar` + } else if (exactTotal) { + confidence = CONFIDENCE.FUZZY_AMOUNT_ONLY + 0.05 + reason = `Fakturans totalbelopp matchar` + } else if (fuzzyRemaining && supplierMatch) { + confidence = CONFIDENCE.FUZZY_AMOUNT_CUSTOMER + reason = `Belopp nära (±1%) och leverantörsnamn matchar` + } else if (fuzzyRemaining) { + confidence = CONFIDENCE.FUZZY_AMOUNT_ONLY + reason = `Belopp nära (±1%)` + } else { + return null + } + + if (isDateWithinDays(entry.entry_date, ctx.invoice.due_date, 7)) { + confidence = Math.min(CONFIDENCE.OCR_REFERENCE_MATCH - 0.001, confidence + DATE_PROXIMITY_BUMP) + } + + return { confidence, match_reason: reason } +} + +export type SupplierVoucherLinkErrorCode = + | 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND' + | 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND' + | 'LINK_SI_VOUCHER_NOT_POSTED' + | 'LINK_SI_VOUCHER_NO_AP_DEBIT' + | 'LINK_SI_VOUCHER_ALREADY_LINKED' + | 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING' + | 'LINK_SI_VOUCHER_CURRENCY_MISMATCH' + | 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID' + | 'LINK_SI_VOUCHER_DB_ERROR' + +export type ValidateSupplierVoucherResult = + | { + ok: true + apDebitAmount: number + apLineCurrency: string | null + voucher: VoucherRow + remainingAfter: number + isFullyPaid: boolean + paymentAmount: number + } + | { + ok: false + code: SupplierVoucherLinkErrorCode + details?: Record + } + +/** + * Validate that a journal entry can be linked as payment for a supplier + * invoice. Used by both the staging path (MCP tool, future) and the commit + * path (web route + MCP commit handler, future) so the guards stay identical. + */ +export async function validateVoucherForSupplierInvoiceLink( + supabase: SupabaseClient, + companyId: string, + invoice: SupplierInvoice & { supplier?: Supplier }, + journalEntryId: string, +): Promise { + const remainingAmount = computeRemaining(invoice) + if (remainingAmount <= AMOUNT_TOLERANCE) { + return { ok: false, code: 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID' } + } + + const { data: voucher, error: voucherError } = await supabase + .from('journal_entries') + .select( + 'id, voucher_series, voucher_number, entry_date, description, status, source_type, fiscal_period_id, company_id', + ) + .eq('id', journalEntryId) + .eq('company_id', companyId) + .maybeSingle() + + if (voucherError || !voucher) { + return { ok: false, code: 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND' } + } + + const v = voucher as VoucherRow & { company_id: string } + if (v.status !== 'posted') { + return { ok: false, code: 'LINK_SI_VOUCHER_NOT_POSTED', details: { status: v.status } } + } + if (EXCLUDED_SOURCE_TYPES.includes(v.source_type ?? '')) { + return { + ok: false, + code: 'LINK_SI_VOUCHER_NO_AP_DEBIT', + details: { source_type: v.source_type }, + } + } + + const { data: lines, error: linesError } = await supabase + .from('journal_entry_lines') + .select('account_number, debit_amount, credit_amount, currency') + .eq('journal_entry_id', journalEntryId) + if (linesError || !lines || lines.length === 0) { + return { ok: false, code: 'LINK_SI_VOUCHER_NO_AP_DEBIT' } + } + + let apDebitTotal = 0 + let lineCurrency: string | null = null + for (const raw of lines) { + const line = raw as { + account_number: string + debit_amount: number | null + credit_amount: number | null + currency: string | null + } + if (!line.account_number?.startsWith(AP_ACCOUNT_PREFIX)) continue + const debit = Number(line.debit_amount ?? 0) + if (debit <= 0) continue + apDebitTotal += debit + if (!lineCurrency) lineCurrency = line.currency + } + apDebitTotal = round2(apDebitTotal) + + if (apDebitTotal <= 0) { + return { ok: false, code: 'LINK_SI_VOUCHER_NO_AP_DEBIT' } + } + + const lineCurrencyEffective = lineCurrency ?? invoice.currency + if (lineCurrencyEffective !== invoice.currency) { + return { + ok: false, + code: 'LINK_SI_VOUCHER_CURRENCY_MISMATCH', + details: { + invoice_currency: invoice.currency, + line_currency: lineCurrencyEffective, + }, + } + } + + if (apDebitTotal > remainingAmount + AMOUNT_TOLERANCE) { + return { + ok: false, + code: 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING', + details: { ap_debit: apDebitTotal, remaining: round2(remainingAmount) }, + } + } + + const { data: existingLinks } = await supabase + .from('supplier_invoice_payments') + .select('id') + .eq('company_id', companyId) + .eq('supplier_invoice_id', invoice.id) + .eq('journal_entry_id', journalEntryId) + .limit(1) + if (existingLinks && existingLinks.length > 0) { + return { ok: false, code: 'LINK_SI_VOUCHER_ALREADY_LINKED' } + } + + const paymentAmount = Math.min(apDebitTotal, round2(remainingAmount)) + const remainingAfter = Math.max(0, round2(remainingAmount - paymentAmount)) + const isFullyPaid = remainingAfter <= AMOUNT_TOLERANCE + + return { + ok: true, + apDebitAmount: apDebitTotal, + apLineCurrency: lineCurrency, + voucher: v, + remainingAfter, + isFullyPaid, + paymentAmount, + } +} + +export interface LinkSupplierInvoiceToVoucherParams { + supplierInvoiceId: string + journalEntryId: string + notes?: string +} + +export interface LinkSupplierInvoiceToVoucherResult { + paymentId: string + invoiceStatus: 'paid' | 'partially_paid' + paidAmount: number + remainingAmount: number + paymentAmount: number + journalEntryId: string +} + +/** + * Atomically link an existing posted verifikat as payment for a supplier + * invoice. Inserts a supplier_invoice_payments row pointing at the JE, advances + * the invoice's paid_amount / remaining_amount, and emits supplier_invoice.paid + * (reusing the existing event so reminder/automation subscribers fire without + * a new channel). + * + * Re-validates inside the same call to defend against stage→commit drift. + */ +interface RpcLinkOk { + ok: true + payment_id: string + invoice_status: 'paid' | 'partially_paid' + paid_amount: number + remaining_amount: number + payment_amount: number + journal_entry_id: string + currency: string +} + +interface RpcLinkErr { + ok: false + code: SupplierVoucherLinkErrorCode + details?: Record +} + +export async function linkSupplierInvoiceToVoucher( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: LinkSupplierInvoiceToVoucherParams, +): Promise< + | { ok: true; result: LinkSupplierInvoiceToVoucherResult } + | { ok: false; code: SupplierVoucherLinkErrorCode; details?: Record } +> { + // All validation + writes happen inside link_supplier_invoice_to_voucher + // (PL/pgSQL). The function locks the invoice row, validates the voucher, + // and applies UPDATE + INSERT in a single PG transaction so a failure on + // either rolls back automatically. The previous TS implementation did + // UPDATE-then-INSERT with a manual rollback that could overwrite a + // concurrent sibling's successful write — PR #602 review fix. + const { data, error } = await supabase.rpc('link_supplier_invoice_to_voucher', { + p_supplier_invoice_id: params.supplierInvoiceId, + p_journal_entry_id: params.journalEntryId, + p_user_id: userId, + p_company_id: companyId, + p_notes: params.notes ?? null, + }) + + if (error) { + log.error('link_supplier_invoice_to_voucher RPC error', { + companyId, + userId, + supplierInvoiceId: params.supplierInvoiceId, + journalEntryId: params.journalEntryId, + message: error.message, + }) + return { + ok: false, + code: 'LINK_SI_VOUCHER_DB_ERROR', + details: { reason: error.message }, + } + } + + const result = data as RpcLinkOk | RpcLinkErr | null + if (!result) { + return { ok: false, code: 'LINK_SI_VOUCHER_DB_ERROR', details: { reason: 'empty RPC response' } } + } + if (!result.ok) { + return { ok: false, code: result.code, details: result.details } + } + + // Fetch the now-updated invoice for event emission. Lightweight; the RPC + // committed before this read so the row reflects post-link state. + // select('*') is intentional — the supplier_invoice.paid event payload is + // typed as `supplierInvoice: SupplierInvoice` in lib/events/types.ts, so + // narrowing here would either break the subscriber contract or require a + // separate event payload type. The event stays in-process (eventBus is a + // module-level singleton) and any consumer subscribing to this event + // legitimately needs the full invoice context for downstream reminders + // and audit-log routing. PR #602 compliance review note documented. + const { data: invoice } = await supabase + .from('supplier_invoices') + .select('*') + .eq('id', params.supplierInvoiceId) + .eq('company_id', companyId) + .maybeSingle() + + if (invoice) { + try { + await eventBus.emit({ + type: 'supplier_invoice.paid', + payload: { + supplierInvoice: invoice as SupplierInvoice, + paymentAmount: result.payment_amount, + userId, + companyId, + }, + }) + } catch (err) { + // Event emission failure must not block the response, but should leave + // an audit trail (ISO 27001:2022 A.8.15 / OWASP V16). Logged at warn + // because the link itself succeeded — the downstream reminder/audit + // subscriber will need separate intervention. + log.warn('supplier_invoice.paid event emission failed', { + err, + supplierInvoiceId: params.supplierInvoiceId, + journalEntryId: params.journalEntryId, + }) + } + } + + return { + ok: true, + result: { + paymentId: result.payment_id, + invoiceStatus: result.invoice_status, + paidAmount: result.paid_amount, + remainingAmount: result.remaining_amount, + paymentAmount: result.payment_amount, + journalEntryId: result.journal_entry_id, + }, + } +} + +// ── Helpers ───────────────────────────────────────────────── + +function computeRemaining(invoice: SupplierInvoice): number { + // Trust the stored value whenever present, including the legitimate 0 for + // a fully-paid invoice. Falling through to `total - paid_amount` for the + // 0 case can leak rounding drift across multiple payments and return a + // tiny positive number, slipping a fully-paid invoice past + // LINK_SI_VOUCHER_INVOICE_FULLY_PAID. PR #602 review fix. + if (typeof invoice.remaining_amount === 'number') { + return Math.max(0, invoice.remaining_amount) + } + const paid = invoice.paid_amount ?? 0 + return Math.max(0, round2(invoice.total - paid)) +} + +function round2(n: number): number { + return Math.round(n * 100) / 100 +} + +function isDateWithinDays(a: string, b: string, days: number): boolean { + const ad = new Date(a).getTime() + const bd = new Date(b).getTime() + if (Number.isNaN(ad) || Number.isNaN(bd)) return false + return Math.abs(ad - bd) <= days * 24 * 3600 * 1000 +} + +function descriptionMentionsToken(description: string | null, token: string): boolean { + if (!description || !token) return false + const normalizedDesc = description.replace(/\s+/g, '').toLowerCase() + const normalizedTok = token.replace(/\s+/g, '').toLowerCase() + if (normalizedTok.length < 2) return false + return normalizedDesc.includes(normalizedTok) +} + +function formatNumber(n: number): string { + return new Intl.NumberFormat('sv-SE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(n) +} diff --git a/messages/en.json b/messages/en.json index 95237adb..1cdac247 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2589,6 +2589,8 @@ "payment_voucher": "Payment verifikat", "notes_title": "Notes", "pay_dialog_title": "Mark as paid", + "tab_new_payment": "New payment", + "tab_existing_voucher": "Existing voucher", "payment_date_label": "Payment date", "payment_amount_label": "Amount to pay", "remaining_to_pay": "Remaining to pay: {amount} {currency}", diff --git a/messages/sv.json b/messages/sv.json index 90d16b1b..a318ca49 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2589,6 +2589,8 @@ "payment_voucher": "Betalningsverifikation", "notes_title": "Anteckningar", "pay_dialog_title": "Markera som betald", + "tab_new_payment": "Ny betalning", + "tab_existing_voucher": "Befintlig verifikation", "payment_date_label": "Betalningsdatum", "payment_amount_label": "Belopp att betala", "remaining_to_pay": "Kvar att betala: {amount} {currency}", diff --git a/supabase/migrations/20260529120000_transaction_voucher_links.sql b/supabase/migrations/20260529120000_transaction_voucher_links.sql new file mode 100644 index 00000000..5139b9a7 --- /dev/null +++ b/supabase/migrations/20260529120000_transaction_voucher_links.sql @@ -0,0 +1,185 @@ +-- Phase 1A — Foundation for multi-tx ↔ multi-voucher matching. +-- +-- This migration scaffolds three pieces of schema that later phases (the +-- match_batch_allocate and bulk_book_transactions RPCs, the new transactions +-- inbox UI) build on. No RPC is added here — RPCs land in a follow-up +-- migration to keep review small. +-- +-- 1. transaction_voucher_links +-- Junction table for N-tx → 1-JE flows (samlingsverifikation, bulk-book, +-- "link N bank lines to an existing day-summary verifikat"). The 1:1 +-- case continues to use transactions.journal_entry_id; this junction is +-- additive. Sum(allocated_amount) per JE must match the JE's net 19xx +-- side within rounding tolerance — enforced by RPC business logic, not +-- a DB constraint (a partial allocation is legitimate before the second +-- bank line lands). +-- +-- 2. block_contradictory_invoice_denorm trigger +-- transactions.invoice_id / supplier_invoice_id are denormalized pointers +-- that only carry meaning for the 1:1 case. After multi-match, +-- invoice_payments / supplier_invoice_payments are the source of truth. +-- This trigger refuses to set the denorm column to an invoice id that +-- already conflicts with a payment row, preventing the table from +-- silently lying after a multi-match. +-- +-- 3. is_transaction_booked(uuid) SQL helper +-- Single source of truth for "is this tx anchored to a verifikat?". A tx +-- is booked when (a) transactions.journal_entry_id is set, or (b) any +-- invoice_payments row references it, or (c) any +-- supplier_invoice_payments row references it, or (d) any +-- transaction_voucher_links row references it. Used by inbox filters +-- and MCP list_uncategorized_transactions so the predicate stays +-- consistent across surfaces. + +-- ───────────────────────────────────────────────────────────────── +-- 1. transaction_voucher_links: N-tx → 1-JE junction +-- ───────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS public.transaction_voucher_links ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES public.companies ON DELETE CASCADE, + transaction_id UUID NOT NULL REFERENCES public.transactions ON DELETE CASCADE, + -- ON DELETE CASCADE so delete_last_voucher (which permits removing the last + -- draft / final voucher in a series, see 20260509103736 + 20260528120000) + -- transparently strips the link rows. The txs themselves remain but become + -- "unbooked" via is_transaction_booked() and re-surface in the inbox for + -- re-booking — the desired behaviour after voucher deletion. + journal_entry_id UUID NOT NULL REFERENCES public.journal_entries ON DELETE CASCADE, + -- Signed amount in the transaction's own currency. Positive when the tx + -- credits the JE's 19xx side (deposit), negative for debits (payment). + -- Sum across all rows pointing at a given JE must equal the JE's net 19xx + -- side within rounding tolerance — enforced by RPC business logic. + allocated_amount NUMERIC(15,2) NOT NULL, + -- bank_line = ordinary settlement leg (default) + -- clearing = clearing-account leg (e.g. card/Swish day-summary clearing) + -- other = future use + role TEXT NOT NULL DEFAULT 'bank_line', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT transaction_voucher_links_role_check + CHECK (role IN ('bank_line', 'clearing', 'other')), + CONSTRAINT transaction_voucher_links_tx_je_unique + UNIQUE (transaction_id, journal_entry_id) +); + +CREATE INDEX IF NOT EXISTS idx_transaction_voucher_links_company_id + ON public.transaction_voucher_links (company_id); +CREATE INDEX IF NOT EXISTS idx_transaction_voucher_links_transaction_id + ON public.transaction_voucher_links (transaction_id); +CREATE INDEX IF NOT EXISTS idx_transaction_voucher_links_journal_entry_id + ON public.transaction_voucher_links (journal_entry_id); + +ALTER TABLE public.transaction_voucher_links ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "transaction_voucher_links_select" ON public.transaction_voucher_links + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "transaction_voucher_links_insert" ON public.transaction_voucher_links + FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "transaction_voucher_links_update" ON public.transaction_voucher_links + FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())) + WITH CHECK (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "transaction_voucher_links_delete" ON public.transaction_voucher_links + FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE TRIGGER transaction_voucher_links_updated_at + BEFORE UPDATE ON public.transaction_voucher_links + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +COMMENT ON TABLE public.transaction_voucher_links IS + 'N-tx → 1-JE junction. Use when one verifikat aggregates multiple bank lines (samlingsverifikation, bulk-book). The 1:1 case continues to use transactions.journal_entry_id; this junction is additive. is_transaction_booked() consults both.'; + +-- ───────────────────────────────────────────────────────────────── +-- 2. block_contradictory_invoice_denorm +-- ───────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.block_contradictory_invoice_denorm() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + conflicting_invoice_id UUID; + conflicting_supplier_invoice_id UUID; +BEGIN + -- transactions.invoice_id must not contradict the invoice_payments table. + IF NEW.invoice_id IS NOT NULL THEN + SELECT ip.invoice_id INTO conflicting_invoice_id + FROM public.invoice_payments ip + WHERE ip.transaction_id = NEW.id + AND ip.invoice_id <> NEW.invoice_id + LIMIT 1; + IF conflicting_invoice_id IS NOT NULL THEN + RAISE EXCEPTION + 'transactions.invoice_id=% contradicts invoice_payments(invoice_id=%) for tx %', + NEW.invoice_id, conflicting_invoice_id, NEW.id + USING ERRCODE = 'check_violation'; + END IF; + END IF; + + -- Same for supplier_invoice_id. + IF NEW.supplier_invoice_id IS NOT NULL THEN + SELECT sip.supplier_invoice_id INTO conflicting_supplier_invoice_id + FROM public.supplier_invoice_payments sip + WHERE sip.transaction_id = NEW.id + AND sip.supplier_invoice_id <> NEW.supplier_invoice_id + LIMIT 1; + IF conflicting_supplier_invoice_id IS NOT NULL THEN + RAISE EXCEPTION + 'transactions.supplier_invoice_id=% contradicts supplier_invoice_payments(supplier_invoice_id=%) for tx %', + NEW.supplier_invoice_id, conflicting_supplier_invoice_id, NEW.id + USING ERRCODE = 'check_violation'; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +-- BEFORE INSERT OR UPDATE so an INSERT carrying both invoice_id and a pre- +-- existing payment row (unusual but possible via direct DB insert) also gets +-- caught. The UPDATE path is the common one (the match endpoints set +-- invoice_id after inserting the payment row). +CREATE TRIGGER trg_block_contradictory_invoice_denorm + BEFORE INSERT OR UPDATE OF invoice_id, supplier_invoice_id + ON public.transactions + FOR EACH ROW EXECUTE FUNCTION public.block_contradictory_invoice_denorm(); + +COMMENT ON COLUMN public.transactions.invoice_id IS + 'Denormalized link for the 1:1 tx → invoice case. NULL when the tx settles multiple invoices (truth lives in invoice_payments). Guarded by trg_block_contradictory_invoice_denorm.'; +COMMENT ON COLUMN public.transactions.supplier_invoice_id IS + 'Denormalized link for the 1:1 tx → supplier_invoice case. NULL when the tx settles multiple supplier invoices (truth lives in supplier_invoice_payments). Guarded by trg_block_contradictory_invoice_denorm.'; + +-- ───────────────────────────────────────────────────────────────── +-- 3. is_transaction_booked(uuid) +-- ───────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.is_transaction_booked(p_transaction_id UUID) +RETURNS BOOLEAN +LANGUAGE sql +STABLE +SECURITY INVOKER +AS $$ + SELECT + EXISTS ( + SELECT 1 FROM public.transactions t + WHERE t.id = p_transaction_id + AND t.journal_entry_id IS NOT NULL + ) + OR EXISTS ( + SELECT 1 FROM public.invoice_payments ip + WHERE ip.transaction_id = p_transaction_id + ) + OR EXISTS ( + SELECT 1 FROM public.supplier_invoice_payments sip + WHERE sip.transaction_id = p_transaction_id + ) + OR EXISTS ( + SELECT 1 FROM public.transaction_voucher_links tvl + WHERE tvl.transaction_id = p_transaction_id + ); +$$; + +COMMENT ON FUNCTION public.is_transaction_booked(UUID) IS + 'Returns true if the transaction is anchored to ANY verifikat — directly via journal_entry_id, indirectly via a payment row, or via the transaction_voucher_links junction. Single source of truth for "is this booked?" used by inbox filters, reconciliation status, and MCP list_uncategorized_transactions.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260529130000_link_supplier_invoice_to_voucher_rpc.sql b/supabase/migrations/20260529130000_link_supplier_invoice_to_voucher_rpc.sql new file mode 100644 index 00000000..ebf3ee0a --- /dev/null +++ b/supabase/migrations/20260529130000_link_supplier_invoice_to_voucher_rpc.sql @@ -0,0 +1,190 @@ +-- PR #602 review fix — atomic supplier-invoice voucher linking RPC. +-- +-- Closes the race surfaced by greptile review: the TS-side +-- linkSupplierInvoiceToVoucher() updated the supplier_invoices row first, then +-- inserted the supplier_invoice_payments row, with a manual unconditional +-- rollback on insert failure. Under concurrent linking against the same +-- invoice (A starts on `registered`, B completes to `paid`, A's insert fails +-- and the rollback overwrites B's `paid` back to `registered`) the rollback +-- could clobber a sibling's successful write while leaving its payment row +-- in place. This RPC moves both writes into a single Postgres transaction +-- so PG's own rollback handles the failure path correctly. +-- +-- Mirrors the existing commit_journal_entry pattern (atomic voucher commit). +-- The TS wrapper now reads the validated invoice + voucher state from the +-- RPC return payload and only emits the `supplier_invoice.paid` event on the +-- happy path. + +CREATE OR REPLACE FUNCTION public.link_supplier_invoice_to_voucher( + p_supplier_invoice_id uuid, + p_journal_entry_id uuid, + p_user_id uuid, + p_company_id uuid, + p_notes text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_invoice RECORD; + v_voucher RECORD; + v_ap_debit_total numeric := 0; + v_line_currency text; + v_remaining numeric; + v_payment_amount numeric; + v_new_paid numeric; + v_new_remaining numeric; + v_new_status text; + v_is_fully_paid boolean; + v_now timestamptz := now(); + v_payment_id uuid; +BEGIN + -- 1. Lock invoice for the duration of this transaction. FOR UPDATE so a + -- concurrent linker has to wait until we commit (or roll back). + SELECT * INTO v_invoice + FROM public.supplier_invoices + WHERE id = p_supplier_invoice_id AND company_id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND'); + END IF; + + IF v_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID', + 'details', jsonb_build_object('status', v_invoice.status) + ); + END IF; + + -- Trust the stored remaining_amount when present (even when 0), only fall + -- through to total - paid_amount when the column is NULL. The "> 0" guard + -- was the original sin from voucher-matching.ts; rounding drift on a + -- fully-paid invoice persisted as remaining_amount=0 could compute a + -- residual via total - paid_amount and slip past the FULLY_PAID guard. + v_remaining := COALESCE(v_invoice.remaining_amount, + v_invoice.total - COALESCE(v_invoice.paid_amount, 0)); + IF v_remaining <= 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID'); + END IF; + + -- 2. Resolve the voucher + SELECT * INTO v_voucher + FROM public.journal_entries + WHERE id = p_journal_entry_id AND company_id = p_company_id; + + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND'); + END IF; + + IF v_voucher.status <> 'posted' THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_SI_VOUCHER_NOT_POSTED', + 'details', jsonb_build_object('status', v_voucher.status) + ); + END IF; + + IF v_voucher.source_type IN ('opening_balance', 'storno') THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT', + 'details', jsonb_build_object('source_type', v_voucher.source_type) + ); + END IF; + + -- 3. Sum AP debit on 2440 across all lines in this voucher. + SELECT COALESCE(SUM(debit_amount), 0), MAX(currency) + INTO v_ap_debit_total, v_line_currency + FROM public.journal_entry_lines + WHERE journal_entry_id = p_journal_entry_id + AND account_number = '2440' + AND debit_amount > 0; + + v_ap_debit_total := ROUND(v_ap_debit_total * 100) / 100; + + IF v_ap_debit_total <= 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT'); + END IF; + + IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH', + 'details', jsonb_build_object( + 'invoice_currency', v_invoice.currency, + 'line_currency', v_line_currency + ) + ); + END IF; + + IF v_ap_debit_total > v_remaining + 0.005 THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING', + 'details', jsonb_build_object( + 'ap_debit', v_ap_debit_total, + 'remaining', ROUND(v_remaining * 100) / 100 + ) + ); + END IF; + + -- 4. Reject re-link of the same voucher to the same invoice. + IF EXISTS ( + SELECT 1 FROM public.supplier_invoice_payments + WHERE company_id = p_company_id + AND supplier_invoice_id = p_supplier_invoice_id + AND journal_entry_id = p_journal_entry_id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_ALREADY_LINKED'); + END IF; + + -- 5. Compute the advance. + v_payment_amount := LEAST(v_ap_debit_total, ROUND(v_remaining * 100) / 100); + v_new_remaining := GREATEST(0, + ROUND((v_remaining - v_payment_amount) * 100) / 100 + ); + v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100; + v_is_fully_paid := v_new_remaining <= 0.005; + v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END; + + -- 6. Apply both writes. The RPC body is one transaction; a failure on the + -- INSERT triggers PG's own rollback of the UPDATE — no manual rollback + -- path needed. + UPDATE public.supplier_invoices + SET status = v_new_status, + paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END, + paid_amount = v_new_paid, + remaining_amount = v_new_remaining, + updated_at = v_now + WHERE id = p_supplier_invoice_id; + + INSERT INTO public.supplier_invoice_payments ( + user_id, company_id, supplier_invoice_id, payment_date, amount, currency, + journal_entry_id, transaction_id, notes + ) VALUES ( + p_user_id, p_company_id, p_supplier_invoice_id, v_voucher.entry_date, + v_payment_amount, v_invoice.currency, p_journal_entry_id, NULL, p_notes + ) + RETURNING id INTO v_payment_id; + + RETURN jsonb_build_object( + 'ok', true, + 'payment_id', v_payment_id, + 'invoice_status', v_new_status, + 'paid_amount', v_new_paid, + 'remaining_amount', v_new_remaining, + 'payment_amount', v_payment_amount, + 'journal_entry_id', p_journal_entry_id, + 'currency', v_invoice.currency + ); +END; +$$; + +COMMENT ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) IS + 'Atomically link an existing posted verifikat as payment for a supplier invoice. Locks the invoice row, validates the voucher debits 2440, advances paid_amount/remaining_amount/status, and inserts a supplier_invoice_payments row in one PG transaction. Returns jsonb { ok, ..., payment_id } on success or { ok: false, code, details } on guard failure.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260529140000_link_supplier_invoice_to_voucher_ap_prefix.sql b/supabase/migrations/20260529140000_link_supplier_invoice_to_voucher_ap_prefix.sql new file mode 100644 index 00000000..e592e9a8 --- /dev/null +++ b/supabase/migrations/20260529140000_link_supplier_invoice_to_voucher_ap_prefix.sql @@ -0,0 +1,144 @@ +-- PR #602 Swedish-compliance fix — broaden AP-account check in +-- link_supplier_invoice_to_voucher to cover the full BAS 2440–2449 range +-- (2440 Leverantörsskulder, 2441 Leverantörsskulder i utländsk valuta, +-- 2443 Skuldfakturor leverantörer, etc.). A samlingsverifikat paying mixed +-- SEK + EUR suppliers will legitimately debit both 2440 and 2441; the +-- earlier hardcode rejected the latter with LINK_SI_VOUCHER_NO_AP_DEBIT +-- even though the booking was BAS-compliant. +-- +-- Mirrors the TS-side change in lib/invoices/supplier-voucher-matching.ts +-- where AP_ACCOUNT='2440' became AP_ACCOUNT_PREFIX='244'. + +CREATE OR REPLACE FUNCTION public.link_supplier_invoice_to_voucher( + p_supplier_invoice_id uuid, + p_journal_entry_id uuid, + p_user_id uuid, + p_company_id uuid, + p_notes text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_invoice RECORD; + v_voucher RECORD; + v_ap_debit_total numeric := 0; + v_line_currency text; + v_remaining numeric; + v_payment_amount numeric; + v_new_paid numeric; + v_new_remaining numeric; + v_new_status text; + v_is_fully_paid boolean; + v_now timestamptz := now(); + v_payment_id uuid; +BEGIN + SELECT * INTO v_invoice + FROM public.supplier_invoices + WHERE id = p_supplier_invoice_id AND company_id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND'); + END IF; + + IF v_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID', + 'details', jsonb_build_object('status', v_invoice.status)); + END IF; + + v_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total - COALESCE(v_invoice.paid_amount, 0)); + IF v_remaining <= 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID'); + END IF; + + SELECT * INTO v_voucher + FROM public.journal_entries + WHERE id = p_journal_entry_id AND company_id = p_company_id; + + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND'); + END IF; + + IF v_voucher.status <> 'posted' THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NOT_POSTED', + 'details', jsonb_build_object('status', v_voucher.status)); + END IF; + + IF v_voucher.source_type IN ('opening_balance', 'storno') THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT', + 'details', jsonb_build_object('source_type', v_voucher.source_type)); + END IF; + + -- Sum AP debit across the full 244x range (was: account_number = '2440'). + SELECT COALESCE(SUM(debit_amount), 0), MAX(currency) + INTO v_ap_debit_total, v_line_currency + FROM public.journal_entry_lines + WHERE journal_entry_id = p_journal_entry_id + AND account_number LIKE '244%' + AND debit_amount > 0; + + v_ap_debit_total := ROUND(v_ap_debit_total * 100) / 100; + + IF v_ap_debit_total <= 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT'); + END IF; + + IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH', + 'details', jsonb_build_object('invoice_currency', v_invoice.currency, 'line_currency', v_line_currency)); + END IF; + + IF v_ap_debit_total > v_remaining + 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING', + 'details', jsonb_build_object('ap_debit', v_ap_debit_total, 'remaining', ROUND(v_remaining * 100) / 100)); + END IF; + + IF EXISTS ( + SELECT 1 FROM public.supplier_invoice_payments + WHERE company_id = p_company_id + AND supplier_invoice_id = p_supplier_invoice_id + AND journal_entry_id = p_journal_entry_id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_ALREADY_LINKED'); + END IF; + + v_payment_amount := LEAST(v_ap_debit_total, ROUND(v_remaining * 100) / 100); + v_new_remaining := GREATEST(0, ROUND((v_remaining - v_payment_amount) * 100) / 100); + v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100; + v_is_fully_paid := v_new_remaining <= 0.005; + v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END; + + UPDATE public.supplier_invoices + SET status = v_new_status, + paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END, + paid_amount = v_new_paid, + remaining_amount = v_new_remaining, + updated_at = v_now + WHERE id = p_supplier_invoice_id; + + INSERT INTO public.supplier_invoice_payments ( + user_id, company_id, supplier_invoice_id, payment_date, amount, currency, + journal_entry_id, transaction_id, notes + ) VALUES ( + p_user_id, p_company_id, p_supplier_invoice_id, v_voucher.entry_date, + v_payment_amount, v_invoice.currency, p_journal_entry_id, NULL, p_notes + ) + RETURNING id INTO v_payment_id; + + RETURN jsonb_build_object( + 'ok', true, + 'payment_id', v_payment_id, + 'invoice_status', v_new_status, + 'paid_amount', v_new_paid, + 'remaining_amount', v_new_remaining, + 'payment_amount', v_payment_amount, + 'journal_entry_id', p_journal_entry_id, + 'currency', v_invoice.currency + ); +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/transaction_voucher_links.pg.test.ts b/tests/pg/transaction_voucher_links.pg.test.ts new file mode 100644 index 00000000..6e754eb5 --- /dev/null +++ b/tests/pg/transaction_voucher_links.pg.test.ts @@ -0,0 +1,367 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + insertAuthUser, + insertCompany, + insertCompanyMember, + insertFiscalPeriod, +} from '@/tests/pg/fixtures' +import { getPool } from '@/tests/pg/setup' + +/** + * Covers 20260529120000_transaction_voucher_links per PR #602 review note: + * - transaction_voucher_links table is RLS-policied + indexed + * - block_contradictory_invoice_denorm trigger refuses an UPDATE that + * would set transactions.invoice_id (or supplier_invoice_id) to a value + * contradicting an existing payment row. + * - is_transaction_booked(uuid) returns true when ANY of: + * transactions.journal_entry_id IS NOT NULL, + * invoice_payments references the tx, + * supplier_invoice_payments references the tx, + * transaction_voucher_links references the tx + * …and false otherwise. + * + * Tests write via the superuser pool (bypass RLS) — the goal is to exercise + * trigger logic + the helper's SQL truth, not the policy layer. + */ + +async function insertCustomer(params: { + userId: string + companyId: string +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.customers + (id, user_id, company_id, name, customer_type, country) + VALUES ($1, $2, $3, 'Kund AB', 'swedish_business', 'SE')`, + [id, params.userId, params.companyId], + ) + return id +} + +async function insertInvoice(params: { + userId: string + companyId: string + customerId: string + total?: number +}): Promise { + const id = randomUUID() + const invoiceNumber = `F-${Date.now() % 1_000_000}-${Math.floor(Math.random() * 1_000)}` + await getPool().query( + `INSERT INTO public.invoices + (id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date, status, + currency, subtotal, vat_amount, total, paid_amount, remaining_amount, vat_treatment) + VALUES ($1, $2, $3, $4, $5, '2026-06-01', '2026-07-01', 'sent', 'SEK', + $6, 0, $6, 0, $6, 'standard_25')`, + [ + id, + params.userId, + params.companyId, + params.customerId, + invoiceNumber, + params.total ?? 1000, + ], + ) + return id +} + +async function insertSupplier(params: { + userId: string + companyId: string +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.suppliers + (id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency) + VALUES ($1, $2, $3, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`, + [id, params.userId, params.companyId], + ) + return id +} + +async function insertSupplierInvoice(params: { + userId: string + companyId: string + supplierId: string + total?: number +}): Promise { + const id = randomUUID() + const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 10_000) + await getPool().query( + `INSERT INTO public.supplier_invoices + (id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number, + invoice_date, due_date, received_date, status, currency, + subtotal, vat_amount, total, paid_amount, remaining_amount, + vat_treatment, reverse_charge, is_credit_note) + VALUES ($1, $2, $3, $4, $5, $6, '2026-06-01', '2026-07-01', '2026-06-02', + 'approved', 'SEK', $7, 0, $7, 0, $7, 'standard_25', false, false)`, + [ + id, + params.userId, + params.companyId, + params.supplierId, + arrivalNumber, + `LF-${arrivalNumber}`, + params.total ?? 1000, + ], + ) + return id +} + +async function insertTransaction(params: { + userId: string + companyId: string + amount?: number +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.transactions + (id, user_id, company_id, date, description, amount, currency, category) + VALUES ($1, $2, $3, '2026-06-05', 'Bank transfer', $4, 'SEK', 'uncategorized')`, + [id, params.userId, params.companyId, params.amount ?? 1000], + ) + return id +} + +async function seedTenant() { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + await insertCompanyMember({ companyId, userId, role: 'owner' }) + const fiscalPeriodId = await insertFiscalPeriod({ + userId, + companyId, + periodStart: '2026-01-01', + periodEnd: '2026-12-31', + }) + return { userId, companyId, fiscalPeriodId } +} + +describe('block_contradictory_invoice_denorm trigger', () => { + it('blocks an UPDATE that sets transactions.invoice_id to a value contradicting an invoice_payments row', async () => { + const { userId, companyId, fiscalPeriodId } = await seedTenant() + const customerId = await insertCustomer({ userId, companyId }) + + const invoiceA = await insertInvoice({ userId, companyId, customerId, total: 1000 }) + const invoiceB = await insertInvoice({ userId, companyId, customerId, total: 2000 }) + const txId = await insertTransaction({ userId, companyId, amount: 1000 }) + + // Create a posted journal_entries row so we can satisfy invoice_payments.journal_entry_id FK + const jeId = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`, + [jeId, userId, companyId, fiscalPeriodId], + ) + await getPool().query( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 1000, 0), ($1, '1510', 0, 1000)`, + [jeId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId]) + + // Insert invoice_payments row linking tx → invoiceA + await getPool().query( + `INSERT INTO public.invoice_payments + (user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id, transaction_id) + VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`, + [userId, companyId, invoiceA, jeId, txId], + ) + + // Now try to set transactions.invoice_id to invoiceB (contradiction) + await expect( + getPool().query(`UPDATE public.transactions SET invoice_id = $1 WHERE id = $2`, [ + invoiceB, + txId, + ]), + ).rejects.toThrow(/contradicts invoice_payments/) + }) + + it('permits an UPDATE that sets transactions.invoice_id to the same value as the existing payment row', async () => { + const { userId, companyId, fiscalPeriodId } = await seedTenant() + const customerId = await insertCustomer({ userId, companyId }) + const invoiceA = await insertInvoice({ userId, companyId, customerId, total: 1000 }) + const txId = await insertTransaction({ userId, companyId, amount: 1000 }) + + const jeId = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`, + [jeId, userId, companyId, fiscalPeriodId], + ) + await getPool().query( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 1000, 0), ($1, '1510', 0, 1000)`, + [jeId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId]) + + await getPool().query( + `INSERT INTO public.invoice_payments + (user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id, transaction_id) + VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`, + [userId, companyId, invoiceA, jeId, txId], + ) + + // Setting invoice_id to the SAME id should succeed + await expect( + getPool().query(`UPDATE public.transactions SET invoice_id = $1 WHERE id = $2`, [ + invoiceA, + txId, + ]), + ).resolves.toBeDefined() + }) + + it('blocks an UPDATE that sets transactions.supplier_invoice_id to a value contradicting a supplier_invoice_payments row', async () => { + const { userId, companyId, fiscalPeriodId } = await seedTenant() + const supplierId = await insertSupplier({ userId, companyId }) + const siA = await insertSupplierInvoice({ userId, companyId, supplierId, total: 1000 }) + const siB = await insertSupplierInvoice({ userId, companyId, supplierId, total: 2000 }) + const txId = await insertTransaction({ userId, companyId, amount: -1000 }) + + const jeId = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`, + [jeId, userId, companyId, fiscalPeriodId], + ) + await getPool().query( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '2440', 1000, 0), ($1, '1930', 0, 1000)`, + [jeId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId]) + + await getPool().query( + `INSERT INTO public.supplier_invoice_payments + (user_id, company_id, supplier_invoice_id, payment_date, amount, currency, + journal_entry_id, transaction_id) + VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`, + [userId, companyId, siA, jeId, txId], + ) + + await expect( + getPool().query(`UPDATE public.transactions SET supplier_invoice_id = $1 WHERE id = $2`, [ + siB, + txId, + ]), + ).rejects.toThrow(/contradicts supplier_invoice_payments/) + }) +}) + +describe('is_transaction_booked', () => { + it('returns false for a fresh, unbooked transaction', async () => { + const { userId, companyId } = await seedTenant() + const txId = await insertTransaction({ userId, companyId }) + const r = await getPool().query<{ is_transaction_booked: boolean }>( + `SELECT is_transaction_booked($1)`, + [txId], + ) + expect(r.rows[0]!.is_transaction_booked).toBe(false) + }) + + it('returns true when transactions.journal_entry_id is set', async () => { + const { userId, companyId, fiscalPeriodId } = await seedTenant() + const txId = await insertTransaction({ userId, companyId }) + + const jeId = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`, + [jeId, userId, companyId, fiscalPeriodId], + ) + await getPool().query( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 1000, 0), ($1, '3001', 0, 1000)`, + [jeId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId]) + + await getPool().query(`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`, [ + jeId, + txId, + ]) + + const r = await getPool().query<{ is_transaction_booked: boolean }>( + `SELECT is_transaction_booked($1)`, + [txId], + ) + expect(r.rows[0]!.is_transaction_booked).toBe(true) + }) + + it('returns true when only an invoice_payments row references the tx (multi-allocation case)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedTenant() + const customerId = await insertCustomer({ userId, companyId }) + const invoiceId = await insertInvoice({ userId, companyId, customerId }) + const txId = await insertTransaction({ userId, companyId }) + + const jeId = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`, + [jeId, userId, companyId, fiscalPeriodId], + ) + await getPool().query( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 1000, 0), ($1, '1510', 0, 1000)`, + [jeId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId]) + + // No tx.journal_entry_id set, but a payment row exists referencing tx. + await getPool().query( + `INSERT INTO public.invoice_payments + (user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id, transaction_id) + VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`, + [userId, companyId, invoiceId, jeId, txId], + ) + + const r = await getPool().query<{ is_transaction_booked: boolean }>( + `SELECT is_transaction_booked($1)`, + [txId], + ) + expect(r.rows[0]!.is_transaction_booked).toBe(true) + }) + + it('returns true when only a transaction_voucher_links row references the tx', async () => { + const { userId, companyId, fiscalPeriodId } = await seedTenant() + const txId = await insertTransaction({ userId, companyId }) + + const jeId = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`, + [jeId, userId, companyId, fiscalPeriodId], + ) + await getPool().query( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 1000, 0), ($1, '3001', 0, 1000)`, + [jeId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId]) + + await getPool().query( + `INSERT INTO public.transaction_voucher_links + (user_id, company_id, transaction_id, journal_entry_id, allocated_amount) + VALUES ($1, $2, $3, $4, 1000)`, + [userId, companyId, txId, jeId], + ) + + const r = await getPool().query<{ is_transaction_booked: boolean }>( + `SELECT is_transaction_booked($1)`, + [txId], + ) + expect(r.rows[0]!.is_transaction_booked).toBe(true) + }) +})