diff --git a/lib/providers/visma/__tests__/mapper-payment-status.test.ts b/lib/providers/visma/__tests__/mapper-payment-status.test.ts new file mode 100644 index 00000000..c0e59a45 --- /dev/null +++ b/lib/providers/visma/__tests__/mapper-payment-status.test.ts @@ -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 = {}): Record { + 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 = {}): Record { + 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') + }) +}) diff --git a/lib/providers/visma/mapper.ts b/lib/providers/visma/mapper.ts index 865709f0..23accd95 100644 --- a/lib/providers/visma/mapper.ts +++ b/lib/providers/visma/mapper.ts @@ -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): 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): 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, + 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): PartyDto { return { name, @@ -49,7 +101,11 @@ function buildParty(name: string, orgNumber?: string, raw?: Record): 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[] | undefined) ?? []; const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({ @@ -70,8 +126,11 @@ export function mapVismaToSalesInvoice(raw: Record): 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): 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): SalesInvoi export function mapVismaToSupplierInvoice(raw: Record): 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[] | undefined) ?? []; const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => { @@ -120,8 +192,9 @@ export function mapVismaToSupplierInvoice(raw: Record): 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): 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,