diff --git a/.gitignore b/.gitignore index 6d63ef03..a3b004c5 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,7 @@ supabase/.temp/ # out of the box without running the generator. supabase/.branches/ -/scripts \ No newline at end of file +/scripts + +# Local-only SIE test fixtures — may contain real/scrubbed company data, never commit +tests/fixtures/sie/ \ No newline at end of file diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index fefb9890..19baa941 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' @@ -15,6 +15,7 @@ import { formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { AccountNumber } from '@/components/ui/account-number' import { useCompany } from '@/contexts/CompanyContext' +import { useSettings } from '@/components/settings/useSettings' import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange' import { ReportsNav } from '@/components/reports/ReportsNav' @@ -171,14 +172,6 @@ export default function ReportsPage() { /> )} -

- {t('sie_moved_hint')}{' '} - - {t('sie_moved_link')} - - . -

- {isLoadingInit ? (
@@ -247,7 +240,12 @@ export default function ReportsPage() { {activeTab === 'balance-sheet' && ( )} - {activeTab === 'vat-declaration' && } + {activeTab === 'vat-declaration' && ( + + )} {activeTab === 'periodisk-sammanstallning' && } {isEnskildFirma && activeTab === 'ne-declaration' && ( @@ -1273,7 +1271,18 @@ function ReportSectionTable({ ) } -function VatDeclarationView() { +// Carries the selected fiscal period into the ruta drill-down rows so their +// source-verifikat query matches the report's period. Only set for yearly +// (räkenskapsår); undefined for monthly/quarterly (calendar periods). +const VatDrillContext = React.createContext<{ fiscalPeriodId?: string }>({}) + +function VatDeclarationView({ + fiscalPeriodId, + fiscalPeriodBounds, +}: { + fiscalPeriodId: string + fiscalPeriodBounds: { start: string; end: string } | null +}) { const currentYear = new Date().getFullYear() const currentMonth = new Date().getMonth() + 1 const currentQuarter = Math.ceil(currentMonth / 3) @@ -1285,6 +1294,25 @@ function VatDeclarationView() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + // Default the periodicity to the company's configured VAT reporting period + // (moms_period in Inställningar) so the picker mirrors the setting instead of + // always starting on quarterly. Applied once per company the first time its + // settings load; a later manual change to the picker is preserved, and a + // company switch re-applies the new company's setting. `useSettings` only + // refetches when the active company changes, so this never clobbers a manual + // selection mid-session. + const { settings } = useSettings() + const appliedForCompany = useRef(null) + useEffect(() => { + const momsPeriod = settings?.moms_period + const companyId = settings?.company_id + if (!momsPeriod || !companyId) return + if (appliedForCompany.current === companyId) return + appliedForCompany.current = companyId + setPeriodType(momsPeriod) + // `period` is reset to a sensible value by the periodType effect below. + }, [settings]) + // Generate year options (last 5 years) const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i) @@ -1331,12 +1359,26 @@ function VatDeclarationView() { } }, [periodType, currentMonth, currentQuarter]) + // Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar year. + // For yearly we pass the selected fiscal period so the API uses its actual + // bounds (handles extended/shortened years); monthly/quarterly stay calendar. + const isYearly = periodType === 'yearly' + const vatQueryString = () => { + const params = new URLSearchParams({ + periodType, + year: String(year), + period: String(period), + }) + if (isYearly && fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId) + return params.toString() + } + const fetchDeclaration = async () => { setLoading(true) setError(null) try { const res = await fetch( - `/api/reports/vat-declaration?periodType=${periodType}&year=${year}&period=${period}` + `/api/reports/vat-declaration?${vatQueryString()}` ) const result = await res.json() if (result.error) { @@ -1352,12 +1394,13 @@ function VatDeclarationView() { } return ( +
-
- - -
-
- - -
+ {isYearly ? ( + // Annual VAT covers the selected räkenskapsår — driven by the + // fiscal-year picker at the top of the page, not a calendar year. +
+ +
+ {fiscalPeriodBounds + ? `${formatDate(fiscalPeriodBounds.start)} – ${formatDate(fiscalPeriodBounds.end)}` + : '—'} +
+
+ ) : ( + <> +
+ + +
+
+ + +
+ + )} @@ -1646,13 +1704,29 @@ function VatDeclarationView() { )}
+
) } -function makeVatFetcher(ruta: string, periodType: VatPeriodType, year: number, period: number): ReportSourceFetcher { +function makeVatFetcher( + ruta: string, + periodType: VatPeriodType, + year: number, + period: number, + fiscalPeriodId?: string, +): ReportSourceFetcher { return async () => { + const params = new URLSearchParams({ + periodType, + year: String(year), + period: String(period), + }) + // Yearly drill-down resolves against the räkenskapsår, matching the report. + if (periodType === 'yearly' && fiscalPeriodId) { + params.set('fiscal_period_id', fiscalPeriodId) + } const res = await fetch( - `/api/reports/vat-declaration/ruta/${encodeURIComponent(ruta)}/sources?periodType=${periodType}&year=${year}&period=${period}` + `/api/reports/vat-declaration/ruta/${encodeURIComponent(ruta)}/sources?${params.toString()}` ) const json = await res.json() if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat') @@ -1680,10 +1754,11 @@ function VatRutaRow({ year?: number period?: number }) { + const { fiscalPeriodId } = React.useContext(VatDrillContext) const canDrill = periodType !== undefined && year !== undefined && period !== undefined const fetcher = React.useMemo( - () => (canDrill ? makeVatFetcher(ruta, periodType!, year!, period!) : null), - [canDrill, ruta, periodType, year, period] + () => (canDrill ? makeVatFetcher(ruta, periodType!, year!, period!, fiscalPeriodId) : null), + [canDrill, ruta, periodType, year, period, fiscalPeriodId] ) // Hooks must be called unconditionally — provide a noop fetcher when drill // is disabled. The early-return for zero rows lives below the hooks. diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx index 3d969a35..8fcc9e21 100644 --- a/app/(dashboard)/supplier-invoices/new/page.tsx +++ b/app/(dashboard)/supplier-invoices/new/page.tsx @@ -24,8 +24,7 @@ import { cn, formatCurrency } from '@/lib/utils' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCanWrite } from '@/lib/hooks/use-can-write' import BankTransactionPicker from '@/components/transactions/BankTransactionPicker' -import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' -import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, CalendarPlus, MessageCircle, Link2 } from 'lucide-react' +import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2 } from 'lucide-react' import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types' interface LineItem { @@ -33,6 +32,9 @@ interface LineItem { amount: number account_number: string vat_rate: number + // Self-assessed VAT rate for omvänd skattskyldighet (0.25/0.12/0.06). Only + // meaningful when reverse_charge is on; the line's vat_rate is then 0. + reverse_charge_rate?: number } interface FormData { @@ -184,6 +186,26 @@ function VatRateCell({ value, onChange }: { value: number; onChange: (v: number) ) } +// Self-assessment rate picker shown in place of the Momssats cell when an +// invoice is reverse charge. The supplier charges no VAT (the line vat_rate is +// 0); this is the Swedish statutory rate the buyer self-assesses at — 25% +// huvudregeln for EU services, 12%/6% for reduced-rated services (ML 6 kap 34 §). +function RcRateSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) { + const t = useTranslations('supplier_invoice_editor') + return ( + + ) +} + const EMPTY_NEW_SUPPLIER: NewSupplierForm = { name: '', supplier_type: 'swedish_business', @@ -221,7 +243,6 @@ export default function NewSupplierInvoicePage() { const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') const [periods, setPeriods] = useState([]) const [periodsLoaded, setPeriodsLoaded] = useState(false) - const [showCreatePeriod, setShowCreatePeriod] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [showReview, setShowReview] = useState(false) const [pendingData, setPendingData] = useState(null) @@ -268,7 +289,7 @@ export default function NewSupplierInvoicePage() { payment_reference: '', notes: '', paid_with_private_funds: false, - items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25 }], + items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25, reverse_charge_rate: 0.25 }], }, }) @@ -559,7 +580,10 @@ export default function NewSupplierInvoicePage() { const itemTotals = (watchedItems || []).map((item) => { const lineTotal = Math.round((item.amount || 0) * 100) / 100 - const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100 + // Reverse charge: VAT is self-assessed at reverse_charge_rate (25% default), + // not the line's vat_rate (which is 0 — the supplier charged nothing). + const effectiveRate = watchedReverseCharge ? (item.reverse_charge_rate ?? 0.25) : (item.vat_rate || 0) + const vatAmount = Math.round(lineTotal * effectiveRate * 100) / 100 return { lineTotal, vatAmount } }) const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0) @@ -660,7 +684,10 @@ export default function NewSupplierInvoicePage() { description: item.description, amount: item.amount, account_number: item.account_number, - vat_rate: item.vat_rate, + // Reverse charge: the supplier charges no VAT, so the line rate is 0 and + // the self-assessed rate travels on reverse_charge_rate (25% default). + vat_rate: data.reverse_charge ? 0 : item.vat_rate, + reverse_charge_rate: data.reverse_charge ? (item.reverse_charge_rate ?? 0.25) : undefined, })), } } @@ -731,6 +758,20 @@ export default function NewSupplierInvoicePage() { } function onSubmit(data: FormData) { + // Hard block: under faktureringsmetoden (and for privately-paid kvitton) a + // verifikation is posted at registration, and BFL 5 kap kräver att + // verifikationsnumret ligger i en obruten serie inom ett räkenskapsår. No + // räkenskapsår for the invoice date → no compliant voucher can exist, so we + // refuse rather than register an unbooked invoice. Kontantmetoden books at + // payment, so it is intentionally not blocked here (see showNoPeriodWarning). + if (showNoPeriodWarning) { + toast({ + title: t('warning_title'), + description: t('no_period_warning', { date: data.invoice_date }), + variant: 'destructive', + }) + return + } if (!data.supplier_id) { toast({ title: t('supplier_missing_title'), description: t('supplier_missing_description'), variant: 'destructive' }) return @@ -1166,22 +1207,15 @@ export default function NewSupplierInvoicePage() {
{showNoPeriodWarning && ( -
- -
+
+ +

{t('no_period_warning', { date: watchedInvoiceDate })}

{t('no_period_help')}

-
)} @@ -1197,7 +1231,7 @@ export default function NewSupplierInvoicePage() { size="sm" className="w-full sm:w-auto" onClick={() => - append({ description: '', amount: 0, account_number: '', vat_rate: 0.25 }) + append({ description: '', amount: 0, account_number: '', vat_rate: 0.25, reverse_charge_rate: 0.25 }) } > @@ -1282,8 +1316,8 @@ export default function NewSupplierInvoicePage() { {t('col_account')} {t('col_description')} {t('col_amount_excl')} - {t('col_vat_rate')} - {t('col_vat')} + {watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')} + {watchedReverseCharge ? t('col_rc_vat') : t('col_vat')} @@ -1336,13 +1370,23 @@ export default function NewSupplierInvoicePage() { /> - ( - - )} - /> + {watchedReverseCharge ? ( + ( + + )} + /> + ) : ( + ( + + )} + /> + )} {formatAmount(itemTotals[index]?.vatAmount ?? 0)} @@ -1418,18 +1462,28 @@ export default function NewSupplierInvoicePage() { />
- - ( - - )} - /> + + {watchedReverseCharge ? ( + ( + + )} + /> + ) : ( + ( + + )} + /> + )}
- {t('col_vat')} + {watchedReverseCharge ? t('col_rc_vat') : t('col_vat')} {formatAmount(itemTotals[index]?.vatAmount ?? 0)} @@ -1515,7 +1569,7 @@ export default function NewSupplierInvoicePage() { type="submit" variant="outline" className="w-full sm:w-auto" - disabled={isSubmitting || !canWrite} + disabled={isSubmitting || !canWrite || showNoPeriodWarning} onClick={() => { submitModeRef.current = 'register_and_match' }} title={!canWrite ? t('viewer_disabled_tooltip') : undefined} > @@ -1525,7 +1579,7 @@ export default function NewSupplierInvoicePage() { )}
- -
) } diff --git a/app/api/reports/vat-declaration/route.ts b/app/api/reports/vat-declaration/route.ts index 97ed1161..c5172236 100644 --- a/app/api/reports/vat-declaration/route.ts +++ b/app/api/reports/vat-declaration/route.ts @@ -24,6 +24,10 @@ export const GET = withRouteContext( const periodType = searchParams.get('periodType') as VatPeriodType | null const yearStr = searchParams.get('year') const periodStr = searchParams.get('period') + // For yearly (helårsmoms) the period is the räkenskapsår, not the calendar + // year; the client passes the selected fiscal period so an extended year is + // covered in full. Ignored for monthly/quarterly (calendar periods). + const fiscalPeriodId = searchParams.get('fiscal_period_id') ?? undefined if (!periodType || !yearStr || !periodStr) { return errorResponseFromCode('VAT_REPORT_MISSING_PARAMS', log, { requestId }) @@ -83,11 +87,14 @@ export const GET = withRouteContext( try { const declaration = await calculateVatDeclaration( supabase, companyId!, periodType, year, period, accountingMethod, + { fiscalPeriodId }, ) return NextResponse.json({ data: { ...declaration, + // For yearly the authoritative span is declaration.period.start/end + // (the räkenskapsår). The label stays a coarse "Helår {year}". periodLabel: formatPeriodLabel(periodType, year, period), }, }) diff --git a/app/api/reports/vat-declaration/xlsx/route.ts b/app/api/reports/vat-declaration/xlsx/route.ts index 3910c94e..d015604a 100644 --- a/app/api/reports/vat-declaration/xlsx/route.ts +++ b/app/api/reports/vat-declaration/xlsx/route.ts @@ -38,6 +38,8 @@ export async function GET(request: Request) { const periodType = searchParams.get('periodType') as VatPeriodType | null const yearStr = searchParams.get('year') const periodStr = searchParams.get('period') + // Yearly = räkenskapsår (see main route); ignored for monthly/quarterly. + const fiscalPeriodId = searchParams.get('fiscal_period_id') ?? undefined if (!periodType || !yearStr || !periodStr) { return NextResponse.json( @@ -73,6 +75,7 @@ export async function GET(request: Request) { try { const declaration = await calculateVatDeclaration( supabase, companyId, periodType, year, period, accountingMethod, + { fiscalPeriodId }, ) const rows: RutaRow[] = (Object.keys(declaration.rutor) as (keyof VatDeclarationRutor)[]).map( diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts index b5134302..0685c8f0 100644 --- a/app/api/supplier-invoices/[id]/credit/route.ts +++ b/app/api/supplier-invoices/[id]/credit/route.ts @@ -82,6 +82,9 @@ export const POST = withRouteContext( vat_code: item.vat_code, vat_rate: item.vat_rate, vat_amount: item.vat_amount, + // Preserve the self-assessed RC rate so the credit-note verifikat + // reverses fiktiv moms at the same rate the original was booked at. + reverse_charge_rate: item.reverse_charge_rate, })) await supabase.from('supplier_invoice_items').insert(creditItems) diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts index 83f5b79e..dc593816 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -208,6 +208,7 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { vat_code: null, vat_rate: 0.25, vat_amount: 2000, + reverse_charge_rate: null, created_at: '2024-06-01T00:00:00Z', }, ], diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index c55bc149..d177d95d 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -132,6 +132,10 @@ export const POST = withRouteContext( vat_code: item.vat_code || null, vat_rate: vatRate, vat_amount: vatAmount, + // Self-assessed RC rate (0.06/0.12/0.25) or null. For reverse charge the + // supplier charges no VAT (vat_rate stays 0); the engine self-assesses + // at this rate, defaulting to 25% huvudregeln when null. + reverse_charge_rate: body.reverse_charge ? (item.reverse_charge_rate ?? null) : null, } }) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts index 9a2972c6..fbc3d328 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts @@ -57,7 +57,7 @@ const SI_FULL_COLUMNS = ` vat_treatment, reverse_charge, remaining_amount, is_credit_note, credited_invoice_id, arrival_number, supplier:suppliers(id, name, supplier_type), - items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount) + items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate) ` const SupplierInvoiceCredited = z.object({ @@ -166,6 +166,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string vat_code: string | null vat_rate: number vat_amount: number + reverse_charge_rate: number | null }> } & Record @@ -215,6 +216,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string vat_code: item.vat_code, vat_rate: item.vat_rate, vat_amount: item.vat_amount, + reverse_charge_rate: item.reverse_charge_rate, })) return dryRunPreview( { @@ -306,6 +308,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string vat_code: item.vat_code, vat_rate: item.vat_rate, vat_amount: item.vat_amount, + // Preserve the self-assessed RC rate so the credit note reverses fiktiv + // moms at the same rate the original was booked at. + reverse_charge_rate: item.reverse_charge_rate, })) if (creditItems.length > 0) { const { error: itemsErr } = await ctx.supabase diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts index efc3b489..6deee803 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts @@ -169,7 +169,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string subtotal, subtotal_sek, vat_amount, vat_amount_sek, total_sek, due_date, received_date, is_credit_note, credited_invoice_id, payment_journal_entry_id, supplier:suppliers(id, name, supplier_type), - items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount) + items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate) `) .eq('company_id', ctx.companyId!) .eq('id', invoiceId) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts index 77b65c3e..04be8e51 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts @@ -34,7 +34,7 @@ const SI_DETAIL_COLUMNS = 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_at, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, transaction_id, document_id, notes, reversed_at, created_at, updated_at' const SI_ITEM_COLUMNS = - 'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount' + 'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate' const SI_PAYMENT_COLUMNS = 'id, payment_date, amount, currency, exchange_rate, exchange_rate_difference, journal_entry_id, transaction_id, notes, created_at' diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts index 4fb9fd35..d29adaf5 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts @@ -264,7 +264,7 @@ const SI_RESPONSE_COLUMNS = 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, notes, created_at, updated_at' const SI_ITEMS_RESPONSE_COLUMNS = - 'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount' + 'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate' const SupplierInvoiceCreated = z.object({ id: z.string().uuid(), @@ -345,6 +345,7 @@ interface ComputedItem { vat_code: string | null vat_rate: number vat_amount: number + reverse_charge_rate: number | null } // Swedish VAT rates per ML 2 kap 1 § + Skatteverket's 2026 satser. Allow @@ -385,6 +386,10 @@ function computeItemsAndTotals(input: z.infer sum + i.line_total, 0) diff --git a/components/suppliers/SupplierInvoiceReviewContent.tsx b/components/suppliers/SupplierInvoiceReviewContent.tsx index 9c52bde3..61bc7032 100644 --- a/components/suppliers/SupplierInvoiceReviewContent.tsx +++ b/components/suppliers/SupplierInvoiceReviewContent.tsx @@ -5,6 +5,11 @@ import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { AccountNumber } from '@/components/ui/account-number' import { formatCurrency } from '@/lib/utils' +import { + resolveReverseChargeRate, + isReverseChargeBasisAccount, + generateReverseChargeBasisLines, +} from '@/lib/bookkeeping/vat-entries' import type { Supplier } from '@/types' interface ReviewLineItem { @@ -15,6 +20,9 @@ interface ReviewLineItem { // When set, the user typed the deductible VAT explicitly (manual override). // Used for bilförmån 50%, representation tak, FX-rundningar etc. vat_amount?: number + // Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25). The + // supplier charges no VAT (vat_rate = 0); this drives the fiktiv-moms preview. + reverse_charge_rate?: number } interface SupplierInvoiceReviewContentProps { @@ -91,20 +99,38 @@ function buildJournalPreview( : Math.round(item.amount * item.vat_rate * 100) / 100 if (reverseCharge) { - // Reverse charge: fiktiv moms is always statutory base × rate, regardless - // of any manual override on the items themselves (matches engine). + // Reverse charge: the supplier charges no VAT, so the buyer self-assesses at + // the Swedish statutory rate (resolveReverseChargeRate — 25% huvudregel + // default, or the per-item reverse_charge_rate). We book BOTH the fiktiv-moms + // pair (2645/2647 + 2614/2624/2634) AND the basbeloppsrader (44xx/45xx + + // 4598), exactly as the engine does, so this preview matches the saved + // verifikat. ML 16 kap requires both sides reported; silent netting is + // prohibited (Skatteverket felkod FK004). Driving off the resolved rate (not + // item.vat_rate) is what makes a 0%-rate RC line book its VAT at all. const isDomesticRC = supplierType === 'swedish_business' const inputAccount = isDomesticRC ? '2647' : '2645' + const rcSupplierType: 'eu_business' | 'non_eu_business' | 'swedish_business' = + supplierType === 'non_eu_business' || supplierType === 'swedish_business' + ? supplierType + : 'eu_business' + // Base per self-assessed rate, plus the non-basis-account portion that needs + // parallel basbeloppsrader (items booked straight to a 44xx/45xx basis + // account already populate ruta 20-24 via the expense line, so they're + // excluded there to avoid double-counting). const baseByRate = new Map() + const nonBasisBaseByRate = new Map() for (const item of items) { - if (item.vat_rate > 0) { - const current = baseByRate.get(item.vat_rate) || 0 - baseByRate.set(item.vat_rate, current + toSek(item.amount)) + const rate = resolveReverseChargeRate(item) + const sek = toSek(item.amount) + baseByRate.set(rate, (baseByRate.get(rate) || 0) + sek) + if (!isReverseChargeBasisAccount(item.account_number)) { + nonBasisBaseByRate.set(rate, (nonBasisBaseByRate.get(rate) || 0) + sek) } } for (const [rate, netAmount] of baseByRate) { + if (netAmount <= 0) continue const fiktivVat = Math.round(netAmount * rate * 100) / 100 const outputAccount = getOutputVatAccount(rate) lines.push({ @@ -119,6 +145,17 @@ function buildJournalPreview( debit: 0, credit: fiktivVat, }) + const nonBasisBase = nonBasisBaseByRate.get(rate) || 0 + if (nonBasisBase > 0) { + for (const bl of generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)) { + lines.push({ + account_number: bl.account_number, + description: bl.line_description ?? bl.account_number, + debit: bl.debit_amount, + credit: bl.credit_amount, + }) + } + } } // Credit: 2440 at subtotal (no real VAT for reverse charge) @@ -248,9 +285,16 @@ export function SupplierInvoiceReviewContent({ {items.map((item, index) => { - const vatAmount = item.vat_amount != null - ? Math.round(item.vat_amount * 100) / 100 - : Math.round(item.amount * item.vat_rate * 100) / 100 + // For reverse charge the supplier charges 0%, so show the + // self-assessed rate/amount the buyer books (matches the voucher + // preview below). Manual vat_amount overrides only apply to + // ordinary deductible VAT, never to RC self-assessment. + const displayRate = reverseCharge ? resolveReverseChargeRate(item) : item.vat_rate + const vatAmount = reverseCharge + ? Math.round(item.amount * displayRate * 100) / 100 + : item.vat_amount != null + ? Math.round(item.vat_amount * 100) / 100 + : Math.round(item.amount * item.vat_rate * 100) / 100 return ( @@ -258,7 +302,7 @@ export function SupplierInvoiceReviewContent({ {item.description} {formatAmount(item.amount)} - {Math.round(item.vat_rate * 100)}% + {Math.round(displayRate * 100)}% {formatAmount(vatAmount)} ) @@ -268,9 +312,12 @@ export function SupplierInvoiceReviewContent({
{items.map((item, index) => { - const vatAmount = item.vat_amount != null - ? Math.round(item.vat_amount * 100) / 100 - : Math.round(item.amount * item.vat_rate * 100) / 100 + const displayRate = reverseCharge ? resolveReverseChargeRate(item) : item.vat_rate + const vatAmount = reverseCharge + ? Math.round(item.amount * displayRate * 100) / 100 + : item.vat_amount != null + ? Math.round(item.vat_amount * 100) / 100 + : Math.round(item.amount * item.vat_rate * 100) / 100 return (
@@ -279,7 +326,7 @@ export function SupplierInvoiceReviewContent({
{formatAmount(item.amount)} kr - {t('review_vat_inline', { rate: Math.round(item.vat_rate * 100), amount: formatAmount(vatAmount) })} + {t('review_vat_inline', { rate: Math.round(displayRate * 100), amount: formatAmount(vatAmount) })}
) @@ -293,7 +340,7 @@ export function SupplierInvoiceReviewContent({ {formatCurrency(subtotal, currency)}
- {t('vat_label_short')} + {reverseCharge ? t('vat_reverse_charge') : t('vat_label_short')} {formatCurrency(totalVat, currency)}
diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index 049b4646..c7966af3 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -15,6 +15,7 @@ import { } from './lib/provider-client' import { mapCompanyInfo } from './lib/entity-mapper' import { executeMigration } from './lib/migration-orchestrator' +import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers' import type { ArcimProvider } from './types' import { ARCIM_PROVIDERS } from './types' import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser' @@ -1001,6 +1002,7 @@ export const arcimMigrationExtension: Extension = { importSuppliers = true, importSalesInvoices = true, importSupplierInvoices = true, + reconcileVouchers = true, } = await request.json() as { consentId: string importCompanyInfo?: boolean @@ -1008,6 +1010,7 @@ export const arcimMigrationExtension: Extension = { importSuppliers?: boolean importSalesInvoices?: boolean importSupplierInvoices?: boolean + reconcileVouchers?: boolean } if (!consentId) { @@ -1034,6 +1037,7 @@ export const arcimMigrationExtension: Extension = { importSuppliers, importSalesInvoices, importSupplierInvoices, + reconcileVouchers, }) log.info('Migration completed:', results) @@ -1055,6 +1059,60 @@ export const arcimMigrationExtension: Extension = { }, }, + // ── Reconcile supplier invoices to GL payment vouchers ──────── + // Re-runnable maintenance endpoint. The migration runs this automatically as + // its final step, but SIE (the GL) and entity import are two separate HTTP + // requests whose order is UI-driven — so if the GL lands after the entity + // import, or a company was migrated before this feature existed, call this to + // auto-link settled supplier invoices to their existing vouchers. Pass + // { dryRun: true } to preview the plan (incl. items needing manual review) + // without writing. + { + method: 'POST', + path: '/reconcile', + handler: async (request: Request, ctx?: ExtensionContext) => { + const log = ctx?.log ?? console + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = ctx?.companyId ?? user.id + + let dryRun = false + try { + const body = (await request.json()) as { dryRun?: boolean } + dryRun = body?.dryRun === true + } catch { + // empty body is fine — default to a real run + } + + try { + const result = await reconcileSupplierInvoiceVouchers({ + supabase, + companyId, + userId: user.id, + dryRun, + }) + log.info('arcim reconcile completed', { + companyId, + dryRun, + autoLinked: result.autoLinked, + ambiguous: result.ambiguous, + unmatched: result.unmatched, + }) + return NextResponse.json({ success: true, dryRun, result }) + } catch (error) { + log.error('arcim reconcile failed', error as Error) + return errorResponseFromCode('PROVIDER_MIGRATE_FAILED', moduleLog, { + details: { reason: error instanceof Error ? error.message : 'unknown' }, + }) + } + }, + }, + // ── Accept consent (mark as fully connected after import) ───── { method: 'POST', diff --git a/extensions/general/arcim-migration/lib/__tests__/entity-mapper-status.test.ts b/extensions/general/arcim-migration/lib/__tests__/entity-mapper-status.test.ts new file mode 100644 index 00000000..7aa1c46d --- /dev/null +++ b/extensions/general/arcim-migration/lib/__tests__/entity-mapper-status.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest' +import { mapSupplierInvoice } from '../entity-mapper' +import type { SupplierInvoiceDto, InvoiceStatusCode, PartyDto } from '@/lib/providers/dto' + +/** + * Guards the status/paid consistency hardening in mapSupplierInvoice: the + * provider's lifecycle status (dto.status) and its payment status are computed + * independently upstream and can contradict each other. The mapper must emit a + * `status` that always agrees with paid_amount / remaining_amount, and treat + * Balance numerically (drift-safe), without ever flipping a credit note. + */ + +const party: PartyDto = { name: 'Leverantör AB', identifications: [] } + +function makeDto(over: { + status?: InvoiceStatusCode + paid?: boolean + balance?: number + total?: number + invoiceTypeCode?: string + lastPaymentDate?: string +}): SupplierInvoiceDto { + const total = over.total ?? 1000 + return { + id: 'inv-1', + invoiceNumber: 'F-100', + issueDate: '2026-01-10', + dueDate: '2026-02-10', + invoiceTypeCode: over.invoiceTypeCode, + currencyCode: 'SEK', + status: over.status ?? 'booked', + supplier: party, + buyer: party, + lines: [ + { + id: '1', + description: 'Tjänst', + lineExtensionAmount: { value: total, currencyCode: 'SEK' }, + taxPercent: 25, + }, + ], + legalMonetaryTotal: { + lineExtensionAmount: { value: total, currencyCode: 'SEK' }, + payableAmount: { value: total, currencyCode: 'SEK' }, + }, + paymentStatus: { + paid: over.paid ?? false, + balance: { value: over.balance ?? total, currencyCode: 'SEK' }, + lastPaymentDate: over.lastPaymentDate, + }, + } +} + +function map(over: Parameters[0]) { + return mapSupplierInvoice(makeDto(over), 'user-1', 'company-1', 'supplier-1').invoice +} + +describe('mapSupplierInvoice — status/paid consistency', () => { + it('unpaid booked invoice → registered with full remaining', () => { + const inv = map({ status: 'booked', paid: false, balance: 1000, total: 1000 }) + expect(inv.status).toBe('registered') + expect(inv.paid_amount).toBe(0) + expect(inv.remaining_amount).toBe(1000) + expect(inv.paid_at).toBeNull() + }) + + it('booked-but-paid invoice → flips to paid (status follows payment)', () => { + // The bug: dto.status='booked' (→registered) while paymentStatus.paid=true. + const inv = map({ status: 'booked', paid: true, balance: 0, total: 1000, lastPaymentDate: '2026-02-05' }) + expect(inv.status).toBe('paid') + expect(inv.paid_amount).toBe(1000) + expect(inv.remaining_amount).toBe(0) + expect(inv.paid_at).toBe('2026-02-05') + }) + + it('near-zero residual balance (0.004) resolves to paid, not unpaid', () => { + const inv = map({ status: 'booked', paid: false, balance: 0.004, total: 1000 }) + expect(inv.status).toBe('paid') + expect(inv.remaining_amount).toBe(0) + expect(inv.paid_amount).toBe(1000) + }) + + it('partially-paid invoice (0 < paid < total) → partially_paid', () => { + const inv = map({ status: 'booked', paid: false, balance: 300, total: 1000 }) + expect(inv.status).toBe('partially_paid') + expect(inv.paid_amount).toBe(700) + expect(inv.remaining_amount).toBe(300) + expect(inv.paid_at).not.toBeNull() + }) + + it('credit note with zero balance stays credited — never flipped to paid', () => { + const inv = map({ status: 'credited', paid: true, balance: 0, total: 1000, invoiceTypeCode: '381' }) + expect(inv.status).toBe('credited') + expect(inv.is_credit_note).toBe(true) + }) + + it('credit note is forced to credited even if the provider sends a non-terminal status', () => { + // invoiceTypeCode='381' but a contradictory lifecycle status (the arcim + // gateway does not guarantee status='credited' alongside the type code). + for (const status of ['booked', 'paid', 'sent', 'draft'] as InvoiceStatusCode[]) { + const inv = map({ status, paid: true, balance: 0, total: 1000, invoiceTypeCode: '381' }) + expect(inv.status, `status=${status}`).toBe('credited') + expect(inv.is_credit_note).toBe(true) + expect(inv.paid_at).toBeNull() + } + }) + + it('overdue lifecycle status is preserved when nothing is paid', () => { + const inv = map({ status: 'overdue', paid: false, balance: 1000, total: 1000 }) + expect(inv.status).toBe('overdue') + expect(inv.remaining_amount).toBe(1000) + }) + + it('never emits a status outside the supplier_invoices CHECK allow-list', () => { + const allowed = new Set([ + 'registered', 'approved', 'paid', 'partially_paid', 'overdue', 'disputed', 'credited', 'reversed', + ]) + for (const status of ['draft', 'sent', 'booked', 'paid', 'overdue', 'cancelled', 'credited'] as InvoiceStatusCode[]) { + for (const paid of [true, false]) { + for (const balance of [0, 250, 1000]) { + const inv = map({ status, paid, balance, total: 1000 }) + expect(allowed.has(inv.status as string)).toBe(true) + } + } + } + }) +}) diff --git a/extensions/general/arcim-migration/lib/entity-mapper.ts b/extensions/general/arcim-migration/lib/entity-mapper.ts index 78e1b81a..808f1e75 100644 --- a/extensions/general/arcim-migration/lib/entity-mapper.ts +++ b/extensions/general/arcim-migration/lib/entity-mapper.ts @@ -357,6 +357,38 @@ export function mapSupplierInvoice( const isCreditNote = dto.invoiceTypeCode === '381' + // Payment-derived amounts. Treat Balance numerically (never strict === 0) so + // floating drift or a residual öre resolves cleanly to paid/unpaid. + const balance = round2(dto.paymentStatus.balance.value) + const paidAmount = dto.paymentStatus.paid ? total : round2(total - balance) + + // Status MUST stay consistent with the payment amounts. The provider's + // lifecycle status (dto.status) and its payment status are computed + // independently upstream and can contradict each other (e.g. a Fortnox + // invoice that is "booked" but fully paid). Payment state wins: + // fully paid -> 'paid' + // 0 < paid < total -> 'partially_paid' + // otherwise -> the mapped lifecycle status + const mappedStatus = statusMap[dto.status] || 'registered' + let resolvedStatus: string + if (isCreditNote) { + // A kreditfaktura is never an open or "paid" payable. Force a credit-note + // terminal status regardless of the provider's lifecycle status — the + // arcim gateway is the only source of invoiceTypeCode and is NOT guaranteed + // to also send status='credited', so trusting dto.status here could persist + // a credit note as 'registered'/'paid' (contradicting its amounts). + resolvedStatus = mappedStatus === 'reversed' ? 'reversed' : 'credited' + } else if (mappedStatus === 'credited' || mappedStatus === 'reversed') { + // Terminal states from the provider: never flipped by payment. + resolvedStatus = mappedStatus + } else if (dto.paymentStatus.paid || balance <= 0) { + resolvedStatus = 'paid' + } else if (paidAmount > 0 && paidAmount < total) { + resolvedStatus = 'partially_paid' + } else { + resolvedStatus = mappedStatus + } + const invoice: Record = { user_id: userId, company_id: companyId, @@ -366,7 +398,7 @@ export function mapSupplierInvoice( due_date: dto.dueDate || dto.issueDate, received_date: dto.issueDate, delivery_date: dto.deliveryDate || null, - status: statusMap[dto.status] || 'registered', + status: resolvedStatus, currency: dto.currencyCode || 'SEK', exchange_rate: dto.currencyCode === 'SEK' ? null : null, subtotal, @@ -378,9 +410,11 @@ export function mapSupplierInvoice( vat_treatment: vatTreatment, reverse_charge: vatTreatment === 'reverse_charge', payment_reference: dto.ocrNumber || null, - paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null, - paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value), - remaining_amount: round2(dto.paymentStatus.balance.value), + paid_at: resolvedStatus === 'paid' || resolvedStatus === 'partially_paid' + ? dto.paymentStatus.lastPaymentDate || dto.issueDate + : null, + paid_amount: resolvedStatus === 'paid' ? total : Math.max(0, paidAmount), + remaining_amount: resolvedStatus === 'paid' ? 0 : Math.max(0, balance), is_credit_note: isCreditNote, notes: dto.note || null, } diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts index 71aa5a36..2c57d44d 100644 --- a/extensions/general/arcim-migration/lib/migration-orchestrator.ts +++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts @@ -30,6 +30,7 @@ import { fetchSupplierInvoicesDirect, } from '@/lib/providers/provider-data-fetcher' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers' import { mapCustomer, mapSupplier, @@ -49,6 +50,8 @@ export interface MigrationOptions { importSuppliers?: boolean importSalesInvoices?: boolean importSupplierInvoices?: boolean + /** Auto-link imported supplier invoices to GL payment vouchers. Default true. */ + reconcileVouchers?: boolean onProgress?: (progress: MigrationProgress) => void } @@ -666,6 +669,30 @@ export async function executeMigration(options: MigrationOptions): Promise { it('passes raw transactions to ingest function', async () => { mockGetAllTransactionsWithRaw.mockResolvedValue({ - transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' } }], + transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' }, booking_date: '2024-06-15' }], rawPages: ['{}'], }) @@ -222,6 +222,83 @@ describe('syncAccountTransactions', () => { expect(rawTxns[0].import_source).toBe('enable_banking') }) + it('skips pending entries (no booking_date) and ingests only booked ones', async () => { + // A pending PSD2 entry has no booking_date — only a value_date. Importing it + // is what produced the production duplicates: its date (hence its dedup id) + // differs from the same transaction's booked representation. Pending entries + // must be skipped; booked ones import as usual, keyed on booking_date. + mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({ + id: 'x', + date: tx.booking_date || tx.value_date, + booking_date: tx.booking_date || tx.value_date, + amount: -parseFloat(tx.transaction_amount.amount), + currency: 'SEK', + description: 'T', + })) + mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + + mockGetAllTransactionsWithRaw.mockResolvedValue({ + transactions: [ + { transaction_amount: { amount: '50', currency: 'SEK' }, value_date: '2026-02-15' }, // pending → skipped + { transaction_amount: { amount: '75', currency: 'SEK' }, booking_date: '2026-03-01' }, // booked → kept + ], + rawPages: ['{}'], + }) + + await syncAccountTransactions( + {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(), + '2026-01-01', '2026-06-01', mockIngest + ) + + const batch = mockIngest.mock.calls[0][3] + expect(batch).toHaveLength(1) + expect(batch[0].date).toBe('2026-03-01') + expect(batch[0].external_id).toBe('eb_acc-uid-1_2026-03-01_-7500_0') + }) + + it('does not re-import a booked transaction when a later sync returns it pending (no booking_date)', async () => { + // Regression for the 45→90 duplication. The same transaction is returned + // booked in sync 1 (booking_date 2026-04-21) and pending in sync 2 (only + // value_date 2026-02-15). Previously sync 2 ingested the pending copy with a + // value_date-derived id (different date → new id → duplicate). It must now + // be skipped, so sync 2 ingests nothing for it. + mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({ + id: 'x', + date: tx.booking_date || tx.value_date, + booking_date: tx.booking_date || tx.value_date, + amount: -parseFloat(tx.transaction_amount.amount), + currency: 'SEK', + description: 'AVI ÖVERDRAG', + })) + mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + + // Sync 1 — booked + mockGetAllTransactionsWithRaw.mockResolvedValueOnce({ + transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-21', value_date: '2026-02-15' }], + rawPages: ['{}'], + }) + await syncAccountTransactions( + {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(), + '2026-01-01', '2026-06-01', mockIngest + ) + const firstBatch = mockIngest.mock.calls[0][3] + expect(firstBatch).toHaveLength(1) + expect(firstBatch[0].date).toBe('2026-04-21') + expect(firstBatch[0].external_id).toBe('eb_acc-uid-1_2026-04-21_-10000_0') + + // Sync 2 — same transaction now returned pending (booking_date dropped) + mockGetAllTransactionsWithRaw.mockResolvedValueOnce({ + transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, value_date: '2026-02-15' }], + rawPages: ['{}'], + }) + await syncAccountTransactions( + {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(), + '2026-01-01', '2026-06-01', mockIngest + ) + const secondBatch = mockIngest.mock.calls[1][3] + expect(secondBatch).toHaveLength(0) + }) + it('gives identical same-day same-amount transactions distinct, stable external_ids', async () => { // Two genuinely distinct transactions that share date + amount must both be // kept (distinct ids), and re-running the sync must reproduce the SAME set diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index 388011c8..af479472 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -100,32 +100,68 @@ export async function syncAccountTransactions( const bankTransactions = transactions.map(tx => convertTransaction(tx, account.currency)) - // Derive a stable, content-based external_id per transaction. We deliberately - // do NOT key off the bank's transaction id (entry_reference/transaction_id): - // many Swedish ASPSPs regenerate those across requests, so a repeat "synka nu" - // produced a fresh id and re-imported transactions the user had already - // booked. buildStableExternalIds derives the id from (account, date, amount) - // plus an occurrence index, so re-syncs collide on (company_id, external_id) - // and dedupe while genuinely identical transactions are still kept apart. - // Normalize the IBAN (strip whitespace, uppercase) so formatting variants - // from the ASPSP ("SE45 5000 …" vs "SE455000…") don't change the scope and - // orphan every prior external_id. Falls back to the provider account uid. + // Only ingest BOOKED transactions — those the ASPSP returned with a real + // booking_date. Pending entries are intentionally skipped: a pending row is + // unstable across syncs (a later "synka nu" returns the same transaction + // either still pending or finally booked, often with a *different* effective + // date). Because BOTH the dedup external_id and the content-dedup key are + // date-derived, that drift mints a brand-new id and re-imports a transaction + // that already exists. Observed in production as the same amount+description + // landing twice with different dates — the bank's value_date in one sync, its + // booking_date in another. Gating the import set on a stable booking_date + // removes the drift at the source, and leaves booked rows' ids byte-identical + // (so the existing rows are NOT re-orphaned). + // + // booking_date is read from the RAW transaction (transactions[i]), index- + // aligned with bankTransactions: convertTransaction's booking_date already + // falls back to value_date/today, so it cannot tell booked from pending. + const bookedEntries = bankTransactions.flatMap((tx, i) => { + const bookingDate = transactions[i]?.booking_date + return typeof bookingDate === 'string' && bookingDate.trim() !== '' + ? [{ tx, bookingDate: bookingDate.trim() }] + : [] + }) + + const skippedPending = bankTransactions.length - bookedEntries.length + if (skippedPending > 0) { + console.log('[enable-banking] Skipped pending transactions (no booking_date)', { + connectionId, + accountUid: account.uid, + skippedPending, + total: bankTransactions.length, + }) + } + + // Derive a stable, content-based external_id per booked transaction. We + // deliberately do NOT key off the bank's transaction id (entry_reference/ + // transaction_id): many Swedish ASPSPs regenerate those across requests, so a + // repeat "synka nu" produced a fresh id and re-imported transactions the user + // had already booked. buildStableExternalIds derives the id from (account, + // booking_date, amount) plus an occurrence index, so re-syncs collide on + // (company_id, external_id) and dedupe while genuinely identical transactions + // are still kept apart. Normalize the IBAN (strip whitespace, uppercase) so + // formatting variants from the ASPSP ("SE45 5000 …" vs "SE455000…") don't + // change the scope and orphan every prior external_id. Falls back to the + // provider account uid. const accountScope = account.iban?.replace(/\s+/g, '').toUpperCase() || account.uid const externalIds = buildStableExternalIds( 'eb', accountScope, - bankTransactions.map((tx) => ({ date: tx.booking_date || tx.date, amount: tx.amount })) + bookedEntries.map(({ tx, bookingDate }) => ({ date: bookingDate, amount: tx.amount })) ) // Convert Enable Banking format to generic RawTransaction. counterparty // identification: prefer IBAN (international, normalized) over BBAN/BG // numbers — the own-account detector matches on IBAN first, falling back // to counterparty_account for Swedish domestic transfers. - const rawTransactions: RawTransaction[] = bankTransactions.map((tx, i) => { + const rawTransactions: RawTransaction[] = bookedEntries.map(({ tx, bookingDate }, i) => { const cpAccount = tx.counterparty_account ?? null const looksLikeIban = cpAccount && /^[A-Z]{2}\d/.test(cpAccount.replace(/\s+/g, '')) return { - date: tx.booking_date || tx.date, + // The booked date is both the stable dedup anchor (see bookedEntries) and + // the accounting-correct ledger date; keep it identical to the value the + // external_id was derived from. + date: bookingDate, // tx.description is already non-empty (convertTransaction guarantees a // label); the trailing fallbacks are defensive. Ingest re-normalizes. description: tx.description || tx.counterparty_name || FALLBACK_DESCRIPTION, diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index d32a6e0d..f11b9496 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -1672,6 +1672,9 @@ export const invoiceInboxExtension: Extension = { vat_code: bodyItem.vat_code || null, vat_rate: vatRate, vat_amount: vatAmount, + // Self-assessed RC rate (0.06/0.12/0.25) or null — engine defaults + // to 25% huvudregeln when null for a reverse-charge invoice. + reverse_charge_rate: body.reverse_charge ? (bodyItem.reverse_charge_rate ?? null) : null, } }) @@ -1782,7 +1785,24 @@ export const invoiceInboxExtension: Extension = { }) } } catch (err) { - console.error('[invoice-inbox/convert] Failed to create registration journal entry:', err) + // Engine threw (period lock, unbalanced entry, etc.) instead of + // cleanly returning null. Roll back the supplier invoice so the inbox + // item is never marked converted against an unbooked invoice (an orphan + // understating 2440/2641), then surface the error — mirroring the main + // /api/supplier-invoices route's registration catch. + await ctx.supabase + .from('supplier_invoices') + .delete() + .eq('id', invoice.id) + .eq('company_id', ctx.companyId) + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + return errorResponseFromCode('SI_CREATE_FAILED', ctx.log, { + details: { + reason: err instanceof Error ? err.message : 'unknown', + step: 'registration_journal_entry', + }, + }) } } diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 50bd1d1f..b0b86ba9 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -31,9 +31,16 @@ describe('tools/list payload size guard', () => { // closes the MCP parity gap with the existing REST endpoint so agents // can attach a bank tx to an already-posted verifikat without creating // duplicate bookkeeping. Description trimmed to ~180 chars. + // * 31.5K → 32K when gnubok_find_voucher_candidates_for_supplier_invoice + + // gnubok_link_supplier_invoice_to_voucher landed — the supplier-side + // mirror of the customer find/link voucher tools. The link tool inlines + // the shared STAGED_OPERATION_SCHEMA. Lets agents mark a leverantörs- + // faktura paid against an already-posted verifikat (no new bokföring), + // which is exactly the fix for invoices imported from Fortnox as open + // payables while their payment already exists in the SIE-imported GL. // Long-term answer to growth is leaning harder on gnubok_search_tools — if this // fires again, prefer trimming descriptions or making a tool opt-in via search // before bumping further. - expect(approxTokens).toBeLessThan(31_500) + expect(approxTokens).toBeLessThan(32_000) }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index cb4f9445..568a0ef8 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -52,6 +52,10 @@ import { findMatchingVouchersForInvoice, validateVoucherForInvoiceLink, } from '@/lib/invoices/voucher-matching' +import { + findMatchingVouchersForSupplierInvoice, + validateVoucherForSupplierInvoiceLink, +} from '@/lib/invoices/supplier-voucher-matching' import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' import { closePeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service' @@ -5087,6 +5091,157 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_find_voucher_candidates_for_supplier_invoice', + description: 'List posted verifikat that debit leverantörsskuld (2440) and could be the payment for this supplier invoice. Use before gnubok_link_supplier_invoice_to_voucher when marking a leverantörsfaktura paid against an existing verifikation (no new bokföring).', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + supplier_invoice_id: { type: 'string', description: 'UUID of the supplier invoice to find candidates for' }, + limit: { type: 'number', description: 'Max candidates to return (default 10, max 50)' }, + }, + required: ['supplier_invoice_id'], + }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { + supplier_invoice_id: { type: 'string' }, + invoice_status: { type: 'string' }, + candidates: { type: 'array', items: { type: 'object' } }, + }, + required: ['supplier_invoice_id', 'candidates'], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, _userId, supabase) { + const supplierInvoiceId = args.supplier_invoice_id as string + if (!supplierInvoiceId) throw new Error('supplier_invoice_id is required') + const limit = Math.min(Math.max(1, Number(args.limit) || 10), 50) + + const { data: invoice, error } = await supabase + .from('supplier_invoices') + .select( + 'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, supplier:suppliers(id, name)' + ) + .eq('id', supplierInvoiceId) + .eq('company_id', companyId) + .single() + if (error || !invoice) throw new Error('Supplier invoice not found') + + if (!['registered', 'approved', 'overdue', 'partially_paid'].includes(invoice.status)) { + return { + supplier_invoice_id: supplierInvoiceId, + invoice_status: invoice.status, + candidates: [], + } + } + + const candidates = await findMatchingVouchersForSupplierInvoice( + supabase, + companyId, + invoice as never, + { limit }, + ) + return { + supplier_invoice_id: supplierInvoiceId, + invoice_status: invoice.status, + candidates, + } + }, + }, + + { + name: 'gnubok_link_supplier_invoice_to_voucher', + description: 'Markera en leverantörsfaktura som betald genom att länka till en befintlig verifikation som redan debiterar leverantörsskuld (2440). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_supplier_invoice först.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + supplier_invoice_id: { type: 'string', description: 'UUID of the supplier invoice to mark paid' }, + journal_entry_id: { type: 'string', description: 'UUID of the existing posted verifikat to link' }, + notes: { type: 'string', description: 'Optional note stored on the supplier_invoice_payments row' }, + }, + required: ['supplier_invoice_id', 'journal_entry_id'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const supplierInvoiceId = args.supplier_invoice_id as string + const journalEntryId = args.journal_entry_id as string + const notes = (args.notes as string | undefined) ?? undefined + if (!supplierInvoiceId || !journalEntryId) { + throw new Error('supplier_invoice_id and journal_entry_id are required') + } + + const { data: invoice, error: invErr } = await supabase + .from('supplier_invoices') + .select( + 'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, supplier:suppliers(id, name)' + ) + .eq('id', supplierInvoiceId) + .eq('company_id', companyId) + .single() + if (invErr || !invoice) throw new Error('Supplier invoice not found') + if (!['registered', 'approved', 'overdue', 'partially_paid'].includes(invoice.status)) { + throw new Error('Supplier invoice is not in a matchable state (must be registered, approved, overdue, or partially_paid)') + } + + const validation = await validateVoucherForSupplierInvoiceLink( + supabase, + companyId, + invoice as never, + journalEntryId, + ) + if (!validation.ok) { + throw new Error( + `${validation.code}${validation.details ? `: ${JSON.stringify(validation.details)}` : ''}`, + ) + } + + const voucherLabel = validation.voucher.voucher_series && validation.voucher.voucher_number != null + ? `${validation.voucher.voucher_series}-${validation.voucher.voucher_number}` + : journalEntryId.slice(0, 8) + + return stagePendingOperation( + supabase, + companyId, + userId, + 'link_supplier_invoice_voucher', + `Länka verifikat ${voucherLabel} → leverantörsfaktura ${invoice.supplier_invoice_number ?? supplierInvoiceId.slice(0, 8)}`, + { supplier_invoice_id: supplierInvoiceId, journal_entry_id: journalEntryId, notes }, + { + supplier_invoice_number: invoice.supplier_invoice_number, + invoice_currency: invoice.currency, + invoice_remaining: invoice.remaining_amount, + voucher_label: voucherLabel, + voucher_date: validation.voucher.entry_date, + voucher_description: validation.voucher.description, + ap_debit_amount: validation.apDebitAmount, + payment_amount: validation.paymentAmount, + will_be_fully_paid: validation.isFullyPaid, + remaining_after: validation.remainingAfter, + supplier_name: (invoice.supplier as unknown as { name?: string } | null)?.name ?? null, + }, + actor, + { + description: 'After approval the supplier invoice transitions to paid (or partially_paid). No new verifikat is created — the existing voucher is the payment posting.', + tool: 'gnubok_get_supplier_ledger', + }, + ) + }, + }, + { name: 'gnubok_auto_match_period', description: "Bulk reconciliation: scan unmatched income transactions in a date range and propose invoice matches with confidence + reasoning. dry_run=true (default) previews without staging; dry_run=false stages every match above confidence_threshold as a pending operation.", diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 6d5bcbf4..a7e63eae 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -355,6 +355,16 @@ export const CreateSupplierInvoiceItemSchema = z.object({ // currency rounding, or POS receipts where supplier-side rounding makes the // VAT off by öre. vat_amount: z.number().min(0).optional(), + // Self-assessed VAT rate for omvänd skattskyldighet (reverse charge). The + // supplier charges no VAT (vat_rate stays 0); this is the Swedish statutory + // rate the buyer self-assesses at — 25% huvudregel default, 12%/6% for + // reduced-rated services (ML 6 kap 34 §). Must be a statutory rate. + reverse_charge_rate: z + .number() + .refine((r) => r === 0.06 || r === 0.12 || r === 0.25, { + message: 'reverse_charge_rate must be 0.06, 0.12, or 0.25', + }) + .optional(), vat_code: z.string().optional(), quantity: z.number().optional(), unit: z.string().optional(), diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 9a817522..290e402d 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -13,8 +13,8 @@ export const API_KEY_SCOPES = { 'customers:write': { label: 'Kunder — skriv', description: 'Skapa kunder (1 verktyg)' }, 'invoices:read': { label: 'Fakturor — läs', description: 'Lista fakturor (1 verktyg)' }, 'invoices:write': { label: 'Fakturor — skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' }, - 'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor (2 verktyg)' }, - 'suppliers:write': { label: 'Leverantörer — skriv', description: 'Godkänn och kreditera leverantörsfakturor (2 verktyg)' }, + 'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor, hitta verifikat-kandidater (3 verktyg)' }, + 'suppliers:write': { label: 'Leverantörer — skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor (6 verktyg)' }, 'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' }, 'bookkeeping:write': { label: 'Bokföring — skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar' }, 'payroll:read': { label: 'Löner — läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' }, @@ -212,6 +212,9 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_credit_supplier_invoice: 'suppliers:write', gnubok_create_supplier_invoice_from_inbox: 'suppliers:write', gnubok_set_inbox_extracted_data: 'suppliers:write', + // Supplier invoice payment via existing verifikat (no new bokföring) + gnubok_find_voucher_candidates_for_supplier_invoice: 'suppliers:read', + gnubok_link_supplier_invoice_to_voucher: 'suppliers:write', // Invoice conversion + crediting gnubok_convert_invoice: 'invoices:write', gnubok_credit_invoice: 'invoices:write', diff --git a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts index d2808047..d3697777 100644 --- a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts +++ b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts @@ -36,9 +36,14 @@ vi.mock('../currency-utils', () => ({ ), })) -// Mock vat-entries with real reverse charge logic -vi.mock('../vat-entries', () => ({ - generateReverseChargeLines: vi.fn().mockImplementation( +// Mock vat-entries: keep the real pure helpers (resolveReverseChargeRate, +// isReverseChargeBasisAccount, RC_BASIS_ACCOUNTS) and stub only the two +// line-builders with simplified logic the assertions below rely on. +vi.mock('../vat-entries', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + generateReverseChargeLines: vi.fn().mockImplementation( (baseAmount: number, vatRate: number = 0.25, isDomestic: boolean = false) => { const vatAmount = Math.round(baseAmount * vatRate * 100) / 100 const inputAccount = isDomestic ? '2647' : '2645' @@ -72,7 +77,8 @@ vi.mock('../vat-entries', () => ({ ] } ), -})) + } +}) const { createJournalEntry, findFiscalPeriod } = await import('../engine') const mockedCreateEntry = vi.mocked(createJournalEntry) @@ -107,6 +113,7 @@ function makeItem(overrides: Partial = {}): SupplierInvoice vat_code: null, vat_rate: vatRate, vat_amount: vatAmount, + reverse_charge_rate: null, created_at: '2024-06-01T00:00:00Z', ...overrides, } @@ -371,6 +378,78 @@ describe('createSupplierInvoiceRegistrationEntry', () => { assertBalanced(input) }) + it('books reverse charge VAT for a 0%-rate line item — defaults to 25% huvudregeln (regression)', async () => { + // The exact reported bug: a Finnish (EU) supplier invoice entered with the + // line at 0% momssats (the supplier charges no VAT) must still self-assess + // at 25%. Before the fix the `rate > 0` guard skipped ALL VAT lines, so the + // verifikat was just expense + 2440 — the user had to add VAT lines by hand. + const invoice = makeSupplierInvoice({ + subtotal: 12000, + vat_amount: 0, + total: 12000, + reverse_charge: true, + }) + const items = [makeItem({ line_total: 12000, account_number: '5910', vat_rate: 0, reverse_charge_rate: null })] + + await createSupplierInvoiceRegistrationEntry( + null as never, 'company-1', 'user-1', invoice, items, 'eu_business' + ) + + const input = mockedCreateEntry.mock.calls[0][3] + expect(findByAccount(input.lines, '5910')[0].debit_amount).toBe(12000) + // Fiktiv moms self-assessed at the 25% huvudregel default (ruta 30 / 48). + expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(3000) + expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(3000) + // Basbeloppsrader for ruta 21 (EU services) — required or SKV rejects FK004. + expect(findByAccount(input.lines, '4535')[0].debit_amount).toBe(12000) + expect(findByAccount(input.lines, '4598')[0].credit_amount).toBe(12000) + // Leverantörsskuld is the net (no VAT rolls into the payable under RC). + expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(12000) + assertBalanced(input) + }) + + it('honours an explicit reverse_charge_rate (12%) on a 0%-rate line item', async () => { + const invoice = makeSupplierInvoice({ + subtotal: 10000, vat_amount: 0, total: 10000, reverse_charge: true, + }) + const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0, reverse_charge_rate: 0.12 })] + + await createSupplierInvoiceRegistrationEntry( + null as never, 'company-1', 'user-1', invoice, items, 'eu_business' + ) + + const input = mockedCreateEntry.mock.calls[0][3] + expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(1200) // 10000 * 0.12 + expect(findByAccount(input.lines, '2624')[0].credit_amount).toBe(1200) // ruta 31 + expect(findByAccount(input.lines, '4536')[0].debit_amount).toBe(10000) // ruta 21 @ 12% + // 25% accounts must NOT appear when the self-assessed rate is 12%. + expect(findByAccount(input.lines, '2614')).toHaveLength(0) + expect(findByAccount(input.lines, '4535')).toHaveLength(0) + assertBalanced(input) + }) + + it('honours an explicit reverse_charge_rate (6%) on a 0%-rate line item', async () => { + const invoice = makeSupplierInvoice({ + subtotal: 10000, vat_amount: 0, total: 10000, reverse_charge: true, + }) + const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0, reverse_charge_rate: 0.06 })] + + await createSupplierInvoiceRegistrationEntry( + null as never, 'company-1', 'user-1', invoice, items, 'eu_business' + ) + + const input = mockedCreateEntry.mock.calls[0][3] + expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(600) // 10000 * 0.06 + expect(findByAccount(input.lines, '2634')[0].credit_amount).toBe(600) // ruta 32 + expect(findByAccount(input.lines, '4537')[0].debit_amount).toBe(10000) // ruta 21 @ 6% + // Higher-rate accounts must NOT appear when the self-assessed rate is 6%. + expect(findByAccount(input.lines, '2614')).toHaveLength(0) + expect(findByAccount(input.lines, '2624')).toHaveLength(0) + expect(findByAccount(input.lines, '4535')).toHaveLength(0) + expect(findByAccount(input.lines, '4536')).toHaveLength(0) + assertBalanced(input) + }) + it('books non-EU services to 4531 (ruta 22) and motkonto 4598', async () => { const invoice = makeSupplierInvoice({ subtotal: 8000, @@ -1038,6 +1117,24 @@ describe('createSupplierInvoiceCashEntry', () => { assertBalanced(input) }) + it('EU reverse charge with a 0%-rate line item self-assesses at 25% (regression)', async () => { + const invoice = makeSupplierInvoice({ + subtotal: 12000, vat_amount: 0, total: 12000, reverse_charge: true, + }) + const items = [makeItem({ line_total: 12000, account_number: '5910', vat_rate: 0, reverse_charge_rate: null })] + + await createSupplierInvoiceCashEntry( + null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'eu_business' + ) + + const input = mockedCreateEntry.mock.calls[0][3] + expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(3000) + expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(3000) + expect(findByAccount(input.lines, '4535')[0].debit_amount).toBe(12000) + expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(12000) + assertBalanced(input) + }) + it('has no 2440 line', async () => { const invoice = makeSupplierInvoice() const items = [makeItem()] @@ -1228,6 +1325,32 @@ describe('createSupplierCreditNoteEntry', () => { assertBalanced(input) }) + it('reverses a 0%-rate reverse charge credit note at the 25% default (regression)', async () => { + // A credit note for the buggy 0%-rate RC invoice must reverse the same + // self-assessed VAT the registration booked, or it leaves ruta 21/30/48 + // half-cancelled. The credit-note path resolves the same 25% default. + const creditNote = makeSupplierInvoice({ + is_credit_note: true, + subtotal: -12000, + vat_amount: 0, + total: -12000, + reverse_charge: true, + }) + const items = [makeItem({ line_total: -12000, account_number: '5910', vat_rate: 0, reverse_charge_rate: null })] + + await createSupplierCreditNoteEntry( + null as never, 'company-1', 'user-1', creditNote, items, 'eu_business' + ) + + const input = mockedCreateEntry.mock.calls[0][3] + expect(findByAccount(input.lines, '2645')[0].credit_amount).toBe(3000) + expect(findByAccount(input.lines, '2614')[0].debit_amount).toBe(3000) + expect(findByAccount(input.lines, '4535')[0].credit_amount).toBe(12000) + expect(findByAccount(input.lines, '4598')[0].debit_amount).toBe(12000) + expect(findByAccount(input.lines, '2440')[0].debit_amount).toBe(12000) + assertBalanced(input) + }) + it('uses Math.abs for all amounts (negative inputs produce positive lines)', async () => { const creditNote = makeSupplierInvoice({ is_credit_note: true, diff --git a/lib/bookkeeping/supplier-invoice-entries.ts b/lib/bookkeeping/supplier-invoice-entries.ts index f747b9e5..3aba7f40 100644 --- a/lib/bookkeeping/supplier-invoice-entries.ts +++ b/lib/bookkeeping/supplier-invoice-entries.ts @@ -1,6 +1,11 @@ import { createJournalEntry, findFiscalPeriod } from './engine' import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils' -import { generateReverseChargeLines, generateReverseChargeBasisLines } from './vat-entries' +import { + generateReverseChargeLines, + generateReverseChargeBasisLines, + isReverseChargeBasisAccount, + resolveReverseChargeRate, +} from './vat-entries' import { createLogger } from '@/lib/logger' import type { SupabaseClient } from '@supabase/supabase-js' import type { @@ -13,29 +18,6 @@ import type { const log = createLogger('supplier-invoice-entries') -/** - * Accounts that already populate momsdeklaration ruta 20-24 directly when - * debited. If the user picked one of these as the expense account on an RC - * invoice item, the engine must NOT add the parallel basbeloppsrader (those - * would double-count the basis). - */ -const RC_BASIS_ACCOUNTS = new Set([ - // ruta 20 — EU goods - '4515', '4516', '4517', - // ruta 21 — EU services - '4535', '4536', '4537', - // ruta 22 — non-EU services - '4531', '4532', '4533', - // ruta 23 — domestic goods RC - '4415', '4416', '4417', - // ruta 24 — domestic services RC - '4425', '4426', '4427', -]) - -function isBasisAccount(account: string): boolean { - return RC_BASIS_ACCOUNTS.has(account) -} - /** * Build a BFL-compliant verifikation description with event type, counterparty, and suffix. * Falls back to prefix + invoiceNumber + suffix if name is not provided (backward compat). @@ -662,9 +644,15 @@ function groupVatByRate( } /** - * Group items by VAT rate and sum the base (line_total) per rate. - * Used by reverse-charge paths to compute fiktiv moms from the basis, - * decoupled from any manual VAT override on the items themselves. + * Group items by their self-assessed reverse-charge rate and sum the base + * (line_total) per rate. Used by reverse-charge paths to compute fiktiv moms + * from the basis, decoupled from any manual VAT override on the items. + * + * The grouping key is the *self-assessed* rate (resolveReverseChargeRate), not + * the line's vat_rate: under omvänd skattskyldighet the supplier charges 0%, so + * the line vat_rate is 0, but the buyer self-assesses at 25% (huvudregeln) or + * the explicit per-item reverse_charge_rate. Without this a 0%-rate RC line + * would key on rate 0 and the `rate > 0` guard below would skip its VAT lines. */ function groupBaseByRate( items: SupplierInvoiceItem[], @@ -674,7 +662,7 @@ function groupBaseByRate( ): Map { const baseByRate = new Map() for (const item of items) { - const rate = item.vat_rate ?? 0.25 + const rate = resolveReverseChargeRate(item) let baseSek = resolveSekAmount(item.line_total, null, currency, exchangeRate) if (useAbsoluteValues) baseSek = Math.abs(baseSek) baseByRate.set(rate, (baseByRate.get(rate) || 0) + baseSek) @@ -696,8 +684,8 @@ function groupNonBasisBaseByRate( ): Map { const baseByRate = new Map() for (const item of items) { - if (isBasisAccount(item.account_number)) continue - const rate = item.vat_rate ?? 0.25 + if (isReverseChargeBasisAccount(item.account_number)) continue + const rate = resolveReverseChargeRate(item) let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate) if (useAbsoluteValues) itemSek = Math.abs(itemSek) baseByRate.set(rate, (baseByRate.get(rate) || 0) + itemSek) diff --git a/lib/bookkeeping/vat-entries.ts b/lib/bookkeeping/vat-entries.ts index 47d23aba..57e05050 100644 --- a/lib/bookkeeping/vat-entries.ts +++ b/lib/bookkeeping/vat-entries.ts @@ -38,6 +38,57 @@ export function getVatRate(treatment: VatTreatment): number { } } +/** + * Expense/basis accounts that already populate momsdeklaration ruta 20-24 + * directly when debited (the basbelopp for a reverse-charge purchase). If an RC + * item is booked straight to one of these, the engine must NOT add the parallel + * basbeloppsrader — that would double-count ruta 20-24. + * + * ruta 20 EU goods 4515/4516/4517 + * ruta 21 EU services 4535/4536/4537 + * ruta 22 non-EU services 4531/4532/4533 + * ruta 23 domestic goods RC 4415/4416/4417 + * ruta 24 domestic services RC 4425/4426/4427 + */ +export const RC_BASIS_ACCOUNTS: ReadonlySet = new Set([ + '4515', '4516', '4517', + '4535', '4536', '4537', + '4531', '4532', '4533', + '4415', '4416', '4417', + '4425', '4426', '4427', +]) + +export function isReverseChargeBasisAccount(account: string): boolean { + return RC_BASIS_ACCOUNTS.has(account) +} + +/** + * The self-assessed VAT rate to apply to a reverse-charge line. + * + * Under omvänd skattskyldighet the supplier charges no VAT, so the line's own + * `vat_rate` is 0 (the v1 supplier-invoice API mandates this). The buyer must + * still self-assess output + input VAT at the Swedish statutory rate that would + * apply to the service domestically — 25% under huvudregeln for EU services + * (ML 6 kap 34 §), 12%/6% for reduced-rated services. Resolution order: + * + * 1. explicit per-item `reverse_charge_rate` (the UI's self-assessment picker) + * 2. a positive `vat_rate` on the line (legacy/API callers that encoded the + * self-assessment rate directly on vat_rate) + * 3. 25% huvudregel default — never silently drop the fiktiv-moms lines. + * + * Keeping this in one place means the booking engine and the review-dialog + * preview can never drift. The original bug was two independent copies of a + * `rate > 0` assumption, each skipping the VAT entirely on a 0%-rate RC line. + */ +export function resolveReverseChargeRate( + item: { vat_rate?: number | null; reverse_charge_rate?: number | null }, +): number { + const explicit = item.reverse_charge_rate + if (explicit != null && explicit > 0) return explicit + if (item.vat_rate != null && item.vat_rate > 0) return item.vat_rate + return 0.25 +} + /** * Generate output VAT lines for sales invoices * Debit 1510 Kundfordringar [total incl VAT] diff --git a/lib/invoices/__tests__/bulk-reconcile-supplier-vouchers.test.ts b/lib/invoices/__tests__/bulk-reconcile-supplier-vouchers.test.ts new file mode 100644 index 00000000..a70ae944 --- /dev/null +++ b/lib/invoices/__tests__/bulk-reconcile-supplier-vouchers.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the two dependencies so we test the ORCHESTRATION logic (confidence +// gating, near-tie margin, cross-invoice voucher exclusivity, consumed-voucher +// filtering) in isolation. The matcher + RPC link are exercised by their own +// suites (supplier-voucher-matching.test.ts / .pg.test.ts). +vi.mock('@/lib/supabase/fetch-all', () => ({ fetchAllRows: vi.fn() })) +vi.mock('../supplier-voucher-matching', () => ({ + findMatchingVouchersForSupplierInvoice: vi.fn(), + linkSupplierInvoiceToVoucher: vi.fn(), +})) + +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + findMatchingVouchersForSupplierInvoice, + linkSupplierInvoiceToVoucher, +} from '../supplier-voucher-matching' +import { reconcileSupplierInvoiceVouchers } from '../bulk-reconcile-supplier-vouchers' + +const mFetchAll = vi.mocked(fetchAllRows) +const mFind = vi.mocked(findMatchingVouchersForSupplierInvoice) +const mLink = vi.mocked(linkSupplierInvoiceToVoucher) + +interface InvOver { + id: string + number?: string + status?: string + total?: number + remaining?: number + due?: string + isCredit?: boolean +} + +function inv(over: InvOver) { + const total = over.total ?? 1000 + return { + id: over.id, + supplier_invoice_number: over.number ?? `F-${over.id}`, + arrival_number: 1, + status: over.status ?? 'overdue', + currency: 'SEK', + total, + paid_amount: 0, + remaining_amount: over.remaining ?? total, + due_date: over.due ?? '2026-02-01', + paid_at: null, + exchange_rate: null, + supplier_id: 's1', + is_credit_note: over.isCredit ?? false, + supplier: { id: 's1', name: 'Leverantör AB' }, + } +} + +function cand(over: { je: string; confidence?: number; amount?: number; n?: number }) { + return { + journal_entry_id: over.je, + voucher_series: 'A', + voucher_number: over.n ?? 1, + entry_date: '2026-02-01', + description: 'Leverantörsbetalning', + ap_debit_amount: over.amount ?? 1000, + currency: 'SEK', + ap_line_currency: 'SEK', + period_locked: false, + confidence: over.confidence ?? 0.95, + match_reason: 'test', + } +} + +/** Queue the two fetchAllRows reads: invoices, then existing payments. */ +function queue(invoices: unknown[], payments: { journal_entry_id: string | null }[] = []) { + mFetchAll.mockReset() + mFetchAll.mockResolvedValueOnce(invoices as never).mockResolvedValueOnce(payments as never) +} + +const okLink = (over: { paymentAmount?: number; status?: 'paid' | 'partially_paid'; je: string }) => ({ + ok: true as const, + result: { + paymentId: 'p1', + invoiceStatus: over.status ?? ('paid' as const), + paidAmount: 1000, + remainingAmount: 0, + paymentAmount: over.paymentAmount ?? 1000, + journalEntryId: over.je, + }, +}) + +const run = () => + reconcileSupplierInvoiceVouchers({ supabase: {} as never, companyId: 'c1', userId: 'u1' }) + +describe('reconcileSupplierInvoiceVouchers', () => { + beforeEach(() => { + vi.clearAllMocks() + mFind.mockReset() + mLink.mockReset() + }) + + it('auto-links a single unambiguous exact match and marks it paid', async () => { + queue([inv({ id: 'i1', remaining: 1000 })]) + mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never) + mLink.mockResolvedValueOnce(okLink({ je: 'v1' }) as never) + + const res = await run() + + expect(res.scanned).toBe(1) + expect(res.autoLinked).toBe(1) + expect(res.ambiguous).toBe(0) + expect(res.unmatched).toBe(0) + expect(res.links).toHaveLength(1) + expect(res.links[0]).toMatchObject({ journal_entry_id: 'v1', invoice_status: 'paid' }) + expect(mLink).toHaveBeenCalledTimes(1) + expect(mLink).toHaveBeenCalledWith({} , 'u1', 'c1', expect.objectContaining({ + supplierInvoiceId: 'i1', + journalEntryId: 'v1', + })) + }) + + it('does not auto-link a below-threshold (amount-only) candidate', async () => { + queue([inv({ id: 'i1' })]) + mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.8, amount: 1000 })] as never) + + const res = await run() + + expect(res.autoLinked).toBe(0) + expect(res.ambiguous).toBe(1) + expect(res.review[0]).toMatchObject({ supplier_invoice_id: 'i1', reason: 'low_confidence' }) + expect(mLink).not.toHaveBeenCalled() + }) + + it('does not auto-link when the top two candidates are within the margin', async () => { + queue([inv({ id: 'i1' })]) + mFind.mockResolvedValueOnce([ + cand({ je: 'v1', confidence: 0.95, amount: 1000 }), + cand({ je: 'v2', confidence: 0.95, amount: 1000, n: 2 }), + ] as never) + + const res = await run() + + expect(res.autoLinked).toBe(0) + expect(res.ambiguous).toBe(1) + expect(res.review[0].reason).toBe('multiple_candidates') + expect(mLink).not.toHaveBeenCalled() + }) + + it('demotes BOTH invoices when one voucher is the top pick for two of them', async () => { + queue([inv({ id: 'i1', remaining: 1000 }), inv({ id: 'i2', remaining: 1000 })]) + // Each invoice has exactly one strong candidate — but it is the SAME voucher. + mFind + .mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never) + .mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never) + + const res = await run() + + expect(res.autoLinked).toBe(0) + expect(res.ambiguous).toBe(2) + expect(res.review.every((r) => r.reason === 'voucher_contested')).toBe(true) + expect(mLink).not.toHaveBeenCalled() + }) + + it('excludes a voucher already consumed as a payment on another invoice', async () => { + queue([inv({ id: 'i1' })], [{ journal_entry_id: 'v1' }]) + mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.99, amount: 1000 })] as never) + + const res = await run() + + expect(res.unmatched).toBe(1) + expect(res.autoLinked).toBe(0) + expect(mLink).not.toHaveBeenCalled() + }) + + it('routes a candidate whose AP debit exceeds the remaining to review', async () => { + queue([inv({ id: 'i1', total: 1000, remaining: 500 })]) + mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.99, amount: 1000 })] as never) + + const res = await run() + + expect(res.autoLinked).toBe(0) + expect(res.review[0].reason).toBe('amount_exceeds_remaining') + expect(mLink).not.toHaveBeenCalled() + }) + + it('dryRun produces the plan without writing', async () => { + queue([inv({ id: 'i1', remaining: 1000 })]) + mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never) + + const res = await reconcileSupplierInvoiceVouchers({ + supabase: {} as never, + companyId: 'c1', + userId: 'u1', + dryRun: true, + }) + + expect(res.autoLinked).toBe(1) + expect(res.links[0]).toMatchObject({ journal_entry_id: 'v1', invoice_status: 'paid' }) + expect(mLink).not.toHaveBeenCalled() + }) + + it('skips credit notes and zero-remaining invoices entirely', async () => { + queue([inv({ id: 'i1', isCredit: true }), inv({ id: 'i2', remaining: 0 })]) + + const res = await run() + + expect(res.scanned).toBe(0) + expect(res.autoLinked).toBe(0) + expect(mFind).not.toHaveBeenCalled() + }) + + it('surfaces an RPC rejection as review rather than a successful link', async () => { + queue([inv({ id: 'i1', remaining: 1000 })]) + mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never) + mLink.mockResolvedValueOnce({ ok: false, code: 'LINK_SI_VOUCHER_ALREADY_LINKED' } as never) + + const res = await run() + + expect(res.autoLinked).toBe(0) + expect(res.ambiguous).toBe(1) + expect(res.review[0].reason).toBe('voucher_contested') + }) +}) diff --git a/lib/invoices/bulk-reconcile-supplier-vouchers.ts b/lib/invoices/bulk-reconcile-supplier-vouchers.ts new file mode 100644 index 00000000..d40ee053 --- /dev/null +++ b/lib/invoices/bulk-reconcile-supplier-vouchers.ts @@ -0,0 +1,370 @@ +/** + * Bulk reconcile supplier invoices to already-posted GL payment vouchers. + * + * Context: when a company is migrated from another system (e.g. Fortnox via the + * arcim-migration extension), the general ledger — including the bank-payment + * vouchers that settle accounts payable (Dr 2440 / Cr 1930) — is imported + * separately via SIE. Supplier invoices are imported as standalone + * `supplier_invoices` rows with NO link to those vouchers (the entity mapper + * never sets `payment_journal_entry_id`). Fortnox is queried with + * `?filter=unpaid`, so an invoice whose payment was booked in the source GL but + * never registered against the leverantörsfaktura object arrives here as an + * open payable. Once its due date passes the nightly cron flips it to + * `overdue` — even though the settling voucher already exists in the GL. + * + * This pass links each open payable to its matching posted voucher (reusing the + * exact same matcher + RPC behind the manual "Markera som betald → Befintlig + * verifikation" UI flow), so genuinely-settled invoices show as paid instead of + * falsely overdue. It NEVER creates, edits, or deletes a journal entry — it only + * inserts a `supplier_invoice_payments` row pointing at the existing voucher and + * advances the invoice's paid/remaining/status (all via the atomic + * `link_supplier_invoice_to_voucher` RPC). + * + * Safety: auto-linking is intentionally conservative. A voucher is linked + * automatically only when the match is unambiguous (see AUTO_LINK_* constants + * and the uniqueness rules below). Everything else is surfaced for manual review + * rather than guessed at. The function is idempotent and order-independent — it + * can be re-run any time after both halves of a migration exist. + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + findMatchingVouchersForSupplierInvoice, + linkSupplierInvoiceToVoucher, + type SupplierVoucherCandidate, +} from './supplier-voucher-matching' +import type { SupplierInvoice, Supplier } from '@/types' + +const log = createLogger('bulk-reconcile-supplier-vouchers') + +/** + * Minimum confidence for an UNATTENDED auto-link. 0.95 = OCR/invoice-number hit + * (0.99) or exact-remaining-amount AND supplier-name corroboration (0.95). + * Amount-only matches (0.80, even with the +0.05 date bump → 0.85) are + * deliberately excluded — too many invoices share round amounts. + */ +const AUTO_LINK_MIN_CONFIDENCE = 0.95 +/** + * Required confidence gap between the top candidate and the runner-up. A near + * tie means two vouchers look equally plausible → not safe to auto-pick. A + * margin (not exact equality) absorbs the ±0.05 date-proximity perturbation. + */ +const AUTO_LINK_MIN_MARGIN = 0.1 +/** 0.5 öre — mirrors the tolerance used across the matching/RPC paths. */ +const AMOUNT_TOLERANCE = 0.005 +/** Safety cap on invoices processed in a single run (Vercel 300s budget). */ +const DEFAULT_MAX_INVOICES = 2000 + +/** Supplier-invoice statuses that represent an open payable. */ +const PAYABLE_STATUSES = ['registered', 'approved', 'overdue', 'partially_paid'] + +type ReconcileInvoiceRow = SupplierInvoice & { + is_credit_note?: boolean | null + supplier?: { id: string; name: string } | null +} + +export type ReconcileReviewReason = + | 'multiple_candidates' // ≥2 candidates within the auto-link margin + | 'low_confidence' // best candidate below AUTO_LINK_MIN_CONFIDENCE + | 'amount_exceeds_remaining' // best candidate would overpay the invoice + | 'voucher_contested' // one voucher is the top pick for >1 invoice, or RPC rejected + +export interface ReconcileLink { + supplier_invoice_id: string + supplier_invoice_number: string | null + journal_entry_id: string + payment_amount: number + invoice_status: 'paid' | 'partially_paid' + confidence: number + match_reason: string +} + +export interface ReconcileReviewItem { + supplier_invoice_id: string + supplier_invoice_number: string | null + reason: ReconcileReviewReason + candidates: SupplierVoucherCandidate[] +} + +export interface ReconcileResult { + /** Open payables considered (after credit-note / zero-remaining filtering). */ + scanned: number + /** Invoices auto-linked to a voucher (or that would be, when dryRun). */ + autoLinked: number + /** Invoices with candidate(s) but not safe to auto-link — need manual review. */ + ambiguous: number + /** Invoices with no eligible voucher candidate at all. */ + unmatched: number + /** True when more payables existed than `maxInvoices` and the rest were skipped. */ + capped: boolean + links: ReconcileLink[] + review: ReconcileReviewItem[] +} + +export interface ReconcileOptions { + supabase: SupabaseClient + companyId: string + /** Real user id — written onto the supplier_invoice_payments row + emitted event. */ + userId: string + /** Compute the plan without writing. Default false. */ + dryRun?: boolean + /** Max invoices to process in one run. Default 2000. */ + maxInvoices?: number + onProgress?: (done: number, total: number) => void +} + +const SELECT_COLUMNS = + 'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, is_credit_note, supplier:suppliers(id, name)' + +function remainingOf(inv: ReconcileInvoiceRow): number { + if (typeof inv.remaining_amount === 'number') return Math.max(0, inv.remaining_amount) + return Math.max(0, Math.round((inv.total - (inv.paid_amount ?? 0)) * 100) / 100) +} + +/** + * Link open supplier-invoice payables to their matching already-posted GL + * vouchers. See file header for the full rationale and guarantees. + */ +export async function reconcileSupplierInvoiceVouchers( + opts: ReconcileOptions, +): Promise { + const { supabase, companyId, userId, dryRun = false } = opts + const maxInvoices = opts.maxInvoices ?? DEFAULT_MAX_INVOICES + + const result: ReconcileResult = { + scanned: 0, + autoLinked: 0, + ambiguous: 0, + unmatched: 0, + capped: false, + links: [], + review: [], + } + + // 1. Open payables with an outstanding balance, excluding credit notes. + // Deterministic order so re-runs and the cross-invoice uniqueness pass are + // stable. Fully-paid invoices ('paid') are excluded by the status filter, + // making re-runs naturally idempotent. + const invoices = await fetchAllRows( + ({ from, to }) => + supabase + .from('supplier_invoices') + .select(SELECT_COLUMNS) + .eq('company_id', companyId) + .in('status', PAYABLE_STATUSES) + .order('due_date', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to) as unknown as PromiseLike<{ + // The `supplier:suppliers(id, name)` join makes PostgREST infer `supplier` + // as an array; ReconcileInvoiceRow models the runtime single-object shape. + data: ReconcileInvoiceRow[] | null + error: { message: string } | null + }>, + ) + + const payables = invoices.filter( + (inv) => !inv.is_credit_note && remainingOf(inv) > AMOUNT_TOLERANCE, + ) + + const toProcess = payables.slice(0, maxInvoices) + if (payables.length > maxInvoices) { + result.capped = true + log.warn('reconcile capped to maxInvoices — remaining payables left for a later run', { + companyId, + totalPayables: payables.length, + cap: maxInvoices, + }) + } + + // 2. Pre-load every voucher already consumed as a supplier payment (for ANY + // invoice in the company). Neither the matcher nor the RPC stop the SAME + // voucher being linked to a SECOND invoice, so we enforce exclusivity here. + const existingPayments = await fetchAllRows<{ journal_entry_id: string | null }>(({ from, to }) => + supabase + .from('supplier_invoice_payments') + .select('journal_entry_id') + .eq('company_id', companyId) + .not('journal_entry_id', 'is', null) + .range(from, to), + ) + const consumedVouchers = new Set( + existingPayments + .map((p) => p.journal_entry_id) + .filter((id): id is string => !!id), + ) + + // 3. Per-invoice candidate gathering (read-only). Decide auto-eligibility. + interface Plan { + invoice: ReconcileInvoiceRow + candidates: SupplierVoucherCandidate[] + top?: SupplierVoucherCandidate + } + const autoCandidatePlans: Plan[] = [] + + for (const invoice of toProcess) { + result.scanned++ + const candidates = await findMatchingVouchersForSupplierInvoice( + supabase, + companyId, + invoice as unknown as SupplierInvoice & { supplier?: Supplier }, + { limit: 5 }, + ) + // Drop vouchers already used elsewhere in the company. + const fresh = candidates.filter((c) => !consumedVouchers.has(c.journal_entry_id)) + + if (fresh.length === 0) { + result.unmatched++ + continue + } + + const top = fresh[0] + const runnerUp = fresh[1] + const remaining = remainingOf(invoice) + + const confidentEnough = top.confidence >= AUTO_LINK_MIN_CONFIDENCE + const clearMargin = !runnerUp || top.confidence - runnerUp.confidence >= AUTO_LINK_MIN_MARGIN + // The RPC rejects a voucher whose AP debit exceeds the remaining amount; an + // OCR match (which ignores amount) could trip this, so screen it out here. + const amountFits = top.ap_debit_amount <= remaining + AMOUNT_TOLERANCE + + if (confidentEnough && clearMargin && amountFits) { + autoCandidatePlans.push({ invoice, candidates: fresh, top }) + } else { + result.ambiguous++ + result.review.push({ + supplier_invoice_id: invoice.id, + supplier_invoice_number: invoice.supplier_invoice_number ?? null, + reason: !confidentEnough + ? 'low_confidence' + : !amountFits + ? 'amount_exceeds_remaining' + : 'multiple_candidates', + candidates: fresh, + }) + } + } + + // 4. Cross-invoice uniqueness: if one voucher is the top auto-pick for more + // than one invoice (e.g. two identical 5 000 kr invoices both grabbing the + // same 5 000 kr voucher), auto-link NONE of them — demote all to review. + const claimsByVoucher = new Map() + for (const plan of autoCandidatePlans) { + const key = plan.top!.journal_entry_id + const arr = claimsByVoucher.get(key) ?? [] + arr.push(plan) + claimsByVoucher.set(key, arr) + } + + const safePlans: Plan[] = [] + for (const claimants of claimsByVoucher.values()) { + if (claimants.length === 1) { + safePlans.push(claimants[0]) + } else { + for (const c of claimants) { + result.ambiguous++ + result.review.push({ + supplier_invoice_id: c.invoice.id, + supplier_invoice_number: c.invoice.supplier_invoice_number ?? null, + reason: 'voucher_contested', + candidates: c.candidates, + }) + } + } + } + + // 5. Link the unambiguous plans. Deterministic order; respect exclusivity + // across the batch via consumedVouchers. + safePlans.sort( + (a, b) => + (a.invoice.due_date ?? '').localeCompare(b.invoice.due_date ?? '') || + a.invoice.id.localeCompare(b.invoice.id), + ) + + let done = 0 + for (const plan of safePlans) { + const top = plan.top! + const remaining = remainingOf(plan.invoice) + + // Defensive: a voucher consumed earlier in THIS batch is off-limits. + if (consumedVouchers.has(top.journal_entry_id)) { + result.ambiguous++ + result.review.push({ + supplier_invoice_id: plan.invoice.id, + supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null, + reason: 'voucher_contested', + candidates: plan.candidates, + }) + continue + } + + if (dryRun) { + const willBeFullyPaid = top.ap_debit_amount >= remaining - AMOUNT_TOLERANCE + result.autoLinked++ + result.links.push({ + supplier_invoice_id: plan.invoice.id, + supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null, + journal_entry_id: top.journal_entry_id, + payment_amount: Math.min(top.ap_debit_amount, remaining), + invoice_status: willBeFullyPaid ? 'paid' : 'partially_paid', + confidence: top.confidence, + match_reason: top.match_reason, + }) + consumedVouchers.add(top.journal_entry_id) + done++ + opts.onProgress?.(done, safePlans.length) + continue + } + + const outcome = await linkSupplierInvoiceToVoucher(supabase, userId, companyId, { + supplierInvoiceId: plan.invoice.id, + journalEntryId: top.journal_entry_id, + notes: `Auto-länkad vid avstämning (${Math.round(top.confidence * 100)}% säkerhet): ${top.match_reason}`, + }) + + if (outcome.ok) { + result.autoLinked++ + consumedVouchers.add(top.journal_entry_id) + result.links.push({ + supplier_invoice_id: plan.invoice.id, + supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null, + journal_entry_id: top.journal_entry_id, + payment_amount: outcome.result.paymentAmount, + invoice_status: outcome.result.invoiceStatus, + confidence: top.confidence, + match_reason: top.match_reason, + }) + } else { + // The RPC re-validates atomically; a rejection here (race, already-linked, + // amount drift) means it isn't a clean auto-link — surface it. + result.ambiguous++ + result.review.push({ + supplier_invoice_id: plan.invoice.id, + supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null, + reason: 'voucher_contested', + candidates: plan.candidates, + }) + log.warn('auto-link rejected by RPC', { + companyId, + supplierInvoiceId: plan.invoice.id, + journalEntryId: top.journal_entry_id, + code: outcome.code, + }) + } + + done++ + opts.onProgress?.(done, safePlans.length) + } + + log.info('reconcile complete', { + companyId, + dryRun, + scanned: result.scanned, + autoLinked: result.autoLinked, + ambiguous: result.ambiguous, + unmatched: result.unmatched, + capped: result.capped, + }) + + return result +} diff --git a/lib/pending-operations/__tests__/link-supplier-invoice-voucher.test.ts b/lib/pending-operations/__tests__/link-supplier-invoice-voucher.test.ts new file mode 100644 index 00000000..0364193e --- /dev/null +++ b/lib/pending-operations/__tests__/link-supplier-invoice-voucher.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for commitLinkSupplierInvoiceVoucher, driven through the public + * commitPendingOperation dispatcher. + * + * The MCP tool gnubok_link_supplier_invoice_to_voucher stages a + * 'link_supplier_invoice_voucher' pending_operation; this dispatcher picks it up + * and the executor delegates to linkSupplierInvoiceToVoucher (the atomic + * link_supplier_invoice_to_voucher RPC). The RPC itself is covered by + * lib/invoices/__tests__/supplier-voucher-matching{,.pg}.test.ts — these tests + * focus on the dispatcher/executor wiring + status mapping. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase, makeSupplierInvoice } from '@/tests/helpers' +import type { PendingOperation } from '@/types' + +import { commitPendingOperation } from '../commit' + +const SI_UUID = '550e8400-e29b-41d4-a716-446655440010' +const JE_UUID = '550e8400-e29b-41d4-a716-446655440011' + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'link_supplier_invoice_voucher', + status: 'pending', + title: 'test', + params: {}, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'medium', + created_at: '2026-06-01T00:00:00Z', + resolved_at: null, + updated_at: '2026-06-01T00:00:00Z', + ...overrides, + } as PendingOperation +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +describe('commitPendingOperation: link_supplier_invoice_voucher', () => { + it('returns 400 when supplier_invoice_id is missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ params: { journal_entry_id: JE_UUID } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/supplier_invoice_id/i) + }) + + it('happy path: links the verifikat and marks the supplier invoice paid', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + vi.spyOn(eventBus, 'emit').mockResolvedValue(undefined) + + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + // executor -> linkSupplierInvoiceToVoucher -> RPC + enqueue({ + data: { + ok: true, + payment_id: 'sip-1', + invoice_status: 'paid', + paid_amount: 1000, + remaining_amount: 0, + payment_amount: 1000, + journal_entry_id: JE_UUID, + currency: 'SEK', + }, + error: null, + }) + // post-link invoice re-fetch for the event payload + enqueue({ + data: makeSupplierInvoice({ id: SI_UUID, status: 'paid', total: 1000, remaining_amount: 0 }), + error: null, + }) + enqueue({ data: null, error: null }) // dispatcher commit update + + const op = makePendingOp({ params: { supplier_invoice_id: SI_UUID, journal_entry_id: JE_UUID } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + invoice_status: 'paid', + paid_amount: 1000, + remaining_amount: 0, + payment_amount: 1000, + payment_id: 'sip-1', + journal_entry_id: JE_UUID, + }) + }) + + it('auto-rejects with 404 when the RPC reports the invoice is gone', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { ok: false, code: 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND' }, error: null }) // RPC + enqueue({ data: null, error: null }) // dispatcher's auto-reject update + + const op = makePendingOp({ params: { supplier_invoice_id: SI_UUID, journal_entry_id: JE_UUID } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(404) + }) + + it('auto-rejects with 409 when the verifikat is already linked', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { ok: false, code: 'LINK_SI_VOUCHER_ALREADY_LINKED' }, error: null }) // RPC + enqueue({ data: null, error: null }) // dispatcher's auto-reject update + + const op = makePendingOp({ params: { supplier_invoice_id: SI_UUID, journal_entry_id: JE_UUID } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index ed7c67a7..b8eecfee 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -40,6 +40,7 @@ import { createSupplierInvoiceRegistrationEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching' +import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching' import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry' import { getErrorEntry } from '@/lib/errors/structured-errors' import { parseSIEFile } from '@/lib/import/sie-parser' @@ -1038,6 +1039,48 @@ async function commitLinkInvoiceVoucher( } } +async function commitLinkSupplierInvoiceVoucher( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const supplierInvoiceId = params.supplier_invoice_id as string | undefined + const journalEntryId = params.journal_entry_id as string | undefined + const notes = (params.notes as string | undefined) ?? undefined + + if (!supplierInvoiceId || !journalEntryId) { + return { error: 'supplier_invoice_id and journal_entry_id are required', status: 400 } + } + + const outcome = await linkSupplierInvoiceToVoucher(supabase, userId, companyId, { + supplierInvoiceId, + journalEntryId, + notes, + }) + + if (!outcome.ok) { + const entry = getErrorEntry(outcome.code) + // 404/409 are auto-rejected by the dispatcher (the user can re-stage with + // adjusted inputs); 400 surfaces as a normal failure so the UI can explain. + return { + error: entry?.message_en ?? outcome.code, + status: entry?.httpStatus ?? 500, + } + } + + return { + data: { + invoice_status: outcome.result.invoiceStatus, + paid_amount: outcome.result.paidAmount, + remaining_amount: outcome.result.remainingAmount, + payment_amount: outcome.result.paymentAmount, + payment_id: outcome.result.paymentId, + journal_entry_id: outcome.result.journalEntryId, + }, + } +} + // ── Stream 1 Phase 1 + follow-up executors ─────────────────────── async function commitClosePeriod( @@ -1614,6 +1657,11 @@ async function commitCreateSupplierInvoiceFromInbox( vat_code: null, vat_rate: vatRate, vat_amount: vatAmt, + // For reverse charge the buyer self-assesses VAT; carry an explicit + // statutory rate when staged, else null (engine defaults to 25%). + reverse_charge_rate: reverseCharge + ? ([0.06, 0.12, 0.25].includes(Number(item.reverse_charge_rate)) ? Number(item.reverse_charge_rate) : null) + : null, } }) @@ -2909,6 +2957,9 @@ export async function commitPendingOperation( case 'link_invoice_voucher': result = await commitLinkInvoiceVoucher(supabase, userId, companyId, pendingOp.params) break + case 'link_supplier_invoice_voucher': + result = await commitLinkSupplierInvoiceVoucher(supabase, userId, companyId, pendingOp.params) + break case 'close_period': result = await commitClosePeriod(supabase, userId, companyId, pendingOp.params) break diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index bffa6a08..97fdd182 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -30,6 +30,11 @@ export const OPERATION_RISK_TIERS: Record = { // entry is created or modified. Sits next to match_transaction_invoice // semantically — both attach an existing booking to an invoice. link_invoice_voucher: 'medium', + // Supplier-side mirror of link_invoice_voucher: link an existing posted + // verifikat (Dr 2440) as payment for a leverantörsfaktura. Reversible by + // deleting the supplier_invoice_payments row and reverting status; no journal + // entry is created or modified. + link_supplier_invoice_voucher: 'medium', create_invoice: 'medium', // creates as draft; sending is a separate op create_transaction: 'medium', // ingests an uncategorized row; reversible by delete // Supplier master data carries payment-routing fields (IBAN, BIC, bankgiro, diff --git a/lib/providers/fortnox/__tests__/mapper-payment-status.test.ts b/lib/providers/fortnox/__tests__/mapper-payment-status.test.ts new file mode 100644 index 00000000..bf4a4910 --- /dev/null +++ b/lib/providers/fortnox/__tests__/mapper-payment-status.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest' +import { mapFortnoxToSupplierInvoice, mapFortnoxToSalesInvoice } from '../mapper' + +/** + * Guards the paid-status hardening: deriveInvoiceStatus and paymentStatus.paid + * share one isFullyPaid() source of truth, so status === 'paid' iff + * paymentStatus.paid (for non-cancelled / non-credit rows). An ABSENT Balance + * must never be read as paid — on either the supplier OR the sales path. + */ + +function supplierRaw(over: Record): Record { + return { + GivenNumber: '100', + Total: 1000, + InvoiceDate: '2026-01-10', + DueDate: '2026-02-10', + SupplierName: 'Leverantör AB', + Booked: true, + ...over, + } +} + +function salesRaw(over: Record): Record { + return { + DocumentNumber: '200', + Total: 1000, + InvoiceDate: '2026-01-10', + DueDate: '2026-02-10', + CustomerName: 'Kund AB', + Sent: true, + ...over, + } +} + +describe('Fortnox mapper — paid-status consistency', () => { + it('supplier: absent Balance is NOT paid (defaults to unpaid, not 0)', () => { + const dto = mapFortnoxToSupplierInvoice(supplierRaw({})) // no Balance key + expect(dto.status).toBe('booked') + expect(dto.paymentStatus.paid).toBe(false) + expect(dto.paymentStatus.balance.value).toBe(1000) + }) + + it('supplier: Balance 0 → paid and status paid', () => { + const dto = mapFortnoxToSupplierInvoice(supplierRaw({ Balance: 0 })) + expect(dto.status).toBe('paid') + expect(dto.paymentStatus.paid).toBe(true) + }) + + it('supplier: positive Balance → unpaid', () => { + const dto = mapFortnoxToSupplierInvoice(supplierRaw({ Balance: 250 })) + expect(dto.status).toBe('booked') + expect(dto.paymentStatus.paid).toBe(false) + expect(dto.paymentStatus.balance.value).toBe(250) + }) + + it('supplier: FullyPaid flag with absent Balance keeps status and paid CONSISTENT', () => { + // Previously deriveInvoiceStatus said paid while paymentStatus.paid said unpaid. + const dto = mapFortnoxToSupplierInvoice(supplierRaw({ FullyPaid: true })) + expect(dto.status).toBe('paid') + expect(dto.paymentStatus.paid).toBe(true) + // paid ⇒ no outstanding balance, even though the raw payload omits Balance + // (previously balance fell back to the full total, contradicting paid=true). + expect(dto.paymentStatus.balance.value).toBe(0) + }) + + it('sales: absent Balance is NOT paid (no false-paid on the sales path)', () => { + const dto = mapFortnoxToSalesInvoice(salesRaw({})) // no Balance key + expect(dto.status).toBe('sent') + expect(dto.paymentStatus.paid).toBe(false) + expect(dto.paymentStatus.balance.value).toBe(1000) + }) + + it('sales: Balance 0 → paid and status paid', () => { + const dto = mapFortnoxToSalesInvoice(salesRaw({ Balance: 0 })) + expect(dto.status).toBe('paid') + expect(dto.paymentStatus.paid).toBe(true) + }) + + it('sales: FullyPaid flag with absent Balance → paid with zero balance', () => { + const dto = mapFortnoxToSalesInvoice(salesRaw({ FullyPaid: true })) + expect(dto.status).toBe('paid') + expect(dto.paymentStatus.paid).toBe(true) + expect(dto.paymentStatus.balance.value).toBe(0) + }) + + it('status === paid iff paymentStatus.paid across a matrix (both paths)', () => { + const balances = [undefined, 0, 0.004, 250, 1000] + const flags = [undefined, true] + for (const Balance of balances) { + for (const FullyPaid of flags) { + const over: Record = { FullyPaid } + if (Balance !== undefined) over.Balance = Balance + for (const dto of [ + mapFortnoxToSupplierInvoice(supplierRaw(over)), + mapFortnoxToSalesInvoice(salesRaw(over)), + ]) { + expect( + dto.status === 'paid', + `Balance=${Balance} FullyPaid=${FullyPaid}`, + ).toBe(dto.paymentStatus.paid) + // Invariant: paid ⇒ balance zeroed (never "fully paid yet full balance"). + if (dto.paymentStatus.paid) { + expect( + dto.paymentStatus.balance.value, + `Balance=${Balance} FullyPaid=${FullyPaid}`, + ).toBe(0) + } + } + } + } + }) +}) diff --git a/lib/providers/fortnox/mapper.ts b/lib/providers/fortnox/mapper.ts index 73ab21a3..95a061b6 100644 --- a/lib/providers/fortnox/mapper.ts +++ b/lib/providers/fortnox/mapper.ts @@ -14,10 +14,22 @@ function amount(value: number | undefined | null, currency: string = 'SEK'): Amo return { value: value ?? 0, currencyCode: currency }; } +/** + * Single source of truth for "is this invoice fully settled?", used by BOTH + * deriveInvoiceStatus and the paymentStatus.paid flag so they can never diverge. + * Numeric, not strict === 0, so a residual öre / float drift still reads as paid. + * Number(undefined ?? NaN) = NaN and NaN <= 0 is false, so an ABSENT Balance is + * treated as NOT paid (the supplier-invoice list payload omits Balance) — only an + * explicit FullyPaid flag or a present non-positive Balance counts as paid. + */ +function isFullyPaid(raw: Record): boolean { + return raw['FullyPaid'] === true || Number(raw['Balance'] ?? NaN) <= 0; +} + function deriveInvoiceStatus(raw: Record): InvoiceStatusCode { if (raw['Cancelled'] === true) return 'cancelled'; if (raw['Credit'] === true) return 'credited'; - if (raw['FullyPaid'] === true || raw['Balance'] === 0) return 'paid'; + if (isFullyPaid(raw)) return 'paid'; if (raw['Booked'] === true) return 'booked'; if (raw['Sent'] === true) return 'sent'; return 'draft'; @@ -49,7 +61,14 @@ function buildParty(name: string, orgNumber?: string, address?: Record): SalesInvoiceDto { const currency = (raw['Currency'] as string) ?? 'SEK'; const total = raw['Total'] as number ?? 0; - const balance = raw['Balance'] as number ?? 0; + // Default an ABSENT Balance to the full total (= fully unpaid), never 0, so a + // missing Balance never silently reads as paid. A present Balance (incl. 0) is + // used as-is. Mirrors the supplier path; paid-ness comes from isFullyPaid(). + // When paid, force balance to 0 so the DTO is internally consistent + // (paid ⇒ nothing outstanding): an explicit FullyPaid with no Balance field + // would otherwise leave balance = total alongside paid = true. + const paid = isFullyPaid(raw); + const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total); const rows = (raw['InvoiceRows'] as Record[] | undefined) ?? []; const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({ @@ -72,7 +91,7 @@ export function mapFortnoxToSalesInvoice(raw: Record): SalesInv }; const paymentStatus: PaymentStatusDto = { - paid: balance === 0 && total > 0, + paid, balance: amount(balance, currency), }; @@ -107,7 +126,15 @@ export function mapFortnoxToSalesInvoice(raw: Record): SalesInv export function mapFortnoxToSupplierInvoice(raw: Record): SupplierInvoiceDto { const currency = (raw['Currency'] as string) ?? 'SEK'; const total = raw['Total'] as number ?? 0; - const balance = raw['Balance'] as number ?? 0; + // Default an ABSENT Balance to the full total (= fully unpaid), never 0. + // The supplier-invoice list is fetched with ?filter=unpaid, so a missing + // Balance must not be mistaken for "settled" — that would flip a genuinely + // open payable to paid downstream. A present Balance (incl. 0) is used as-is. + // When paid, force balance to 0 so the DTO is internally consistent + // (paid ⇒ nothing outstanding): an explicit FullyPaid with no Balance field + // would otherwise leave balance = total alongside paid = true. + const paid = isFullyPaid(raw); + const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total); const rows = (raw['SupplierInvoiceRows'] as Record[] | undefined) ?? []; const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({ @@ -127,7 +154,7 @@ export function mapFortnoxToSupplierInvoice(raw: Record): Suppl }; const paymentStatus: PaymentStatusDto = { - paid: balance === 0 && total > 0, + paid, balance: amount(balance, currency), }; diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index fb3b4560..cf614b36 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -13,6 +13,7 @@ function makeBuilder() { b[m] = vi.fn().mockReturnValue(b) } b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) + b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null }) return b } @@ -1050,3 +1051,65 @@ describe('calculateVatDeclaration — parent/summary accounts', () => { expect(result.rutor.ruta49).toBe(2719.55) // 9768 − 7048.45, owed (was −7048.45 pre-fix) }) }) + +describe('calculateVatDeclaration — annual VAT spans the räkenskapsår', () => { + it('uses the fiscal period bounds for yearly when a fiscalPeriodId is given', async () => { + // Förlängt räkenskapsår (extended first year, 18 months) — annual VAT + // (helårsmoms) must cover the whole period, not the calendar year that + // period_start falls in. The first queued result feeds the fiscal_periods + // lookup, the second the journal lines, the third the entry counts. + results = [ + { data: { period_start: '2025-07-03', period_end: '2026-12-31' }, error: null }, + { + data: [ + { account_number: '3001', debit_amount: 0, credit_amount: 21600 }, + { account_number: '2610', debit_amount: 0, credit_amount: 9768 }, + { account_number: '2641', debit_amount: 7048.45, credit_amount: 0 }, + ], + error: null, + }, + { data: [], error: null }, + ] + + const result = await calculateVatDeclaration( + supabase, 'company-1', 'yearly', 2026, 1, 'accrual', { fiscalPeriodId: 'fp-1' }, + ) + + expect(result.period.start).toBe('2025-07-03') + expect(result.period.end).toBe('2026-12-31') + expect(result.rutor.ruta05).toBe(21600) + expect(result.rutor.ruta10).toBe(9768) + expect(result.rutor.ruta48).toBe(7048.45) + }) + + it('falls back to the calendar year when the fiscal period cannot be resolved', async () => { + results = [ + { data: null, error: null }, // fiscal_periods lookup → not found + { data: [], error: null }, // journal lines + { data: [], error: null }, // entry counts + ] + + const result = await calculateVatDeclaration( + supabase, 'company-1', 'yearly', 2026, 1, 'accrual', { fiscalPeriodId: 'missing' }, + ) + + expect(result.period.start).toBe('2026-01-01') + expect(result.period.end).toBe('2026-12-31') + }) + + it('ignores fiscalPeriodId for monthly periods (calendar month, no lookup)', async () => { + // No fiscal_periods lookup is made for monthly, so the first queued result + // is the journal lines — proving the räkenskapsår path is yearly-only. + results = [ + { data: [], error: null }, // journal lines + { data: [], error: null }, // entry counts + ] + + const result = await calculateVatDeclaration( + supabase, 'company-1', 'monthly', 2026, 3, 'accrual', { fiscalPeriodId: 'fp-1' }, + ) + + expect(result.period.start).toBe('2026-03-01') + expect(result.period.end).toBe('2026-03-31') + }) +}) diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 66321640..99e3e241 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -296,12 +296,17 @@ async function generatePeriodReports( let vatDeclaration: unknown = null try { const startDate = new Date(period.period_start) + // Annual VAT for an archive must cover the whole räkenskapsår, which may be + // extended/shortened — pass the fiscal period so the span isn't truncated to + // the calendar year that period_start happens to fall in. vatDeclaration = await calculateVatDeclaration( supabase, companyId, 'yearly', startDate.getFullYear(), - 1 + 1, + 'accrual', + { fiscalPeriodId: period.id } ) } catch { // VAT declaration may fail if no relevant entries exist — skip gracefully diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index c9a17a65..19d76654 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -185,6 +185,44 @@ function round(value: number): number { return Math.round(value * 100) / 100 } +/** + * Resolve the start/end dates for a VAT period. + * + * Monthly and quarterly VAT periods are always calendar months/quarters + * (kalendermånad / kalenderkvartal per SFL 26 kap), so they use the plain + * calendar calculation. + * + * Annual VAT (helårsmoms), however, is reported per *räkenskapsår* — the + * beskattningsår — not per calendar year (SFL 26 kap 10–11 §§). A räkenskapsår + * can be extended or shortened (up to 18 months for a first/changed year per + * BFL 3 kap 3 §), so a calendar Jan–Dec span would silently drop part of an + * extended year (e.g. a first year 2025-07-03 → 2026-12-31). When the caller + * supplies the fiscal period we therefore use its actual bounds. If the period + * can't be resolved we fall back to the calendar span so behaviour degrades + * gracefully instead of erroring. + */ +async function resolvePeriodDates( + supabase: SupabaseClient, + companyId: string, + periodType: VatPeriodType, + year: number, + period: number, + fiscalPeriodId?: string +): Promise<{ start: string; end: string }> { + if (periodType === 'yearly' && fiscalPeriodId) { + const { data: fp } = await supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .maybeSingle() + if (fp?.period_start && fp?.period_end) { + return { start: fp.period_start, end: fp.period_end } + } + } + return calculatePeriodDates(periodType, year, period) +} + /** * Calculate VAT declaration from the general ledger. * @@ -203,9 +241,14 @@ export async function calculateVatDeclaration( periodType: VatPeriodType, year: number, period: number, - _accountingMethod: AccountingMethod = 'accrual' + _accountingMethod: AccountingMethod = 'accrual', + options: { fiscalPeriodId?: string } = {} ): Promise { - const { start, end } = calculatePeriodDates(periodType, year, period) + // For yearly VAT this resolves to the räkenskapsår bounds (when a fiscal + // period is supplied), not the calendar year — see resolvePeriodDates. + const { start, end } = await resolvePeriodDates( + supabase, companyId, periodType, year, period, options.fiscalPeriodId + ) // Fetch all posted journal entry lines on VAT-relevant accounts for the period const lines = await fetchAllRows<{ diff --git a/messages/en.json b/messages/en.json index 67342160..8a9df048 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2489,9 +2489,8 @@ }, "supplier_invoice_editor": { "page_title": "Register supplier invoice", - "no_period_warning": "Invoice date {date} falls outside every fiscal year you have set up.", - "no_period_help": "Create the fiscal year so the invoice can be booked — otherwise it can't be registered.", - "create_period": "Create fiscal year", + "no_period_warning": "You're creating a supplier invoice for a fiscal year that doesn't exist ({date}).", + "no_period_help": "Create the fiscal year first, or change the invoice date — the invoice can't be registered without one.", "back_aria": "Back to supplier invoices", "back_aria_inbox": "Back to the inbox", "loading_inbox": "Loading data from inbox…", @@ -2531,6 +2530,8 @@ "col_amount": "Amount", "col_vat_rate": "VAT rate", "col_vat": "VAT", + "col_rc_vat_rate": "VAT rate (RC)", + "col_rc_vat": "Self-assessed VAT", "col_debit": "Debit", "col_credit": "Credit", "vat_rate_presets_aria": "Pick VAT rate from list", diff --git a/messages/sv.json b/messages/sv.json index 140a8c22..bbfca033 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2489,9 +2489,8 @@ }, "supplier_invoice_editor": { "page_title": "Registrera leverantörsfaktura", - "no_period_warning": "Fakturadatumet {date} ligger utanför alla upplagda räkenskapsår.", - "no_period_help": "Lägg upp räkenskapsåret så att fakturan kan bokföras – annars kan den inte registreras.", - "create_period": "Skapa räkenskapsår", + "no_period_warning": "Du skapar en leverantörsfaktura för ett räkenskapsår som inte finns ({date}).", + "no_period_help": "Lägg upp räkenskapsåret först, eller ändra fakturadatumet – fakturan kan inte registreras utan ett räkenskapsår.", "back_aria": "Tillbaka till leverantörsfakturor", "back_aria_inbox": "Tillbaka till inkorgen", "loading_inbox": "Laddar uppgifter från inkorgen…", @@ -2531,6 +2530,8 @@ "col_amount": "Belopp", "col_vat_rate": "Momssats", "col_vat": "Moms", + "col_rc_vat_rate": "Momssats (omvänd)", + "col_rc_vat": "Beräknad moms", "col_debit": "Debet", "col_credit": "Kredit", "vat_rate_presets_aria": "Välj momssats från lista", diff --git a/supabase/migrations/20260612120000_pending_operations_add_link_supplier_invoice_voucher.sql b/supabase/migrations/20260612120000_pending_operations_add_link_supplier_invoice_voucher.sql new file mode 100644 index 00000000..adc65c13 --- /dev/null +++ b/supabase/migrations/20260612120000_pending_operations_add_link_supplier_invoice_voucher.sql @@ -0,0 +1,61 @@ +-- Backfill `link_supplier_invoice_voucher` into the +-- pending_operations.operation_type CHECK constraint. +-- +-- The supplier-side mirror of `link_invoice_voucher` (added in +-- 20260528120001). The new MCP tool gnubok_link_supplier_invoice_to_voucher +-- stages a `link_supplier_invoice_voucher` pending operation, which is then +-- committed by commitLinkSupplierInvoiceVoucher via the +-- link_supplier_invoice_to_voucher RPC (20260529130000 / 20260529140000). +-- The op also has a risk-tier entry ('medium', reversible — no journal entry +-- is created or modified). +-- +-- Without this migration any INSERT staged by the new tool would be rejected +-- with a constraint violation, silently blocking the supplier_invoice_payments +-- audit-trail row required by BFL 5 kap 6–7§ (every affärshändelse must have a +-- verifikation with a logged payment match). + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', + 'generate_agi', + 'link_transaction_journal_entry', + 'link_supplier_invoice_voucher' -- supplier-side mirror of link_invoice_voucher + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260612121000_supplier_invoice_items_add_reverse_charge_rate.sql b/supabase/migrations/20260612121000_supplier_invoice_items_add_reverse_charge_rate.sql new file mode 100644 index 00000000..635d9896 --- /dev/null +++ b/supabase/migrations/20260612121000_supplier_invoice_items_add_reverse_charge_rate.sql @@ -0,0 +1,26 @@ +-- Add reverse_charge_rate to supplier_invoice_items. +-- +-- For omvänd skattskyldighet (reverse charge, ML 16 kap) the EU/non-EU or +-- domestic-RC supplier charges no VAT, so the line's own vat_rate is 0 — the +-- v1 supplier-invoice API even mandates vat_rate=0 on every RC line. The buyer +-- must nonetheless self-assess BOTH output and input VAT at the Swedish +-- statutory rate that would apply to the service domestically: 25% under +-- huvudregeln for EU services (ML 6 kap 34 §), or 12%/6% for reduced-rated +-- services. That self-assessed rate is conceptually distinct from "what the +-- supplier charged" (0%), so it gets its own column instead of overloading +-- vat_rate — which previously caused the engine to skip the fiktiv-moms lines +-- (2614/2624/2634 + 2645/2647) and basbeloppsrader (44xx/45xx) entirely, +-- understating momsdeklaration ruta 20-24 / 30-32 / 48. +-- +-- NULL = not a reverse-charge line (the booking engine then falls back to a +-- positive vat_rate if present, else the 25% huvudregel default). +-- 0.06 / 0.12 / 0.25 = explicit self-assessed rate (set by the UI picker). + +ALTER TABLE supplier_invoice_items + ADD COLUMN IF NOT EXISTS reverse_charge_rate numeric + CHECK (reverse_charge_rate IS NULL OR reverse_charge_rate IN (0.06, 0.12, 0.25)); + +COMMENT ON COLUMN supplier_invoice_items.reverse_charge_rate IS + 'Self-assessed VAT rate for omvänd skattskyldighet (decimal 0.06/0.12/0.25). NULL for non-RC lines. The line vat_rate stays 0 (supplier charges no VAT); this rate drives fiktiv moms (2614/2624/2634 + 2645/2647) and basbelopp (44xx/45xx) booking — see lib/bookkeeping/supplier-invoice-entries.ts.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index be3f7227..2f96f911 100644 --- a/types/index.ts +++ b/types/index.ts @@ -657,6 +657,10 @@ export interface SupplierInvoiceItem { vat_code: string | null vat_rate: number vat_amount: number + // Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25), null + // for non-RC lines. The supplier charges no VAT so vat_rate stays 0; this + // rate drives the fiktiv-moms + basbelopp booking. See the booking engine. + reverse_charge_rate: number | null created_at: string } @@ -965,6 +969,9 @@ export interface CreateSupplierInvoiceItemInput { vat_rate?: number // Manual override. See CreateSupplierInvoiceItemSchema for rationale. vat_amount?: number + // Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25). When + // set, the engine books fiktiv moms at this rate while vat_rate stays 0. + reverse_charge_rate?: number vat_code?: string // Legacy fields (backward compat, ignored when amount is set) quantity?: number @@ -1573,6 +1580,9 @@ export type PendingOperationType = | 'generate_agi' // Mark invoice paid by linking an existing posted verifikat (no new JE) | 'link_invoice_voucher' + // Supplier-side mirror: mark a leverantörsfaktura paid by linking an existing + // posted verifikat that debits 2440 (no new JE) + | 'link_supplier_invoice_voucher' // PR #603/#607: allocate 1 bank tx across N customer or supplier invoices | 'match_batch_allocate' // PR #606/#610: bulk-book N bank txs into 1 combined verifikat