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:
Mattsson
2026-06-02 13:25:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 358c25094d
commit f6ee0c2a82
43 changed files with 2226 additions and 182 deletions
@@ -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,
+18 -30
View File
@@ -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)
+51
View File
@@ -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]