From 9e54a8e4003184c5a724079b3b6a4ef73abacdea Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:44:59 +0200 Subject: [PATCH] fix: preserve invoice payment dates (#1332) Signed-off-by: Emil --- DECISIONS.md | 3 + .../[id]/mark-paid/__tests__/route.test.ts | 15 +- .../[id]/mark-paid/__tests__/route.test.ts | 14 +- .../supplier-invoices/[id]/mark-paid/route.ts | 12 +- .../__tests__/route.test.ts | 22 +- .../match-invoice/__tests__/route.test.ts | 24 +- .../transactions/[id]/match-invoice/route.ts | 24 +- .../__tests__/route.test.ts | 20 + .../[id]/match-supplier-invoice/route.ts | 24 +- .../[id]/mark-paid/__tests__/route.test.ts | 15 +- .../invoices/[id]/mark-paid/route.ts | 6 +- .../[id]/mark-paid/__tests__/route.test.ts | 35 +- .../supplier-invoices/[id]/mark-paid/route.ts | 16 +- .../match-invoice/__tests__/route.test.ts | 253 +++++ .../transactions/[id]/match-invoice/route.ts | 25 +- .../__tests__/route.test.ts | 253 +++++ .../[id]/match-supplier-invoice/route.ts | 24 +- lib/invoices/__tests__/paid-at.test.ts | 27 + .../__tests__/settle-invoice-payment.test.ts | 10 +- .../__tests__/voucher-matching.pg.test.ts | 80 +- lib/invoices/paid-at.ts | 12 + lib/invoices/settle-invoice-payment.ts | 10 +- .../__tests__/mark-invoice-paid.test.ts | 12 +- ...saction-invoice-settlement-account.test.ts | 20 +- lib/pending-operations/commit.ts | 32 +- lib/transactions/link-journal-entry.ts | 22 +- ..._use_invoice_payment_dates_for_paid_at.sql | 960 ++++++++++++++++++ tests/pg/match-batch-allocate.pg.test.ts | 78 +- 28 files changed, 1975 insertions(+), 73 deletions(-) create mode 100644 app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/__tests__/route.test.ts create mode 100644 app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts create mode 100644 lib/invoices/__tests__/paid-at.test.ts create mode 100644 lib/invoices/paid-at.ts create mode 100644 supabase/migrations/20260801204551_use_invoice_payment_dates_for_paid_at.sql diff --git a/DECISIONS.md b/DECISIONS.md index a2789c23..38d1da84 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,6 +2,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and humans when a non-obvious choice is made (approach picked over an alternative, dependency declined, action stopped by a CLAUDE.md rule). Read before re-litigating a past decision. +[2026-08-01] Invoice payment dates stored in timestamptz use a shared UTC-noon representation: `paid_at` has date-only business semantics, and noon preserves the selected or bank transaction date when formatted in UTC, Europe/Stockholm, and all negative UTC offsets through UTC-12; UTC midnight displays as the prior day in American time zones. +[2026-08-01] Privately paid supplier-invoice creation stays outside the payment-date correction: that flow deliberately journals on the invoice date while its optional out-of-pocket payment date can differ, so changing only `paid_at` would require a separate Swedish accounting semantics decision. + [2026-07-02] Adopted this decision log: CLAUDE.md rewritten per config-over-prompt principles; decisions persist here instead of being re-derived each session. [2026-07-03] Prod constraint clobber (self-inflicted, repaired in ~10 min): applied pending_operations link_document_to_voucher migration from a checkout predating 20260702171000 (retag_line_dimensions): hand-copied CHECK lists clobber concurrent adds. Zero impact (no retag ops in window). Rule: before applying any expand-types migration to prod, diff the list against the LIVE prod constraint, not the local file history. Long-term fix queued in mcp_optimization_plan P0-1 follow-up (audit test now guards CI). [2026-07-03] Archived 4 completed/superseded plans to dev_docs/archive/ (dimensions_implementation_plan, specialized-agent-plan, api_ai_architecture/PLAN, mcp-apps-architecture-reference): moved, not deleted, because dev_docs is gitignored (no git history to recover from). Live remnants relocated first: PR10 backlog → dimensions_architecture.md; eval-harness spec → claude_surface_plan.md §2.1. agent_first_vision.md §8 marked superseded by claude_surface_plan.md (Skatteverket filing is BUILT, contra its P0 item 6). diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 1632ac05..7d1fe8bb 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -175,7 +175,10 @@ describe('POST /api/invoices/[id]/mark-paid', () => { const paidHandler = vi.fn() eventBus.on('invoice.paid', paidHandler) - const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { + method: 'POST', + body: { payment_date: '2026-05-12' }, + }) const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) const { status, body } = await parseJsonResponse<{ success: boolean @@ -183,6 +186,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { paid_amount: number remaining_amount: number journal_entry_id: string | null + paid_at: string | null }>(response) expect(status).toBe(200) @@ -191,6 +195,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { expect(body.paid_amount).toBe(12500) expect(body.remaining_amount).toBe(0) expect(body.journal_entry_id).toBe('je-1') + expect(body.paid_at).toBe('2026-05-12T12:00:00Z') // invoice.paid must fire so registered webhooks fan out (issue #825). expect(paidHandler).toHaveBeenCalledTimes(1) expect(paidHandler).toHaveBeenCalledWith( @@ -198,7 +203,13 @@ describe('POST /api/invoices/[id]/mark-paid', () => { companyId: 'company-1', userId: 'user-1', paymentAmount: 12500, - invoice: expect.objectContaining({ id: 'inv-1', status: 'paid', paid_amount: 12500, remaining_amount: 0 }), + invoice: expect.objectContaining({ + id: 'inv-1', + status: 'paid', + paid_amount: 12500, + remaining_amount: 0, + paid_at: '2026-05-12T12:00:00Z', + }), }), ) expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts index 24986170..c73c1a2e 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -8,7 +8,7 @@ import { makeSupplier, } from '@/tests/helpers' -const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase() vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) @@ -135,9 +135,12 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { // Record payment enqueue({ data: null, error: null }) + const paidHandler = vi.fn() + eventBus.on('supplier_invoice.paid', paidHandler) + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { method: 'POST', - body: {}, + body: { payment_date: '2026-05-12' }, }) const response = await POST(request, createMockRouteParams({ id: 'si-1' })) const { status, body } = await parseJsonResponse<{ @@ -155,6 +158,13 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { expect(body.remaining_amount).toBe(0) expect(body.journal_entry_id).toBe('je-1') expect(mockCreateSupplierInvoicePaymentEntry).toHaveBeenCalled() + const invoiceUpdate = findCalls('supplier_invoices', 'update').at(-1)?.[0] + expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' }) + expect(paidHandler).toHaveBeenCalledWith( + expect.objectContaining({ + supplierInvoice: expect.objectContaining({ paid_at: '2026-05-12T12:00:00Z' }), + }), + ) // Issue #1259: full settlement retires every transaction's suggestion // pointer at this invoice. No exceptTransactionId: mark-paid is not driven // by a bank transaction. diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index d2a4721e..40788a48 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -10,6 +10,7 @@ import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-en import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { paidAtFromDate } from '@/lib/invoices/paid-at' import { validateBody } from '@/lib/api/validate' import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas' import { withRouteContext } from '@/lib/api/with-route-context' @@ -66,7 +67,6 @@ export const POST = withRouteContext( const paymentDate = body.payment_date || new Date().toISOString().split('T')[0] const paymentAmount = body.amount || invoice.remaining_amount - const now = new Date().toISOString() if (body.force) { opLog.warn('duplicate-payment guard bypassed', { @@ -310,6 +310,7 @@ export const POST = withRouteContext( const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100 const isFullyPaid = newRemaining <= 0 const newStatus = isFullyPaid ? 'paid' : 'partially_paid' + const paidAt = isFullyPaid ? paidAtFromDate(paymentDate) : null const { data: updateResult, error: updateError } = await supabase .from('supplier_invoices') @@ -317,7 +318,7 @@ export const POST = withRouteContext( status: newStatus, remaining_amount: Math.max(0, newRemaining), paid_amount: newPaidAmount, - paid_at: isFullyPaid ? now : null, + paid_at: paidAt, payment_journal_entry_id: journalEntryId, }) .eq('id', id) @@ -419,7 +420,12 @@ export const POST = withRouteContext( try { await eventBus.emit({ type: 'supplier_invoice.paid', - payload: { supplierInvoice: invoice as SupplierInvoice, paymentAmount, companyId: companyId!, userId: user.id }, + payload: { + supplierInvoice: { ...invoice, paid_at: paidAt ?? invoice.paid_at } as SupplierInvoice, + paymentAmount, + companyId: companyId!, + userId: user.id, + }, }) } catch (err) { opLog.warn('supplier_invoice.paid event emission failed', err as Error) diff --git a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts index d98001d0..1bcb44d5 100644 --- a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts +++ b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts @@ -8,7 +8,7 @@ import { makeInvoice, } from '@/tests/helpers' -const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase() vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) @@ -43,6 +43,7 @@ vi.mock('@/lib/auth/require-write', () => ({ })) import { POST } from '../route' +import { eventBus } from '@/lib/events/bus' const TX_UUID = '550e8400-e29b-41d4-a716-446655440000' const JE_UUID = '550e8400-e29b-41d4-a716-446655440001' @@ -258,6 +259,25 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => { expect(body.invoice_status).toBe('paid') expect(body.paid_amount).toBe(1000) expect(body.remaining_amount).toBe(0) + const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] + expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-15T12:00:00Z' }) + expect(vi.mocked(eventBus.emit)).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'invoice.match_confirmed', + payload: expect.objectContaining({ + invoice: expect.objectContaining({ + status: 'paid', + paid_at: '2026-05-15T12:00:00Z', + paid_amount: 1000, + remaining_amount: 0, + }), + transaction: expect.objectContaining({ + invoice_id: INV_UUID, + journal_entry_id: JE_UUID, + }), + }), + }), + ) // Issue #1259: the invoice is settled, so no other transaction may keep // pointing at it as a match suggestion. expect(mockClearSuggestions).toHaveBeenCalledTimes(1) 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 8ad36e4c..3fb88769 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -9,7 +9,7 @@ import { makeCustomer, } from '@/tests/helpers' -const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase() vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) @@ -69,6 +69,7 @@ vi.mock('@/lib/auth/require-write', () => ({ })) import { POST } from '../route' +import { eventBus } from '@/lib/events/bus' // Mocked above: imported here as a spy handle to assert FX rate provenance // lands in the audit trail (PR #615 review). import { logMatchEvent } from '@/lib/invoices/match-log' @@ -478,6 +479,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { paid_amount: number remaining_amount: number journal_entry_id: string + paid_at: string | null }>(response) expect(status).toBe(200) @@ -486,6 +488,26 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.paid_amount).toBe(12500) expect(body.remaining_amount).toBe(0) expect(body.journal_entry_id).toBe('je-1') + expect(body.paid_at).toBe('2024-06-15T12:00:00Z') + const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] + expect(invoiceUpdate).toMatchObject({ paid_at: '2024-06-15T12:00:00Z' }) + expect(vi.mocked(eventBus.emit)).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'invoice.match_confirmed', + payload: expect.objectContaining({ + invoice: expect.objectContaining({ + status: 'paid', + paid_at: '2024-06-15T12:00:00Z', + paid_amount: 12500, + remaining_amount: 0, + }), + transaction: expect.objectContaining({ + invoice_id: VALID_UUID, + journal_entry_id: 'je-1', + }), + }), + }), + ) // Clearing path now builds lines via buildInvoicePaymentClearingLines and // posts via createJournalEntry directly (FX fix PR #614 round 6). For a diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index b4b9b25a..1175b512 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -16,6 +16,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log' import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { paidAtFromDate } from '@/lib/invoices/paid-at' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import type { Currency, EntityType, Invoice, Transaction } from '@/types' @@ -357,7 +358,6 @@ export const POST = withRouteContext( } } - const now = new Date().toISOString() // paidAmountInInvoiceCurrency is what gets accumulated into // invoice.paid_amount / remaining_amount and stored on the // invoice_payments row. For same-currency it's just tx.amount; for @@ -384,6 +384,7 @@ export const POST = withRouteContext( }) } const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan + const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null const { data: settings } = await supabase .from('company_settings') @@ -622,7 +623,7 @@ export const POST = withRouteContext( .from('invoices') .update({ status: newStatus, - paid_at: isFullyPaid ? now : null, + paid_at: paidAt, paid_amount: newPaidAmount, remaining_amount: newRemaining, }) @@ -736,8 +737,21 @@ export const POST = withRouteContext( eventBus.emit({ type: 'invoice.match_confirmed', payload: { - invoice: invoice as Invoice, - transaction: transaction as Transaction, + invoice: { + ...invoice, + status: newStatus, + paid_at: paidAt, + paid_amount: newPaidAmount, + remaining_amount: newRemaining, + } as Invoice, + transaction: { + ...transaction, + invoice_id, + potential_invoice_id: null, + journal_entry_id: journalEntryId, + is_business: true, + category: 'income_services', + } as Transaction, userId: user.id, companyId, }, @@ -749,7 +763,7 @@ export const POST = withRouteContext( return NextResponse.json({ success: true, invoice_status: newStatus, - paid_at: isFullyPaid ? now : null, + paid_at: paidAt, paid_amount: newPaidAmount, remaining_amount: newRemaining, journal_entry_id: journalEntryId, 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 63a69417..e04aa189 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 @@ -67,6 +67,7 @@ vi.mock('@/lib/bookkeeping/engine', () => ({ })) import { POST } from '../route' +import { eventBus } from '@/lib/events/bus' const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -459,6 +460,25 @@ describe('POST /api/transactions/[id]/match-supplier-invoice: non-FX paths', () expect(body.success).toBe(true) expect(body.paid_amount).toBe(1000) expect(body.remaining_amount).toBe(0) + const invoiceUpdate = findCalls('supplier_invoices', 'update').at(-1)?.[0] + expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' }) + expect(vi.mocked(eventBus.emit)).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'supplier_invoice.match_confirmed', + payload: expect.objectContaining({ + supplierInvoice: expect.objectContaining({ + status: 'paid', + paid_at: '2026-05-12T12:00:00Z', + paid_amount: 1000, + remaining_amount: 0, + }), + transaction: expect.objectContaining({ + supplier_invoice_id: SI_UUID, + journal_entry_id: 'je-1', + }), + }), + }), + ) }) // The suggestion pointer must not survive the match that consumes it: this diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index a4dbc380..67d18158 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -16,6 +16,7 @@ import { validateBody } from '@/lib/api/validate' import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { paidAtFromDate } from '@/lib/invoices/paid-at' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types' @@ -214,8 +215,6 @@ export const POST = withRouteContext( // as paymentAmountSek - exchangeRateDifference internally. const paymentAmountSek = exchangeRateDifference !== 0 ? originalBookedSek : actualBankSek - const now = new Date().toISOString() - // A full settlement pays off the whole remaining balance. Cross-currency // matches always do (paymentAmountInvoiceCurrency is clamped to // invoice.remaining_amount above); same-currency does when the bank amount @@ -361,6 +360,7 @@ export const POST = withRouteContext( // reports remaining 0 / status paid even though the bank paid a sub-krona // less (or more): the residual lives on 3740, not the supplier ledger. const { newRemaining, newPaidAmount, isFullyPaid, newStatus } = paymentPlan.plan + const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null const { data: updatedRows, error: updateInvError } = await supabase .from('supplier_invoices') @@ -368,7 +368,7 @@ export const POST = withRouteContext( status: newStatus, remaining_amount: newRemaining, paid_amount: newPaidAmount, - paid_at: isFullyPaid ? now : null, + paid_at: paidAt, payment_journal_entry_id: journalEntryId, transaction_id: transactionId, }) @@ -489,8 +489,22 @@ export const POST = withRouteContext( eventBus.emit({ type: 'supplier_invoice.match_confirmed', payload: { - supplierInvoice: invoice as SupplierInvoice, - transaction: transaction as Transaction, + supplierInvoice: { + ...invoice, + status: newStatus, + remaining_amount: newRemaining, + paid_amount: newPaidAmount, + paid_at: paidAt, + payment_journal_entry_id: journalEntryId, + transaction_id: transactionId, + } as SupplierInvoice, + transaction: { + ...transaction, + supplier_invoice_id, + potential_supplier_invoice_id: null, + journal_entry_id: journalEntryId, + is_business: true, + } as Transaction, userId: user.id, companyId, }, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts index 2ab0d279..df8b26af 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -141,7 +141,7 @@ const PAID_INVOICE = { status: 'paid', remaining_amount: 0, paid_amount: 12500, - paid_at: '2026-05-12', + paid_at: '2026-05-12T12:00:00Z', } beforeEach(() => { @@ -159,6 +159,7 @@ beforeEach(() => { describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => { it('books a full payment under faktureringsmetoden (accrual default)', async () => { + const calls: RecordedCall[] = [] mockServiceClient.mockReturnValue( makeFlexibleSupabase({ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, @@ -167,7 +168,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => { { data: PAID_INVOICE, error: null }, ], company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, - }), + }, calls), ) const paidHandler = vi.fn() @@ -185,14 +186,22 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => { const body = await res.json() expect(body.data.status).toBe('paid') expect(body.data.remaining_amount).toBe(0) + expect(body.data.paid_at).toBe('2026-05-12T12:00:00Z') expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj') expect(mockPayment).toHaveBeenCalled() expect(mockCash).not.toHaveBeenCalled() // invoice.paid must fire so registered webhooks fan out (issue #825). expect(paidHandler).toHaveBeenCalledTimes(1) expect(paidHandler).toHaveBeenCalledWith( - expect.objectContaining({ companyId: COMPANY_ID, userId: USER_ID, paymentAmount: 12500 }), + expect.objectContaining({ + companyId: COMPANY_ID, + userId: USER_ID, + paymentAmount: 12500, + invoice: expect.objectContaining({ paid_at: '2026-05-12T12:00:00Z' }), + }), ) + const invoiceUpdate = calls.find((call) => call.table === 'invoices' && call.method === 'update') + expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' }) // Issue #1259: the invoice is settled, so no transaction may keep pointing // at it as a match suggestion. expect(mockClearSuggestions).toHaveBeenCalledTimes(1) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts index c8efbf6f..0994b12d 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -44,6 +44,7 @@ import { eventBus } from '@/lib/events' import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates' import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { paidAtFromDate } from '@/lib/invoices/paid-at' import { roundOre } from '@/lib/money' import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types' @@ -240,6 +241,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const today = new Date().toISOString().split('T')[0] const paymentDate = bodyPaymentDate || today + const paidAt = paidAtFromDate(paymentDate) // Fetch settings for accounting method + entity type. const { data: settings } = await ctx.supabase @@ -388,7 +390,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining, - paid_at: paymentDate, + paid_at: paidAt, would_create_journal_entry: !typed.document_type || typed.document_type === 'invoice', accounting_method: accountingMethod, would_use_custom_lines: customLines !== undefined, @@ -503,7 +505,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string updated_at: new Date().toISOString(), } if (newStatus === 'paid') { - updatePayload.paid_at = paymentDate + updatePayload.paid_at = paidAt } // Deliberately NOT writing journal_entry_id here: that column means "the // registration entry that booked this invoice at issuance" and drives the diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts index 36f7c43f..e54b981c 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -64,7 +64,11 @@ const mockValidate = validateApiKey as ReturnType const mockServiceClient = createServiceClientNoCookies as ReturnType type MockResult = { data?: unknown; error?: unknown } -function makeFlexibleSupabase(byTable: Record) { +type RecordedCall = { table: string; method: string; args: unknown[] } +function makeFlexibleSupabase( + byTable: Record, + calls?: RecordedCall[], +) { const queues = new Map() for (const [t, val] of Object.entries(byTable)) { queues.set(t, Array.isArray(val) ? [...val] : [val]) @@ -79,7 +83,10 @@ function makeFlexibleSupabase(byTable: Record resolve(next) } } - return () => buildChain(table) + return (...args: unknown[]) => { + calls?.push({ table, method: String(prop), args }) + return buildChain(table) + } }, } return new Proxy({}, handler) @@ -151,26 +158,46 @@ beforeEach(() => { describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid', () => { it('retires the settled invoice suggestions on a full payment (issue #1259)', async () => { + const calls: RecordedCall[] = [] mockServiceClient.mockReturnValue( makeFlexibleSupabase({ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, supplier_invoices: [ { data: APPROVED_SI, error: null }, { - data: { ...APPROVED_SI, status: 'paid', paid_amount: 1000, remaining_amount: 0 }, + data: { + ...APPROVED_SI, + status: 'paid', + paid_amount: 1000, + remaining_amount: 0, + paid_at: '2026-05-12T12:00:00Z', + }, error: null, }, ], company_settings: { data: { accounting_method: 'accrual' }, error: null }, supplier_invoice_payments: { data: null, error: null }, - }), + }, calls), ) + const paidHandler = vi.fn() + eventBus.on('supplier_invoice.paid', paidHandler) + const res = await markPaid(makeRequest({ payment_date: '2026-05-12' }), detailParams()) expect(res.status).toBe(200) const body = await res.json() expect(body.data.status).toBe('paid') + expect(body.data.paid_at).toBe('2026-05-12T12:00:00Z') + const invoiceUpdate = calls.find( + (call) => call.table === 'supplier_invoices' && call.method === 'update', + ) + expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' }) + expect(paidHandler).toHaveBeenCalledWith( + expect.objectContaining({ + supplierInvoice: expect.objectContaining({ paid_at: '2026-05-12T12:00:00Z' }), + }), + ) expect(mockClearSuggestions).toHaveBeenCalledTimes(1) expect(mockClearSuggestions).toHaveBeenCalledWith( expect.anything(), diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts index a22867be..9af5966e 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts @@ -30,6 +30,7 @@ import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookke import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { paidAtFromDate } from '@/lib/invoices/paid-at' import { eventBus } from '@/lib/events' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -145,6 +146,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const today = new Date().toISOString().split('T')[0] const paymentDate = bodyPaymentDate || today + const paidAt = paidAtFromDate(paymentDate) // Reject future payment_date at the schema layer. BFL 5 kap 2 § // requires bokföring to follow real cash movement; a payment booked @@ -313,17 +315,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string } if (ctx.dryRun) { - // paid_at: the live UPDATE writes `new Date().toISOString()` (a full UTC - // timestamp). Mirror that shape here so callers validating dry-run vs - // live against the same regex don't see surprises. payment_date stays - // ISO date because it represents the user-supplied calendar date. + // Keep the preview aligned with the live date-only payment timestamp. return dryRunPreview( { ...typed, status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining, - paid_at: newStatus === 'paid' ? new Date().toISOString() : null, + paid_at: newStatus === 'paid' ? paidAt : null, payment_date: paymentDate, payment_amount: paymentAmount, would_create_payment_journal_entry: true, @@ -425,7 +424,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string status: newStatus, remaining_amount: newRemaining, paid_amount: newPaidAmount, - paid_at: newStatus === 'paid' ? new Date().toISOString() : null, + paid_at: newStatus === 'paid' ? paidAt : null, payment_journal_entry_id: journalEntryId, }) .eq('company_id', ctx.companyId!) @@ -532,7 +531,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string await eventBus.emit({ type: 'supplier_invoice.paid', payload: { - supplierInvoice: typed as unknown as SupplierInvoice, + supplierInvoice: { + ...typed, + paid_at: newStatus === 'paid' ? paidAt : (typed.paid_at ?? null), + } as unknown as SupplierInvoice, paymentAmount, companyId: ctx.companyId!, userId: ctx.userId, diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/__tests__/route.test.ts new file mode 100644 index 00000000..d0d831b7 --- /dev/null +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -0,0 +1,253 @@ +/** + * Regression coverage for the public customer-invoice bank-match route. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `match-invoice route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: vi.fn().mockResolvedValue({ id: 'je-1' }), + findFiscalPeriod: vi.fn().mockResolvedValue('fp-1'), + reverseEntry: vi.fn(), +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { createJournalEntry as mockedCreateJournalEntry } from '@/lib/bookkeeping/engine' +import { eventBus } from '@/lib/events/bus' +import { POST as matchInvoice } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType +const mockCreateJournalEntry = mockedCreateJournalEntry as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +type RecordedCall = { table: string; method: string; args: unknown[] } + +function makeFlexibleSupabase( + byTable: Record, + calls?: RecordedCall[], +) { + const queues = new Map() + for (const [table, value] of Object.entries(byTable)) { + queues.set(table, Array.isArray(value) ? [...value] : [value]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (value: unknown) => void) => { + const queue = queues.get(table) + const next = queue && queue.length > 1 + ? queue.shift()! + : (queue?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (...args: unknown[]) => { + calls?.push({ table, method: String(prop), args }) + return buildChain(table) + } + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const INVOICE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const USER_ID = 'user-1' + +function makeRequest(url: string, body?: unknown): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-1010-4abc-8def-1234567890ab', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +const TRANSACTION = { + id: TX_ID, + company_id: COMPANY_ID, + amount: 12500, + currency: 'SEK', + amount_sek: null, + exchange_rate: null, + date: '2024-06-15', + invoice_id: null, + journal_entry_id: null, + cash_account_id: null, + category: null, +} +const SENT_INVOICE = { + id: INVOICE_ID, + invoice_number: '2024-0001', + status: 'sent', + document_type: 'invoice', + currency: 'SEK', + exchange_rate: null, + total: 12500, + remaining_amount: 12500, + paid_amount: 0, + credited_invoice_id: null, + journal_entry_id: null, + customer: { name: 'Acme AB' }, +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['transactions:write'], + mode: 'live', + }) +}) + +describe('POST /api/v1/companies/:companyId/transactions/:id/match-invoice', () => { + it('persists and returns the bank transaction date as paid_at', async () => { + const calls: RecordedCall[] = [] + const matchedHandler = vi.fn() + eventBus.on('invoice.match_confirmed', matchedHandler) + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { data: TRANSACTION, error: null }, + invoices: [ + { data: SENT_INVOICE, error: null }, + { data: [{ id: INVOICE_ID }], error: null }, + ], + company_settings: { + data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, + error: null, + }, + }, + calls, + ), + ) + + const response = await matchInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`, + { invoice_id: INVOICE_ID }, + ), + detailParams(COMPANY_ID, TX_ID), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.invoice_status).toBe('paid') + expect(body.data.paid_at).toBe('2024-06-15T12:00:00Z') + expect(mockCreateJournalEntry).toHaveBeenCalled() + const invoiceUpdate = calls.find( + (call) => call.table === 'invoices' && call.method === 'update', + ) + expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2024-06-15T12:00:00Z' }) + expect(matchedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + invoice: expect.objectContaining({ + status: 'paid', + paid_at: '2024-06-15T12:00:00Z', + paid_amount: 12500, + remaining_amount: 0, + }), + transaction: expect.objectContaining({ + invoice_id: INVOICE_ID, + journal_entry_id: 'je-1', + }), + }), + ) + }) + + it('returns 401 when no bearer token is supplied', async () => { + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + const response = await matchInvoice( + new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem4041-4041-4abc-8def-1234567890ab', + }, + body: JSON.stringify({ invoice_id: INVOICE_ID }), + }, + ), + detailParams(COMPANY_ID, TX_ID), + ) + + expect(response.status).toBe(401) + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + + it('returns 400 VALIDATION_ERROR when invoice_id is missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + const response = await matchInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`, + {}, + ), + detailParams(COMPANY_ID, TX_ID), + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 404 when the invoice does not belong to the company', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { data: TRANSACTION, error: null }, + invoices: { data: null, error: null }, + }), + ) + const response = await matchInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`, + { invoice_id: INVOICE_ID }, + ), + detailParams(COMPANY_ID, TX_ID), + ) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('MATCH_INVOICE_NOT_FOUND') + }) +}) diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index d70aadac..313ef9e7 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -42,6 +42,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log' import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { paidAtFromDate } from '@/lib/invoices/paid-at' import { eventBus } from '@/lib/events/bus' import type { Currency, EntityType, Invoice, Transaction } from '@/types' @@ -409,6 +410,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan + const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null if (transaction.journal_entry_id) { try { @@ -432,8 +434,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string } } - const now = new Date().toISOString() - const { data: settings } = await ctx.supabase .from('company_settings') .select('accounting_method, entity_type') @@ -675,7 +675,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string .from('invoices') .update({ status: newStatus, - paid_at: isFullyPaid ? now : null, + paid_at: paidAt, paid_amount: newPaidAmount, remaining_amount: newRemaining, }) @@ -798,8 +798,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string eventBus.emit({ type: 'invoice.match_confirmed', payload: { - invoice: invoice as Invoice, - transaction: transaction as Transaction, + invoice: { + ...invoice, + status: newStatus, + paid_at: paidAt, + paid_amount: newPaidAmount, + remaining_amount: newRemaining, + } as Invoice, + transaction: { + ...transaction, + invoice_id, + potential_invoice_id: null, + journal_entry_id: journalEntryId, + is_business: true, + ...(existingTxCategory ? { category: existingTxCategory } : {}), + } as Transaction, userId: ctx.userId, companyId: ctx.companyId!, }, @@ -812,7 +825,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string { success: true, invoice_status: newStatus, - paid_at: isFullyPaid ? now : null, + paid_at: paidAt, paid_amount: newPaidAmount, remaining_amount: newRemaining, journal_entry_id: journalEntryId, diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts new file mode 100644 index 00000000..6933279d --- /dev/null +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts @@ -0,0 +1,253 @@ +/** + * Regression coverage for the public supplier-invoice bank-match route. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `match-supplier-invoice route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoicePaymentEntry: vi.fn().mockResolvedValue({ id: 'je-1' }), + createSupplierInvoiceCashEntry: vi.fn().mockResolvedValue({ id: 'je-1' }), +})) +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: vi.fn().mockResolvedValue({ id: 'je-1' }), + findFiscalPeriod: vi.fn().mockResolvedValue('fp-1'), + reverseEntry: vi.fn(), +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { createSupplierInvoicePaymentEntry as mockedCreatePaymentEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { eventBus } from '@/lib/events/bus' +import { POST as matchSupplierInvoice } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType +const mockCreatePaymentEntry = mockedCreatePaymentEntry as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +type RecordedCall = { table: string; method: string; args: unknown[] } + +function makeFlexibleSupabase( + byTable: Record, + calls?: RecordedCall[], +) { + const queues = new Map() + for (const [table, value] of Object.entries(byTable)) { + queues.set(table, Array.isArray(value) ? [...value] : [value]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (value: unknown) => void) => { + const queue = queues.get(table) + const next = queue && queue.length > 1 + ? queue.shift()! + : (queue?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (...args: unknown[]) => { + calls?.push({ table, method: String(prop), args }) + return buildChain(table) + } + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const SI_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const USER_ID = 'user-1' + +function makeRequest(url: string, body?: unknown): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-1010-4abc-8def-1234567890ab', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +const TRANSACTION = { + id: TX_ID, + company_id: COMPANY_ID, + amount: -1000, + currency: 'SEK', + amount_sek: null, + exchange_rate: null, + date: '2026-05-12', + supplier_invoice_id: null, + journal_entry_id: null, + cash_account_id: null, + document_id: null, +} +const REGISTERED_INVOICE = { + id: SI_ID, + supplier_invoice_number: 'F-2026001', + status: 'registered', + currency: 'SEK', + exchange_rate: null, + total: 1000, + total_sek: 1000, + remaining_amount: 1000, + paid_amount: 0, + registration_journal_entry_id: null, + supplier: { name: 'Leverantoren AB', supplier_type: 'swedish_business' }, + items: [], +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['transactions:write'], + mode: 'live', + }) +}) + +describe('POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice', () => { + it('persists the bank transaction date as paid_at', async () => { + const calls: RecordedCall[] = [] + const matchedHandler = vi.fn() + eventBus.on('supplier_invoice.match_confirmed', matchedHandler) + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { data: TRANSACTION, error: null }, + supplier_invoices: [ + { data: REGISTERED_INVOICE, error: null }, + { data: [{ id: SI_ID }], error: null }, + ], + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + }, + calls, + ), + ) + + const response = await matchSupplierInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`, + { supplier_invoice_id: SI_ID }, + ), + detailParams(COMPANY_ID, TX_ID), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.invoice_status).toBe('paid') + expect(mockCreatePaymentEntry).toHaveBeenCalled() + const invoiceUpdate = calls.find( + (call) => call.table === 'supplier_invoices' && call.method === 'update', + ) + expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' }) + expect(matchedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + supplierInvoice: expect.objectContaining({ + status: 'paid', + paid_at: '2026-05-12T12:00:00Z', + paid_amount: 1000, + remaining_amount: 0, + }), + transaction: expect.objectContaining({ + supplier_invoice_id: SI_ID, + journal_entry_id: 'je-1', + }), + }), + ) + }) + + it('returns 401 when no bearer token is supplied', async () => { + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + const response = await matchSupplierInvoice( + new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem4041-4041-4abc-8def-1234567890ab', + }, + body: JSON.stringify({ supplier_invoice_id: SI_ID }), + }, + ), + detailParams(COMPANY_ID, TX_ID), + ) + + expect(response.status).toBe(401) + expect(mockCreatePaymentEntry).not.toHaveBeenCalled() + }) + + it('returns 400 VALIDATION_ERROR when supplier_invoice_id is missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + const response = await matchSupplierInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`, + {}, + ), + detailParams(COMPANY_ID, TX_ID), + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 404 when the supplier invoice does not belong to the company', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { data: TRANSACTION, error: null }, + supplier_invoices: { data: null, error: null }, + }), + ) + const response = await matchSupplierInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`, + { supplier_invoice_id: SI_ID }, + ), + detailParams(COMPANY_ID, TX_ID), + ) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('MATCH_SI_NOT_FOUND') + }) +}) diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts index e15853aa..ca11304f 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts @@ -24,6 +24,7 @@ import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-inv import { getErrorMessage } from '@/lib/errors/get-error-message' import { logMatchEvent } from '@/lib/invoices/match-log' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { paidAtFromDate } from '@/lib/invoices/paid-at' import { eventBus } from '@/lib/events/bus' import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types' @@ -266,8 +267,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string } } - const now = new Date().toISOString() - const { data: settings } = await ctx.supabase .from('company_settings') .select('accounting_method') @@ -407,6 +406,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string Math.round((invoice.paid_amount + paymentAmountInvoiceCurrency) * 100) / 100 const isFullyPaid = newRemaining <= 0 const newStatus = isFullyPaid ? 'paid' : 'partially_paid' + const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null const { data: updatedRows, error: updateInvErr } = await ctx.supabase .from('supplier_invoices') @@ -414,7 +414,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string status: newStatus, remaining_amount: newRemaining, paid_amount: newPaidAmount, - paid_at: isFullyPaid ? now : null, + paid_at: paidAt, payment_journal_entry_id: journalEntryId, transaction_id: txId, }) @@ -538,8 +538,22 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string eventBus.emit({ type: 'supplier_invoice.match_confirmed', payload: { - supplierInvoice: invoice as SupplierInvoice, - transaction: transaction as Transaction, + supplierInvoice: { + ...invoice, + status: newStatus, + remaining_amount: newRemaining, + paid_amount: newPaidAmount, + paid_at: paidAt, + payment_journal_entry_id: journalEntryId, + transaction_id: txId, + } as SupplierInvoice, + transaction: { + ...transaction, + supplier_invoice_id, + potential_supplier_invoice_id: null, + journal_entry_id: journalEntryId, + is_business: true, + } as Transaction, userId: ctx.userId, companyId: ctx.companyId!, }, diff --git a/lib/invoices/__tests__/paid-at.test.ts b/lib/invoices/__tests__/paid-at.test.ts new file mode 100644 index 00000000..5ba3c58d --- /dev/null +++ b/lib/invoices/__tests__/paid-at.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { paidAtFromDate } from '@/lib/invoices/paid-at' + +function dateInTimeZone(timestamp: string, timeZone: string): string { + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(timestamp)) + const values = Object.fromEntries(parts.map(({ type, value }) => [type, value])) + return `${values.year}-${values.month}-${values.day}` +} + +describe('paidAtFromDate', () => { + it('anchors date-only payments at UTC noon', () => { + expect(paidAtFromDate('2026-05-12')).toBe('2026-05-12T12:00:00Z') + }) + + it.each(['America/New_York', 'Europe/Stockholm', 'UTC'])( + 'renders the original payment date in %s', + (timeZone) => { + const paidAt = paidAtFromDate('2026-05-12') + expect(dateInTimeZone(paidAt, timeZone)).toBe('2026-05-12') + }, + ) +}) diff --git a/lib/invoices/__tests__/settle-invoice-payment.test.ts b/lib/invoices/__tests__/settle-invoice-payment.test.ts index 17afa016..19ccde05 100644 --- a/lib/invoices/__tests__/settle-invoice-payment.test.ts +++ b/lib/invoices/__tests__/settle-invoice-payment.test.ts @@ -365,7 +365,7 @@ describe('settleInvoicePayment', () => { it('emits invoice.paid with the settled state', async () => { const handler = vi.fn() eventBus.on('invoice.paid', handler) - const { supabase, enqueue } = createQueuedMockSupabase() + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() enqueue({ data: [{ id: 'inv-1' }] }) await settleInvoicePayment(supabase as unknown as SupabaseClient, 'company-1', 'user-1', { @@ -377,8 +377,14 @@ describe('settleInvoicePayment', () => { expect.objectContaining({ companyId: 'company-1', paymentAmount: 1250, - invoice: expect.objectContaining({ id: 'inv-1', status: 'paid' }), + invoice: expect.objectContaining({ + id: 'inv-1', + status: 'paid', + paid_at: '2026-07-12T12:00:00Z', + }), }), ) + const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] + expect(invoiceUpdate).toMatchObject({ paid_at: '2026-07-12T12:00:00Z' }) }) }) diff --git a/lib/invoices/__tests__/voucher-matching.pg.test.ts b/lib/invoices/__tests__/voucher-matching.pg.test.ts index e1f607ca..94d426c2 100644 --- a/lib/invoices/__tests__/voucher-matching.pg.test.ts +++ b/lib/invoices/__tests__/voucher-matching.pg.test.ts @@ -333,19 +333,24 @@ describe('link_invoice_to_voucher RPC (atomic link: audit C2)', () => { expect(Number(result.remaining_amount)).toBe(0) const { rows: inv } = await getPool().query( - `SELECT status, paid_amount, remaining_amount FROM public.invoices WHERE id = $1`, + `SELECT status, paid_amount, remaining_amount, + paid_at = TIMESTAMPTZ '2026-05-05 12:00:00+00' AS paid_at_matches + FROM public.invoices WHERE id = $1`, [invoiceId], ) expect(inv[0].status).toBe('paid') expect(Number(inv[0].paid_amount)).toBe(1000) expect(Number(inv[0].remaining_amount)).toBe(0) + expect(inv[0].paid_at_matches).toBe(true) const { rows: pay } = await getPool().query( - `SELECT amount FROM public.invoice_payments WHERE invoice_id = $1 AND journal_entry_id = $2`, + `SELECT amount, payment_date = DATE '2026-05-05' AS payment_date_matches + FROM public.invoice_payments WHERE invoice_id = $1 AND journal_entry_id = $2`, [invoiceId, voucherId], ) expect(pay).toHaveLength(1) expect(Number(pay[0].amount)).toBe(1000) + expect(pay[0].payment_date_matches).toBe(true) }) it('links a partial payment as partially_paid with the right remaining', async () => { @@ -543,3 +548,74 @@ describe('link_invoice_to_voucher RPC (kontantmetoden: 19xx debit)', () => { expect(result.invoice_status).toBe('paid') }) }) + +describe('link_supplier_invoice_to_voucher RPC payment date', () => { + it('anchors paid_at at UTC noon on the voucher entry date', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + await insertCompanyMember({ companyId, userId }) + const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId }) + + const supplierId = 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')`, + [supplierId, userId, companyId], + ) + + const supplierInvoiceId = randomUUID() + const arrivalNumber = Number.parseInt(supplierInvoiceId.slice(0, 8), 16) % 2_000_000_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-04-01', '2026-05-01', '2026-04-01', + 'approved', 'SEK', 1000, 0, 1000, 0, 1000, + 'standard_25', false, false)`, + [ + supplierInvoiceId, + userId, + companyId, + supplierId, + arrivalNumber, + `LF-${supplierInvoiceId.slice(0, 8)}`, + ], + ) + + const voucherId = await seedVoucherDebitCredit({ + userId, + companyId, + fiscalPeriodId, + amount: 1000, + debitAccount: '2440', + creditAccount: '1930', + }) + + const { rows } = await getPool().query<{ result: RpcResult }>( + `SELECT public.link_supplier_invoice_to_voucher($1, $2, $3, $4, NULL) AS result`, + [supplierInvoiceId, voucherId, userId, companyId], + ) + expect(rows[0].result.ok).toBe(true) + expect(rows[0].result.invoice_status).toBe('paid') + + const { rows: invoiceRows } = await getPool().query<{ paid_at_matches: boolean }>( + `SELECT paid_at = TIMESTAMPTZ '2026-05-05 12:00:00+00' AS paid_at_matches + FROM public.supplier_invoices WHERE id = $1`, + [supplierInvoiceId], + ) + expect(invoiceRows[0].paid_at_matches).toBe(true) + + const { rows: paymentRows } = await getPool().query<{ payment_date_matches: boolean }>( + `SELECT payment_date = DATE '2026-05-05' AS payment_date_matches + FROM public.supplier_invoice_payments + WHERE supplier_invoice_id = $1 AND journal_entry_id = $2`, + [supplierInvoiceId, voucherId], + ) + expect(paymentRows).toHaveLength(1) + expect(paymentRows[0].payment_date_matches).toBe(true) + }) +}) diff --git a/lib/invoices/paid-at.ts b/lib/invoices/paid-at.ts new file mode 100644 index 00000000..09dd91f5 --- /dev/null +++ b/lib/invoices/paid-at.ts @@ -0,0 +1,12 @@ +/** + * Convert a date-only invoice payment date to the canonical `paid_at` value. + * + * `paid_at` is a Postgres `timestamptz`, while payment and transaction dates + * are calendar dates without a time zone. UTC noon keeps that calendar date + * stable in UTC, Europe/Stockholm, and every negative UTC offset through + * UTC-12. Midnight UTC would display as the previous day in American time + * zones when passed through the shared local-time date formatter. + */ +export function paidAtFromDate(paymentDate: string): string { + return `${paymentDate}T12:00:00Z` +} diff --git a/lib/invoices/settle-invoice-payment.ts b/lib/invoices/settle-invoice-payment.ts index 3898df5f..2ecd7897 100644 --- a/lib/invoices/settle-invoice-payment.ts +++ b/lib/invoices/settle-invoice-payment.ts @@ -9,6 +9,7 @@ import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { paidAtFromDate } from '@/lib/invoices/paid-at' import { eventBus } from '@/lib/events' import type { CreateJournalEntryInput, Customer, EntityType, Invoice } from '@/types' @@ -112,8 +113,6 @@ export async function settleInvoicePayment( } } - const now = new Date().toISOString() - // Drive the JE shape from the invoice's actual booking state, not from // the current accounting_method setting. If the invoice was booked at // send (Dr 1510 / Cr 30xx + VAT), the payment MUST clear 1510: @@ -147,6 +146,7 @@ export async function settleInvoicePayment( } } const { newPaidAmount, newRemaining, newStatus } = payment.plan + const paidAt = newStatus === 'paid' ? paidAtFromDate(paymentDate) : null const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' let journalEntryId: string | null = null @@ -249,7 +249,7 @@ export async function settleInvoicePayment( status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining, - ...(newStatus === 'paid' ? { paid_at: now } : {}), + ...(paidAt ? { paid_at: paidAt } : {}), }) .eq('id', invoice.id) .eq('company_id', companyId) @@ -305,7 +305,7 @@ export async function settleInvoicePayment( status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining, - paid_at: newStatus === 'paid' ? now : invoice.paid_at, + paid_at: paidAt ?? invoice.paid_at, } as Invoice, companyId, userId, @@ -323,6 +323,6 @@ export async function settleInvoicePayment( newPaidAmount, newRemaining, journalEntryId, - paidAt: newStatus === 'paid' ? now : null, + paidAt, } } diff --git a/lib/pending-operations/__tests__/mark-invoice-paid.test.ts b/lib/pending-operations/__tests__/mark-invoice-paid.test.ts index 1c176e47..cdfb46c1 100644 --- a/lib/pending-operations/__tests__/mark-invoice-paid.test.ts +++ b/lib/pending-operations/__tests__/mark-invoice-paid.test.ts @@ -105,7 +105,7 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () => }) it('zeroes remaining_amount and emits invoice.paid on full payment (issue #825)', async () => { - const { supabase, enqueue } = createQueuedMockSupabase() + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { @@ -140,9 +140,17 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () => companyId: 'company-1', userId: 'user-1', paymentAmount: 525, - invoice: expect.objectContaining({ id: 'inv-1', status: 'paid', remaining_amount: 0, paid_amount: 525 }), + invoice: expect.objectContaining({ + id: 'inv-1', + status: 'paid', + remaining_amount: 0, + paid_amount: 525, + paid_at: '2026-03-30T12:00:00Z', + }), }), ) + const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] + expect(invoiceUpdate).toMatchObject({ paid_at: '2026-03-30T12:00:00Z' }) // Issue #1259: the invoice is settled, so no transaction may keep pointing // at it as a match suggestion. This flow is not driven by a bank diff --git a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts index bd8d5bc7..dc220d7f 100644 --- a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts +++ b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts @@ -104,7 +104,9 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r }) it('credits the payment JE to the transaction\'s own linked cash account, not a hardcoded 1930', async () => { - const { supabase, enqueue } = createQueuedMockSupabase() + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + const matchedHandler = vi.fn() + eventBus.on('invoice.match_confirmed', matchedHandler) enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { @@ -157,6 +159,22 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r '1940', ) expect(mockCreateCashEntry).not.toHaveBeenCalled() + const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] + expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' }) + expect(matchedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + invoice: expect.objectContaining({ + status: 'paid', + paid_at: '2026-05-12T12:00:00Z', + paid_amount: 12500, + remaining_amount: 0, + }), + transaction: expect.objectContaining({ + invoice_id: 'inv-1', + journal_entry_id: 'je-1', + }), + }), + ) // Issue #1259: the invoice is settled, so every OTHER transaction still // carrying a suggestion pointer at it is retired; this op's own row is // cleared by the link update. diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 704cc621..b0fe9895 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -58,6 +58,7 @@ import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates' import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { paidAtFromDate } from '@/lib/invoices/paid-at' import { clearSettledBatchAllocationSuggestions, type BatchAllocationResult, @@ -2012,7 +2013,7 @@ async function commitMarkInvoicePaid( } } - const now = new Date().toISOString() + const paidAt = newStatus === 'paid' ? paidAtFromDate(paymentDate) : null // CAS guard: only flip from a payable status so a concurrently-settled // invoice no-ops here instead of double-booking the payment. const { data: updateResult, error: updateError } = await supabase @@ -2021,7 +2022,7 @@ async function commitMarkInvoicePaid( status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining, - ...(newStatus === 'paid' ? { paid_at: now } : {}), + ...(paidAt ? { paid_at: paidAt } : {}), }) .eq('id', invoiceId) .eq('company_id', companyId) @@ -2076,7 +2077,7 @@ async function commitMarkInvoicePaid( status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining, - paid_at: newStatus === 'paid' ? now : (invoice as Invoice).paid_at, + paid_at: paidAt ?? (invoice as Invoice).paid_at, } as Invoice, companyId, userId, @@ -2496,8 +2497,7 @@ async function commitMatchTransactionInvoice( } } const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan - - const now = new Date().toISOString() + const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null // Read-only prevalidation, deliberately hoisted ABOVE the irreversible // storno below (issue #842): resolveSettlementAccount can throw @@ -2573,7 +2573,7 @@ async function commitMatchTransactionInvoice( .from('invoices') .update({ status: newStatus, - paid_at: isFullyPaid ? now : null, + paid_at: paidAt, paid_amount: newPaidAmount, remaining_amount: newRemaining, }) @@ -2635,7 +2635,25 @@ async function commitMatchTransactionInvoice( try { await eventBus.emit({ type: 'invoice.match_confirmed', - payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId, companyId }, + payload: { + invoice: { + ...(invoice as Invoice), + status: newStatus, + paid_at: paidAt, + paid_amount: newPaidAmount, + remaining_amount: newRemaining, + } as Invoice, + transaction: { + ...(transaction as Transaction), + invoice_id: invoiceId, + potential_invoice_id: null, + journal_entry_id: journalEntryId, + is_business: true, + category: 'income_services', + } as Transaction, + userId, + companyId, + }, }) } catch { /* non-critical */ } diff --git a/lib/transactions/link-journal-entry.ts b/lib/transactions/link-journal-entry.ts index dfbdcd61..4970b9c1 100644 --- a/lib/transactions/link-journal-entry.ts +++ b/lib/transactions/link-journal-entry.ts @@ -16,6 +16,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { eventBus } from '@/lib/events/bus' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { paidAtFromDate } from '@/lib/invoices/paid-at' import { logMatchEvent } from '@/lib/invoices/match-log' import { createLogger } from '@/lib/logger' import type { Invoice, Transaction } from '@/types' @@ -332,14 +333,14 @@ export async function linkTransactionToJournalEntry( } } - const now = new Date().toISOString() + const paidAt = invoice && isFullyPaid ? paidAtFromDate(transaction.date) : null if (invoice && invoiceId) { const { data: updatedRows, error: updateInvError } = await supabase .from('invoices') .update({ status: newStatus, - paid_at: isFullyPaid ? now : null, + paid_at: paidAt, paid_amount: newPaidAmount, remaining_amount: newRemaining, }) @@ -428,8 +429,21 @@ export async function linkTransactionToJournalEntry( eventBus.emit({ type: 'invoice.match_confirmed', payload: { - invoice: invoice as Invoice, - transaction: transaction as Transaction, + invoice: { + ...invoice, + status: newStatus, + paid_at: paidAt, + paid_amount: newPaidAmount, + remaining_amount: newRemaining, + } as Invoice, + transaction: { + ...transaction, + journal_entry_id: journalEntryId, + invoice_id: invoiceId, + potential_invoice_id: null, + potential_supplier_invoice_id: null, + is_business: true, + } as Transaction, userId, companyId, }, diff --git a/supabase/migrations/20260801204551_use_invoice_payment_dates_for_paid_at.sql b/supabase/migrations/20260801204551_use_invoice_payment_dates_for_paid_at.sql new file mode 100644 index 00000000..3c0d0826 --- /dev/null +++ b/supabase/migrations/20260801204551_use_invoice_payment_dates_for_paid_at.sql @@ -0,0 +1,960 @@ +-- Preserve the business payment date in paid_at without UTC-midnight +-- rendering as the previous day in negative-offset time zones. Journal and +-- payment dates remain unchanged; only the date-only timestamptz projection is +-- anchored at UTC noon. + +CREATE OR REPLACE FUNCTION public.link_invoice_to_voucher( + p_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_ar_credit_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; + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); + v_acting_user uuid := p_user_id; + v_accounting_method text; + -- Unit resolution (new): the currency the invoice's amounts are quoted in, + -- plus the matched-side lines that cannot be expressed in it. + v_invoice_currency text; + v_account_prefix text; + v_unreadable_count integer := 0; + v_unreadable_currency text; +BEGIN + -- 0. Tenant guard (mirrors 20260611140000): anon/authenticated may only act + -- on their own companies; service_role / direct access bypasses. The + -- NULL-safe caller_is_company_member() form (20260703180000), not the + -- raw NOT-IN-over-user_company_ids() shape: that one skips the deny + -- branch on UNKNOWN and is banned by the pg-real ratchet + -- (tests/pg/null-safe-tenant-guards.pg.test.ts, which scans prosrc: + -- comments included, so the banned shape must not even be spelled out + -- here). + IF v_jwt_role IN ('anon', 'authenticated') THEN + IF NOT public.caller_is_company_member(p_company_id) THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND'); + END IF; + -- Attribution: the JWT sub is authoritative for user-session callers: + -- p_user_id cannot point the payment row at someone else. + v_acting_user := coalesce( + (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid, + p_user_id + ); + END IF; + + IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_VOUCHER_NOTES_TOO_LONG', + 'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes)) + ); + END IF; + + -- 1. Lock the 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.invoices + WHERE id = p_invoice_id AND company_id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND'); + END IF; + + IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_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_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_VOUCHER_VOUCHER_NOT_FOUND'); + END IF; + + IF v_voucher.status <> 'posted' THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_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_VOUCHER_NO_AR_CREDIT', + 'details', jsonb_build_object('source_type', v_voucher.source_type) + ); + END IF; + + -- 3. Sum the matched amount across the voucher's lines, EXPRESSED IN THE + -- INVOICE'S CURRENCY. Branch on the company's accounting method (defaults + -- to accrual when no settings row). + SELECT cs.accounting_method INTO v_accounting_method + FROM public.company_settings cs + WHERE cs.company_id = p_company_id; + v_accounting_method := COALESCE(v_accounting_method, 'accrual'); + + -- `invoices.currency` is `text default 'SEK'` and therefore NULLABLE; a + -- missing code has always meant kronor, and must not be read as "not SEK". + v_invoice_currency := COALESCE(v_invoice.currency, 'SEK'); + v_account_prefix := CASE WHEN v_accounting_method = 'cash' THEN '19' ELSE '151' END; + + IF v_invoice_currency = 'SEK' THEN + -- VERBATIM from 20260620130000. The ledger columns are kronor already, so + -- the document label on the line is irrelevant here. + IF v_accounting_method = 'cash' THEN + -- Kontantmetoden: the payment verifikat debits a liquid-funds account (19xx). + SELECT COALESCE(SUM(debit_amount), 0), MAX(currency) + INTO v_ar_credit_total, v_line_currency + FROM public.journal_entry_lines + WHERE journal_entry_id = p_journal_entry_id + AND account_number LIKE '19%' + AND debit_amount > 0; + ELSE + -- Faktureringsmetoden: the payment verifikat credits the AR account (151x). + SELECT COALESCE(SUM(credit_amount), 0), MAX(currency) + INTO v_ar_credit_total, v_line_currency + FROM public.journal_entry_lines + WHERE journal_entry_id = p_journal_entry_id + AND account_number LIKE '151%' + AND credit_amount > 0; + END IF; + ELSE + -- Foreign invoice: `amount_in_currency` is the only column quoted in the + -- invoice's currency. Magnitude from ABS() because a handful of production + -- rows store the foreign figure negatively while the debit/credit side is + -- authoritative, and that side is already pinned by the `> 0` predicate. + SELECT + COALESCE(SUM(ABS(l.amount_in_currency)) FILTER ( + WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL + ), 0), + MAX(l.currency) FILTER ( + WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL + ), + COUNT(*) FILTER ( + WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL + ), + MIN(l.currency) FILTER ( + WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL + ) + INTO v_ar_credit_total, v_line_currency, v_unreadable_count, v_unreadable_currency + FROM public.journal_entry_lines l + WHERE l.journal_entry_id = p_journal_entry_id + AND l.account_number LIKE v_account_prefix || '%' + AND (CASE WHEN v_accounting_method = 'cash' THEN l.debit_amount ELSE l.credit_amount END) > 0; + + -- Fail CLOSED on a matched-side line we cannot read in the invoice's + -- currency: summing only the readable ones would understate the voucher. + IF COALESCE(v_unreadable_count, 0) > 0 THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_VOUCHER_CURRENCY_MISMATCH', + 'details', jsonb_build_object( + 'invoice_currency', v_invoice.currency, + 'line_currency', v_unreadable_currency + ) + ); + END IF; + END IF; + + v_ar_credit_total := ROUND(v_ar_credit_total * 100) / 100; + + IF v_ar_credit_total <= 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_NO_AR_CREDIT'); + END IF; + + -- Label guard, still load-bearing, but no longer as a unit check: + -- v_ar_credit_total is already in the invoice's currency. What it catches + -- now is a counterparty discriminator, a matched line stamped with some + -- other document's currency. Always passes on a foreign invoice, because + -- only same-labelled lines could be read at all. Both sides compare the + -- RESOLVED v_invoice_currency, never the raw nullable column: with the raw + -- column, a legacy NULL-currency invoice (which has always meant SEK) hit + -- 'SEK' IS DISTINCT FROM NULL = true and an ordinary domestic payment + -- raised LINK_VOUCHER_CURRENCY_MISMATCH forever. + IF COALESCE(v_line_currency, v_invoice_currency) IS DISTINCT FROM v_invoice_currency THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_VOUCHER_CURRENCY_MISMATCH', + 'details', jsonb_build_object( + 'invoice_currency', v_invoice.currency, + 'line_currency', v_line_currency + ) + ); + END IF; + + -- Both sides are now in the invoice's currency. + IF v_ar_credit_total > v_remaining + 0.005 THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING', + 'details', jsonb_build_object( + 'ar_credit', v_ar_credit_total, + 'remaining', ROUND(v_remaining * 100) / 100 + ) + ); + END IF; + + -- 4. Reject re-link of the same voucher to the same invoice. Authoritative + -- under the FOR UPDATE lock; the partial unique index + -- idx_invoice_payments_je_inv_unique stays as the last line of defence + -- for non-RPC writers. + IF EXISTS ( + SELECT 1 FROM public.invoice_payments + WHERE company_id = p_company_id + AND invoice_id = p_invoice_id + AND journal_entry_id = p_journal_entry_id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_ALREADY_LINKED'); + END IF; + + -- 5. Compute the advance. + v_payment_amount := LEAST(v_ar_credit_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.invoices + SET status = v_new_status, + paid_at = CASE WHEN v_is_fully_paid THEN + ((v_voucher.entry_date::timestamp + interval '12 hours') AT TIME ZONE 'UTC') + ELSE paid_at END, + paid_amount = v_new_paid, + remaining_amount = v_new_remaining, + updated_at = v_now + WHERE id = p_invoice_id; + + -- The payment row persists the RESOLVED currency: writing the raw column + -- would store NULL for a legacy NULL-currency invoice, and the payment's + -- unit is a fact this row must state, not inherit as "unknown". + INSERT INTO public.invoice_payments ( + user_id, company_id, invoice_id, payment_date, amount, currency, + exchange_rate, journal_entry_id, transaction_id, notes + ) VALUES ( + v_acting_user, p_company_id, p_invoice_id, v_voucher.entry_date, + v_payment_amount, v_invoice_currency, v_invoice.exchange_rate, + 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, + 'payment_date', v_voucher.entry_date + ); +END; +$$; + +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; + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); + v_acting_user uuid := p_user_id; + -- Unit resolution (new), as in link_invoice_to_voucher above. + v_invoice_currency text; + v_unreadable_count integer := 0; + v_unreadable_currency text; +BEGIN + -- Tenant guard (mirrors 20260611140000): anon/authenticated may only act on + -- their own companies; service_role / direct access bypasses. NULL-safe + -- caller_is_company_member() form, as in link_invoice_to_voucher above. + IF v_jwt_role IN ('anon', 'authenticated') THEN + IF NOT public.caller_is_company_member(p_company_id) THEN + RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND'); + END IF; + -- Attribution: the JWT sub is authoritative for user-session callers: + -- p_user_id cannot point the payment row at someone else. + v_acting_user := coalesce( + (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid, + p_user_id + ); + END IF; + + IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'LINK_SI_VOUCHER_NOTES_TOO_LONG', + 'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes)) + ); + END IF; + + 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 the AP debit across the full 244x range, EXPRESSED IN THE INVOICE'S + -- CURRENCY. `supplier_invoices.currency` is NOT NULL DEFAULT 'SEK', but the + -- COALESCE keeps this symmetric with the customer side. + v_invoice_currency := COALESCE(v_invoice.currency, 'SEK'); + + IF v_invoice_currency = 'SEK' THEN + -- VERBATIM from 20260615120000: the ledger column is kronor already. + 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; + ELSE + SELECT + COALESCE(SUM(ABS(l.amount_in_currency)) FILTER ( + WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL + ), 0), + MAX(l.currency) FILTER ( + WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL + ), + COUNT(*) FILTER ( + WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL + ), + MIN(l.currency) FILTER ( + WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL + ) + INTO v_ap_debit_total, v_line_currency, v_unreadable_count, v_unreadable_currency + FROM public.journal_entry_lines l + WHERE l.journal_entry_id = p_journal_entry_id + AND l.account_number LIKE '244%' + AND l.debit_amount > 0; + + IF COALESCE(v_unreadable_count, 0) > 0 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_unreadable_currency + )); + END IF; + END IF; + + 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; + + -- Label guard: a counterparty discriminator, not a unit check. Compares the + -- RESOLVED currency on both sides, as in link_invoice_to_voucher above. + 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; + + -- Both sides are now in the invoice's currency. + 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_voucher.entry_date::timestamp + interval '12 hours') AT TIME ZONE 'UTC') + 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 ( + v_acting_user, 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; +$$; + +CREATE OR REPLACE FUNCTION public.match_batch_allocate( + p_tx_id uuid, + p_allocations jsonb, + p_company_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_tx RECORD; + v_tx_abs numeric; + v_tx_date_short text; + v_allocation jsonb; + v_alloc_index int := 0; + v_kind text; + v_invoice_id uuid; + v_supplier_invoice_id uuid; + v_alloc_amount numeric; + v_total_allocated numeric := 0; + v_has_customer boolean := false; + v_has_supplier boolean := false; + v_seen_ids text[] := ARRAY[]::text[]; + v_target_id text; + v_invoice RECORD; + v_si_invoice RECORD; + v_supplier_name text; + v_supplier_invoice_number text; + v_invoice_number text; + v_fiscal_period_id uuid; + v_period_is_closed boolean; + v_period_locked_at timestamptz; + v_journal_entry_id uuid := gen_random_uuid(); + v_voucher_series text := 'A'; + v_voucher_number int; + v_entry_description text; + v_source_type text; + v_line_sort_order int := 0; + v_new_paid numeric; + v_new_remaining numeric; + v_new_status text; + v_now timestamptz := now(); + v_payment_id uuid; + v_results jsonb := '[]'::jsonb; + v_inv_remaining numeric; + v_inv_currency text; + v_inv_fx_rate numeric; + v_inv_total numeric; + v_booked_sek numeric; + v_fx_diff numeric; + v_paid_in_inv_currency numeric; + v_payment_rate numeric; -- round-3 (swedish-compliance traceability) + v_inv_number_short text; + v_caller uuid := auth.uid(); +BEGIN + IF v_caller IS NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED'); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.company_members + WHERE user_id = v_caller AND company_id = p_company_id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED'); + END IF; + + SELECT * INTO v_tx FROM public.transactions + WHERE id = p_tx_id AND company_id = p_company_id FOR UPDATE; + IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_NOT_FOUND'); END IF; + IF v_tx.journal_entry_id IS NOT NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ALREADY_BOOKED', + 'details', jsonb_build_object('journal_entry_id', v_tx.journal_entry_id)); + END IF; + IF v_tx.amount = 0 THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ZERO_AMOUNT'); END IF; + v_tx_abs := ABS(v_tx.amount); + v_tx_date_short := LEFT(v_tx.date::text, 10); + + IF jsonb_typeof(p_allocations) IS DISTINCT FROM 'array' OR jsonb_array_length(p_allocations) = 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_ALLOCATIONS'); + END IF; + + FOR v_allocation IN + SELECT value FROM jsonb_array_elements(p_allocations) AS t(value) + ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '') + LOOP + v_kind := v_allocation->>'kind'; + v_alloc_amount := (v_allocation->>'amount')::numeric; + v_target_id := COALESCE(v_allocation->>'invoice_id', v_allocation->>'supplier_invoice_id'); + + IF v_alloc_amount IS NULL OR v_alloc_amount <= 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_AMOUNT', + 'details', jsonb_build_object('index', v_alloc_index, 'amount', v_alloc_amount)); + END IF; + IF v_target_id IS NOT NULL AND v_target_id = ANY(v_seen_ids) THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DUPLICATE_ALLOCATION', + 'details', jsonb_build_object('id', v_target_id, 'index', v_alloc_index)); + END IF; + IF v_target_id IS NOT NULL THEN v_seen_ids := array_append(v_seen_ids, v_target_id); END IF; + v_total_allocated := v_total_allocated + v_alloc_amount; + + IF v_kind = 'customer_invoice' THEN + v_has_customer := true; + v_invoice_id := (v_allocation->>'invoice_id')::uuid; + SELECT * INTO v_invoice FROM public.invoices + WHERE id = v_invoice_id AND company_id = p_company_id FOR UPDATE; + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_FOUND', + 'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id)); + END IF; + IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_OPEN', + 'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, 'status', v_invoice.status)); + END IF; + + v_inv_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total); + v_inv_currency := v_invoice.currency; + v_inv_fx_rate := v_invoice.exchange_rate; + + IF v_inv_currency = v_tx.currency THEN + IF v_alloc_amount > v_inv_remaining + 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT', + 'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, + 'requested', v_alloc_amount, 'remaining', v_inv_remaining)); + END IF; + ELSE + IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 OR v_inv_fx_rate >= 100000 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_RATE_MISSING', + 'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, + 'invoice_currency', v_inv_currency)); + END IF; + v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100; + IF ABS(v_alloc_amount - v_booked_sek) > v_booked_sek * 0.10 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_DEVIATION_TOO_LARGE', + 'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, + 'allocation_amount', v_alloc_amount, 'expected_sek', v_booked_sek)); + END IF; + END IF; + + ELSIF v_kind = 'supplier_invoice' THEN + v_has_supplier := true; + v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid; + SELECT * INTO v_si_invoice FROM public.supplier_invoices + WHERE id = v_supplier_invoice_id AND company_id = p_company_id FOR UPDATE; + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_FOUND', + 'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id)); + END IF; + IF v_si_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_OPEN', + 'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id, 'status', v_si_invoice.status)); + END IF; + + v_inv_remaining := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total); + v_inv_currency := v_si_invoice.currency; + v_inv_fx_rate := v_si_invoice.exchange_rate; + + IF v_inv_currency = v_tx.currency THEN + IF v_alloc_amount > v_inv_remaining + 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT', + 'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id, + 'requested', v_alloc_amount, 'remaining', v_inv_remaining)); + END IF; + ELSE + IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 OR v_inv_fx_rate >= 100000 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_RATE_MISSING', + 'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id, + 'invoice_currency', v_inv_currency)); + END IF; + v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100; + IF ABS(v_alloc_amount - v_booked_sek) > v_booked_sek * 0.10 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_DEVIATION_TOO_LARGE', + 'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id, + 'allocation_amount', v_alloc_amount, 'expected_sek', v_booked_sek)); + END IF; + END IF; + ELSE + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_KIND', + 'details', jsonb_build_object('index', v_alloc_index, 'kind', v_kind)); + END IF; + v_alloc_index := v_alloc_index + 1; + END LOOP; + + IF v_has_customer AND v_has_supplier THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_MIXED_KINDS_UNSUPPORTED'); + END IF; + + IF v_total_allocated > v_tx_abs + 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_EXCEEDS_TX', + 'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs)); + END IF; + IF v_total_allocated < v_tx_abs - 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_BELOW_TX', + 'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs)); + END IF; + + IF v_has_customer AND v_tx.amount <= 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH', + 'details', jsonb_build_object('expected', 'income', 'tx_amount', v_tx.amount)); + END IF; + IF v_has_supplier AND v_tx.amount >= 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH', + 'details', jsonb_build_object('expected', 'expense', 'tx_amount', v_tx.amount)); + END IF; + + SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at + FROM public.fiscal_periods + WHERE company_id = p_company_id AND v_tx.date BETWEEN period_start AND period_end + ORDER BY period_start DESC LIMIT 1; + IF v_fiscal_period_id IS NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_FISCAL_PERIOD', + 'details', jsonb_build_object('tx_date', v_tx.date)); + END IF; + IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BATCH_PERIOD_LOCKED', + 'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id, + 'is_closed', v_period_is_closed, 'locked_at', v_period_locked_at)); + END IF; + + v_entry_description := CASE WHEN v_has_customer THEN 'Samlingsinbetalning ' || v_tx_date_short ELSE 'Samlingsbetalning ' || v_tx_date_short END; + v_source_type := CASE WHEN v_has_customer THEN 'invoice_paid' ELSE 'supplier_invoice_paid' END; + + INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES + (v_journal_entry_id, v_caller, p_company_id, v_fiscal_period_id, 0, v_voucher_series, + v_tx.date, v_entry_description, v_source_type, 'draft'); + + v_alloc_index := 0; + FOR v_allocation IN + SELECT value FROM jsonb_array_elements(p_allocations) AS t(value) + ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '') + LOOP + v_alloc_amount := (v_allocation->>'amount')::numeric; + + IF v_has_customer THEN + v_invoice_id := (v_allocation->>'invoice_id')::uuid; + SELECT invoice_number, currency, exchange_rate, remaining_amount, total + INTO v_invoice_number, v_inv_currency, v_inv_fx_rate, v_inv_remaining, v_inv_total + FROM public.invoices + WHERE id = v_invoice_id AND company_id = p_company_id; + v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total); + v_inv_number_short := LEFT(COALESCE(v_invoice_number, ''), 32); + + IF v_inv_currency = v_tx.currency THEN + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, '1510', 0, v_alloc_amount, v_tx.currency, v_line_sort_order, + 'Faktura ' || v_inv_number_short); + v_line_sort_order := v_line_sort_order + 1; + ELSE + v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100; + v_fx_diff := ROUND((v_booked_sek - v_alloc_amount) * 100) / 100; + + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, '1510', 0, v_booked_sek, v_tx.currency, v_line_sort_order, + 'Faktura ' || v_inv_number_short || ' (' || v_inv_currency || ')'); + v_line_sort_order := v_line_sort_order + 1; + + IF ABS(v_fx_diff) > 0.005 THEN + IF v_fx_diff > 0 THEN + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, '7960', v_fx_diff, 0, v_tx.currency, v_line_sort_order, + 'Valutakursförlust ' || v_inv_number_short); + ELSE + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, '3960', 0, ABS(v_fx_diff), v_tx.currency, v_line_sort_order, + 'Valutakursvinst ' || v_inv_number_short); + END IF; + v_line_sort_order := v_line_sort_order + 1; + END IF; + END IF; + + ELSE + v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid; + SELECT si.supplier_invoice_number, s.name, si.currency, si.exchange_rate, + si.remaining_amount, si.total + INTO v_supplier_invoice_number, v_supplier_name, v_inv_currency, v_inv_fx_rate, + v_inv_remaining, v_inv_total + FROM public.supplier_invoices si LEFT JOIN public.suppliers s ON s.id = si.supplier_id + WHERE si.id = v_supplier_invoice_id AND si.company_id = p_company_id; + v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total); + v_inv_number_short := LEFT(COALESCE(v_supplier_invoice_number, ''), 32); + + IF v_inv_currency = v_tx.currency THEN + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, '2440', v_alloc_amount, 0, v_tx.currency, v_line_sort_order, + TRIM(BOTH ' - ' FROM COALESCE(v_supplier_name, '') || ' - ' || v_inv_number_short)); + v_line_sort_order := v_line_sort_order + 1; + ELSE + v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100; + v_fx_diff := ROUND((v_booked_sek - v_alloc_amount) * 100) / 100; + + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, '2440', v_booked_sek, 0, v_tx.currency, v_line_sort_order, + TRIM(BOTH ' - ' FROM + COALESCE(v_supplier_name, '') || ' - ' || v_inv_number_short + || ' (' || v_inv_currency || ')')); + v_line_sort_order := v_line_sort_order + 1; + + IF ABS(v_fx_diff) > 0.005 THEN + IF v_fx_diff > 0 THEN + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, '3960', 0, v_fx_diff, v_tx.currency, v_line_sort_order, + 'Valutakursvinst ' || v_inv_number_short); + ELSE + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, '7960', ABS(v_fx_diff), 0, v_tx.currency, v_line_sort_order, + 'Valutakursförlust ' || v_inv_number_short); + END IF; + v_line_sort_order := v_line_sort_order + 1; + END IF; + END IF; + END IF; + v_alloc_index := v_alloc_index + 1; + END LOOP; + + IF v_has_customer THEN + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, '1930', v_tx_abs, 0, v_tx.currency, v_line_sort_order, + 'Inbetalning ' || v_tx_date_short); + ELSE + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, '1930', 0, v_tx_abs, v_tx.currency, v_line_sort_order, + 'Utbetalning ' || v_tx_date_short); + END IF; + + SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id); + + v_alloc_index := 0; + FOR v_allocation IN + SELECT value FROM jsonb_array_elements(p_allocations) AS t(value) + ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '') + LOOP + v_alloc_amount := (v_allocation->>'amount')::numeric; + + IF v_has_customer THEN + v_invoice_id := (v_allocation->>'invoice_id')::uuid; + SELECT * INTO v_invoice FROM public.invoices + WHERE id = v_invoice_id AND company_id = p_company_id; + + IF v_invoice.currency = v_tx.currency THEN + v_paid_in_inv_currency := v_alloc_amount; + v_payment_rate := NULL; -- same-currency: no FX context + ELSE + v_paid_in_inv_currency := COALESCE(v_invoice.remaining_amount, v_invoice.total); + -- Round-3: effective payment-day rate. SEK_paid / foreign_remaining. + IF v_paid_in_inv_currency > 0 THEN + v_payment_rate := ROUND((v_alloc_amount / v_paid_in_inv_currency) * 1000000) / 1000000; + ELSE + v_payment_rate := NULL; + END IF; + END IF; + + v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_paid_in_inv_currency) * 100) / 100; + v_new_remaining := GREATEST(0, + ROUND((COALESCE(v_invoice.remaining_amount, v_invoice.total) - v_paid_in_inv_currency) * 100) / 100); + v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END; + + UPDATE public.invoices SET status = v_new_status, + paid_at = CASE WHEN v_new_status = 'paid' THEN + ((v_tx.date::timestamp + interval '12 hours') AT TIME ZONE 'UTC') + ELSE paid_at END, + paid_amount = v_new_paid, remaining_amount = v_new_remaining, updated_at = v_now + WHERE id = v_invoice_id AND company_id = p_company_id; + + INSERT INTO public.invoice_payments + (user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate, + payment_exchange_rate, journal_entry_id, transaction_id) + VALUES + (v_caller, p_company_id, v_invoice_id, v_tx.date, v_paid_in_inv_currency, v_invoice.currency, + v_invoice.exchange_rate, v_payment_rate, v_journal_entry_id, p_tx_id) + RETURNING id INTO v_payment_id; + + v_results := v_results || jsonb_build_array(jsonb_build_object( + 'kind', 'customer_invoice', 'invoice_id', v_invoice_id, 'payment_id', v_payment_id, + 'status', v_new_status, 'paid_amount', v_new_paid, 'remaining_amount', v_new_remaining, + 'amount', v_alloc_amount, + 'cross_currency', v_invoice.currency <> v_tx.currency)); + ELSE + v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid; + SELECT * INTO v_si_invoice FROM public.supplier_invoices + WHERE id = v_supplier_invoice_id AND company_id = p_company_id; + + IF v_si_invoice.currency = v_tx.currency THEN + v_paid_in_inv_currency := v_alloc_amount; + v_payment_rate := NULL; + ELSE + v_paid_in_inv_currency := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total); + IF v_paid_in_inv_currency > 0 THEN + v_payment_rate := ROUND((v_alloc_amount / v_paid_in_inv_currency) * 1000000) / 1000000; + ELSE + v_payment_rate := NULL; + END IF; + END IF; + + v_new_paid := ROUND((COALESCE(v_si_invoice.paid_amount, 0) + v_paid_in_inv_currency) * 100) / 100; + v_new_remaining := GREATEST(0, + ROUND((COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) - v_paid_in_inv_currency) * 100) / 100); + v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END; + + UPDATE public.supplier_invoices SET status = v_new_status, + paid_at = CASE WHEN v_new_status = 'paid' THEN + ((v_tx.date::timestamp + interval '12 hours') AT TIME ZONE 'UTC') + ELSE paid_at END, + paid_amount = v_new_paid, remaining_amount = v_new_remaining, + payment_journal_entry_id = v_journal_entry_id, updated_at = v_now + WHERE id = v_supplier_invoice_id AND company_id = p_company_id; + + INSERT INTO public.supplier_invoice_payments + (user_id, company_id, supplier_invoice_id, payment_date, amount, currency, exchange_rate, + payment_exchange_rate, journal_entry_id, transaction_id) + VALUES + (v_caller, p_company_id, v_supplier_invoice_id, v_tx.date, v_paid_in_inv_currency, + v_si_invoice.currency, v_si_invoice.exchange_rate, v_payment_rate, v_journal_entry_id, p_tx_id) + RETURNING id INTO v_payment_id; + + v_results := v_results || jsonb_build_array(jsonb_build_object( + 'kind', 'supplier_invoice', 'supplier_invoice_id', v_supplier_invoice_id, + 'payment_id', v_payment_id, 'status', v_new_status, 'paid_amount', v_new_paid, + 'remaining_amount', v_new_remaining, 'amount', v_alloc_amount, + 'cross_currency', v_si_invoice.currency <> v_tx.currency)); + END IF; + v_alloc_index := v_alloc_index + 1; + END LOOP; + + UPDATE public.transactions SET journal_entry_id = v_journal_entry_id, is_business = TRUE, + invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_customer AND ABS(v_total_allocated - v_tx_abs) < 0.005 + THEN (p_allocations->0->>'invoice_id')::uuid ELSE NULL END, + supplier_invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_supplier AND ABS(v_total_allocated - v_tx_abs) < 0.005 + THEN (p_allocations->0->>'supplier_invoice_id')::uuid ELSE NULL END, + potential_invoice_id = NULL, potential_supplier_invoice_id = NULL, + updated_at = v_now WHERE id = p_tx_id AND company_id = p_company_id; + + RETURN jsonb_build_object('ok', true, 'journal_entry_id', v_journal_entry_id, + 'voucher_series', v_voucher_series, 'voucher_number', v_voucher_number, + 'tx_id', p_tx_id, 'allocations', v_results, 'total_allocated', v_total_allocated, + 'leftover', 0); +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/match-batch-allocate.pg.test.ts b/tests/pg/match-batch-allocate.pg.test.ts index 24ea24cf..1b51fa5c 100644 --- a/tests/pg/match-batch-allocate.pg.test.ts +++ b/tests/pg/match-batch-allocate.pg.test.ts @@ -204,21 +204,35 @@ describe('match_batch_allocate', () => { expect(apSum).toBe(6500) // Verify all 3 supplier invoices flipped to 'paid'. - const inv1 = await client.query<{ status: string; paid_amount: string; remaining_amount: string }>( - `SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`, + const inv1 = await client.query<{ + status: string + paid_amount: string + remaining_amount: string + paid_at_matches: boolean + }>( + `SELECT status, paid_amount, remaining_amount, + paid_at = TIMESTAMPTZ '2026-06-05 12:00:00+00' AS paid_at_matches + FROM public.supplier_invoices WHERE id = $1`, [si1], ) expect(inv1.rows[0]!.status).toBe('paid') expect(Number(inv1.rows[0]!.paid_amount)).toBe(2000) expect(Number(inv1.rows[0]!.remaining_amount)).toBe(0) + expect(inv1.rows[0]!.paid_at_matches).toBe(true) // Verify 3 supplier_invoice_payments rows all reference the same JE. - const payments = await client.query<{ journal_entry_id: string; supplier_invoice_id: string }>( - `SELECT journal_entry_id, supplier_invoice_id + const payments = await client.query<{ + journal_entry_id: string + supplier_invoice_id: string + payment_date_matches: boolean + }>( + `SELECT journal_entry_id, supplier_invoice_id, + payment_date = DATE '2026-06-05' AS payment_date_matches FROM public.supplier_invoice_payments WHERE transaction_id = $1`, [txId], ) expect(payments.rows).toHaveLength(3) + expect(payments.rows.every((payment) => payment.payment_date_matches)).toBe(true) const jeIds = new Set(payments.rows.map((p) => p.journal_entry_id)) expect(jeIds.size).toBe(1) expect(jeIds.has(result.journal_entry_id!)).toBe(true) @@ -248,6 +262,62 @@ describe('match_batch_allocate', () => { }) }) + it('anchors customer paid_at at UTC noon on the bank transaction date', async () => { + const { userId, companyId } = await seedTenant() + const customerId = 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')`, + [customerId, userId, companyId], + ) + + const invoiceId = randomUUID() + 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', + 1000, 0, 1000, 0, 1000, 'standard_25')`, + [invoiceId, userId, companyId, customerId, `F-${invoiceId.slice(0, 8)}`], + ) + const txId = await insertTransaction({ + userId, + companyId, + amount: 1000, + date: '2026-06-07', + }) + + await withUserContext(userId, async (client) => { + const response = await client.query<{ match_batch_allocate: RpcResult }>( + `SELECT match_batch_allocate($1, $2::jsonb, $3)`, + [ + txId, + JSON.stringify([{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1000 }]), + companyId, + ], + ) + expect(response.rows[0]!.match_batch_allocate.ok).toBe(true) + + const invoice = await client.query<{ paid_at_matches: boolean }>( + `SELECT paid_at = TIMESTAMPTZ '2026-06-07 12:00:00+00' AS paid_at_matches + FROM public.invoices WHERE id = $1`, + [invoiceId], + ) + expect(invoice.rows[0]!.paid_at_matches).toBe(true) + + const payment = await client.query<{ payment_date_matches: boolean }>( + `SELECT payment_date = DATE '2026-06-07' AS payment_date_matches + FROM public.invoice_payments + WHERE invoice_id = $1 AND transaction_id = $2`, + [invoiceId, txId], + ) + expect(payment.rows).toHaveLength(1) + expect(payment.rows[0]!.payment_date_matches).toBe(true) + }) + }) + it('rejects with BATCH_OVERSHOOT when allocation exceeds invoice remaining', async () => { const { userId, companyId } = await seedTenant() const supplier = await insertSupplier({ userId, companyId })