fix(vat): keep the RC-basis worklist visible until every voucher is fixed (#1164)

Correcting a single voucher cleared the momsdeklaration's RC_BASIS_MISSING
error and the whole per-voucher worklist with it: the check tested mere
presence of ruta 20-24 basis, the stepper re-derived its landing step and
yanked the user to Granska mid-work, and the remounted checks card never
refetched gaps once the aggregate check stopped firing. The declaration
then claimed "klart" while the remaining vouchers still under-reported
rutor 20-24 (FK004).

- Make RC_BASIS_MISSING/RC_OUTPUT_MISSING proportional: compare reported
  basis against the basis the per-rate output boxes imply (moms/sats),
  with a 0.5% + 1 kr tolerance for per-voucher ore rounding.
- Fetch the rc-basis-gaps worklist once per period, ungated from the
  aggregate check, so remaining rows survive remounts.
- Latch the automatic stepper landing once per period so a refetch after
  a korrigering cannot navigate the user off Kontrollera.
- Resolve rc-basis-gaps against the rakenskapsar (fiscal_period_id) for
  helarsmoms, matching the declaration totals; a calendar span hid gap
  vouchers in the tail of an extended first year.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 22:10:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 98e1a48c2b
commit 98886e68d8
9 changed files with 375 additions and 30 deletions
@@ -0,0 +1,122 @@
/**
* Tests for GET /api/reports/vat-declaration/rc-basis-gaps.
*
* Exercises the route through the real withRouteContext wrapper, mocking its
* auth/company dependencies and findRcBasisGaps(). Covers: 401, missing and
* invalid params, the happy path, and that fiscal_period_id is forwarded so
* yearly (helårsmoms) worklists cover the räkenskapsår instead of the
* calendar year.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const findRcBasisGapsMock = vi.fn()
vi.mock('@/lib/reports/rc-basis-gaps', () => ({
findRcBasisGaps: (...args: unknown[]) => findRcBasisGapsMock(...args),
}))
import { GET } from '../route'
function gapsRequest(searchParams: Record<string, string>) {
return createMockRequest('/api/reports/vat-declaration/rc-basis-gaps', { searchParams })
}
const validParams = { periodType: 'quarterly', year: '2026', period: '2' }
describe('GET /api/reports/vat-declaration/rc-basis-gaps', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
findRcBasisGapsMock.mockResolvedValue([])
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(gapsRequest(validParams), { params: Promise.resolve({}) })
expect(response.status).toBe(401)
expect(findRcBasisGapsMock).not.toHaveBeenCalled()
})
it('returns 400 when period params are missing', async () => {
const response = await GET(
gapsRequest({ periodType: 'quarterly' }),
{ params: Promise.resolve({}) },
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns 400 for an invalid periodType', async () => {
const response = await GET(
gapsRequest({ ...validParams, periodType: 'weekly' }),
{ params: Promise.resolve({}) },
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('VAT_REPORT_INVALID_PERIOD_TYPE')
})
it('returns the detected gaps (happy path)', async () => {
const gap = {
entryId: 'entry-1',
voucherNumber: 8,
voucherSeries: 'A',
entryDate: '2026-05-20',
description: 'Greptile Apr-Maj',
rcOutputAccount: '2614',
rcOutputAmount: 527.29,
expectedBasisAmount: 2109.16,
suggestedBasisAccount: '4535',
rate: 0.25,
}
findRcBasisGapsMock.mockResolvedValue([gap])
const response = await GET(gapsRequest(validParams), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: { gaps: unknown[] } }>(response)
expect(status).toBe(200)
expect(body.data.gaps).toEqual([gap])
expect(findRcBasisGapsMock).toHaveBeenCalledWith(supabase, 'company-1', 'quarterly', 2026, 2, {
fiscalPeriodId: undefined,
})
})
it('forwards fiscal_period_id for yearly declarations', async () => {
const response = await GET(
gapsRequest({ periodType: 'yearly', year: '2026', period: '1', fiscal_period_id: 'fp-1' }),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(200)
expect(findRcBasisGapsMock).toHaveBeenCalledWith(supabase, 'company-1', 'yearly', 2026, 1, {
fiscalPeriodId: 'fp-1',
})
})
it('maps a lib failure to VAT_REPORT_GENERATION_FAILED', async () => {
findRcBasisGapsMock.mockRejectedValue(new Error('boom'))
const response = await GET(gapsRequest(validParams), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBeGreaterThanOrEqual(500)
expect(body.error.code).toBe('VAT_REPORT_GENERATION_FAILED')
})
})
@@ -14,6 +14,9 @@ export const GET = withRouteContext(
const periodType = searchParams.get('periodType') as VatPeriodType | null
const yearStr = searchParams.get('year')
const periodStr = searchParams.get('period')
// Yearly (helårsmoms) resolves against the räkenskapsår, mirroring the
// declaration route; monthly/quarterly ignore it.
const fiscalPeriodId = searchParams.get('fiscal_period_id') ?? undefined
if (!periodType || !yearStr || !periodStr) {
return errorResponseFromCode('VAT_REPORT_MISSING_PARAMS', log, { requestId })
@@ -35,7 +38,9 @@ export const GET = withRouteContext(
}
try {
const gaps = await findRcBasisGaps(supabase, companyId, periodType, year, period)
const gaps = await findRcBasisGaps(supabase, companyId, periodType, year, period, {
fiscalPeriodId,
})
return NextResponse.json({ data: { gaps } })
} catch (err) {
log.error('rc-basis-gaps detection failed', err as Error, { periodType, year, period })