feat(vat-declaration): implement RC basis gap detection and correctio… (#466)

* feat(vat-declaration): implement RC basis gap detection and correction functionality

* fix(vat-declaration): improve error handling and validation for RC basis account selection
This commit is contained in:
Mattsson
2026-05-13 16:13:45 +02:00
committed by GitHub
parent a9c98da243
commit f0a2577b8b
4 changed files with 623 additions and 0 deletions
@@ -0,0 +1,182 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
import type { CreateJournalEntryLineInput, JournalEntryLine } from '@/types'
/**
* POST /api/reports/vat-declaration/rc-basis-gaps/fix
*
* Adds the missing basbelopp pair (44xx/45xx debit + 4598 credit) to a
* posted journal entry that has reverse-charge output VAT (2614/2624/2634)
* but no corresponding basis lines. Uses correctEntry() so the original
* voucher is preserved in compliance with BFL (storno + corrected entry).
*/
const SUPPLIER_TYPE = z.enum(['eu_business', 'non_eu_business', 'swedish_business'])
const SERVICE_OR_GOODS = z.enum(['service', 'goods'])
const FixGapSchema = z.object({
entryId: z.string().uuid(),
supplierType: SUPPLIER_TYPE,
supplyType: SERVICE_OR_GOODS,
})
const RC_OUTPUT_ACCOUNTS = new Set(['2614', '2624', '2634'])
const RATE_BY_OUTPUT: Record<string, number> = {
'2614': 0.25,
'2624': 0.12,
'2634': 0.06,
}
function pickBasisAccount(
outputAccount: string,
supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business',
supplyType: 'service' | 'goods',
): { account: string; error?: undefined } | { account?: undefined; error: string } {
const rateIdx = outputAccount === '2614' ? 0 : outputAccount === '2624' ? 1 : outputAccount === '2634' ? 2 : -1
if (rateIdx < 0) return { error: 'Okänt RC-utgående konto.' }
// EU services 4535/4536/4537, EU goods 4515/4516/4517,
// non-EU services 4531/4532/4533, domestic services 4425/4426/4427,
// domestic goods 4415/4416/4417.
// Non-EU goods is NOT reverse charge — it's import VAT (ruta 50/60-62 via
// 4545-4547), a separate flow that doesn't belong on this correction path.
if (supplierType === 'eu_business' && supplyType === 'service') return { account: ['4535', '4536', '4537'][rateIdx] }
if (supplierType === 'eu_business' && supplyType === 'goods') return { account: ['4515', '4516', '4517'][rateIdx] }
if (supplierType === 'non_eu_business' && supplyType === 'service') return { account: ['4531', '4532', '4533'][rateIdx] }
if (supplierType === 'non_eu_business' && supplyType === 'goods') {
return {
error:
'Varor från leverantörer utanför EU hanteras som import (ruta 50/60-62), inte omvänd skattskyldighet. ' +
'Korrigera verifikationen manuellt med importmoms på 2615/4545.',
}
}
if (supplierType === 'swedish_business' && supplyType === 'service') return { account: ['4425', '4426', '4427'][rateIdx] }
if (supplierType === 'swedish_business' && supplyType === 'goods') return { account: ['4415', '4416', '4417'][rateIdx] }
return { error: 'Kunde inte välja basbeloppskonto för angiven leverantörstyp.' }
}
export const POST = withRouteContext(
'report.vat_declaration.rc_basis_gaps.fix',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const result = await validateBody(request, FixGapSchema)
if (!result.success) return result.response
const { entryId, supplierType, supplyType } = result.data
// Fetch the entry + its lines (RLS + explicit company filter)
const { data: entry, error: fetchErr } = await supabase
.from('journal_entries')
.select('id, status, lines:journal_entry_lines(*)')
.eq('id', entryId)
.eq('company_id', companyId)
.single()
if (fetchErr || !entry) {
return errorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', log, { requestId, details: { entryId } })
}
if (entry.status !== 'posted') {
return errorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', log, {
requestId,
details: { entryId, reason: `entry is ${entry.status}, expected posted` },
})
}
const originalLines = (entry.lines as JournalEntryLine[]) || []
// Identify the RC output account and amount (sum across multiple lines if any)
let outputAccount: string | null = null
let outputAmount = 0
for (const line of originalLines) {
if (RC_OUTPUT_ACCOUNTS.has(line.account_number)) {
const net = (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0)
if (net > 0) {
if (outputAccount && outputAccount !== line.account_number) {
return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, {
requestId,
details: {
reason: 'Verifikationen har RC-moms på flera räntesatser. Korrigera manuellt.',
},
})
}
outputAccount = line.account_number
outputAmount += net
}
}
}
if (!outputAccount || outputAmount <= 0) {
return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, {
requestId,
details: { reason: 'Ingen RC-utgående moms hittades i verifikationen.' },
})
}
const rate = RATE_BY_OUTPUT[outputAccount]
const pick = pickBasisAccount(outputAccount, supplierType, supplyType)
if (!pick.account) {
return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, {
requestId,
details: { reason: pick.error ?? 'Kunde inte välja basbeloppskonto.' },
})
}
const basisAccount: string = pick.account
const basisAmount = Math.round((outputAmount / rate) * 100) / 100
const rateLabel = `${Math.round(rate * 100)}%`
// Build corrected lines = original lines + basis pair (44xx debit + 4598 credit)
const correctedLines: CreateJournalEntryLineInput[] = [
...originalLines.map((l) => {
const line: CreateJournalEntryLineInput = {
account_number: l.account_number,
debit_amount: Number(l.debit_amount) || 0,
credit_amount: Number(l.credit_amount) || 0,
}
if (l.currency) line.currency = l.currency
if (l.amount_in_currency != null) line.amount_in_currency = Number(l.amount_in_currency)
if (l.exchange_rate != null) line.exchange_rate = Number(l.exchange_rate)
if (l.line_description) line.line_description = l.line_description
if (l.tax_code) line.tax_code = l.tax_code
if (l.cost_center) line.cost_center = l.cost_center
if (l.project) line.project = l.project
return line
}),
{
account_number: basisAccount,
debit_amount: basisAmount,
credit_amount: 0,
line_description: `Basbelopp omvänd skattskyldighet ${rateLabel}`,
},
{
account_number: '4598',
debit_amount: 0,
credit_amount: basisAmount,
line_description: `Motkonto beräknad omvänd moms ${rateLabel}`,
},
]
try {
const correction = await correctEntry(supabase, companyId, user.id, entryId, correctedLines)
return NextResponse.json({
data: {
reversalId: correction.reversal.id,
correctedId: correction.corrected.id,
basisAccount,
basisAmount,
},
})
} catch (err) {
log.error('rc-basis-gap fix failed', err as Error, { entryId })
return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
)
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { findRcBasisGaps } from '@/lib/reports/rc-basis-gaps'
import type { VatPeriodType } from '@/types'
export const GET = withRouteContext(
'report.vat_declaration.rc_basis_gaps',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { searchParams } = new URL(request.url)
const periodType = searchParams.get('periodType') as VatPeriodType | null
const yearStr = searchParams.get('year')
const periodStr = searchParams.get('period')
if (!periodType || !yearStr || !periodStr) {
return errorResponseFromCode('VAT_REPORT_MISSING_PARAMS', log, { requestId })
}
if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) {
return errorResponseFromCode('VAT_REPORT_INVALID_PERIOD_TYPE', log, {
requestId,
details: { received: periodType },
})
}
const year = parseInt(yearStr, 10)
const period = parseInt(periodStr, 10)
if (isNaN(year) || isNaN(period)) {
return errorResponseFromCode('VAT_REPORT_INVALID_PERIOD', log, {
requestId,
details: { year: yearStr, period: periodStr },
})
}
try {
const gaps = await findRcBasisGaps(supabase, companyId, periodType, year, period)
return NextResponse.json({ data: { gaps } })
} catch (err) {
log.error('rc-basis-gaps detection failed', err as Error, { periodType, year, period })
return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
)
+194
View File
@@ -27,6 +27,8 @@ import {
runVatDeclarationChecks,
type VatDeclarationCheck,
} from '@/lib/reports/vat-declaration-checks'
import type { RcBasisGap } from '@/lib/reports/rc-basis-gaps'
import { formatDate } from '@/lib/utils'
interface SkatteverketStatus {
connected: boolean
@@ -106,6 +108,75 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
const localErrors = localChecks.filter((c) => c.status === 'ERROR')
const localBlocked = localErrors.length > 0
// Per-voucher RC basis gap detection — fetched whenever a RC_BASIS_MISSING
// warning fires so we can show the user exactly which verifikationer are
// missing the basbelopp pair and offer a one-click correction.
const hasRcBasisWarning = localChecks.some((c) => c.code === 'RC_BASIS_MISSING')
const [gaps, setGaps] = useState<RcBasisGap[]>([])
const [gapsLoading, setGapsLoading] = useState(false)
const [fixingId, setFixingId] = useState<string | null>(null)
const [gapSelections, setGapSelections] = useState<
Record<string, { supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business'; supplyType: 'service' | 'goods' }>
>({})
useEffect(() => {
if (!hasRcBasisWarning) {
setGaps([])
return
}
let cancelled = false
setGapsLoading(true)
fetch(
`/api/reports/vat-declaration/rc-basis-gaps?periodType=${periodType}&year=${year}&period=${period}`,
)
.then((r) => r.json())
.then((j) => {
if (cancelled) return
setGaps(j?.data?.gaps || [])
})
.catch(() => {
if (cancelled) return
setGaps([])
})
.finally(() => {
if (cancelled) return
setGapsLoading(false)
})
return () => {
cancelled = true
}
}, [hasRcBasisWarning, periodType, year, period])
const handleFixGap = async (gap: RcBasisGap) => {
const sel = gapSelections[gap.entryId] ?? { supplierType: 'eu_business', supplyType: 'service' as const }
setFixingId(gap.entryId)
setError(null)
try {
const res = await fetch('/api/reports/vat-declaration/rc-basis-gaps/fix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entryId: gap.entryId,
supplierType: sel.supplierType,
supplyType: sel.supplyType,
}),
})
const result = await res.json()
if (!res.ok) {
setError(result?.error || 'Kunde inte korrigera verifikationen')
} else {
setGaps((prev) => prev.filter((g) => g.entryId !== gap.entryId))
setSuccess(
`Verifikation ${gap.voucherSeries}-${gap.voucherNumber} korrigerad. Storno + ny verifikation skapad. Ladda om sidan för att uppdatera rutorna.`,
)
}
} catch {
setError('Kunde inte korrigera verifikationen')
} finally {
setFixingId(null)
}
}
/**
* Apply an API JSON error result. When the error indicates the SKV session
* has expired/been revoked/lost scope, immediately reflect that in the
@@ -553,6 +624,129 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
</div>
)}
{/* Per-voucher RC basis gaps — concrete list of verifikationer that
triggered RC_BASIS_MISSING, with a one-click korrigera action. */}
{hasRcBasisWarning && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Verifikationer som saknar basbelopp
</p>
{gapsLoading ? (
<div className="text-sm text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Söker berörda verifikationer...
</div>
) : gaps.length === 0 ? (
<p className="text-sm text-muted-foreground">
Inga verifikationer hittades. Bristen kan ligga utanför perioden
eller i bokföring som inte är posted.
</p>
) : (
<div className="space-y-2">
{gaps.map((gap) => {
const sel = gapSelections[gap.entryId] ?? {
supplierType: 'eu_business' as const,
supplyType: 'service' as const,
}
return (
<div
key={gap.entryId}
className="rounded-lg border bg-card p-3 space-y-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">
Verifikation {gap.voucherSeries}-{gap.voucherNumber}
<span className="text-muted-foreground font-normal">
{' · '}
{formatDate(gap.entryDate)}
</span>
</p>
<p className="text-sm text-muted-foreground truncate">
{gap.description}
</p>
<p className="text-xs text-muted-foreground mt-1 tabular-nums">
{gap.rcOutputAccount} har{' '}
{gap.rcOutputAmount.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
kr fiktiv moms saknar basbelopp{' '}
{gap.expectedBasisAmount.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
kr
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
className="text-xs border rounded px-2 py-1 bg-background"
value={sel.supplierType}
onChange={(e) => {
const next = e.target.value as typeof sel.supplierType
setGapSelections((prev) => ({
...prev,
[gap.entryId]: {
supplierType: next,
// Non-EU + goods is import VAT, not RC — coerce back
// to service so the user can't submit an invalid combo.
supplyType: next === 'non_eu_business' ? 'service' : sel.supplyType,
},
}))
}}
disabled={fixingId === gap.entryId}
>
<option value="eu_business">EU-leverantör</option>
<option value="non_eu_business">Utanför EU</option>
<option value="swedish_business">Svensk RC</option>
</select>
<select
className="text-xs border rounded px-2 py-1 bg-background"
value={sel.supplyType}
onChange={(e) =>
setGapSelections((prev) => ({
...prev,
[gap.entryId]: {
...sel,
supplyType: e.target.value as typeof sel.supplyType,
},
}))
}
disabled={fixingId === gap.entryId}
>
<option value="service">Tjänst</option>
{/* Non-EU goods is import VAT, not reverse charge — hide the
option for that combination so the fix endpoint never
has to reject it. */}
{sel.supplierType !== 'non_eu_business' && (
<option value="goods">Vara</option>
)}
</select>
<Button
variant="outline"
size="sm"
onClick={() => handleFixGap(gap)}
disabled={fixingId !== null}
className="gap-1.5 h-7"
>
{fixingId === gap.entryId ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<CheckCircle2 className="h-3 w-3" />
)}
Korrigera
</Button>
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* Validation results from Skatteverket */}
{kontroller.length > 0 && (
<div className="space-y-1.5">
+200
View File
@@ -0,0 +1,200 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { calculatePeriodDates } from './vat-declaration'
import type { VatPeriodType } from '@/types'
/**
* Per-voucher detection of FK004: reverse-charge output VAT booked
* (2614/2624/2634) without a matching basbelopp pair on 44xx/45xx.
*
* Used by the momsdeklaration UI to give the user a concrete list of
* verifikationer to correct, rather than a generic "ruta 30-32 utan
* ruta 20-24" warning that doesn't tell them what to fix.
*/
const RC_OUTPUT_ACCOUNTS = ['2614', '2624', '2634'] as const
type RcOutputAccount = typeof RC_OUTPUT_ACCOUNTS[number]
const RC_BASIS_ACCOUNTS = new Set([
'4515', '4516', '4517', // EU goods 25/12/6%
'4531', '4532', '4533', // non-EU services 25/12/6%
'4535', '4536', '4537', // EU services 25/12/6%
'4415', '4416', '4417', // domestic goods RC
'4425', '4426', '4427', // domestic services RC
])
const RATE_BY_OUTPUT: Record<RcOutputAccount, number> = {
'2614': 0.25,
'2624': 0.12,
'2634': 0.06,
}
// Default to EU services (matches the booking-template default
// reverse_charge_supplier_type = 'eu_business'). The user can pick a
// different supplier type on the Korrigera form if needed.
const DEFAULT_BASIS_BY_OUTPUT: Record<RcOutputAccount, string> = {
'2614': '4535',
'2624': '4536',
'2634': '4537',
}
export interface RcBasisGap {
entryId: string
voucherNumber: number
voucherSeries: string
entryDate: string
description: string
rcOutputAccount: RcOutputAccount
rcOutputAmount: number
expectedBasisAmount: number
suggestedBasisAccount: string
rate: number
}
interface RcLineRow {
journal_entry_id: string
account_number: string
debit_amount: number
credit_amount: number
// Supabase typings unpredictably model joined relations as either an object
// or an array depending on the FK; we accept both and normalize below.
journal_entries:
| {
id: string
voucher_number: number
voucher_series: string
entry_date: string
description: string
}
| {
id: string
voucher_number: number
voucher_series: string
entry_date: string
description: string
}[]
}
interface EntryFields {
id: string
voucher_number: number
voucher_series: string
entry_date: string
description: string
}
function pickEntry(row: RcLineRow): EntryFields | null {
const j = row.journal_entries
if (Array.isArray(j)) return j.length > 0 ? j[0] : null
return j ?? null
}
interface SiblingLineRow {
journal_entry_id: string
account_number: string
debit_amount: number
credit_amount: number
}
export async function findRcBasisGaps(
supabase: SupabaseClient,
companyId: string,
periodType: VatPeriodType,
year: number,
period: number,
): Promise<RcBasisGap[]> {
const { start, end } = calculatePeriodDates(periodType, year, period)
const rcLines = (await fetchAllRows<unknown>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
journal_entry_id,
account_number,
debit_amount,
credit_amount,
journal_entries!inner (
id, voucher_number, voucher_series, entry_date, description, status, company_id
)
`)
.in('account_number', RC_OUTPUT_ACCOUNTS as unknown as string[])
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
.range(from, to),
)) as RcLineRow[]
if (rcLines.length === 0) return []
const entryIds = [...new Set(rcLines.map((l) => l.journal_entry_id))]
const siblingLines = await fetchAllRows<SiblingLineRow>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('journal_entry_id, account_number, debit_amount, credit_amount')
.in('journal_entry_id', entryIds)
.range(from, to),
)
const basisByEntry = new Map<string, number>()
for (const line of siblingLines) {
if (RC_BASIS_ACCOUNTS.has(line.account_number)) {
const prev = basisByEntry.get(line.journal_entry_id) || 0
basisByEntry.set(
line.journal_entry_id,
prev + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
)
}
}
// Aggregate RC output per (entry, account) — a voucher may have multiple
// 2614 lines (rare) and we want to flag the total shortfall.
const aggregated = new Map<string, { row: RcLineRow; amount: number }>()
for (const line of rcLines) {
const key = `${line.journal_entry_id}:${line.account_number}`
const amount = (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0)
const existing = aggregated.get(key)
if (existing) {
existing.amount += amount
} else {
aggregated.set(key, { row: line, amount })
}
}
const eps = 0.5
const gaps: RcBasisGap[] = []
for (const { row, amount } of aggregated.values()) {
if (amount <= eps) continue
const account = row.account_number as RcOutputAccount
const rate = RATE_BY_OUTPUT[account]
if (!rate) continue
const expectedBasis = Math.round((amount / rate) * 100) / 100
const actualBasis = basisByEntry.get(row.journal_entry_id) || 0
if (actualBasis + eps >= expectedBasis) continue
const entry = pickEntry(row)
if (!entry) continue
gaps.push({
entryId: row.journal_entry_id,
voucherNumber: entry.voucher_number,
voucherSeries: entry.voucher_series,
entryDate: entry.entry_date,
description: entry.description,
rcOutputAccount: account,
rcOutputAmount: amount,
expectedBasisAmount: expectedBasis,
suggestedBasisAccount: DEFAULT_BASIS_BY_OUTPUT[account],
rate,
})
}
gaps.sort((a, b) => {
if (a.voucherSeries !== b.voucherSeries) {
return a.voucherSeries.localeCompare(b.voucherSeries)
}
return a.voucherNumber - b.voucherNumber
})
return gaps
}