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(
|
||||
|
||||
@@ -78,9 +78,21 @@ export async function anchorSupplierInvoiceDocument(
|
||||
payment_journal_entry_id: string | null
|
||||
},
|
||||
)
|
||||
if (!entryId) return null
|
||||
if (!entryId) {
|
||||
// The invoice HAS a floating retained document but no verifikat that can
|
||||
// take it. In the payment routes (which call this right after posting)
|
||||
// that is an anomaly worth a log line: prod case 2026-08-28 (Anders,
|
||||
// faktura 118776) stayed "Underlag saknas" through exactly this kind of
|
||||
// silent bail, and nothing recorded which branch gave up.
|
||||
log.warn('supplier invoice document is floating but no verifikat can anchor it', {
|
||||
companyId,
|
||||
supplierInvoiceId,
|
||||
documentId: doc.id,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { data: updatedRows, error } = await supabase
|
||||
.from('document_attachments')
|
||||
.update({ journal_entry_id: entryId })
|
||||
.eq('id', doc.id)
|
||||
@@ -89,6 +101,7 @@ export async function anchorSupplierInvoiceDocument(
|
||||
// read above. Never steal a document that already serves a verifikat.
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_current_version', true)
|
||||
.select('id')
|
||||
|
||||
if (error) {
|
||||
log.warn('failed to anchor supplier invoice document to verifikat', {
|
||||
@@ -100,6 +113,19 @@ export async function anchorSupplierInvoiceDocument(
|
||||
})
|
||||
return null
|
||||
}
|
||||
// The guarded update can match zero rows (a concurrent writer got there
|
||||
// first, or RLS filtered the row). That is NOT a successful anchor: report
|
||||
// null so callers and the reconcile cron treat the document as still
|
||||
// floating instead of trusting a write that never happened.
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
log.warn('anchor update matched no rows; document left floating', {
|
||||
companyId,
|
||||
supplierInvoiceId,
|
||||
documentId: doc.id,
|
||||
journalEntryId: entryId,
|
||||
})
|
||||
return null
|
||||
}
|
||||
return entryId
|
||||
} catch (err) {
|
||||
log.warn('anchorSupplierInvoiceDocument threw', {
|
||||
@@ -193,6 +219,69 @@ async function pickAnchorEntry(
|
||||
*
|
||||
* Returns the number of documents re-anchored.
|
||||
*/
|
||||
export interface FloatingDocumentSweepResult {
|
||||
/** Invoices whose floating retained document was examined. */
|
||||
candidates: number
|
||||
/** Documents actually anchored to a verifikat this run. */
|
||||
anchored: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Prod-wide self-heal for retained supplier-invoice documents that stayed
|
||||
* floating although the invoice has a posted verifikat: the inline anchoring
|
||||
* in the payment routes is best-effort by design (never throws, the booking is
|
||||
* already committed), so a transient failure there strands the document until
|
||||
* something retries. Historically that "something" was a hand-written repair
|
||||
* migration (20260727180000, 20260824150000); this makes the retry a standing
|
||||
* daily cron instead. Prod case 2026-08-28 (Anders, faktura 118776): payment
|
||||
* verifikat posted, invoice document eligible on every static condition, yet
|
||||
* the inline anchor did nothing and no log recorded why, so the verifikat
|
||||
* showed "Underlag saknas" until the user re-uploaded the PDF by hand.
|
||||
*
|
||||
* Anchoring an already-shown-but-floating document is strictly an improvement
|
||||
* (it puts the file behind the WORM deletion guard and satisfies BFL 5 kap
|
||||
* 7 §), and anchorSupplierInvoiceDocument never moves an anchored document,
|
||||
* so re-running this sweep is idempotent. Locked/closed periods are skipped by
|
||||
* pickAnchorEntry, matching the repair migrations.
|
||||
*
|
||||
* Meant to run under the service-role client from a cron: RLS would otherwise
|
||||
* scope the candidate scan to one user's companies.
|
||||
*/
|
||||
export async function sweepFloatingSupplierInvoiceDocuments(
|
||||
supabase: SupabaseClient,
|
||||
opts: { limit?: number } = {},
|
||||
): Promise<FloatingDocumentSweepResult> {
|
||||
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 1000)
|
||||
|
||||
// FK named explicitly: with an ambiguous relationship PostgREST rejects the
|
||||
// embed and the sweep would silently see zero candidates (the #2022 lesson).
|
||||
const { data, error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select(
|
||||
'id, company_id, document:document_attachments!supplier_invoices_document_id_fkey!inner(id, journal_entry_id, is_current_version)',
|
||||
)
|
||||
.not('document_id', 'is', null)
|
||||
.or('payment_journal_entry_id.not.is.null,registration_journal_entry_id.not.is.null')
|
||||
.is('document.journal_entry_id', null)
|
||||
.eq('document.is_current_version', true)
|
||||
.limit(limit)
|
||||
|
||||
if (error) {
|
||||
log.warn('floating-document sweep query failed', { reason: error.message })
|
||||
return { candidates: 0, anchored: 0 }
|
||||
}
|
||||
|
||||
const rows = (data ?? []) as unknown as Array<{ id: string; company_id: string }>
|
||||
let anchored = 0
|
||||
for (const row of rows) {
|
||||
if (await anchorSupplierInvoiceDocument(supabase, row.company_id, row.id)) anchored++
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
log.info('floating-document sweep complete', { candidates: rows.length, anchored })
|
||||
}
|
||||
return { candidates: rows.length, anchored }
|
||||
}
|
||||
|
||||
export async function reanchorOrphanedSupplierInvoiceDocuments(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
|
||||
@@ -63,6 +63,21 @@ const INTENTIONAL_DIVERGENCES: Record<string, Divergence> = {
|
||||
'taxes paid by the firm are an eget uttag, so the template now debits 2013 Övriga egna ' +
|
||||
'uttag; migration 20260810120000 retargets the seeded rows the same way.',
|
||||
},
|
||||
'inkop-eu-varor-omvand-moms-25': {
|
||||
seededName: 'Inköp EU-varor, omvänd moms 25%',
|
||||
reason:
|
||||
'Seeded version booked the cost on 4010 Varuinköp, which no momsdeklaration ruta reads: ' +
|
||||
'the fiktiv moms filled ruta 30/48 while ruta 20 (inköpsvärdet) stayed 0, which Skatteverket ' +
|
||||
'rejects (felkod FK004, ML 13 kap kräver både underlag och moms). Now books on 4515 Inköp ' +
|
||||
'varor från annat EU-land 25%, the ACCOUNT_RUTA basis account for ruta 20. User report ' +
|
||||
'(Anders, 2026-08-25).',
|
||||
},
|
||||
'inkop-eu-tjanster-omvand-moms-25': {
|
||||
seededName: 'Inköp EU-tjänster, omvänd moms 25%',
|
||||
reason:
|
||||
'Same ruta gap as inkop-eu-varor-omvand-moms-25: cost sat on 6540 IT-tjänster, so ruta 21 ' +
|
||||
'stayed 0. Now books on 4535 Inköp tjänster från annat EU-land 25% (ruta 21).',
|
||||
},
|
||||
'representation-avdragsgill-25-moms': {
|
||||
seededName: 'Representation (avdragsgill, 25% moms)',
|
||||
reason:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import path from 'node:path'
|
||||
import { loadPacks } from '@/lib/packs/load'
|
||||
|
||||
/**
|
||||
* A reverse-charge pack must book its cost on a basbelopp account.
|
||||
*
|
||||
* Momsdeklaration rutor 20-24 are driven purely by BAS account number
|
||||
* (ACCOUNT_RUTA in lib/reports/vat-declaration.ts): the fiktiv-moms pair
|
||||
* (2614/2624/2634 + 2645/2647) fills ruta 30-32 and 48, but the purchase
|
||||
* value itself only reaches ruta 20-24 when the expense sits on the 44xx/45xx
|
||||
* basis series. A reverse-charge pack that books its cost elsewhere (the
|
||||
* pre-fix 4010/6540) produces a declaration with VAT but no inköpsvärde,
|
||||
* which Skatteverket rejects (felkod FK004; ML 13 kap requires both sides).
|
||||
* User report: Anders, 2026-08-25.
|
||||
*/
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../..')
|
||||
|
||||
// Fictitious output-VAT accounts that mark a purchase-side reverse-charge pack.
|
||||
const FIKTIV_OUTPUT_VAT = new Set(['2614', '2624', '2634'])
|
||||
|
||||
// Same range isBasisAccount() in lib/bookkeeping/booking-templates.ts guards:
|
||||
// 44xx/45xx, the ruta 20-24 inputs.
|
||||
const BASIS_ACCOUNT_RE = /^4[45]\d{2}$/
|
||||
|
||||
describe('reverse-charge packs feed momsdeklaration ruta 20-24', () => {
|
||||
const { packs, errors } = loadPacks(ROOT)
|
||||
|
||||
it('catalogue loads', () => {
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
const reverseChargePacks = packs.filter((p) =>
|
||||
p.pack.lines.some((l) => FIKTIV_OUTPUT_VAT.has(l.account)),
|
||||
)
|
||||
|
||||
it('the catalogue actually contains reverse-charge purchase packs', () => {
|
||||
// Guards the filter above: if the fiktiv accounts are ever renumbered,
|
||||
// this suite must be updated rather than silently asserting nothing.
|
||||
expect(reverseChargePacks.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['inkop-eu-varor-omvand-moms-25', '4515'],
|
||||
['inkop-eu-tjanster-omvand-moms-25', '4535'],
|
||||
])('%s books its cost on basis account %s', (slug, account) => {
|
||||
const pack = packs.find((p) => p.pack.meta.slug === slug)
|
||||
expect(pack).toBeDefined()
|
||||
const business = pack!.pack.lines.filter((l) => l.type === 'business')
|
||||
expect(business).toHaveLength(1)
|
||||
expect(business[0].account).toBe(account)
|
||||
expect(business[0].side).toBe('debit')
|
||||
})
|
||||
|
||||
it('every reverse-charge purchase pack books its business debit on a 44xx/45xx basis account', () => {
|
||||
for (const p of reverseChargePacks) {
|
||||
const businessDebits = p.pack.lines.filter(
|
||||
(l) => l.type === 'business' && l.side === 'debit',
|
||||
)
|
||||
for (const line of businessDebits) {
|
||||
expect(
|
||||
BASIS_ACCOUNT_RE.test(line.account),
|
||||
`${p.pack.meta.slug}: business debit on ${line.account} never reaches ruta 20-24; ` +
|
||||
`book reverse-charge cost on the 44xx/45xx basis series`,
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user