fix: preserve invoice payment dates (#1332)

Signed-off-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Mattsson
2026-08-02 20:44:59 +02:00
committed by GitHub
parent 18cdba3574
commit 9e54a8e400
28 changed files with 1975 additions and 73 deletions
@@ -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(
@@ -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.
@@ -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)
@@ -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)
@@ -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
@@ -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,
@@ -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
@@ -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,
},
@@ -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)
@@ -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
@@ -64,7 +64,11 @@ const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
type RecordedCall = { table: string; method: string; args: unknown[] }
function makeFlexibleSupabase(
byTable: Record<string, MockResult | MockResult[]>,
calls?: RecordedCall[],
) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
@@ -79,7 +83,10 @@ function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>
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(),
@@ -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,
@@ -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<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@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<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockCreateJournalEntry = mockedCreateJournalEntry as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
type RecordedCall = { table: string; method: string; args: unknown[] }
function makeFlexibleSupabase(
byTable: Record<string, MockResult | MockResult[]>,
calls?: RecordedCall[],
) {
const queues = new Map<string, MockResult[]>()
for (const [table, value] of Object.entries(byTable)) {
queues.set(table, Array.isArray(value) ? [...value] : [value])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
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')
})
})
@@ -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,
@@ -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<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@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<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockCreatePaymentEntry = mockedCreatePaymentEntry as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
type RecordedCall = { table: string; method: string; args: unknown[] }
function makeFlexibleSupabase(
byTable: Record<string, MockResult | MockResult[]>,
calls?: RecordedCall[],
) {
const queues = new Map<string, MockResult[]>()
for (const [table, value] of Object.entries(byTable)) {
queues.set(table, Array.isArray(value) ? [...value] : [value])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
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')
})
})
@@ -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!,
},