fix(vat,documents): EU reverse-charge packs feed ruta 20/21; daily reanchor cron for floating supplier-invoice underlag (#2095)
Two user reports (Anders, 2026-08-25 + 2026-08-29): 1. The seeded standardmallar "Inkop EU-varor/-tjanster, omvand moms 25%" booked the cost on 4010/6540, which no momsdeklaration ruta reads, so the fiktiv moms filled ruta 30/48 while ruta 20/21 (inkopsvarde) stayed 0; Skatteverket rejects that (FK004, ML 13 kap). The packs now book directly on the basis accounts 4515/4535 (ACCOUNT_RUTA -> ruta 20/21); the transaction-picker path already skips its own basis emission for basis debit accounts, so no double counting. Regression test pins every reverse-charge pack to a 44xx/45xx business debit. Prod rows update via the existing pack sync cron (upsert on pack_slug). 2. A kontantmetod payment verifikat stayed "Underlag saknas" although the invoice PDF was attached and eligible on every static condition: the inline anchorSupplierInvoiceDocument silently did nothing (prod case 2026-08-28, verified in audit_log: no document_attachments update between the payment booking and the user's manual re-upload). The helper now verifies the guarded update actually matched a row instead of claiming success on zero rows, logs its silent bail branches, and a new daily cron (/api/documents/reanchor/cron) re-runs the anchor for any floating retained document with a posted verifikat, replacing the pattern of one-off repair migrations (20260727180000, 20260824150000). The sweep names the FK in its embed and is idempotent; locked/closed periods are skipped as before. Claude-Session: https://claude.ai/code/session_01Jj6Rg1ViyFRej55gbxLVgj Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
08dbfdc5c4
commit
e113e9c099
@@ -4,6 +4,7 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
anchorSupplierInvoiceDocument,
|
||||
reanchorOrphanedSupplierInvoiceDocuments,
|
||||
sweepFloatingSupplierInvoiceDocuments,
|
||||
} from '../supplier-invoice-underlag'
|
||||
|
||||
/**
|
||||
@@ -45,7 +46,7 @@ describe('anchorSupplierInvoiceDocument', () => {
|
||||
{ id: 'je-pay', status: 'posted', fiscal_period: openPeriod },
|
||||
],
|
||||
},
|
||||
{ data: null },
|
||||
{ data: [{ id: 'doc-1' }] },
|
||||
])
|
||||
|
||||
const anchored = await anchorSupplierInvoiceDocument(
|
||||
@@ -77,7 +78,7 @@ describe('anchorSupplierInvoiceDocument', () => {
|
||||
{ id: 'je-pay', status: 'posted', fiscal_period: openPeriod },
|
||||
],
|
||||
},
|
||||
{ data: null },
|
||||
{ data: [{ id: 'doc-1' }] },
|
||||
])
|
||||
|
||||
expect(
|
||||
@@ -108,7 +109,7 @@ describe('anchorSupplierInvoiceDocument', () => {
|
||||
{ id: 'je-p2', status: 'posted', fiscal_period: openPeriod },
|
||||
],
|
||||
},
|
||||
{ data: null },
|
||||
{ data: [{ id: 'doc-1' }] },
|
||||
])
|
||||
|
||||
expect(
|
||||
@@ -227,6 +228,32 @@ describe('anchorSupplierInvoiceDocument', () => {
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports a zero-row update as null: a write that never happened is not an anchor', async () => {
|
||||
// Prod case 2026-08-28 (faktura 118776): every static condition passed yet
|
||||
// the document stayed floating. The guarded update can match nothing (a
|
||||
// concurrent writer, or RLS filtering the row); claiming success then hides
|
||||
// the miss from both callers and the reconcile cron.
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{
|
||||
data: {
|
||||
id: 'si-1',
|
||||
document_id: 'doc-1',
|
||||
registration_journal_entry_id: null,
|
||||
payment_journal_entry_id: 'je-pay',
|
||||
},
|
||||
},
|
||||
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
|
||||
{ data: [] },
|
||||
{ data: [{ id: 'je-pay', status: 'posted', fiscal_period: openPeriod }] },
|
||||
{ data: [] }, // UPDATE matched no rows
|
||||
])
|
||||
|
||||
await expect(
|
||||
anchorSupplierInvoiceDocument(supabase as unknown as SupabaseClient, 'company-1', 'si-1'),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('reports failure as null instead of throwing at the caller', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
@@ -250,6 +277,63 @@ describe('anchorSupplierInvoiceDocument', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweepFloatingSupplierInvoiceDocuments', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const openPeriod = { is_closed: false, locked_at: null }
|
||||
|
||||
it('anchors every floating retained document it finds, across companies', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
// Candidate scan: two invoices with floating current-version documents.
|
||||
{
|
||||
data: [
|
||||
{ id: 'si-1', company_id: 'company-1', document: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
|
||||
{ id: 'si-2', company_id: 'company-2', document: { id: 'doc-2', journal_entry_id: null, is_current_version: true } },
|
||||
],
|
||||
},
|
||||
// anchorSupplierInvoiceDocument for si-1 (5 queries)
|
||||
{ data: { id: 'si-1', document_id: 'doc-1', registration_journal_entry_id: null, payment_journal_entry_id: 'je-1' } },
|
||||
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
|
||||
{ data: [] },
|
||||
{ data: [{ id: 'je-1', status: 'posted', fiscal_period: openPeriod }] },
|
||||
{ data: [{ id: 'doc-1' }] },
|
||||
// anchorSupplierInvoiceDocument for si-2: its only verifikat is locked.
|
||||
{ data: { id: 'si-2', document_id: 'doc-2', registration_journal_entry_id: null, payment_journal_entry_id: 'je-2' } },
|
||||
{ data: { id: 'doc-2', journal_entry_id: null, is_current_version: true } },
|
||||
{ data: [] },
|
||||
{ data: [{ id: 'je-2', status: 'posted', fiscal_period: { is_closed: true, locked_at: null } }] },
|
||||
])
|
||||
|
||||
const result = await sweepFloatingSupplierInvoiceDocuments(
|
||||
supabase as unknown as SupabaseClient,
|
||||
)
|
||||
expect(result).toEqual({ candidates: 2, anchored: 1 })
|
||||
})
|
||||
|
||||
it('returns zeros and touches nothing when no document is floating', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([{ data: [] }])
|
||||
|
||||
const result = await sweepFloatingSupplierInvoiceDocuments(
|
||||
supabase as unknown as SupabaseClient,
|
||||
)
|
||||
expect(result).toEqual({ candidates: 0, anchored: 0 })
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('survives a failed candidate scan without throwing', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([{ error: { message: 'relation walk failed' } }])
|
||||
|
||||
await expect(
|
||||
sweepFloatingSupplierInvoiceDocuments(supabase as unknown as SupabaseClient),
|
||||
).resolves.toEqual({ candidates: 0, anchored: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reanchorOrphanedSupplierInvoiceDocuments', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -272,7 +356,7 @@ describe('reanchorOrphanedSupplierInvoiceDocuments', () => {
|
||||
{
|
||||
data: [{ id: 'je-pay', status: 'posted', fiscal_period: { is_closed: false, locked_at: null } }],
|
||||
},
|
||||
{ data: null },
|
||||
{ data: [{ id: 'doc-1' }] },
|
||||
])
|
||||
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user