944 lines
35 KiB
TypeScript
944 lines
35 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import {
|
|
createMockRequest,
|
|
parseJsonResponse,
|
|
createMockRouteParams,
|
|
createQueuedMockSupabase,
|
|
makeInvoice,
|
|
makeCustomer,
|
|
} from '@/tests/helpers'
|
|
import { eventBus } from '@/lib/events'
|
|
|
|
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: () => Promise.resolve(mockSupabase),
|
|
}))
|
|
|
|
vi.mock('@/lib/init', () => ({
|
|
ensureInitialized: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
|
}))
|
|
|
|
const mockCreateInvoicePaymentJournalEntry = vi.fn()
|
|
const mockCreateInvoiceCashEntry = vi.fn()
|
|
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
|
createInvoicePaymentJournalEntry: (...args: unknown[]) =>
|
|
mockCreateInvoicePaymentJournalEntry(...args),
|
|
createInvoiceCashEntry: (...args: unknown[]) =>
|
|
mockCreateInvoiceCashEntry(...args),
|
|
}))
|
|
|
|
const mockCreateJournalEntry = vi.fn()
|
|
const mockFindFiscalPeriod = vi.fn()
|
|
vi.mock('@/lib/bookkeeping/engine', () => ({
|
|
createJournalEntry: (...args: unknown[]) =>
|
|
mockCreateJournalEntry(...args),
|
|
findFiscalPeriod: (...args: unknown[]) =>
|
|
mockFindFiscalPeriod(...args),
|
|
}))
|
|
|
|
import { POST } from '../route'
|
|
|
|
describe('POST /api/invoices/[id]/mark-paid', () => {
|
|
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
eventBus.clear()
|
|
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
|
})
|
|
|
|
it('returns 401 when not authenticated', async () => {
|
|
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse(response)
|
|
|
|
expect(status).toBe(401)
|
|
expect(body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
|
|
it('returns 404 when invoice not found', async () => {
|
|
enqueue({ data: null, error: { message: 'Not found' } })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(404)
|
|
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_NOT_FOUND')
|
|
})
|
|
|
|
it('returns 400 when invoice is in draft status', async () => {
|
|
const invoice = makeInvoice({ status: 'draft' })
|
|
enqueue({ data: invoice, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_NOT_PAYABLE')
|
|
})
|
|
|
|
it('returns 400 when invoice is already paid', async () => {
|
|
const invoice = makeInvoice({ status: 'paid' })
|
|
enqueue({ data: invoice, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_NOT_PAYABLE')
|
|
})
|
|
|
|
it('returns 400 when invoice is credited', async () => {
|
|
const invoice = makeInvoice({ status: 'credited' })
|
|
enqueue({ data: invoice, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body: _body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
})
|
|
|
|
it('rejects a sent credit note before booking a payment', async () => {
|
|
const invoice = makeInvoice({
|
|
status: 'sent',
|
|
credited_invoice_id: 'original-invoice-1',
|
|
})
|
|
enqueue({ data: invoice, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string; details?: unknown } }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
|
|
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
|
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
|
|
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects an original invoice while an active credit-note draft exists', async () => {
|
|
const invoice = {
|
|
...makeInvoice({ status: 'sent', credited_invoice_id: null }),
|
|
credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }],
|
|
}
|
|
enqueue({ data: invoice, error: null })
|
|
|
|
const response = await POST(
|
|
createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }),
|
|
createMockRouteParams({ id: 'inv-1' }),
|
|
)
|
|
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
|
|
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('marks sent invoice as paid with accrual method', async () => {
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
total: 12500,
|
|
customer,
|
|
})
|
|
|
|
// Fetch invoice
|
|
enqueue({ data: invoice, error: null })
|
|
// Duplicate-payment guard: merchant_name ILIKE, no candidates
|
|
enqueue({ data: [], error: null })
|
|
// Duplicate-payment guard: description ILIKE, no candidates
|
|
enqueue({ data: [], error: null })
|
|
// Fetch company settings (now before update due to journal-first ordering)
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
|
// Update invoice status (CAS guard: returns matched row)
|
|
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
|
|
|
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
|
|
|
|
const paidHandler = vi.fn()
|
|
eventBus.on('invoice.paid', paidHandler)
|
|
|
|
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
|
|
status: string
|
|
paid_amount: number
|
|
remaining_amount: number
|
|
journal_entry_id: string | null
|
|
paid_at: string | null
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.success).toBe(true)
|
|
expect(body.status).toBe('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(
|
|
expect.objectContaining({
|
|
companyId: 'company-1',
|
|
userId: 'user-1',
|
|
paymentAmount: 12500,
|
|
invoice: expect.objectContaining({
|
|
id: 'inv-1',
|
|
status: 'paid',
|
|
paid_amount: 12500,
|
|
remaining_amount: 0,
|
|
paid_at: '2026-05-12T12:00:00Z',
|
|
}),
|
|
}),
|
|
)
|
|
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
'user-1',
|
|
expect.objectContaining({ id: 'inv-1' }),
|
|
expect.any(String),
|
|
undefined,
|
|
expect.anything(),
|
|
undefined, // paymentAmount: full settle
|
|
undefined // settlementAccountNumber: default 1930
|
|
)
|
|
})
|
|
|
|
it('refuses to mark paid (INVOICE_PAID_BOOK_FAILED) when no payment journal entry is produced', async () => {
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500, customer })
|
|
|
|
// Fetch invoice
|
|
enqueue({ data: invoice, error: null })
|
|
// Duplicate-payment guard: two ILIKE probes, no candidates
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
// Company settings
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
|
// Deliberately NO status-update enqueued: the route must fail closed BEFORE
|
|
// touching the invoice row when nothing was booked.
|
|
|
|
// Helper returns null without throwing (e.g. a closed/locked fiscal period).
|
|
mockCreateInvoicePaymentJournalEntry.mockResolvedValue(null)
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
|
|
|
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled()
|
|
// No silent "paid with no journal entry": GL must not diverge from the AR ledger.
|
|
expect(body.error.code).toBe('INVOICE_PAID_BOOK_FAILED')
|
|
})
|
|
|
|
it('marks overdue invoice as paid with cash method', async () => {
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'overdue',
|
|
total: 12500,
|
|
customer,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
// Duplicate-payment guard: merchant_name ILIKE, no candidates
|
|
enqueue({ data: [], error: null })
|
|
// Duplicate-payment guard: description ILIKE, no candidates
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
|
// Update invoice status (CAS guard: returns matched row)
|
|
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
|
|
|
mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-2' })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
success: boolean
|
|
journal_entry_id: string | null
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.success).toBe(true)
|
|
expect(body.journal_entry_id).toBe('je-2')
|
|
expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
'user-1',
|
|
expect.objectContaining({ id: 'inv-1' }),
|
|
expect.any(String),
|
|
'enskild_firma',
|
|
expect.anything(),
|
|
undefined // settlementAccountNumber: default 1930
|
|
)
|
|
})
|
|
|
|
it('returns 500 when journal entry creation fails (invoice not marked paid)', async () => {
|
|
// No customer attached → duplicate guard skips with missing_customer_name
|
|
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
|
|
|
mockCreateInvoicePaymentJournalEntry.mockRejectedValueOnce(new Error('Period locked'))
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status } = await parseJsonResponse(response)
|
|
|
|
expect(status).toBe(500)
|
|
})
|
|
|
|
it('uses custom lines when provided instead of auto-generating', async () => {
|
|
// No customer attached → duplicate guard skips with missing_customer_name
|
|
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
|
|
|
// Fetch invoice
|
|
enqueue({ data: invoice, error: null })
|
|
// Fetch company settings (before update, journal-first ordering)
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
|
// Update invoice status (CAS guard: returns matched row)
|
|
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
|
|
|
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
|
mockCreateJournalEntry.mockResolvedValue({ id: 'je-custom' })
|
|
|
|
const customLines = [
|
|
{ account_number: '1920', debit_amount: 12500, credit_amount: 0, line_description: 'Betalning' },
|
|
{ account_number: '1510', debit_amount: 0, credit_amount: 12500, line_description: 'Betalning' },
|
|
]
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: {
|
|
payment_date: '2025-03-17',
|
|
lines: customLines,
|
|
},
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
success: boolean
|
|
journal_entry_id: string | null
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.success).toBe(true)
|
|
expect(body.journal_entry_id).toBe('je-custom')
|
|
// Should NOT call auto-generation functions
|
|
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
|
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
|
|
// Should call createJournalEntry directly with custom lines
|
|
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
'user-1',
|
|
expect.objectContaining({
|
|
entry_date: '2025-03-17',
|
|
source_type: 'invoice_paid',
|
|
lines: customLines,
|
|
})
|
|
)
|
|
})
|
|
|
|
it('returns 400 when custom lines are unbalanced', async () => {
|
|
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
|
|
|
// Fetch invoice
|
|
enqueue({ data: invoice, error: null })
|
|
|
|
const unbalancedLines = [
|
|
{ account_number: '1920', debit_amount: 12500, credit_amount: 0 },
|
|
{ account_number: '1510', debit_amount: 0, credit_amount: 10000 },
|
|
]
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: {
|
|
payment_date: '2025-03-17',
|
|
lines: unbalancedLines,
|
|
},
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_LINES_UNBALANCED')
|
|
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 400 when body has invalid schema (e.g. bad account number)', async () => {
|
|
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
|
|
|
// Fetch invoice
|
|
enqueue({ data: invoice, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: {
|
|
payment_date: '2025-03-17',
|
|
lines: [
|
|
{ account_number: 'XXXX', debit_amount: 12500, credit_amount: 0 },
|
|
],
|
|
},
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status } = await parseJsonResponse(response)
|
|
|
|
expect(status).toBe(400)
|
|
})
|
|
|
|
it('returns 409 INVOICE_PAID_LIKELY_DUPLICATE when an unlinked transaction matches', async () => {
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
total: 12500,
|
|
customer,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
// Duplicate-payment guard: merchant_name ILIKE returns the match
|
|
enqueue({
|
|
data: [
|
|
{
|
|
id: 'tx-99',
|
|
date: '2026-05-10',
|
|
amount: 12500,
|
|
description: 'Inbetalning Test AB',
|
|
merchant_name: 'Test AB',
|
|
reference: null,
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
// description ILIKE, no additional match (dedup keeps merchant_name result)
|
|
enqueue({ data: [], error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: { code: string; details: { candidates: Array<{ id: string; match_reason: string }> } }
|
|
}>(response)
|
|
|
|
expect(status).toBe(409)
|
|
expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE')
|
|
expect(body.error.details.candidates).toHaveLength(1)
|
|
expect(body.error.details.candidates[0].id).toBe('tx-99')
|
|
expect(body.error.details.candidates[0].match_reason).toBe('name_amount_fuzzy')
|
|
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('proceeds when force=true even with candidates present', async () => {
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
total: 12500,
|
|
customer,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
// Guard query is SKIPPED because force=true short-circuits the check
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
|
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
|
|
|
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-force' })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: { force: true },
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string }>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.success).toBe(true)
|
|
expect(body.journal_entry_id).toBe('je-force')
|
|
})
|
|
|
|
it('skips duplicate guard on partial payment (lines total < remaining)', async () => {
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
total: 12500,
|
|
customer,
|
|
})
|
|
|
|
// No guard query enqueued: guard is skipped for partial payments
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
|
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
|
|
|
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
|
mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial' })
|
|
|
|
const partialLines = [
|
|
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
|
|
{ account_number: '1510', debit_amount: 0, credit_amount: 5000 },
|
|
]
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: { lines: partialLines },
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
success: boolean
|
|
status: string
|
|
paid_amount: number
|
|
remaining_amount: number
|
|
paid_at: string | null
|
|
journal_entry_id: string
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.success).toBe(true)
|
|
expect(body.journal_entry_id).toBe('je-partial')
|
|
// A 5000 payment on a 12500 invoice → partially_paid, remaining 7500.
|
|
expect(body.status).toBe('partially_paid')
|
|
expect(body.paid_amount).toBe(5000)
|
|
expect(body.remaining_amount).toBe(7500)
|
|
expect(body.paid_at).toBeNull()
|
|
})
|
|
|
|
it('accepts an öresavrundning overshoot: rounded "Att betala" settles the invoice in full', async () => {
|
|
// Invoice stored with öre (1234.75), PDF shows the rounded 1235.00 and the
|
|
// customer pays that: the 3740 line carries the 0.25 residual. No customer
|
|
// → duplicate guard skips.
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
total: 1234.75,
|
|
remaining_amount: 1234.75,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
|
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
|
|
|
|
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
|
mockCreateJournalEntry.mockResolvedValue({ id: 'je-ore' })
|
|
|
|
const oreLines = [
|
|
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
|
|
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
|
|
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
|
|
]
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: { lines: oreLines },
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
success: boolean
|
|
status: string
|
|
paid_amount: number
|
|
remaining_amount: number
|
|
journal_entry_id: string
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.success).toBe(true)
|
|
expect(body.status).toBe('paid')
|
|
expect(body.paid_amount).toBe(1234.75)
|
|
expect(body.remaining_amount).toBe(0)
|
|
expect(body.journal_entry_id).toBe('je-ore')
|
|
})
|
|
|
|
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when custom lines overpay the invoice', async () => {
|
|
// No customer → duplicate guard skips; the overpayment guard must reject
|
|
// BEFORE any journal entry is created (planInvoicePayment runs first).
|
|
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
|
|
const overpayLines = [
|
|
{ account_number: '1930', debit_amount: 15000, credit_amount: 0 },
|
|
{ account_number: '1510', debit_amount: 0, credit_amount: 15000 },
|
|
]
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: { lines: overpayLines },
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect((body.error as unknown as { code: string }).code).toBe('MATCH_AMOUNT_EXCEEDS_REMAINING')
|
|
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
|
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('surfaces ocr_exact match_reason when tx reference normalizes to invoice_number', async () => {
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
invoice_number: '2026-0042',
|
|
status: 'sent',
|
|
total: 12500,
|
|
customer,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
// OCR match: tx.reference '20260042' normalizes to invoice_number '20260042'
|
|
enqueue({
|
|
data: [
|
|
{
|
|
id: 'tx-ocr',
|
|
date: '2026-05-10',
|
|
amount: 12500,
|
|
description: 'Insättning',
|
|
merchant_name: 'Test AB',
|
|
reference: '2026 0042',
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
// description ILIKE, no additional match
|
|
enqueue({ data: [], error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: { code: string; details: { candidates: Array<{ id: string; match_reason: string }> } }
|
|
}>(response)
|
|
|
|
expect(status).toBe(409)
|
|
expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE')
|
|
expect(body.error.details.candidates[0].match_reason).toBe('ocr_exact')
|
|
})
|
|
|
|
it('ranks ocr_exact ahead of name_amount_fuzzy when multiple candidates match', async () => {
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
invoice_number: '2026-0042',
|
|
status: 'sent',
|
|
total: 12500,
|
|
customer,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({
|
|
data: [
|
|
// Name+amount only (no OCR)
|
|
{
|
|
id: 'tx-name',
|
|
date: '2026-05-09',
|
|
amount: 12500,
|
|
description: 'Inbetalning Test AB',
|
|
merchant_name: 'Test AB',
|
|
reference: null,
|
|
},
|
|
// OCR exact match
|
|
{
|
|
id: 'tx-ocr',
|
|
date: '2026-05-08',
|
|
amount: 12500,
|
|
description: 'Inbetalning Test AB',
|
|
merchant_name: 'Test AB',
|
|
reference: '2026-0042',
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
// description ILIKE, no additional matches
|
|
enqueue({ data: [], error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: { code: string; details: { candidates: Array<{ id: string; match_reason: string }> } }
|
|
}>(response)
|
|
|
|
expect(status).toBe(409)
|
|
expect(body.error.details.candidates[0].id).toBe('tx-ocr')
|
|
expect(body.error.details.candidates[0].match_reason).toBe('ocr_exact')
|
|
expect(body.error.details.candidates[1].id).toBe('tx-name')
|
|
expect(body.error.details.candidates[1].match_reason).toBe('name_amount_fuzzy')
|
|
})
|
|
|
|
it('falls back to auto-generation when lines are not provided', async () => {
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
total: 12500,
|
|
customer,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
// Duplicate-payment guard: merchant_name ILIKE, no candidates
|
|
enqueue({ data: [], error: null })
|
|
// Duplicate-payment guard: description ILIKE, no candidates
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
|
// Update invoice status (CAS guard: returns matched row)
|
|
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
|
|
|
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-auto' })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: { payment_date: '2025-03-17' },
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
success: boolean
|
|
journal_entry_id: string | null
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.journal_entry_id).toBe('je-auto')
|
|
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled()
|
|
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// ------------------------------------------------------------------
|
|
// Foreign-currency unit handling.
|
|
// total / paid_amount / remaining_amount are stored in the INVOICE currency;
|
|
// custom lines are journal lines and therefore SEK. Everything below pins
|
|
// the conversion between the two.
|
|
// ------------------------------------------------------------------
|
|
|
|
it('converts a SEK custom-line payment to invoice currency before touching the ledger (EUR, partial)', async () => {
|
|
// 1 000 EUR invoice at 11,4967 SEK/EUR. The customer pays 5 748,35 kr,
|
|
// which is exactly 500 EUR: a genuine partial payment. Without the
|
|
// conversion the guard compared 5 748,35 (SEK) against 1 000 (EUR), read
|
|
// the partial as a full settlement and ran the duplicate-payment guard on
|
|
// it: a matching bank row would then 409 a perfectly valid partial.
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
currency: 'EUR',
|
|
exchange_rate: 11.4967,
|
|
total: 1000,
|
|
remaining_amount: 1000,
|
|
customer,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
// No duplicate-guard probes enqueued: 500 EUR < 1 000 EUR remaining, so the
|
|
// guard is skipped entirely.
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
|
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
|
|
|
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
|
mockCreateJournalEntry.mockResolvedValue({ id: 'je-eur-partial' })
|
|
const paidHandler = vi.fn()
|
|
eventBus.on('invoice.paid', paidHandler)
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: {
|
|
lines: [
|
|
{ account_number: '1930', debit_amount: 5748.35, credit_amount: 0 },
|
|
{ account_number: '1510', debit_amount: 0, credit_amount: 5748.35 },
|
|
],
|
|
},
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
success: boolean
|
|
status: string
|
|
paid_amount: number
|
|
remaining_amount: number
|
|
journal_entry_id: string
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.status).toBe('partially_paid')
|
|
// Both in EUR, never 5 748,35 and never a negative remainder.
|
|
expect(body.paid_amount).toBe(500)
|
|
expect(body.remaining_amount).toBe(500)
|
|
expect(body.journal_entry_id).toBe('je-eur-partial')
|
|
// The load-bearing assertion for the guard fix: the guard compares in
|
|
// invoice currency now, so a 500 EUR payment on a 1 000 EUR remaining is a
|
|
// partial and the transactions scan never runs. Comparing the raw SEK
|
|
// 5 748,35 against 1 000 read it as a full settlement and probed.
|
|
expect(mockSupabase.from).not.toHaveBeenCalledWith('transactions')
|
|
// The event carries the invoice-currency amount, matching the ledger.
|
|
expect(paidHandler).toHaveBeenCalledWith(
|
|
expect.objectContaining({ paymentAmount: 500 }),
|
|
)
|
|
})
|
|
|
|
it('still runs the duplicate guard when the converted SEK lines settle a EUR invoice in full', async () => {
|
|
// The complement of the test above: 11 496,70 kr at 11,4967 is exactly the
|
|
// 1 000 EUR remaining, so this IS a full settlement and the advisory must
|
|
// still fire. Converting for the comparison must not disable the guard on
|
|
// foreign-currency invoices.
|
|
const customer = makeCustomer()
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
currency: 'EUR',
|
|
exchange_rate: 11.4967,
|
|
total: 1000,
|
|
remaining_amount: 1000,
|
|
customer,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
// merchant_name ILIKE probe: the bank row is in kronor, because the
|
|
// candidate lookup scans transactions.amount, which is SEK.
|
|
enqueue({
|
|
data: [
|
|
{
|
|
id: 'tx-eur',
|
|
date: '2026-05-10',
|
|
amount: 11496.7,
|
|
description: 'Inbetalning Test AB',
|
|
merchant_name: 'Test AB',
|
|
reference: null,
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
// description ILIKE probe: no additional match.
|
|
enqueue({ data: [], error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: {
|
|
lines: [
|
|
{ account_number: '1930', debit_amount: 11496.7, credit_amount: 0 },
|
|
{ account_number: '1510', debit_amount: 0, credit_amount: 11496.7 },
|
|
],
|
|
},
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: { code: string; details: { candidates: Array<{ id: string }> } }
|
|
}>(response)
|
|
|
|
expect(status).toBe(409)
|
|
expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE')
|
|
expect(body.error.details.candidates[0].id).toBe('tx-eur')
|
|
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 400 MATCH_INVOICE_BOOKING_RATE_MISSING when a EUR invoice carries no exchange rate', async () => {
|
|
// 11 496,70 kr against a 1 000 EUR invoice with no rate on file. Defaulting
|
|
// the rate to 1 would read the payment as 11 496,70 EUR.
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
currency: 'EUR',
|
|
exchange_rate: null,
|
|
total: 1000,
|
|
remaining_amount: 1000,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: {
|
|
lines: [
|
|
{ account_number: '1930', debit_amount: 11496.7, credit_amount: 0 },
|
|
{ account_number: '1510', debit_amount: 0, credit_amount: 11496.7 },
|
|
],
|
|
},
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string; details?: unknown } }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
// Same code the bank-match path throws for the same condition
|
|
// (lib/bookkeeping/invoice-payment-lines.ts).
|
|
expect(body.error.code).toBe('MATCH_INVOICE_BOOKING_RATE_MISSING')
|
|
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
|
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refuses a sub-remaining SEK payment on a rate-less EUR invoice instead of booking kronor as euro', async () => {
|
|
// The silent-corruption direction: 500 kr against a 1 000 EUR invoice sits
|
|
// below the invoice-currency remaining, so no overpayment guard catches it.
|
|
// With a rate-1 fallback it recorded 500 EUR paid and left 500 EUR
|
|
// remaining, when the customer had really paid ~43 EUR.
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
currency: 'EUR',
|
|
exchange_rate: null,
|
|
total: 1000,
|
|
remaining_amount: 1000,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
|
method: 'POST',
|
|
body: {
|
|
lines: [
|
|
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
|
{ account_number: '1510', debit_amount: 0, credit_amount: 500 },
|
|
],
|
|
},
|
|
})
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error.code).toBe('MATCH_INVOICE_BOOKING_RATE_MISSING')
|
|
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('leaves a rate-less EUR invoice payable when no custom lines are supplied', async () => {
|
|
// The default path pays remaining_amount, which is already in invoice
|
|
// currency: no conversion happens, so no rate is required.
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'sent',
|
|
currency: 'EUR',
|
|
exchange_rate: null,
|
|
total: 1000,
|
|
remaining_amount: 1000,
|
|
})
|
|
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
|
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
|
|
|
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-eur-full' })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
status: string
|
|
paid_amount: number
|
|
remaining_amount: number
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.status).toBe('paid')
|
|
expect(body.paid_amount).toBe(1000)
|
|
expect(body.remaining_amount).toBe(0)
|
|
})
|
|
})
|