Bug/customer invoice bug (#628)
* fix(supplier-invoices): self-assess reverse-charge VAT + link payments to vouchers Reverse-charge supplier invoices now carry a per-item reverse_charge_rate (0.06/0.12/0.25). Under omvänd skattskyldighet the supplier charges 0% VAT, so the line vat_rate stays 0 and the buyer self-assesses fiktiv moms at the statutory rate. Centralizes rate resolution (resolveReverseChargeRate) and the ruta 20-24 basis-account guard (isReverseChargeBasisAccount) in vat-entries so the booking engine and review-dialog preview can no longer drift. Adds the link_supplier_invoice_voucher pending operation: mark a leverantorsfaktura paid by linking an existing posted verifikat that debits 2440, with no new journal entry. Exposes find-candidates/link MCP tools and the bulk-reconcile helper, scoped under suppliers:read/write. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(vat): report yearly VAT over the rakenskapsar, not the calendar year Annual VAT (helarsmoms) is filed per beskattningsar/rakenskapsar (SFL 26 kap), which can be extended or shortened up to 18 months. The previous Jan-Dec calendar span silently dropped part of an extended first year. calculateVatDeclaration now accepts a fiscalPeriodId and resolves the period's actual bounds for yearly; monthly/quarterly stay calendar. The reports UI passes the selected fiscal period, defaults the periodicity from the company's moms_period setting, and carries the period into the ruta drill-down. full-archive export threads the period id through too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migration): resolve supplier invoice status from payment amounts The provider's lifecycle status and its payment status are computed independently upstream and can contradict each other (e.g. a Fortnox invoice marked booked but fully paid). Both the arcim entity-mapper and the Fortnox mapper now let payment state win: fully paid -> paid, partial -> partially_paid, otherwise the mapped lifecycle status, with credit notes forced terminal. Balance is compared numerically (never strict === 0) so float drift or a residual ore resolves cleanly, and an absent Balance is treated as unpaid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enable-banking): only ingest booked transactions to stop re-import drift Pending entries are skipped during sync: a pending row is unstable across syncs (a later 'synka nu' returns it 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 minted a new id and re-imported a transaction that already existed - observed in production as the same amount+description landing twice with different dates. Gating the import set on a stable booking_date removes the drift at the source and leaves booked rows' ids byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(gitignore): ignore local SIE test fixtures tests/fixtures/sie/ may contain real or scrubbed company data and must never be committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoice): handle errors during registration journal entry creation and ensure invoice rollback feat(tests): add test for reverse charge rate handling on supplier invoice line items feat(fortnox): ensure paid status reflects zero balance for fully paid invoices chore(migrations): add reverse_charge_rate to supplier_invoice_items and backfill link_supplier_invoice_voucher --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -67,4 +67,7 @@ supabase/.temp/
|
||||
# out of the box without running the generator.
|
||||
supabase/.branches/
|
||||
|
||||
/scripts
|
||||
/scripts
|
||||
|
||||
# Local-only SIE test fixtures — may contain real/scrubbed company data, never commit
|
||||
tests/fixtures/sie/
|
||||
@@ -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() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('sie_moved_hint')}{' '}
|
||||
<Link href="/import?view=export#sie-export" className="text-foreground underline underline-offset-4 decoration-muted-foreground/40 hover:decoration-foreground">
|
||||
{t('sie_moved_link')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
{isLoadingInit ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
@@ -247,7 +240,12 @@ export default function ReportsPage() {
|
||||
{activeTab === 'balance-sheet' && (
|
||||
<BalanceSheetView periodId={selectedPeriod} dateRange={dateRange} onNavigateToAccount={navigateToAccount} />
|
||||
)}
|
||||
{activeTab === 'vat-declaration' && <VatDeclarationView />}
|
||||
{activeTab === 'vat-declaration' && (
|
||||
<VatDeclarationView
|
||||
fiscalPeriodId={selectedPeriod}
|
||||
fiscalPeriodBounds={selectedPeriodBounds}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'periodisk-sammanstallning' && <PeriodiskSammanstallningView />}
|
||||
{isEnskildFirma && activeTab === 'ne-declaration' && (
|
||||
<NEDeclarationView periodId={selectedPeriod} />
|
||||
@@ -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<string | null>(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<string | null>(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 (
|
||||
<VatDrillContext.Provider value={{ fiscalPeriodId: isYearly ? fiscalPeriodId : undefined }}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/api/reports/vat-declaration/xlsx?periodType=${periodType}&year=${year}&period=${period}`, '_blank')}
|
||||
onClick={() => window.open(`/api/reports/vat-declaration/xlsx?${vatQueryString()}`, '_blank')}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-2" />
|
||||
Ladda ner Excel
|
||||
@@ -1387,34 +1430,49 @@ function VatDeclarationView() {
|
||||
<option value="yearly">Årsvis</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>År</Label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{yearOptions.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Period</Label>
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(parseInt(e.target.value))}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{getPeriodOptions().map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{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.
|
||||
<div>
|
||||
<Label>Räkenskapsår</Label>
|
||||
<div className="mt-1 rounded-md border border-input bg-muted/40 px-3 py-2 text-sm tabular-nums">
|
||||
{fiscalPeriodBounds
|
||||
? `${formatDate(fiscalPeriodBounds.start)} – ${formatDate(fiscalPeriodBounds.end)}`
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Label>År</Label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{yearOptions.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Period</Label>
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(parseInt(e.target.value))}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{getPeriodOptions().map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={fetchDeclaration} disabled={loading}>
|
||||
{loading ? 'Laddar...' : 'Hämta'}
|
||||
</Button>
|
||||
@@ -1646,13 +1704,29 @@ function VatDeclarationView() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</VatDrillContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 (
|
||||
<Select value={String(value ?? 0.25)} onValueChange={(v) => onChange(parseFloat(v))}>
|
||||
<SelectTrigger className="h-9 tabular-nums" aria-label={t('col_rc_vat_rate')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.25" className="tabular-nums">25 %</SelectItem>
|
||||
<SelectItem value="0.12" className="tabular-nums">12 %</SelectItem>
|
||||
<SelectItem value="0.06" className="tabular-nums">6 %</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
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<FiscalPeriod[]>([])
|
||||
const [periodsLoaded, setPeriodsLoaded] = useState(false)
|
||||
const [showCreatePeriod, setShowCreatePeriod] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [pendingData, setPendingData] = useState<FormData | null>(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() {
|
||||
</div>
|
||||
|
||||
{showNoPeriodWarning && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
|
||||
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
|
||||
<div className="flex-1 text-sm text-warning-foreground">
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/10 p-3"
|
||||
>
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5 shrink-0" />
|
||||
<div className="flex-1 text-sm text-destructive">
|
||||
<p className="font-medium">{t('no_period_warning', { date: watchedInvoiceDate })}</p>
|
||||
<p className="mt-0.5">{t('no_period_help')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreatePeriod(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<CalendarPlus className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('create_period')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -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 })
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
@@ -1282,8 +1316,8 @@ export default function NewSupplierInvoicePage() {
|
||||
<th className="pb-2 w-28">{t('col_account')}</th>
|
||||
<th className="pb-2">{t('col_description')}</th>
|
||||
<th className="pb-2 w-32">{t('col_amount_excl')}</th>
|
||||
<th className="pb-2 w-36">{t('col_vat_rate')}</th>
|
||||
<th className="pb-2 w-24 text-right">{t('col_vat')}</th>
|
||||
<th className="pb-2 w-36">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</th>
|
||||
<th className="pb-2 w-24 text-right">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</th>
|
||||
<th className="pb-2 w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1336,13 +1370,23 @@ export default function NewSupplierInvoicePage() {
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Controller
|
||||
name={`items.${index}.vat_rate`}
|
||||
control={control}
|
||||
render={({ field: f }) => (
|
||||
<VatRateCell value={f.value} onChange={f.onChange} />
|
||||
)}
|
||||
/>
|
||||
{watchedReverseCharge ? (
|
||||
<Controller
|
||||
name={`items.${index}.reverse_charge_rate`}
|
||||
control={control}
|
||||
render={({ field: f }) => (
|
||||
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
name={`items.${index}.vat_rate`}
|
||||
control={control}
|
||||
render={({ field: f }) => (
|
||||
<VatRateCell value={f.value} onChange={f.onChange} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-2 text-right tabular-nums text-muted-foreground">
|
||||
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
|
||||
@@ -1418,18 +1462,28 @@ export default function NewSupplierInvoicePage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('col_vat_rate')}</Label>
|
||||
<Controller
|
||||
name={`items.${index}.vat_rate`}
|
||||
control={control}
|
||||
render={({ field: f }) => (
|
||||
<VatRateCell value={f.value} onChange={f.onChange} />
|
||||
)}
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</Label>
|
||||
{watchedReverseCharge ? (
|
||||
<Controller
|
||||
name={`items.${index}.reverse_charge_rate`}
|
||||
control={control}
|
||||
render={({ field: f }) => (
|
||||
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
name={`items.${index}.vat_rate`}
|
||||
control={control}
|
||||
render={({ field: f }) => (
|
||||
<VatRateCell value={f.value} onChange={f.onChange} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1 border-t flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{t('col_vat')}</span>
|
||||
<span className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
|
||||
</span>
|
||||
@@ -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() {
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !canWrite}
|
||||
disabled={isSubmitting || !canWrite || showNoPeriodWarning}
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => { submitModeRef.current = 'register' }}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
@@ -1733,14 +1787,6 @@ export default function NewSupplierInvoicePage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreatePeriodDialog
|
||||
open={showCreatePeriod}
|
||||
onOpenChange={setShowCreatePeriod}
|
||||
entryDate={watchedInvoiceDate}
|
||||
periods={periods}
|
||||
onCreated={fetchPeriods}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<typeof CreateSupplierInvoiceSchema
|
||||
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
|
||||
// line vat_rate is 0 (validated below); the engine self-assesses at this
|
||||
// rate, defaulting to 25% huvudregeln when null.
|
||||
reverse_charge_rate: item.reverse_charge_rate ?? null,
|
||||
})
|
||||
}
|
||||
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
|
||||
|
||||
@@ -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<number, number>()
|
||||
const nonBasisBaseByRate = new Map<number, number>()
|
||||
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({
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
@@ -258,7 +302,7 @@ export function SupplierInvoiceReviewContent({
|
||||
</td>
|
||||
<td className="py-2">{item.description}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(item.amount)}</td>
|
||||
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
|
||||
<td className="py-2 text-right">{Math.round(displayRate * 100)}%</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(vatAmount)}</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -268,9 +312,12 @@ export function SupplierInvoiceReviewContent({
|
||||
</div>
|
||||
<div className="sm:hidden space-y-2">
|
||||
{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 (
|
||||
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -279,7 +326,7 @@ export function SupplierInvoiceReviewContent({
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span>{formatAmount(item.amount)} kr</span>
|
||||
<span className="text-xs">{t('review_vat_inline', { rate: Math.round(item.vat_rate * 100), amount: formatAmount(vatAmount) })}</span>
|
||||
<span className="text-xs">{t('review_vat_inline', { rate: Math.round(displayRate * 100), amount: formatAmount(vatAmount) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -293,7 +340,7 @@ export function SupplierInvoiceReviewContent({
|
||||
<span>{formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('vat_label_short')}</span>
|
||||
<span className="text-muted-foreground">{reverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}</span>
|
||||
<span>{formatCurrency(totalVat, currency)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof makeDto>[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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> = {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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<Migra
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 6: Reconcile supplier invoices to GL payment vouchers ────
|
||||
// The GL (incl. the Dr 2440 / Cr 1930 bank-payment vouchers) is imported
|
||||
// separately via SIE. Supplier invoices arrive (via ?filter=unpaid) as open
|
||||
// payables with no link to those vouchers, so settled invoices would surface
|
||||
// as overdue. Auto-link the unambiguous matches. Best-effort: a failure here
|
||||
// must never fail the migration — the imported data is already persisted.
|
||||
if (options.reconcileVouchers !== false) {
|
||||
emitProgress(options, { status: 'importing', currentStep: 'Stämmer av betalningar mot verifikationer...', progress: 95 })
|
||||
try {
|
||||
const recon = await reconcileSupplierInvoiceVouchers({ supabase, companyId, userId })
|
||||
results.reconciliation = {
|
||||
scanned: recon.scanned,
|
||||
autoLinked: recon.autoLinked,
|
||||
ambiguous: recon.ambiguous,
|
||||
unmatched: recon.unmatched,
|
||||
}
|
||||
console.log(
|
||||
`[migration] Reconcile: ${recon.autoLinked} auto-linked, ${recon.ambiguous} need review, ${recon.unmatched} unmatched (${recon.scanned} scanned)`,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to reconcile supplier invoice payments:', err)
|
||||
}
|
||||
}
|
||||
|
||||
emitProgress(options, { status: 'completed', progress: 100, results })
|
||||
return results
|
||||
} catch (error) {
|
||||
|
||||
@@ -65,6 +65,13 @@ export interface MigrationResults {
|
||||
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
|
||||
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
|
||||
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
|
||||
/**
|
||||
* Auto-reconciliation of imported supplier invoices to the GL payment
|
||||
* vouchers that the separate SIE import already posted. `autoLinked` invoices
|
||||
* are now marked paid; `ambiguous` need manual review; `unmatched` had no
|
||||
* candidate voucher.
|
||||
*/
|
||||
reconciliation?: { scanned: number; autoLinked: number; ambiguous: number; unmatched: number }
|
||||
}
|
||||
|
||||
// ── Consent flow ────────────────────────────────────────────────────
|
||||
|
||||
@@ -184,7 +184,7 @@ describe('syncAccountTransactions', () => {
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<string, ApiKeyScope> = {
|
||||
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',
|
||||
|
||||
@@ -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<typeof import('../vat-entries')>()
|
||||
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<SupplierInvoiceItem> = {}): 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,
|
||||
|
||||
@@ -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<number, number> {
|
||||
const baseByRate = new Map<number, number>()
|
||||
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<number, number> {
|
||||
const baseByRate = new Map<number, number>()
|
||||
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)
|
||||
|
||||
@@ -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<string> = 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]
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<ReconcileResult> {
|
||||
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<ReconcileInvoiceRow>(
|
||||
({ 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<string, Plan[]>()
|
||||
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
|
||||
}
|
||||
@@ -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>): 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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
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
|
||||
|
||||
@@ -30,6 +30,11 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
||||
// 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,
|
||||
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
GivenNumber: '100',
|
||||
Total: 1000,
|
||||
InvoiceDate: '2026-01-10',
|
||||
DueDate: '2026-02-10',
|
||||
SupplierName: 'Leverantör AB',
|
||||
Booked: true,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function salesRaw(over: Record<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown> = { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>): boolean {
|
||||
return raw['FullyPaid'] === true || Number(raw['Balance'] ?? NaN) <= 0;
|
||||
}
|
||||
|
||||
function deriveInvoiceStatus(raw: Record<string, unknown>): 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<string, u
|
||||
export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): 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<string, unknown>[] | undefined) ?? [];
|
||||
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
|
||||
@@ -72,7 +91,7 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: balance === 0 && total > 0,
|
||||
paid,
|
||||
balance: amount(balance, currency),
|
||||
};
|
||||
|
||||
@@ -107,7 +126,15 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
|
||||
export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): 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<string, unknown>[] | undefined) ?? [];
|
||||
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
|
||||
@@ -127,7 +154,7 @@ export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): Suppl
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: balance === 0 && total > 0,
|
||||
paid,
|
||||
balance: amount(balance, currency),
|
||||
};
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<VatDeclaration> {
|
||||
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<{
|
||||
|
||||
+4
-3
@@ -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",
|
||||
|
||||
+4
-3
@@ -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",
|
||||
|
||||
+61
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user