fix(providers): map Fortnox VAT-inclusive rows net of VAT, and refuse migrated rows that contradict their header (#2302)
The first production run of the row-completion pass (#2291) wrote 345 Profilio invoices whose rows summed to the invoice GROSS with 25 % VAT computed on top, beside a header (Net / TotalVAT) that was right. Fortnox prices an invoice either excluding or including VAT and says which with the invoice-level VATIncluded flag; the mapper had always read the row Total and Price as net. Every such row set is 1.25 x its header net, to the öre, across all 345. - lib/providers/fortnox/mapper.ts: netOfVat() divides row Total and Price by (1 + rate) when VATIncluded is true; TotalExcludingVAT and PriceExcludingVAT are preferred when the payload carries them. A row without a rate cannot be split and keeps its amount. - complete-invoice-lines.ts: rows whose net or VAT disagree with the header the same payload established by more than 1 kr are reported as rowsMismatch and left untouched. Öresavrundning stays inside the tolerance; VAT-inside rows, header-level freight and discounts do not. Rows that contradict their own header are worse than no rows. - Cron summary carries rowsMismatch. None of the 345 is open or booked; a separate repair removes today's rows for them so the fixed pass refills them. 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>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
7c36d471b5
commit
e80ea74e76
@@ -239,6 +239,45 @@ describe('completeMigratedInvoiceLines', () => {
|
||||
expect(headerUpdates(calls)[0]).toMatchObject({ subtotal: 1000, subtotal_sek: 11200, vat_amount: 250, vat_amount_sek: 2800 })
|
||||
})
|
||||
|
||||
it('refuses rows that do not add up to the header the same payload established', async () => {
|
||||
// The shape Profilio's 345 invoices took: rows priced with VAT inside,
|
||||
// summing to the gross, beside a header that was right. Storing them
|
||||
// would put a row list under the invoice that contradicts its totals.
|
||||
mFetchAll.mockResolvedValue([storedRow()])
|
||||
const dto = providerInvoice({
|
||||
lines: [
|
||||
{ id: '1', description: 'Mugg', quantity: 1, unitPrice: amount(1250), lineExtensionAmount: amount(1250), taxPercent: 25 },
|
||||
],
|
||||
})
|
||||
mList.mockResolvedValue([dto])
|
||||
mHydrate.mockResolvedValue(hydratedAll([dto]))
|
||||
const { supabase, calls } = makeSupabase(() => ok)
|
||||
|
||||
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
|
||||
|
||||
expect(result).toMatchObject({ matched: 1, completed: 0, rowsMismatch: 1, remaining: 1 })
|
||||
expect(insertedRows(calls)).toHaveLength(0)
|
||||
expect(headerUpdates(calls)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('tolerates öresavrundning between the rows and the header', async () => {
|
||||
// Fortnox rounds Total to whole kronor; the header net absorbs the öre.
|
||||
mFetchAll.mockResolvedValue([storedRow({ total: 12263 })])
|
||||
const dto = providerInvoice({
|
||||
lines: [{ id: '1', description: 'Konsult', quantity: 1, unitPrice: amount(9810), lineExtensionAmount: amount(9810), taxPercent: 25 }],
|
||||
taxTotal: { taxAmount: amount(2452.5) },
|
||||
legalMonetaryTotal: { lineExtensionAmount: amount(9810.5), taxInclusiveAmount: amount(12263), payableAmount: amount(12263) },
|
||||
})
|
||||
mList.mockResolvedValue([dto])
|
||||
mHydrate.mockResolvedValue(hydratedAll([dto]))
|
||||
const { supabase, calls } = makeSupabase(() => ok)
|
||||
|
||||
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
|
||||
|
||||
expect(result).toMatchObject({ completed: 1, rowsMismatch: 0 })
|
||||
expect(insertedRows(calls)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves an invoice untouched when the provider total differs from the stored one', async () => {
|
||||
mFetchAll.mockResolvedValue([storedRow({ total: 1300, subtotal: 1300 })])
|
||||
const dto = providerInvoice()
|
||||
|
||||
@@ -76,6 +76,12 @@ export interface CompleteInvoiceLinesResult {
|
||||
totalMismatch: number
|
||||
/** Matched and hydrated, but the detail form itself carries no rows. */
|
||||
noLinesAtProvider: number
|
||||
/**
|
||||
* Matched and hydrated, but the mapped rows do not add up to the header the
|
||||
* same payload established (net or VAT off by more than ROWS_TOLERANCE_KR);
|
||||
* left untouched rather than stored as rows that contradict their invoice.
|
||||
*/
|
||||
rowsMismatch: number
|
||||
/** Matched but not hydrated this run (budget, auth, or a failed fetch); the next run retries them. */
|
||||
notHydrated: number
|
||||
/** Rows written but the header left alone because the detail form established no VAT. */
|
||||
@@ -106,6 +112,17 @@ interface CandidateRow {
|
||||
/** Invoices per statement. Small enough that a chunk's rows stay one request. */
|
||||
const WRITE_CHUNK_SIZE = 100
|
||||
|
||||
/**
|
||||
* How far the rows may disagree with the header before the invoice is left
|
||||
* alone. Öresavrundning puts up to 0.50 kr between a Fortnox `Total` and the
|
||||
* unrounded net plus VAT, and per-row VAT rounding adds öre per row; a real
|
||||
* disagreement (rows priced with VAT inside, a header-level freight or
|
||||
* discount the rows do not carry) is kronor, not öre. A row set that fails
|
||||
* this is a mapper or payload problem to be understood, not stored: rows that
|
||||
* contradict their own header are worse than no rows.
|
||||
*/
|
||||
const ROWS_TOLERANCE_KR = 1
|
||||
|
||||
/**
|
||||
* "number::YYYY-MM-DD", the same key the registration-voucher relink joins
|
||||
* on. A date that does not start like an ISO date joins nothing.
|
||||
@@ -218,6 +235,7 @@ export async function completeMigratedInvoiceLines(
|
||||
headersUpdated: 0,
|
||||
totalMismatch: 0,
|
||||
noLinesAtProvider: 0,
|
||||
rowsMismatch: 0,
|
||||
notHydrated: 0,
|
||||
vatUnresolved: 0,
|
||||
failed: 0,
|
||||
@@ -292,6 +310,21 @@ export async function completeMigratedInvoiceLines(
|
||||
continue
|
||||
}
|
||||
|
||||
if (!mapped.vatUnresolved) {
|
||||
const rowsNet = mapped.items.reduce((sum, item) => sum + Number(item.line_total ?? 0), 0)
|
||||
const rowsVat = mapped.items.reduce((sum, item) => sum + Number(item.vat_amount ?? 0), 0)
|
||||
const headerNet = mapped.invoice.subtotal as number
|
||||
const headerVat = mapped.invoice.vat_amount as number
|
||||
if (Math.abs(rowsNet - headerNet) > ROWS_TOLERANCE_KR || Math.abs(rowsVat - headerVat) > ROWS_TOLERANCE_KR) {
|
||||
result.rowsMismatch++
|
||||
log.warn('mapped rows do not add up to the header; invoice left untouched', {
|
||||
companyId, invoiceId: row.id, invoiceNumber: row.invoice_number,
|
||||
headerNet, rowsNet: roundOre(rowsNet), headerVat, rowsVat: roundOre(rowsVat), rows: mapped.items.length,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let header: HeaderFill | null = null
|
||||
if (mapped.vatUnresolved) {
|
||||
result.vatUnresolved++
|
||||
@@ -371,6 +404,7 @@ export async function completeMigratedInvoiceLines(
|
||||
headersUpdated: result.headersUpdated,
|
||||
notHydrated: result.notHydrated,
|
||||
totalMismatch: result.totalMismatch,
|
||||
rowsMismatch: result.rowsMismatch,
|
||||
failed: result.failed,
|
||||
remaining: result.remaining,
|
||||
hydration: result.hydration,
|
||||
|
||||
Reference in New Issue
Block a user