From b7f60b23f57fea7b20150b8f08a3b812be4ccd38 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:51:31 +0200 Subject: [PATCH] fix(invoices): v1 mark-paid booking-state routing + journal_entry_id backfill (#713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invoices.journal_entry_id means "the registration verifikat that booked this invoice at issuance" — payment flows route on it (set → clear 1510, NULL → kontantmetoden cash entry). Two bugs in v1 mark-paid broke that: - The pre-flight select omitted journal_entry_id, so invoiceAlreadyBooked always read false — a kontantmetoden company paying an already-registered invoice would re-recognise revenue + VAT (double-booking) and orphan the 1510 receivable. Fixed by fetching the column for routing only; the response contract and invoice.paid event payload are unchanged. - The update wrote the just-created PAYMENT/cash entry id into the column (wrong semantic) — once routing reads the column, a cash partial payment #1 would make payment #2 clear a 1510 that was never debited. Removed; the payment entry id still returns in the response body. New backfill migration links the earliest posted invoice_created entry to historical invoices (353 registered-but-unlinked rows in hosted prod), repairs any payment-type links, and links credit_note reversal entries to credit-note rows. Idempotent; rows with no registration entry stay NULL (correct for kontantmetoden/unsent invoices). Tests: 3 new unit tests lock the select projection, the already-booked→ clearing routing, and the no-write-back semantics (the supabase mock now records call args). New pg-real suite (11 tests) runs the actual migration SQL: earliest-wins, reversed/draft exclusion, no-overwrite, cash stays NULL, payment-link repair, credit notes, cross-company isolation, idempotency. insertDraftJournalEntry fixture gains optional sourceType/ sourceId/createdAt (defaults unchanged). Hosted prod requires manual migration apply after merge (Supabase MCP). Co-authored-by: Claude Fable 5 --- .../[id]/mark-paid/__tests__/route.test.ts | 114 +++++- .../invoices/[id]/mark-paid/route.ts | 14 +- ...000_invoices_journal_entry_id_backfill.sql | 72 ++++ tests/pg/fixtures.ts | 12 +- ...oices-journal-entry-id-backfill.pg.test.ts | 332 ++++++++++++++++++ 5 files changed, 535 insertions(+), 9 deletions(-) create mode 100644 supabase/migrations/20260622120000_invoices_journal_entry_id_backfill.sql create mode 100644 tests/pg/invoices-journal-entry-id-backfill.pg.test.ts 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 1877bbc6..320bc6c9 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 @@ -55,7 +55,13 @@ const mockPayment = mockedPayment as ReturnType const mockCash = mockedCash as ReturnType type MockResult = { data?: unknown; error?: unknown } -function makeFlexibleSupabase(byTable: Record) { +type RecordedCall = { table: string; method: string; args: unknown[] } +function makeFlexibleSupabase( + byTable: Record, + // Optional recorder: collects every (table, method, args) so tests can + // assert on select projections and update payloads, not just results. + calls?: RecordedCall[], +) { const queues = new Map() for (const [t, val] of Object.entries(byTable)) { queues.set(t, Array.isArray(val) ? [...val] : [val]) @@ -70,7 +76,10 @@ 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) @@ -192,6 +201,107 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => { expect(mockPayment).not.toHaveBeenCalled() }) + it('fetches journal_entry_id in the pre-flight select but keeps it out of the response select', async () => { + const calls: RecordedCall[] = [] + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: SENT_INVOICE, error: null }, + { data: PAID_INVOICE, error: null }, + ], + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }, + calls, + ), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + { payment_date: '2026-05-12' }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + expect(res.status).toBe(200) + + const invoiceSelects = calls.filter((c) => c.table === 'invoices' && c.method === 'select') + // Pre-flight select must fetch journal_entry_id — invoiceAlreadyBooked + // routing reads it; omitting it silently forces the cash path. + expect(invoiceSelects.length).toBeGreaterThanOrEqual(2) + expect(String(invoiceSelects[0].args[0])).toContain('journal_entry_id') + // Response select (the update's .select) keeps the public contract unchanged. + expect(String(invoiceSelects[1].args[0])).not.toContain('journal_entry_id') + }) + + it('clears AR (payment entry) when a cash-method company pays an invoice booked at send', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { + // Booked at send under accrual: registration entry linked. + data: { ...SENT_INVOICE, journal_entry_id: 'rrrrrrrr-rrrr-4rrr-8rrr-rrrrrrrrrrrr' }, + error: null, + }, + { data: PAID_INVOICE, error: null }, + ], + company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + { payment_date: '2026-05-12' }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + // Already-booked → clearing entry (Dr 1930 / Cr 1510), NOT a cash entry — + // a cash entry here would re-recognise revenue + VAT (double-booking). + expect(mockPayment).toHaveBeenCalled() + expect(mockCash).not.toHaveBeenCalled() + }) + + it('does not write journal_entry_id back to the invoice row (registration semantics)', async () => { + const calls: RecordedCall[] = [] + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: SENT_INVOICE, error: null }, + { data: PAID_INVOICE, error: null }, + ], + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }, + calls, + ), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + { payment_date: '2026-05-12' }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + expect(res.status).toBe(200) + + // The column means "registration entry at issuance"; writing the payment + // entry id would make a kontantmetoden invoice look registered. + const update = calls.find((c) => c.table === 'invoices' && c.method === 'update') + expect(update).toBeDefined() + expect(Object.keys(update!.args[0] as Record)).not.toContain('journal_entry_id') + + // The payment entry id still reaches the caller via the response body. + const body = await res.json() + expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj') + }) + it('returns 400 INVOICE_PAID_LINES_UNBALANCED when custom lines do not balance', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ 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 870bc37d..1adad292 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 @@ -166,10 +166,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string } // Pre-flight: fetch invoice with relations needed for journal entry. + // journal_entry_id is fetched for booking-state routing only (it stays out + // of INVOICE_MARK_PAID_RESPONSE_COLUMNS so the response contract and the + // invoice.paid event payload are unchanged). const { data: invoice, error: fetchErr } = await ctx.supabase .from('invoices') .select( - `${INVOICE_MARK_PAID_RESPONSE_COLUMNS}, customer:customers(id, name, customer_type), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount)`, + `${INVOICE_MARK_PAID_RESPONSE_COLUMNS}, journal_entry_id, customer:customers(id, name, customer_type), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount)`, ) .eq('company_id', ctx.companyId!) .eq('id', invoiceId) @@ -428,9 +431,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string if (newStatus === 'paid') { updatePayload.paid_at = paymentDate } - if (journalEntryId) { - updatePayload.journal_entry_id = journalEntryId - } + // Deliberately NOT writing journal_entry_id here: that column means "the + // registration entry that booked this invoice at issuance" and drives the + // invoiceAlreadyBooked routing above. Writing the payment/cash entry id + // would make a kontantmetoden invoice look registered, so a later partial + // payment would clear a 1510 that was never debited. The payment entry id + // is returned in the response body instead. const { data: updated, error: updateErr } = await ctx.supabase .from('invoices') diff --git a/supabase/migrations/20260622120000_invoices_journal_entry_id_backfill.sql b/supabase/migrations/20260622120000_invoices_journal_entry_id_backfill.sql new file mode 100644 index 00000000..769ab310 --- /dev/null +++ b/supabase/migrations/20260622120000_invoices_journal_entry_id_backfill.sql @@ -0,0 +1,72 @@ +-- Migration: backfill invoices.journal_entry_id (registration entry linkage) +-- +-- The column (added in 20260613100000_self_billing_received_invoices.sql) +-- means "the verifikat that BOOKED this invoice at issuance" — the +-- invoice_created registration entry (Dr 1510 / Cr 30xx+26xx), or the +-- credit_note reversal entry on a credit-note row. Payment flows route on it: +-- set → clearing entry (Dr 1930 / Cr 1510); NULL → kontantmetoden cash entry +-- (Dr 1930 / Cr 30xx+26xx). Write-backs only shipped ~2026-06-05, so nearly +-- all historical rows are NULL even where a posted registration entry exists — +-- a company on (or switching to) kontantmetoden would double-book revenue + +-- VAT when marking those invoices paid. +-- +-- Passes 1–2 touch ONLY rows that are still NULL, so the migration is +-- idempotent and safe to re-run / resume after an interruption. Rows with no +-- posted registration entry stay NULL — that is the CORRECT value for +-- kontantmetoden / unsent / proforma invoices (revenue is recognised at +-- payment instead). + +-- Pass 0 — defensive repair. An earlier version of the v1 mark-paid route +-- wrote the PAYMENT/cash entry id into this column (wrong semantic: it would +-- make a kontantmetoden invoice look registered, so a later partial payment +-- clears a 1510 that was never debited). Hosted prod has no such rows, but +-- self-hosted databases that ran that code may. Null them out so Pass 1 can +-- re-link the correct registration entry where one exists. +UPDATE public.invoices i +SET journal_entry_id = NULL +FROM public.journal_entries je +WHERE je.id = i.journal_entry_id + AND je.source_type IN ('invoice_paid', 'invoice_cash_payment'); + +-- Pass 1 — registration entries. Earliest posted invoice_created entry per +-- invoice; status='posted' excludes reversed/cancelled/draft (a stornoed +-- registration must not mark the invoice as booked). DISTINCT ON with the +-- created_at,id ordering gives a deterministic pick if duplicates exist. +-- The company_id equality guard is defense-in-depth against cross-tenant +-- uuid collisions. idx_journal_entries_source (source_type, source_id) makes +-- the subquery cheap. +UPDATE public.invoices i +SET journal_entry_id = je.id +FROM ( + SELECT DISTINCT ON (company_id, source_id) id, company_id, source_id + FROM public.journal_entries + WHERE source_type = 'invoice_created' + AND status = 'posted' + AND source_id IS NOT NULL + ORDER BY company_id, source_id, created_at ASC, id ASC +) je +WHERE i.journal_entry_id IS NULL + AND je.source_id = i.id + AND je.company_id = i.company_id; + +-- Pass 2 — credit-note reversal entries onto credit-note rows +-- (source_type='credit_note', source_id = the credit note's own invoice row +-- id; matches the live write sites: v1 credit route, app/api/invoices POST). +-- Payment routing never reads these (mark-paid rejects credit notes) — this +-- pass exists for the dashboard verifikat link and linkage completeness. +UPDATE public.invoices i +SET journal_entry_id = je.id +FROM ( + SELECT DISTINCT ON (company_id, source_id) id, company_id, source_id + FROM public.journal_entries + WHERE source_type = 'credit_note' + AND status = 'posted' + AND source_id IS NOT NULL + ORDER BY company_id, source_id, created_at ASC, id ASC +) je +WHERE i.journal_entry_id IS NULL + AND i.credited_invoice_id IS NOT NULL + AND je.source_id = i.id + AND je.company_id = i.company_id; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/fixtures.ts b/tests/pg/fixtures.ts index 2b00c89b..0a7e358b 100644 --- a/tests/pg/fixtures.ts +++ b/tests/pg/fixtures.ts @@ -170,15 +170,18 @@ export async function insertDraftJournalEntry(params: { entryDate?: string description?: string voucherSeries?: string - status?: 'draft' | 'posted' + status?: 'draft' | 'posted' | 'reversed' | 'cancelled' voucherNumber?: number + sourceType?: string + sourceId?: string | null + createdAt?: string }): Promise { const id = randomUUID() await getPool().query( `INSERT INTO public.journal_entries (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, - entry_date, description, source_type, status) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'manual', $9)`, + entry_date, description, source_type, source_id, status, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, COALESCE($12::timestamptz, now()))`, [ id, params.userId, @@ -188,7 +191,10 @@ export async function insertDraftJournalEntry(params: { params.voucherSeries ?? 'A', params.entryDate ?? '2026-06-01', params.description ?? 'Test entry', + params.sourceType ?? 'manual', + params.sourceId ?? null, params.status ?? 'draft', + params.createdAt ?? null, ], ) return id diff --git a/tests/pg/invoices-journal-entry-id-backfill.pg.test.ts b/tests/pg/invoices-journal-entry-id-backfill.pg.test.ts new file mode 100644 index 00000000..a17a25fb --- /dev/null +++ b/tests/pg/invoices-journal-entry-id-backfill.pg.test.ts @@ -0,0 +1,332 @@ +import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures' +import { getPool } from '@/tests/pg/setup' + +/** + * pg-real coverage for 20260622120000_invoices_journal_entry_id_backfill.sql. + * + * invoices.journal_entry_id means "the registration verifikat that booked the + * invoice at issuance" — payment flows route on it (set → clear 1510; NULL → + * kontantmetoden cash entry). A wrong link silently double-books revenue + + * VAT, so the backfill's guards are load-bearing: + * - only posted invoice_created entries qualify (reversed/draft excluded) + * - earliest entry wins deterministically on duplicates + * - rows with an existing link are never overwritten + * - payment-type links (wrong semantic from an earlier route version) are + * repaired: nulled, then re-linked from the registration entry if any + * - kontantmetoden invoices (no registration entry) stay NULL + * - credit-note rows link their credit_note reversal entry + * - cross-company isolation + * - idempotent (re-run is a no-op) + */ + +// Run the real migration SQL so the test exercises exactly what ships. +const BACKFILL_SQL = readFileSync( + join(process.cwd(), 'supabase/migrations/20260622120000_invoices_journal_entry_id_backfill.sql'), + 'utf8', +) +async function runBackfill(): Promise { + await getPool().query(BACKFILL_SQL) +} + +async function insertInvoice(params: { + userId: string + companyId: string + status?: string + creditedInvoiceId?: string | null + journalEntryId?: string | null +}): Promise { + const id = randomUUID() + const customerId = randomUUID() + await getPool().query( + `INSERT INTO public.customers (id, user_id, company_id, name) + VALUES ($1, $2, $3, 'Testkund AB')`, + [customerId, params.userId, params.companyId], + ) + await getPool().query( + `INSERT INTO public.invoices + (id, user_id, company_id, customer_id, invoice_number, + invoice_date, due_date, currency, subtotal, vat_amount, total, + vat_treatment, vat_rate, moms_ruta, status, credited_invoice_id, + journal_entry_id) + VALUES ($1, $2, $3, $4, $5, + '2026-06-01', '2026-06-30', 'SEK', 10000, 2500, 12500, + 'standard_25', 25, '05', $6, $7, $8)`, + [ + id, + params.userId, + params.companyId, + customerId, + `F-${randomUUID().slice(0, 8)}`, + params.status ?? 'sent', + params.creditedInvoiceId ?? null, + params.journalEntryId ?? null, + ], + ) + return id +} + +async function getLink(invoiceId: string): Promise { + const { rows } = await getPool().query( + `SELECT journal_entry_id FROM public.invoices WHERE id = $1`, + [invoiceId], + ) + return rows[0]?.journal_entry_id ?? null +} + +describe('invoices.journal_entry_id backfill — Pass 1 (registration entries)', () => { + it('links the posted invoice_created entry to its invoice', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const invoiceId = await insertInvoice({ userId, companyId }) + const jeId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'invoice_created', + sourceId: invoiceId, + }) + + await runBackfill() + + expect(await getLink(invoiceId)).toBe(jeId) + }) + + it('picks the earliest posted entry when duplicates exist', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const invoiceId = await insertInvoice({ userId, companyId }) + const early = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'invoice_created', + sourceId: invoiceId, + createdAt: '2026-06-01T08:00:00Z', + }) + await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'invoice_created', + sourceId: invoiceId, + createdAt: '2026-06-02T08:00:00Z', + }) + + await runBackfill() + + expect(await getLink(invoiceId)).toBe(early) + }) + + it('skips reversed and draft registration entries (invoice stays NULL)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const invoiceId = await insertInvoice({ userId, companyId }) + await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'reversed', + sourceType: 'invoice_created', + sourceId: invoiceId, + }) + await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'draft', + sourceType: 'invoice_created', + sourceId: invoiceId, + }) + + await runBackfill() + + // A stornoed registration must not mark the invoice as booked — the + // payment flow should recognise revenue via the cash path instead. + expect(await getLink(invoiceId)).toBeNull() + }) + + it('never overwrites an existing link', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + // Existing link points at a manual entry (e.g. user-curated) — backfill + // must leave it alone even though a registration entry also exists. + const manual = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + }) + const invoiceId = await insertInvoice({ userId, companyId, journalEntryId: manual }) + await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'invoice_created', + sourceId: invoiceId, + }) + + await runBackfill() + + expect(await getLink(invoiceId)).toBe(manual) + }) + + it('leaves kontantmetoden invoices NULL (only a cash payment entry exists)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const invoiceId = await insertInvoice({ userId, companyId, status: 'paid' }) + await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'invoice_cash_payment', + sourceId: invoiceId, + }) + + await runBackfill() + + // NULL is the CORRECT value here — revenue was recognised at payment. + expect(await getLink(invoiceId)).toBeNull() + }) + + it('does not cross-link between companies', async () => { + const a = await seedCompany() + const b = await seedCompany() + const invoiceA = await insertInvoice({ userId: a.userId, companyId: a.companyId }) + // Company B has a posted registration entry whose source_id happens to + // reference company A's invoice (corrupt/cross-tenant data) — the + // company_id guard must refuse the link. + await insertDraftJournalEntry({ + userId: b.userId, + companyId: b.companyId, + fiscalPeriodId: b.fiscalPeriodId, + status: 'posted', + sourceType: 'invoice_created', + sourceId: invoiceA, + }) + + await runBackfill() + + expect(await getLink(invoiceA)).toBeNull() + }) +}) + +describe('invoices.journal_entry_id backfill — Pass 0 (payment-link repair)', () => { + it('nulls a payment-type link, then re-links the registration entry', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const invoiceId = await insertInvoice({ userId, companyId, status: 'paid' }) + const registration = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'invoice_created', + sourceId: invoiceId, + }) + const payment = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'invoice_paid', + sourceId: invoiceId, + }) + // Simulate the old v1 mark-paid write-back: payment entry id in the column. + await getPool().query( + `UPDATE public.invoices SET journal_entry_id = $1 WHERE id = $2`, + [payment, invoiceId], + ) + + await runBackfill() + + expect(await getLink(invoiceId)).toBe(registration) + }) + + it('nulls a cash-payment link with no registration entry (stays NULL)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const invoiceId = await insertInvoice({ userId, companyId, status: 'paid' }) + const cash = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'invoice_cash_payment', + sourceId: invoiceId, + }) + await getPool().query( + `UPDATE public.invoices SET journal_entry_id = $1 WHERE id = $2`, + [cash, invoiceId], + ) + + await runBackfill() + + expect(await getLink(invoiceId)).toBeNull() + }) +}) + +describe('invoices.journal_entry_id backfill — Pass 2 (credit notes)', () => { + it('links the credit_note reversal entry to the credit-note row', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const original = await insertInvoice({ userId, companyId }) + const creditNote = await insertInvoice({ + userId, + companyId, + creditedInvoiceId: original, + }) + const reversal = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'credit_note', + sourceId: creditNote, + }) + + await runBackfill() + + expect(await getLink(creditNote)).toBe(reversal) + expect(await getLink(original)).toBeNull() + }) + + it('ignores credit_note entries pointing at non-credit-note rows', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const invoiceId = await insertInvoice({ userId, companyId }) + await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'credit_note', + sourceId: invoiceId, + }) + + await runBackfill() + + // credited_invoice_id IS NULL → Pass 2 must not touch the row. + expect(await getLink(invoiceId)).toBeNull() + }) +}) + +describe('invoices.journal_entry_id backfill — idempotency', () => { + it('re-running the backfill changes nothing', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const invoiceId = await insertInvoice({ userId, companyId }) + const jeId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + sourceType: 'invoice_created', + sourceId: invoiceId, + }) + + await runBackfill() + expect(await getLink(invoiceId)).toBe(jeId) + + await runBackfill() + expect(await getLink(invoiceId)).toBe(jeId) + }) +})