Files
accounted/extensions/general/arcim-migration/lib/__tests__/entity-mapper-vat.test.ts
T
34bf5a7387 fix(providers): Fortnox freight and fee as rows, text rows as text, string quantities as numbers (#2304)
* fix(providers): Fortnox freight and fee as rows, text rows as text, string quantities as numbers

Three shapes seen on live Profilio payloads after #2302's rows-versus-header
check went in:

- Freight and AdministrationFee live on the invoice header, not in
  InvoiceRows, while Total and TotalVAT include them. The rows summed to
  less than the header by exactly the charge and the check refused the
  invoice (14 of Profilio's 384). They are now rows: FreightVAT and
  AdministrationFeeVAT are VAT amounts (88 and 22 on a 25 % invoice), and
  the charge is gross when VATIncluded is true (99 = 79.20 + 19.80).
- Free-text rows (DeliveredQuantity "0", Total 0, VAT 0) counted as a
  stated 0 % rate beside the 25 % rows, so the migration marked the invoice
  mixed and nulled its header rate on roughly half of two registers. They
  no longer state a rate, and land as line_type 'text' with no amounts, the
  way the invoice page and the booking engine expect them.
- DeliveredQuantity is serialised as a string and was stored unparsed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf

* fix(migration): type the text-row check so resolveInvoiceVat's line shape accepts it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 11:41:17 +02:00

363 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { mapSalesInvoice, mapSupplierInvoice } from '../entity-mapper'
import { mapFortnoxToSalesInvoice } from '@/lib/providers/fortnox/mapper'
import type { SalesInvoiceDto, SupplierInvoiceDto } from '@/lib/providers/dto'
const USER = 'user-1'
const COMPANY = 'company-1'
const COUNTERPARTY = 'counterparty-1'
function salesDto(over: Partial<SalesInvoiceDto> = {}): SalesInvoiceDto {
return {
id: '4',
invoiceNumber: '4',
issueDate: '2025-11-01',
dueDate: '2025-12-01',
currencyCode: 'SEK',
status: 'sent',
supplier: { name: '', identifications: [] },
customer: { name: 'Ronaldiniho', identifications: [] },
lines: [],
legalMonetaryTotal: { payableAmount: { value: 1845000, currencyCode: 'SEK' } },
paymentStatus: { paid: false, balance: { value: 1845000, currencyCode: 'SEK' } },
...over,
}
}
function supplierDto(over: Partial<SupplierInvoiceDto> = {}): SupplierInvoiceDto {
return {
id: '77',
invoiceNumber: '77',
issueDate: '2025-11-01',
currencyCode: 'SEK',
status: 'sent',
supplier: { name: 'Leverantör AB', identifications: [] },
buyer: { name: '', identifications: [] },
lines: [],
legalMonetaryTotal: { payableAmount: { value: 1250, currencyCode: 'SEK' } },
paymentStatus: { paid: false, balance: { value: 1250, currencyCode: 'SEK' } },
...over,
}
}
describe('mapSalesInvoice: VAT is observed, never assumed', () => {
it('does not claim 25 % on an invoice whose payload states no VAT', () => {
// The exact reported record: Fortnox list form, 1 845 000 kr, no rows.
// It used to persist as vat_rate 25 / vat_amount 0, a contradiction the
// invoice page rendered as "Momsbehandling: 25 % moms / Moms: 0 kr".
const { invoice, vatUnresolved } = mapSalesInvoice(salesDto(), USER, COMPANY, COUNTERPARTY)
expect(vatUnresolved).toBe(true)
expect(invoice.vat_rate).toBeNull()
expect(invoice.vat_amount).toBe(0)
// Only the gross is known, so it is the only figure asserted.
expect(invoice.total).toBe(1845000)
expect(invoice.subtotal).toBe(1845000)
})
it('splits gross into net and VAT when the provider states the VAT total', () => {
const { invoice, vatUnresolved } = mapSalesInvoice(
salesDto({ taxTotal: { taxAmount: { value: 369000, currencyCode: 'SEK' } } }),
USER, COMPANY, COUNTERPARTY,
)
expect(vatUnresolved).toBe(false)
expect(invoice.subtotal).toBe(1476000)
expect(invoice.vat_amount).toBe(369000)
expect(invoice.vat_rate).toBe(25)
expect(invoice.vat_treatment).toBe('standard_25')
})
it('classifies 12 % and 6 % from the observed ratio', () => {
const twelve = mapSalesInvoice(
salesDto({
legalMonetaryTotal: { payableAmount: { value: 1120, currencyCode: 'SEK' } },
taxTotal: { taxAmount: { value: 120, currencyCode: 'SEK' } },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(twelve.invoice.vat_rate).toBe(12)
expect(twelve.invoice.vat_treatment).toBe('reduced_12')
const six = mapSalesInvoice(
salesDto({
legalMonetaryTotal: { payableAmount: { value: 1060, currencyCode: 'SEK' } },
taxTotal: { taxAmount: { value: 60, currencyCode: 'SEK' } },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(six.invoice.vat_rate).toBe(6)
expect(six.invoice.vat_treatment).toBe('reduced_6')
})
it('calls a genuine 0 % SEK sale momsfritt, not standard_25', () => {
// The old fallback put a momsfri sale on a 25 % treatment, which routes it
// to revenue account 3001 and into ruta 05 of the momsdeklaration.
const { invoice } = mapSalesInvoice(
salesDto({ taxTotal: { taxAmount: { value: 0, currencyCode: 'SEK' } } }),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBe(0)
expect(invoice.vat_treatment).toBe('exempt')
})
it('calls a 0 % foreign-currency sale an export', () => {
const { invoice } = mapSalesInvoice(
salesDto({
currencyCode: 'EUR',
taxTotal: { taxAmount: { value: 0, currencyCode: 'EUR' } },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_treatment).toBe('export')
})
it('prefers a rate stated on a line over one divided out of the totals', () => {
const { invoice } = mapSalesInvoice(
salesDto({
taxTotal: { taxAmount: { value: 369000, currencyCode: 'SEK' } },
lines: [{
id: '1',
lineExtensionAmount: { value: 1476000, currencyCode: 'SEK' },
taxPercent: 25,
}],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBe(25)
})
it('tolerates provider rounding when dividing the rate out', () => {
// 0,2499… must still read as a 25 % invoice.
const { invoice } = mapSalesInvoice(
salesDto({
legalMonetaryTotal: { payableAmount: { value: 1249.99, currencyCode: 'SEK' } },
taxTotal: { taxAmount: { value: 249.99, currencyCode: 'SEK' } },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBe(25)
})
it('leaves the rate null for a non-Swedish rate rather than snapping it', () => {
// 19 % (DE) is a real rate; forcing it to 25 would misstate the invoice.
const { invoice } = mapSalesInvoice(
salesDto({
currencyCode: 'EUR',
legalMonetaryTotal: { payableAmount: { value: 1190, currencyCode: 'EUR' } },
taxTotal: { taxAmount: { value: 190, currencyCode: 'EUR' } },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBeNull()
})
})
describe('mapSalesInvoice: line items carry the VAT the engine books', () => {
it('derives a line amount from the rate when the provider states only the rate', () => {
const { items } = mapSalesInvoice(
salesDto({
taxTotal: { taxAmount: { value: 250, currencyCode: 'SEK' } },
legalMonetaryTotal: { payableAmount: { value: 1250, currencyCode: 'SEK' } },
lines: [{
id: '1',
lineExtensionAmount: { value: 1000, currencyCode: 'SEK' },
taxPercent: 25,
}],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(items[0]).toMatchObject({ line_total: 1000, vat_rate: 25, vat_amount: 250 })
})
it('stores vat_rate null for a mixed-rate invoice, like a native one', () => {
// buildInvoiceWriteData stores `isMixedRate ? null : theRate`. Labelling
// the header with the first line's rate would assert 25 % on an invoice
// that is 25 % and 6 %.
const { invoice, items } = mapSalesInvoice(
salesDto({
legalMonetaryTotal: { payableAmount: { value: 1310, currencyCode: 'SEK' } },
taxTotal: { taxAmount: { value: 310, currencyCode: 'SEK' } },
lines: [
{ id: '1', lineExtensionAmount: { value: 1000, currencyCode: 'SEK' }, taxPercent: 25 },
{ id: '2', lineExtensionAmount: { value: 1000, currencyCode: 'SEK' }, taxPercent: 6 },
],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBeNull()
// The money is per line, which is what the booking engine groups on.
expect(items[0]).toMatchObject({ vat_rate: 25, vat_amount: 250 })
expect(items[1]).toMatchObject({ vat_rate: 6, vat_amount: 60 })
})
it('does not label a line 25 % when nothing established a rate', () => {
const { items } = mapSalesInvoice(
salesDto({
lines: [{ id: '1', lineExtensionAmount: { value: 1000, currencyCode: 'SEK' } }],
}),
USER, COMPANY, COUNTERPARTY,
)
// 0 % beside 0 kr is at least self-consistent; 25 % beside 0 kr is not.
expect(items[0]).toMatchObject({ vat_rate: 0, vat_amount: 0 })
})
})
describe('mapSupplierInvoice: VAT', () => {
it('flags an unresolved payload instead of asserting a rate', () => {
const { invoice, vatUnresolved } = mapSupplierInvoice(
supplierDto(), USER, COMPANY, COUNTERPARTY,
)
expect(vatUnresolved).toBe(true)
expect(invoice.vat_amount).toBe(0)
expect(invoice.subtotal).toBe(1250)
})
it('stores the line rate as a FRACTION, unlike sales items', () => {
const { items } = mapSupplierInvoice(
supplierDto({
taxTotal: { taxAmount: { value: 250, currencyCode: 'SEK' } },
lines: [{
id: '1',
lineExtensionAmount: { value: 1000, currencyCode: 'SEK' },
taxPercent: 25,
}],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(items[0]).toMatchObject({ vat_rate: 0.25, vat_amount: 250 })
})
it('keeps a foreign rate rather than coercing it to a Swedish one', () => {
const { items } = mapSupplierInvoice(
supplierDto({
currencyCode: 'EUR',
lines: [{
id: '1',
lineExtensionAmount: { value: 100, currencyCode: 'EUR' },
taxPercent: 19,
}],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(items[0]).toMatchObject({ vat_rate: 0.19, vat_amount: 19 })
})
})
describe('end to end: the reported Fortnox invoice', () => {
const listPayload = {
DocumentNumber: 4,
InvoiceDate: '2025-11-01',
DueDate: '2025-12-01',
CustomerName: 'Ronaldiniho',
Currency: 'SEK',
Total: 1845000,
Balance: 1845000,
Sent: true,
}
it('list form: imported without a fabricated rate', () => {
const { invoice, vatUnresolved } = mapSalesInvoice(
mapFortnoxToSalesInvoice(listPayload), USER, COMPANY, COUNTERPARTY,
)
expect(vatUnresolved).toBe(true)
expect(invoice.vat_rate).toBeNull()
})
it('detail form: imported with the VAT Fortnox actually stated', () => {
const { invoice, items, vatUnresolved } = mapSalesInvoice(
mapFortnoxToSalesInvoice({
...listPayload,
Net: 1476000,
TotalVAT: 369000,
InvoiceRows: [
{ RowId: 1, Description: 'Konsultarvode', DeliveredQuantity: 1, Price: 1476000, Total: 1476000, VAT: 25 },
],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(vatUnresolved).toBe(false)
expect(invoice).toMatchObject({
subtotal: 1476000,
vat_amount: 369000,
total: 1845000,
vat_rate: 25,
vat_treatment: 'standard_25',
})
expect(items[0]).toMatchObject({ line_total: 1476000, vat_amount: 369000 })
// The identity the old code never checked.
expect((invoice.subtotal as number) + (invoice.vat_amount as number)).toBe(invoice.total)
})
})
describe('mapSalesInvoice: free-text rows', () => {
const sek = (value: number) => ({ value, currencyCode: 'SEK' })
it('does not let a text row state a 0 % rate beside the priced rows', () => {
// Fortnox ships "5 st M, 8 st L" as a row with Total 0 and VAT 0. Counting
// that as a stated rate made the invoice "mixed" and nulled the header
// rate on roughly half of the Loftux and Clearstoq registers.
const { invoice } = mapSalesInvoice(
salesDto({
lines: [
{ id: '687', description: 'Hoodie', quantity: 14, unitPrice: sek(359.2), lineExtensionAmount: sek(5028.8), taxPercent: 25, taxAmount: sek(1257.2) },
{ id: '688', description: '5 st M, 8 st L, 1 st XL', quantity: 0, unitPrice: sek(0), lineExtensionAmount: sek(0), taxPercent: 0, taxAmount: sek(0) },
],
taxTotal: { taxAmount: sek(1257.2) },
legalMonetaryTotal: { lineExtensionAmount: sek(5028.8), payableAmount: sek(6286) },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBe(25)
expect(invoice.vat_treatment).toBe('standard_25')
})
it('lands a text row as line_type text with no amounts, and a priced row as product', () => {
const { items } = mapSalesInvoice(
salesDto({
lines: [
{ id: '687', description: 'Hoodie', quantity: 14, unitPrice: sek(359.2), lineExtensionAmount: sek(5028.8), taxPercent: 25, taxAmount: sek(1257.2) },
{ id: '688', description: '5 st M, 8 st L, 1 st XL', quantity: 0, unitPrice: sek(0), lineExtensionAmount: sek(0), taxPercent: 0, taxAmount: sek(0) },
],
taxTotal: { taxAmount: sek(1257.2) },
legalMonetaryTotal: { lineExtensionAmount: sek(5028.8), payableAmount: sek(6286) },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(items[0]).toMatchObject({ line_type: 'product', quantity: 14, line_total: 5028.8, vat_rate: 25 })
expect(items[1]).toMatchObject({ line_type: 'text', description: '5 st M, 8 st L, 1 st XL', quantity: 0, unit_price: 0, line_total: 0, vat_amount: 0 })
})
it('still reports a genuinely mixed invoice as mixed', () => {
const { invoice } = mapSalesInvoice(
salesDto({
lines: [
{ id: '1', quantity: 1, unitPrice: sek(1000), lineExtensionAmount: sek(1000), taxPercent: 25, taxAmount: sek(250) },
{ id: '2', quantity: 1, unitPrice: sek(100), lineExtensionAmount: sek(100), taxPercent: 6, taxAmount: sek(6) },
],
taxTotal: { taxAmount: sek(256) },
legalMonetaryTotal: { lineExtensionAmount: sek(1100), payableAmount: sek(1356) },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBeNull()
expect(invoice.vat_treatment).toBe('standard_25')
})
})