fix: preserve invoice payment dates (#1332)
Signed-off-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+31
-4
@@ -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,
|
||||
|
||||
+253
@@ -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,
|
||||
|
||||
+253
@@ -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!,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user