Files
accounted/lib/providers/bjornlunden/mapper.ts
T
dc5079a912 fix(providers): stop inventing 25% VAT on migrated invoices (#1745)
* fix(providers): stop inventing 25% VAT on migrated invoices

An invoice migrated from Fortnox displayed "Momsbehandling: 25 % moms"
next to "Moms: 0 kr", with no line items behind it. It was not a display
bug: the record really did hold vat_rate 25 and vat_amount 0.

Fortnox answers GET /3/invoices with the short form, which carries no
Net, no TotalVAT and no InvoiceRows; those live only on the detail form.
The migration mapped the list payload alone, so `Net ?? total` made the
net equal the gross, VAT derived as gross minus net came out 0, and with
no rows to read a rate from, inferVatTreatment/inferVatRate fell through
to their `return 'standard_25'` / `return 25` defaults. The result
balanced, so nothing downstream noticed.

Measured on prod: 8 712 sales invoices across 43 companies assert a rate
beside 0 kr of VAT (286 MSEK of subtotal), plus 1 240 supplier invoices.
None are booked, but 263 are still open, and the no-items booking
fallback in invoice-entries.ts credits the full gross to 30xx and emits
no 2611 line at all.

Not Fortnox-only. Visma reported its VAT-inclusive TotalAmount as the
ex-VAT amount and read rows via `LineTotal`/`VatRatePercent`, neither of
which exists in the eAccounting schema (the real names are AmountNoVat
and PercentVat), so its lines all landed at 0. Bjorn Lunden reported the
gross as the net with no lines at all. Briox and WINT had the same
gross-as-net fallback, and Bokio defaulted a missing totalTax to 0.

- lib/providers/amounts.ts: readers that return undefined for an absent
  field, so "the provider says zero" stays distinct from "did not say"
- every mapper: populate taxTotal and per-line taxAmount from what the
  payload actually states; leave the net undefined when it does not
- provider-data-fetcher: hydrate the detail endpoint that every config
  has always declared and nothing ever called, open invoices first,
  within a time budget, reporting whatever it could not reach
- entity-mapper: derive rate and treatment from evidence; when there is
  none, write vat_rate null and flag vatUnresolved instead of asserting
  a standard rate

Existing rows are untouched; repairing them needs a separate decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): keep subtotal + VAT equal to the invoice total

Providers state net, VAT and gross independently and they need not agree:
Fortnox's Total is the amount to pay after öresavrundning while
Net + TotalVAT is the unrounded Gross, so the two differ by up to 50 öre.

Passing both through as stated put that gap into the invoice row, where
subtotal + vat_amount no longer equalled total. The header booking path
in invoice-entries.ts derives the 1510 debit from the sum of its credits,
so the receivable would land a few öre away from what the customer owes
while the verifikat still balanced: the same silent shape as the bug this
branch fixes.

resolveVatTriple now always returns a pair summing to the gross, keeping
the VAT intact (it reaches the momsdeklaration) and absorbing the
rounding into the net.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): address invoice detail by the configured idField

Hydration built the detail path from dto.id. Björn Lundén's sales config
names invoiceNumber as its idField while its mapper builds dto.id from
entityId, so BL sales invoices would have been hydrated from the wrong
resource, or from none. Every other provider/resource pair happens to
agree on the two, which is what made the mismatch easy to miss.

The config's idField is the authority, read off the raw payload, with
dto.id only as the fallback. The regression test uses BL with entityId
99001 and invoiceNumber 5 so the two cannot coincide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): store vat_rate null for migrated mixed-rate invoices

resolveInvoiceVat labelled the header with the first line's rate, so an
invoice carrying both 25 % and 6 % lines was recorded as a 25 % invoice.
buildInvoiceWriteData already stores isMixedRate ? null : theRate for
natively created invoices; migrated ones now match.

The money was already right and stays right: generatePerRateLines groups
per item rate, so a mixed invoice books 25 % and 6 % separately off the
per-line vat_rate/vat_amount this branch fixed. Only the header label was
overstating what the source said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): bound hydration against auth failures and the clock

Two failure modes that only appear against a real provider.

A 401 or 403 fails identically for every remaining invoice, so the pass
now stops on the first one instead of issuing hundreds more doomed
calls. That matters more than it looks: TokenBucketRateLimiter keys on
the literal string 'global', so Fortnox's 4 req/s is a platform-wide
budget shared by every company and every concurrent migration, not a
per-token one. A 404 is about one invoice and does not stop the pass.

The budget was checked before starting a call but never during one. The
clients retry 429s and 5xx with backoff (Fortnox: 6 attempts, up to 60 s
apart), so a call starting one millisecond inside the budget could still
be retrying minutes later, and three concurrent ones could hold the
migration past its 300 s function ceiling. Each call is now raced
against the deadline; the socket is not cancelled, but control returns
and the remaining invoices are reported unhydrated instead of the run
dying.

