fix(invoices): record manual and Stripe settlements in invoice_payments (#2236)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
3159920d7c
commit
e2d38b0ab3
@@ -9,7 +9,7 @@ import {
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
@@ -168,6 +168,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// 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: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
@@ -267,6 +268,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
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: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-2' })
|
||||
@@ -318,6 +320,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// 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: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -460,6 +463,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
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: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-force' })
|
||||
@@ -488,6 +492,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// 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: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -535,6 +540,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -582,6 +588,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -658,6 +665,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -869,6 +877,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
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: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-auto' })
|
||||
@@ -917,6 +926,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// 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: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -1095,6 +1105,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-eur-full' })
|
||||
@@ -1125,4 +1136,48 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(body.error.code).toBe('INVOICE_QUOTE_NOT_PAYABLE')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Issue #2019: the manual flow flipped the invoice to paid without an
|
||||
// invoice_payments row, so the kontantmetod cut-off saw no payment date and
|
||||
// re-booked the paid invoice as a fordran at bokslut.
|
||||
it('records the manual payment in invoice_payments with the voucher and no bank transaction', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
currency: 'SEK',
|
||||
customer,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // duplicate guard: merchant_name
|
||||
enqueue({ data: [], error: null }) // duplicate guard: description
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
|
||||
|
||||
mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-cash' })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { payment_date: '2026-08-28' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const inserts = findCalls('invoice_payments', 'insert')
|
||||
expect(inserts).toHaveLength(1)
|
||||
expect(inserts[0][0]).toMatchObject({
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
invoice_id: 'inv-1',
|
||||
payment_date: '2026-08-28',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
journal_entry_id: 'je-cash',
|
||||
transaction_id: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -168,6 +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 },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}, calls),
|
||||
)
|
||||
|
||||
@@ -202,6 +203,23 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
)
|
||||
const invoiceUpdate = calls.find((call) => call.table === 'invoices' && call.method === 'update')
|
||||
expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' })
|
||||
// #2019: the AR sub-ledger row (what the kontantmetod cut-off reads) is
|
||||
// written with the voucher and no bank transaction, before the update.
|
||||
const paymentInsert = calls.find(
|
||||
(call) => call.table === 'invoice_payments' && call.method === 'insert',
|
||||
)
|
||||
expect(paymentInsert?.args[0]).toMatchObject({
|
||||
user_id: USER_ID,
|
||||
company_id: COMPANY_ID,
|
||||
invoice_id: INVOICE_ID,
|
||||
payment_date: '2026-05-12',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
journal_entry_id: 'jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj',
|
||||
transaction_id: null,
|
||||
})
|
||||
expect(calls.findIndex((c) => c.table === 'invoice_payments' && c.method === 'insert'))
|
||||
.toBeLessThan(calls.findIndex((c) => c.table === 'invoices' && c.method === 'update'))
|
||||
// Issue #1259: the invoice is settled, so no transaction may keep pointing
|
||||
// at it as a match suggestion.
|
||||
expect(mockClearSuggestions).toHaveBeenCalledTimes(1)
|
||||
@@ -222,6 +240,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: PAID_INVOICE, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -248,6 +267,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 },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
},
|
||||
calls,
|
||||
),
|
||||
@@ -289,6 +309,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: PAID_INVOICE, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -318,6 +339,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 },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
},
|
||||
calls,
|
||||
),
|
||||
@@ -349,6 +371,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -376,6 +399,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -421,6 +445,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: { ...ROT_INVOICE, status: 'paid', remaining_amount: 0, paid_amount: 86800, paid_at: '2026-08-29T12:00:00Z' }, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
@@ -468,6 +493,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: BOOKED_ROT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
@@ -506,6 +532,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: ROT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
@@ -552,6 +579,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: { ...ORE_INVOICE, status: 'paid', remaining_amount: 0, paid_amount: 1234.75 }, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
@@ -627,6 +655,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -674,6 +703,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: {
|
||||
data: [
|
||||
{
|
||||
@@ -715,6 +745,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 },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
// transactions queue not consulted: force=true short-circuits the guard
|
||||
}),
|
||||
)
|
||||
@@ -748,6 +779,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: {
|
||||
data: [
|
||||
{
|
||||
@@ -854,6 +886,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
},
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: {
|
||||
data: [
|
||||
{
|
||||
@@ -916,6 +949,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: EUR_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: {
|
||||
// In kronor: the candidate lookup scans transactions.amount, which is SEK.
|
||||
data: [
|
||||
@@ -961,6 +995,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: { ...EUR_INVOICE, exchange_rate: null, total_sek: null }, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1003,6 +1038,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
},
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
},
|
||||
calls,
|
||||
),
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { eventBus } from '@/lib/events'
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
planInvoicePaymentForLines,
|
||||
} from '@/lib/invoices/apply-invoice-payment'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { recordInvoicePaymentRow, removeInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row'
|
||||
import { paidAtFromDate } from '@/lib/invoices/paid-at'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||
@@ -543,7 +545,46 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: update the invoice row.
|
||||
// Step 2: AR sub-ledger row (#2019). The kontantmetod cut-off reads
|
||||
// invoice_payments only, so a paid invoice without it is re-booked as a
|
||||
// fordran at bokslut. Written before the CAS update so the failure
|
||||
// branches undo it with the voucher. See lib/invoices/invoice-payment-row.ts.
|
||||
let paymentRowId: string | null = null
|
||||
if (isRealInvoice) {
|
||||
const recorded = await recordInvoicePaymentRow(ctx.supabase, {
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId!,
|
||||
invoice: typed,
|
||||
paymentDate,
|
||||
newPaidAmount,
|
||||
journalEntryId,
|
||||
})
|
||||
if (!recorded.ok) {
|
||||
ctx.log.error('mark-paid: invoice_payments insert failed: cancelling the payment voucher', undefined, {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
error: recorded.error,
|
||||
})
|
||||
if (journalEntryId) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
journalEntryId,
|
||||
'Automatiskt makulerad: betalningsraden kunde inte sparas efter bokförd betalning',
|
||||
)
|
||||
}
|
||||
// The raw driver text stays in the server log above; API callers get
|
||||
// the reason code only.
|
||||
return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'payment_row_insert_failed' },
|
||||
})
|
||||
}
|
||||
paymentRowId = recorded.id
|
||||
}
|
||||
|
||||
// Step 3: update the invoice row.
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
status: newStatus,
|
||||
remaining_amount: newRemaining,
|
||||
@@ -571,6 +612,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
.maybeSingle()
|
||||
|
||||
if (updateErr) {
|
||||
await removeInvoicePaymentRow(ctx.supabase, ctx.companyId!, paymentRowId)
|
||||
ctx.log.error('mark-paid: invoice update failed', updateErr as Error, {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
@@ -583,6 +625,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
if (!updated) {
|
||||
// Race: status transitioned (concurrent mark-paid / credit) between
|
||||
// pre-flight and our update. Surface as 409.
|
||||
await removeInvoicePaymentRow(ctx.supabase, ctx.companyId!, paymentRowId)
|
||||
ctx.log.warn('mark-paid: race: invoice status transitioned during request', {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
|
||||
Reference in New Issue
Block a user