diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/duplicate-guard-band.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/duplicate-guard-band.test.ts index 742a7325..1e8b3dbd 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/duplicate-guard-band.test.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/duplicate-guard-band.test.ts @@ -107,6 +107,14 @@ vi.mock('@/lib/core/documents/document-service', async () => { return { ...actual, linkToJournalEntry: vi.fn() } }) +// Mocked away so the settled-suggestion cleanup (issue #1259) does not add a +// `transactions` query to the recorded set: the txQueries() assertions below +// are about the duplicate-guard sweeps only. The helper's own query shape is +// pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: vi.fn().mockResolvedValue(undefined), +})) + import { eventBus } from '@/lib/events' import { POST } from '../route' diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts index 88998615..24986170 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -39,8 +39,15 @@ vi.mock('@/lib/core/documents/supplier-invoice-underlag', () => ({ anchorSupplierInvoiceDocument: vi.fn().mockResolvedValue(null), })) +// Mocked so it consumes no slot in the queued Supabase mock: the helper's own +// query shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: vi.fn().mockResolvedValue(undefined), +})) + import { eventBus } from '@/lib/events' import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { POST } from '../route' @@ -148,6 +155,16 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { expect(body.remaining_amount).toBe(0) expect(body.journal_entry_id).toBe('je-1') expect(mockCreateSupplierInvoicePaymentEntry).toHaveBeenCalled() + // Issue #1259: full settlement retires every transaction's suggestion + // pointer at this invoice. No exceptTransactionId: mark-paid is not driven + // by a bank transaction. + expect(vi.mocked(clearSettledInvoiceSuggestions)).toHaveBeenCalledTimes(1) + expect(vi.mocked(clearSettledInvoiceSuggestions)).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'supplier_invoice', + 'si-1', + ) }) it('marks as partially paid', async () => { @@ -188,6 +205,9 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { expect(body.status).toBe('partially_paid') expect(body.paid_amount).toBe(5000) expect(body.remaining_amount).toBe(5000) + // Issue #1259: a partially paid invoice is still matchable, so its sibling + // suggestions must survive. + expect(vi.mocked(clearSettledInvoiceSuggestions)).not.toHaveBeenCalled() }) it('uses cash method journal entry when configured', async () => { diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index 9cd9dd41..d2a4721e 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -9,6 +9,7 @@ import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { validateBody } from '@/lib/api/validate' import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas' import { withRouteContext } from '@/lib/api/with-route-context' @@ -396,6 +397,13 @@ export const POST = withRouteContext( }) } + // Fully settled: retire every transaction's suggestion pointer at this + // invoice (issue #1259). No exceptTransactionId: this flow is not driven by + // a bank transaction, so any pointer at it is now dead. + if (isFullyPaid) { + await clearSettledInvoiceSuggestions(supabase, companyId!, 'supplier_invoice', id) + } + // Under kontantmetoden the cash payment entry is the ONLY booking of the // affärshändelse, so its underlag (the document from the inbox) must hang on // THIS verifikat per BFL 5 kap 6 §. Under faktureringsmetoden the document diff --git a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts index 7728601d..d98001d0 100644 --- a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts +++ b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts @@ -17,6 +17,14 @@ vi.mock('@/lib/invoices/match-log', () => ({ logMatchEvent: vi.fn(), })) +// Issue #1259: settling the invoice retires the suggestion pointers at it. +// Mocked so it consumes no slot in the queued Supabase mock; the helper's own +// query shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() })) +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: mockClearSuggestions, +})) + vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn() }, })) @@ -250,6 +258,58 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => { expect(body.invoice_status).toBe('paid') expect(body.paid_amount).toBe(1000) expect(body.remaining_amount).toBe(0) + // Issue #1259: the invoice is settled, so no other transaction may keep + // pointing at it as a match suggestion. + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'invoice', + INV_UUID, + ) + }) + + it('leaves the suggestions alone on a partial payment: the invoice is still matchable', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 400, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ + data: makeInvoice({ + id: INV_UUID, + status: 'sent', + total: 1000, + remaining_amount: 1000, + paid_amount: 0, + currency: 'SEK', + }), + error: null, + }) + enqueue({ data: [{ id: TX_UUID }], error: null }) // update transaction + enqueue({ data: [{ id: INV_UUID }], error: null }) // update invoice + enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: null, error: null }) // logMatchEvent + + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ invoice_status: string | null }>(response) + + expect(status).toBe(200) + expect(body.invoice_status).toBe('partially_paid') + expect(mockClearSuggestions).not.toHaveBeenCalled() }) it('returns 404 when invoice_id supplied but invoice not found', async () => { diff --git a/app/api/transactions/[id]/match-batch/__tests__/route.test.ts b/app/api/transactions/[id]/match-batch/__tests__/route.test.ts index d33eb5f8..f0f0ec18 100644 --- a/app/api/transactions/[id]/match-batch/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-batch/__tests__/route.test.ts @@ -19,6 +19,15 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +// Issue #1259: each fully settled allocation retires the suggestion pointers +// at its invoice. Mocked so it consumes no slot in the queued Supabase mock; +// the helper's own query shape is pinned by +// lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() })) +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: mockClearSuggestions, +})) + vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), @@ -33,6 +42,7 @@ import { POST } from '../route' const TX_UUID = '11111111-1111-4111-8111-111111111111' const INV_UUID = '22222222-2222-4222-8222-222222222222' const SI_UUID = '33333333-3333-4333-8333-333333333333' +const SI_PARTIAL_UUID = '44444444-4444-4444-8444-444444444444' describe('POST /api/transactions/[id]/match-batch', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -116,6 +126,76 @@ describe('POST /api/transactions/[id]/match-batch', () => { expect(body.data.voucher_number).toBe(12) expect(body.data.allocations).toHaveLength(1) expect(body.data.total_allocated).toBe(1000) + // Issue #1259: the allocation settled the invoice in full, so every OTHER + // transaction still pointing at it as a suggestion is retired. + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'invoice', + INV_UUID, + { exceptTransactionId: TX_UUID }, + ) + }) + + it('retires suggestions only for the allocations that settled in full', async () => { + enqueue({ + data: { + ok: true, + journal_entry_id: 'je-batch-2', + voucher_series: 'A', + voucher_number: 13, + tx_id: TX_UUID, + allocations: [ + { + kind: 'supplier_invoice', + supplier_invoice_id: SI_UUID, + payment_id: 'sip-1', + status: 'paid', + paid_amount: 1000, + remaining_amount: 0, + amount: 1000, + }, + { + kind: 'supplier_invoice', + supplier_invoice_id: SI_PARTIAL_UUID, + payment_id: 'sip-2', + status: 'partially_paid', + paid_amount: 400, + remaining_amount: 600, + amount: 400, + }, + ], + total_allocated: 1400, + leftover: 0, + }, + error: null, + }) + enqueue({ data: { id: TX_UUID, amount: -1400, currency: 'SEK' }, error: null }) // tx fetch + enqueue({ data: { id: SI_UUID, currency: 'SEK', status: 'paid' }, error: null }) + enqueue({ data: { id: SI_PARTIAL_UUID, currency: 'SEK', status: 'partially_paid' }, error: null }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, { + method: 'POST', + body: { + allocations: [ + { kind: 'supplier_invoice', supplier_invoice_id: SI_UUID, amount: 1000 }, + { kind: 'supplier_invoice', supplier_invoice_id: SI_PARTIAL_UUID, amount: 400 }, + ], + }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + expect(response.status).toBe(200) + + // Only the fully settled one: a partially paid invoice is still matchable. + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'supplier_invoice', + SI_UUID, + { exceptTransactionId: TX_UUID }, + ) }) it('maps an RPC structured failure to errorResponseFromCode', async () => { diff --git a/app/api/transactions/[id]/match-batch/route.ts b/app/api/transactions/[id]/match-batch/route.ts index 99d46a4b..6e6d5a9a 100644 --- a/app/api/transactions/[id]/match-batch/route.ts +++ b/app/api/transactions/[id]/match-batch/route.ts @@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate' import { MatchBatchSchema } from '@/lib/api/schemas' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { eventBus } from '@/lib/events/bus' +import { clearSettledBatchAllocationSuggestions } from '@/lib/invoices/clear-settled-batch-allocations' import { ensureInitialized } from '@/lib/init' import type { Invoice, SupplierInvoice, Transaction } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -149,6 +150,17 @@ export const POST = withRouteContext( } } + // Every allocation the RPC settled in full retires its suggestion pointer + // from the company's OTHER transactions (issue #1259). This request's own + // row is linked by the RPC, so it is excluded there. Shared with the MCP + // executor for the same RPC (commitMatchBatchAllocate). + await clearSettledBatchAllocationSuggestions( + supabase, + companyId!, + result.allocations, + transactionId, + ) + return NextResponse.json({ data: { journal_entry_id: result.journal_entry_id, diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index 601371b9..8ad36e4c 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -44,6 +44,13 @@ vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({ detectDuplicatePaymentVoucher: (...args: unknown[]) => mockDetectDuplicate(...args), })) +// Mocked so it consumes no slot in the queued Supabase mock: the helper's own +// query shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +const mockClearSuggestions = vi.fn() +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: (...args: unknown[]) => mockClearSuggestions(...args), +})) + vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn() }, })) @@ -628,6 +635,49 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.invoice_status).toBe('partially_paid') expect(body.paid_amount).toBe(5000) expect(body.remaining_amount).toBe(7500) + // Issue #1259: a partially paid invoice is still matchable, so the sibling + // suggestions must survive. + expect(mockClearSuggestions).not.toHaveBeenCalled() + }) + + // Issue #1259: full settlement retires the pointer at this invoice from every + // OTHER transaction still carrying it as an import-time suggestion. + it('retires the settled invoice suggestion on the other transactions', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' }) + const invoice = makeInvoice({ + id: VALID_UUID, + status: 'sent', + total: 12500, + remaining_amount: 12500, + paid_amount: 0, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice + enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: null, error: null }) // update transaction + enqueue({ data: null, error: null }) // logMatchEvent + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'invoice', + VALID_UUID, + { exceptTransactionId: 'tx-1' }, + ) }) it('cash method ignores cash entry when invoice was already booked (accrual→cash migration)', async () => { diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index c2870c66..b4b9b25a 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -15,6 +15,7 @@ import { MatchInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import type { Currency, EntityType, Invoice, Transaction } from '@/types' @@ -688,6 +689,15 @@ export const POST = withRouteContext( return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId }) } + // The invoice is now settled, so every OTHER transaction still carrying a + // suggestion pointer at it is dead: retire them (issue #1259). This + // request's own row is cleared by the update just below. + if (isFullyPaid) { + await clearSettledInvoiceSuggestions(supabase, companyId!, 'invoice', invoice_id, { + exceptTransactionId: transactionId, + }) + } + const { error: updateTxError } = await supabase .from('transactions') .update({ diff --git a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts index c4d487eb..63a69417 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts @@ -38,6 +38,13 @@ vi.mock('@/lib/invoices/match-log', () => ({ logMatchEvent: vi.fn(), })) +// Mocked so it consumes no slot in the queued Supabase mock: the helper's own +// query shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +const mockClearSuggestions = vi.fn() +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: (...args: unknown[]) => mockClearSuggestions(...args), +})) + vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn() }, })) @@ -473,6 +480,36 @@ describe('POST /api/transactions/[id]/match-supplier-invoice: non-FX paths', () }) }) + // Issue #1259: the settled invoice's pointer must also be retired from every + // OTHER transaction that still carries it as an import-time suggestion. + it('retires the settled invoice suggestion on the other transactions', async () => { + enqueueHappyPath({ + transaction: { amount: -1000, currency: 'SEK' }, + invoice: { currency: 'SEK', remaining_amount: 1000 }, + }) + await POST(makeReq(), createMockRouteParams({ id: TX_UUID })) + + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'supplier_invoice', + SI_UUID, + { exceptTransactionId: TX_UUID }, + ) + }) + + it('leaves the suggestions alone on a partial payment: the invoice is still matchable', async () => { + enqueueHappyPath({ + transaction: { amount: -400, currency: 'SEK' }, + invoice: { currency: 'SEK', remaining_amount: 1000 }, + }) + const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID })) + const { body } = await parseJsonResponse<{ invoice_status: string }>(res) + expect(body.invoice_status).toBe('partially_paid') + expect(mockClearSuggestions).not.toHaveBeenCalled() + }) + it('öresavrundning: a whole-krona Bankgiro payment settles an öre-bearing invoice in full via 3740', async () => { // The reported bug: invoice 11 231,25, bank paid 11 231 → previously left // 0,25 stranded as partially_paid. Now → paid, with 0,25 booked to 3740. diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 241dd28c..a4dbc380 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -15,6 +15,7 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure import { validateBody } from '@/lib/api/validate' import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types' @@ -412,6 +413,19 @@ export const POST = withRouteContext( return errorResponseFromCode('MATCH_SI_RECORD_PAYMENT_FAILED', txLog, { requestId }) } + // The invoice is now settled, so every OTHER transaction still carrying a + // suggestion pointer at it is dead: retire them (issue #1259). This + // request's own row is cleared by the update just below. + if (isFullyPaid) { + await clearSettledInvoiceSuggestions( + supabase, + companyId!, + 'supplier_invoice', + supplier_invoice_id, + { exceptTransactionId: transactionId }, + ) + } + const { error: updateTxError } = await supabase .from('transactions') .update({ 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 ae226dfb..2ab0d279 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 @@ -42,6 +42,14 @@ vi.mock('@/lib/bookkeeping/engine', () => ({ findFiscalPeriod: vi.fn().mockResolvedValue('fp-1'), })) +// Issue #1259: settling the invoice retires the suggestion pointers at it. +// Mocked so the assertion is on the orchestration; the helper's own query +// shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() })) +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: mockClearSuggestions, +})) + import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' import { createInvoicePaymentJournalEntry as mockedPayment, @@ -185,6 +193,15 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => { expect(paidHandler).toHaveBeenCalledWith( expect.objectContaining({ companyId: COMPANY_ID, userId: USER_ID, paymentAmount: 12500 }), ) + // Issue #1259: the invoice is settled, so no transaction may keep pointing + // at it as a match suggestion. + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith( + expect.anything(), + COMPANY_ID, + 'invoice', + INVOICE_ID, + ) }) it('uses the cash-basis booking when accounting_method=cash', async () => { @@ -737,6 +754,9 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => { // the transactions scan never runs: the matching bank row above would // otherwise have 409'd a perfectly valid partial payment. expect(calls.some((c) => c.table === 'transactions')).toBe(false) + // Issue #1259: a partially paid invoice is still matchable, so the + // suggestions pointing at it must survive. + expect(mockClearSuggestions).not.toHaveBeenCalled() }) it('still runs the duplicate guard when the converted SEK lines settle a EUR invoice in full', async () => { 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 14c1bf6d..c8efbf6f 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 @@ -43,6 +43,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { eventBus } from '@/lib/events' import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates' import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { roundOre } from '@/lib/money' import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types' @@ -543,6 +544,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // Fully settled: retire every transaction's suggestion pointer at this + // invoice (issue #1259). No exceptTransactionId: this flow is not driven by + // a bank transaction, so any pointer at it is now dead. + if (newStatus === 'paid') { + await clearSettledInvoiceSuggestions(ctx.supabase, ctx.companyId!, 'invoice', invoiceId) + } + // Step 3: emit invoice.paid (best-effort, surfaces in warnings on fail). try { await eventBus.emit({ diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts new file mode 100644 index 00000000..36f7c43f --- /dev/null +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -0,0 +1,209 @@ +/** + * Coverage for POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid. + * + * Scoped to the settled-suggestion cleanup (issue #1259): a supplier invoice + * paid through the API must not leave bank transactions pointing at it as an + * import-time match suggestion, and a PARTIAL payment must leave those + * suggestions alone because the invoice is still matchable. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `mark-paid route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +// Journal-entry helpers are stubbed: the route flow is what we test here. +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoicePaymentEntry: vi.fn().mockResolvedValue({ id: 'je-si-payment' }), + createSupplierInvoiceCashEntry: vi.fn().mockResolvedValue({ id: 'je-si-cash' }), +})) +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: vi.fn().mockResolvedValue({ id: 'je-custom' }), + findFiscalPeriod: vi.fn().mockResolvedValue('fp-1'), + reverseEntry: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/core/documents/supplier-invoice-underlag', () => ({ + anchorSupplierInvoiceDocument: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/api/v1/check-period-lock', () => ({ + checkPeriodLock: vi.fn().mockResolvedValue({ locked: false, fiscal_period_id: 'fp-1' }), +})) + +// Issue #1259: settling the invoice retires the suggestion pointers at it. +// Mocked so the assertion is on the orchestration; the helper's own query +// shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() })) +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: mockClearSuggestions, +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { POST as markPaid } from '../route' +import { eventBus } from '@/lib/events' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(table) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return () => buildChain(table) + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const SI_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const USER_ID = 'user-1' + +function makeRequest(body?: unknown): Request { + return new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, + { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-1010-4abc-8def-1234567890ab', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }, + ) +} +function detailParams() { + return { params: Promise.resolve({ companyId: COMPANY_ID, id: SI_ID }) } +} + +const APPROVED_SI = { + id: SI_ID, + supplier_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + status: 'approved', + currency: 'SEK', + exchange_rate: null, + total: 1000, + paid_amount: 0, + remaining_amount: 1000, + supplier_invoice_number: 'LF-1', + arrival_number: 1, + invoice_date: '2026-05-01', + due_date: '2026-05-31', + received_date: '2026-05-01', + is_credit_note: false, + credited_invoice_id: null, + payment_journal_entry_id: null, + registration_journal_entry_id: 'je-registration', + vat_treatment: 'standard_25', + reverse_charge: false, + subtotal: 800, + vat_amount: 200, + default_dimensions: null, + supplier: { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', name: 'Leverantör AB', supplier_type: 'swedish_business' }, + items: [], +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['suppliers:write'], + mode: 'live', + }) +}) + +describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid', () => { + it('retires the settled invoice suggestions on a full payment (issue #1259)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: [ + { data: APPROVED_SI, error: null }, + { + data: { ...APPROVED_SI, status: 'paid', paid_amount: 1000, remaining_amount: 0 }, + error: null, + }, + ], + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + supplier_invoice_payments: { data: null, error: null }, + }), + ) + + const res = await markPaid(makeRequest({ payment_date: '2026-05-12' }), detailParams()) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('paid') + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith( + expect.anything(), + COMPANY_ID, + 'supplier_invoice', + SI_ID, + ) + }) + + it('leaves the suggestions alone on a partial payment: the invoice is still matchable', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + supplier_invoices: [ + { data: APPROVED_SI, error: null }, + { + data: { ...APPROVED_SI, status: 'partially_paid', paid_amount: 400, remaining_amount: 600 }, + error: null, + }, + ], + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + supplier_invoice_payments: { data: null, error: null }, + }), + ) + + const res = await markPaid( + makeRequest({ payment_date: '2026-05-12', amount: 400 }), + detailParams(), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('partially_paid') + expect(mockClearSuggestions).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts index 6eacb159..a22867be 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts @@ -29,6 +29,7 @@ import { import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { eventBus } from '@/lib/events' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -506,6 +507,18 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // Fully settled: retire every transaction's suggestion pointer at this + // invoice (issue #1259). No exceptTransactionId: this flow is not driven by + // a bank transaction, so any pointer at it is now dead. + if (newStatus === 'paid') { + await clearSettledInvoiceSuggestions( + ctx.supabase, + ctx.companyId!, + 'supplier_invoice', + invoiceId, + ) + } + // Anchor the invoice's retained source document to a posted verifikat if // it is still floating. Under kontantmetoden the cash entry we just booked // is the only booking of the affärshändelse, so its underlag belongs here diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts index 5630e201..0670c615 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts @@ -69,6 +69,15 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ vi.mock('@/lib/invoices/match-log', () => ({ logMatchEvent: vi.fn(), })) +// Issue #1259: the settle paths retire sibling suggestion pointers. Mocked so +// the assertion is on the orchestration; the helper's own query shape is pinned +// by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +const { clearSuggestionsMock } = vi.hoisted(() => ({ + clearSuggestionsMock: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: clearSuggestionsMock, +})) vi.mock('@/lib/bookkeeping/mapping-engine', async () => { // Keep the real applySettlementAccount: it's a pure rewrite (1930 -> the // resolved bank leg) and the v1 categorize route's settlement-account fix @@ -111,6 +120,10 @@ function makeFlexibleSupabase(byTable: Record for (const [t, val] of Object.entries(byTable)) { queues.set(t, Array.isArray(val) ? [...val] : [val]) } + // Every chained builder call, in order. The proxy otherwise swallows its + // arguments, which makes update payloads invisible to assertions. Recording + // is passive: it changes nothing about what a chain resolves to. + const calls: { table: string; method: string; args: unknown[] }[] = [] const buildChain = (table: string): unknown => { const handler: ProxyHandler = { get(_target, prop) { @@ -121,12 +134,25 @@ function makeFlexibleSupabase(byTable: Record resolve(next) } } - return (..._args: unknown[]) => buildChain(table) + return (...args: unknown[]) => { + calls.push({ table, method: String(prop), args }) + return buildChain(table) + } }, } return new Proxy({}, handler) } - return { from: vi.fn((table: string) => buildChain(table)) } + return { from: vi.fn((table: string) => buildChain(table)), calls } +} + +/** Payloads of every `.update()` call made against `table`. */ +function updatePayloads( + supa: { calls: { table: string; method: string; args: unknown[] }[] }, + table: string, +): Record[] { + return supa.calls + .filter((c) => c.table === table && c.method === 'update') + .map((c) => c.args[0] as Record) } const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' @@ -558,6 +584,16 @@ describe('POST :id/match-invoice', () => { expect.objectContaining({ account_number: '1930', debit_amount: 12500, credit_amount: 0 }), expect.objectContaining({ account_number: '1510', debit_amount: 0, credit_amount: 12500 }), ]) + // Issue #1259: settling the invoice retires its suggestion pointer on every + // OTHER transaction of the company. + expect(clearSuggestionsMock).toHaveBeenCalledTimes(1) + expect(clearSuggestionsMock).toHaveBeenCalledWith( + expect.anything(), + COMPANY_ID, + 'invoice', + INV_ID, + { exceptTransactionId: TX_ID }, + ) }) it('rejects negative transaction with MATCH_INVOICE_NOT_INCOME', async () => { @@ -814,41 +850,40 @@ describe('POST :id/match-invoice', () => { describe('POST :id/match-supplier-invoice', () => { it('matches a negative transaction to an open supplier invoice', async () => { - mockServiceClient.mockReturnValue( - makeFlexibleSupabase({ - company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, - transactions: { + const supa = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { + data: { + id: TX_ID, + amount: -5000, + date: '2026-05-12', + currency: 'SEK', + supplier_invoice_id: null, + journal_entry_id: null, + }, + error: null, + }, + supplier_invoices: [ + { data: { - id: TX_ID, - amount: -5000, - date: '2026-05-12', + id: SI_ID, + status: 'approved', + total: 5000, + paid_amount: 0, + remaining_amount: 5000, currency: 'SEK', - supplier_invoice_id: null, - journal_entry_id: null, + exchange_rate: null, + supplier: { name: 'Acme', supplier_type: 'swedish_business' }, + items: [], }, error: null, }, - supplier_invoices: [ - { - data: { - id: SI_ID, - status: 'approved', - total: 5000, - paid_amount: 0, - remaining_amount: 5000, - currency: 'SEK', - exchange_rate: null, - supplier: { name: 'Acme', supplier_type: 'swedish_business' }, - items: [], - }, - error: null, - }, - { data: [{ id: SI_ID }], error: null }, - ], - company_settings: { data: { accounting_method: 'accrual' }, error: null }, - supplier_invoice_payments: { data: null, error: null }, - }), - ) + { data: [{ id: SI_ID }], error: null }, + ], + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + supplier_invoice_payments: { data: null, error: null }, + }) + mockServiceClient.mockReturnValue(supa) const res = await matchSIPOST( makeRequest( `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`, @@ -859,6 +894,25 @@ describe('POST :id/match-supplier-invoice', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.data.invoice_status).toBe('paid') + // Issue #1259: the confirmed link supersedes the suggestion, so this row's + // own hint must not survive it. Parity with the dashboard twin, which this + // route was missing. + expect(updatePayloads(supa, 'transactions')).toContainEqual( + expect.objectContaining({ + supplier_invoice_id: SI_ID, + potential_supplier_invoice_id: null, + is_business: true, + }), + ) + // And every OTHER transaction of the company gets its pointer retired. + expect(clearSuggestionsMock).toHaveBeenCalledTimes(1) + expect(clearSuggestionsMock).toHaveBeenCalledWith( + expect.anything(), + COMPANY_ID, + 'supplier_invoice', + SI_ID, + { exceptTransactionId: TX_ID }, + ) }) it('rejects positive transaction with MATCH_SI_NOT_EXPENSE', async () => { diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index 5cafe5f7..d70aadac 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -41,6 +41,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { logMatchEvent } from '@/lib/invoices/match-log' import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { eventBus } from '@/lib/events/bus' import type { Currency, EntityType, Invoice, Transaction } from '@/types' @@ -741,6 +742,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // The invoice is now settled, so every OTHER transaction still carrying a + // suggestion pointer at it is dead: retire them (issue #1259). This + // request's own row is cleared by the update just below. + if (isFullyPaid) { + await clearSettledInvoiceSuggestions(ctx.supabase, ctx.companyId!, 'invoice', invoice_id, { + exceptTransactionId: txId, + }) + } + // When the tx already has a category (set by a prior :categorize call, // could be income_products / rental / etc.), preserve it. When there is // none, leave the column UNTOUCHED: the existing default ('uncategorized') diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts index 36329ce3..e15853aa 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts @@ -23,6 +23,7 @@ import { findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation' import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag' import { getErrorMessage } from '@/lib/errors/get-error-message' import { logMatchEvent } from '@/lib/invoices/match-log' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { eventBus } from '@/lib/events/bus' import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types' @@ -464,10 +465,26 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // The invoice is now settled, so every OTHER transaction still carrying a + // suggestion pointer at it is dead: retire them (issue #1259). This + // request's own row is cleared by the update just below. + if (isFullyPaid) { + await clearSettledInvoiceSuggestions( + ctx.supabase, + ctx.companyId!, + 'supplier_invoice', + supplier_invoice_id, + { exceptTransactionId: txId }, + ) + } + const { error: updateTxErr } = await ctx.supabase .from('transactions') .update({ supplier_invoice_id, + // Parity with the dashboard route: the confirmed link supersedes the + // suggestion, so the hint must not survive it (issue #1259). + potential_supplier_invoice_id: null, journal_entry_id: journalEntryId, is_business: true, }) diff --git a/lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts b/lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts new file mode 100644 index 00000000..a2b8e45f --- /dev/null +++ b/lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase } from '@/tests/helpers' + +const { mockLoggerWarn } = vi.hoisted(() => ({ mockLoggerWarn: vi.fn() })) +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: mockLoggerWarn, + error: vi.fn(), + child: vi.fn().mockReturnThis(), + }), +})) + +import { clearSettledInvoiceSuggestions } from '../clear-settled-invoice-suggestions' + +const COMPANY = 'company-1' +const SI_ID = '11111111-1111-4111-8111-111111111111' +const INV_ID = '22222222-2222-4222-8222-222222222222' +const TX_ID = '33333333-3333-4333-8333-333333333333' + +const { supabase, enqueue, reset, findCall, findCalls, calls } = createQueuedMockSupabase() + +describe('clearSettledInvoiceSuggestions', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + }) + + it('nulls potential_supplier_invoice_id on every OTHER transaction of the company', async () => { + enqueue({ data: null, error: null }) + await clearSettledInvoiceSuggestions( + supabase as unknown as SupabaseClient, + COMPANY, + 'supplier_invoice', + SI_ID, + { exceptTransactionId: TX_ID }, + ) + + expect(supabase.from).toHaveBeenCalledWith('transactions') + expect(findCall('transactions', 'update')).toEqual([ + { potential_supplier_invoice_id: null }, + ]) + const eqCalls = findCalls('transactions', 'eq') + expect(eqCalls).toEqual([ + ['company_id', COMPANY], + ['potential_supplier_invoice_id', SI_ID], + ]) + expect(findCall('transactions', 'neq')).toEqual(['id', TX_ID]) + }) + + it('nulls only potential_invoice_id for kind invoice', async () => { + enqueue({ data: null, error: null }) + await clearSettledInvoiceSuggestions( + supabase as unknown as SupabaseClient, + COMPANY, + 'invoice', + INV_ID, + ) + + const payload = findCall('transactions', 'update')?.[0] as Record + expect(Object.keys(payload)).toEqual(['potential_invoice_id']) + expect(payload.potential_invoice_id).toBeNull() + expect(findCalls('transactions', 'eq')).toEqual([ + ['company_id', COMPANY], + ['potential_invoice_id', INV_ID], + ]) + }) + + it('omits the neq filter when no exceptTransactionId is given', async () => { + enqueue({ data: null, error: null }) + await clearSettledInvoiceSuggestions( + supabase as unknown as SupabaseClient, + COMPANY, + 'invoice', + INV_ID, + ) + expect(findCalls('transactions', 'neq')).toEqual([]) + + reset() + enqueue({ data: null, error: null }) + await clearSettledInvoiceSuggestions( + supabase as unknown as SupabaseClient, + COMPANY, + 'invoice', + INV_ID, + { exceptTransactionId: null }, + ) + expect(findCalls('transactions', 'neq')).toEqual([]) + }) + + it('never widens the write beyond the settled invoice own pointer', async () => { + enqueue({ data: null, error: null }) + await clearSettledInvoiceSuggestions( + supabase as unknown as SupabaseClient, + COMPANY, + 'supplier_invoice', + SI_ID, + { exceptTransactionId: TX_ID }, + ) + const methods = calls.map((c) => c.method) + expect(methods).not.toContain('or') + expect(methods).not.toContain('is') + expect(methods).not.toContain('in') + // Only one table is ever touched. + expect([...new Set(calls.map((c) => c.table))]).toEqual(['transactions']) + }) + + it('logs and resolves when the update fails: a settle must never fail on cleanup', async () => { + enqueue({ data: null, error: { message: 'permission denied' } }) + await expect( + clearSettledInvoiceSuggestions( + supabase as unknown as SupabaseClient, + COMPANY, + 'supplier_invoice', + SI_ID, + ), + ).resolves.toBeUndefined() + expect(mockLoggerWarn).toHaveBeenCalledTimes(1) + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'failed to clear settled invoice suggestions', + expect.objectContaining({ companyId: COMPANY, invoiceId: SI_ID, reason: 'permission denied' }), + ) + }) +}) diff --git a/lib/invoices/__tests__/settle-invoice-payment.test.ts b/lib/invoices/__tests__/settle-invoice-payment.test.ts index 80513411..17afa016 100644 --- a/lib/invoices/__tests__/settle-invoice-payment.test.ts +++ b/lib/invoices/__tests__/settle-invoice-payment.test.ts @@ -14,6 +14,11 @@ vi.mock('@/lib/bookkeeping/engine', () => ({ vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({ cancelOrphanedPaymentEntry: vi.fn(), })) +// Mocked so it consumes no slot in the queued Supabase mock: the helper's own +// query shape is pinned by ./clear-settled-invoice-suggestions.test.ts. +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: vi.fn(), +})) import { createInvoicePaymentJournalEntry, @@ -21,6 +26,7 @@ import { } from '@/lib/bookkeeping/invoice-entries' import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment' import { eventBus } from '@/lib/events' @@ -314,6 +320,48 @@ describe('settleInvoicePayment', () => { ) }) + // Issue #1259: a fully settled invoice must not keep sibling transactions + // 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: 'inv-1' }] }) + + const result = await settleInvoicePayment( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + { ...BASE_PARAMS, invoice: payableInvoice() }, + ) + + expect(result).toMatchObject({ ok: true, newStatus: 'paid' }) + expect(vi.mocked(clearSettledInvoiceSuggestions)).toHaveBeenCalledTimes(1) + expect(vi.mocked(clearSettledInvoiceSuggestions)).toHaveBeenCalledWith( + supabase, + 'company-1', + 'invoice', + 'inv-1', + ) + }) + + it('leaves the suggestions alone on a partial payment: the invoice is still matchable', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [{ id: 'inv-1' }] }) + + const result = await settleInvoicePayment( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + { + ...BASE_PARAMS, + paymentAmountInInvoiceCurrency: 500, + invoice: payableInvoice(), + }, + ) + + expect(result).toMatchObject({ ok: true, newStatus: 'partially_paid' }) + expect(vi.mocked(clearSettledInvoiceSuggestions)).not.toHaveBeenCalled() + }) + it('emits invoice.paid with the settled state', async () => { const handler = vi.fn() eventBus.on('invoice.paid', handler) diff --git a/lib/invoices/__tests__/supplier-voucher-matching.test.ts b/lib/invoices/__tests__/supplier-voucher-matching.test.ts index d73f7b99..55af69f8 100644 --- a/lib/invoices/__tests__/supplier-voucher-matching.test.ts +++ b/lib/invoices/__tests__/supplier-voucher-matching.test.ts @@ -10,6 +10,14 @@ import { } from '@/tests/helpers' import { eventBus } from '@/lib/events/bus' +// Issue #1259: settling the invoice retires the suggestion pointers at it. +// Mocked so it consumes no slot in the queued Supabase mock; the helper's own +// query shape is pinned by ./clear-settled-invoice-suggestions.test.ts. +const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() })) +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: mockClearSuggestions, +})) + // ============================================================ // validateVoucherForSupplierInvoiceLink: happy path + rejects // ============================================================ @@ -408,6 +416,16 @@ describe('linkSupplierInvoiceToVoucher', () => { payload: expect.objectContaining({ paymentAmount: 1000, userId: 'user-1' }), }), ) + + // Issue #1259: the invoice is settled, so no transaction may keep pointing + // at it as a match suggestion. + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith( + supabase, + 'company-1', + 'supplier_invoice', + invoice.id, + ) }) it('still returns success even if the post-link invoice re-fetch is empty (event is best-effort)', async () => { @@ -441,6 +459,9 @@ describe('linkSupplierInvoiceToVoucher', () => { } // Event NOT emitted when re-fetch found nothing expect(emitSpy).not.toHaveBeenCalled() + // Issue #1259: a partially paid invoice is still matchable, so the + // suggestions pointing at it must survive. + expect(mockClearSuggestions).not.toHaveBeenCalled() }) }) diff --git a/lib/invoices/__tests__/voucher-matching.test.ts b/lib/invoices/__tests__/voucher-matching.test.ts index 8a296a94..1bfa6bb8 100644 --- a/lib/invoices/__tests__/voucher-matching.test.ts +++ b/lib/invoices/__tests__/voucher-matching.test.ts @@ -10,6 +10,14 @@ import { } from '@/tests/helpers' import { eventBus } from '@/lib/events/bus' +// Issue #1259: settling the invoice retires the suggestion pointers at it. +// Mocked so it consumes no slot in the queued Supabase mock; the helper's own +// query shape is pinned by ./clear-settled-invoice-suggestions.test.ts. +const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() })) +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: mockClearSuggestions, +})) + // ============================================================ // validateVoucherForInvoiceLink: happy path + reject codes // ============================================================ @@ -651,4 +659,55 @@ describe('linkInvoiceToVoucher', () => { expect(result.ok).toBe(false) if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_DB_ERROR') }) + + // Issue #1259: an invoice settled through this path must not leave other + // transactions pointing at it as a match suggestion. + function enqueueRpcOk( + enqueue: (r: { data?: unknown; error?: unknown }) => void, + invoiceStatus: 'paid' | 'partially_paid', + ) { + enqueue({ + data: { + ok: true, + payment_id: 'pay-1', + invoice_status: invoiceStatus, + paid_amount: invoiceStatus === 'paid' ? 1000 : 400, + remaining_amount: invoiceStatus === 'paid' ? 0 : 600, + payment_amount: invoiceStatus === 'paid' ? 1000 : 400, + journal_entry_id: 'je-1', + currency: 'SEK', + payment_date: '2026-06-01', + }, + error: null, + }) + // Post-link invoice re-fetch for the event payload. + enqueue({ data: null, error: null }) + } + + it('retires the settled invoice suggestions on a full payment', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueRpcOk(enqueue, 'paid') + + const result = await linkInvoiceToVoucher(supabase as never, 'user-1', 'company-1', { + invoiceId: 'inv-1', + journalEntryId: 'je-1', + }) + + expect(result.ok).toBe(true) + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith(supabase, 'company-1', 'invoice', 'inv-1') + }) + + it('leaves the suggestions alone on a partial payment: the invoice is still matchable', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueRpcOk(enqueue, 'partially_paid') + + const result = await linkInvoiceToVoucher(supabase as never, 'user-1', 'company-1', { + invoiceId: 'inv-1', + journalEntryId: 'je-1', + }) + + expect(result.ok).toBe(true) + expect(mockClearSuggestions).not.toHaveBeenCalled() + }) }) diff --git a/lib/invoices/clear-settled-batch-allocations.ts b/lib/invoices/clear-settled-batch-allocations.ts new file mode 100644 index 00000000..75b5e770 --- /dev/null +++ b/lib/invoices/clear-settled-batch-allocations.ts @@ -0,0 +1,59 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { clearSettledInvoiceSuggestions } from './clear-settled-invoice-suggestions' + +/** + * One allocation result as returned by the `match_batch_allocate` RPC + * (supabase/migrations/20260601122000_match_batch_allocate_round3_fixes.sql). + * Only the fields this cleanup needs are declared; both callers pass richer + * rows. + */ +export interface BatchAllocationResult { + kind: 'customer_invoice' | 'supplier_invoice' | string + invoice_id?: string | null + supplier_invoice_id?: string | null + status: 'paid' | 'partially_paid' | string +} + +/** + * Retire the suggestion pointers at every invoice a samlingsbetalning settled + * in full (issue #1259). + * + * The RPC nulls potential_invoice_id / potential_supplier_invoice_id only on + * the source transaction (WHERE id = p_tx_id), so every OTHER transaction of + * the company keeps a pointer at an invoice the batch just closed. That row is + * excluded here via exceptTransactionId: the RPC already linked it. + * + * Shared by the two callers of `match_batch_allocate`, the HTTP route + * (app/api/transactions/[id]/match-batch/route.ts) and the MCP staged-operation + * executor (commitMatchBatchAllocate in lib/pending-operations/commit.ts), so + * they cannot drift. + * + * Partially paid allocations are deliberately left alone: such an invoice is + * still matchable, so its suggestions must survive. + * + * Best effort like the helper it wraps: the batch verifikat is already + * committed, so a failed cleanup must never fail the settle. + */ +export async function clearSettledBatchAllocationSuggestions( + supabase: SupabaseClient, + companyId: string, + allocations: readonly BatchAllocationResult[], + transactionId: string, +): Promise { + for (const alloc of allocations) { + if (alloc.status !== 'paid') continue + if (alloc.kind === 'customer_invoice' && alloc.invoice_id) { + await clearSettledInvoiceSuggestions(supabase, companyId, 'invoice', alloc.invoice_id, { + exceptTransactionId: transactionId, + }) + } else if (alloc.kind === 'supplier_invoice' && alloc.supplier_invoice_id) { + await clearSettledInvoiceSuggestions( + supabase, + companyId, + 'supplier_invoice', + alloc.supplier_invoice_id, + { exceptTransactionId: transactionId }, + ) + } + } +} diff --git a/lib/invoices/clear-settled-invoice-suggestions.ts b/lib/invoices/clear-settled-invoice-suggestions.ts new file mode 100644 index 00000000..25ff0d97 --- /dev/null +++ b/lib/invoices/clear-settled-invoice-suggestions.ts @@ -0,0 +1,60 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' + +const log = createLogger('invoices/clear-settled-invoice-suggestions') + +export type SettledInvoiceKind = 'invoice' | 'supplier_invoice' + +/** + * Retire the match SUGGESTIONS that point at an invoice which has just been + * fully settled. + * + * potential_invoice_id / potential_supplier_invoice_id are written once, at + * bank import (lib/transactions/ingest.ts) or by the retro-match handler, and + * nothing revisited them. When one of several identical recurring invoices was + * paid off by a DIFFERENT transaction, every other transaction kept a pointer + * at a now fully paid invoice. Read paths already refuse to offer such a + * candidate (lib/invoices/matchable-statuses.ts, lib/worklist/categories.ts, + * the transactions page), but the row still blocks a fresh suggestion: both + * re-suggestion scans require the column to be NULL + * (lib/bookkeeping/handlers/supplier-invoice-handler.ts and + * app/api/transactions/batch-match-invoices/route.ts). + * + * This complements the read-time revalidation rather than replacing it: the + * guards stay the backstop for the settle paths not wired up here. + * + * Call ONLY when the invoice is fully settled: a partially paid invoice is + * still matchable, so its suggestions must survive. + * + * Best-effort and non-fatal by construction: every caller has already booked a + * payment verifikat, so a failed cleanup must never fail the settle. + */ +export async function clearSettledInvoiceSuggestions( + supabase: SupabaseClient, + companyId: string, + kind: SettledInvoiceKind, + invoiceId: string, + options?: { exceptTransactionId?: string | null }, +): Promise { + const column = kind === 'invoice' ? 'potential_invoice_id' : 'potential_supplier_invoice_id' + let query = supabase + .from('transactions') + .update({ [column]: null }) + // company_id is defense in depth: service-role callers have no RLS. + .eq('company_id', companyId) + // Never widen: only this invoice's own pointer, never any other hint and + // never the confirmed link columns (invoice_id / supplier_invoice_id). + .eq(column, invoiceId) + if (options?.exceptTransactionId) { + query = query.neq('id', options.exceptTransactionId) + } + const { error } = await query + if (error) { + log.warn('failed to clear settled invoice suggestions', { + companyId, + kind, + invoiceId, + reason: error.message, + }) + } +} diff --git a/lib/invoices/settle-invoice-payment.ts b/lib/invoices/settle-invoice-payment.ts index fed207cf..3898df5f 100644 --- a/lib/invoices/settle-invoice-payment.ts +++ b/lib/invoices/settle-invoice-payment.ts @@ -8,6 +8,7 @@ import { resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payme 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 { eventBus } from '@/lib/events' import type { CreateJournalEntryInput, Customer, EntityType, Invoice } from '@/types' @@ -285,6 +286,13 @@ export async function settleInvoicePayment( return { ok: false, code: 'INVOICE_PAID_RACE' } } + // Fully settled: retire every transaction's suggestion pointer at this + // invoice (issue #1259). No exceptTransactionId: this flow is not driven by + // a bank transaction, so any pointer at it is now dead. + if (newStatus === 'paid') { + await clearSettledInvoiceSuggestions(supabase, companyId, 'invoice', invoice.id) + } + // Notify subscribers: invoice.paid fans out to registered webhooks and the // Stripe extension's link-deactivation handler. Best-effort: the payment is // already committed, so an emit failure must not fail the operation. diff --git a/lib/invoices/supplier-voucher-matching.ts b/lib/invoices/supplier-voucher-matching.ts index fb48eb54..033e2060 100644 --- a/lib/invoices/supplier-voucher-matching.ts +++ b/lib/invoices/supplier-voucher-matching.ts @@ -25,6 +25,7 @@ import { customerNameMatches, } from './invoice-matching' import { autoReconcileTransactionForLinkedVoucher } from '@/lib/reconciliation/bank-reconciliation' +import { clearSettledInvoiceSuggestions } from './clear-settled-invoice-suggestions' import { documentCurrency, ledgerLineSideAmountIn } from '@/lib/bookkeeping/ledger-line-amount' import type { SupplierInvoice, Supplier } from '@/types' import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' @@ -701,6 +702,19 @@ export async function linkSupplierInvoiceToVoucher( }) } + // The invoice is settled, so every transaction still carrying a suggestion + // pointer at it is dead: retire them (issue #1259). No exceptTransactionId: + // the reconciled row (if any) has already had its own hint cleared by the + // auto-reconcile tag update, so nothing here needs preserving. + if (result.invoice_status === 'paid') { + await clearSettledInvoiceSuggestions( + supabase, + companyId, + 'supplier_invoice', + params.supplierInvoiceId, + ) + } + return { ok: true, result: { diff --git a/lib/invoices/voucher-matching.ts b/lib/invoices/voucher-matching.ts index eed55ed2..e0094598 100644 --- a/lib/invoices/voucher-matching.ts +++ b/lib/invoices/voucher-matching.ts @@ -29,6 +29,7 @@ import { customerNameMatches, } from './invoice-matching' import { autoReconcileTransactionForLinkedVoucher } from '@/lib/reconciliation/bank-reconciliation' +import { clearSettledInvoiceSuggestions } from './clear-settled-invoice-suggestions' import { documentCurrency, ledgerLineSideAmountIn } from '@/lib/bookkeeping/ledger-line-amount' import type { Invoice, Customer } from '@/types' @@ -804,6 +805,14 @@ export async function linkInvoiceToVoucher( }) } + // The invoice is settled, so every transaction still carrying a suggestion + // pointer at it is dead: retire them (issue #1259). No exceptTransactionId: + // the reconciled row (if any) has already had its own hint cleared by the + // auto-reconcile tag update, so nothing here needs preserving. + if (rpc.invoice_status === 'paid') { + await clearSettledInvoiceSuggestions(supabase, companyId, 'invoice', params.invoiceId) + } + return { ok: true, result: { diff --git a/lib/pending-operations/__tests__/mark-invoice-paid.test.ts b/lib/pending-operations/__tests__/mark-invoice-paid.test.ts index f6a6bf45..1c176e47 100644 --- a/lib/pending-operations/__tests__/mark-invoice-paid.test.ts +++ b/lib/pending-operations/__tests__/mark-invoice-paid.test.ts @@ -34,6 +34,14 @@ vi.mock('@/lib/invoices/duplicate-payment-candidates', () => ({ findDuplicatePaymentCandidatesForInvoice: (...args: unknown[]) => mockFindDupPayments(...args), })) +// Issue #1259: settling the invoice retires the suggestion pointers at it. +// Mocked so it consumes no slot in the queued Supabase mock; the helper's own +// query shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() })) +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: mockClearSuggestions, +})) + import { commitPendingOperation } from '../commit' function makePendingOp(overrides: Partial): PendingOperation { @@ -135,5 +143,16 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () => invoice: expect.objectContaining({ id: 'inv-1', status: 'paid', remaining_amount: 0, paid_amount: 525 }), }), ) + + // Issue #1259: the invoice is settled, so no transaction may keep pointing + // at it as a match suggestion. This flow is not driven by a bank + // transaction, so nothing is excluded. + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith(supabase, 'company-1', 'invoice', 'inv-1') }) + + // No partial-payment counterpart here: this executor always settles the full + // remaining balance (no custom amount param), so newStatus is always 'paid'. + // The partial case is pinned on the surfaces that can produce it, e.g. + // lib/invoices/__tests__/settle-invoice-payment.test.ts. }) diff --git a/lib/pending-operations/__tests__/match-batch-allocate.test.ts b/lib/pending-operations/__tests__/match-batch-allocate.test.ts new file mode 100644 index 00000000..88b4e9f4 --- /dev/null +++ b/lib/pending-operations/__tests__/match-batch-allocate.test.ts @@ -0,0 +1,172 @@ +/** + * Suggestion-pointer cleanup for the agent/MCP batch-allocation commit path + * (`commitMatchBatchAllocate` in lib/pending-operations/commit.ts), the second + * caller of the `match_batch_allocate` RPC next to + * POST /api/transactions/[id]/match-batch. + * + * Issue #1259: the RPC nulls potential_invoice_id / + * potential_supplier_invoice_id only on the source transaction + * (WHERE id = p_tx_id), so every OTHER transaction of the company keeps a + * pointer at an invoice the samlingsbetalning just closed. Both callers run the + * same shared cleanup (lib/invoices/clear-settled-batch-allocations.ts); the + * HTTP twin has the same test shape in + * app/api/transactions/[id]/match-batch/__tests__/route.test.ts. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { PendingOperation } from '@/types' + +// Mocked so it consumes no slot in the queued Supabase mock; the helper's own +// query shape is pinned by +// lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() })) +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: mockClearSuggestions, +})) + +import { commitPendingOperation } from '../commit' + +const TX_ID = '11111111-1111-4111-8111-111111111111' +const INV_ID = '22222222-2222-4222-8222-222222222222' +const SI_ID = '33333333-3333-4333-8333-333333333333' +const SI_PARTIAL_ID = '44444444-4444-4444-8444-444444444444' + +function makePendingOp(params: Record): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'match_batch_allocate', + status: 'pending', + title: 'test', + params, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'medium', + created_at: '2026-05-03T00:00:00Z', + resolved_at: null, + updated_at: '2026-05-03T00:00:00Z', + } as PendingOperation +} + +const REQUEST_ALLOCATIONS = [ + { kind: 'supplier_invoice', supplier_invoice_id: SI_ID, amount: 1000 }, + { kind: 'supplier_invoice', supplier_invoice_id: SI_PARTIAL_ID, amount: 400 }, +] + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +describe('commitPendingOperation: match_batch_allocate suggestion cleanup', () => { + it('retires suggestions only for the allocations that settled in full', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + ok: true, + journal_entry_id: 'je-batch-1', + voucher_series: 'A', + voucher_number: 42, + tx_id: TX_ID, + allocations: [ + { + kind: 'supplier_invoice', + supplier_invoice_id: SI_ID, + payment_id: 'sip-1', + status: 'paid', + paid_amount: 1000, + remaining_amount: 0, + amount: 1000, + }, + { + kind: 'supplier_invoice', + supplier_invoice_id: SI_PARTIAL_ID, + payment_id: 'sip-2', + status: 'partially_paid', + paid_amount: 400, + remaining_amount: 600, + amount: 400, + }, + ], + total_allocated: 1400, + leftover: 0, + }, + error: null, + }) // match_batch_allocate RPC + enqueue({ data: null, error: null }) // dispatcher finalize update + + const op = makePendingOp({ transaction_id: TX_ID, allocations: REQUEST_ALLOCATIONS }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + // A partially paid invoice is still matchable, so its suggestions survive. + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith( + supabase, + 'company-1', + 'supplier_invoice', + SI_ID, + { exceptTransactionId: TX_ID }, + ) + }) + + it('retires a fully settled customer invoice allocation', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + ok: true, + journal_entry_id: 'je-batch-2', + voucher_series: 'A', + voucher_number: 43, + tx_id: TX_ID, + allocations: [ + { + kind: 'customer_invoice', + invoice_id: INV_ID, + payment_id: 'ip-1', + status: 'paid', + paid_amount: 1000, + remaining_amount: 0, + amount: 1000, + }, + ], + total_allocated: 1000, + leftover: 0, + }, + error: null, + }) // match_batch_allocate RPC + enqueue({ data: null, error: null }) // dispatcher finalize update + + const op = makePendingOp({ + transaction_id: TX_ID, + allocations: [{ kind: 'customer_invoice', invoice_id: INV_ID, amount: 1000 }], + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith(supabase, 'company-1', 'invoice', INV_ID, { + exceptTransactionId: TX_ID, + }) + }) + + it('retires nothing when the RPC reports a structured failure', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { ok: false, code: 'BATCH_OVER_ALLOCATED' }, error: null }) // RPC + enqueue({ data: null, error: null }) // dispatcher rejection update + + const op = makePendingOp({ transaction_id: TX_ID, allocations: REQUEST_ALLOCATIONS }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(mockClearSuggestions).not.toHaveBeenCalled() + }) +}) diff --git a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts index 1c4412a1..bd8d5bc7 100644 --- a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts +++ b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts @@ -27,6 +27,14 @@ vi.mock('@/lib/bookkeeping/invoice-entries', async () => { } }) +// Issue #1259: settling the invoice retires the suggestion pointers at it. +// Mocked so it consumes no slot in the queued Supabase mock; the helper's own +// query shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. +const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() })) +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: mockClearSuggestions, +})) + import { commitPendingOperation } from '../commit' function makePendingOp(overrides: Partial): PendingOperation { @@ -149,6 +157,58 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r '1940', ) expect(mockCreateCashEntry).not.toHaveBeenCalled() + // Issue #1259: the invoice is settled, so every OTHER transaction still + // carrying a suggestion pointer at it is retired; this op's own row is + // cleared by the link update. + expect(mockClearSuggestions).toHaveBeenCalledTimes(1) + expect(mockClearSuggestions).toHaveBeenCalledWith(supabase, 'company-1', 'invoice', 'inv-1', { + exceptTransactionId: 'tx-1', + }) + }) + + it('leaves the suggestions alone on a partial payment: the invoice is still matchable', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'tx-1', + company_id: 'company-1', + amount: 5000, + currency: 'SEK', + date: '2026-05-12', + invoice_id: null, + journal_entry_id: null, + cash_account_id: null, + }, + error: null, + }) // transaction fetch + enqueue({ + data: { + id: 'inv-1', + invoice_number: 'F-2026001', + status: 'sent', + total: 12500, + remaining_amount: 12500, + paid_amount: 0, + currency: 'SEK', + exchange_rate: null, + journal_entry_id: null, + customer: { name: 'Test AB' }, + }, + error: null, + }) // invoice fetch + enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings + enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update + enqueue({ data: null, error: null }) // invoice_payments insert + enqueue({ data: null, error: null }) // transactions update (link) + enqueue({ data: null, error: null }) // dispatcher pending_operations update + + const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ invoice_status: 'partially_paid' }) + expect(mockClearSuggestions).not.toHaveBeenCalled() }) it('defaults to 1930 when the transaction has no linked cash account', async () => { diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index b6049858..704cc621 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -57,6 +57,11 @@ import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching' import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates' import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { + clearSettledBatchAllocationSuggestions, + type BatchAllocationResult, +} from '@/lib/invoices/clear-settled-batch-allocations' import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry' import { getErrorEntry } from '@/lib/errors/structured-errors' import { parseSIEFile } from '@/lib/import/sie-parser' @@ -2051,6 +2056,13 @@ async function commitMarkInvoicePaid( } } + // Fully settled: retire every transaction's suggestion pointer at this + // invoice (issue #1259). No exceptTransactionId: this flow is not driven by + // a bank transaction, so any pointer at it is now dead. + if (newStatus === 'paid') { + await clearSettledInvoiceSuggestions(supabase, companyId, 'invoice', invoiceId) + } + // Notify subscribers: invoice.paid fans out to registered webhooks // (lib/webhooks/handler.ts). Best-effort: the payment is already committed, // so an emit failure must not fail the operation. Parity with the v1 and @@ -2600,6 +2612,15 @@ async function commitMatchTransactionInvoice( notes: paymentNotes, }) + // The invoice is now settled, so every OTHER transaction still carrying a + // suggestion pointer at it is dead: retire them (issue #1259). This + // operation's own row is cleared by the update just below. + if (isFullyPaid) { + await clearSettledInvoiceSuggestions(supabase, companyId, 'invoice', invoiceId, { + exceptTransactionId: transactionId, + }) + } + await supabase .from('transactions') .update({ @@ -5052,7 +5073,13 @@ async function commitMatchBatchAllocate( }) return { error: error.message || 'Database error', status: 500 } } - const result = data as { ok: boolean; code?: string; details?: unknown; journal_entry_id?: string } + const result = data as { + ok: boolean + code?: string + details?: unknown + journal_entry_id?: string + allocations?: BatchAllocationResult[] + } if (!result || !result.ok) { return { error: result?.code || 'match_batch_allocate failed', @@ -5060,6 +5087,12 @@ async function commitMatchBatchAllocate( data: result?.details as Record | undefined, } } + // Every allocation the RPC settled in full retires its suggestion pointer + // from the company's OTHER transactions (issue #1259): the RPC only nulls + // them on the source tx. Same helper as the HTTP twin + // (app/api/transactions/[id]/match-batch/route.ts) so the two cannot drift. + await clearSettledBatchAllocationSuggestions(supabase, companyId, result.allocations ?? [], txId) + // Structured audit-trail entry on success (compliance-swarm V16). Tx // count + JE id + the source tx id only: no amounts, no // counterparty identifiers, no descriptions. txId is included diff --git a/lib/transactions/link-journal-entry.ts b/lib/transactions/link-journal-entry.ts index f0d56dc8..dfbdcd61 100644 --- a/lib/transactions/link-journal-entry.ts +++ b/lib/transactions/link-journal-entry.ts @@ -15,6 +15,7 @@ */ import type { SupabaseClient } from '@supabase/supabase-js' import { eventBus } from '@/lib/events/bus' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { logMatchEvent } from '@/lib/invoices/match-log' import { createLogger } from '@/lib/logger' import type { Invoice, Transaction } from '@/types' @@ -403,6 +404,14 @@ export async function linkTransactionToJournalEntry( await rollbackTxLink('invoice_payments insert failed') return { ok: false, code: 'MATCH_INVOICE_RECORD_PAYMENT_FAILED' } } + + // The invoice is settled, so every transaction still carrying a suggestion + // pointer at it is dead: retire them (issue #1259). No exceptTransactionId + // needed: this row's own hints were already nulled by the tx update above, + // so the invoice-id filter no longer selects it. + if (isFullyPaid) { + await clearSettledInvoiceSuggestions(supabase, companyId, 'invoice', invoiceId) + } } logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', { diff --git a/lib/worklist/__tests__/categories.test.ts b/lib/worklist/__tests__/categories.test.ts index a7bbfa3b..84d5e1aa 100644 --- a/lib/worklist/__tests__/categories.test.ts +++ b/lib/worklist/__tests__/categories.test.ts @@ -109,7 +109,6 @@ describe('countVerifikatMissingDocument', () => { describe('simple head counts', () => { it.each([ - ['countSuggestedMatches', countSuggestedMatches, 'transactions'], ['countSupplierInvoicesAwaitingApproval', countSupplierInvoicesAwaitingApproval, 'supplier_invoices'], ['countOverdueInvoices', countOverdueInvoices, 'invoices'], ['countDeadlinesNeedingAction', countDeadlinesNeedingAction, 'deadlines'], @@ -121,6 +120,76 @@ describe('simple head counts', () => { }) }) +// Issue #1259: the badge delegates to listSuggestedMatches so it can never +// claim a number the list refuses to render. A raw head count over the hint +// columns counted pointers at invoices settled by a different transaction. +describe('countSuggestedMatches', () => { + it('counts only hints whose candidate is still matchable', async () => { + enqueue({ + data: [ + { + id: 'tx-1', + date: '2026-06-01', + description: 'ICA', + amount: 423, + currency: 'SEK', + potential_invoice_id: 'inv-1', + potential_supplier_invoice_id: null, + }, + { + id: 'tx-2', + date: '2026-05-30', + description: 'TELIA', + amount: -549, + currency: 'SEK', + potential_invoice_id: null, + potential_supplier_invoice_id: 'sinv-paid', + }, + ], + }) + // inv-1 is still open; sinv-paid was settled by another transaction, so the + // status/remaining filters exclude it server-side. + enqueue({ + data: [ + { id: 'inv-1', invoice_number: 'F-1', total: 423, customer: { name: 'Kund AB' } }, + ], + }) + enqueue({ data: [] }) + + await expect(countSuggestedMatches(supabase, COMPANY)).resolves.toBe(1) + expect(mockSupabase.from).toHaveBeenCalledWith('transactions') + }) + + it('returns 0 when the only hint points at an invoice settled elsewhere', async () => { + enqueue({ + data: [ + { + id: 'tx-1', + date: '2026-06-01', + description: 'MONTHLY FEE', + amount: -549, + currency: 'SEK', + potential_invoice_id: null, + potential_supplier_invoice_id: 'sinv-paid', + }, + ], + }) + enqueue({ data: [] }) + await expect(countSuggestedMatches(supabase, COMPANY)).resolves.toBe(0) + }) + + it('soft-fails to 0 on query error', async () => { + enqueue({ error: { message: 'boom' } }) + await expect(countSuggestedMatches(supabase, COMPANY)).resolves.toBe(0) + }) + + it('clamps the scan so the badge cannot walk an unbounded hint set', async () => { + enqueue({ data: [] }) + await countSuggestedMatches(supabase, COMPANY) + expect(findCall('transactions', 'limit')).toEqual([200]) + }) +}) + describe('listSuggestedMatches', () => { it('maps invoice and supplier-invoice hints to confirmable rows', async () => { enqueue({ @@ -282,4 +351,65 @@ describe('listSuggestedMatches', () => { await expect(listSuggestedMatches(supabase, COMPANY)).resolves.toEqual([]) }) + + // The badge scans up to 200 hints (SUGGESTED_MATCH_SCAN_CAP), past the 150 + // ids per .in() that countInboxDocuments already chunks for: PostgREST puts + // them in the GET query string, and a 414 would come back as a silent 0. + it('chunks the candidate id list at 150 ids per lookup', async () => { + const txRows = Array.from({ length: 151 }, (_, i) => ({ + id: `tx-${i}`, + date: '2026-06-01', + description: 'X', + amount: 100, + currency: 'SEK', + potential_invoice_id: `inv-${i}`, + potential_supplier_invoice_id: null, + })) + enqueue({ data: txRows }) + enqueue({ data: [] }) // chunk 1 + enqueue({ data: [] }) // chunk 2 + + await listSuggestedMatches(supabase, COMPANY, 200) + + const idFilters = findCalls('invoices', 'in').filter(([col]) => col === 'id') + expect(idFilters).toHaveLength(2) + expect((idFilters[0][1] as string[]).length).toBe(150) + expect((idFilters[1][1] as string[]).length).toBe(1) + }) + + // Previously the candidate results were consumed without checking .error, so + // a 414 / 500 / RLS change yielded empty maps, an empty list and a zero badge + // with nothing logged. + it('returns [] and logs with companyId when a candidate lookup fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + enqueue({ + data: [ + { + id: 'tx-1', + date: '2026-06-01', + description: 'X', + amount: 100, + currency: 'SEK', + potential_invoice_id: 'inv-1', + potential_supplier_invoice_id: null, + }, + ], + }) + enqueue({ error: { message: 'boom' } }) + + await expect(listSuggestedMatches(supabase, COMPANY)).resolves.toEqual([]) + expect(consoleError).toHaveBeenCalled() + const logged = consoleError.mock.calls.map((c) => String(c[0])).join('\n') + expect(logged).toContain('candidate lookup failed') + expect(logged).toContain(COMPANY) + consoleError.mockRestore() + }) + + it('logs the tenant with the transaction query failure too', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + enqueue({ error: { message: 'boom' } }) + await expect(listSuggestedMatches(supabase, COMPANY)).resolves.toEqual([]) + expect(String(consoleError.mock.calls[0]?.[0])).toContain(COMPANY) + consoleError.mockRestore() + }) }) diff --git a/lib/worklist/aggregate.ts b/lib/worklist/aggregate.ts index c9c376ae..625ab2f9 100644 --- a/lib/worklist/aggregate.ts +++ b/lib/worklist/aggregate.ts @@ -12,9 +12,10 @@ import { } from './categories' /** - * All worklist counts in one round-trip burst. Every count is a cheap - * head-only query (categories.ts) and individually soft-fails to 0, so this - * is safe to call from layouts and server components on every render. + * All worklist counts in one round-trip burst. Each count is a bounded query + * (mostly head-only; suggested_match revalidates its candidates, see + * categories.ts) and individually soft-fails to 0, so this is safe to call + * from layouts and server components on every render. * * `total` is the number of distinct actionable items: suggested_match is a * fast path over transactions already counted in book_transaction, so it is diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts index be49a895..b0f89119 100644 --- a/lib/worklist/categories.ts +++ b/lib/worklist/categories.ts @@ -129,20 +129,29 @@ export async function countInboxDocuments( const SUGGESTED_MATCH_OR = 'potential_invoice_id.not.is.null,potential_supplier_invoice_id.not.is.null' -/** Unbooked transactions with an invoice/supplier-invoice match hint. */ +/** + * Cap on the hint scan behind countSuggestedMatches; clamps like + * INBOX_SCAN_CAP. Above IN_CLAUSE_CHUNK on purpose: the candidate lookups it + * feeds are chunked (fetchCandidatesChunked), so the URL length stays bounded + * regardless of this number. + */ +const SUGGESTED_MATCH_SCAN_CAP = 200 + +/** + * Unbooked transactions with a still-actionable invoice/supplier-invoice match + * hint. + * + * Delegates to listSuggestedMatches so the badge can never claim a number the + * list cannot render: a head count over the raw hint columns still counts a + * pointer at an invoice settled elsewhere (issue #1259), which is exactly the + * divergence lib/worklist exists to prevent (see types.ts). + */ export async function countSuggestedMatches( supabase: SupabaseClient, companyId: string, ): Promise { - const { count, error } = await supabase - .from('transactions') - .select('id', { count: 'exact', head: true }) - .eq('company_id', companyId) - .is('is_business', null) - .eq('is_ignored', false) - .or(SUGGESTED_MATCH_OR) - if (error) return logAndZero('suggested_match', companyId, error) - return count ?? 0 + const matches = await listSuggestedMatches(supabase, companyId, SUGGESTED_MATCH_SCAN_CAP) + return matches.length } /** Supplier invoices awaiting approval ("attestera"). */ @@ -259,6 +268,37 @@ interface SuggestedMatchTxRow { potential_supplier_invoice_id: string | null } +type CandidateRow = { + id: string + invoice_number?: string | null + supplier_invoice_number?: string | null + total: number | null + customer?: { name: string | null } | null + supplier?: { name: string | null } | null +} + +/** + * Run one candidate lookup over a chunked id list. PostgREST serialises .in() + * into the GET query string, so a long list can push the URL past proxy limits + * (HTTP 414) exactly as countInboxDocuments guards against. The first error + * short-circuits: a partial candidate set would silently drop rows from the + * list and, through countSuggestedMatches, from the badge. + */ +async function fetchCandidatesChunked( + ids: string[], + runChunk: ( + chunk: string[], + ) => PromiseLike<{ data: unknown; error: { message?: string } | null }>, +): Promise<{ rows: CandidateRow[]; error: { message?: string } | null }> { + const rows: CandidateRow[] = [] + for (let i = 0; i < ids.length; i += IN_CLAUSE_CHUNK) { + const { data, error } = await runChunk(ids.slice(i, i + IN_CLAUSE_CHUNK)) + if (error) return { rows: [], error } + rows.push(...((data ?? []) as unknown as CandidateRow[])) + } + return { rows, error: null } +} + /** * Suggested transaction↔invoice matches with enough candidate context for a * one-click confirm row. Confirm endpoints: @@ -272,9 +312,11 @@ interface SuggestedMatchTxRow { * that has since been paid would otherwise render a one-click confirm row * whose endpoint can only answer ALREADY_PAID. * - * Revalidation is done here, at read time, rather than by cleaning up sibling - * pointers on settle: the settle paths are many and a missed one leaks, while - * this check covers every route into the list. + * Revalidation stays here, at read time, even though the high-traffic settle + * paths now also retire sibling pointers + * (lib/invoices/clear-settled-invoice-suggestions.ts, issue #1259): the settle + * paths are many and a missed one leaks, while this check covers every route + * into the list. */ export async function listSuggestedMatches( supabase: SupabaseClient, @@ -293,51 +335,55 @@ export async function listSuggestedMatches( .order('date', { ascending: false }) .limit(limit) if (error) { - log.error('worklist listSuggestedMatches failed', { reason: error.message }) + // companyId matches logAndZero's convention so repeated failures can be + // correlated to a tenant in monitoring. + log.error('worklist listSuggestedMatches failed', { companyId, reason: error.message }) return [] } const txs = (txRows ?? []) as SuggestedMatchTxRow[] - const invoiceIds = txs.map((t) => t.potential_invoice_id).filter((x): x is string => !!x) - const supplierInvoiceIds = txs - .map((t) => t.potential_supplier_invoice_id) - .filter((x): x is string => !!x) + const invoiceIds = [ + ...new Set(txs.map((t) => t.potential_invoice_id).filter((x): x is string => !!x)), + ] + const supplierInvoiceIds = [ + ...new Set(txs.map((t) => t.potential_supplier_invoice_id).filter((x): x is string => !!x)), + ] const [invoiceRes, supplierRes] = await Promise.all([ - invoiceIds.length > 0 - ? supabase - .from('invoices') - .select('id, invoice_number, total, customer:customers(name)') - .eq('company_id', companyId) - .in('id', invoiceIds) - .in('status', [...MATCHABLE_INVOICE_STATUSES]) - .gt('remaining_amount', 0) - : Promise.resolve({ data: [], error: null }), - supplierInvoiceIds.length > 0 - ? supabase - .from('supplier_invoices') - .select('id, supplier_invoice_number, total, supplier:suppliers(name)') - .eq('company_id', companyId) - .in('id', supplierInvoiceIds) - .in('status', [...MATCHABLE_SUPPLIER_INVOICE_STATUSES]) - .gt('remaining_amount', 0) - : Promise.resolve({ data: [], error: null }), + fetchCandidatesChunked(invoiceIds, (chunk) => + supabase + .from('invoices') + .select('id, invoice_number, total, customer:customers(name)') + .eq('company_id', companyId) + .in('id', chunk) + .in('status', [...MATCHABLE_INVOICE_STATUSES]) + .gt('remaining_amount', 0), + ), + fetchCandidatesChunked(supplierInvoiceIds, (chunk) => + supabase + .from('supplier_invoices') + .select('id, supplier_invoice_number, total, supplier:suppliers(name)') + .eq('company_id', companyId) + .in('id', chunk) + .in('status', [...MATCHABLE_SUPPLIER_INVOICE_STATUSES]) + .gt('remaining_amount', 0), + ), ]) - type CandidateRow = { - id: string - invoice_number?: string | null - supplier_invoice_number?: string | null - total: number | null - customer?: { name: string | null } | null - supplier?: { name: string | null } | null + // A failed candidate lookup must not pass for "nothing is matchable": that + // would render an empty list and, through countSuggestedMatches, a silent + // zero badge. Log it (with companyId) and bail, same as the tx query above. + const candidateError = invoiceRes.error ?? supplierRes.error + if (candidateError) { + log.error('worklist listSuggestedMatches candidate lookup failed', { + companyId, + reason: candidateError.message, + }) + return [] } - const invoiceById = new Map( - ((invoiceRes.data ?? []) as unknown as CandidateRow[]).map((r) => [r.id, r]), - ) - const supplierById = new Map( - ((supplierRes.data ?? []) as unknown as CandidateRow[]).map((r) => [r.id, r]), - ) + + const invoiceById = new Map(invoiceRes.rows.map((r) => [r.id, r])) + const supplierById = new Map(supplierRes.rows.map((r) => [r.id, r])) const matches: SuggestedMatch[] = [] for (const tx of txs) { diff --git a/supabase/migrations/20260730120000_clear_stale_invoice_match_pointers.sql b/supabase/migrations/20260730120000_clear_stale_invoice_match_pointers.sql new file mode 100644 index 00000000..346ac689 --- /dev/null +++ b/supabase/migrations/20260730120000_clear_stale_invoice_match_pointers.sql @@ -0,0 +1,38 @@ +-- Issue #1259: retire suggestion pointers left behind when the invoice was +-- settled by a different transaction. +-- +-- potential_invoice_id / potential_supplier_invoice_id are write-once import +-- suggestions. Until now nothing revisited them, so a recurring invoice paid +-- off by transaction B left every other transaction pointing at a fully paid +-- invoice. The read paths already refuse to offer such a candidate, but the +-- non-NULL column blocks a FRESH suggestion (both re-suggestion scans require +-- it to be NULL), so the affected transactions can never be suggested again. +-- +-- Data only, no DDL. The potential_* columns carry no accounting meaning: the +-- confirmed links live in invoice_id / supplier_invoice_id, which are +-- untouched here, as are all journal entries, verifikat and period locks. +-- Idempotent: re-running clears nothing extra. +-- +-- The status lists below are byte-identical to +-- lib/invoices/matchable-statuses.ts (MATCHABLE_SUPPLIER_INVOICE_STATUSES / +-- MATCHABLE_INVOICE_STATUSES). Keep them in sync. + +UPDATE public.transactions t +SET potential_supplier_invoice_id = NULL +WHERE t.potential_supplier_invoice_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM public.supplier_invoices si + WHERE si.id = t.potential_supplier_invoice_id + AND si.status IN ('registered', 'approved', 'overdue', 'partially_paid') + AND COALESCE(si.remaining_amount, 0) > 0 + ); + +UPDATE public.transactions t +SET potential_invoice_id = NULL +WHERE t.potential_invoice_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM public.invoices i + WHERE i.id = t.potential_invoice_id + AND i.status IN ('sent', 'overdue', 'partially_paid') + AND COALESCE(i.remaining_amount, 0) > 0 + );