* fix(invoices): record manual and Stripe settlements in invoice_payments (#2019)
settleInvoicePayment created the payment voucher and flipped the invoice to
paid but never wrote the AR sub-ledger row. The kontantmetod bokslut cut-off
reads invoice_payments only (payment DATE, not remaining_amount), so a
manually settled invoice was booked again as a fordran with vilande moms at
year end, double-counting revenue and VAT. The same gap hid the payment from
the Betalningar view and from the voucher -> invoice reference map.
- Insert the row between voucher creation and the CAS status update, same
shape as the bank-match path (amount in invoice currency, transaction_id
null). An insert failure cancels the voucher and fails closed; both CAS
failure branches remove the row together with the voucher.
- Backfill: scripts/backfill-invoice-payment-rows.ts (dry-run default) with
a pure planner in lib/invoices/backfill-invoice-payment-rows.ts. Writes
only where exactly one posted payment voucher exists; zero or several are
reported, never guessed. Rows carry notes 'backfill:#2019' so one DELETE
reverts a run. Executed on staging (10 rows); prod awaits explicit go.
- pg-real: transaction-less rows coexist under the tx/invoice unique index,
the je/invoice index still refuses a double link, and the authenticated
writer can delete its own row (the CAS-failure path depends on it).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): write the payment row from every mark-paid path and harden the backfill
Skeptic and review round on #2236 (issue #2019):
- One helper (lib/invoices/invoice-payment-row.ts) now writes the
invoice_payments row for all four transaction-less settlement paths:
dashboard mark-paid and Stripe via settleInvoicePayment, plus the MCP
mark_invoice_paid commit and the v1 mark-paid route, which booked their
own voucher and never wrote the row. Amount = applied amount (new
paid_amount minus prior), not cash received, so a 3740 öre absorption
never yields a negative fordran in the cut-off or a wrong storno restore.
- The two duplicate detectors no longer treat a payment row with
transaction_id NULL as "reconciled to a bank line": the bank line for a
manual settlement arrives later and the voucher must stay a twin.
- Backfill: payment_date from the voucher entry_date (paid_at was
wall-clock before #1332); refuse rows that disagree with the voucher's
1510 credit / settlement debit; report partially covered invoices
(rows_short) instead of patching; record each executed run in
behandlingshistorik (InvoicePaymentRowBackfilled, migration
20260903180000). Re-run end to end on staging: 10 rows, 10 events.
- Typecheck ratchet: cast in the cut-off test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): use roundOre in the #2019 backfill (guard ratchet)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): log a failed payment-row rollback and keep backfill rows with their audit event
Swedish review round 2 on #2236:
- removeInvoicePaymentRow no longer swallows a failed compensating DELETE:
it logs at error level with company and row id (a stranded row would
read as a settlement in the kontantmetod cut-off) and returns whether
the row is gone. Unit tests for the helper.
- The backfill deletes a company's rows from the run again when its
behandlingshistorik event cannot be written, so rows and change log
(BFNAR 2013:2 p. 9.16) never diverge; the company is listed for a re-run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): keep raw insert errors out of the v1 and MCP mark-paid responses
Compliance swarm on #2236 (ISO 27001 A.8.28): the payment-row insert
failure returned the driver's error text to API callers and MCP users.
The text now stays in the server log; callers get the reason code and a
generic Swedish outcome.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): never backfill a payment row into a closed or locked period
Swedish review round 3 on #2236: a row dated into a closed or locked
fiscal period changes facts a filed bokslut or deklaration relied on. The
planner now reports such invoices (period_closed) instead of writing them,
and the script header states that the tagged DELETE is an emergency revert
for the window before any cut-off relies on the rows; afterwards the
correction path is a storno of the cut-off verifikat.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* test(fiscal-periods): pass route params in the two mid-month tests (typecheck ratchet)
cc18e9d53 (#2242) added two POST(req) calls without the params argument,
raising the file's TypeScript error count above the ratchet baseline
(25 vs 23). main is red on "Checks" for every PR since; this unblocks the
gate for #2236 and the rest without touching the baseline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
207 lines
7.9 KiB
TypeScript
207 lines
7.9 KiB
TypeScript
/**
|
|
* State + event coverage for the agent/MCP mark-invoice-paid commit path
|
|
* (`commitMarkInvoicePaid` in lib/pending-operations/commit.ts).
|
|
*
|
|
* Regression guard for issue #825: this path previously flipped status to
|
|
* 'paid' and set paid_amount = total but never wrote remaining_amount (leaving
|
|
* it at the original total) and never emitted invoice.paid (so webhooks never
|
|
* fired). It now routes through the shared planInvoicePayment helper and emits
|
|
* invoice.paid, matching the dashboard and v1 mark-paid routes.
|
|
*
|
|
* Duplicate-payment-guard behaviour is covered separately in
|
|
* commit-duplicate-guard.test.ts; here the guard is stubbed to "no candidates".
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import type { PendingOperation } from '@/types'
|
|
|
|
const mockCreatePaymentEntry = vi.fn()
|
|
const mockCreateCashEntry = vi.fn()
|
|
vi.mock('@/lib/bookkeeping/invoice-entries', async () => {
|
|
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/invoice-entries')>(
|
|
'@/lib/bookkeeping/invoice-entries',
|
|
)
|
|
return {
|
|
...actual,
|
|
createInvoicePaymentJournalEntry: (...args: unknown[]) => mockCreatePaymentEntry(...args),
|
|
createInvoiceCashEntry: (...args: unknown[]) => mockCreateCashEntry(...args),
|
|
}
|
|
})
|
|
|
|
const mockFindDupPayments = vi.fn()
|
|
vi.mock('@/lib/invoices/duplicate-payment-candidates', () => ({
|
|
findDuplicatePaymentCandidatesForInvoice: (...args: unknown[]) => mockFindDupPayments(...args),
|
|
}))
|
|
|
|
// Issue #1259: settling the invoice retires the suggestion pointers at it.
|
|
// Mocked so it consumes no slot in the queued Supabase mock; the helper's own
|
|
// query shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts.
|
|
const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() }))
|
|
vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({
|
|
clearSettledInvoiceSuggestions: mockClearSuggestions,
|
|
}))
|
|
|
|
import { commitPendingOperation } from '../commit'
|
|
|
|
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
|
|
return {
|
|
id: 'op-1',
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'mark_invoice_paid',
|
|
status: 'pending',
|
|
title: 'test',
|
|
params: {},
|
|
preview_data: {},
|
|
result_data: null,
|
|
actor_type: 'user',
|
|
actor_id: null,
|
|
actor_label: null,
|
|
risk_level: 'medium',
|
|
created_at: '2026-05-03T00:00:00Z',
|
|
resolved_at: null,
|
|
updated_at: '2026-05-03T00:00:00Z',
|
|
...overrides,
|
|
} as PendingOperation
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
mockFindDupPayments.mockResolvedValue([])
|
|
mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
|
mockCreateCashEntry.mockResolvedValue({ id: 'je-1' })
|
|
})
|
|
|
|
describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () => {
|
|
it('rejects credit notes before creating a payment journal entry', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: {
|
|
id: 'credit-1',
|
|
invoice_number: 'KR-F-2026001',
|
|
status: 'sent',
|
|
total: -525,
|
|
remaining_amount: -525,
|
|
paid_amount: null,
|
|
credited_invoice_id: 'inv-1',
|
|
document_type: 'invoice',
|
|
journal_entry_id: 'je-credit',
|
|
customer: { name: 'Test AB' },
|
|
},
|
|
error: null,
|
|
}) // invoice fetch
|
|
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
|
|
|
const op = makePendingOp({ params: { invoice_id: 'credit-1', payment_date: '2026-03-30' } })
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(409)
|
|
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
|
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('zeroes remaining_amount and emits invoice.paid on full payment (issue #825)', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: {
|
|
id: 'inv-1',
|
|
invoice_number: '2026001',
|
|
status: 'sent',
|
|
total: 525,
|
|
remaining_amount: 525,
|
|
paid_amount: null,
|
|
document_type: 'invoice',
|
|
journal_entry_id: null,
|
|
customer: { name: 'Test AB' },
|
|
},
|
|
error: null,
|
|
}) // invoice fetch
|
|
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
|
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert (#2019)
|
|
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
|
|
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
|
|
|
const paidHandler = vi.fn()
|
|
eventBus.on('invoice.paid', paidHandler)
|
|
|
|
const op = makePendingOp({ params: { invoice_id: 'inv-1', payment_date: '2026-03-30' } })
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({ status: 'paid', remaining_amount: 0, journal_entry_id: 'je-1' })
|
|
|
|
expect(paidHandler).toHaveBeenCalledTimes(1)
|
|
expect(paidHandler).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
companyId: 'company-1',
|
|
userId: 'user-1',
|
|
paymentAmount: 525,
|
|
invoice: expect.objectContaining({
|
|
id: 'inv-1',
|
|
status: 'paid',
|
|
remaining_amount: 0,
|
|
paid_amount: 525,
|
|
paid_at: '2026-03-30T12:00:00Z',
|
|
}),
|
|
}),
|
|
)
|
|
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
|
|
expect(invoiceUpdate).toMatchObject({ paid_at: '2026-03-30T12:00:00Z' })
|
|
|
|
// Issue #1259: the invoice is settled, so no transaction may keep pointing
|
|
// at it as a match suggestion. This flow is not driven by a bank
|
|
// transaction, so nothing is excluded.
|
|
expect(mockClearSuggestions).toHaveBeenCalledTimes(1)
|
|
expect(mockClearSuggestions).toHaveBeenCalledWith(supabase, 'company-1', 'invoice', 'inv-1')
|
|
// #2019: the AR sub-ledger row is what the kontantmetod cut-off reads.
|
|
const paymentInserts = findCalls('invoice_payments', 'insert')
|
|
expect(paymentInserts).toHaveLength(1)
|
|
expect(paymentInserts[0][0]).toMatchObject({
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
invoice_id: 'inv-1',
|
|
transaction_id: null,
|
|
})
|
|
})
|
|
|
|
// No partial-payment counterpart here: this executor always settles the full
|
|
// remaining balance (no custom amount param), so newStatus is always 'paid'.
|
|
// The partial case is pinned on the surfaces that can produce it, e.g.
|
|
// lib/invoices/__tests__/settle-invoice-payment.test.ts.
|
|
|
|
it('rejects a quote (offert) before creating a payment journal entry', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: {
|
|
id: 'q-1',
|
|
invoice_number: 'OF-001',
|
|
status: 'sent',
|
|
total: 2500,
|
|
remaining_amount: 0,
|
|
paid_amount: null,
|
|
credited_invoice_id: null,
|
|
document_type: 'quote',
|
|
quote_status: 'accepted',
|
|
journal_entry_id: null,
|
|
customer: { name: 'Test AB' },
|
|
},
|
|
error: null,
|
|
}) // invoice fetch
|
|
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
|
|
|
const op = makePendingOp({ params: { invoice_id: 'q-1', payment_date: '2026-06-30' } })
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(409)
|
|
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
|
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
|
})
|
|
})
|