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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in New Issue
Block a user