* fix(providers): paginate Visma eAccounting with $page/$pagesize eAccounting silently ignores OData $top/$skip, so every request returned page 1 and getPaginated appended the first page TotalNumberOfPages times: customers were imported in triplicate and invoice chunks hit unique violations. Also stop on an empty page so a stale Meta can never loop or duplicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migration): survive bad rows in entity imports instead of failing whole chunks One PostgREST insert per 500-row chunk is all-or-nothing, so a single duplicate reported every row as failed ('300 misslyckades') with no cause shown. Now: dedupe repeats within the fetched data (paging faults, source duplicates), fall back to per-row inserts when a chunk is rejected, store empty invoice numbers as NULL instead of colliding '', surface the first DB error in the result UI, and mark all-failed steps with an error icon. Sales invoices also carry remaining_amount so open invoices no longer land as settled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migration): never per-row retry after a successful bulk insert with short read-back A succeeded statement whose .select() returns fewer rows than sent means the rows ARE in the table; retrying them one by one would duplicate every unreturned row. Pair what came back and report the tail instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migration): count stub-insert casualties as failed and sample enrichment errors Review follow-ups: invoices dropped because their customer/supplier stub insert errored are DB failures, not matching misses; classifying them as noMatch rendered a green result row with the database error hidden. Enrichment failures now also feed errorSample. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
100 lines
3.7 KiB
TypeScript
100 lines
3.7 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { mapSalesInvoice, mapSupplierInvoice } from '../entity-mapper'
|
|
import type { SalesInvoiceDto, SupplierInvoiceDto, PartyDto } from '@/lib/providers/dto'
|
|
|
|
/**
|
|
* Guards two migration hardenings:
|
|
* - empty invoice numbers from a provider payload must be stored as NULL,
|
|
* never '': the unique indexes on (company_id, invoice_number) and
|
|
* (company_id, supplier_id, supplier_invoice_number) treat NULLs as
|
|
* distinct but collide on repeated empty strings;
|
|
* - sales invoices must carry remaining_amount (NOT NULL DEFAULT 0), or
|
|
* every migrated open invoice looks fully settled in AR aging.
|
|
*/
|
|
|
|
const party: PartyDto = { name: 'Motpart AB', identifications: [] }
|
|
|
|
function makeSalesDto(over: { invoiceNumber?: string; paid?: boolean; balance?: number; total?: number }): SalesInvoiceDto {
|
|
const total = over.total ?? 1000
|
|
return {
|
|
id: 'inv-1',
|
|
invoiceNumber: over.invoiceNumber ?? 'F-100',
|
|
issueDate: '2026-01-10',
|
|
dueDate: '2026-02-10',
|
|
currencyCode: 'SEK',
|
|
status: 'sent',
|
|
supplier: party,
|
|
customer: party,
|
|
lines: [],
|
|
legalMonetaryTotal: {
|
|
lineExtensionAmount: { value: total, currencyCode: 'SEK' },
|
|
payableAmount: { value: total, currencyCode: 'SEK' },
|
|
},
|
|
paymentStatus: {
|
|
paid: over.paid ?? false,
|
|
balance: { value: over.balance ?? total, currencyCode: 'SEK' },
|
|
},
|
|
}
|
|
}
|
|
|
|
function makeSupplierDto(invoiceNumber: string): SupplierInvoiceDto {
|
|
return {
|
|
id: 'sinv-1',
|
|
invoiceNumber,
|
|
issueDate: '2026-01-10',
|
|
currencyCode: 'SEK',
|
|
status: 'booked',
|
|
supplier: party,
|
|
buyer: party,
|
|
lines: [],
|
|
legalMonetaryTotal: {
|
|
lineExtensionAmount: { value: 500, currencyCode: 'SEK' },
|
|
payableAmount: { value: 500, currencyCode: 'SEK' },
|
|
},
|
|
paymentStatus: { paid: false, balance: { value: 500, currencyCode: 'SEK' } },
|
|
}
|
|
}
|
|
|
|
describe('invoice number nulling', () => {
|
|
it('sales invoice: empty invoiceNumber becomes NULL, real one is kept', () => {
|
|
const empty = mapSalesInvoice(makeSalesDto({ invoiceNumber: '' }), 'u', 'c', 'cust').invoice
|
|
expect(empty.invoice_number).toBeNull()
|
|
|
|
const real = mapSalesInvoice(makeSalesDto({ invoiceNumber: 'F-7' }), 'u', 'c', 'cust').invoice
|
|
expect(real.invoice_number).toBe('F-7')
|
|
})
|
|
|
|
it('supplier invoice: empty invoiceNumber becomes NULL', () => {
|
|
const empty = mapSupplierInvoice(makeSupplierDto(''), 'u', 'c', 'sup').invoice
|
|
expect(empty.supplier_invoice_number).toBeNull()
|
|
|
|
const real = mapSupplierInvoice(makeSupplierDto('LF-9'), 'u', 'c', 'sup').invoice
|
|
expect(real.supplier_invoice_number).toBe('LF-9')
|
|
})
|
|
})
|
|
|
|
describe('sales invoice remaining_amount', () => {
|
|
it('open invoice keeps its full balance as remaining', () => {
|
|
const inv = mapSalesInvoice(makeSalesDto({ paid: false, balance: 1000, total: 1000 }), 'u', 'c', 'cust').invoice
|
|
expect(inv.remaining_amount).toBe(1000)
|
|
expect(inv.paid_amount).toBe(0)
|
|
})
|
|
|
|
it('partially paid invoice: remaining mirrors the provider balance', () => {
|
|
const inv = mapSalesInvoice(makeSalesDto({ paid: false, balance: 250.5, total: 1000 }), 'u', 'c', 'cust').invoice
|
|
expect(inv.remaining_amount).toBe(250.5)
|
|
expect(inv.paid_amount).toBe(749.5)
|
|
})
|
|
|
|
it('paid invoice: remaining is 0', () => {
|
|
const inv = mapSalesInvoice(makeSalesDto({ paid: true, balance: 0, total: 1000 }), 'u', 'c', 'cust').invoice
|
|
expect(inv.remaining_amount).toBe(0)
|
|
expect(inv.paid_amount).toBe(1000)
|
|
})
|
|
|
|
it('negative provider balance never yields negative remaining', () => {
|
|
const inv = mapSalesInvoice(makeSalesDto({ paid: false, balance: -3, total: 1000 }), 'u', 'c', 'cust').invoice
|
|
expect(inv.remaining_amount).toBe(0)
|
|
})
|
|
})
|