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:
co-authored by
Claude Fable 5
parent
98e1a48c2b
commit
98886e68d8
@@ -365,3 +365,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-24] Onboarding journey migration COMPLETE with PR #1150: wizard deleted, /companies/new on journey mode='add', BankID picker = searchable list, flag conditional removed (env var cleaned from Vercel post-merge). Bot-review triage: compliance findings on getUser()/redirect()/ensure_user_team skipped as App Router misreadings or pre-existing patterns; fixed the real ones (stale select_company keys, unused hasExistingCompanies plumbing).
|
||||
[2026-07-24] Invite recovery on onboarding surfaces = cookie retry + hint, NOT accept-by-email: a BankID signup's email is confirmed via a client-delivered magiclink (no mailbox proof), so auto-joining on email match would let anyone who registers the invitee's address claim the membership. The cookie/token path keeps mailbox possession required; cookie-less invitees get pointed back to the mailed link, with no company name leaked.
|
||||
[2026-07-24] Share-capital pair rule validated in the PUT route, not UpdateSettingsSchema: the all-or-nothing check needs the stored row (a partial update may send only one key), which Zod cannot see; the route already owns the other cross-field effective-value checks.
|
||||
[2026-07-24] VAT RC checks proportional (0.5% + 1 kr tolerance) + latched stepper landing: the binary present/absent RC_BASIS_MISSING check cleared after one korrigering and hid a 38-voucher worklist behind "klart"; tolerance absorbs per-voucher basis rounding (moms/sats vs invoiced amount) without hiding a missing voucher; landing step latches once per period so a mid-work refetch cannot navigate the user off Kontrollera.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -107,10 +107,9 @@ export function VatChecksCard({
|
||||
// Gap fetch tagged with the key it was requested under: loading is derived
|
||||
// by comparing tags, so the effect never sets state synchronously. Fixed
|
||||
// rows are removed via removedIds (the fetch itself is not re-run after a
|
||||
// korrigering: the period key is unchanged). The key is period-only, NOT
|
||||
// gated on RC_BASIS_MISSING: the aggregate check clears as soon as ONE
|
||||
// voucher is corrected, and the remaining unfixed rows must survive the
|
||||
// declaration refetch that follows each korrigering.
|
||||
// korrigering: the period key is unchanged). The key is period-only so the
|
||||
// list survives the declaration refetch that follows each korrigering, and
|
||||
// survives a remount even when the aggregate check no longer fires.
|
||||
const gapsKey = `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}`
|
||||
const [gapsResult, setGapsResult] = useState<{
|
||||
key: string
|
||||
@@ -145,25 +144,26 @@ export function VatChecksCard({
|
||||
const gapsFetched = gapsResult !== null && gapsResult.key === gapsKey
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch once per period, and only while the aggregate check flags a gap:
|
||||
// the fetched list then outlives the check, which clears after the first
|
||||
// correction even though other vouchers may remain broken.
|
||||
if (!hasRcBasisGaps || gapsFetched) return
|
||||
// Fetch once per period, NOT gated on the aggregate check: the check
|
||||
// compares period totals and can clear (or never fire) while individual
|
||||
// vouchers still miss their basis pair. Gating the fetch on it meant a
|
||||
// remount after the first korrigering showed "inga fel" with the
|
||||
// remaining broken vouchers silently hidden.
|
||||
if (gapsFetched) return
|
||||
let cancelled = false
|
||||
const params = new URLSearchParams({
|
||||
periodType,
|
||||
year: String(year),
|
||||
period: String(period),
|
||||
})
|
||||
// Forward-compatible: the route reads calendar params today, but yearly
|
||||
// (helårsmoms) declarations resolve against the räkenskapsår.
|
||||
// Yearly (helårsmoms) resolves against the räkenskapsår server-side.
|
||||
if (fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId)
|
||||
fetch(`/api/reports/vat-declaration/rc-basis-gaps?${params.toString()}`)
|
||||
.then(async (r) => {
|
||||
const j = await r.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
// A failed fetch must not masquerade as "no gaps found": the check
|
||||
// above says there ARE gaps, so an empty list here would mislead.
|
||||
// A failed fetch must not masquerade as "no gaps found": an empty
|
||||
// list would mislead whenever gaps actually exist.
|
||||
if (!r.ok || j?.error) setGapsResult({ key: gapsKey, gaps: [], failed: true })
|
||||
else setGapsResult({ key: gapsKey, gaps: j?.data?.gaps || [] })
|
||||
})
|
||||
@@ -173,7 +173,7 @@ export function VatChecksCard({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hasRcBasisGaps, gapsFetched, gapsKey, periodType, year, period, fiscalPeriodId])
|
||||
}, [gapsFetched, gapsKey, periodType, year, period, fiscalPeriodId])
|
||||
|
||||
const gaps = gapsFetched
|
||||
? gapsResult.gaps.filter((g) => !removedIds.has(g.entryId))
|
||||
|
||||
@@ -1627,6 +1627,16 @@ export function VatDeclarationView() {
|
||||
const checksBlocked = checks.some((c) => c.status === 'ERROR')
|
||||
const errorCount = checks.filter((c) => c.status === 'ERROR').length
|
||||
const warningCount = checks.filter((c) => c.status === 'WARNING').length
|
||||
// Latch the automatic landing step once per period, as a render-phase
|
||||
// adjustment when the period's declaration first settles. Deriving it live
|
||||
// from checksBlocked navigated the user away mid-work: the refetch after a
|
||||
// korrigering can clear the aggregate error while the Kontrollera worklist
|
||||
// still holds broken vouchers, and the view would jump to Granska under
|
||||
// their feet. The period-change effect below resets chosenStep to null,
|
||||
// which re-arms this latch for the next period.
|
||||
if (chosenStep === null && upToDate && data && !error) {
|
||||
setChosenStep(checksBlocked ? 1 : 2)
|
||||
}
|
||||
const activeStep = chosenStep ?? (checksBlocked ? 1 : 2)
|
||||
|
||||
// Settings not settled yet — the picker defaults and the gate both depend
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for findRcBasisGaps: per-voucher FK004 detection.
|
||||
*
|
||||
* Mocks the entry-lines fetch layer and resolvePeriodDates. The period must
|
||||
* resolve through resolvePeriodDates (not the calendar arithmetic) so yearly
|
||||
* (helårsmoms) worklists cover extended/broken räkenskapsår: a calendar span
|
||||
* hid gap vouchers that the declaration totals still included.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const resolvePeriodDatesMock = vi.fn()
|
||||
vi.mock('../vat-declaration', () => ({
|
||||
resolvePeriodDates: (...args: unknown[]) => resolvePeriodDatesMock(...args),
|
||||
}))
|
||||
|
||||
const fetchEntryLinesMock = vi.fn()
|
||||
const fetchLinesByEntryIdsMock = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/entry-lines', () => ({
|
||||
fetchEntryLines: (...args: unknown[]) => fetchEntryLinesMock(...args),
|
||||
fetchLinesByEntryIds: (...args: unknown[]) => fetchLinesByEntryIdsMock(...args),
|
||||
}))
|
||||
|
||||
import { findRcBasisGaps } from '../rc-basis-gaps'
|
||||
|
||||
const supabase = {} as SupabaseClient
|
||||
|
||||
function rcLine(entryId: string, voucherNumber: number, credit: number) {
|
||||
return {
|
||||
journal_entry_id: entryId,
|
||||
account_number: '2614',
|
||||
debit_amount: 0,
|
||||
credit_amount: credit,
|
||||
journal_entries: {
|
||||
id: entryId,
|
||||
voucher_number: voucherNumber,
|
||||
voucher_series: 'A',
|
||||
entry_date: '2026-05-20',
|
||||
description: `Voucher ${voucherNumber}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('findRcBasisGaps', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resolvePeriodDatesMock.mockResolvedValue({ start: '2025-07-17', end: '2026-12-31' })
|
||||
fetchEntryLinesMock.mockResolvedValue([])
|
||||
fetchLinesByEntryIdsMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('resolves the period via resolvePeriodDates with the fiscal period id', async () => {
|
||||
await findRcBasisGaps(supabase, 'company-1', 'yearly', 2026, 1, { fiscalPeriodId: 'fp-1' })
|
||||
|
||||
expect(resolvePeriodDatesMock).toHaveBeenCalledWith(
|
||||
supabase, 'company-1', 'yearly', 2026, 1, 'fp-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('filters entries on the resolved bounds, not the calendar year', async () => {
|
||||
await findRcBasisGaps(supabase, 'company-1', 'yearly', 2026, 1, { fiscalPeriodId: 'fp-1' })
|
||||
|
||||
const { filterEntries } = fetchEntryLinesMock.mock.calls[0][0]
|
||||
const calls: Array<[string, ...unknown[]]> = []
|
||||
const q = new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_t, method: string) =>
|
||||
(...args: unknown[]) => {
|
||||
calls.push([method, ...args])
|
||||
return q
|
||||
},
|
||||
},
|
||||
)
|
||||
filterEntries(q)
|
||||
|
||||
expect(calls).toContainEqual(['gte', 'entry_date', '2025-07-17'])
|
||||
expect(calls).toContainEqual(['lte', 'entry_date', '2026-12-31'])
|
||||
})
|
||||
|
||||
it('flags vouchers whose RC output VAT lacks a matching basis pair', async () => {
|
||||
fetchEntryLinesMock.mockResolvedValue([
|
||||
rcLine('entry-1', 8, 527.29), // no basis lines at all
|
||||
rcLine('entry-2', 9, 250), // fully booked basis
|
||||
])
|
||||
fetchLinesByEntryIdsMock.mockResolvedValue([
|
||||
{
|
||||
id: 'l-1',
|
||||
journal_entry_id: 'entry-2',
|
||||
account_number: '4535',
|
||||
debit_amount: 1000,
|
||||
credit_amount: 0,
|
||||
},
|
||||
])
|
||||
|
||||
const gaps = await findRcBasisGaps(supabase, 'company-1', 'monthly', 2026, 5)
|
||||
|
||||
expect(gaps).toHaveLength(1)
|
||||
expect(gaps[0]).toMatchObject({
|
||||
entryId: 'entry-1',
|
||||
voucherNumber: 8,
|
||||
rcOutputAccount: '2614',
|
||||
rcOutputAmount: 527.29,
|
||||
expectedBasisAmount: 2109.16,
|
||||
suggestedBasisAccount: '4535',
|
||||
rate: 0.25,
|
||||
})
|
||||
})
|
||||
|
||||
it('flags a voucher whose basis is materially short of the expected amount', async () => {
|
||||
fetchEntryLinesMock.mockResolvedValue([rcLine('entry-1', 8, 2500)])
|
||||
fetchLinesByEntryIdsMock.mockResolvedValue([
|
||||
{
|
||||
id: 'l-1',
|
||||
journal_entry_id: 'entry-1',
|
||||
account_number: '4535',
|
||||
debit_amount: 4000, // expected 10000
|
||||
credit_amount: 0,
|
||||
},
|
||||
])
|
||||
|
||||
const gaps = await findRcBasisGaps(supabase, 'company-1', 'monthly', 2026, 5)
|
||||
expect(gaps).toHaveLength(1)
|
||||
expect(gaps[0].expectedBasisAmount).toBe(10000)
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,61 @@ describe('runVatDeclarationChecks', () => {
|
||||
expect(fk004?.message).toMatch(/ruta 20-24/)
|
||||
})
|
||||
|
||||
// Regression (2026-07-24): the check compared presence, not proportion, so
|
||||
// correcting ONE voucher out of ~39 cleared the error while ~51 tkr of
|
||||
// basis was still missing and the declaration claimed to be ready.
|
||||
it('flags ERROR when RC basis is only partially reported', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta21: 2109.16, // one corrected voucher
|
||||
ruta30: 13446.18, // fiktiv moms for ~39 vouchers → expects ~53 785 kr basis
|
||||
ruta48: 13446.18,
|
||||
ruta49: 0,
|
||||
}
|
||||
const finding = runVatDeclarationChecks(rutor).find((f) => f.code === 'RC_BASIS_MISSING')
|
||||
expect(finding?.status).toBe('ERROR')
|
||||
expect(finding?.message).toMatch(/saknas/)
|
||||
})
|
||||
|
||||
it('does not flag RC basis inside the per-voucher rounding tolerance', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta21: 9990, // expected 10000, tolerance max(1, 0.5%) = 50
|
||||
ruta30: 2500,
|
||||
ruta48: 2500,
|
||||
ruta49: 0,
|
||||
}
|
||||
const findings = runVatDeclarationChecks(rutor)
|
||||
expect(findings.find((f) => f.code === 'RC_BASIS_MISSING')).toBeUndefined()
|
||||
expect(findings.find((f) => f.code === 'RC_OUTPUT_MISSING')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flags ERROR when a whole voucher of basis is missing beyond the tolerance', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta21: 9000, // expected 10000: one ~1000 kr voucher missing
|
||||
ruta30: 2500,
|
||||
ruta48: 2500,
|
||||
ruta49: 0,
|
||||
}
|
||||
expect(
|
||||
runVatDeclarationChecks(rutor).find((f) => f.code === 'RC_BASIS_MISSING')?.status,
|
||||
).toBe('ERROR')
|
||||
})
|
||||
|
||||
it('flags ERROR when basis exceeds what the output VAT accounts for', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
ruta21: 60000, // expected only 10000 from ruta30: fiktiv moms missing
|
||||
ruta30: 2500,
|
||||
ruta48: 2500,
|
||||
ruta49: 0,
|
||||
}
|
||||
expect(
|
||||
runVatDeclarationChecks(rutor).find((f) => f.code === 'RC_OUTPUT_MISSING')?.status,
|
||||
).toBe('ERROR')
|
||||
})
|
||||
|
||||
it('flags ERROR when basis is present but no output RC VAT', () => {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
...emptyRutor,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
fetchLinesByEntryIds,
|
||||
type EntryLinesQuery,
|
||||
} from '@/lib/bookkeeping/entry-lines'
|
||||
import { calculatePeriodDates } from './vat-declaration'
|
||||
import { resolvePeriodDates } from './vat-declaration'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -107,8 +107,15 @@ export async function findRcBasisGaps(
|
||||
periodType: VatPeriodType,
|
||||
year: number,
|
||||
period: number,
|
||||
options: { fiscalPeriodId?: string } = {},
|
||||
): Promise<RcBasisGap[]> {
|
||||
const { start, end } = calculatePeriodDates(periodType, year, period)
|
||||
// Same period resolution as the declaration itself: helårsmoms covers the
|
||||
// räkenskapsår, not the calendar year, so a calendar span would hide gap
|
||||
// vouchers from the tail of an extended/broken fiscal year while the
|
||||
// declaration totals (and the aggregate check) still include them.
|
||||
const { start, end } = await resolvePeriodDates(
|
||||
supabase, companyId, periodType, year, period, options.fiscalPeriodId
|
||||
)
|
||||
|
||||
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
||||
const rcLines = (await fetchEntryLines<unknown>({
|
||||
|
||||
@@ -72,32 +72,50 @@ export function runVatDeclarationChecks(rutor: VatDeclarationRutor): VatDeclarat
|
||||
// and we don't want a 0.01 rounding scrap to trip a sanity check.
|
||||
const eps = 0.5
|
||||
|
||||
// FK004 mirror: output RC VAT exists, basis missing.
|
||||
if (rcOutput > eps && rcBasis <= eps) {
|
||||
// The basis the per-rate output boxes imply (basbelopp = fiktiv moms / sats).
|
||||
// The RC checks compare reported basis against this instead of testing mere
|
||||
// presence: a binary present/absent test clears as soon as ONE voucher in
|
||||
// the period carries a basis pair, silently passing a declaration where the
|
||||
// remaining vouchers still under-report rutor 20-24 (FK004).
|
||||
const expectedRcBasis =
|
||||
rutor.ruta30 / 0.25 + rutor.ruta31 / 0.12 + rutor.ruta32 / 0.06
|
||||
// Per-voucher öre rounding (basis derived as moms/sats vs the invoiced
|
||||
// amount) accumulates with voucher count; 0.5% with a 1 kr floor absorbs
|
||||
// that without hiding a genuinely missing voucher.
|
||||
const rcTolerance = Math.max(1, expectedRcBasis * 0.005)
|
||||
|
||||
// FK004 mirror: output RC VAT exists, basis missing or too low.
|
||||
if (rcOutput > eps && rcBasis + rcTolerance < expectedRcBasis) {
|
||||
const shortfall = Math.round(expectedRcBasis - rcBasis)
|
||||
findings.push({
|
||||
code: 'RC_BASIS_MISSING',
|
||||
status: 'ERROR',
|
||||
message:
|
||||
'Du har redovisat utgående moms på inköp (ruta 30-32) men inget ' +
|
||||
'basbelopp för omvänd skattskyldighet (ruta 20-24). Skatteverket ' +
|
||||
'kräver att båda sidor finns med (ML 13 kap; SKV felkod FK004). ' +
|
||||
'Kontrollera att leverantörsfakturor med omvänd skattskyldighet ' +
|
||||
'är bokförda med basbelopp på 44xx/45xx-konton.',
|
||||
'Den utgående momsen på inköp (ruta 30-32) motsvarar ett basbelopp ' +
|
||||
`på cirka ${Math.round(expectedRcBasis).toLocaleString('sv-SE')} kr, ` +
|
||||
'men ruta 20-24 innehåller bara ' +
|
||||
`${Math.round(rcBasis).toLocaleString('sv-SE')} kr: cirka ` +
|
||||
`${shortfall.toLocaleString('sv-SE')} kr saknas. Skatteverket kräver ` +
|
||||
'att båda sidor finns med (ML 13 kap; SKV felkod FK004). Kontrollera ' +
|
||||
'att leverantörsfakturor med omvänd skattskyldighet är bokförda med ' +
|
||||
'basbelopp på 44xx/45xx-konton.',
|
||||
rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'],
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror: basis present but no output VAT, equally broken, often a
|
||||
// half-finished manual posting.
|
||||
if (rcBasis > eps && rcOutput <= eps) {
|
||||
// Mirror: more basis than the output VAT accounts for, fiktiv moms missing
|
||||
// for some vouchers. Covers both the all-output-missing case and a partial
|
||||
// one; often a half-finished manual posting.
|
||||
if (rcBasis > eps && rcBasis > expectedRcBasis + rcTolerance) {
|
||||
findings.push({
|
||||
code: 'RC_OUTPUT_MISSING',
|
||||
status: 'ERROR',
|
||||
message:
|
||||
'Du har redovisat basbelopp för omvänd skattskyldighet (ruta 20-24) ' +
|
||||
'men ingen utgående moms (ruta 30-32). Vid omvänd skattskyldighet ' +
|
||||
'måste köparen redovisa både underlag och fiktiv moms (ML 13 kap). ' +
|
||||
'Kontrollera att fiktiv moms är bokförd på 2614/2624/2634.',
|
||||
'Basbeloppet för omvänd skattskyldighet (ruta 20-24) är större än ' +
|
||||
'vad den utgående momsen (ruta 30-32) motsvarar. Vid omvänd ' +
|
||||
'skattskyldighet måste köparen redovisa både underlag och fiktiv ' +
|
||||
'moms (ML 13 kap). Kontrollera att fiktiv moms är bokförd på ' +
|
||||
'2614/2624/2634 för varje inköp.',
|
||||
rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'],
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user