diff --git a/DECISIONS.md b/DECISIONS.md index e5d43ba9..9260b586 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1527,6 +1527,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-03] Old-address social identities are unlinked by a BEFORE UPDATE trigger on auth.users (migration 20260903110000), not by the /auth/callback done path: the callback never runs for a completing click from a browser without a session, and admin-side changes bypass it entirely; the trigger covers every path and keeps the email identity, password and BankID intact. [2026-09-03] AGI redovisningsperiod = the payout month (agiReportingPeriod on payment_date), not salary_runs.period_*: Skatteverket files per the month the pay went out (kontantprincipen), so lön i efterskott (August work paid 25 September) is declared in September. The in-period payment-date guard (dashboard PATCH, lib/salary/update-run.ts, v1 PATCH, RunHeader min/max) is lifted rather than widened: its only stated reason was that the AGI keyed on period_*, and any residual month window would bite the next efterskott variant. Existing agi_declarations rows keep their stored period (no backfill): a declaration already filed under the earned month is a real-world correction with Skatteverket, not a re-key. New AGI_PERIOD_CONFLICT (409) refuses to overwrite a live run's declaration for the same payout month, since one month's AGI must cover every payment that month and the generator cannot merge runs. Issue #2191. [2026-09-03] The cursor:// deeplink is its own allowlist provider (cursor_deeplink) rendered "Din egen dator" and never "Verifierad", after the skeptic, CodeRabbit and Superagent all made the same point: a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it carries loopback trust, not vendor trust, and the consent page must not say otherwise; https://www.cursor.com/... keeps the verified label. Same pass fixed the consent-page CSP for custom schemes: new URL('cursor://...').origin is the string "null", so form-action became `'self' null` and Chromium would have blocked the post-consent 303 (correctness skeptic refutation); the header now uses the scheme-source (`cursor:`) when the origin is opaque. Not done: rejecting a missing code_challenge at /authorize. A code minted without one is unexchangeable (verifyPkce against an empty challenge is always false, now pinned by a test), so it is fail-closed; making it fail earlier is a separate change touching every client. +[2026-09-03] settleInvoicePayment writes the invoice_payments row BEFORE the CAS status update and removes it in both failure branches, instead of inserting after the update: the kontantmetod cut-off reads invoice_payments only, so a paid invoice without a row is the #2019 defect itself; failing closed on the insert (voucher storno + INVOICE_PAID_BOOK_FAILED) keeps GL, sub-ledger and invoice status in step. The #2019 backfill inserts only where exactly one posted payment voucher exists (invoice_paid / invoice_cash_payment with source_id = invoice); zero or several vouchers are reported, never guessed, and every row is tagged backfill:#2019 in notes so one DELETE reverts a run. +[2026-09-03] #2019 skeptic round: the invoice_payments row is written by one helper (lib/invoices/invoice-payment-row.ts) from all four transaction-less settlement paths (dashboard, v1, MCP mark-paid, Stripe), with amount = applied amount (new paid_amount minus prior) rather than cash received, so a 3740 öre absorption never produces a negative fordran in the cut-off or a wrong storno restore. A payment row with transaction_id NULL does NOT count as "reconciled to a bank line" in the two duplicate detectors: the bank line for a manual settlement arrives later and the voucher must still be offered as a twin. The backfill dates rows from the voucher entry_date (paid_at was wall-clock before #1332), refuses rows that disagree with the voucher's 1510 credit / settlement debit, reports partially covered invoices (rows_short) instead of patching them, and records each executed run in behandlingshistorik (new event type InvoicePaymentRowBackfilled, migration 20260903180000). Not done here, pre-existing: the bank-match and pending-operations match paths still store cash received as the row amount, and the kontantmetod cut-off ignores ROT/RUT deduction_total (1513 share shows as outstanding); both filed as follow-ups. [2026-09-03] Enable Banking ASPSP_ERROR ladder (#2202): the widest history window a bank has answered is stored per account as accounts_data[].accepted_history_days (no migration; same write-back as dedup_scope and the balance), not as an absolute date, because a bank's limit is a width from today and an absolute known-good date would age into a genuine width rejection. On a rejected window: no wider than accepted = bank unavailable after ONE call; wider = one retry at the accepted width, then unavailable (was up to five calls). AspspUnavailableError maps to 503 BANK_UNAVAILABLE on the web sync route with copy that says the connection does not need renewing; the agent path keeps the contract code BANK_SYNC_FAILED (adding a code touches core contract + structured-errors + v1 docs, left for a follow-up) but no longer persists renewal advice. The envelope's `detail` field is NOT used as a signal: one sample, "Unknown error", identical to a width rejection. A narrowed sync now returns history_from so the UI can say from which date it is complete. [2026-09-03] KPI monthly breakdown counts reversed originals (#2201): the monthly section of get_kpi_report_aggregates (new migration 20260903160000) and lib/reports/monthly-breakdown.ts now use tb_ex_year_end's entry set verbatim (posted + reversed, minus the undone year-end chain) instead of posted-only. A same-year storno then cancels inside the months as it does in the year total, so sum(months) = Nettoresultat; the reversal shows as negative revenue in its own month, which is the honest month view. The pg-real pin "in tb, not in monthly" was flipped, not worked around. Unblocks the per-month sum on Nyckeltal (#2196). [2026-09-03] customers.country and suppliers.country are ISO 3166-1 alpha-2 at every writer (form select, internal + v1 REST, MCP, imports, provider migration), normalised through one helper (lib/vat/country-codes.ts) that also accepts the Swedish/English names the form used to write; unknown text is a 400 on write and left as-is by the backfill (migration 20260903173000 keeps the original in country_raw for a one-UPDATE rollback, and derives the country from the VAT prefix for eu_business rows whose country was null or only the old writer default SE: on prod that is one validated row plus sixteen without a country, and without it they would flip from reverse charge to 25% on their next invoice). No CHECK constraint on the column: unmapped legacy rows would violate it, and the periodisk report already warns on those. The country-vs-type rule (swedish_business = SE, eu_business = not SE and either in the EU VAT area with a matching prefix or holding an EU-trade VAT registration such as a Swiss company with a DE number or Northern Ireland XI, non_eu_business = outside the EU) is enforced on customers only, and on update only when type, country or VAT number is part of the change so a contradictory legacy row can still change its email; individuals are free (a foreign private person is still a Swedish-VAT customer) and suppliers get normalisation without the rule, since #2025/#2028 are about sales VAT. An omitted country on create is SE for Swedish types, derived from the VAT prefix for eu_business, and a 400 for non_eu_business: guessing a non-EU country is not possible, and Sweden-by-default was the bug. vat-rules.ts takes the country as a third optional argument and refuses reverse charge only for SE (a VIES-validated number outweighs a non-EU address), and not for an unknown/unmapped country: charging Swedish VAT to a genuine German customer whose row says Deutschland (Bayern) would be the worse error. diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 77604451..6d768ae0 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -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, + }) + }) }) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts index 9215f9f5..6c7401a7 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -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, ), diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts index 2c8cc405..ccb1f0f5 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -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 = { 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, diff --git a/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts b/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts index efb4eb69..9c2fe217 100644 --- a/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts +++ b/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts @@ -575,6 +575,26 @@ describe('collectKontantmetodCutoff', () => { expect(result.payables[0]).toMatchObject({ outstanding: 9200, vat: 1840 }) }) + // Issue #2019: a manual "Markera som betald" payment carries no bank + // transaction. The cut-off keys on payment DATE alone, so such a row must + // retire the fordran exactly like a bank-matched one. + it('treats a transaction-less manual payment as settling the fordran', async () => { + const result = await collectKontantmetodCutoff(makePagedSupabase({ + invoices: [{ + id: 'inv-manual', invoice_number: '001', invoice_date: '2026-08-01', status: 'paid', + total: 12500, vat_amount: 2500, vat_treatment: 'standard_25', document_type: 'invoice', + currency: 'SEK', + }], + invoice_payments: [{ + id: 'ip-manual', invoice_id: 'inv-manual', amount: 12500, payment_date: '2026-08-28', + transaction_id: null, journal_entry_id: 'je-cash', + }], + supplier_invoices: [], + supplier_invoice_payments: [], + }) as never, 'co-1', '2026-01-01', '2026-12-31') + expect(result.receivables).toEqual([]) + }) + it('collects reverse-charge rate, supplier type, and scaled declaration basis', async () => { const result = await collectKontantmetodCutoff(makePagedSupabase({ supplier_invoices: [{ diff --git a/lib/invoices/__tests__/backfill-invoice-payment-rows.test.ts b/lib/invoices/__tests__/backfill-invoice-payment-rows.test.ts new file mode 100644 index 00000000..769a1bea --- /dev/null +++ b/lib/invoices/__tests__/backfill-invoice-payment-rows.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from 'vitest' +import { + BACKFILL_NOTES_TAG, + planInvoicePaymentBackfill, + settlementSekFromLines, + type BackfillInvoice, + type BackfillVoucher, +} from '../backfill-invoice-payment-rows' + +const invoice = (over: Partial = {}): BackfillInvoice => ({ + id: 'inv-1', + company_id: 'co-1', + user_id: 'user-1', + invoice_number: '001', + status: 'paid', + document_type: 'invoice', + currency: 'SEK', + exchange_rate: null, + paid_amount: 12500, + paid_at: '2026-08-28T12:00:00+00:00', + ...over, +}) + +const NONE = { count: 0, sum: 0 } + +const voucher = (over: Partial = {}): BackfillVoucher => ({ + id: 'je-1', + source_id: 'inv-1', + source_type: 'invoice_cash_payment', + status: 'posted', + entry_date: '2026-08-28', + settlement_sek: 12500, + ...over, +}) + +describe('planInvoicePaymentBackfill', () => { + it('plans one tagged row from the single posted payment voucher', () => { + const plan = planInvoicePaymentBackfill(invoice(), [voucher()], NONE) + expect(plan).toEqual({ + kind: 'insert', + row: { + user_id: 'user-1', + company_id: 'co-1', + invoice_id: 'inv-1', + payment_date: '2026-08-28', + amount: 12500, + currency: 'SEK', + exchange_rate: null, + journal_entry_id: 'je-1', + transaction_id: null, + notes: expect.stringContaining(BACKFILL_NOTES_TAG), + }, + }) + }) + + it('takes the payment date from the voucher, never from paid_at', () => { + const partial = planInvoicePaymentBackfill( + invoice({ status: 'partially_paid', paid_amount: 5000, paid_at: null }), + [voucher({ source_type: 'invoice_paid', entry_date: '2026-08-20', settlement_sek: 5000 })], + NONE, + ) + expect(partial).toMatchObject({ kind: 'insert', row: { payment_date: '2026-08-20', amount: 5000 } }) + + // Pre-#1332 paid_at was the wall-clock registration time: a December + // payment booked in January must stay in December. + const registeredLater = planInvoicePaymentBackfill( + invoice({ paid_at: '2026-01-08T09:12:44Z' }), + [voucher({ entry_date: '2025-12-30' })], + NONE, + ) + expect(registeredLater).toMatchObject({ kind: 'insert', row: { payment_date: '2025-12-30' } }) + }) + + it('keeps the invoice currency and rate on the row', () => { + const plan = planInvoicePaymentBackfill( + invoice({ currency: 'EUR', exchange_rate: 11.5, paid_amount: 1000 }), + [voucher({ source_type: 'invoice_paid', settlement_sek: 11500 })], + NONE, + ) + expect(plan).toMatchObject({ + kind: 'insert', + row: { currency: 'EUR', exchange_rate: 11.5, amount: 1000 }, + }) + }) + + it('skips invoices that already have a sub-ledger row', () => { + expect(planInvoicePaymentBackfill(invoice(), [voucher()], { count: 1, sum: 12500 })).toEqual({ + kind: 'skip', + reason: 'has_rows', + }) + }) + + it('reports rows that sum to less than paid_amount instead of patching the difference', () => { + // Manual partial 4 000 (no row) followed by a bank-matched 6 000 (row). + expect( + planInvoicePaymentBackfill( + invoice({ paid_amount: 10000 }), + [voucher(), voucher({ id: 'je-bank' })], + { count: 1, sum: 6000 }, + ), + ).toEqual({ kind: 'skip', reason: 'rows_short' }) + // Öre noise is not a shortfall. + expect( + planInvoicePaymentBackfill(invoice({ paid_amount: 10000 }), [voucher()], { count: 1, sum: 9999.996 }), + ).toEqual({ kind: 'skip', reason: 'has_rows' }) + }) + + it('skips non-invoices, unpaid invoices and zero paid amounts', () => { + expect(planInvoicePaymentBackfill(invoice({ document_type: 'proforma' }), [voucher()], NONE)) + .toMatchObject({ kind: 'skip', reason: 'not_invoice' }) + expect(planInvoicePaymentBackfill(invoice({ status: 'sent' }), [voucher()], NONE)) + .toMatchObject({ kind: 'skip', reason: 'not_paid' }) + expect(planInvoicePaymentBackfill(invoice({ paid_amount: 0 }), [voucher()], NONE)) + .toMatchObject({ kind: 'skip', reason: 'no_paid_amount' }) + }) + + it('refuses a row whose amount the voucher never booked', () => { + // Header says 12 500 paid, the clearing entry credited 1510 with 12 000. + expect( + planInvoicePaymentBackfill(invoice(), [voucher({ settlement_sek: 12000 })], NONE), + ).toEqual({ kind: 'skip', reason: 'voucher_amount_mismatch', voucherIds: ['je-1'] }) + // Öre absorption on 3740 (voucher 12 500.40 vs paid 12 500) is inside the band. + expect( + planInvoicePaymentBackfill(invoice(), [voucher({ settlement_sek: 12500.4 })], NONE), + ).toMatchObject({ kind: 'insert' }) + // Foreign currency: SEK leg checked through the invoice rate within 1 %. + expect( + planInvoicePaymentBackfill( + invoice({ currency: 'EUR', exchange_rate: 11.5, paid_amount: 1000 }), + [voucher({ settlement_sek: 11000 })], + NONE, + ), + ).toEqual({ kind: 'skip', reason: 'voucher_amount_mismatch', voucherIds: ['je-1'] }) + // No rate and no readable settlement leg cannot be verified. + expect( + planInvoicePaymentBackfill( + invoice({ currency: 'EUR', exchange_rate: null, paid_amount: 1000 }), + [voucher({ settlement_sek: 11500 })], + NONE, + ), + ).toEqual({ kind: 'skip', reason: 'voucher_amount_unverifiable', voucherIds: ['je-1'] }) + expect( + planInvoicePaymentBackfill(invoice(), [voucher({ settlement_sek: null })], NONE), + ).toEqual({ kind: 'skip', reason: 'voucher_amount_unverifiable', voucherIds: ['je-1'] }) + }) + + it('reads the applied amount from the 1510 credit, else from the settlement debit', () => { + expect( + settlementSekFromLines([ + { account_number: '1930', debit_amount: 12500.4, credit_amount: 0 }, + { account_number: '1510', debit_amount: 0, credit_amount: 12500 }, + { account_number: '3740', debit_amount: 0, credit_amount: 0.4 }, + ]), + ).toBe(12500) + // Kontantmetoden ROT: the 1513 leg is Skatteverket's share, not customer money. + expect( + settlementSekFromLines([ + { account_number: '1930', debit_amount: 17500, credit_amount: 0 }, + { account_number: '1513', debit_amount: 7500, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 20000 }, + { account_number: '2611', debit_amount: 0, credit_amount: 5000 }, + ]), + ).toBe(17500) + expect( + settlementSekFromLines([ + { account_number: '1686', debit_amount: 1250, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 1250 }, + ]), + ).toBe(1250) + expect(settlementSekFromLines([])).toBeNull() + }) + + it('reports a row that would land in a closed or locked period instead of writing it', () => { + const closedBefore2026 = (date: string) => date < '2026-01-01' + expect( + planInvoicePaymentBackfill( + invoice(), + [voucher({ entry_date: '2025-12-30' })], + NONE, + { isPeriodClosed: closedBefore2026 }, + ), + ).toEqual({ kind: 'skip', reason: 'period_closed', voucherIds: ['je-1'] }) + expect( + planInvoicePaymentBackfill(invoice(), [voucher()], NONE, { isPeriodClosed: closedBefore2026 }), + ).toMatchObject({ kind: 'insert', row: { payment_date: '2026-08-28' } }) + }) + + it('never guesses the voucher: zero or several posted payment vouchers are skipped', () => { + expect(planInvoicePaymentBackfill(invoice(), [], NONE)).toEqual({ + kind: 'skip', + reason: 'no_payment_voucher', + }) + // A reversed voucher, a registration entry and a voucher of another + // invoice are not payment vouchers of this one. + expect( + planInvoicePaymentBackfill( + invoice(), + [ + voucher({ status: 'reversed' }), + voucher({ id: 'je-reg', source_type: 'invoice_created' }), + voucher({ id: 'je-other', source_id: 'inv-2' }), + ], + NONE, + ), + ).toEqual({ kind: 'skip', reason: 'no_payment_voucher' }) + expect( + planInvoicePaymentBackfill(invoice(), [voucher(), voucher({ id: 'je-2' })], NONE), + ).toEqual({ kind: 'skip', reason: 'multiple_payment_vouchers', voucherIds: ['je-1', 'je-2'] }) + }) +}) diff --git a/lib/invoices/__tests__/duplicate-payment-detection.test.ts b/lib/invoices/__tests__/duplicate-payment-detection.test.ts index e26a8897..ddc0eaed 100644 --- a/lib/invoices/__tests__/duplicate-payment-detection.test.ts +++ b/lib/invoices/__tests__/duplicate-payment-detection.test.ts @@ -203,8 +203,8 @@ describe('detectDuplicatePaymentVoucher', () => { date: '2026-05-15', }), ]) - // invoice_payments has a row linking this JE - enqueue({ data: [{ journal_entry_id: 'je-3' }], error: null }) + // invoice_payments has a row linking this JE to a bank transaction + enqueue({ data: [{ journal_entry_id: 'je-3', transaction_id: 'tx-bank' }], error: null }) enqueue({ data: [], error: null }) const result = await detectDuplicatePaymentVoucher(supabase as never, { @@ -218,6 +218,33 @@ describe('detectDuplicatePaymentVoucher', () => { expect(result).toBeNull() }) + // #2019: "Markera som betald" and Stripe now write a payment row WITHOUT a + // bank transaction. That row means "paid by hand", not "reconciled to a + // bank line", so the voucher must still surface when the real bank line + // arrives; otherwise a second payment voucher posts silently. + it('still flags a voucher whose payment row carries no bank transaction (manual settlement)', async () => { + enqueueLines([ + makeLineRow({ + je_id: 'je-manual', + account: '1930', + debit: 1000, + date: '2026-05-15', + }), + ]) + enqueue({ data: [{ journal_entry_id: 'je-manual', transaction_id: null }], error: null }) + enqueue({ data: [], error: null }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 1000, + transactionCurrency: 'SEK', + }) + + expect(result?.journal_entry_id).toBe('je-manual') + }) + it('excludes JEs already linked from another transaction', async () => { enqueueLines([ makeLineRow({ diff --git a/lib/invoices/__tests__/invoice-payment-row.test.ts b/lib/invoices/__tests__/invoice-payment-row.test.ts new file mode 100644 index 00000000..cc4512fc --- /dev/null +++ b/lib/invoices/__tests__/invoice-payment-row.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { SupabaseClient } from '@supabase/supabase-js' + +const { logError } = vi.hoisted(() => ({ logError: vi.fn() })) +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ error: logError, warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})) + +import { recordInvoicePaymentRow, removeInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row' + +describe('recordInvoicePaymentRow', () => { + beforeEach(() => vi.clearAllMocks()) + + it('stores the applied amount, not the cash received', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' } }) + + // Öresavrundning: 1 235 kr received against a 1 234.75 remaining; the + // 0.25 sits on 3740 and is not part of the receivable. + const result = await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, { + userId: 'user-1', + companyId: 'company-1', + invoice: { id: 'inv-1', currency: 'SEK', exchange_rate: null, paid_amount: 500 }, + paymentDate: '2026-08-28', + newPaidAmount: 1734.75, + journalEntryId: 'je-1', + }) + + expect(result).toEqual({ ok: true, id: 'ip-1' }) + expect(findCalls('invoice_payments', 'insert')[0][0]).toEqual({ + user_id: 'user-1', + company_id: 'company-1', + invoice_id: 'inv-1', + payment_date: '2026-08-28', + amount: 1234.75, + currency: 'SEK', + exchange_rate: null, + journal_entry_id: 'je-1', + transaction_id: null, + notes: null, + }) + }) + + it('reports an insert failure instead of throwing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'rls' } }) + const result = await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, { + userId: 'user-1', + companyId: 'company-1', + invoice: { id: 'inv-1' }, + paymentDate: '2026-08-28', + newPaidAmount: 100, + journalEntryId: 'je-1', + }) + expect(result).toEqual({ ok: false, error: 'rls' }) + }) +}) + +describe('removeInvoicePaymentRow', () => { + beforeEach(() => vi.clearAllMocks()) + + it('deletes by id and company and reports success', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: null }) + await expect( + removeInvoicePaymentRow(supabase as unknown as SupabaseClient, 'company-1', 'ip-1'), + ).resolves.toBe(true) + expect(findCalls('invoice_payments', 'delete')).toHaveLength(1) + expect(findCalls('invoice_payments', 'eq')).toEqual([ + ['id', 'ip-1'], + ['company_id', 'company-1'], + ]) + expect(logError).not.toHaveBeenCalled() + }) + + it('logs a failed rollback at error level with the row id, and never throws', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'permission denied' } }) + await expect( + removeInvoicePaymentRow(supabase as unknown as SupabaseClient, 'company-1', 'ip-1'), + ).resolves.toBe(false) + expect(logError).toHaveBeenCalledWith( + expect.stringContaining('stranded'), + { message: 'permission denied' }, + { companyId: 'company-1', invoicePaymentId: 'ip-1' }, + ) + }) + + it('is a no-op without a row id', async () => { + const { supabase, findCalls } = createQueuedMockSupabase() + await expect( + removeInvoicePaymentRow(supabase as unknown as SupabaseClient, 'company-1', null), + ).resolves.toBe(true) + expect(findCalls('invoice_payments', 'delete')).toHaveLength(0) + }) +}) diff --git a/lib/invoices/__tests__/settle-invoice-payment.test.ts b/lib/invoices/__tests__/settle-invoice-payment.test.ts index 314e82d4..ed829987 100644 --- a/lib/invoices/__tests__/settle-invoice-payment.test.ts +++ b/lib/invoices/__tests__/settle-invoice-payment.test.ts @@ -79,6 +79,7 @@ describe('settleInvoicePayment', () => { it('books via the payment entry and forwards the settlement account', async () => { const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched const invoice = payableInvoice({ journal_entry_id: 'je-orig' } as Partial) @@ -161,6 +162,7 @@ describe('settleInvoicePayment', () => { it('uses the cash entry for unbooked kontantmetoden invoices', async () => { const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: [{ id: 'inv-1' }] }) const invoice = payableInvoice({ journal_entry_id: null } as Partial) @@ -189,6 +191,7 @@ describe('settleInvoicePayment', () => { vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1') vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-ore' } as never) const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched // Invoice total 1234.75, PDF "Att betala" 1235.00: the customer pays the @@ -227,6 +230,7 @@ describe('settleInvoicePayment', () => { vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1') vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-partial' } as never) const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched // Deliberate partial: both legs lowered, no 3740. Absorbing here would @@ -357,6 +361,7 @@ describe('settleInvoicePayment', () => { it('cancels the orphaned voucher when the CAS update loses the race', async () => { const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: [] }) // CAS update matched nothing (concurrent settle) const result = await settleInvoicePayment( @@ -380,6 +385,7 @@ describe('settleInvoicePayment', () => { // pointing at it as a match suggestion. it('retires the settled invoice suggestions when the invoice reaches paid', async () => { const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: [{ id: 'inv-1' }] }) const result = await settleInvoicePayment( @@ -401,6 +407,7 @@ describe('settleInvoicePayment', () => { it('leaves the suggestions alone on a partial payment: the invoice is still matchable', async () => { const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: [{ id: 'inv-1' }] }) const result = await settleInvoicePayment( @@ -422,6 +429,7 @@ describe('settleInvoicePayment', () => { const handler = vi.fn() eventBus.on('invoice.paid', handler) const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: [{ id: 'inv-1' }] }) await settleInvoicePayment(supabase as unknown as SupabaseClient, 'company-1', 'user-1', { @@ -443,4 +451,156 @@ describe('settleInvoicePayment', () => { const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] expect(invoiceUpdate).toMatchObject({ paid_at: '2026-07-12T12:00:00Z' }) }) + + // Issue #2019: the manual and Stripe flows never wrote the AR sub-ledger + // row, so the kontantmetod cut-off (which reads invoice_payments only) + // booked a paid invoice as a fordran with vilande moms at bokslut. + describe('invoice_payments row (#2019)', () => { + it('records the payment in the sub-ledger with the voucher and no bank transaction', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' } }) // invoice_payments insert + enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched + + const invoice = payableInvoice({ + journal_entry_id: 'je-orig', + exchange_rate: 1, + } as Partial) + const result = await settleInvoicePayment( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + { ...BASE_PARAMS, invoice }, + ) + + expect(result).toMatchObject({ ok: true, newStatus: 'paid', journalEntryId: 'je-1' }) + const inserts = findCalls('invoice_payments', 'insert') + expect(inserts).toHaveLength(1) + expect(inserts[0][0]).toEqual({ + user_id: 'user-1', + company_id: 'company-1', + invoice_id: 'inv-1', + payment_date: '2026-07-12', + amount: 1250, + currency: 'SEK', + exchange_rate: 1, + journal_entry_id: 'je-1', + transaction_id: null, + notes: null, + }) + expect(findCalls('invoice_payments', 'delete')).toHaveLength(0) + }) + + it('stores a partial in invoice currency, not the SEK line total', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' } }) + enqueue({ data: [{ id: 'inv-1' }] }) + + const invoice = payableInvoice({ + total: 1000, + remaining_amount: 1000, + currency: 'EUR', + exchange_rate: 11.5, + journal_entry_id: 'je-orig', + } as Partial) + const result = await settleInvoicePayment( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + { ...BASE_PARAMS, invoice, paymentAmountInInvoiceCurrency: 400 }, + ) + + expect(result).toMatchObject({ ok: true, newStatus: 'partially_paid', newRemaining: 600 }) + expect(findCalls('invoice_payments', 'insert')[0][0]).toMatchObject({ + amount: 400, + currency: 'EUR', + exchange_rate: 11.5, + transaction_id: null, + }) + }) + + it('writes the row before the CAS update so the invoice never reaches paid without it', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { code: '42501', message: 'rls' } }) // insert refused + // Deliberately no CAS slot: the update must not run. + + const result = await settleInvoicePayment( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + { ...BASE_PARAMS, invoice: payableInvoice() }, + ) + + expect(result).toMatchObject({ + ok: false, + code: 'INVOICE_PAID_BOOK_FAILED', + details: { reason: 'payment_row_insert_failed', error: 'rls' }, + }) + expect(vi.mocked(cancelOrphanedPaymentEntry)).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'je-1', + expect.any(String), + ) + }) + + it('removes the row together with the voucher when the CAS update loses the race', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' } }) // insert + enqueue({ data: [] }) // CAS matched nothing + enqueue({ data: null }) // delete + + const result = await settleInvoicePayment( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + { ...BASE_PARAMS, invoice: payableInvoice() }, + ) + + expect(result).toMatchObject({ ok: false, code: 'INVOICE_PAID_RACE' }) + expect(findCalls('invoice_payments', 'delete')).toHaveLength(1) + const deleteEqs = findCalls('invoice_payments', 'eq') + expect(deleteEqs).toEqual( + expect.arrayContaining([ + ['id', 'ip-1'], + ['company_id', 'company-1'], + ]), + ) + expect(vi.mocked(cancelOrphanedPaymentEntry)).toHaveBeenCalledTimes(1) + }) + + it('removes the row when the invoice update itself fails', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-1' } }) // insert + enqueue({ data: null, error: { message: 'update failed' } }) // CAS update error + enqueue({ data: null }) // delete + + const result = await settleInvoicePayment( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + { ...BASE_PARAMS, invoice: payableInvoice() }, + ) + + expect(result).toMatchObject({ ok: false, code: 'UPDATE_FAILED' }) + expect(findCalls('invoice_payments', 'delete')).toHaveLength(1) + expect(vi.mocked(cancelOrphanedPaymentEntry)).toHaveBeenCalledTimes(1) + }) + + it('skips the sub-ledger for non-invoice document types', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: [{ id: 'inv-1' }] }) // CAS update only + + const invoice = payableInvoice({ document_type: 'proforma' } as Partial) + const result = await settleInvoicePayment( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + { ...BASE_PARAMS, invoice }, + ) + + expect(result).toMatchObject({ ok: true, journalEntryId: null }) + expect(findCalls('invoice_payments', 'insert')).toHaveLength(0) + }) + }) }) diff --git a/lib/invoices/backfill-invoice-payment-rows.ts b/lib/invoices/backfill-invoice-payment-rows.ts new file mode 100644 index 00000000..151cb6c3 --- /dev/null +++ b/lib/invoices/backfill-invoice-payment-rows.ts @@ -0,0 +1,210 @@ +/** + * Planner for the #2019 backfill: paid or partially paid customer invoices + * that were settled through "Markera som betald" (or the Stripe sync) before + * settleInvoicePayment wrote the AR sub-ledger row. Pure: the script in + * scripts/backfill-invoice-payment-rows.ts owns the reads and writes. + * + * Deterministic on purpose (project doctrine: never guess). A row is planned + * only when the invoice has exactly ONE posted payment voucher, so the + * journal link is unambiguous. Everything else is reported and skipped: + * zero vouchers (imported / migrated invoices never booked here), several + * vouchers (partials whose split cannot be reconstructed from the header), + * or a row already present (bank-matched, link-to-voucher, or an earlier run). + */ + +import { roundOre } from '@/lib/money' + +/** Tag written to invoice_payments.notes so one DELETE reverts a whole run. */ +export const BACKFILL_NOTES_TAG = 'backfill:#2019' + +/** Source types settleInvoicePayment produces (lib/bookkeeping/invoice-entries.ts). */ +export const PAYMENT_VOUCHER_SOURCE_TYPES = ['invoice_paid', 'invoice_cash_payment'] as const + +export interface BackfillInvoice { + id: string + company_id: string + user_id: string + invoice_number: string | null + status: string + document_type: string | null + currency: string | null + exchange_rate: number | null + paid_amount: number | null + paid_at: string | null +} + +export interface BackfillVoucher { + id: string + source_id: string | null + source_type: string + status: string + entry_date: string + /** + * What the voucher actually applied to the receivable, in SEK: the 1510 + * credit for a clearing entry (faktureringsmetoden), else the debit on the + * settlement account (19xx / 1686) for a kontantmetoden cash entry. null + * when neither leg exists; undefined when the caller did not load lines. + */ + settlement_sek?: number | null +} + +/** + * Derive `settlement_sek` from a voucher's lines. Exported for the script and + * its test; the planner only consumes the result. + */ +export function settlementSekFromLines( + lines: Array<{ account_number: string; debit_amount: number | null; credit_amount: number | null }>, +): number | null { + const credit1510 = lines + .filter((l) => l.account_number === '1510') + .reduce((sum, l) => sum + Number(l.credit_amount ?? 0), 0) + if (credit1510 > 0) return roundOre(credit1510) + const settlementDebit = lines + .filter((l) => l.account_number.startsWith('19') || l.account_number === '1686') + .reduce((sum, l) => sum + Number(l.debit_amount ?? 0), 0) + if (settlementDebit > 0) return roundOre(settlementDebit) + return null +} + +export interface BackfillPaymentRow { + user_id: string + company_id: string + invoice_id: string + payment_date: string + amount: number + currency: string + exchange_rate: number | null + journal_entry_id: string + transaction_id: null + notes: string +} + +export type BackfillSkipReason = + | 'has_rows' + | 'rows_short' + | 'not_invoice' + | 'not_paid' + | 'no_paid_amount' + | 'no_payment_voucher' + | 'multiple_payment_vouchers' + | 'voucher_amount_mismatch' + | 'voucher_amount_unverifiable' + | 'period_closed' + +export type BackfillPlan = + | { kind: 'insert'; row: BackfillPaymentRow } + | { kind: 'skip'; reason: BackfillSkipReason; voucherIds?: string[] } + +export interface ExistingPaymentRows { + count: number + /** Sum of invoice_payments.amount, invoice currency. */ + sum: number +} + +/** + * Decide what to do for one invoice given every voucher whose source_id + * points at it and the invoice_payments rows it already has. + * + * Rows present but summing to less than paid_amount means an earlier manual + * partial has no row while a later bank-matched one does. That invoice is + * `rows_short`: reported for a human, never patched, because the difference + * cannot be attributed to a voucher without guessing. + */ +export interface BackfillPlanOptions { + /** + * Whether the fiscal period covering `date` (YYYY-MM-DD) for this invoice's + * company is closed or locked. A row dated into such a period changes facts + * a filed bokslut or deklaration relied on, so it is reported, not written. + */ + isPeriodClosed?: (date: string) => boolean +} + +export function planInvoicePaymentBackfill( + invoice: BackfillInvoice, + vouchers: BackfillVoucher[], + existing: ExistingPaymentRows, + options: BackfillPlanOptions = {}, +): BackfillPlan { + const paidAmountRaw = Number(invoice.paid_amount ?? 0) + if (existing.count > 0) { + const short = roundOre(paidAmountRaw - existing.sum) + return short > 0 ? { kind: 'skip', reason: 'rows_short' } : { kind: 'skip', reason: 'has_rows' } + } + if (invoice.document_type && invoice.document_type !== 'invoice') { + return { kind: 'skip', reason: 'not_invoice' } + } + if (invoice.status !== 'paid' && invoice.status !== 'partially_paid') { + return { kind: 'skip', reason: 'not_paid' } + } + const paidAmount = Number(invoice.paid_amount ?? 0) + if (!Number.isFinite(paidAmount) || paidAmount <= 0) { + return { kind: 'skip', reason: 'no_paid_amount' } + } + + const paymentVouchers = vouchers.filter( + (v) => + v.source_id === invoice.id && + v.status === 'posted' && + (PAYMENT_VOUCHER_SOURCE_TYPES as readonly string[]).includes(v.source_type), + ) + if (paymentVouchers.length === 0) return { kind: 'skip', reason: 'no_payment_voucher' } + if (paymentVouchers.length > 1) { + return { + kind: 'skip', + reason: 'multiple_payment_vouchers', + voucherIds: paymentVouchers.map((v) => v.id), + } + } + const voucher = paymentVouchers[0] + + // The row must agree with what the voucher booked, or the cut-off inherits + // a header figure the ledger never carried. SEK invoices must match to the + // öre band; foreign-currency ones are checked through the invoice rate + // within 1 %. An unreadable voucher (no 1510 credit, no settlement debit) or + // a rate-less foreign invoice cannot be verified and is left to a human. + const settlementSek = voucher.settlement_sek + if (settlementSek === undefined || settlementSek === null) { + return { kind: 'skip', reason: 'voucher_amount_unverifiable', voucherIds: [voucher.id] } + } + const currency = invoice.currency ?? 'SEK' + if (currency === 'SEK') { + if (Math.abs(settlementSek - paidAmount) > 0.5) { + return { kind: 'skip', reason: 'voucher_amount_mismatch', voucherIds: [voucher.id] } + } + } else { + const rate = Number(invoice.exchange_rate ?? 0) + if (!(rate > 0)) { + return { kind: 'skip', reason: 'voucher_amount_unverifiable', voucherIds: [voucher.id] } + } + if (Math.abs(settlementSek / rate - paidAmount) > paidAmount * 0.01) { + return { kind: 'skip', reason: 'voucher_amount_mismatch', voucherIds: [voucher.id] } + } + } + + // The voucher's entry_date is the affärshändelse date (BFL 5 kap 7 §) and + // is what the settle paths stamp from the user's payment date. paid_at is + // NOT usable: before 2026-08-02 (#1332) it was the wall-clock registration + // time, so a payment booked in January for a December date would land in + // the wrong year. The two agree for every row written since. + const paymentDate = voucher.entry_date + + if (options.isPeriodClosed?.(paymentDate)) { + return { kind: 'skip', reason: 'period_closed', voucherIds: [voucher.id] } + } + + return { + kind: 'insert', + row: { + user_id: invoice.user_id, + company_id: invoice.company_id, + invoice_id: invoice.id, + payment_date: paymentDate, + amount: roundOre(paidAmount), + currency: invoice.currency ?? 'SEK', + exchange_rate: invoice.exchange_rate ?? null, + journal_entry_id: voucher.id, + transaction_id: null, + notes: `${BACKFILL_NOTES_TAG} Markera som betald utan betalningsrad; verifikat ${voucher.id}`, + }, + } +} diff --git a/lib/invoices/duplicate-payment-detection.ts b/lib/invoices/duplicate-payment-detection.ts index 7316307f..0be2699f 100644 --- a/lib/invoices/duplicate-payment-detection.ts +++ b/lib/invoices/duplicate-payment-detection.ts @@ -204,13 +204,16 @@ export async function detectDuplicatePaymentVoucher( if (candidates.length === 0) return null - // Exclude entries already linked from invoice_payments or any transaction. + // Exclude entries already linked to a bank transaction, directly or through + // an invoice_payments row that carries one. A payment row with + // transaction_id NULL is a manual / Stripe settlement (#2019): its bank line + // has not been matched yet, so the voucher stays a duplicate candidate. const entryIds = candidates.map((l) => l.journal_entry.id) const [{ data: paymentLinks }, { data: txLinks }] = await Promise.all([ supabase .from('invoice_payments') - .select('journal_entry_id') + .select('journal_entry_id, transaction_id') .eq('company_id', companyId) .in('journal_entry_id', entryIds), supabase @@ -221,8 +224,11 @@ export async function detectDuplicatePaymentVoucher( ]) const linkedIds = new Set() - for (const row of (paymentLinks ?? []) as { journal_entry_id: string | null }[]) { - if (row.journal_entry_id) linkedIds.add(row.journal_entry_id) + for (const row of (paymentLinks ?? []) as { + journal_entry_id: string | null + transaction_id: string | null + }[]) { + if (row.journal_entry_id && row.transaction_id) linkedIds.add(row.journal_entry_id) } for (const row of (txLinks ?? []) as { id: string; journal_entry_id: string | null }[]) { // A transaction can link to its own JE via the current match flow: but diff --git a/lib/invoices/invoice-payment-row.ts b/lib/invoices/invoice-payment-row.ts new file mode 100644 index 00000000..1af475a3 --- /dev/null +++ b/lib/invoices/invoice-payment-row.ts @@ -0,0 +1,111 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' +import { roundOre } from '@/lib/money' + +const log = createLogger('invoice-payment-row') + +/** + * The AR sub-ledger row for a payment that no bank transaction drives: + * "Markera som betald" (dashboard, v1, MCP) and the Stripe payment sync. + * + * Without the row the payment has no DATE anywhere. The kontantmetod bokslut + * cut-off (lib/core/bookkeeping/kontantmetod-cutoff.ts) reads invoice_payments + * only and would book a paid invoice as a fordran with vilande moms at year + * end, double-counting revenue and VAT (#2019); the Betalningar view and the + * voucher -> invoice reference map read the same table. + * + * Shape mirrors the bank-match path (app/api/transactions/[id]/match-invoice): + * amount in INVOICE currency, transaction_id null. The + * (transaction_id, invoice_id) unique index treats nulls as distinct, so + * several manual partials on one invoice coexist; (journal_entry_id, + * invoice_id) still refuses the same voucher twice. + * + * `amount` is the amount APPLIED to the invoice (new paid_amount minus the + * prior one), not the cash received: a SEK öresavrundning overshoot absorbed + * on 3740 is part of the voucher but not of the receivable, and every reader + * subtracts rows from `total`. + */ +export interface RecordInvoicePaymentRowParams { + userId: string + companyId: string + invoice: { + id: string + currency?: string | null + exchange_rate?: number | null + paid_amount?: number | null + } + /** Booking date (YYYY-MM-DD); same value the voucher carries. */ + paymentDate: string + /** paid_amount after this payment, in invoice currency. */ + newPaidAmount: number + journalEntryId: string | null +} + +export type RecordInvoicePaymentRowResult = + | { ok: true; id: string } + | { ok: false; error: string } + +export async function recordInvoicePaymentRow( + supabase: SupabaseClient, + params: RecordInvoicePaymentRowParams, +): Promise { + const { userId, companyId, invoice, paymentDate, newPaidAmount, journalEntryId } = params + const amount = roundOre(newPaidAmount - (invoice.paid_amount ?? 0)) + + const { data, error } = await supabase + .from('invoice_payments') + .insert({ + user_id: userId, + company_id: companyId, + invoice_id: invoice.id, + payment_date: paymentDate, + amount, + currency: invoice.currency ?? 'SEK', + exchange_rate: invoice.exchange_rate ?? null, + journal_entry_id: journalEntryId, + transaction_id: null, + notes: null, + }) + .select('id') + .single() + + if (error || !data) { + return { ok: false, error: error?.message ?? 'no_row_returned' } + } + return { ok: true, id: (data as { id: string }).id } +} + +/** + * Undo the row on a failed settlement. Best-effort like the voucher storno + * next to it: the caller is already on a decided error path (race or update + * failure), and that response must not be replaced by a delete error. Never + * throws, but never silent either: a row that survives here points at a + * cancelled voucher for an invoice that never reached paid, and the + * kontantmetod cut-off would read it as a settlement, so the failure is + * logged at error level with everything an operator needs to delete it. + * + * @returns true when the row is gone, false when it may be stranded. + */ +export async function removeInvoicePaymentRow( + supabase: SupabaseClient, + companyId: string, + paymentRowId: string | null, +): Promise { + if (!paymentRowId) return true + const ctx = { companyId, invoicePaymentId: paymentRowId } + try { + const { error } = await supabase + .from('invoice_payments') + .delete() + .eq('id', paymentRowId) + .eq('company_id', companyId) + if (error) { + log.error('invoice_payments rollback failed (row may be stranded)', error, ctx) + return false + } + return true + } catch (err) { + log.error('invoice_payments rollback threw (row may be stranded)', err as Error, ctx) + return false + } +} diff --git a/lib/invoices/settle-invoice-payment.ts b/lib/invoices/settle-invoice-payment.ts index c97e94e5..0906bd89 100644 --- a/lib/invoices/settle-invoice-payment.ts +++ b/lib/invoices/settle-invoice-payment.ts @@ -10,6 +10,7 @@ import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' 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 { eventBus } from '@/lib/events' import type { CreateJournalEntryInput, Customer, EntityType, Invoice } from '@/types' @@ -23,9 +24,13 @@ import type { CreateJournalEntryInput, Customer, EntityType, Invoice } from '@/t * 1. planInvoicePayment: ledger math + overpayment guard * 2. journal entry: custom lines | cash entry (kontantmetoden, unbooked) | * payment entry (clears 1510), fail-closed for real invoices - * 3. CAS-guarded invoice status update; a lost race or failed update cancels - * the just-posted voucher so GL and sub-ledger never diverge - * 4. invoice.paid event (best-effort) + * 3. invoice_payments row (the AR sub-ledger): the only source of the + * payment DATE, which the kontantmetod bokslut cut-off, the voucher -> + * invoice reference map and the "Betalningar" view all read (#2019) + * 4. CAS-guarded invoice status update; a lost race or failed update cancels + * the just-posted voucher and removes the payment row so GL and + * sub-ledger never diverge + * 5. invoice.paid event (best-effort) * * `settlementAccountNumber` routes the debit side: default 1930 (bank), 1686 * for PSP-balance settlements (Stripe) where the money reaches the bank only @@ -272,6 +277,39 @@ export async function settleInvoicePayment( } } + // Sub-ledger row (see lib/invoices/invoice-payment-row.ts for why and for + // the shape). Written BEFORE the CAS update so the failure branches below + // can undo it together with the voucher; a real invoice never reaches paid + // through this service without it. + let paymentRowId: string | null = null + if (isRealInvoice) { + const recorded = await recordInvoicePaymentRow(supabase, { + userId, + companyId, + invoice, + paymentDate, + newPaidAmount, + journalEntryId, + }) + if (!recorded.ok) { + if (journalEntryId) { + await cancelOrphanedPaymentEntry( + supabase, + companyId, + userId, + journalEntryId, + 'Automatiskt makulerad: betalningsraden kunde inte sparas efter bokförd betalning', + ) + } + return { + ok: false, + code: 'INVOICE_PAID_BOOK_FAILED', + details: { reason: 'payment_row_insert_failed', error: recorded.error }, + } + } + paymentRowId = recorded.id + } + // CAS guard: only update if status is still in a payable state. const { data: updateResult, error: updateError } = await supabase .from('invoices') @@ -289,6 +327,7 @@ export async function settleInvoicePayment( if (updateError) { // The payment voucher already posted but the invoice row did not flip to // paid; cancel the orphan so the GL doesn't diverge from the sub-ledger. + await removeInvoicePaymentRow(supabase, companyId, paymentRowId) if (journalEntryId) { await cancelOrphanedPaymentEntry( supabase, @@ -304,6 +343,7 @@ export async function settleInvoicePayment( if (!updateResult || updateResult.length === 0) { // Status changed between read and write (concurrent settle): cancel the // orphaned payment voucher; the trigger documents the voucher gap. + await removeInvoicePaymentRow(supabase, companyId, paymentRowId) if (journalEntryId) { await cancelOrphanedPaymentEntry( supabase, diff --git a/lib/pending-operations/__tests__/mark-invoice-paid.test.ts b/lib/pending-operations/__tests__/mark-invoice-paid.test.ts index 1c6de18c..5d500dbe 100644 --- a/lib/pending-operations/__tests__/mark-invoice-paid.test.ts +++ b/lib/pending-operations/__tests__/mark-invoice-paid.test.ts @@ -122,6 +122,7 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () => 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 @@ -157,6 +158,15 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () => // 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 diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 77abd9cf..5815c690 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -97,6 +97,7 @@ import { type LinkSupplierInvoiceToVoucherResult, } from '@/lib/invoices/supplier-voucher-matching' 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 { clearSettledBatchAllocationSuggestions, @@ -2662,6 +2663,48 @@ async function commitMarkInvoicePaid( } } + // 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(supabase, { + userId, + companyId, + invoice: { + id: invoiceId, + currency: invoice.currency, + exchange_rate: invoice.exchange_rate, + paid_amount: inv.paid_amount, + }, + paymentDate, + newPaidAmount, + journalEntryId, + }) + if (!recorded.ok) { + // Raw driver text stays server-side; the user gets the outcome only. + log.error('mark_invoice_paid: invoice_payments insert failed', undefined, { + invoiceId, + companyId, + error: recorded.error, + }) + if (journalEntryId) { + await cancelOrphanedPaymentEntry( + supabase, companyId, userId, journalEntryId, + 'Automatiskt makulerad: betalningsraden kunde inte sparas efter bokförd betalning', + ) + } + return { + error: + 'Betalningen kunde inte registreras i reskontran. ' + + 'Verifikationen har makulerats och fakturan har inte markerats som betald.', + status: 500, + } + } + paymentRowId = recorded.id + } + const paidAt = newStatus === 'paid' ? paidAtFromDate(paymentDate) : null // CAS guard: only flip from a payable status so a concurrently-settled // invoice no-ops here instead of double-booking the payment. @@ -2681,6 +2724,7 @@ async function commitMarkInvoicePaid( if (updateError) { // The payment voucher already posted but the invoice row did not flip; // cancel the orphan so the GL doesn't diverge from the sub-ledger. + await removeInvoicePaymentRow(supabase, companyId, paymentRowId) if (journalEntryId) { await cancelOrphanedPaymentEntry( supabase, companyId, userId, journalEntryId, @@ -2694,6 +2738,7 @@ async function commitMarkInvoicePaid( // Race lost: the invoice was settled concurrently between our read and // write. Cancel the orphaned payment voucher and document the gap rather // than leaving a double booking. + await removeInvoicePaymentRow(supabase, companyId, paymentRowId) if (journalEntryId) { await cancelOrphanedPaymentEntry( supabase, companyId, userId, journalEntryId, diff --git a/lib/processing-history/append.ts b/lib/processing-history/append.ts index 1c237bb8..151e2d86 100644 --- a/lib/processing-history/append.ts +++ b/lib/processing-history/append.ts @@ -57,6 +57,7 @@ export const PROCESSING_EVENT_TYPES = [ 'InboxUnderlagReconciled', 'InvoiceDuplicatePaymentDismissed', 'InvoiceJournalEntrySkipped', + 'InvoicePaymentRowBackfilled', 'OAuthClientRevoked', 'PendingOperationApproved', 'PendingOperationRejected', diff --git a/lib/transactions/__tests__/booking-duplicate-detection.test.ts b/lib/transactions/__tests__/booking-duplicate-detection.test.ts index 490fb40c..3e0362cf 100644 --- a/lib/transactions/__tests__/booking-duplicate-detection.test.ts +++ b/lib/transactions/__tests__/booking-duplicate-detection.test.ts @@ -447,7 +447,7 @@ function makeLedgerSupabase(opts: { ledgerAccount?: string | null lines?: Jel[] txLinks?: LedgerTxLink[] - payLinks?: { journal_entry_id: string }[] + payLinks?: { journal_entry_id: string; transaction_id?: string | null }[] transactionRows?: TxRow[] // siblings for the orchestrator fall-through }) { // Fixtures stay embed-shaped for readability; the two-step fetch reads the @@ -598,13 +598,31 @@ describe('detectLedgerDuplicateVoucher', () => { }) it('excludes a voucher already linked to an invoice payment', async () => { - const supabase = makeLedgerSupabase({ lines: [jel()], payLinks: [{ journal_entry_id: 'je-2' }] }) + const supabase = makeLedgerSupabase({ + lines: [jel()], + payLinks: [{ journal_entry_id: 'je-2', transaction_id: 'tx-bank' }], + }) const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null, }) expect(result).toBeNull() }) + // #2019: a manual / Stripe settlement writes a payment row with no bank + // transaction. The bank line for that money arrives later; the voucher must + // stay a twin so the user is offered a link instead of a blind re-booking. + it('keeps a voucher whose payment row has no bank transaction as a twin', async () => { + const supabase = makeLedgerSupabase({ + lines: [jel()], + payLinks: [{ journal_entry_id: 'je-2', transaction_id: null }], + }) + const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null, + }) + expect(result?.journal_entry_id).toBe('je-2') + expect(result?.transaction_id).toBeNull() + }) + // ── De-exclusion (gap G3): the linking transaction is itself the twin ───── it('returns a voucher whose linking transaction itself matches the target, with transaction_id set', async () => { // The date-drifted duplicate-import shape: one copy of the bank fee is diff --git a/lib/transactions/booking-duplicate-detection.ts b/lib/transactions/booking-duplicate-detection.ts index a506dedf..81f67a18 100644 --- a/lib/transactions/booking-duplicate-detection.ts +++ b/lib/transactions/booking-duplicate-detection.ts @@ -683,7 +683,11 @@ export async function detectLedgerDuplicateVoucher( .select('id, date, amount, currency, cash_account_id, journal_entry_id') .eq('company_id', companyId) .in('journal_entry_id', entryIds), - supabase.from('invoice_payments').select('journal_entry_id').eq('company_id', companyId).in('journal_entry_id', entryIds), + supabase + .from('invoice_payments') + .select('journal_entry_id, transaction_id') + .eq('company_id', companyId) + .in('journal_entry_id', entryIds), ]) type LinkedTxRow = { @@ -712,9 +716,14 @@ export async function detectLedgerDuplicateVoucher( arr.push(r) linkedTxByEntry.set(r.journal_entry_id, arr) } + // Only a payment row that carries a bank transaction means "reconciled to + // a bank line". A row with transaction_id NULL is a manual / Stripe + // settlement (#2019): the bank line for that money is still to come, and + // this voucher must stay a twin candidate so the user is offered a link + // instead of a blind second booking. const paymentLinked = new Set() - for (const r of (payLinks ?? []) as { journal_entry_id: string | null }[]) { - if (r.journal_entry_id) paymentLinked.add(r.journal_entry_id) + for (const r of (payLinks ?? []) as { journal_entry_id: string | null; transaction_id: string | null }[]) { + if (r.journal_entry_id && r.transaction_id) paymentLinked.add(r.journal_entry_id) } const survivors: { line: (typeof candidates)[number]; twinTransactionId: string | null }[] = [] diff --git a/scripts/backfill-invoice-payment-rows.ts b/scripts/backfill-invoice-payment-rows.ts new file mode 100644 index 00000000..db531569 --- /dev/null +++ b/scripts/backfill-invoice-payment-rows.ts @@ -0,0 +1,361 @@ +#!/usr/bin/env npx tsx +/** + * Backfill for issue #2019: customer invoices settled through "Markera som + * betald" (or the Stripe payment sync) before settleInvoicePayment wrote the + * invoice_payments row. + * + * Why it matters: the kontantmetod bokslut cut-off reads invoice_payments + * ONLY (payment DATE, not remaining_amount), so a paid invoice without a row + * is re-booked as a fordran with vilande moms at year end, double-counting + * revenue and VAT. The same gap hides the payment from the "Betalningar" + * view and from the voucher -> invoice reference map. + * + * What it writes: one row per invoice, amount = paid_amount in invoice + * currency, payment_date = the payment voucher's entry_date (paid_at was the + * wall-clock registration time before 2026-08-02 and is not trusted), + * journal_entry_id = the single posted payment voucher, transaction_id NULL, + * notes tagged `backfill:#2019` so the whole run reverts with one statement: + * + * DELETE FROM invoice_payments WHERE notes LIKE 'backfill:#2019%'; + * + * That revert is an emergency path for the window in which nothing has + * relied on the rows yet (BFL 5 kap 5 §). Rows are never written into a + * closed or locked fiscal period (see period_closed below), and once a + * bokslut cut-off has been posted from them the correction path is a storno + * of that cut-off verifikat, not a DELETE of the sub-ledger rows. + * + * Never guesses (lib/invoices/backfill-invoice-payment-rows.ts): an invoice + * with zero or several posted payment vouchers is listed, not written, and + * so is one whose existing rows sum to less than paid_amount (a pre-fix + * manual partial next to a bank-matched one) or whose voucher booked a + * different amount than the header says, or whose payment date falls in a + * closed or locked fiscal period (a filed bokslut or deklaration may rely on + * that year's cut-off). Idempotent: invoices whose rows cover paid_amount + * are excluded. + * + * Every executed run is recorded in behandlingshistorik (one + * InvoicePaymentRowBackfilled event per company, BFL 5 kap 11 §): the rows + * feed the bokslut cut-off, so the bulk write is a change to processing. + * + * Usage: + * npx tsx scripts/backfill-invoice-payment-rows.ts # dry-run (default) + * npx tsx scripts/backfill-invoice-payment-rows.ts --execute # apply + * npx tsx scripts/backfill-invoice-payment-rows.ts --company # one company only + * npx tsx scripts/backfill-invoice-payment-rows.ts --verbose # list every skipped invoice + * + * DRY-RUN IS THE DEFAULT. Point NEXT_PUBLIC_SUPABASE_URL / + * SUPABASE_SERVICE_ROLE_KEY (.env.local) at staging first; prod only after + * explicit confirmation. Service role bypasses RLS but not the + * payment_company_consistency trigger, so a row can never land on the wrong + * tenant. + */ + +import { config } from 'dotenv' +config({ path: '.env.local' }) +import { randomUUID } from 'node:crypto' +import { createClient } from '@supabase/supabase-js' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { roundOre } from '@/lib/money' +import { appendProcessingHistoryWithClient } from '@/lib/processing-history/append' +import { + BACKFILL_NOTES_TAG, + PAYMENT_VOUCHER_SOURCE_TYPES, + planInvoicePaymentBackfill, + settlementSekFromLines, + type BackfillInvoice, + type BackfillPaymentRow, + type BackfillSkipReason, + type BackfillVoucher, + type ExistingPaymentRows, +} from '@/lib/invoices/backfill-invoice-payment-rows' + +const EXECUTE = process.argv.includes('--execute') +const VERBOSE = process.argv.includes('--verbose') +const companyArgIndex = process.argv.indexOf('--company') +const COMPANY_FILTER = companyArgIndex >= 0 ? process.argv[companyArgIndex + 1] : null + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!supabaseUrl || !serviceRoleKey) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local') + process.exit(1) +} +if (companyArgIndex >= 0 && !COMPANY_FILTER) { + console.error('--company needs a company id') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) + +const CHUNK = 200 +const INSERT_BATCH = 100 + +function chunk(items: T[], size: number): T[][] { + const out: T[][] = [] + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)) + return out +} + +async function main() { + console.log(`Target: ${supabaseUrl}`) + console.log(EXECUTE ? 'MODE: EXECUTE (writing)' : 'MODE: dry-run (no writes)') + if (COMPANY_FILTER) console.log(`Company filter: ${COMPANY_FILTER}`) + + // 1. Candidate invoices: paid or partially paid with money received. + const invoices = await fetchAllRows(({ from, to }) => { + let q = supabase + .from('invoices') + .select( + 'id, company_id, user_id, invoice_number, status, document_type, currency, exchange_rate, paid_amount, paid_at', + ) + .in('status', ['paid', 'partially_paid']) + .gt('paid_amount', 0) + if (COMPANY_FILTER) q = q.eq('company_id', COMPANY_FILTER) + return q.order('id', { ascending: true }).range(from, to) + }) + console.log(`Paid / partially paid invoices with paid_amount > 0: ${invoices.length}`) + + const invoiceIds = invoices.map((i) => i.id) + + // 2. Existing sub-ledger rows and posted payment vouchers, per invoice. + const existingRows = new Map() + const vouchersByInvoice = new Map() + for (const ids of chunk(invoiceIds, CHUNK)) { + const rows = await fetchAllRows<{ id: string; invoice_id: string; amount: number | null }>( + ({ from, to }) => + supabase + .from('invoice_payments') + .select('id, invoice_id, amount') + .in('invoice_id', ids) + .order('id', { ascending: true }) + .range(from, to), + ) + for (const r of rows) { + const acc = existingRows.get(r.invoice_id) ?? { count: 0, sum: 0 } + acc.count += 1 + acc.sum = roundOre(acc.sum + Number(r.amount ?? 0)) + existingRows.set(r.invoice_id, acc) + } + + const vouchers = await fetchAllRows(({ from, to }) => + supabase + .from('journal_entries') + .select('id, source_id, source_type, status, entry_date') + .in('source_id', ids) + .in('source_type', [...PAYMENT_VOUCHER_SOURCE_TYPES]) + .eq('status', 'posted') + .order('id', { ascending: true }) + .range(from, to), + ) + const voucherIds = vouchers.map((v) => v.id) + const lines = voucherIds.length + ? await fetchAllRows<{ + journal_entry_id: string + account_number: string + debit_amount: number | null + credit_amount: number | null + }>(({ from, to }) => + supabase + .from('journal_entry_lines') + .select('journal_entry_id, account_number, debit_amount, credit_amount') + .in('journal_entry_id', voucherIds) + .order('id', { ascending: true }) + .range(from, to), + ) + : [] + const linesByVoucher = new Map() + for (const l of lines) { + const list = linesByVoucher.get(l.journal_entry_id) ?? [] + list.push(l) + linesByVoucher.set(l.journal_entry_id, list) + } + for (const v of vouchers) { + if (!v.source_id) continue + v.settlement_sek = settlementSekFromLines(linesByVoucher.get(v.id) ?? []) + const list = vouchersByInvoice.get(v.source_id) ?? [] + list.push(v) + vouchersByInvoice.set(v.source_id, list) + } + } + + // 3. Closed / locked fiscal periods per company: a row dated into one is + // reported, never written. + const companyIds = Array.from(new Set(invoices.map((i) => i.company_id))) + const closedRanges = new Map>() + for (const ids of chunk(companyIds, CHUNK)) { + const periods = await fetchAllRows<{ + company_id: string + period_start: string + period_end: string + is_closed: boolean + locked_at: string | null + }>(({ from, to }) => + supabase + .from('fiscal_periods') + .select('company_id, period_start, period_end, is_closed, locked_at') + .in('company_id', ids) + .order('id', { ascending: true }) + .range(from, to), + ) + for (const fp of periods) { + if (!fp.is_closed && !fp.locked_at) continue + const list = closedRanges.get(fp.company_id) ?? [] + list.push({ start: fp.period_start, end: fp.period_end }) + closedRanges.set(fp.company_id, list) + } + } + const isPeriodClosedFor = (companyId: string) => (date: string) => + (closedRanges.get(companyId) ?? []).some((r) => date >= r.start && date <= r.end) + + // 4. Plan. + const toInsert: Array<{ invoice: BackfillInvoice; row: BackfillPaymentRow }> = [] + const skipped = new Map() + for (const invoice of invoices) { + const plan = planInvoicePaymentBackfill( + invoice, + vouchersByInvoice.get(invoice.id) ?? [], + existingRows.get(invoice.id) ?? { count: 0, sum: 0 }, + { isPeriodClosed: isPeriodClosedFor(invoice.company_id) }, + ) + if (plan.kind === 'insert') { + toInsert.push({ invoice, row: plan.row }) + } else { + const list = skipped.get(plan.reason) ?? [] + list.push(invoice) + skipped.set(plan.reason, list) + } + } + + console.log('') + console.log(`Rows to insert: ${toInsert.length}`) + const perCompany = new Map() + for (const { row } of toInsert) perCompany.set(row.company_id, (perCompany.get(row.company_id) ?? 0) + 1) + for (const [companyId, n] of perCompany) console.log(` ${companyId}: ${n}`) + for (const { invoice, row } of toInsert) { + console.log( + ` + ${invoice.company_id} ${invoice.invoice_number ?? invoice.id} ` + + `${row.amount} ${row.currency} on ${row.payment_date} -> ${row.journal_entry_id}`, + ) + } + + console.log('') + console.log('Skipped:') + for (const [reason, list] of skipped) { + console.log(` ${reason}: ${list.length}`) + // The reasons that need a human: a paid invoice with no voucher to hang + // the row on, several vouchers whose split is unknown, or existing rows + // that do not cover paid_amount. On prod the first group is dominated by + // imported history (paid long before the company came here; thousands of + // rows), so it is summarised per company unless --verbose asks for every + // invoice. + if ( + reason === 'no_payment_voucher' || + reason === 'multiple_payment_vouchers' || + reason === 'rows_short' || + reason === 'voucher_amount_mismatch' || + reason === 'voucher_amount_unverifiable' || + reason === 'period_closed' + ) { + if (VERBOSE) { + for (const inv of list) { + console.log(` ${inv.company_id} ${inv.invoice_number ?? inv.id} (${inv.status}, paid ${inv.paid_amount})`) + } + } else { + const byCompany = new Map() + for (const inv of list) byCompany.set(inv.company_id, (byCompany.get(inv.company_id) ?? 0) + 1) + for (const [companyId, n] of byCompany) console.log(` ${companyId}: ${n}`) + } + } + } + + if (!EXECUTE) { + console.log('') + console.log('Re-run with --execute to apply.') + return + } + + // 5. Write in batches. A unique-index collision (a row written between the + // read and this insert) fails the batch loudly rather than being skipped. + let inserted = 0 + for (const batch of chunk(toInsert, INSERT_BATCH)) { + const { error } = await supabase.from('invoice_payments').insert(batch.map((b) => b.row)) + if (error) { + console.error(`Insert failed after ${inserted} rows: ${error.code ?? ''} ${error.message}`) + process.exitCode = 1 + return + } + inserted += batch.length + } + console.log('') + console.log(`Inserted ${inserted} row(s) tagged "${BACKFILL_NOTES_TAG}".`) + console.log(`Rollback: DELETE FROM invoice_payments WHERE notes LIKE '${BACKFILL_NOTES_TAG}%';`) + + // 6. Behandlingshistorik: one event per company naming every row written. + // The rows feed the bokslut cut-off, so the run is a change to processing + // (BFL 5 kap 11 §, BFNAR 2013:2 p. 9.16). Rows without their change-log + // entry must not stay: if the append fails, that company's rows from this + // run are deleted again (by id, tag-guarded) so data and audit trail move + // together, and the company is listed for a re-run. + const runId = randomUUID() + const byCompany = new Map>() + for (const item of toInsert) { + const list = byCompany.get(item.row.company_id) ?? [] + list.push(item) + byCompany.set(item.row.company_id, list) + } + let appended = 0 + const rolledBack: string[] = [] + for (const [companyId, items] of byCompany) { + try { + await appendProcessingHistoryWithClient(supabase, { + companyId, + correlationId: runId, + aggregateType: 'System', + aggregateId: companyId, + eventType: 'InvoicePaymentRowBackfilled', + payload: { + source: 'backfill-invoice-payment-rows', + issue: 2019, + notes_tag: BACKFILL_NOTES_TAG, + row_count: items.length, + invoice_ids: items.map((i) => i.invoice.id), + journal_entry_ids: items.map((i) => i.row.journal_entry_id), + }, + actor: { type: 'system', id: 'backfill-invoice-payment-rows' }, + occurredAt: new Date(), + }) + appended += 1 + } catch (err) { + console.error( + `processing_history append failed for company ${companyId}: ` + + (err instanceof Error ? err.message : String(err)), + ) + const { error: rollbackError } = await supabase + .from('invoice_payments') + .delete() + .eq('company_id', companyId) + .in('invoice_id', items.map((i) => i.invoice.id)) + .like('notes', `${BACKFILL_NOTES_TAG}%`) + if (rollbackError) { + console.error( + ` rollback of ${items.length} row(s) for ${companyId} FAILED: ${rollbackError.message}. ` + + `Delete by hand: DELETE FROM invoice_payments WHERE company_id = '${companyId}' AND notes LIKE '${BACKFILL_NOTES_TAG}%';`, + ) + } else { + console.error(` rolled back ${items.length} row(s) for ${companyId}; re-run the script for this company.`) + rolledBack.push(companyId) + } + process.exitCode = 1 + } + } + console.log(`Behandlingshistorik: ${appended}/${byCompany.size} company event(s) appended (run ${runId}).`) + if (rolledBack.length > 0) { + console.log(`Rolled back (no audit event): ${rolledBack.join(', ')}`) + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/supabase/migrations/20260903180000_processing_event_type_invoice_payment_row_backfilled.sql b/supabase/migrations/20260903180000_processing_event_type_invoice_payment_row_backfilled.sql new file mode 100644 index 00000000..a530e3d0 --- /dev/null +++ b/supabase/migrations/20260903180000_processing_event_type_invoice_payment_row_backfilled.sql @@ -0,0 +1,14 @@ +-- Register the behandlingshistorik event type emitted by +-- scripts/backfill-invoice-payment-rows.ts (#2019). +-- +-- The backfill writes invoice_payments rows outside the normal settlement +-- flow, and those rows feed the kontantmetod bokslut cut-off, so the run is a +-- change to processing that BFL 5 kap 11 § / BFNAR 2013:2 p. 9.16 require in +-- the change log. processing_history.event_type has an FK to this catalog and +-- every append is best-effort, so an unregistered type would be lost silently +-- (see lib/processing-history/append.ts and +-- tests/pg/processing-event-types.pg.test.ts). + +INSERT INTO public.processing_event_types (event_type) +VALUES ('InvoicePaymentRowBackfilled') +ON CONFLICT (event_type) DO NOTHING; diff --git a/tests/pg/invoice-payments-manual-rows.pg.test.ts b/tests/pg/invoice-payments-manual-rows.pg.test.ts new file mode 100644 index 00000000..b024dae2 --- /dev/null +++ b/tests/pg/invoice-payments-manual-rows.pg.test.ts @@ -0,0 +1,150 @@ +/** + * pg-real test for the invoice_payments rows written by settleInvoicePayment + * (issue #2019: "Markera som betald" and the Stripe sync now record the + * payment in the AR sub-ledger). + * + * Those rows carry transaction_id NULL because no bank line drives the flow. + * The service relies on three database facts that only a real Postgres can + * pin: + * + * 1. idx_invoice_payments_tx_inv_unique (transaction_id, invoice_id) treats + * NULL as distinct, so several manual partials on one invoice coexist. + * 2. idx_invoice_payments_je_inv_unique still refuses the same voucher + * linked twice to the same invoice (the last line of defence against a + * double settle). + * 3. The authenticated writer can DELETE its own row: the CAS-failure + * branch in settleInvoicePayment removes the row through the user + * client, and a policy gap there would strand rows silently. + */ +import { describe, it, expect } from 'vitest' +import { randomUUID } from 'node:crypto' +import { getPool, withUserContext } from './setup' +import { insertPostedJournalEntry, seedCompany } from './fixtures' + +async function seedCustomerInvoice(params: { + userId: string + companyId: string + total?: number +}): Promise { + const customerId = randomUUID() + await getPool().query( + `INSERT INTO public.customers (id, user_id, company_id, name, customer_type) + VALUES ($1, $2, $3, 'Test Kund AB', 'swedish_business')`, + [customerId, params.userId, params.companyId], + ) + const id = randomUUID() + const total = params.total ?? 12500 + await getPool().query( + `INSERT INTO public.invoices + (id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date, + currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status, + paid_amount, remaining_amount) + VALUES ($1, $2, $3, $4, $5, '2026-08-01', '2026-08-31', 'SEK', + $6, 0, $6, 'standard_25', 25, 'sent', 0, $6)`, + [id, params.userId, params.companyId, customerId, `F-${id.slice(0, 8)}`, total], + ) + return id +} + +async function setActiveCompany(userId: string, companyId: string) { + await getPool().query( + `INSERT INTO public.user_preferences (user_id, active_company_id) + VALUES ($1, $2) + ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`, + [userId, companyId], + ) +} + +const INSERT_MANUAL_PAYMENT = ` + INSERT INTO public.invoice_payments + (user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate, + journal_entry_id, transaction_id, notes) + VALUES ($1, $2, $3, $4, $5, 'SEK', NULL, $6, NULL, NULL) + RETURNING id` + +async function seedPaymentVoucher(params: { + userId: string + companyId: string + fiscalPeriodId: string + invoiceId: string + amount: number + voucherNumber: number +}): Promise { + return insertPostedJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + entryDate: '2026-08-28', + description: 'Kontantbetalning kundfaktura', + sourceType: 'invoice_cash_payment', + sourceId: params.invoiceId, + voucherNumber: params.voucherNumber, + lines: [ + { accountNumber: '1930', debitAmount: params.amount, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: params.amount }, + ], + }) +} + +describe('invoice_payments rows without a bank transaction (#2019)', () => { + it('lets several transaction-less payments coexist on one invoice', async () => { + const seeded = await seedCompany() + const invoiceId = await seedCustomerInvoice(seeded) + const jeA = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 5000, voucherNumber: 31 }) + const jeB = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 7500, voucherNumber: 32 }) + + const first = await getPool().query(INSERT_MANUAL_PAYMENT, [ + seeded.userId, seeded.companyId, invoiceId, '2026-08-20', 5000, jeA, + ]) + const second = await getPool().query(INSERT_MANUAL_PAYMENT, [ + seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 7500, jeB, + ]) + expect(first.rowCount).toBe(1) + expect(second.rowCount).toBe(1) + + const rows = await getPool().query<{ n: string; total: string }>( + `SELECT count(*)::text AS n, sum(amount)::text AS total + FROM public.invoice_payments WHERE invoice_id = $1 AND transaction_id IS NULL`, + [invoiceId], + ) + expect(rows.rows[0]).toEqual({ n: '2', total: '12500' }) + }) + + it('still refuses the same voucher linked twice to the same invoice', async () => { + const seeded = await seedCompany() + const invoiceId = await seedCustomerInvoice(seeded) + const je = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 12500, voucherNumber: 41 }) + + await getPool().query(INSERT_MANUAL_PAYMENT, [ + seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 12500, je, + ]) + await expect( + getPool().query(INSERT_MANUAL_PAYMENT, [ + seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 12500, je, + ]), + ).rejects.toThrow(/idx_invoice_payments_je_inv_unique/) + }) + + it('lets the authenticated writer insert and delete its own row under RLS', async () => { + const seeded = await seedCompany() + await setActiveCompany(seeded.userId, seeded.companyId) + const invoiceId = await seedCustomerInvoice(seeded) + const je = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 12500, voucherNumber: 51 }) + + const outcome = await withUserContext(seeded.userId, async (client) => { + const inserted = await client.query<{ id: string }>(INSERT_MANUAL_PAYMENT, [ + seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 12500, je, + ]) + const rowId = inserted.rows[0]?.id + // The CAS-failure branch deletes by (id, company_id) through the user + // client: the delete policy must let the row go. + const deleted = await client.query( + `DELETE FROM public.invoice_payments WHERE id = $1 AND company_id = $2`, + [rowId, seeded.companyId], + ) + return { inserted: inserted.rowCount, deleted: deleted.rowCount } + }) + + expect(outcome).toEqual({ inserted: 1, deleted: 1 }) + }) +})