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:
co-authored by
Claude Opus 4.8
parent
358c25094d
commit
f6ee0c2a82
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user