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>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
4b0a185876
commit
cadf02e407
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mapVismaToSalesInvoice, mapVismaToSupplierInvoice } from '../mapper'
|
||||
|
||||
/**
|
||||
* Guards the paid/credit-note derivation against the fields eAccounting
|
||||
* actually populates (ElvaSmultron support case, 2026-08-08):
|
||||
*
|
||||
* - The /supplierinvoices LIST payload omits RemainingAmount. Reading the
|
||||
* absence as 0 imported all 290 supplier invoices as fully paid, including
|
||||
* the two that were open in the source system. SupplierInvoiceApi's
|
||||
* PaymentStatus enum (Unpaid=3 ... Paid=6 ... PaidInBank=9) is the reliable
|
||||
* signal and must win over a missing amount.
|
||||
* - Credit invoices carry a negative TotalAmount, so the old
|
||||
* `remaining === 0 && total > 0` check could never mark them settled: they
|
||||
* fell through to 'draft' and surfaced on the dashboard as overdue unsent
|
||||
* invoices. IsCreditInvoice must map to invoiceTypeCode '381'.
|
||||
*/
|
||||
|
||||
function supplierRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
Id: 'b1',
|
||||
InvoiceNumber: '903127919426',
|
||||
InvoiceDate: '2026-07-31',
|
||||
DueDate: '2026-08-30',
|
||||
CurrencyCode: 'SEK',
|
||||
TotalAmount: 1250,
|
||||
SupplierName: 'PostNord Sverige AB',
|
||||
Rows: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function salesRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
Id: 's1',
|
||||
InvoiceNumber: '10060',
|
||||
InvoiceDate: '2026-07-24',
|
||||
DueDate: '2026-08-10',
|
||||
CurrencyCode: 'SEK',
|
||||
TotalAmount: 75000,
|
||||
InvoiceCustomerName: 'Kund AB',
|
||||
Rows: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('mapVismaToSupplierInvoice payment status', () => {
|
||||
it('PaymentStatus Unpaid (3) with RemainingAmount ABSENT stays an open payable', () => {
|
||||
const dto = mapVismaToSupplierInvoice(supplierRaw({ PaymentStatus: 3 }))
|
||||
expect(dto.paymentStatus.paid).toBe(false)
|
||||
// Open balance falls back to the total, never to a settled-looking 0.
|
||||
expect(dto.paymentStatus.balance.value).toBe(1250)
|
||||
expect(dto.status).toBe('booked')
|
||||
})
|
||||
|
||||
it('PaymentStatus Paid (6) is settled even without RemainingAmount', () => {
|
||||
const dto = mapVismaToSupplierInvoice(supplierRaw({ PaymentStatus: 6, PaymentDate: '2026-08-02' }))
|
||||
expect(dto.paymentStatus.paid).toBe(true)
|
||||
expect(dto.paymentStatus.balance.value).toBe(0)
|
||||
expect(dto.paymentStatus.lastPaymentDate).toBe('2026-08-02')
|
||||
expect(dto.status).toBe('paid')
|
||||
})
|
||||
|
||||
it('PaymentStatus PaidInBank (9) is settled', () => {
|
||||
const dto = mapVismaToSupplierInvoice(supplierRaw({ PaymentStatus: 9 }))
|
||||
expect(dto.paymentStatus.paid).toBe(true)
|
||||
})
|
||||
|
||||
it('bank in-flight states (SentToBank=15, ReceivedByBank=16) are NOT settled', () => {
|
||||
for (const ps of [8, 10, 11, 15, 16, 17]) {
|
||||
const dto = mapVismaToSupplierInvoice(supplierRaw({ PaymentStatus: ps }))
|
||||
expect(dto.paymentStatus.paid).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('PaymentStatus OverDue (7) maps to overdue', () => {
|
||||
const dto = mapVismaToSupplierInvoice(supplierRaw({ PaymentStatus: 7 }))
|
||||
expect(dto.status).toBe('overdue')
|
||||
expect(dto.paymentStatus.paid).toBe(false)
|
||||
})
|
||||
|
||||
it('partial payment with RemainingAmount present keeps the real balance', () => {
|
||||
const dto = mapVismaToSupplierInvoice(supplierRaw({ PaymentStatus: 5, RemainingAmount: 250 }))
|
||||
expect(dto.paymentStatus.paid).toBe(false)
|
||||
expect(dto.paymentStatus.balance.value).toBe(250)
|
||||
})
|
||||
|
||||
it('falls back to RemainingAmount === 0 when the PaymentStatus enum is absent', () => {
|
||||
const dto = mapVismaToSupplierInvoice(supplierRaw({ RemainingAmount: 0 }))
|
||||
expect(dto.paymentStatus.paid).toBe(true)
|
||||
const open = mapVismaToSupplierInvoice(supplierRaw({ RemainingAmount: 1250 }))
|
||||
expect(open.paymentStatus.paid).toBe(false)
|
||||
})
|
||||
|
||||
it('both enum and amount absent: open, not silently paid', () => {
|
||||
const dto = mapVismaToSupplierInvoice(supplierRaw())
|
||||
expect(dto.paymentStatus.paid).toBe(false)
|
||||
expect(dto.paymentStatus.balance.value).toBe(1250)
|
||||
})
|
||||
|
||||
it('IsCreditInvoice sets invoiceTypeCode 381 and status credited', () => {
|
||||
const dto = mapVismaToSupplierInvoice(supplierRaw({ IsCreditInvoice: true, TotalAmount: -500 }))
|
||||
expect(dto.invoiceTypeCode).toBe('381')
|
||||
expect(dto.status).toBe('credited')
|
||||
})
|
||||
|
||||
it('Status Draft (0) maps to draft, Deleted (2) to cancelled', () => {
|
||||
expect(mapVismaToSupplierInvoice(supplierRaw({ Status: 0, PaymentStatus: 3 })).status).toBe('draft')
|
||||
expect(mapVismaToSupplierInvoice(supplierRaw({ Status: 2, PaymentStatus: 3 })).status).toBe('cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapVismaToSalesInvoice payment status', () => {
|
||||
it('PaymentStatus Paid (0) is settled even without RemainingAmount', () => {
|
||||
const dto = mapVismaToSalesInvoice(salesRaw({ PaymentStatus: 0, PaymentDate: '2026-08-01' }))
|
||||
expect(dto.paymentStatus.paid).toBe(true)
|
||||
expect(dto.paymentStatus.balance.value).toBe(0)
|
||||
expect(dto.paymentStatus.lastPaymentDate).toBe('2026-08-01')
|
||||
expect(dto.status).toBe('paid')
|
||||
})
|
||||
|
||||
it('PaymentStatus Unpaid (1) with RemainingAmount absent stays open at full total', () => {
|
||||
const dto = mapVismaToSalesInvoice(salesRaw({ PaymentStatus: 1, IsBooked: true }))
|
||||
expect(dto.paymentStatus.paid).toBe(false)
|
||||
expect(dto.paymentStatus.balance.value).toBe(75000)
|
||||
expect(dto.status).toBe('booked')
|
||||
})
|
||||
|
||||
it('PaymentStatus Overdue (2) maps to overdue', () => {
|
||||
const dto = mapVismaToSalesInvoice(salesRaw({ PaymentStatus: 2, RemainingAmount: 75000 }))
|
||||
expect(dto.status).toBe('overdue')
|
||||
expect(dto.paymentStatus.paid).toBe(false)
|
||||
})
|
||||
|
||||
it('legacy fallback: RemainingAmount 0 with positive total is paid when enum absent', () => {
|
||||
const dto = mapVismaToSalesInvoice(salesRaw({ RemainingAmount: 0 }))
|
||||
expect(dto.paymentStatus.paid).toBe(true)
|
||||
expect(dto.status).toBe('paid')
|
||||
})
|
||||
|
||||
it('credit invoice (negative total) becomes a settled credit note, never a draft', () => {
|
||||
const dto = mapVismaToSalesInvoice(salesRaw({
|
||||
IsCreditInvoice: true,
|
||||
TotalAmount: -2495,
|
||||
RemainingAmount: 0,
|
||||
}))
|
||||
expect(dto.invoiceTypeCode).toBe('381')
|
||||
expect(dto.status).toBe('credited')
|
||||
expect(dto.paymentStatus.paid).toBe(true)
|
||||
})
|
||||
|
||||
it('unbooked unpaid invoice still derives sent from SendType', () => {
|
||||
const dto = mapVismaToSalesInvoice(salesRaw({ PaymentStatus: 1, SendType: 1 }))
|
||||
expect(dto.status).toBe('sent')
|
||||
})
|
||||
})
|
||||
@@ -13,16 +13,68 @@ function amount(value: number | undefined | null, currency: string = 'SEK'): Amo
|
||||
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 = raw['RemainingAmount'] as number ?? 0;
|
||||
const remaining = readRemaining(raw);
|
||||
const total = raw['TotalAmount'] as number ?? 0;
|
||||
const ps = raw['PaymentStatus'] as number | undefined;
|
||||
if (raw['IsCancelled'] === true) return 'cancelled';
|
||||
if (remaining === 0 && total > 0) return 'paid';
|
||||
// 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,
|
||||
@@ -49,7 +101,11 @@ function buildParty(name: string, orgNumber?: string, raw?: Record<string, unkno
|
||||
export function mapVismaToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
|
||||
const currency = (raw['CurrencyCode'] as string) ?? 'SEK';
|
||||
const total = raw['TotalAmount'] as number ?? 0;
|
||||
const remaining = raw['RemainingAmount'] 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) => ({
|
||||
@@ -70,8 +126,11 @@ export function mapVismaToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: remaining === 0 && total > 0,
|
||||
balance: amount(remaining, currency),
|
||||
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 {
|
||||
@@ -81,6 +140,7 @@ export function mapVismaToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
|
||||
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,
|
||||
@@ -98,7 +158,19 @@ export function mapVismaToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
|
||||
export function mapVismaToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
|
||||
const currency = (raw['CurrencyCode'] as string) ?? 'SEK';
|
||||
const total = raw['TotalAmount'] as number ?? 0;
|
||||
const remaining = raw['RemainingAmount'] 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) => {
|
||||
@@ -120,8 +192,9 @@ export function mapVismaToSupplierInvoice(raw: Record<string, unknown>): Supplie
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: remaining === 0 && total > 0,
|
||||
balance: amount(remaining, currency),
|
||||
paid,
|
||||
balance: amount(balance, currency),
|
||||
lastPaymentDate: raw['PaymentDate'] as string | undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -130,7 +203,8 @@ export function mapVismaToSupplierInvoice(raw: Record<string, unknown>): Supplie
|
||||
issueDate: (raw['InvoiceDate'] as string) ?? '',
|
||||
dueDate: raw['DueDate'] as string | undefined,
|
||||
currencyCode: currency,
|
||||
status: deriveInvoiceStatus(raw),
|
||||
status: deriveSupplierInvoiceStatus(raw, paid),
|
||||
invoiceTypeCode: raw['IsCreditInvoice'] === true ? '381' : undefined,
|
||||
supplier: buildParty((raw['SupplierName'] ?? '') as string),
|
||||
buyer: buildParty(''),
|
||||
lines,
|
||||
|
||||
Reference in New Issue
Block a user