Migration timeouts (#258)

* feat: optimize migration process with bulk reads and chunked inserts to improve performance

* feat: add SIE-import requirement banner for specific providers in migration workflow

* feat: enhance error handling in TIC API integration with structured HTTP responses

* Update components/extensions/general/ArcimMigrationWorkspace.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Mattsson
2026-04-16 16:52:14 +02:00
committed by GitHub
co-authored by greptile-apps[bot]
parent fe137346df
commit 942ab7636f
4 changed files with 561 additions and 256 deletions
@@ -204,9 +204,33 @@ function ProviderStep({
isLoadingStatus: boolean
}) {
const activeConsents = connectionStatus?.consents.filter(c => c.status === 1) ?? []
const hasSieImport = (connectionStatus?.sieImports.filter(i => i.status === 'completed').length ?? 0) > 0
const allFortnox = activeConsents.length > 0 && activeConsents.every(c => c.provider === 'fortnox')
const showSieRequiredBanner = !isLoadingStatus && !hasSieImport && !allFortnox
return (
<div className="space-y-4">
{/* SIE-required banner (not relevant for Fortnox — it fetches SIE itself) */}
{showSieRequiredBanner && (
<div className="flex gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">SIE-import krävs först</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Bokio, Visma, Björn Lundén och Briox hämtar endast kunder, leverantörer och fakturor via API:et. Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil först. Gäller inte Fortnox — där hämtar vi SIE direkt via API:et.
</p>
<Link
href="/import?mode=sie"
className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'mt-3')}
>
<BookOpen className="mr-2 h-4 w-4" />
Ladda upp SIE-fil
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</Link>
</div>
</div>
)}
{/* Existing connections */}
{activeConsents.length > 0 && (
<Card>
@@ -10,11 +10,17 @@
* 3. Suppliers → needed before supplier invoices
* 4. Sales invoices (all statuses, duplicates skipped)
* 5. Supplier invoices (all statuses, duplicates skipped)
*
* Performance note: All steps use bulk reads + chunked inserts to
* avoid N+1 round-trips that would exhaust the Vercel function
* timeout (300s hard cap). A typical import with a few thousand
* entities completes in a handful of Supabase requests per step.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { MigrationProgress, MigrationResults, SkipReasons } from '../types'
import type { ProviderName } from '@/lib/providers/types'
import type { CustomerDto, SupplierDto, SalesInvoiceDto, SupplierInvoiceDto, PartyDto } from '@/lib/providers/dto'
import { resolveConsent } from '@/lib/providers/resolve-consent'
import {
fetchCompanyInfoDirect,
@@ -23,6 +29,7 @@ import {
fetchSalesInvoicesDirect,
fetchSupplierInvoicesDirect,
} from '@/lib/providers/provider-data-fetcher'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
mapCustomer,
mapSupplier,
@@ -45,10 +52,30 @@ export interface MigrationOptions {
onProgress?: (progress: MigrationProgress) => void
}
/**
* Chunk size for bulk inserts. 500 rows/request keeps payloads below
* PostgREST's practical size limit while minimising round-trips.
*/
const INSERT_CHUNK_SIZE = 500
function emitProgress(options: MigrationOptions, progress: MigrationProgress) {
options.onProgress?.(progress)
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
return out
}
function getOrgNumberFromParty(party: PartyDto): string | null {
return (
party.legalEntity?.companyId ||
party.identifications?.find((i) => i.schemeId === 'SE:ORGNR')?.id ||
null
)
}
// ── Main orchestrator ─────────────────────────────────────────────
export async function executeMigration(options: MigrationOptions): Promise<MigrationResults> {
@@ -61,8 +88,6 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const accessToken = resolved.accessToken
const providerCompanyId = resolved.providerCompanyId
try {
// ── Step 1: Company information ───────────────────────────────
if (options.importCompanyInfo !== false) {
@@ -104,57 +129,84 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
// ── Step 2: Customers ─────────────────────────────────────────
// ── Step 2: Customers (bulk) ──────────────────────────────────
// customerIdMap: provider customer id → gnubok customer id.
// orgNumberToCustomerId / nameToCustomerId speed up invoice lookup
// without extra queries later.
const customerIdMap = new Map<string, string>()
const orgNumberToCustomerId = new Map<string, string>()
const nameToCustomerId = new Map<string, string>()
if (options.importCustomers !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar kunder...', progress: 20 })
try {
const customers = await fetchCustomersDirect(provider, accessToken, providerCompanyId)
// One bulk read instead of N `.eq('org_number', ...)` lookups.
const existingCustomers = await fetchAllRows<{ id: string; org_number: string | null; name: string | null }>(
({ from, to }) =>
supabase
.from('customers')
.select('id, org_number, name')
.eq('company_id', companyId)
.range(from, to)
)
for (const row of existingCustomers) {
if (row.org_number) orgNumberToCustomerId.set(row.org_number, row.id)
if (row.name) nameToCustomerId.set(row.name, row.id)
}
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
type PendingCustomer = {
dto: CustomerDto
row: Record<string, unknown>
}
const pending: PendingCustomer[] = []
for (const customer of customers) {
if (!customer.active) {
console.log(`[migration] Customer skipped (inactive): ${customer.party.name}`)
skipReasons.inactive = (skipReasons.inactive ?? 0) + 1
skipped++
continue
}
const orgNumber = customer.party.legalEntity?.companyId ||
customer.party.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
if (orgNumber) {
const { data: existing } = await supabase
.from('customers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', orgNumber)
.limit(1)
if (existing && existing.length > 0) {
console.log(`[migration] Customer skipped (duplicate org_number ${orgNumber}): ${customer.party.name}`)
customerIdMap.set(customer.id, existing[0].id)
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
const orgNumber = getOrgNumberFromParty(customer.party)
if (orgNumber && orgNumberToCustomerId.has(orgNumber)) {
customerIdMap.set(customer.id, orgNumberToCustomerId.get(orgNumber)!)
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
const mapped = mapCustomer(customer, userId, companyId)
pending.push({ dto: customer, row: mapCustomer(customer, userId, companyId) })
}
for (const batch of chunk(pending, INSERT_CHUNK_SIZE)) {
const rows = batch.map((p) => p.row)
const { data: inserted, error } = await supabase
.from('customers')
.insert(mapped)
.select('id')
.single()
.insert(rows)
.select('id, org_number, name')
if (error || !inserted) {
console.error(`[migration] Customer insert failed: ${customer.party.name}`, error?.message)
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
} else {
customerIdMap.set(customer.id, inserted.id)
if (error) {
console.error(`[migration] Customer batch insert failed (${batch.length} rows):`, error.message)
skipReasons.failed = (skipReasons.failed ?? 0) + batch.length
skipped += batch.length
continue
}
// PostgREST returns inserted rows in the same order as supplied,
// so we can pair them up by index to recover the provider id.
const insertedRows = inserted ?? []
for (let i = 0; i < batch.length && i < insertedRows.length; i++) {
const providerId = batch[i].dto.id
const newId = insertedRows[i].id
customerIdMap.set(providerId, newId)
if (insertedRows[i].org_number) orgNumberToCustomerId.set(insertedRows[i].org_number!, newId)
if (insertedRows[i].name) nameToCustomerId.set(insertedRows[i].name!, newId)
imported++
}
}
@@ -165,57 +217,75 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
// ── Step 3: Suppliers ─────────────────────────────────────────
// ── Step 3: Suppliers (bulk) ──────────────────────────────────
const supplierIdMap = new Map<string, string>()
const orgNumberToSupplierId = new Map<string, string>()
const nameToSupplierId = new Map<string, string>()
if (options.importSuppliers !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörer...', progress: 40 })
try {
const suppliers = await fetchSuppliersDirect(provider, accessToken, providerCompanyId)
const existingSuppliers = await fetchAllRows<{ id: string; org_number: string | null; name: string | null }>(
({ from, to }) =>
supabase
.from('suppliers')
.select('id, org_number, name')
.eq('company_id', companyId)
.range(from, to)
)
for (const row of existingSuppliers) {
if (row.org_number) orgNumberToSupplierId.set(row.org_number, row.id)
if (row.name) nameToSupplierId.set(row.name, row.id)
}
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
type PendingSupplier = { dto: SupplierDto; row: Record<string, unknown> }
const pending: PendingSupplier[] = []
for (const supplier of suppliers) {
if (!supplier.active) {
console.log(`[migration] Supplier skipped (inactive): ${supplier.party.name}`)
skipReasons.inactive = (skipReasons.inactive ?? 0) + 1
skipped++
continue
}
const orgNumber = supplier.party.legalEntity?.companyId ||
supplier.party.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
if (orgNumber) {
const { data: existing } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', orgNumber)
.limit(1)
if (existing && existing.length > 0) {
console.log(`[migration] Supplier skipped (duplicate org_number ${orgNumber}): ${supplier.party.name}`)
supplierIdMap.set(supplier.id, existing[0].id)
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
const orgNumber = getOrgNumberFromParty(supplier.party)
if (orgNumber && orgNumberToSupplierId.has(orgNumber)) {
supplierIdMap.set(supplier.id, orgNumberToSupplierId.get(orgNumber)!)
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
const mapped = mapSupplier(supplier, userId, companyId)
pending.push({ dto: supplier, row: mapSupplier(supplier, userId, companyId) })
}
for (const batch of chunk(pending, INSERT_CHUNK_SIZE)) {
const rows = batch.map((p) => p.row)
const { data: inserted, error } = await supabase
.from('suppliers')
.insert(mapped)
.select('id')
.single()
.insert(rows)
.select('id, org_number, name')
if (error || !inserted) {
console.error(`[migration] Supplier insert failed: ${supplier.party.name}`, error?.message)
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
} else {
supplierIdMap.set(supplier.id, inserted.id)
if (error) {
console.error(`[migration] Supplier batch insert failed (${batch.length} rows):`, error.message)
skipReasons.failed = (skipReasons.failed ?? 0) + batch.length
skipped += batch.length
continue
}
const insertedRows = inserted ?? []
for (let i = 0; i < batch.length && i < insertedRows.length; i++) {
const providerId = batch[i].dto.id
const newId = insertedRows[i].id
supplierIdMap.set(providerId, newId)
if (insertedRows[i].org_number) orgNumberToSupplierId.set(insertedRows[i].org_number!, newId)
if (insertedRows[i].name) nameToSupplierId.set(insertedRows[i].name!, newId)
imported++
}
}
@@ -226,44 +296,68 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
// ── Step 4: Sales invoices ────────────────────────────────────
// ── Step 4: Sales invoices (bulk) ─────────────────────────────
if (options.importSalesInvoices !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar kundfakturor...', progress: 60 })
try {
const invoices = await fetchSalesInvoicesDirect(provider, accessToken, providerCompanyId)
console.log(`[migration] Sales invoices: ${invoices.length} total`)
// Bulk-load existing invoice numbers once.
const existingInvoices = await fetchAllRows<{ invoice_number: string }>(({ from, to }) =>
supabase
.from('invoices')
.select('invoice_number')
.eq('company_id', companyId)
.range(from, to)
)
const existingInvoiceNumbers = new Set(existingInvoices.map((r) => r.invoice_number))
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
for (const inv of invoices) {
const customerOrgNumber = inv.customer.legalEntity?.companyId ||
inv.customer.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
// Phase A: resolve customer for each invoice; collect those that
// need a minimal customer record to be created on-the-fly.
type ResolvedInvoice = { dto: SalesInvoiceDto; customerId: string }
const resolved: ResolvedInvoice[] = []
type NewCustomerStub = {
key: string // dedupe key (orgNumber or lowercased name)
row: Record<string, unknown>
// invoices waiting for this stub's id
waitingInvoiceIndices: number[]
}
const stubByKey = new Map<string, NewCustomerStub>()
const stubsForThisBatch: { orgNumber: string | null; name: string }[] = []
for (const inv of invoices) {
if (existingInvoiceNumbers.has(inv.invoiceNumber)) {
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
const customerOrgNumber = getOrgNumberFromParty(inv.customer)
let customerId: string | null = null
if (customerOrgNumber) {
const { data: match } = await supabase
.from('customers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', customerOrgNumber)
.limit(1)
if (match?.[0]) customerId = match[0].id
if (customerOrgNumber && orgNumberToCustomerId.has(customerOrgNumber)) {
customerId = orgNumberToCustomerId.get(customerOrgNumber)!
} else if (nameToCustomerId.has(inv.customer.name)) {
customerId = nameToCustomerId.get(inv.customer.name)!
}
if (!customerId) {
const { data: match } = await supabase
.from('customers')
.select('id')
.eq('company_id', companyId)
.eq('name', inv.customer.name)
.limit(1)
if (match?.[0]) customerId = match[0].id
if (customerId) {
resolved.push({ dto: inv, customerId })
continue
}
if (!customerId) {
// Need to create a minimal customer — dedupe by org number first,
// then by name, so invoices sharing a missing party only create
// one stub row.
const key = (customerOrgNumber ?? `name:${inv.customer.name.toLowerCase()}`).trim()
let stub = stubByKey.get(key)
if (!stub) {
const customerType = inferTypeFromParty(inv.customer)
const minimalCustomer = {
user_id: userId,
@@ -271,66 +365,104 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
name: inv.customer.name,
customer_type: customerType,
default_payment_terms: 30,
country: inv.customer.postalAddress?.countryCode || (customerType === 'swedish_business' ? 'SE' : null),
country:
inv.customer.postalAddress?.countryCode ||
(customerType === 'swedish_business' ? 'SE' : null),
vat_number_validated: false,
org_number: customerOrgNumber,
}
const { data: created, error: custErr } = await supabase
stub = { key, row: minimalCustomer, waitingInvoiceIndices: [] }
stubByKey.set(key, stub)
stubsForThisBatch.push({ orgNumber: customerOrgNumber, name: inv.customer.name })
}
// reserve slot; we'll backfill customerId after stubs insert
const placeholderIndex = resolved.length
resolved.push({ dto: inv, customerId: '' })
stub.waitingInvoiceIndices.push(placeholderIndex)
}
// Phase B: insert any missing customer stubs in chunks.
if (stubByKey.size > 0) {
const stubList = [...stubByKey.values()]
for (const batch of chunk(stubList, INSERT_CHUNK_SIZE)) {
const { data: inserted, error } = await supabase
.from('customers')
.insert(minimalCustomer)
.select('id')
.single()
if (created) {
customerId = created.id
} else {
console.error(`[migration] Sales invoice ${inv.invoiceNumber} skipped — could not create customer "${inv.customer.name}":`, custErr?.message)
.insert(batch.map((s) => s.row))
.select('id, org_number, name')
if (error) {
console.error(
`[migration] Sales invoice customer stub insert failed (${batch.length} rows):`,
error.message
)
// Mark invoices waiting on failed stubs as no-match
for (const s of batch) {
for (const idx of s.waitingInvoiceIndices) {
resolved[idx] = { ...resolved[idx], customerId: '__FAILED__' }
}
}
continue
}
const insertedRows = inserted ?? []
for (let i = 0; i < batch.length && i < insertedRows.length; i++) {
const newId = insertedRows[i].id
if (insertedRows[i].org_number) orgNumberToCustomerId.set(insertedRows[i].org_number!, newId)
if (insertedRows[i].name) nameToCustomerId.set(insertedRows[i].name!, newId)
for (const idx of batch[i].waitingInvoiceIndices) {
resolved[idx] = { ...resolved[idx], customerId: newId }
}
}
}
}
if (!customerId) {
console.log(`[migration] Sales invoice ${inv.invoiceNumber} skipped — no customer match for "${inv.customer.name}" (org: ${customerOrgNumber || 'n/a'})`)
// Drop invoices whose customer couldn't be created.
const ready = resolved.filter((r) => {
if (r.customerId === '__FAILED__') {
skipReasons.noMatch = (skipReasons.noMatch ?? 0) + 1
skipped++
continue
return false
}
return !!r.customerId
})
const { data: existingInv } = await supabase
// Phase C: chunk-insert invoices + their line items.
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
const mappedBatch = batch.map((r) => ({
...mapSalesInvoice(r.dto, userId, companyId, r.customerId),
dto: r.dto,
}))
const { data: insertedInvoices, error: invErr } = await supabase
.from('invoices')
.insert(mappedBatch.map((m) => m.invoice))
.select('id')
.eq('company_id', companyId)
.eq('invoice_number', inv.invoiceNumber)
.limit(1)
if (existingInv && existingInv.length > 0) {
console.log(`[migration] Sales invoice ${inv.invoiceNumber} skipped — already exists`)
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
if (invErr) {
console.error(`[migration] Sales invoice batch insert failed (${batch.length}):`, invErr.message)
skipReasons.failed = (skipReasons.failed ?? 0) + batch.length
skipped += batch.length
continue
}
const { invoice: mappedInvoice, items: mappedItems } = mapSalesInvoice(inv, userId, companyId, customerId)
const { data: insertedInv, error: invError } = await supabase
.from('invoices')
.insert(mappedInvoice)
.select('id')
.single()
if (invError || !insertedInv) {
console.error(`[migration] Sales invoice ${inv.invoiceNumber} insert failed:`, invError?.message)
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
continue
const invoiceRows = insertedInvoices ?? []
const allItems: Record<string, unknown>[] = []
for (let i = 0; i < mappedBatch.length && i < invoiceRows.length; i++) {
const invoiceId = invoiceRows[i].id
for (const item of mappedBatch[i].items) {
allItems.push({ ...item, invoice_id: invoiceId })
}
imported++
}
if (mappedItems.length > 0) {
const itemsWithInvoiceId = mappedItems.map(item => ({
...item,
invoice_id: insertedInv.id,
}))
await supabase.from('invoice_items').insert(itemsWithInvoiceId)
if (allItems.length > 0) {
for (const itemBatch of chunk(allItems, INSERT_CHUNK_SIZE)) {
const { error: itemErr } = await supabase.from('invoice_items').insert(itemBatch)
if (itemErr) {
console.error(`[migration] Sales invoice items insert failed (${itemBatch.length}):`, itemErr.message)
}
}
}
imported++
}
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons }
@@ -339,44 +471,81 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
// ── Step 5: Supplier invoices ─────────────────────────────────
// ── Step 5: Supplier invoices (bulk) ──────────────────────────
if (options.importSupplierInvoices !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörsfakturor...', progress: 80 })
try {
const invoices = await fetchSupplierInvoicesDirect(provider, accessToken, providerCompanyId)
console.log(`[migration] Supplier invoices: ${invoices.length} total`)
// Load existing (supplier_invoice_number, supplier_id) pairs once.
const existingSuppInv = await fetchAllRows<{
supplier_invoice_number: string | null
supplier_id: string | null
}>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select('supplier_invoice_number, supplier_id')
.eq('company_id', companyId)
.range(from, to)
)
const existingSuppInvKeys = new Set(
existingSuppInv
.filter((r) => r.supplier_invoice_number && r.supplier_id)
.map((r) => `${r.supplier_id}::${r.supplier_invoice_number}`)
)
// Compute next arrival number locally. Unique index is
// (company_id, arrival_number); we're the only writer during
// migration so incrementing in-memory is safe.
const { data: maxRow } = await supabase
.from('supplier_invoices')
.select('arrival_number')
.eq('company_id', companyId)
.order('arrival_number', { ascending: false })
.limit(1)
.maybeSingle()
let nextArrivalNumber = ((maxRow?.arrival_number as number | undefined) ?? 0) + 1
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
for (const inv of invoices) {
const supplierOrgNumber = inv.supplier.legalEntity?.companyId ||
inv.supplier.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
type ResolvedSupplierInvoice = { dto: SupplierInvoiceDto; supplierId: string }
const resolved: ResolvedSupplierInvoice[] = []
type NewSupplierStub = {
key: string
row: Record<string, unknown>
waitingInvoiceIndices: number[]
}
const stubByKey = new Map<string, NewSupplierStub>()
for (const inv of invoices) {
const supplierOrgNumber = getOrgNumberFromParty(inv.supplier)
let supplierId: string | null = null
if (supplierOrgNumber) {
const { data: match } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', supplierOrgNumber)
.limit(1)
if (match?.[0]) supplierId = match[0].id
if (supplierOrgNumber && orgNumberToSupplierId.has(supplierOrgNumber)) {
supplierId = orgNumberToSupplierId.get(supplierOrgNumber)!
} else if (nameToSupplierId.has(inv.supplier.name)) {
supplierId = nameToSupplierId.get(inv.supplier.name)!
}
if (!supplierId) {
const { data: match } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('name', inv.supplier.name)
.limit(1)
if (match?.[0]) supplierId = match[0].id
if (supplierId) {
const dupKey = `${supplierId}::${inv.invoiceNumber}`
if (existingSuppInvKeys.has(dupKey)) {
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
resolved.push({ dto: inv, supplierId })
continue
}
if (!supplierId) {
// Need to create a minimal supplier — dedupe the same way as customers.
const key = (supplierOrgNumber ?? `name:${inv.supplier.name.toLowerCase()}`).trim()
let stub = stubByKey.get(key)
if (!stub) {
const supplierType = inferTypeFromParty(inv.supplier)
const minimalSupplier = {
user_id: userId,
@@ -385,79 +554,110 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
supplier_type: supplierType,
default_payment_terms: 30,
default_currency: 'SEK',
country: inv.supplier.postalAddress?.countryCode || (supplierType === 'swedish_business' ? 'SE' : null),
country:
inv.supplier.postalAddress?.countryCode ||
(supplierType === 'swedish_business' ? 'SE' : null),
org_number: supplierOrgNumber,
}
const { data: created, error: supErr } = await supabase
stub = { key, row: minimalSupplier, waitingInvoiceIndices: [] }
stubByKey.set(key, stub)
}
const placeholderIndex = resolved.length
resolved.push({ dto: inv, supplierId: '' })
stub.waitingInvoiceIndices.push(placeholderIndex)
}
if (stubByKey.size > 0) {
const stubList = [...stubByKey.values()]
for (const batch of chunk(stubList, INSERT_CHUNK_SIZE)) {
const { data: inserted, error } = await supabase
.from('suppliers')
.insert(minimalSupplier)
.select('id')
.single()
if (created) {
supplierId = created.id
} else {
console.error(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — could not create supplier "${inv.supplier.name}":`, supErr?.message)
.insert(batch.map((s) => s.row))
.select('id, org_number, name')
if (error) {
console.error(
`[migration] Supplier invoice supplier stub insert failed (${batch.length}):`,
error.message
)
for (const s of batch) {
for (const idx of s.waitingInvoiceIndices) {
resolved[idx] = { ...resolved[idx], supplierId: '__FAILED__' }
}
}
continue
}
const insertedRows = inserted ?? []
for (let i = 0; i < batch.length && i < insertedRows.length; i++) {
const newId = insertedRows[i].id
if (insertedRows[i].org_number) orgNumberToSupplierId.set(insertedRows[i].org_number!, newId)
if (insertedRows[i].name) nameToSupplierId.set(insertedRows[i].name!, newId)
for (const idx of batch[i].waitingInvoiceIndices) {
resolved[idx] = { ...resolved[idx], supplierId: newId }
}
}
}
}
if (!supplierId) {
console.log(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — no supplier match for "${inv.supplier.name}" (org: ${supplierOrgNumber || 'n/a'})`)
skipReasons.noMatch = (skipReasons.noMatch ?? 0) + 1
skipped++
continue
// After stubs, do a final dedupe pass against existing supplier invoices
// using the now-resolved supplierId.
const ready = resolved.filter((r) => {
if (r.supplierId === '__FAILED__' || !r.supplierId) {
if (r.supplierId === '__FAILED__') {
skipReasons.noMatch = (skipReasons.noMatch ?? 0) + 1
skipped++
}
return false
}
const { data: existingInv } = await supabase
.from('supplier_invoices')
.select('id')
.eq('company_id', companyId)
.eq('supplier_invoice_number', inv.invoiceNumber)
.eq('supplier_id', supplierId)
.limit(1)
if (existingInv && existingInv.length > 0) {
console.log(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — already exists for supplier "${inv.supplier.name}"`)
const dupKey = `${r.supplierId}::${r.dto.invoiceNumber}`
if (existingSuppInvKeys.has(dupKey)) {
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
return false
}
return true
})
const { invoice: mappedInvoice, items: mappedItems } = mapSupplierInvoice(inv, userId, companyId, supplierId)
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
const mappedBatch = batch.map((r) => {
const { invoice, items } = mapSupplierInvoice(r.dto, userId, companyId, r.supplierId)
invoice.arrival_number = nextArrivalNumber++
return { invoice, items, dto: r.dto }
})
// Get next arrival number (ankomstnummer) — required NOT NULL column
const { data: arrivalNum, error: arrivalError } = await supabase
.rpc('get_next_arrival_number', { p_company_id: companyId })
if (arrivalError || arrivalNum == null) {
console.error(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — could not get arrival number:`, arrivalError?.message)
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
continue
}
mappedInvoice.arrival_number = arrivalNum
const { data: insertedInv, error: invError } = await supabase
const { data: insertedInvoices, error: invErr } = await supabase
.from('supplier_invoices')
.insert(mappedInvoice)
.insert(mappedBatch.map((m) => m.invoice))
.select('id')
.single()
if (invError || !insertedInv) {
console.error(`[migration] Supplier invoice ${inv.invoiceNumber} insert failed for "${inv.supplier.name}":`, invError?.message, JSON.stringify(mappedInvoice, null, 2))
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
if (invErr) {
console.error(`[migration] Supplier invoice batch insert failed (${batch.length}):`, invErr.message)
skipReasons.failed = (skipReasons.failed ?? 0) + batch.length
skipped += batch.length
// Roll the counter back so we don't leave a huge gap on retry.
nextArrivalNumber -= batch.length
continue
}
if (mappedItems.length > 0) {
const itemsWithInvoiceId = mappedItems.map(item => ({
...item,
supplier_invoice_id: insertedInv.id,
}))
await supabase.from('supplier_invoice_items').insert(itemsWithInvoiceId)
const invoiceRows = insertedInvoices ?? []
const allItems: Record<string, unknown>[] = []
for (let i = 0; i < mappedBatch.length && i < invoiceRows.length; i++) {
const invoiceId = invoiceRows[i].id
for (const item of mappedBatch[i].items) {
allItems.push({ ...item, supplier_invoice_id: invoiceId })
}
imported++
}
imported++
if (allItems.length > 0) {
for (const itemBatch of chunk(allItems, INSERT_CHUNK_SIZE)) {
const { error: itemErr } = await supabase.from('supplier_invoice_items').insert(itemBatch)
if (itemErr) {
console.error(`[migration] Supplier invoice items insert failed (${itemBatch.length}):`, itemErr.message)
}
}
}
}
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons }
@@ -17,6 +17,7 @@ import {
getEmails,
getPhones,
} from '../lib/tic-client'
import { TICAPIError } from '../lib/tic-types'
import type { TICCompanyDocument } from '../lib/tic-types'
const mockSearch = vi.mocked(searchCompanyByOrgNumber)
@@ -142,4 +143,50 @@ describe('TIC lookup route', () => {
const { data } = await res.json()
expect(data.isCeased).toBe(true)
})
it('returns 503 when TIC is not configured', async () => {
mockSearch.mockRejectedValue(
new TICAPIError('TIC_API_PROXY_URL is not configured', undefined, 'NOT_CONFIGURED')
)
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(503)
})
it('returns 429 when TIC rate-limits us', async () => {
mockSearch.mockRejectedValue(
new TICAPIError('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED')
)
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(429)
})
it('returns 504 when TIC times out', async () => {
mockSearch.mockRejectedValue(new TICAPIError('Request timeout', undefined, 'TIMEOUT'))
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(504)
})
it('returns 400 when upstream rejects the org number (4xx)', async () => {
mockSearch.mockRejectedValue(new TICAPIError('TIC API error: Bad Request', 400))
const res = await lookupHandler(makeRequest('1234567-1234'))
expect(res.status).toBe(400)
})
it('returns 502 when upstream returns 5xx', async () => {
mockSearch.mockRejectedValue(new TICAPIError('TIC API error: Bad Gateway', 502))
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(502)
})
it('returns 502 when fetch fails (network error)', async () => {
mockSearch.mockRejectedValue(new TICAPIError('Failed to fetch from TIC: ECONNRESET'))
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(502)
})
it('returns 500 for non-TICAPIError unexpected errors', async () => {
mockSearch.mockRejectedValue(new Error('boom'))
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(500)
})
})
+93 -59
View File
@@ -43,6 +43,88 @@ function bankAccountTypeLabel(type?: number): string {
}
}
/**
* Translate any error from the TIC pipeline into a structured HTTP response.
*
* Status mapping:
* - NOT_CONFIGURED → 503 (proxy URL missing)
* - RATE_LIMIT_EXCEEDED → 429 (TIC quota hit)
* - TIMEOUT → 504 (TIC took longer than 15s)
* - upstream 4xx → 400 (TIC rejected the input — typically a malformed org number)
* - upstream 5xx → 502 (TIC outage)
* - other / unknown → 500
*
* Always logs the cleaned org number so we can correlate failures with input
* in Vercel logs.
*/
function handleTicError(
error: unknown,
log: { error: (msg: string, meta?: unknown) => void } | Console,
route: 'lookup' | 'profile',
orgNumber: string,
fallbackMessage: string
): Response {
if (error instanceof TICAPIError) {
const meta = {
route,
orgNumber,
message: error.message,
statusCode: error.statusCode,
code: error.code,
}
if (error.code === 'NOT_CONFIGURED') {
log.error(`[tic] ${route}: not configured`, meta)
return NextResponse.json({ error: 'TIC is not configured' }, { status: 503 })
}
if (error.code === 'RATE_LIMIT_EXCEEDED') {
log.error(`[tic] ${route}: rate limit exceeded`, meta)
return NextResponse.json({ error: 'Rate limit exceeded, try again later' }, { status: 429 })
}
if (error.code === 'TIMEOUT') {
log.error(`[tic] ${route}: upstream timeout`, meta)
return NextResponse.json(
{ error: 'TIC service did not respond in time' },
{ status: 504 }
)
}
// Upstream returned a non-OK status we surfaced as a TICAPIError
if (typeof error.statusCode === 'number') {
if (error.statusCode >= 400 && error.statusCode < 500) {
log.error(`[tic] ${route}: upstream rejected request`, meta)
return NextResponse.json(
{ error: 'Invalid request to TIC (upstream rejected)' },
{ status: 400 }
)
}
if (error.statusCode >= 500) {
log.error(`[tic] ${route}: upstream error`, meta)
return NextResponse.json(
{ error: 'TIC service is temporarily unavailable' },
{ status: 502 }
)
}
}
// Network/DNS/parse failure surfaced as a TICAPIError without code or statusCode
log.error(`[tic] ${route}: upstream failure`, meta)
return NextResponse.json(
{ error: 'TIC service is temporarily unavailable' },
{ status: 502 }
)
}
log.error(`[tic] ${route}: unexpected error`, {
route,
orgNumber,
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error),
})
return NextResponse.json({ error: fallbackMessage }, { status: 500 })
}
export const ticExtension: Extension = {
id: 'tic',
name: 'Bolagsuppgifter',
@@ -65,6 +147,8 @@ export const ticExtension: Extension = {
)
}
const cleanedOrgNumber = orgNumber.replace(/[\s-]/g, '')
try {
// Phase 1: Search — returns name, address, registration flags
const doc = await searchCompanyByOrgNumber(orgNumber)
@@ -137,10 +221,10 @@ export const ticExtension: Extension = {
// Log Phase 2 failures for debugging
if (bankResult.status === 'rejected') {
log.warn('[tic] bank accounts fetch failed', { reason: String(bankResult.reason) })
log.warn('[tic] bank accounts fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(bankResult.reason) })
}
if (sniResult.status === 'rejected') {
log.warn('[tic] SNI codes fetch failed', { reason: String(sniResult.reason) })
log.warn('[tic] SNI codes fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(sniResult.reason) })
}
const result: CompanyLookupResult = {
@@ -156,33 +240,7 @@ export const ticExtension: Extension = {
return NextResponse.json({ data: result })
} catch (error) {
if (error instanceof TICAPIError) {
log.error('[tic] lookup failed', {
message: error.message,
statusCode: error.statusCode,
code: error.code,
})
if (error.code === 'NOT_CONFIGURED') {
return NextResponse.json(
{ error: 'TIC is not configured' },
{ status: 503 }
)
}
if (error.code === 'RATE_LIMIT_EXCEEDED') {
return NextResponse.json(
{ error: 'Rate limit exceeded, try again later' },
{ status: 429 }
)
}
}
log.error('[tic] unexpected error', { error: String(error) })
return NextResponse.json(
{ error: 'Failed to look up company' },
{ status: 500 }
)
return handleTicError(error, log, 'lookup', cleanedOrgNumber, 'Failed to look up company')
}
},
},
@@ -201,6 +259,8 @@ export const ticExtension: Extension = {
)
}
const cleanedOrgNumber = orgNumber.replace(/[\s-]/g, '')
try {
const doc = await searchCompanyByOrgNumber(orgNumber)
@@ -267,13 +327,13 @@ export const ticExtension: Extension = {
// Log Phase 2 failures
if (bankResult.status === 'rejected') {
log.warn('[tic] profile: bank accounts fetch failed', { reason: String(bankResult.reason) })
log.warn('[tic] profile: bank accounts fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(bankResult.reason) })
}
if (sniResult.status === 'rejected') {
log.warn('[tic] profile: SNI codes fetch failed', { reason: String(sniResult.reason) })
log.warn('[tic] profile: SNI codes fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(sniResult.reason) })
}
if (reportsResult.status === 'rejected') {
log.warn('[tic] profile: financial reports fetch failed', { reason: String(reportsResult.reason) })
log.warn('[tic] profile: financial reports fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(reportsResult.reason) })
}
const fin = doc.mostRecentFinancialSummary
@@ -329,33 +389,7 @@ export const ticExtension: Extension = {
return NextResponse.json({ data: profile })
} catch (error) {
if (error instanceof TICAPIError) {
log.error('[tic] profile failed', {
message: error.message,
statusCode: error.statusCode,
code: error.code,
})
if (error.code === 'NOT_CONFIGURED') {
return NextResponse.json(
{ error: 'TIC is not configured' },
{ status: 503 }
)
}
if (error.code === 'RATE_LIMIT_EXCEEDED') {
return NextResponse.json(
{ error: 'Rate limit exceeded, try again later' },
{ status: 429 }
)
}
}
log.error('[tic] profile unexpected error', { error: String(error) })
return NextResponse.json(
{ error: 'Failed to fetch company profile' },
{ status: 500 }
)
return handleTicError(error, log, 'profile', cleanedOrgNumber, 'Failed to fetch company profile')
}
},
},