fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura (#2113)
* fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura
Two independent defects in the provider migration, both customer-visible.
A per-resource 403 was classified as a dead grant. classifyProviderError mapped
any 401 or 403 to PROVIDER_AUTH_EXPIRED, which is fatal, so a Fortnox account
without leverantorsregister permission aborted the whole migration at the
suppliers step with "Anslutningen har gatt ut. Ateranslut" even though the same
token had just succeeded on the previous step. Reconnecting can never fix that,
and steps 4 and later never ran. The provider's own reason ("Saknar behorighet
for leverantorsregister.") never reached the user. A 403 is now non-fatal once
the same token has already succeeded in the run, the migration continues, and
the provider's reason is surfaced. A 401, or a 403 on the first call, keeps the
auth-expired path.
fetchCompanyInfoDirect swallowed every error and returned null, which made the
existing PROVIDER_API_MODULE_INACTIVE remediation unreachable: a Visma customer
whose api_standard module is off got a silent 200 with an empty company card
instead of the precise Swedish explanation that was already written.
Kreditfakturor were dropped entirely. entity-mapper wrote document_type
'credit_note', but invoices_document_type_check allows only invoice, proforma
and delivery_note, and credit notes are modelled by credited_invoice_id. Every
migrated kreditfaktura was rejected and counted as skipped. One customer
imported 255 sales invoices and 0 credit notes on 2026-08-31; AR and revenue
are overstated by the credited amounts, and kreditfakturor are
rakenskapsinformation. They now import as invoice rows with reversed amounts
and status 'credited', following the in-app credit convention. They import
unlinked: no provider DTO carries a reference to the invoice being credited, so
there is nothing to match on and guessing would corrupt the AR ledger. The
wizard says so instead of burying them in skipped.
Also makes the OAuth callback non-replayable from browser history (no-store
plus history replacement), which is what the "state rejected" events were: a
replay of a callback that had already succeeded seconds earlier. No
already-connected page, so consumed-vs-unknown state stays unobservable to an
unauthenticated caller. Expected PSD2 session expiry drops from error to warn.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc
* fix(arcim): entity line needs the failed flag
The unlinked-credit-note row omitted `failed`, which the entityLines element
type requires. Caught by the zero-extensions build, not by vitest: the unit
suite does not typecheck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc
* fix(arcim): write the missing-reference disclosure onto the credit note itself
Review finding (swedish-compliance-review-bot): ML 17 kap 22-23 § wants a
kreditfaktura to reference the invoice it credits, and BFL 5 kap 6-7 § wants a
verifikation to reference its underlag. No provider DTO carries that reference,
so the pairing cannot be resolved at import and guessing it would corrupt the
AR ledger. Reporting the count in the migration wizard is not enough: a result
screen is not rakenskapsinformation, and the gap has to be legible on the
record itself years later.
The disclosure now goes into invoices.notes and supplier_invoices.notes,
preserving whatever note the provider sent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1c04262e06
commit
f1d76deaba
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mapSalesInvoice } from '../entity-mapper'
|
||||
import type { InvoiceStatusCode, PartyDto, SalesInvoiceDto } from '@/lib/providers/dto'
|
||||
|
||||
/**
|
||||
* Guards the kreditfaktura shape written by mapSalesInvoice.
|
||||
*
|
||||
* The mapper used to write `document_type: 'credit_note'`, which
|
||||
* invoices_document_type_check refuses (it allows only 'invoice', 'proforma'
|
||||
* and 'delivery_note'). Every migrated kreditfaktura was rejected with a 23514
|
||||
* and counted as skipped, so the imported AR and revenue were overstated by
|
||||
* the credited amounts while the credit notes themselves, which are
|
||||
* räkenskapsinformation, never landed.
|
||||
*
|
||||
* Accounted models a credit note as an ordinary invoice row with reversed
|
||||
* amounts (app/api/invoices/route.ts, app/api/v1/.../invoices/[id]/credit) and
|
||||
* a `credited_invoice_id` pointing at the invoice it credits.
|
||||
*/
|
||||
|
||||
const party: PartyDto = { name: 'Kund AB', identifications: [] }
|
||||
|
||||
/** allow-list of invoices_document_type_check */
|
||||
const DOCUMENT_TYPES = ['invoice', 'proforma', 'delivery_note']
|
||||
|
||||
/** allow-list of invoices_status_check */
|
||||
const STATUSES = ['draft', 'sent', 'paid', 'partially_paid', 'overdue', 'cancelled', 'credited']
|
||||
|
||||
function makeDto(over: {
|
||||
status?: InvoiceStatusCode
|
||||
invoiceTypeCode?: string
|
||||
/** Sign of the amounts as the provider states them: Visma negates, the gateway does not. */
|
||||
signOfAmounts?: 1 | -1
|
||||
net?: number
|
||||
vat?: number
|
||||
quantity?: number
|
||||
paid?: boolean
|
||||
balance?: number
|
||||
note?: string
|
||||
} = {}): SalesInvoiceDto {
|
||||
const s = over.signOfAmounts ?? 1
|
||||
const net = (over.net ?? 1000) * s
|
||||
const vat = (over.vat ?? 250) * s
|
||||
const gross = net + vat
|
||||
return {
|
||||
id: 'inv-1',
|
||||
invoiceNumber: 'KF-100',
|
||||
issueDate: '2026-03-10',
|
||||
dueDate: '2026-04-10',
|
||||
invoiceTypeCode: over.invoiceTypeCode,
|
||||
currencyCode: 'SEK',
|
||||
status: over.status ?? 'credited',
|
||||
supplier: party,
|
||||
customer: party,
|
||||
lines: [
|
||||
{
|
||||
id: '1',
|
||||
description: 'Konsulttimmar',
|
||||
quantity: (over.quantity ?? 2) * s,
|
||||
unitCode: 'tim',
|
||||
unitPrice: { value: (over.net ?? 1000) / (over.quantity ?? 2), currencyCode: 'SEK' },
|
||||
lineExtensionAmount: { value: net, currencyCode: 'SEK' },
|
||||
taxPercent: 25,
|
||||
taxAmount: { value: vat, currencyCode: 'SEK' },
|
||||
},
|
||||
],
|
||||
taxTotal: { taxAmount: { value: vat, currencyCode: 'SEK' } },
|
||||
legalMonetaryTotal: {
|
||||
lineExtensionAmount: { value: net, currencyCode: 'SEK' },
|
||||
payableAmount: { value: gross, currencyCode: 'SEK' },
|
||||
},
|
||||
paymentStatus: {
|
||||
paid: over.paid ?? false,
|
||||
balance: { value: over.balance ?? gross, currencyCode: 'SEK' },
|
||||
},
|
||||
note: over.note,
|
||||
}
|
||||
}
|
||||
|
||||
function map(over: Parameters<typeof makeDto>[0] = {}) {
|
||||
return mapSalesInvoice(makeDto(over), 'user-1', 'company-1', 'customer-1')
|
||||
}
|
||||
|
||||
describe('mapSalesInvoice: kreditfaktura', () => {
|
||||
it('writes a document_type the invoices CHECK constraint accepts', () => {
|
||||
for (const invoiceTypeCode of ['381', '380', undefined]) {
|
||||
const { invoice } = map({ invoiceTypeCode })
|
||||
expect(invoice.document_type, `invoiceTypeCode=${invoiceTypeCode}`).toBe('invoice')
|
||||
expect(DOCUMENT_TYPES).toContain(invoice.document_type as string)
|
||||
}
|
||||
})
|
||||
|
||||
it('reverses the header amounts, matching an in-app credit note', () => {
|
||||
const { invoice } = map({ invoiceTypeCode: '381' })
|
||||
expect(invoice.subtotal).toBe(-1000)
|
||||
expect(invoice.vat_amount).toBe(-250)
|
||||
expect(invoice.total).toBe(-1250)
|
||||
// SEK invoice: sekFactor 1, so the SEK columns mirror the stated amounts.
|
||||
expect(invoice.subtotal_sek).toBe(-1000)
|
||||
expect(invoice.vat_amount_sek).toBe(-250)
|
||||
expect(invoice.total_sek).toBe(-1250)
|
||||
})
|
||||
|
||||
it('reverses quantity, line total and VAT per item, and keeps the unit price positive', () => {
|
||||
const { items } = map({ invoiceTypeCode: '381' })
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].quantity).toBe(-2)
|
||||
expect(items[0].line_total).toBe(-1000)
|
||||
expect(items[0].vat_amount).toBe(-250)
|
||||
expect(items[0].unit_price).toBe(500)
|
||||
expect(items[0].vat_rate).toBe(25)
|
||||
})
|
||||
|
||||
it('lands on the same row whether the provider states the credit as negative or positive', () => {
|
||||
const stated = map({ invoiceTypeCode: '381', signOfAmounts: -1 })
|
||||
const magnitude = map({ invoiceTypeCode: '381', signOfAmounts: 1 })
|
||||
expect(stated.invoice).toEqual(magnitude.invoice)
|
||||
expect(stated.items).toEqual(magnitude.items)
|
||||
})
|
||||
|
||||
it('resolves the VAT rate on a credit note the provider states negatively', () => {
|
||||
// resolveInvoiceVat divides VAT by subtotal, which classifies only when
|
||||
// both are positive: a negatively stated credit note used to land at
|
||||
// vat_rate null even though the payload said 25 %.
|
||||
const { invoice, vatUnresolved } = map({ invoiceTypeCode: '381', signOfAmounts: -1 })
|
||||
expect(invoice.vat_rate).toBe(25)
|
||||
expect(invoice.vat_treatment).toBe('standard_25')
|
||||
expect(vatUnresolved).toBe(false)
|
||||
})
|
||||
|
||||
it('forces the terminal status whatever lifecycle status the provider sends', () => {
|
||||
for (const status of ['draft', 'sent', 'booked', 'paid', 'overdue'] as InvoiceStatusCode[]) {
|
||||
const { invoice } = map({ invoiceTypeCode: '381', status, paid: true, balance: 0 })
|
||||
expect(invoice.status, `status=${status}`).toBe('credited')
|
||||
expect(STATUSES).toContain(invoice.status as string)
|
||||
}
|
||||
})
|
||||
|
||||
it('collects nothing on a credit note', () => {
|
||||
const { invoice } = map({ invoiceTypeCode: '381', paid: true, balance: 0 })
|
||||
expect(invoice.paid_at).toBeNull()
|
||||
expect(invoice.paid_amount).toBe(0)
|
||||
expect(invoice.remaining_amount).toBe(0)
|
||||
})
|
||||
|
||||
it('imports the credit note unlinked, and reports it as such', () => {
|
||||
// No provider DTO carries a reference to the credited invoice, so there is
|
||||
// nothing to match on: zero candidates, and the row is written without a
|
||||
// credited_invoice_id rather than paired by guesswork. The flag is what
|
||||
// the migration summary counts.
|
||||
const { invoice, creditNoteUnlinked } = map({ invoiceTypeCode: '381' })
|
||||
expect(creditNoteUnlinked).toBe(true)
|
||||
expect(invoice).not.toHaveProperty('credited_invoice_id')
|
||||
})
|
||||
|
||||
it('writes the missing-reference disclosure into notes, durably', () => {
|
||||
// ML 17 kap 22-23 § wants a kreditfaktura to reference the invoice it
|
||||
// credits, and BFL 5 kap 6-7 § wants a verifikation to reference its
|
||||
// underlag. The wizard's count is an ephemeral result screen, so the gap
|
||||
// has to be legible on the record itself years later.
|
||||
const { invoice } = map({ invoiceTypeCode: '381' })
|
||||
expect(invoice.notes).toContain('Referens till ursprungsfakturan')
|
||||
})
|
||||
|
||||
it('preserves the provider note alongside the disclosure', () => {
|
||||
const { invoice } = map({ invoiceTypeCode: '381', note: 'Kreditering enligt overenskommelse' })
|
||||
expect(invoice.notes).toContain('Kreditering enligt overenskommelse')
|
||||
expect(invoice.notes).toContain('Referens till ursprungsfakturan')
|
||||
})
|
||||
|
||||
it('leaves an ordinary invoice note untouched', () => {
|
||||
const { invoice } = map({ note: 'Tack for din bestallning' })
|
||||
expect(invoice.notes).toBe('Tack for din bestallning')
|
||||
})
|
||||
|
||||
it('rounds öre rather than carrying float drift', () => {
|
||||
const { invoice, items } = map({ invoiceTypeCode: '381', net: 33.33, vat: 8.3325, quantity: 3 })
|
||||
expect(invoice.subtotal).toBe(-33.33)
|
||||
expect(invoice.vat_amount).toBe(-8.33)
|
||||
expect(invoice.total).toBe(-41.66)
|
||||
expect(items[0].line_total).toBe(-33.33)
|
||||
expect(items[0].vat_amount).toBe(-8.33)
|
||||
})
|
||||
|
||||
it('leaves an ordinary invoice untouched', () => {
|
||||
const { invoice, items, creditNoteUnlinked } = map({ status: 'sent', invoiceTypeCode: '380' })
|
||||
expect(invoice.status).toBe('sent')
|
||||
expect(invoice.subtotal).toBe(1000)
|
||||
expect(invoice.vat_amount).toBe(250)
|
||||
expect(invoice.total).toBe(1250)
|
||||
expect(invoice.remaining_amount).toBe(1250)
|
||||
expect(items[0].quantity).toBe(2)
|
||||
expect(items[0].line_total).toBe(1000)
|
||||
expect(items[0].vat_amount).toBe(250)
|
||||
expect(creditNoteUnlinked).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a paid ordinary invoice settled', () => {
|
||||
const { invoice } = map({ status: 'paid', paid: true, balance: 0 })
|
||||
expect(invoice.status).toBe('paid')
|
||||
expect(invoice.paid_amount).toBe(1250)
|
||||
expect(invoice.remaining_amount).toBe(0)
|
||||
expect(invoice.paid_at).toBe('2026-03-10')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { normalizeVatRateToFraction } from '@/lib/vat/vat-rate-unit'
|
||||
import { sumLineVat, lineVatFromPercent } from '@/lib/providers/amounts'
|
||||
import type { Currency, CustomerType, ExchangeRate, SupplierType, VatTreatment } from '@/types'
|
||||
import type {
|
||||
AmountType,
|
||||
CustomerDto,
|
||||
SupplierDto,
|
||||
SalesInvoiceDto,
|
||||
@@ -543,6 +544,23 @@ export interface MappedInvoice {
|
||||
* could not establish VAT says so instead of looking clean.
|
||||
*/
|
||||
vatUnresolved: boolean
|
||||
/**
|
||||
* True for an imported kreditfaktura that carries no pointer at the invoice
|
||||
* it credits.
|
||||
*
|
||||
* `invoices` models that relation only through `credited_invoice_id`, and no
|
||||
* provider DTO carries a reference to the credited invoice: SalesInvoiceDto
|
||||
* and SupplierInvoiceDto (lib/providers/dto.ts) state the type through
|
||||
* `invoiceTypeCode` 381 and nothing else. So every credit note the migration
|
||||
* imports lands unlinked, and guessing the original from a number in a note
|
||||
* or from the amount would put a wrong pair in the AR ledger. The row itself
|
||||
* is complete räkenskapsinformation (reversed amounts, terminal status);
|
||||
* only the pairing is missing. Meant to be counted into the migration
|
||||
* summary the way `vatUnresolved` is, so the user is told instead of finding
|
||||
* it out in the ledger; the orchestrator does not read it yet, and the copy
|
||||
* that reports it waits in `ext_arcim_credit_notes_unlinked_detail`.
|
||||
*/
|
||||
creditNoteUnlinked: boolean
|
||||
}
|
||||
|
||||
// ── Public mappers ──────────────────────────────────────────────────
|
||||
@@ -608,6 +626,45 @@ export function mapSupplier(dto: SupplierDto, userId: string, companyId: string)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reversed sign for a kreditfaktura amount, without producing -0. */
|
||||
function negate(n: number): number {
|
||||
return n === 0 ? 0 : -n
|
||||
}
|
||||
|
||||
/**
|
||||
* The same document stated in magnitudes.
|
||||
*
|
||||
* Providers disagree on the sign a kreditfaktura carries: Visma reports a
|
||||
* credit invoice with a negative TotalAmount (lib/providers/visma/mapper.ts)
|
||||
* while the arcim gateway states the magnitude beside invoiceTypeCode 381.
|
||||
* Both have to land on the single convention Accounted stores, so the amounts
|
||||
* are resolved from the magnitudes and the credit sign is applied once, at the
|
||||
* end. It is also what lets resolveInvoiceVat classify the rate at all: it
|
||||
* divides VAT by subtotal, which only yields a statutory rate when both are
|
||||
* positive.
|
||||
*/
|
||||
function withAbsoluteAmounts(dto: SalesInvoiceDto): SalesInvoiceDto {
|
||||
const abs = (amount: AmountType): AmountType => ({ ...amount, value: Math.abs(amount.value) })
|
||||
return {
|
||||
...dto,
|
||||
lines: dto.lines.map((line) => ({
|
||||
...line,
|
||||
quantity: line.quantity != null ? Math.abs(line.quantity) : undefined,
|
||||
unitPrice: line.unitPrice ? abs(line.unitPrice) : undefined,
|
||||
lineExtensionAmount: abs(line.lineExtensionAmount),
|
||||
taxAmount: line.taxAmount ? abs(line.taxAmount) : undefined,
|
||||
})),
|
||||
taxTotal: dto.taxTotal ? { ...dto.taxTotal, taxAmount: abs(dto.taxTotal.taxAmount) } : undefined,
|
||||
legalMonetaryTotal: {
|
||||
...dto.legalMonetaryTotal,
|
||||
lineExtensionAmount: dto.legalMonetaryTotal.lineExtensionAmount
|
||||
? abs(dto.legalMonetaryTotal.lineExtensionAmount)
|
||||
: undefined,
|
||||
payableAmount: abs(dto.legalMonetaryTotal.payableAmount),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function mapSalesInvoice(
|
||||
dto: SalesInvoiceDto,
|
||||
userId: string,
|
||||
@@ -615,10 +672,15 @@ export function mapSalesInvoice(
|
||||
customerId: string,
|
||||
fxRates?: FxRateIndex
|
||||
): MappedInvoice {
|
||||
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
|
||||
const vat = resolveInvoiceVat(dto)
|
||||
const subtotal = vat.subtotal
|
||||
const vatAmount = vat.vatAmount
|
||||
const isCreditNote = dto.invoiceTypeCode === '381'
|
||||
|
||||
const amounts = isCreditNote ? withAbsoluteAmounts(dto) : dto
|
||||
const sign = (n: number): number => (isCreditNote ? negate(n) : n)
|
||||
|
||||
const total = sign(round2(amounts.legalMonetaryTotal.payableAmount.value))
|
||||
const vat = resolveInvoiceVat(amounts)
|
||||
const subtotal = sign(vat.subtotal)
|
||||
const vatAmount = sign(vat.vatAmount)
|
||||
|
||||
// Map Arcim status to Accounted status
|
||||
const statusMap: Record<string, string> = {
|
||||
@@ -631,7 +693,30 @@ export function mapSalesInvoice(
|
||||
credited: 'credited',
|
||||
}
|
||||
|
||||
const isCreditNote = dto.invoiceTypeCode === '381'
|
||||
// A kreditfaktura is never an open or a paid receivable, so it gets a
|
||||
// terminal status regardless of the provider's lifecycle status:
|
||||
// invoiceTypeCode is the only signal that the document IS a credit note, and
|
||||
// the arcim gateway is not guaranteed to also send status='credited'. Same
|
||||
// reasoning as mapSupplierInvoice.
|
||||
const status = isCreditNote ? 'credited' : (statusMap[dto.status] || 'sent')
|
||||
|
||||
// Nothing is ever collected on a kreditfaktura: it reduces what the customer
|
||||
// owes rather than settling anything. This also keeps the row clear of
|
||||
// invoices_credit_note_not_paid, which forbids paid/partially_paid the moment
|
||||
// the row points at the invoice it credits.
|
||||
const settlement = isCreditNote
|
||||
? { paidAt: null as string | null, paidAmount: 0, remainingAmount: 0 }
|
||||
: {
|
||||
paidAt: dto.paymentStatus.paid
|
||||
? dto.paymentStatus.lastPaymentDate || dto.issueDate
|
||||
: null,
|
||||
paidAmount: dto.paymentStatus.paid
|
||||
? total
|
||||
: round2(total - dto.paymentStatus.balance.value),
|
||||
remainingAmount: dto.paymentStatus.paid
|
||||
? 0
|
||||
: Math.max(0, round2(dto.paymentStatus.balance.value)),
|
||||
}
|
||||
|
||||
// SEK value of a foreign invoice, at the rate valid on its own issue date.
|
||||
const fx = resolveFx(dto.currencyCode, dto.issueDate, fxRates)
|
||||
@@ -646,7 +731,7 @@ export function mapSalesInvoice(
|
||||
invoice_number: dto.invoiceNumber || null,
|
||||
invoice_date: dto.issueDate,
|
||||
due_date: dto.dueDate || dto.issueDate,
|
||||
status: statusMap[dto.status] || 'sent',
|
||||
status,
|
||||
currency: dto.currencyCode || 'SEK',
|
||||
// null for a SEK invoice (no rate applies) and for a foreign invoice whose
|
||||
// rate could not be established: that case is reported via fxUnresolved.
|
||||
@@ -665,18 +750,52 @@ export function mapSalesInvoice(
|
||||
vat_rate: vat.rate,
|
||||
your_reference: null,
|
||||
our_reference: null,
|
||||
notes: dto.note || null,
|
||||
document_type: isCreditNote ? 'credit_note' : 'invoice',
|
||||
paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null,
|
||||
paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value),
|
||||
notes: isCreditNote ? creditNoteUnlinkedNote(dto.note) : (dto.note || null),
|
||||
// Always 'invoice'. invoices_document_type_check allows only
|
||||
// ('invoice', 'proforma', 'delivery_note'), and Accounted models a
|
||||
// kreditfaktura as an invoice row with reversed amounts plus
|
||||
// credited_invoice_id, not as a document type of its own (see
|
||||
// app/api/invoices/route.ts and .../invoices/[id]/credit/route.ts).
|
||||
// Writing 'credit_note' here made Postgres reject every migrated
|
||||
// kreditfaktura with a 23514, and the run counted each one as skipped.
|
||||
document_type: 'invoice',
|
||||
paid_at: settlement.paidAt,
|
||||
paid_amount: settlement.paidAmount,
|
||||
// remaining_amount is NOT NULL DEFAULT 0, so omitting it makes every
|
||||
// migrated open invoice look fully settled in AR aging.
|
||||
remaining_amount: dto.paymentStatus.paid ? 0 : Math.max(0, round2(dto.paymentStatus.balance.value)),
|
||||
remaining_amount: settlement.remainingAmount,
|
||||
}
|
||||
|
||||
const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx, vat.rate))
|
||||
const items = amounts.lines.map((line, idx) => mapSalesInvoiceLine(line, idx, vat.rate, isCreditNote))
|
||||
|
||||
return { invoice, items, fxUnresolved: fx.unresolved, vatUnresolved: vat.unresolved }
|
||||
return {
|
||||
invoice,
|
||||
items,
|
||||
fxUnresolved: fx.unresolved,
|
||||
vatUnresolved: vat.unresolved,
|
||||
creditNoteUnlinked: isCreditNote,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable note for a migrated kreditfaktura that carries no pointer at the
|
||||
* invoice it credits.
|
||||
*
|
||||
* ML 17 kap 22-23 § requires a kreditfaktura to reference the original
|
||||
* invoice, and BFL 5 kap 6-7 § requires a verifikation to reference its
|
||||
* underlag. No provider DTO carries that reference (lib/providers/dto.ts), so
|
||||
* the pairing cannot be resolved at import time and guessing it would corrupt
|
||||
* the AR ledger. The wizard reports the count, but a wizard result screen is
|
||||
* not rakenskapsinformation: the gap has to be legible on the record itself,
|
||||
* years later, to whoever opens the invoice. So it is written into `notes`,
|
||||
* preserving whatever note the provider sent.
|
||||
*/
|
||||
function creditNoteUnlinkedNote(providerNote: string | null | undefined): string {
|
||||
const disclosure =
|
||||
'Kreditfaktura importerad vid systembyte. Referens till ursprungsfakturan '
|
||||
+ 'saknas: kallsystemet skickade ingen sadan referens vid migreringen.'
|
||||
const existing = (providerNote || '').trim()
|
||||
return existing ? `${existing}\n\n${disclosure}` : disclosure
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -695,22 +814,27 @@ function mapSalesInvoiceLine(
|
||||
line: SalesInvoiceLineDto,
|
||||
index: number,
|
||||
invoiceRate: number | null,
|
||||
isCreditNote: boolean,
|
||||
): Record<string, unknown> {
|
||||
const lineTotal = round2(line.lineExtensionAmount.value)
|
||||
const rate = line.taxPercent != null ? snapToSwedishRate(line.taxPercent) : invoiceRate
|
||||
const vatAmount = line.taxAmount?.value ?? lineVatFromPercent(lineTotal, rate ?? undefined)
|
||||
// A kreditfaktura reverses quantity, line total and VAT and keeps the unit
|
||||
// price and the rate positive: exactly what buildCreditNoteItem writes for a
|
||||
// credit note issued in-app (lib/invoices/build-credit-note-item.ts).
|
||||
const sign = (n: number): number => (isCreditNote ? negate(n) : n)
|
||||
|
||||
return {
|
||||
sort_order: index + 1,
|
||||
description: line.description || line.itemName || '',
|
||||
quantity: line.quantity || 1,
|
||||
quantity: sign(line.quantity || 1),
|
||||
unit: line.unitCode || 'st',
|
||||
unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
|
||||
line_total: lineTotal,
|
||||
line_total: sign(lineTotal),
|
||||
// 0 rather than the old hardcoded 25 when nothing established a rate: a
|
||||
// 0 % line beside 0 kr of VAT is at least internally consistent.
|
||||
vat_rate: rate ?? 0,
|
||||
vat_amount: round2(vatAmount ?? 0),
|
||||
vat_amount: sign(round2(vatAmount ?? 0)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,12 +932,21 @@ export function mapSupplierInvoice(
|
||||
paid_amount: resolvedStatus === 'paid' ? total : Math.max(0, paidAmount),
|
||||
remaining_amount: resolvedStatus === 'paid' ? 0 : Math.max(0, balance),
|
||||
is_credit_note: isCreditNote,
|
||||
notes: dto.note || null,
|
||||
notes: isCreditNote ? creditNoteUnlinkedNote(dto.note) : (dto.note || null),
|
||||
}
|
||||
|
||||
const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx, vat.rate))
|
||||
|
||||
return { invoice, items, fxUnresolved: fx.unresolved, vatUnresolved: vat.unresolved }
|
||||
return {
|
||||
invoice,
|
||||
items,
|
||||
fxUnresolved: fx.unresolved,
|
||||
vatUnresolved: vat.unresolved,
|
||||
// supplier_invoices carries is_credit_note, so the row still reads as a
|
||||
// kreditfaktura on its own; what is missing is the same pointer at the
|
||||
// original that the sales side lacks.
|
||||
creditNoteUnlinked: isCreditNote,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,9 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { MigrationProgress, MigrationResults, MigrationStepError, SkipReasons } from '../types'
|
||||
import type { ProviderName } from '@/lib/providers/types'
|
||||
import { classifyProviderError } from '@/lib/providers/with-provider-call'
|
||||
import { fortnoxErrorMessage } from '@/lib/providers/fortnox/client'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
import { getProviderResourceForbiddenMessage } from '@/lib/errors/get-error-message'
|
||||
import type { CustomerDto, SupplierDto, SalesInvoiceDto, SupplierInvoiceDto, PartyDto } from '@/lib/providers/dto'
|
||||
import { resolveConsent } from '@/lib/providers/resolve-consent'
|
||||
import { normalizeVatNumber, isValidSwedishVatNumber } from '@/lib/vat/vat-number'
|
||||
@@ -101,6 +103,10 @@ function chunk<T>(arr: T[], size: number): T[][] {
|
||||
* "successful" migration with zero rows (the failure mode that sent a real
|
||||
* config issue to the bug tracker). Rethrow so /migrate answers with the
|
||||
* structured code and the wizard shows the actual remediation.
|
||||
*
|
||||
* PROVIDER_RESOURCE_FORBIDDEN is deliberately NOT here: it means the grant
|
||||
* answered other calls in this same run and only one register is closed, so
|
||||
* the remaining steps have every chance of succeeding.
|
||||
*/
|
||||
const FATAL_STEP_ERROR_CODES = new Set([
|
||||
'PROVIDER_AUTH_EXPIRED',
|
||||
@@ -108,6 +114,31 @@ const FATAL_STEP_ERROR_CODES = new Set([
|
||||
'PROVIDER_API_MODULE_INACTIVE',
|
||||
])
|
||||
|
||||
/**
|
||||
* What this run has learned about the grant, threaded through the steps so a
|
||||
* later failure can be read in context.
|
||||
*/
|
||||
interface ProviderRunState {
|
||||
/**
|
||||
* True once any provider fetch in this run has returned data: the access
|
||||
* token provably works, so a 403 after that point is the provider closing
|
||||
* one register, not the grant dying.
|
||||
*
|
||||
* Set it from rows actually returned, never from a fetch that merely
|
||||
* resolved. fetchCustomersDirect and friends answer [] without issuing any
|
||||
* request when the provider needs a company id this consent has none of
|
||||
* (Bokio, Björn Lundén) or does not expose the register at all (WINT
|
||||
* suppliers), and a step that never spoke to the provider proves nothing
|
||||
* about the grant. Reading it as proof would downgrade a genuine auth
|
||||
* expiry on the NEXT step to a non-fatal per-register denial, and the run
|
||||
* would report success with empty sections: exactly what
|
||||
* FATAL_STEP_ERROR_CODES exists to prevent. An empty but real answer only
|
||||
* costs the better message on a later 403, so under-claiming is the safe
|
||||
* direction.
|
||||
*/
|
||||
grantProven: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed step on the results so the UI can render it. Non-fatal
|
||||
* failures keep the log-and-continue behavior (one bad step must not discard
|
||||
@@ -118,13 +149,29 @@ function recordStepError(
|
||||
results: MigrationResults,
|
||||
step: MigrationStepError['step'],
|
||||
err: unknown,
|
||||
runState: ProviderRunState,
|
||||
): void {
|
||||
const code = classifyProviderError(err)
|
||||
// Company information is step 1, so grantProven is false there by
|
||||
// definition. Its 403 is still no proof of a dead grant: the fetch used to
|
||||
// swallow every error and return null, and a grant that really is dead says
|
||||
// so on the next step, which stays fatal. Letting the opening call abort the
|
||||
// run would turn a partial import into no import at all.
|
||||
const grantProven = runState.grantProven || step === 'companyInfo'
|
||||
const code = classifyProviderError(err, { grantProven })
|
||||
if (code && FATAL_STEP_ERROR_CODES.has(code)) throw err
|
||||
|
||||
const rawMessage = err instanceof Error ? err.message : String(err)
|
||||
const entry = code ? getErrorEntry(code) : undefined
|
||||
const message = entry?.message_sv ?? `Leverantören svarade med ett fel: ${rawMessage}`
|
||||
let message: string
|
||||
if (code === 'PROVIDER_RESOURCE_FORBIDDEN') {
|
||||
// The registry cannot hold this copy: the useful half is the provider's
|
||||
// own sentence naming the register, which only the error carries. Fortnox
|
||||
// is the one provider that sends it (fortnoxErrorMessage caps it at 300
|
||||
// chars); the others send an opaque body and get the base message alone.
|
||||
message = getProviderResourceForbiddenMessage(fortnoxErrorMessage(err))
|
||||
} else {
|
||||
message = entry?.message_sv ?? `Leverantören svarade med ett fel: ${rawMessage}`
|
||||
}
|
||||
|
||||
results.stepErrors = results.stepErrors ?? []
|
||||
results.stepErrors.push({ step, code, message })
|
||||
@@ -165,6 +212,9 @@ function logFxUnresolved(kind: string, invoiceNumber: string, fx: FxUnresolved):
|
||||
export async function executeMigration(options: MigrationOptions): Promise<MigrationResults> {
|
||||
const { consentId, companyId, userId, supabase } = options
|
||||
const results: MigrationResults = {}
|
||||
// What this run has proven about the grant, read by recordStepError: a 403
|
||||
// once a call has already succeeded is one closed register, not a dead token.
|
||||
const runState: ProviderRunState = { grantProven: false }
|
||||
// Every invoice this run inserted, with the booking voucher the provider
|
||||
// named for it. Linked to the SIE-imported registration verifikat after both
|
||||
// invoice steps (see the registration-link step below).
|
||||
@@ -183,6 +233,10 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
try {
|
||||
const companyInfo = await fetchCompanyInfoDirect(provider, accessToken, providerCompanyId)
|
||||
if (companyInfo) {
|
||||
// A DTO means the provider answered on this token. A null does not:
|
||||
// it also means "no company-information resource for this provider",
|
||||
// which is decided before any request goes out.
|
||||
runState.grantProven = true
|
||||
const mapped = mapCompanyInfo(companyInfo)
|
||||
const { data: existing } = await supabase
|
||||
.from('company_settings')
|
||||
@@ -228,7 +282,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
} catch (err) {
|
||||
console.error('Failed to import company info:', err)
|
||||
results.companyInfo = { imported: false }
|
||||
recordStepError(results, 'companyInfo', err)
|
||||
recordStepError(results, 'companyInfo', err, runState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +298,8 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
emitProgress(options, { status: 'importing', currentStep: 'Importerar kunder...', progress: 20 })
|
||||
try {
|
||||
const customers = await fetchCustomersDirect(provider, accessToken, providerCompanyId)
|
||||
// Rows, not a resolved promise: see ProviderRunState.grantProven.
|
||||
if (customers.length > 0) runState.grantProven = true
|
||||
|
||||
// One bulk read instead of N `.eq('org_number', ...)` lookups.
|
||||
type ExistingCustomer = ExistingCustomerMetadata & {
|
||||
@@ -392,7 +448,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
results.customers = { total: customers.length, imported, updated, skipped, skipReasons, errorSample: errorSample ?? undefined }
|
||||
} catch (err) {
|
||||
console.error('Failed to import customers:', err)
|
||||
recordStepError(results, 'customers', err)
|
||||
recordStepError(results, 'customers', err, runState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +461,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörer...', progress: 40 })
|
||||
try {
|
||||
const suppliers = await fetchSuppliersDirect(provider, accessToken, providerCompanyId)
|
||||
if (suppliers.length > 0) runState.grantProven = true
|
||||
|
||||
const existingSuppliers = await fetchAllRows<{ id: string; org_number: string | null; name: string | null }>(
|
||||
({ from, to }) =>
|
||||
@@ -492,7 +549,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
results.suppliers = { total: suppliers.length, imported, skipped, skipReasons, errorSample: errorSample ?? undefined }
|
||||
} catch (err) {
|
||||
console.error('Failed to import suppliers:', err)
|
||||
recordStepError(results, 'suppliers', err)
|
||||
recordStepError(results, 'suppliers', err, runState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +562,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const { invoices, hydration, unhydratedIds } = await fetchSalesInvoicesHydrated(
|
||||
provider, accessToken, providerCompanyId,
|
||||
)
|
||||
if (invoices.length > 0) runState.grantProven = true
|
||||
console.log(`[migration] Sales invoices: ${invoices.length} total`)
|
||||
|
||||
// Bulk-load existing invoice numbers once.
|
||||
@@ -659,6 +717,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
)
|
||||
let fxUnresolved = 0
|
||||
let vatUnresolved = 0
|
||||
let creditNotesUnlinked = 0
|
||||
|
||||
// Phase C: chunk-insert invoices + their line items.
|
||||
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
|
||||
@@ -711,6 +770,9 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
+ 'imported with gross as subtotal and a null rate.'
|
||||
)
|
||||
}
|
||||
if (mappedBatch[i].creditNoteUnlinked) {
|
||||
creditNotesUnlinked++
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
@@ -724,10 +786,10 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
}
|
||||
}
|
||||
|
||||
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, vatUnresolved, hydration, errorSample: errorSample ?? undefined }
|
||||
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, vatUnresolved, creditNotesUnlinked, hydration, errorSample: errorSample ?? undefined }
|
||||
} catch (err) {
|
||||
console.error('Failed to import sales invoices:', err)
|
||||
recordStepError(results, 'salesInvoices', err)
|
||||
recordStepError(results, 'salesInvoices', err, runState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,6 +800,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const { invoices, hydration, unhydratedIds } = await fetchSupplierInvoicesHydrated(
|
||||
provider, accessToken, providerCompanyId,
|
||||
)
|
||||
if (invoices.length > 0) runState.grantProven = true
|
||||
console.log(`[migration] Supplier invoices: ${invoices.length} total`)
|
||||
|
||||
// Load existing (supplier_invoice_number, supplier_id) pairs once.
|
||||
@@ -974,7 +1037,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, vatUnresolved, hydration, errorSample: errorSample ?? undefined }
|
||||
} catch (err) {
|
||||
console.error('Failed to import supplier invoices:', err)
|
||||
recordStepError(results, 'supplierInvoices', err)
|
||||
recordStepError(results, 'supplierInvoices', err, runState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,7 +1072,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to link registration vouchers:', err)
|
||||
recordStepError(results, 'registrationLinks', err)
|
||||
recordStepError(results, 'registrationLinks', err, runState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1029,7 +1092,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
results.assets = { total: 0, imported: 0, skipped: 0, scopesMissing: true }
|
||||
} else {
|
||||
console.error('Failed to import assets:', err)
|
||||
recordStepError(results, 'assets', err)
|
||||
recordStepError(results, 'assets', err, runState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1055,7 +1118,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to reconcile supplier invoice payments:', err)
|
||||
recordStepError(results, 'reconciliation', err)
|
||||
recordStepError(results, 'reconciliation', err, runState)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user