Files
accounted/lib/providers/fortnox/mapper.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

457 lines
19 KiB
TypeScript

import type {
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
LegalMonetaryTotalDto, PaymentStatusDto,
SupplierInvoiceDto, SupplierInvoiceLineDto,
CustomerDto, SupplierDto,
JournalDto, AccountingEntryDto,
AccountingAccountDto, AccountType,
CompanyInformationDto,
AmountType, PartyDto,
} from '../dto';
import { readNumber, resolveVatTriple, lineVatFromPercent } from '../amounts';
import { sourceVoucherFromParts } from '../source-voucher';
import { roundOre } from '@/lib/money';
/**
* A row amount net of VAT.
*
* Fortnox prices an invoice either excluding or including VAT, and says which
* with the invoice-level `VATIncluded` flag: when it is true the row `Price`
* and `Total` are the amounts the customer saw, VAT inside, and the net is
* the amount divided by (1 + rate). Newer payloads also carry the net on the
* row (`TotalExcludingVAT`, `PriceExcludingVAT`), which callers prefer when
* present; this is the fallback for the ones that do not. Without a stated
* rate the amount cannot be split and is returned as it is, and the
* consumer's rows-versus-header check reports the disagreement.
*
* Found on Profilio (2026-09-05): 345 VAT-inclusive invoices whose rows were
* stored as if net, so the rows summed to the gross and carried 25 % VAT on
* top of it, beside a header that was right.
*/
function netOfVat(amount: number, vatIncluded: boolean, ratePercent: number | undefined): number {
if (!vatIncluded || ratePercent === undefined) return amount;
const rate = ratePercent > 1 ? ratePercent / 100 : ratePercent;
return roundOre(amount / (1 + rate));
}
/**
* Fortnox splits its invoice payloads in two. `GET /3/invoices` answers with
* the short form (`InvoiceShort`): DocumentNumber, dates, customer, `Total`,
* `Balance` and the status flags, but no `Net`, no `TotalVAT` and no
* `InvoiceRows`. Those three live only on the detail form (`InvoiceFull`)
* behind `GET /3/invoices/{DocumentNumber}`.
*
* The migration used to map the list form alone, so `Net` was always absent
* and defaulted to `Total`; VAT, derived as gross minus net, came out 0 on
* every migrated Fortnox invoice. Hydrating the detail (see
* `hydrateSalesInvoiceDetails` in provider-data-fetcher.ts) is what makes
* these fields available; the readers below keep "absent" distinguishable
* from "zero" for the invoices that are not hydrated.
*/
const FORTNOX_NET_KEYS = ['Net'] as const;
const FORTNOX_VAT_KEYS = ['TotalVAT'] as const;
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: value ?? 0, currencyCode: currency };
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function providerEmailAddresses(
raw: Record<string, unknown>,
field: string,
): string[] | undefined {
if (!(field in raw)) return undefined;
const value = raw[field];
const parts = Array.isArray(value)
? value.flatMap((item) => typeof item === 'string' ? item.split(/[\n,;]+/) : [])
: typeof value === 'string'
? value.split(/[\n,;]+/)
: [];
const seen = new Set<string>();
return parts.flatMap((part) => {
const address = part.trim();
const key = address.toLocaleLowerCase('en-US');
if (!key || seen.has(key)) return [];
seen.add(key);
return [address];
});
}
/**
* 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 (isFullyPaid(raw)) return 'paid';
if (raw['Booked'] === true) return 'booked';
if (raw['Sent'] === true) return 'sent';
return 'draft';
}
function buildParty(name: string, orgNumber?: string, address?: Record<string, unknown>): PartyDto {
return {
name,
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
postalAddress: address ? {
streetName: (address['Address1'] ?? address['Address']) as string | undefined,
additionalStreetName: address['Address2'] as string | undefined,
cityName: (address['City'] ?? address['CityName']) as string | undefined,
postalZone: (address['ZipCode'] ?? address['PostalCode']) as string | undefined,
countryCode: address['Country'] as string | undefined,
} : undefined,
legalEntity: orgNumber ? {
registrationName: name,
companyId: orgNumber,
companyIdSchemeId: 'SE:ORGNR',
} : undefined,
contact: {
name: nonEmptyString(address?.['YourReference']),
// EmailInvoice is the delivery address. Email is the general contact
// fallback and must not override an invoice-specific address.
email: nonEmptyString(address?.['EmailInvoice']) ?? nonEmptyString(address?.['Email']),
telephone: nonEmptyString(address?.['Phone1']),
},
};
}
/** Header-level charges Fortnox keeps outside InvoiceRows, as rows. */
const FORTNOX_HEADER_CHARGES = [
{ id: 'freight', amountKey: 'Freight', vatKey: 'FreightVAT', description: 'Frakt' },
{ id: 'administration-fee', amountKey: 'AdministrationFee', vatKey: 'AdministrationFeeVAT', description: 'Administrationsavgift' },
] as const;
function headerChargeLines(
raw: Record<string, unknown>,
vatIncluded: boolean,
currency: string,
): SalesInvoiceLineDto[] {
const lines: SalesInvoiceLineDto[] = [];
for (const charge of FORTNOX_HEADER_CHARGES) {
const stated = readNumber(raw, [charge.amountKey]);
if (!stated) continue;
const vatAmount = readNumber(raw, [charge.vatKey]) ?? 0;
const net = vatIncluded ? roundOre(stated - vatAmount) : stated;
// The rate is not stated for a charge; it follows from the two amounts.
const taxPercent = net !== 0 ? Math.round((vatAmount / net) * 100) : 0;
lines.push({
id: charge.id,
description: charge.description,
quantity: 1,
unitPrice: amount(net, currency),
lineExtensionAmount: amount(net, currency),
taxPercent,
taxAmount: amount(vatAmount, currency),
itemName: charge.description,
});
}
return lines;
}
export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
const currency = (raw['Currency'] as string) ?? 'SEK';
const total = raw['Total'] 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);
// Whether the row amounts include VAT. Absent on the list form, where there
// are no rows anyway; false is the default when the detail form omits it.
const vatIncluded = raw['VATIncluded'] === true;
const rows = (raw['InvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => {
// `VAT` on a row is the rate in percent (25), not an amount. `Total` and
// `Price` are net only when the invoice is priced excluding VAT; see
// netOfVat for the VATIncluded case.
const taxPercent = readNumber(row, ['VAT']);
const lineNet = readNumber(row, ['TotalExcludingVAT'])
?? netOfVat(readNumber(row, ['Total']) ?? 0, vatIncluded, taxPercent);
const rawPrice = readNumber(row, ['Price']);
const unitPrice = readNumber(row, ['PriceExcludingVAT'])
?? (rawPrice !== undefined ? netOfVat(rawPrice, vatIncluded, taxPercent) : undefined);
const lineVat = lineVatFromPercent(lineNet, taxPercent);
return {
id: String(row['RowId'] ?? idx + 1),
description: row['Description'] as string | undefined,
// Fortnox serialises the quantity as a string ("14"); read it as a number.
quantity: readNumber(row, ['DeliveredQuantity']),
unitCode: row['Unit'] as string | undefined,
unitPrice: unitPrice !== undefined ? amount(unitPrice, currency) : undefined,
lineExtensionAmount: amount(lineNet, currency),
taxPercent,
// Fortnox states the rate per row but not the money. Deriving it here is
// what lets the migration write a per-line vat_amount: the booking engine
// sums those to post 2611, so a line left at 0 posts no output VAT.
taxAmount: lineVat !== undefined ? amount(lineVat, currency) : undefined,
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
itemName: row['Description'] as string | undefined,
};
});
// Freight and administration fee live on the header, not in InvoiceRows,
// and Fortnox's `Net` excludes them while `TotalVAT` and `Total` include
// them. Verified on live payloads (Profilio 295 and 242, 2026-09-05):
// `Freight` is the fee as the customer saw it (gross when VATIncluded,
// net otherwise) and `FreightVAT` is the VAT AMOUNT on it, not a rate
// (88 and 22 on a 25 % invoice). The same pair exists for the fee. Without
// these as rows, the rows sum to less than the header by exactly the
// charge and the migration's rows-versus-header check refuses the invoice.
lines.push(...headerChargeLines(raw, vatIncluded, currency));
const vat = resolveVatTriple({
gross: total,
net: readNumber(raw, FORTNOX_NET_KEYS),
vat: readNumber(raw, FORTNOX_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
// Undefined when the payload is the list form: the net was not stated and
// must not be assumed equal to the gross.
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
const paymentStatus: PaymentStatusDto = {
paid,
balance: amount(balance, currency),
};
return {
id: String(raw['DocumentNumber'] ?? ''),
invoiceNumber: String(raw['DocumentNumber'] ?? ''),
issueDate: (raw['InvoiceDate'] as string) ?? '',
dueDate: raw['DueDate'] as string | undefined,
currencyCode: currency,
status: deriveInvoiceStatus(raw),
supplier: buildParty(
(raw['CompanyName'] ?? '') as string,
raw['OrganisationNumber'] as string | undefined,
),
customer: buildParty(
(raw['CustomerName'] ?? '') as string,
raw['OrganisationNumber'] as string | undefined,
raw as Record<string, unknown>,
),
lines,
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
paymentTerms: raw['TermsOfPayment'] as string | undefined,
note: raw['Remarks'] as string | undefined,
buyerReference: raw['YourReference'] as string | undefined,
orderReference: raw['YourOrderNumber'] as string | undefined,
// The booking voucher, present on the detail form of a booked invoice.
// `VoucherYear` is deliberately not read: the invoice date resolves the
// fiscal year on our side, and the source's year id is not ours.
sourceVoucher: sourceVoucherFromParts(raw['VoucherSeries'], raw['VoucherNumber']) ?? undefined,
updatedAt: raw['@LastModified'] as string | undefined,
_raw: raw,
};
}
export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
const currency = (raw['Currency'] as string) ?? 'SEK';
const total = raw['Total'] 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) => {
const lineNet = readNumber(row, ['Total']) ?? 0;
const taxPercent = readNumber(row, ['VAT']);
const lineVat = lineVatFromPercent(lineNet, taxPercent);
return {
id: String(row['RowId'] ?? idx + 1),
description: row['Description'] as string | undefined,
quantity: row['Quantity'] as number | undefined,
unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined,
lineExtensionAmount: amount(lineNet, currency),
taxPercent,
taxAmount: lineVat !== undefined ? amount(lineVat, currency) : undefined,
accountNumber: row['Account'] != null ? String(row['Account']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
};
});
const vat = resolveVatTriple({
gross: total,
net: readNumber(raw, FORTNOX_NET_KEYS),
vat: readNumber(raw, FORTNOX_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
const paymentStatus: PaymentStatusDto = {
paid,
balance: amount(balance, currency),
};
return {
id: String(raw['GivenNumber'] ?? ''),
invoiceNumber: String(raw['GivenNumber'] ?? ''),
issueDate: (raw['InvoiceDate'] as string) ?? '',
dueDate: raw['DueDate'] as string | undefined,
currencyCode: currency,
status: deriveInvoiceStatus(raw),
supplier: buildParty(
(raw['SupplierName'] ?? '') as string,
raw['OrganisationNumber'] as string | undefined,
),
buyer: buildParty(''),
lines,
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
ocrNumber: raw['OCR'] as string | undefined,
sourceVoucher: sourceVoucherFromParts(raw['VoucherSeries'], raw['VoucherNumber']) ?? undefined,
updatedAt: raw['@LastModified'] as string | undefined,
_raw: raw,
};
}
export function mapFortnoxToCustomer(raw: Record<string, unknown>): CustomerDto {
const name = (raw['Name'] as string) ?? '';
const orgNumber = raw['OrganisationNumber'] as string | undefined;
return {
id: String(raw['CustomerNumber'] ?? ''),
customerNumber: String(raw['CustomerNumber'] ?? ''),
type: raw['Type'] === 'PRIVATE' ? 'private' : 'company',
party: buildParty(name, orgNumber, raw),
invoiceEmailCcAddresses: providerEmailAddresses(raw, 'EmailInvoiceCC'),
invoiceEmailBccAddresses: providerEmailAddresses(raw, 'EmailInvoiceBCC'),
active: raw['Active'] !== false,
vatNumber: raw['VATNumber'] as string | undefined,
defaultPaymentTermsDays: raw['TermsOfPayment'] != null ? Number(raw['TermsOfPayment']) : undefined,
note: raw['Comments'] as string | undefined,
updatedAt: raw['@LastModified'] as string | undefined,
_raw: raw,
};
}
export function mapFortnoxToSupplier(raw: Record<string, unknown>): SupplierDto {
const name = (raw['Name'] as string) ?? '';
const orgNumber = raw['OrganisationNumber'] as string | undefined;
return {
id: String(raw['SupplierNumber'] ?? ''),
supplierNumber: String(raw['SupplierNumber'] ?? ''),
party: buildParty(name, orgNumber, raw),
active: raw['Active'] !== false,
vatNumber: raw['VATNumber'] as string | undefined,
bankAccount: raw['BankAccountNumber'] as string | undefined,
bankGiro: raw['BG'] as string | undefined,
plusGiro: raw['PG'] as string | undefined,
defaultPaymentTermsDays: raw['TermsOfPayment'] != null ? Number(raw['TermsOfPayment']) : undefined,
note: raw['Comments'] as string | undefined,
updatedAt: raw['@LastModified'] as string | undefined,
_raw: raw,
};
}
export function mapFortnoxToJournal(raw: Record<string, unknown>): JournalDto {
const voucherRows = (raw['VoucherRows'] as Record<string, unknown>[] | undefined) ?? [];
const entries: AccountingEntryDto[] = voucherRows.map((row) => ({
accountNumber: String(row['Account'] ?? ''),
accountName: row['AccountDescription'] as string | undefined,
debit: (row['Debit'] as number) ?? 0,
credit: (row['Credit'] as number) ?? 0,
transactionDate: row['TransactionDate'] as string | undefined,
description: row['Description'] as string | undefined,
}));
return {
id: `${raw['VoucherSeries'] ?? ''}-${raw['VoucherNumber'] ?? ''}`,
journalNumber: String(raw['VoucherNumber'] ?? ''),
series: raw['VoucherSeries'] ? {
id: String(raw['VoucherSeries']),
description: raw['VoucherSeriesDescription'] as string | undefined,
} : undefined,
description: raw['Description'] as string | undefined,
registrationDate: (raw['TransactionDate'] as string) ?? '',
fiscalYear: raw['Year'] != null ? Number(raw['Year']) : undefined,
entries,
_raw: raw,
};
}
export function mapFortnoxToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
let type: AccountType | undefined;
const num = Number(raw['Number']);
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['Number'] ?? ''),
name: (raw['Description'] as string) ?? '',
type,
vatCode: raw['VATCode'] as string | undefined,
active: raw['Active'] !== false,
balanceBroughtForward: raw['BalanceBroughtForward'] as number | undefined,
balanceCarriedForward: raw['BalanceCarriedForward'] as number | undefined,
sruCode: raw['SRU'] != null ? String(raw['SRU']) : undefined,
_raw: raw,
};
}
export function mapFortnoxToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
return {
companyName: (raw['CompanyName'] as string) ?? '',
organizationNumber: raw['OrganizationNumber'] as string | undefined,
legalEntity: {
registrationName: (raw['CompanyName'] as string) ?? '',
companyId: raw['OrganizationNumber'] as string | undefined,
companyIdSchemeId: 'SE:ORGNR',
},
address: {
streetName: raw['Address'] as string | undefined,
cityName: raw['City'] as string | undefined,
postalZone: raw['ZipCode'] as string | undefined,
countryCode: raw['Country'] as string | undefined,
},
contact: {
email: raw['Email'] as string | undefined,
telephone: raw['Phone1'] as string | undefined,
website: raw['WWW'] as string | undefined,
},
_raw: raw,
};
}