Files
accounted/lib/providers/fortnox/mapper.ts
T
Mattsson 00ae3540db feat(customers): carry contact person and invoice copy recipients through migration (#1392)
* feat(customers): carry contact person and invoice copy recipients through migration

Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical
DTOs, customer APIs (web + v1) and invoice send flows so contact person and
customer-level invoice CC/BCC addresses survive provider migrations. NULL
means unconfigured and empty means an explicit clear, so re-syncs enrich
legacy gaps without resurrecting deliberately removed values. Fortnox fixed
assets are split into a dedicated follow-up issue.

Fixes #1345

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(db): bump customer metadata migration past pack-slug version

Main already contains 20260803230000; keep new versions strictly newest so
Supabase branching applies them in order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(customers): complete Customer type consumers and make enrichment payload resolvable

The preview-pdf mock customer and the makeCustomer fixture now carry the
three new metadata fields, fixing the type-check failure in Build (zero
extensions) and Vercel.

The enrichment update in the migration orchestrator now spells its payload
as an object literal typed CustomerMetadataEnrichment (absent keys drop at
serialization), so the phantom-column guard resolves the columns instead of
counting another unresolvable dynamic payload past its ceiling. The cc/bcc
guards also verify element types instead of casting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:00:03 +02:00

334 lines
13 KiB
TypeScript

import type {
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
LegalMonetaryTotalDto, PaymentStatusDto,
SupplierInvoiceDto, SupplierInvoiceLineDto,
CustomerDto, SupplierDto,
JournalDto, AccountingEntryDto,
AccountingAccountDto, AccountType,
CompanyInformationDto,
PaymentDto,
AmountType, PartyDto,
} from '../dto';
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']),
},
};
}
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);
const rows = (raw['InvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
id: String(row['RowId'] ?? idx + 1),
description: row['Description'] as string | undefined,
quantity: row['DeliveredQuantity'] as number | undefined,
unitCode: row['Unit'] as string | undefined,
unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined,
lineExtensionAmount: amount(row['Total'] as number ?? 0, currency),
taxPercent: row['VAT'] as number | undefined,
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
itemName: row['Description'] as string | undefined,
}));
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(raw['Net'] as number ?? total, currency),
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,
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,
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) => ({
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(row['Total'] as number ?? 0, currency),
accountNumber: row['Account'] != null ? String(row['Account']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
}));
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(raw['Net'] as number ?? total, currency),
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,
legalMonetaryTotal,
paymentStatus,
ocrNumber: raw['OCR'] as string | 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,
};
}
export function mapFortnoxToPayment(raw: Record<string, unknown>, invoiceId?: string): PaymentDto {
return {
id: String(raw['Number'] ?? ''),
paymentNumber: String(raw['Number'] ?? ''),
invoiceId: invoiceId ?? String(raw['InvoiceNumber'] ?? ''),
paymentDate: (raw['PaymentDate'] as string) ?? '',
amount: amount(raw['Amount'] as number ?? 0, (raw['Currency'] as string) ?? 'SEK'),
reference: raw['Reference'] as string | undefined,
_raw: raw,
};
}