dc5079a912
* 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>
346 lines
14 KiB
TypeScript
346 lines
14 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';
|
|
|
|
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
|
|
return { value: value ?? 0, currencyCode: currency };
|
|
}
|
|
|
|
/** Briox often serializes numbers as strings ("250.00"): coerce defensively. */
|
|
function num(value: unknown): number | undefined {
|
|
if (value == null || value === '') return undefined;
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : undefined;
|
|
}
|
|
|
|
/**
|
|
* Briox exposes the ex-VAT amount as `net_amount`, which the list payload has
|
|
* been observed to omit. It used to fall back to the gross total, making the
|
|
* derived VAT 0 on any invoice missing the field. The VAT total itself is
|
|
* spelled inconsistently across Briox's endpoints, so several candidates are
|
|
* tried; none matching leaves the VAT unknown rather than zero.
|
|
*/
|
|
const BRIOX_NET_KEYS = ['net_amount'] as const;
|
|
const BRIOX_VAT_KEYS = ['vat_amount', 'total_vat', 'vat'] as const;
|
|
|
|
/**
|
|
* Single source of truth for "is this invoice fully settled?", used by BOTH
|
|
* deriveInvoiceStatus and the paymentStatus.paid flag so they can never
|
|
* diverge (mirrors the Fortnox mapper). An ABSENT balance is treated as NOT
|
|
* paid: only an explicit paid status/flag, or a present non-positive balance
|
|
* on a positive-total invoice, counts as paid.
|
|
*/
|
|
function isFullyPaid(raw: Record<string, unknown>): boolean {
|
|
if (raw['status'] === 'paid' || raw['fully_paid'] === true) return true;
|
|
const total = num(raw['total_amount']);
|
|
const balance = num(raw['balance']);
|
|
return total != null && total > 0 && balance != null && balance <= 0;
|
|
}
|
|
|
|
function deriveInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
|
|
const status = raw['status'] as string | undefined;
|
|
if (status === 'cancelled') return 'cancelled';
|
|
if (status === 'credited') return 'credited';
|
|
if (isFullyPaid(raw)) return 'paid';
|
|
if (status === 'booked' || raw['booked'] === true) return 'booked';
|
|
if (status === 'sent' || raw['sent'] === true) return 'sent';
|
|
if (status === 'overdue') return 'overdue';
|
|
return 'draft';
|
|
}
|
|
|
|
function buildParty(name: string, orgNumber?: string, raw?: Record<string, unknown>): PartyDto {
|
|
return {
|
|
name,
|
|
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
|
|
postalAddress: raw ? {
|
|
streetName: (raw['address1'] ?? raw['address']) as string | undefined,
|
|
additionalStreetName: raw['address2'] as string | undefined,
|
|
cityName: raw['city'] as string | undefined,
|
|
postalZone: (raw['zip_code'] ?? raw['postal_code']) as string | undefined,
|
|
countryCode: raw['country'] as string | undefined,
|
|
} : undefined,
|
|
legalEntity: orgNumber ? {
|
|
registrationName: name,
|
|
companyId: orgNumber,
|
|
companyIdSchemeId: 'SE:ORGNR',
|
|
} : undefined,
|
|
contact: {
|
|
email: raw?.['email'] as string | undefined,
|
|
telephone: raw?.['phone'] as string | undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function mapBrioxToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
|
|
const currency = (raw['currency_code'] as string) ?? 'SEK';
|
|
const total = num(raw['total_amount']) ?? 0;
|
|
// Default an ABSENT balance to the full total (= fully unpaid), never 0, so
|
|
// a missing balance never silently reads as paid. When paid, force balance
|
|
// to 0 so the DTO is internally consistent (paid ⇒ nothing outstanding).
|
|
const paid = isFullyPaid(raw);
|
|
const balance = paid ? 0 : (num(raw['balance']) ?? total);
|
|
|
|
const rows = (raw['rows'] as Record<string, unknown>[] | undefined) ?? [];
|
|
// Line-level amounts arrive from the same string-serializing API as the
|
|
// header amounts: coerce ALL numerics through num(), never blind casts.
|
|
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => {
|
|
const lineNet = num(row['total']);
|
|
const taxPercent = num(row['vat_rate']);
|
|
const lineVat = lineNet !== undefined ? lineVatFromPercent(lineNet, taxPercent) : undefined;
|
|
|
|
return {
|
|
id: String(row['id'] ?? idx + 1),
|
|
description: row['description'] as string | undefined,
|
|
quantity: num(row['quantity']),
|
|
unitCode: row['unit'] as string | undefined,
|
|
unitPrice: row['price'] != null ? amount(num(row['price']), currency) : undefined,
|
|
lineExtensionAmount: amount(lineNet, currency),
|
|
taxPercent,
|
|
// Briox states the rate per row but not the money; the migration needs
|
|
// the money, because the booking engine sums per-line VAT to post 2611.
|
|
taxAmount: lineVat !== undefined ? amount(lineVat, currency) : undefined,
|
|
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
|
|
articleNumber: row['article_number'] as string | undefined,
|
|
itemName: row['description'] as string | undefined,
|
|
};
|
|
});
|
|
|
|
const vat = resolveVatTriple({
|
|
gross: total,
|
|
net: readNumber(raw, BRIOX_NET_KEYS),
|
|
vat: readNumber(raw, BRIOX_VAT_KEYS),
|
|
});
|
|
|
|
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
|
// Undefined when `net_amount` is absent: falling back to the gross records
|
|
// the whole invoice as its own net, and 0 kr of VAT alongside it.
|
|
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['id'] ?? ''),
|
|
invoiceNumber: String(raw['invoice_number'] ?? raw['id'] ?? ''),
|
|
issueDate: (raw['invoice_date'] as string) ?? '',
|
|
dueDate: raw['due_date'] as string | undefined,
|
|
currencyCode: currency,
|
|
status: deriveInvoiceStatus(raw),
|
|
supplier: buildParty(''),
|
|
customer: buildParty(
|
|
(raw['customer_name'] ?? '') as string,
|
|
raw['customer_org_number'] as string | undefined,
|
|
),
|
|
lines,
|
|
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
|
|
legalMonetaryTotal,
|
|
paymentStatus,
|
|
paymentTerms: raw['payment_terms'] as string | undefined,
|
|
note: raw['remarks'] as string | undefined,
|
|
buyerReference: raw['your_reference'] as string | undefined,
|
|
orderReference: raw['your_order_number'] as string | undefined,
|
|
updatedAt: raw['modified_date'] as string | undefined,
|
|
_raw: raw,
|
|
};
|
|
}
|
|
|
|
export function mapBrioxToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
|
|
const currency = (raw['currency_code'] as string) ?? 'SEK';
|
|
const total = num(raw['total_amount']) ?? 0;
|
|
// Same absent-balance hardening as the sales path: missing balance reads as
|
|
// fully unpaid, paid forces balance to 0.
|
|
const paid = isFullyPaid(raw);
|
|
const balance = paid ? 0 : (num(raw['balance']) ?? total);
|
|
|
|
const rows = (raw['rows'] as Record<string, unknown>[] | undefined) ?? [];
|
|
// Same string-coercion hardening as the sales path (Briox serializes
|
|
// numbers as strings): route every numeric line field through num().
|
|
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
|
|
id: String(row['id'] ?? idx + 1),
|
|
description: row['description'] as string | undefined,
|
|
quantity: num(row['quantity']),
|
|
unitPrice: row['price'] != null ? amount(num(row['price']), currency) : undefined,
|
|
lineExtensionAmount: amount(num(row['total']), currency),
|
|
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
|
|
}));
|
|
|
|
const vat = resolveVatTriple({
|
|
gross: total,
|
|
net: readNumber(raw, BRIOX_NET_KEYS),
|
|
vat: readNumber(raw, BRIOX_VAT_KEYS),
|
|
});
|
|
|
|
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
|
// Undefined when `net_amount` is absent: falling back to the gross records
|
|
// the whole invoice as its own net, and 0 kr of VAT alongside it.
|
|
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['id'] ?? ''),
|
|
invoiceNumber: String(raw['invoice_number'] ?? raw['id'] ?? ''),
|
|
issueDate: (raw['invoice_date'] as string) ?? '',
|
|
dueDate: raw['due_date'] as string | undefined,
|
|
currencyCode: currency,
|
|
status: deriveInvoiceStatus(raw),
|
|
supplier: buildParty(
|
|
(raw['supplier_name'] ?? '') as string,
|
|
raw['supplier_org_number'] as string | undefined,
|
|
),
|
|
buyer: buildParty(''),
|
|
lines,
|
|
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
|
|
legalMonetaryTotal,
|
|
paymentStatus,
|
|
ocrNumber: raw['ocr'] as string | undefined,
|
|
updatedAt: raw['modified_date'] as string | undefined,
|
|
_raw: raw,
|
|
};
|
|
}
|
|
|
|
export function mapBrioxToCustomer(raw: Record<string, unknown>): CustomerDto {
|
|
const name = (raw['name'] as string) ?? '';
|
|
const orgNumber = raw['org_number'] as string | undefined;
|
|
|
|
return {
|
|
id: String(raw['id'] ?? ''),
|
|
customerNumber: String(raw['customer_number'] ?? raw['id'] ?? ''),
|
|
type: raw['type'] === 'private' ? 'private' : 'company',
|
|
party: buildParty(name, orgNumber, raw),
|
|
active: raw['active'] !== false,
|
|
vatNumber: raw['vat_number'] as string | undefined,
|
|
defaultPaymentTermsDays: raw['payment_terms_days'] != null ? Number(raw['payment_terms_days']) : undefined,
|
|
note: raw['note'] as string | undefined,
|
|
updatedAt: raw['modified_date'] as string | undefined,
|
|
_raw: raw,
|
|
};
|
|
}
|
|
|
|
export function mapBrioxToSupplier(raw: Record<string, unknown>): SupplierDto {
|
|
const name = (raw['name'] as string) ?? '';
|
|
const orgNumber = raw['org_number'] as string | undefined;
|
|
|
|
return {
|
|
id: String(raw['id'] ?? ''),
|
|
supplierNumber: String(raw['supplier_number'] ?? raw['id'] ?? ''),
|
|
party: buildParty(name, orgNumber, raw),
|
|
active: raw['active'] !== false,
|
|
vatNumber: raw['vat_number'] as string | undefined,
|
|
bankAccount: raw['bank_account'] as string | undefined,
|
|
bankGiro: raw['bank_giro'] as string | undefined,
|
|
plusGiro: raw['plus_giro'] as string | undefined,
|
|
defaultPaymentTermsDays: raw['payment_terms_days'] != null ? Number(raw['payment_terms_days']) : undefined,
|
|
note: raw['note'] as string | undefined,
|
|
updatedAt: raw['modified_date'] as string | undefined,
|
|
_raw: raw,
|
|
};
|
|
}
|
|
|
|
export function mapBrioxToJournal(raw: Record<string, unknown>): JournalDto {
|
|
// Briox detail API returns rows as "journal_rows" (list endpoint omits them)
|
|
const rows = (raw['journal_rows'] as Record<string, unknown>[] | undefined)
|
|
?? (raw['journalrows'] as Record<string, unknown>[] | undefined) ?? [];
|
|
const entries: AccountingEntryDto[] = rows.map((row) => ({
|
|
// Briox uses "account" (not "account_number") for the account field
|
|
accountNumber: String(row['account'] ?? row['account_number'] ?? ''),
|
|
accountName: row['account_name'] as string | undefined,
|
|
// Briox returns debit/credit as strings
|
|
debit: Number(row['debit'] ?? 0),
|
|
credit: Number(row['credit'] ?? 0),
|
|
transactionDate: (row['transactiondate'] ?? row['transaction_date']) as string | undefined,
|
|
description: (row['transactioninfo'] ?? row['description']) as string | undefined,
|
|
}));
|
|
|
|
return {
|
|
id: String(raw['id'] ?? ''),
|
|
journalNumber: String(raw['id'] ?? raw['journal_number'] ?? ''),
|
|
series: raw['series'] ? {
|
|
id: String(raw['series']),
|
|
} : undefined,
|
|
// Briox uses "descr" for the journal description
|
|
description: (raw['descr'] ?? raw['description']) as string | undefined,
|
|
// Briox uses "transactiondate" for the date
|
|
registrationDate: ((raw['transactiondate'] ?? raw['journal_date'] ?? raw['date']) as string) ?? '',
|
|
fiscalYear: raw['year'] != null ? Number(raw['year']) : (raw['financial_year'] != null ? Number(raw['financial_year']) : undefined),
|
|
entries,
|
|
_raw: raw,
|
|
};
|
|
}
|
|
|
|
export function mapBrioxToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
|
|
// Briox uses "id" as the account number field
|
|
const num = Number(raw['id'] ?? raw['account_number'] ?? raw['number']);
|
|
let type: AccountType | undefined;
|
|
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'] ?? raw['account_number'] ?? raw['number'] ?? ''),
|
|
// Briox uses "description" for the account name
|
|
name: ((raw['description'] ?? raw['name']) as string) ?? '',
|
|
type,
|
|
// Briox returns active as "1"/"0" strings
|
|
active: raw['active'] !== false && raw['active'] !== '0' && raw['active'] !== 0,
|
|
vatCode: raw['vat_code'] != null ? String(raw['vat_code']) : undefined,
|
|
// Briox uses "incoming_balance" for opening balance
|
|
balanceCarriedForward: raw['incoming_balance'] != null ? Number(raw['incoming_balance']) : undefined,
|
|
_raw: raw,
|
|
};
|
|
}
|
|
|
|
export function mapBrioxToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
|
|
// /user/info returns { info: { company_name, accounts: [...] } }
|
|
const info = (raw['info'] as Record<string, unknown> | undefined) ?? raw;
|
|
const accounts = (info['accounts'] as Record<string, unknown>[] | undefined) ?? [];
|
|
const account = accounts[0] as Record<string, unknown> | undefined;
|
|
const addr = account?.['address'] as Record<string, unknown> | undefined;
|
|
|
|
const companyName = (info['company_name'] ?? account?.['database_label'] ?? '') as string;
|
|
const orgNumber = account?.['organization_number'] as string | undefined;
|
|
|
|
return {
|
|
companyName,
|
|
organizationNumber: orgNumber,
|
|
legalEntity: {
|
|
registrationName: companyName,
|
|
companyId: orgNumber,
|
|
companyIdSchemeId: 'SE:ORGNR',
|
|
},
|
|
address: {
|
|
streetName: addr?.['addressline1'] as string | undefined,
|
|
additionalStreetName: addr?.['addressline2'] as string | undefined,
|
|
cityName: addr?.['city'] as string | undefined,
|
|
postalZone: addr?.['zip'] as string | undefined,
|
|
countryCode: (addr?.['countrycode'] ?? addr?.['country']) as string | undefined,
|
|
},
|
|
contact: {
|
|
email: (account?.['email'] ?? info['email']) as string | undefined,
|
|
telephone: (account?.['phone'] ?? info['phone']) as string | undefined,
|
|
website: account?.['website'] as string | undefined,
|
|
},
|
|
_raw: raw,
|
|
};
|
|
}
|