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
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user