diff --git a/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts b/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts new file mode 100644 index 00000000..4dc40c70 --- /dev/null +++ b/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts @@ -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 = { + '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' }, + }) + } + }, +) diff --git a/app/api/reports/vat-declaration/rc-basis-gaps/route.ts b/app/api/reports/vat-declaration/rc-basis-gaps/route.ts new file mode 100644 index 00000000..6a8b400a --- /dev/null +++ b/app/api/reports/vat-declaration/rc-basis-gaps/route.ts @@ -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' }, + }) + } + }, +) diff --git a/components/reports/SkatteverketPanel.tsx b/components/reports/SkatteverketPanel.tsx index df26116e..193ea88d 100644 --- a/components/reports/SkatteverketPanel.tsx +++ b/components/reports/SkatteverketPanel.tsx @@ -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([]) + const [gapsLoading, setGapsLoading] = useState(false) + const [fixingId, setFixingId] = useState(null) + const [gapSelections, setGapSelections] = useState< + Record + >({}) + + 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 )} + {/* Per-voucher RC basis gaps — concrete list of verifikationer that + triggered RC_BASIS_MISSING, with a one-click korrigera action. */} + {hasRcBasisWarning && ( +
+

+ Verifikationer som saknar basbelopp +

+ {gapsLoading ? ( +
+ + Söker berörda verifikationer... +
+ ) : gaps.length === 0 ? ( +

+ Inga verifikationer hittades. Bristen kan ligga utanför perioden + eller i bokföring som inte är posted. +

+ ) : ( +
+ {gaps.map((gap) => { + const sel = gapSelections[gap.entryId] ?? { + supplierType: 'eu_business' as const, + supplyType: 'service' as const, + } + return ( +
+
+
+

+ Verifikation {gap.voucherSeries}-{gap.voucherNumber} + + {' · '} + {formatDate(gap.entryDate)} + +

+

+ {gap.description} +

+

+ {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 +

+
+
+
+ + + +
+
+ ) + })} +
+ )} +
+ )} + {/* Validation results from Skatteverket */} {kontroller.length > 0 && (
diff --git a/lib/reports/rc-basis-gaps.ts b/lib/reports/rc-basis-gaps.ts new file mode 100644 index 00000000..7af6514e --- /dev/null +++ b/lib/reports/rc-basis-gaps.ts @@ -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 = { + '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 = { + '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 { + const { start, end } = calculatePeriodDates(periodType, year, period) + + const rcLines = (await fetchAllRows(({ 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(({ 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() + 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() + 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 +}