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:
Mattsson
2026-09-01 10:16:07 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 08dbfdc5c4
commit e113e9c099
12 changed files with 376 additions and 10 deletions
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const verifyCronSecret = vi.fn<() => unknown>(() => null)
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: () => verifyCronSecret(),
}))
vi.mock('@/lib/supabase/service-client', () => ({
createServiceRoleClient: vi.fn(() => ({ mocked: true })),
}))
const sweepFloatingSupplierInvoiceDocuments = vi.fn()
vi.mock('@/lib/core/documents/supplier-invoice-underlag', () => ({
sweepFloatingSupplierInvoiceDocuments: (...args: unknown[]) =>
sweepFloatingSupplierInvoiceDocuments(...args),
}))
import { GET } from '../route'
function cronRequest(): Request {
return new Request('http://localhost:3000/api/documents/reanchor/cron')
}
beforeEach(() => {
vi.clearAllMocks()
verifyCronSecret.mockReturnValue(null)
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'
})
describe('GET /api/documents/reanchor/cron', () => {
it('rejects a request without a valid cron secret', async () => {
verifyCronSecret.mockReturnValue({ error: 'unauthorized' })
const response = await GET(cronRequest())
expect(response.status).toBe(401)
expect(sweepFloatingSupplierInvoiceDocuments).not.toHaveBeenCalled()
})
it('runs the sweep under the service-role client and reports the summary', async () => {
sweepFloatingSupplierInvoiceDocuments.mockResolvedValue({ candidates: 3, anchored: 2 })
const response = await GET(cronRequest())
const json = await response.json()
expect(response.status).toBe(200)
expect(json).toEqual({ data: { candidates: 3, anchored: 2 } })
expect(sweepFloatingSupplierInvoiceDocuments).toHaveBeenCalledWith({ mocked: true })
})
it('fails closed when Supabase configuration is missing', async () => {
delete process.env.SUPABASE_SERVICE_ROLE_KEY
const response = await GET(cronRequest())
expect(response.status).toBeGreaterThanOrEqual(500)
expect(sweepFloatingSupplierInvoiceDocuments).not.toHaveBeenCalled()
})
})
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceRoleClient } from '@/lib/supabase/service-client'
import { sweepFloatingSupplierInvoiceDocuments } from '@/lib/core/documents/supplier-invoice-underlag'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* GET /api/documents/reanchor/cron: daily 03:30 UTC (schedule in vercel.json).
*
* Re-anchors supplier-invoice retained documents that are floating although
* the invoice has a posted verifikat. The inline anchoring in the payment
* routes is best-effort (the booking is already committed when it runs), so a
* transient failure there leaves the verifikat flagged "Underlag saknas" with
* the PDF plainly attached to the invoice, and until now only a hand-written
* repair migration ever retried. Prod case 2026-08-28: kontantmetod payment
* verifikat posted, document eligible, anchor silently did nothing.
*
* Idempotent: an anchored document is never moved, locked/closed periods are
* skipped, and a clean run touches nothing.
*/
export const maxDuration = 120
export const GET = withCronContext('cron.documents_reanchor', async (_request, ctx) => {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'Missing Supabase configuration' },
})
}
const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey)
const result = await sweepFloatingSupplierInvoiceDocuments(supabase)
ctx.log.info('reanchor sweep finished', { ...result })
return NextResponse.json({ data: result })
})