Both outcomes are reported as HydrationReport.abortedBy so a partial
pass is visible rather than looking complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <invoice@arcim.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:07:52 +02:00

371 lines
14 KiB
TypeScript

import type {
SalesInvoiceDto, InvoiceStatusCode,
LegalMonetaryTotalDto, PaymentStatusDto,
SupplierInvoiceDto,
CustomerDto,
SupplierDto,
JournalDto, AccountingEntryDto,
AccountingAccountDto, AccountType,
CompanyInformationDto,
AmountType, PartyDto,
} from '../dto';
import { readNumber, resolveVatTriple } from '../amounts';
/**
* BL's invoice list carries only gross amounts (`amountInLocalCurrency`) and
* no line items. Those grosses used to be reported as `lineExtensionAmount`,
* i.e. as the amount EXCLUDING VAT, so the migration derived 0 kr of VAT for
* every BL invoice while still labelling it 25 % moms.
*
* BL's list payload is not documented to carry a VAT total; the candidates
* below cover the spellings its endpoints use elsewhere. When none matches,
* the VAT stays unknown and the migration reports it, instead of the mapper
* asserting zero. Hydrating the detail endpoint (provider-data-fetcher) is
* what actually supplies line items for BL.
*/
const BL_VAT_KEYS = [
'vatAmountInLocalCurrency',
'vatAmount',
'vat',
'totalVat',
] as const;
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: value ?? 0, currencyCode: currency };
}
/**
* Derive an invoice lifecycle status from BL's fields.
*
* Sandbox-verified: `status` is an ARRAY of numeric codes. Documented for
* customer invoices (supplier invoices observed to follow the same scheme):
* 0 Unpaid, 1 Overdue, 2 Fully paid, 3 Partially paid, 4 Overpaid,
* 5 Deleted, 6 Customer loss, 7 Marked for collection, 8 Sent for collection.
* Codes ≥40 are ROT/RUT, factoring ("Invoier") and e-invoice transport noise:
* ignored here. The `paid`/`preliminary` booleans are the primary signals;
* the codes refine terminal states the booleans can't express.
*/
function deriveBLInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
const codes = Array.isArray(raw['status'])
? (raw['status'] as unknown[]).map(Number).filter(Number.isFinite)
: [];
// Terminal states win over payment flags: a deleted (makulerad) or
// written-off (kundförlust) invoice must not be imported as open/paid.
if (codes.includes(5) || codes.includes(6)) return 'cancelled';
if (raw['paid'] === true) return 'paid';
if (raw['preliminary'] === true) return 'draft';
if (codes.includes(2) || codes.includes(4)) return 'paid';
if (codes.includes(1) || codes.includes(7) || codes.includes(8)) return 'overdue';
// Defensive: handle a plain string status should BL ever send one
const status = typeof raw['status'] === 'string' ? raw['status'].toLowerCase() : undefined;
if (status === 'cancelled') return 'cancelled';
if (status === 'credited') return 'credited';
if (status === 'sent') return 'sent';
return 'booked';
}
/**
* Map BL Customer Invoice to SalesInvoiceDto.
*
* BL fields: entityId, invoiceNumber, invoiceDate, dueDate, currency,
* customerId, customerName, amount, amountInLocalCurrency,
* amountPaidInLocalCurrency, paid, preliminary, status
*/
export function mapBLToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
const currency = (raw['currency'] as string) ?? 'SEK';
const totalAmount = (raw['amountInLocalCurrency'] as number) ?? (raw['amount'] as number) ?? 0;
const paidAmount = (raw['amountPaidInLocalCurrency'] as number) ?? 0;
const balance = totalAmount - paidAmount;
const customer: PartyDto = {
name: (raw['customerName'] as string) ?? '',
identifications: raw['customerId'] ? [{ id: String(raw['customerId']), schemeId: 'BL:CUSTOMER_ID' }] : [],
};
const vat = resolveVatTriple({
gross: totalAmount,
vat: readNumber(raw, BL_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
// `amountInLocalCurrency` is the gross. Reporting it as the net is what
// made every BL invoice look like a 0 kr VAT sale.
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(totalAmount, currency),
payableAmount: amount(totalAmount, currency),
};
const paymentStatus: PaymentStatusDto = {
paid: raw['paid'] === true,
balance: amount(balance, currency),
};
return {
id: String(raw['entityId'] ?? raw['invoiceNumber'] ?? ''),
invoiceNumber: String(raw['invoiceNumber'] ?? ''),
issueDate: (raw['invoiceDate'] as string) ?? '',
dueDate: raw['dueDate'] as string | undefined,
currencyCode: currency,
status: deriveBLInvoiceStatus(raw),
supplier: { name: '', identifications: [] },
customer,
lines: [], // BL doesn't include line items in list responses
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
_raw: raw,
};
}
/**
* Map BL Supplier Invoice to SupplierInvoiceDto.
*
* BL fields: entityId, invoiceNumber, invoiceDate, dueDate, currency,
* supplierId, supplierName, amountInLocalCurrency,
* amountPaidInLocalCurrency, amountRemainingInLocalCurrency, paid, preliminary, status
*/
export function mapBLToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
const currency = (raw['currency'] as string) ?? 'SEK';
const totalAmount = (raw['amountInLocalCurrency'] as number) ?? 0;
const paidAmount = (raw['amountPaidInLocalCurrency'] as number) ?? 0;
const remaining = (raw['amountRemainingInLocalCurrency'] as number) ?? (totalAmount - paidAmount);
const supplier: PartyDto = {
name: (raw['supplierName'] as string) ?? '',
identifications: raw['supplierId'] ? [{ id: String(raw['supplierId']), schemeId: 'BL:SUPPLIER_ID' }] : [],
};
const vat = resolveVatTriple({
gross: totalAmount,
vat: readNumber(raw, BL_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
// `amountInLocalCurrency` is the gross. Reporting it as the net is what
// made every BL invoice look like a 0 kr VAT sale.
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(totalAmount, currency),
payableAmount: amount(totalAmount, currency),
};
const paymentStatus: PaymentStatusDto = {
paid: raw['paid'] === true,
balance: amount(remaining, currency),
};
return {
id: String(raw['entityId'] ?? raw['invoiceNumber'] ?? ''),
invoiceNumber: String(raw['invoiceNumber'] ?? ''),
issueDate: (raw['invoiceDate'] as string) ?? '',
dueDate: raw['dueDate'] as string | undefined,
currencyCode: currency,
status: deriveBLInvoiceStatus(raw),
supplier,
buyer: { name: '', identifications: [] },
lines: [], // BL doesn't include line items in list responses
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
_raw: raw,
};
}
/**
* Map BL Customer to CustomerDto.
*
* BL fields: entityId, id, name, organisationNumber, street, box, zip, city,
* country, phone, email, currency, vatNumber, paymentTerms, closed
*/
export function mapBLToCustomer(raw: Record<string, unknown>): CustomerDto {
const name = (raw['name'] as string) ?? '';
const orgNumber = raw['organisationNumber'] as string | undefined;
const party: PartyDto = {
name,
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
postalAddress: {
streetName: raw['street'] as string | undefined,
additionalStreetName: raw['box'] as string | undefined,
postalZone: raw['zip'] as string | undefined,
cityName: raw['city'] as string | undefined,
countryCode: raw['country'] as string | undefined,
},
legalEntity: orgNumber ? {
registrationName: name,
companyId: orgNumber,
companyIdSchemeId: 'SE:ORGNR',
} : undefined,
contact: {
telephone: raw['phone'] as string | undefined,
email: raw['email'] as string | undefined,
},
};
return {
id: String(raw['id'] ?? raw['entityId'] ?? ''),
customerNumber: String(raw['id'] ?? ''),
type: 'company',
party,
active: raw['closed'] !== true,
vatNumber: raw['vatNumber'] as string | undefined,
defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined,
_raw: raw,
};
}
/**
* Map BL Supplier to SupplierDto.
*
* BL fields: entityId, id, name, organisationId, address1, address2, zipCode, city,
* countryCode, phone, email, bg, pg, iban, vatNr, paymentTerms, closed
*/
export function mapBLToSupplier(raw: Record<string, unknown>): SupplierDto {
const name = (raw['name'] as string) ?? '';
const orgNumber = raw['organisationId'] as string | undefined;
const party: PartyDto = {
name,
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
postalAddress: {
streetName: raw['address1'] as string | undefined,
additionalStreetName: raw['address2'] as string | undefined,
postalZone: raw['zipCode'] as string | undefined,
cityName: raw['city'] as string | undefined,
countryCode: raw['countryCode'] as string | undefined,
},
legalEntity: orgNumber ? {
registrationName: name,
companyId: orgNumber,
companyIdSchemeId: 'SE:ORGNR',
} : undefined,
contact: {
telephone: raw['phone'] as string | undefined,
email: raw['email'] as string | undefined,
},
};
return {
id: String(raw['id'] ?? raw['entityId'] ?? ''),
supplierNumber: String(raw['id'] ?? ''),
party,
active: raw['closed'] !== true,
vatNumber: raw['vatNr'] as string | undefined,
bankGiro: raw['bg'] as string | undefined,
plusGiro: raw['pg'] as string | undefined,
bankAccount: raw['iban'] as string | undefined,
defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined,
_raw: raw,
};
}
/**
* Map BL Journal/Ledger Entry to JournalDto.
*
* BL fields: entityId, journalId, journalEntryId, journalEntryDate,
* journalEntryText, financialYearId, ledgerEntries[{ accountId, amount, text }],
* totalCreditSum, totalDebitSum
*/
export function mapBLToJournal(raw: Record<string, unknown>): JournalDto {
const rawEntries = (raw['ledgerEntries'] as Record<string, unknown>[] | undefined) ?? [];
const entries: AccountingEntryDto[] = rawEntries.map((entry) => {
// BL LedgerEntry has a single `amount` field: positive = debit, negative = credit
const amt = (entry['amount'] as number) ?? 0;
return {
accountNumber: String(entry['accountId'] ?? ''),
debit: amt > 0 ? amt : 0,
credit: amt < 0 ? Math.abs(amt) : 0,
description: entry['text'] as string | undefined,
};
});
const totalCredit = (raw['totalCreditSum'] as number) ?? 0;
const totalDebit = (raw['totalDebitSum'] as number) ?? entries.reduce((sum, e) => sum + e.debit, 0);
return {
id: String(raw['entityId'] ?? raw['journalEntryId'] ?? ''),
journalNumber: String(raw['journalId'] ?? raw['journalEntryId'] ?? ''),
description: raw['journalEntryText'] as string | undefined,
registrationDate: (raw['journalEntryDate'] as string) ?? '',
fiscalYear: raw['financialYearId'] != null ? Number(raw['financialYearId']) : undefined,
entries,
totalDebit: { value: totalDebit, currencyCode: 'SEK' },
totalCredit: { value: totalCredit, currencyCode: 'SEK' },
_raw: raw,
};
}
/** BL's own account `type` values (sandbox-verified) → our AccountType. */
const BL_ACCOUNT_TYPE_MAP: Record<string, AccountType> = {
asset: 'asset',
liability: 'liability',
income: 'revenue',
cost: 'expense',
};
/**
* Map BL Account to AccountingAccountDto.
*
* BL fields: entityId, id (account number), name, vatCode, sruCode, closed, type
* BL sends an explicit `type` (asset|liability|income|cost): prefer it, since
* it also covers off-plan accounts like 0099 "Konvertering"; fall back to BAS
* number ranges when absent.
*/
export function mapBLToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
const num = Number(raw['id']);
let type: AccountType | undefined = BL_ACCOUNT_TYPE_MAP[String(raw['type'] ?? '').toLowerCase()];
if (!type) {
if (num >= 1000 && num < 2000) type = 'asset';
else if (num >= 2000 && num < 3000) type = 'liability';
else if (num >= 3000 && num < 4000) type = 'revenue';
else if (num >= 4000 && num < 9000) type = 'expense';
}
return {
accountNumber: String(raw['id'] ?? ''),
name: (raw['name'] as string) ?? '',
type,
vatCode: raw['vatCode'] as string | undefined,
sruCode: raw['sruCode'] != null ? String(raw['sruCode']) : undefined,
active: raw['closed'] !== true,
balanceCarriedForward: (raw['debit'] != null || raw['credit'] != null)
? (Number(raw['debit'] ?? 0) - Number(raw['credit'] ?? 0))
: undefined,
_raw: raw,
};
}
/**
* Map BL Company Details to CompanyInformationDto.
*
* BL fields: name, orgNumber, street, box, zip, city, country,
* phone, email, bg, pg, iban, vatNumber, preferredSettings.currency
*/
export function mapBLToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
const settings = raw['preferredSettings'] as Record<string, unknown> | undefined;
return {
companyName: (raw['name'] as string) ?? '',
organizationNumber: raw['orgNumber'] as string | undefined,
legalEntity: {
registrationName: (raw['name'] as string) ?? '',
companyId: raw['orgNumber'] as string | undefined,
companyIdSchemeId: 'SE:ORGNR',
},
address: {
streetName: raw['street'] as string | undefined,
additionalStreetName: raw['box'] as string | undefined,
postalZone: raw['zip'] as string | undefined,
cityName: raw['city'] as string | undefined,
countryCode: raw['country'] as string | undefined,
},
contact: {
telephone: raw['phone'] as string | undefined,
email: raw['email'] as string | undefined,
},
vatNumber: raw['vatNumber'] as string | undefined,
baseCurrency: (settings?.['currency'] as string) ?? 'SEK',
_raw: raw,
};
}