Files
accounted/lib/providers/visma/mapper.ts
T
Jakob Wennberg cadf02e407 fix(migration): derive Visma invoice payment state from PaymentStatus enum (#1462)
The eAccounting /supplierinvoices LIST payload omits RemainingAmount, and
reading the absence as 0 made every migrated supplier invoice fully paid
(ElvaSmultron: 290/290 imported as paid, including two open payables).
SupplierInvoiceApi has no IsCancelled/IsBooked/IsSent either, so the shared
status derivation could never produce an open supplier invoice.

- Supplier invoices: paid = PaymentStatus in (Paid=6, PaidInBank=9); bank
  in-flight states stay open; missing RemainingAmount now falls back to the
  invoice total instead of a settled-looking 0; lifecycle from Status
  (0=Draft, 2=Deleted) + overdue from PaymentStatus (4, 7).
- Sales invoices: paid = PaymentStatus 0 (enum: 0=Paid, 1=Unpaid, 2=Overdue)
  with the old RemainingAmount check as fallback only.
- IsCreditInvoice now maps to invoiceTypeCode '381' on both sides: credit
  notes have negative totals, could never satisfy 'remaining 0 && total > 0'
  and fell through to 'draft', surfacing on the dashboard as overdue unsent
  invoices.
- PaymentDate now feeds lastPaymentDate so paid_at is the real payment date
  rather than the invoice date.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:26:34 +02:00

314 lines
12 KiB
TypeScript

import type {
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
LegalMonetaryTotalDto, PaymentStatusDto,
SupplierInvoiceDto, SupplierInvoiceLineDto,
CustomerDto, SupplierDto,
JournalDto, AccountingEntryDto,
AccountingAccountDto, AccountType,
CompanyInformationDto,
AmountType, PartyDto,
} from '../dto';
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: value ?? 0, currencyCode: currency };
}
// CustomerInvoiceApi.PaymentStatus: 0 = Paid, 1 = Unpaid, 2 = Overdue.
const CUSTOMER_PS_PAID = 0;
const CUSTOMER_PS_OVERDUE = 2;
// SupplierInvoiceApi.PaymentStatus: Unpaid = 3, PartiallyPaidOverDue = 4,
// PartiallyPaid = 5, Paid = 6, OverDue = 7, PaidInBank = 9; the remaining
// values (8, 10-17) are bank-integration in-flight states where the money has
// NOT verifiably left the account, so they must stay open payables.
const SUPPLIER_PS_SETTLED = new Set([6, 9]);
const SUPPLIER_PS_OVERDUE = new Set([4, 7]);
/**
* RemainingAmount is nullable in the eAccounting schema, and in practice the
* /supplierinvoices LIST payload omits it entirely: reading a missing value as
* 0 made every migrated supplier invoice look fully settled (ElvaSmultron,
* 290/290 imported as paid). Distinguish "0" from "absent" and let the caller
* fall back to the PaymentStatus enum / TotalAmount instead.
*/
function readRemaining(raw: Record<string, unknown>): number | null {
const company = raw['RemainingAmount'];
if (typeof company === 'number') return company;
const invoiceCurrency = raw['RemainingAmountInvoiceCurrency'];
if (typeof invoiceCurrency === 'number') return invoiceCurrency;
return null;
}
function deriveInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
const remaining = readRemaining(raw);
const total = raw['TotalAmount'] as number ?? 0;
const ps = raw['PaymentStatus'] as number | undefined;
if (raw['IsCancelled'] === true) return 'cancelled';
// A credit invoice has a negative TotalAmount, so the `total > 0` paid check
// below can never match it: without this it fell all the way through to
// 'draft' and surfaced on the dashboard as an overdue unsent invoice.
if (raw['IsCreditInvoice'] === true) return 'credited';
if (ps === CUSTOMER_PS_PAID) return 'paid';
if (ps === CUSTOMER_PS_OVERDUE) return 'overdue';
if (ps == null && remaining === 0 && total !== 0) return 'paid';
if (raw['IsBooked'] === true) return 'booked';
if (raw['IsSent'] === true || raw['SendType'] != null) return 'sent';
return 'draft';
}
/**
* SupplierInvoiceApi carries none of the customer-invoice flags
* (IsCancelled/IsBooked/IsSent): its lifecycle lives in `Status`
* (0 = Draft, 1 = Normal, 2 = Deleted) plus the PaymentStatus enum.
*/
function deriveSupplierInvoiceStatus(
raw: Record<string, unknown>,
paid: boolean,
): InvoiceStatusCode {
const status = raw['Status'] as number | undefined;
if (status === 2) return 'cancelled';
if (raw['IsCreditInvoice'] === true) return 'credited';
if (paid) return 'paid';
if (status === 0) return 'draft';
const ps = raw['PaymentStatus'] as number | undefined;
if (ps != null && SUPPLIER_PS_OVERDUE.has(ps)) return 'overdue';
return 'booked';
}
function buildParty(name: string, orgNumber?: string, raw?: Record<string, unknown>): PartyDto {
return {
name,
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
postalAddress: raw ? {
streetName: (raw['InvoiceAddress1'] ?? raw['Address1']) as string | undefined,
additionalStreetName: (raw['InvoiceAddress2'] ?? raw['Address2']) as string | undefined,
cityName: (raw['InvoiceCity'] ?? raw['City']) as string | undefined,
postalZone: (raw['InvoicePostalCode'] ?? raw['PostalCode']) as string | undefined,
countryCode: raw['CountryCode'] as string | undefined,
} : undefined,
legalEntity: orgNumber ? {
registrationName: name,
companyId: orgNumber,
companyIdSchemeId: 'SE:ORGNR',
} : undefined,
contact: {
email: (raw?.['EmailAddress'] ?? raw?.['Email']) as string | undefined,
telephone: (raw?.['Telephone'] ?? raw?.['Phone']) as string | undefined,
},
};
}
export function mapVismaToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
const currency = (raw['CurrencyCode'] as string) ?? 'SEK';
const total = raw['TotalAmount'] as number ?? 0;
const remaining = readRemaining(raw);
const ps = raw['PaymentStatus'] as number | undefined;
const paid = ps != null
? ps === CUSTOMER_PS_PAID
: remaining === 0 && total !== 0;
const rows = (raw['Rows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
id: String(row['LineNumber'] ?? idx + 1),
description: row['Text'] as string | undefined,
quantity: row['Quantity'] as number | undefined,
unitCode: row['UnitAbbreviation'] as string | undefined,
unitPrice: row['UnitPrice'] != null ? amount(row['UnitPrice'] as number, currency) : undefined,
lineExtensionAmount: amount(row['LineTotal'] as number ?? 0, currency),
taxPercent: row['VatRatePercent'] as number | undefined,
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
}));
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
const paymentStatus: PaymentStatusDto = {
paid,
// When the payload omits RemainingAmount the honest open balance for an
// unpaid invoice is its total, not 0: 0 would read as fully settled.
balance: amount(remaining ?? (paid ? 0 : total), currency),
lastPaymentDate: raw['PaymentDate'] as string | undefined,
};
return {
id: String(raw['Id'] ?? ''),
invoiceNumber: String(raw['InvoiceNumber'] ?? ''),
issueDate: (raw['InvoiceDate'] as string) ?? '',
dueDate: raw['DueDate'] as string | undefined,
currencyCode: currency,
status: deriveInvoiceStatus(raw),
invoiceTypeCode: raw['IsCreditInvoice'] === true ? '381' : undefined,
supplier: buildParty(''),
customer: buildParty(
(raw['InvoiceCustomerName'] ?? '') as string,
undefined,
),
lines,
legalMonetaryTotal,
paymentStatus,
createdAt: raw['CreatedUtc'] as string | undefined,
updatedAt: raw['ModifiedUtc'] as string | undefined,
_raw: raw,
};
}
export function mapVismaToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
const currency = (raw['CurrencyCode'] as string) ?? 'SEK';
const total = raw['TotalAmount'] as number ?? 0;
const remaining = readRemaining(raw);
const ps = raw['PaymentStatus'] as number | undefined;
// The PaymentStatus enum is the reliable signal here: the /supplierinvoices
// LIST payload omits RemainingAmount, and reading that absence as 0 imported
// every supplier invoice as fully paid. Fall back to RemainingAmount only
// when the enum itself is missing.
const paid = ps != null
? SUPPLIER_PS_SETTLED.has(ps)
: remaining === 0 && total !== 0;
// A partially paid invoice without a RemainingAmount cannot be represented
// faithfully: report the full total as open (visible and correctable)
// rather than inventing a split.
const balance = remaining ?? (paid ? 0 : total);
const rows = (raw['Rows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => {
const debit = (row['DebetAmount'] as number) ?? 0;
const credit = (row['CreditAmount'] as number) ?? 0;
const rowAmount = debit || credit;
return {
id: String(row['LineNumber'] ?? idx + 1),
description: row['TransactionText'] as string | undefined,
quantity: row['Quantity'] as number | undefined,
lineExtensionAmount: amount(rowAmount, currency),
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
};
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
const paymentStatus: PaymentStatusDto = {
paid,
balance: amount(balance, currency),
lastPaymentDate: raw['PaymentDate'] as string | undefined,
};
return {
id: String(raw['Id'] ?? ''),
invoiceNumber: String(raw['InvoiceNumber'] ?? ''),
issueDate: (raw['InvoiceDate'] as string) ?? '',
dueDate: raw['DueDate'] as string | undefined,
currencyCode: currency,
status: deriveSupplierInvoiceStatus(raw, paid),
invoiceTypeCode: raw['IsCreditInvoice'] === true ? '381' : undefined,
supplier: buildParty((raw['SupplierName'] ?? '') as string),
buyer: buildParty(''),
lines,
legalMonetaryTotal,
paymentStatus,
updatedAt: raw['ModifiedUtc'] as string | undefined,
_raw: raw,
};
}
export function mapVismaToCustomer(raw: Record<string, unknown>): CustomerDto {
return {
id: String(raw['Id'] ?? ''),
customerNumber: String(raw['CustomerNumber'] ?? ''),
type: raw['IsPrivatePerson'] === true ? 'private' : 'company',
party: buildParty(
(raw['Name'] as string) ?? '',
raw['CorporateIdentityNumber'] as string | undefined,
raw,
),
active: raw['IsActive'] !== false,
note: raw['Note'] as string | undefined,
updatedAt: raw['ChangedUtc'] as string | undefined,
_raw: raw,
};
}
export function mapVismaToSupplier(raw: Record<string, unknown>): SupplierDto {
return {
id: String(raw['Id'] ?? ''),
supplierNumber: String(raw['SupplierNumber'] ?? ''),
party: buildParty(
(raw['Name'] as string) ?? '',
raw['CorporateIdentityNumber'] as string | undefined,
raw,
),
active: raw['IsActive'] !== false,
bankAccount: raw['BankAccountNumber'] as string | undefined,
bankGiro: raw['BankGiro'] as string | undefined,
plusGiro: raw['PlusGiro'] as string | undefined,
updatedAt: raw['ModifiedUtc'] as string | undefined,
_raw: raw,
};
}
export function mapVismaToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
const num = 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['Number'] ?? ''),
name: (raw['Name'] as string) ?? '',
type,
vatCode: raw['VatCodeId'] != null ? String(raw['VatCodeId']) : undefined,
active: raw['IsActive'] !== false,
_raw: raw,
};
}
export function mapVismaToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
return {
companyName: (raw['Name'] as string) ?? '',
organizationNumber: raw['CorporateIdentityNumber'] as string | undefined,
legalEntity: {
registrationName: (raw['Name'] as string) ?? '',
companyId: raw['CorporateIdentityNumber'] as string | undefined,
companyIdSchemeId: 'SE:ORGNR',
},
address: {
streetName: raw['Address1'] as string | undefined,
cityName: raw['City'] as string | undefined,
postalZone: raw['PostalCode'] as string | undefined,
countryCode: raw['CountryCode'] as string | undefined,
},
contact: {
email: raw['Email'] as string | undefined,
telephone: raw['Phone'] as string | undefined,
},
baseCurrency: raw['CurrencyCode'] as string | undefined,
_raw: raw,
};
}
export function mapVismaToJournal(raw: Record<string, unknown>): JournalDto {
const rows = (raw['Rows'] as Record<string, unknown>[] | undefined) ?? [];
const entries: AccountingEntryDto[] = rows.map((row) => ({
accountNumber: String(row['AccountNumber'] ?? ''),
accountName: row['AccountName'] as string | undefined,
debit: (row['DebitAmount'] as number) ?? 0,
credit: (row['CreditAmount'] as number) ?? 0,
description: row['Description'] as string | undefined,
}));
return {
id: String(raw['Id'] ?? ''),
journalNumber: String(raw['VoucherNumber'] ?? raw['Number'] ?? ''),
description: raw['Description'] as string | undefined,
registrationDate: (raw['VoucherDate'] as string) ?? '',
entries,
_raw: raw,
};
}