* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1128 lines
50 KiB
TypeScript
1128 lines
50 KiB
TypeScript
/**
|
|
* Migration orchestrator: coordinates the data migration from
|
|
* an external accounting system directly via provider APIs into gnubok.
|
|
*
|
|
* Bookkeeping data (accounts, balances, vouchers) is imported
|
|
* via SIE files through the core SIE import engine. This orchestrator
|
|
* handles only entity-level imports:
|
|
* 1. Company info → pre-fill company_settings
|
|
* 2. Customers → needed before sales invoices
|
|
* 3. Suppliers → needed before supplier invoices
|
|
* 4. Sales invoices (all statuses, duplicates skipped)
|
|
* 5. Supplier invoices (all statuses, duplicates skipped)
|
|
* 6. Asset register (Fortnox only) → asset register rows, no journal entries
|
|
*
|
|
* 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 { chunk } from '@/lib/utils'
|
|
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'
|
|
import {
|
|
fetchCompanyInfoDirect,
|
|
fetchCustomersDirect,
|
|
fetchSuppliersDirect,
|
|
fetchSalesInvoicesHydrated,
|
|
fetchSupplierInvoicesHydrated,
|
|
} from '@/lib/providers/provider-data-fetcher'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
|
|
import {
|
|
linkMigratedRegistrationVouchers,
|
|
type MigratedInvoiceLinkInput,
|
|
} from '@/lib/invoices/link-migrated-registration-vouchers'
|
|
import {
|
|
buildCustomerMetadataEnrichment,
|
|
type CustomerMetadataEnrichment,
|
|
type ExistingCustomerMetadata,
|
|
} from './customer-metadata'
|
|
import { insertWithPerRowFallback } from './insert-fallback'
|
|
import { importProviderAssets, FortnoxAssetScopesRequiredError } from './import-assets'
|
|
import {
|
|
mapCustomer,
|
|
mapSupplier,
|
|
mapSalesInvoice,
|
|
mapSupplierInvoice,
|
|
mapCompanyInfo,
|
|
inferTypeFromParty,
|
|
buildFxRateIndex,
|
|
type FxUnresolved,
|
|
} from './entity-mapper'
|
|
|
|
const log = createLogger('extensions/arcim-migration/migration-orchestrator')
|
|
|
|
export interface MigrationOptions {
|
|
consentId: string
|
|
companyId: string
|
|
userId: string
|
|
supabase: SupabaseClient
|
|
importCompanyInfo?: boolean
|
|
importCustomers?: boolean
|
|
importSuppliers?: boolean
|
|
importSalesInvoices?: boolean
|
|
importSupplierInvoices?: boolean
|
|
/** Import the provider's asset register (Fortnox only). Default true. */
|
|
importAssets?: boolean
|
|
/** Auto-link imported supplier invoices to GL payment vouchers. Default true. */
|
|
reconcileVouchers?: boolean
|
|
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
|
|
const ENRICHMENT_CONCURRENCY = 10
|
|
|
|
function emitProgress(options: MigrationOptions, progress: MigrationProgress) {
|
|
options.onProgress?.(progress)
|
|
}
|
|
|
|
/**
|
|
* Codes that doom every remaining provider call in this run: dead/expired
|
|
* grant, missing integration license, API module not activated. Continuing
|
|
* would fail four more times with the same cause and then report a
|
|
* "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',
|
|
'PROVIDER_LICENSE_MISSING',
|
|
'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
|
|
* the others' already-persisted rows); fatal connection-level failures
|
|
* rethrow, see FATAL_STEP_ERROR_CODES.
|
|
*/
|
|
function recordStepError(
|
|
results: MigrationResults,
|
|
step: MigrationStepError['step'],
|
|
err: unknown,
|
|
runState: ProviderRunState,
|
|
): void {
|
|
// 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
|
|
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 })
|
|
}
|
|
|
|
function getOrgNumberFromParty(party: PartyDto): string | null {
|
|
return (
|
|
party.legalEntity?.companyId ||
|
|
party.identifications?.find((i) => i.schemeId === 'SE:ORGNR')?.id ||
|
|
null
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Log a foreign-currency document that was imported WITHOUT a SEK conversion.
|
|
*
|
|
* It is still imported (dropping it would lose räkenskapsinformation), but it
|
|
* carries exchange_rate = null, so every booking path refuses it loudly rather
|
|
* than posting it as if 1 unit = 1 SEK. Counted into the step's result so the
|
|
* migration reports it instead of passing it off as an ordinary import.
|
|
*/
|
|
function logFxUnresolved(kind: string, invoiceNumber: string, fx: FxUnresolved): void {
|
|
// Structured logger, not console.error: the record passes the observability
|
|
// redaction pipeline (lib/observability/redact.ts) before it can reach any
|
|
// sink, so invoice identifiers in log output stay inside the same PII
|
|
// controls as every other server log line.
|
|
log.error('document imported without a SEK conversion; set an exchange rate before booking it', {
|
|
entityType: kind,
|
|
entityId: invoiceNumber,
|
|
currency: fx.currency,
|
|
documentDate: fx.date || null,
|
|
reason: fx.reason,
|
|
})
|
|
}
|
|
|
|
// ── Main orchestrator ─────────────────────────────────────────────
|
|
|
|
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).
|
|
const registrationLinkInputs: MigratedInvoiceLinkInput[] = []
|
|
|
|
// Resolve consent to get access token and provider
|
|
const resolved = await resolveConsent(companyId, consentId)
|
|
const provider = resolved.consent.provider as ProviderName
|
|
const accessToken = resolved.accessToken
|
|
const providerCompanyId = resolved.providerCompanyId
|
|
|
|
try {
|
|
// ── Step 1: Company information ───────────────────────────────
|
|
if (options.importCompanyInfo !== false) {
|
|
emitProgress(options, { status: 'fetching', currentStep: 'Hämtar företagsinformation...', progress: 5 })
|
|
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')
|
|
.select('company_name, org_number, vat_number')
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
const updates: Record<string, unknown> = {}
|
|
if (!existing?.company_name && mapped.company_name) updates.company_name = mapped.company_name
|
|
if (!existing?.org_number && mapped.org_number) updates.org_number = mapped.org_number
|
|
if (!existing?.vat_number && mapped.vat_number) {
|
|
// Normalise provider input; only persist a structurally valid
|
|
// SE+12 momsregistreringsnummer so a malformed value from an
|
|
// external API can't enter company_settings unchecked.
|
|
const normalizedVat = normalizeVatNumber(mapped.vat_number)
|
|
if (isValidSwedishVatNumber(normalizedVat)) {
|
|
updates.vat_number = normalizedVat
|
|
updates.vat_registered = true
|
|
} else {
|
|
// Observability: a provider sent a VAT number we can't normalise
|
|
// to a valid SE+12 momsregistreringsnummer. We drop it (above),
|
|
// but surface the anomaly so consistently-bad provider data is
|
|
// visible. Don't log the raw value: it can embed a personnummer.
|
|
console.warn(
|
|
`[migration] Dropped malformed VAT number from ${provider} for company ${companyId} (normalized length ${normalizedVat.length})`,
|
|
)
|
|
}
|
|
}
|
|
if (mapped.fiscal_year_start_month !== 1) {
|
|
updates.fiscal_year_start_month = mapped.fiscal_year_start_month
|
|
}
|
|
if (mapped.address_line1) updates.address_line1 = mapped.address_line1
|
|
if (mapped.postal_code) updates.postal_code = mapped.postal_code
|
|
if (mapped.city) updates.city = mapped.city
|
|
if (mapped.phone) updates.phone = mapped.phone
|
|
if (mapped.email) updates.email = mapped.email
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
await supabase.from('company_settings').update(updates).eq('company_id', companyId)
|
|
}
|
|
results.companyInfo = { imported: true }
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to import company info:', err)
|
|
results.companyInfo = { imported: false }
|
|
recordStepError(results, 'companyInfo', err, runState)
|
|
}
|
|
}
|
|
|
|
// ── Step 2: Customers (bulk) ──────────────────────────────────
|
|
// customerIdMap: provider customer id → Accounted 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)
|
|
// 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 & {
|
|
id: string
|
|
org_number: string | null
|
|
name: string | null
|
|
}
|
|
const existingCustomers = await fetchAllRows<ExistingCustomer>(
|
|
({ from, to }) =>
|
|
supabase
|
|
.from('customers')
|
|
.select('id, org_number, name, contact_person, invoice_email_cc_addresses, invoice_email_bcc_addresses')
|
|
.eq('company_id', companyId)
|
|
.range(from, to)
|
|
)
|
|
const existingCustomerById = new Map(existingCustomers.map((row) => [row.id, row]))
|
|
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 updated = 0
|
|
let skipped = 0
|
|
const skipReasons: SkipReasons = {}
|
|
let errorSample: string | null = null
|
|
|
|
type PendingCustomer = {
|
|
dto: CustomerDto
|
|
row: Record<string, unknown>
|
|
}
|
|
const pending: PendingCustomer[] = []
|
|
const pendingEnrichments: { id: string; changes: CustomerMetadataEnrichment }[] = []
|
|
// Providers can hand back the same record more than once (a paging
|
|
// fault upstream, or genuine source duplicates). The DB-backed maps
|
|
// above only know rows that existed BEFORE this run, so without an
|
|
// in-run key set every repeat would be inserted again.
|
|
const pendingCustomerKeys = new Set<string>()
|
|
|
|
for (const customer of customers) {
|
|
if (!customer.active) {
|
|
skipReasons.inactive = (skipReasons.inactive ?? 0) + 1
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
// Dedup against already-imported records: prefer org-number, but fall
|
|
// back to name when the party has no org-number. Otherwise org-less
|
|
// customers (private persons) are re-created on every re-sync, since
|
|
// the org-number map can never match them.
|
|
const orgNumber = getOrgNumberFromParty(customer.party)
|
|
const existingCustomerId = orgNumber
|
|
? orgNumberToCustomerId.get(orgNumber)
|
|
: customer.party.name
|
|
? nameToCustomerId.get(customer.party.name)
|
|
: undefined
|
|
if (existingCustomerId) {
|
|
customerIdMap.set(customer.id, existingCustomerId)
|
|
const existingCustomer = existingCustomerById.get(existingCustomerId)
|
|
const mapped = mapCustomer(customer, userId, companyId)
|
|
const changes = existingCustomer
|
|
? buildCustomerMetadataEnrichment(existingCustomer, mapped)
|
|
: null
|
|
if (changes) {
|
|
pendingEnrichments.push({ id: existingCustomerId, changes })
|
|
} else {
|
|
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
|
|
skipped++
|
|
}
|
|
continue
|
|
}
|
|
|
|
const pendingKey = (orgNumber ?? `name:${customer.party.name?.toLowerCase() ?? ''}`).trim()
|
|
if (pendingCustomerKeys.has(pendingKey)) {
|
|
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
|
|
skipped++
|
|
continue
|
|
}
|
|
pendingCustomerKeys.add(pendingKey)
|
|
|
|
pending.push({ dto: customer, row: mapCustomer(customer, userId, companyId) })
|
|
}
|
|
|
|
for (const batch of chunk(pending, INSERT_CHUNK_SIZE)) {
|
|
const outcome = await insertWithPerRowFallback(
|
|
supabase, 'customers', batch.map((p) => p.row), 'id, org_number, name'
|
|
)
|
|
|
|
if (outcome.failedCount > 0) {
|
|
console.error(
|
|
`[migration] Customer insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
|
|
outcome.firstError
|
|
)
|
|
skipReasons.failed = (skipReasons.failed ?? 0) + outcome.failedCount
|
|
skipped += outcome.failedCount
|
|
errorSample ??= outcome.firstError
|
|
}
|
|
|
|
for (let i = 0; i < batch.length; i++) {
|
|
const insertedRow = outcome.returned[i]
|
|
if (!insertedRow) continue
|
|
const providerId = batch[i].dto.id
|
|
const newId = insertedRow.id as string
|
|
customerIdMap.set(providerId, newId)
|
|
if (insertedRow.org_number) orgNumberToCustomerId.set(insertedRow.org_number as string, newId)
|
|
if (insertedRow.name) nameToCustomerId.set(insertedRow.name as string, newId)
|
|
imported++
|
|
}
|
|
}
|
|
|
|
// A rerun can match hundreds of legacy customers. Update only rows
|
|
// that actually have new provider metadata, with bounded concurrency,
|
|
// so enrichment neither overwrites edits nor serializes the migration.
|
|
for (const batch of chunk(pendingEnrichments, ENRICHMENT_CONCURRENCY)) {
|
|
const outcomes = await Promise.all(batch.map(async ({ id, changes }) => {
|
|
const { data, error } = await supabase
|
|
.from('customers')
|
|
// Object literal, not the record itself: absent keys serialize
|
|
// away, and the phantom-column guard can resolve the columns.
|
|
.update({
|
|
contact_person: changes.contact_person,
|
|
invoice_email_cc_addresses: changes.invoice_email_cc_addresses,
|
|
invoice_email_bcc_addresses: changes.invoice_email_bcc_addresses,
|
|
})
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.select('id')
|
|
.maybeSingle()
|
|
return { data, error }
|
|
}))
|
|
|
|
for (const outcome of outcomes) {
|
|
if (outcome.error || !outcome.data) {
|
|
if (outcome.error) {
|
|
console.error('[migration] Customer metadata enrichment failed:', outcome.error.message)
|
|
errorSample ??= outcome.error.message
|
|
}
|
|
skipReasons.failed = (skipReasons.failed ?? 0) + 1
|
|
skipped++
|
|
} else {
|
|
updated++
|
|
}
|
|
}
|
|
}
|
|
|
|
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, runState)
|
|
}
|
|
}
|
|
|
|
// ── 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)
|
|
if (suppliers.length > 0) runState.grantProven = true
|
|
|
|
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 = {}
|
|
let errorSample: string | null = null
|
|
|
|
type PendingSupplier = { dto: SupplierDto; row: Record<string, unknown> }
|
|
const pending: PendingSupplier[] = []
|
|
// Same in-run repeat guard as customers.
|
|
const pendingSupplierKeys = new Set<string>()
|
|
|
|
for (const supplier of suppliers) {
|
|
if (!supplier.active) {
|
|
skipReasons.inactive = (skipReasons.inactive ?? 0) + 1
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
// Same org-number-then-name dedup as customers, so org-less suppliers
|
|
// (e.g. PostNord, IKANO BANK) aren't duplicated on every re-sync.
|
|
const orgNumber = getOrgNumberFromParty(supplier.party)
|
|
const existingSupplierId = orgNumber
|
|
? orgNumberToSupplierId.get(orgNumber)
|
|
: supplier.party.name
|
|
? nameToSupplierId.get(supplier.party.name)
|
|
: undefined
|
|
if (existingSupplierId) {
|
|
supplierIdMap.set(supplier.id, existingSupplierId)
|
|
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
const pendingKey = (orgNumber ?? `name:${supplier.party.name?.toLowerCase() ?? ''}`).trim()
|
|
if (pendingSupplierKeys.has(pendingKey)) {
|
|
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
|
|
skipped++
|
|
continue
|
|
}
|
|
pendingSupplierKeys.add(pendingKey)
|
|
|
|
pending.push({ dto: supplier, row: mapSupplier(supplier, userId, companyId) })
|
|
}
|
|
|
|
for (const batch of chunk(pending, INSERT_CHUNK_SIZE)) {
|
|
const outcome = await insertWithPerRowFallback(
|
|
supabase, 'suppliers', batch.map((p) => p.row), 'id, org_number, name'
|
|
)
|
|
|
|
if (outcome.failedCount > 0) {
|
|
console.error(
|
|
`[migration] Supplier insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
|
|
outcome.firstError
|
|
)
|
|
skipReasons.failed = (skipReasons.failed ?? 0) + outcome.failedCount
|
|
skipped += outcome.failedCount
|
|
errorSample ??= outcome.firstError
|
|
}
|
|
|
|
for (let i = 0; i < batch.length; i++) {
|
|
const insertedRow = outcome.returned[i]
|
|
if (!insertedRow) continue
|
|
const providerId = batch[i].dto.id
|
|
const newId = insertedRow.id as string
|
|
supplierIdMap.set(providerId, newId)
|
|
if (insertedRow.org_number) orgNumberToSupplierId.set(insertedRow.org_number as string, newId)
|
|
if (insertedRow.name) nameToSupplierId.set(insertedRow.name as string, newId)
|
|
imported++
|
|
}
|
|
}
|
|
|
|
results.suppliers = { total: suppliers.length, imported, skipped, skipReasons, errorSample: errorSample ?? undefined }
|
|
} catch (err) {
|
|
console.error('Failed to import suppliers:', err)
|
|
recordStepError(results, 'suppliers', err, runState)
|
|
}
|
|
}
|
|
|
|
// ── Step 4: Sales invoices (bulk) ─────────────────────────────
|
|
if (options.importSalesInvoices !== false) {
|
|
emitProgress(options, { status: 'importing', currentStep: 'Importerar kundfakturor...', progress: 60 })
|
|
try {
|
|
// Hydrated, not the bare list: the list payload omits VAT, the net
|
|
// and the line items for most providers (see provider-data-fetcher).
|
|
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.
|
|
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 = {}
|
|
let errorSample: string | null = null
|
|
// invoice_number carries a UNIQUE (company_id, invoice_number) index,
|
|
// so a repeated number WITHIN the fetched set (paging fault or source
|
|
// duplicate) must be skipped here: inside one insert statement it
|
|
// would reject the whole chunk. Empty numbers are exempt: they are
|
|
// stored as NULL, which the partial index does not cover.
|
|
const seenInvoiceNumbers = new Set<string>()
|
|
|
|
// 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
|
|
}
|
|
if (inv.invoiceNumber) {
|
|
if (seenInvoiceNumbers.has(inv.invoiceNumber)) {
|
|
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
|
|
skipped++
|
|
continue
|
|
}
|
|
seenInvoiceNumbers.add(inv.invoiceNumber)
|
|
}
|
|
|
|
const customerOrgNumber = getOrgNumberFromParty(inv.customer)
|
|
let customerId: string | null = null
|
|
|
|
if (customerOrgNumber && orgNumberToCustomerId.has(customerOrgNumber)) {
|
|
customerId = orgNumberToCustomerId.get(customerOrgNumber)!
|
|
} else if (nameToCustomerId.has(inv.customer.name)) {
|
|
customerId = nameToCustomerId.get(inv.customer.name)!
|
|
}
|
|
|
|
if (customerId) {
|
|
resolved.push({ dto: inv, customerId })
|
|
continue
|
|
}
|
|
|
|
// 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,
|
|
company_id: companyId,
|
|
name: inv.customer.name,
|
|
customer_type: customerType,
|
|
default_payment_terms: 30,
|
|
country:
|
|
inv.customer.postalAddress?.countryCode ||
|
|
(customerType === 'swedish_business' ? 'SE' : null),
|
|
vat_number_validated: false,
|
|
org_number: customerOrgNumber,
|
|
}
|
|
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 outcome = await insertWithPerRowFallback(
|
|
supabase, 'customers', batch.map((s) => s.row), 'id, org_number, name'
|
|
)
|
|
|
|
if (outcome.failedCount > 0) {
|
|
console.error(
|
|
`[migration] Sales invoice customer stub insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
|
|
outcome.firstError
|
|
)
|
|
errorSample ??= outcome.firstError
|
|
}
|
|
|
|
for (let i = 0; i < batch.length; i++) {
|
|
const insertedRow = outcome.returned[i]
|
|
if (!insertedRow) {
|
|
// Mark invoices waiting on this failed stub as no-match
|
|
for (const idx of batch[i].waitingInvoiceIndices) {
|
|
resolved[idx] = { ...resolved[idx], customerId: '__FAILED__' }
|
|
}
|
|
continue
|
|
}
|
|
const newId = insertedRow.id as string
|
|
if (insertedRow.org_number) orgNumberToCustomerId.set(insertedRow.org_number as string, newId)
|
|
if (insertedRow.name) nameToCustomerId.set(insertedRow.name as string, newId)
|
|
for (const idx of batch[i].waitingInvoiceIndices) {
|
|
resolved[idx] = { ...resolved[idx], customerId: newId }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drop invoices whose customer couldn't be created. That is a DB
|
|
// failure (the stub insert errored, errorSample carries it), not a
|
|
// matching miss: counting it as noMatch would render a green result
|
|
// row with the database error hidden.
|
|
const ready = resolved.filter((r) => {
|
|
if (r.customerId === '__FAILED__') {
|
|
skipReasons.failed = (skipReasons.failed ?? 0) + 1
|
|
skipped++
|
|
return false
|
|
}
|
|
return !!r.customerId
|
|
})
|
|
|
|
// Phase B2: resolve the SEK conversion for every foreign-currency
|
|
// invoice, at the rate valid on its OWN issue date. The provider DTO
|
|
// carries no rate and no SEK amount, so without this every foreign
|
|
// invoice lands unconverted. One pass over the whole step (not per
|
|
// chunk) so repeat (currency, date) pairs are fetched once.
|
|
const fxRates = await buildFxRateIndex(
|
|
supabase,
|
|
ready.map((r) => ({ currencyCode: r.dto.currencyCode, issueDate: r.dto.issueDate }))
|
|
)
|
|
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)) {
|
|
const mappedBatch = batch.map((r) => ({
|
|
...mapSalesInvoice(r.dto, userId, companyId, r.customerId, fxRates),
|
|
dto: r.dto,
|
|
}))
|
|
|
|
const outcome = await insertWithPerRowFallback(
|
|
supabase, 'invoices', mappedBatch.map((m) => m.invoice), 'id'
|
|
)
|
|
|
|
if (outcome.failedCount > 0) {
|
|
console.error(
|
|
`[migration] Sales invoice insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
|
|
outcome.firstError
|
|
)
|
|
skipReasons.failed = (skipReasons.failed ?? 0) + outcome.failedCount
|
|
skipped += outcome.failedCount
|
|
errorSample ??= outcome.firstError
|
|
}
|
|
|
|
const allItems: Record<string, unknown>[] = []
|
|
for (let i = 0; i < mappedBatch.length; i++) {
|
|
const insertedRow = outcome.returned[i]
|
|
if (!insertedRow) continue
|
|
const invoiceId = insertedRow.id
|
|
for (const item of mappedBatch[i].items) {
|
|
allItems.push({ ...item, invoice_id: invoiceId })
|
|
}
|
|
registrationLinkInputs.push({
|
|
invoiceId: String(invoiceId),
|
|
kind: 'customer',
|
|
sourceVoucher: mappedBatch[i].dto.sourceVoucher ?? null,
|
|
refNotFetched: unhydratedIds.has(mappedBatch[i].dto.id),
|
|
invoiceDate: mappedBatch[i].dto.issueDate,
|
|
totalSek: mappedBatch[i].invoice.total_sek as number | null,
|
|
currencyCode: mappedBatch[i].dto.currencyCode || 'SEK',
|
|
invoiceNumber: mappedBatch[i].dto.invoiceNumber || null,
|
|
})
|
|
const fx = mappedBatch[i].fxUnresolved
|
|
if (fx) {
|
|
fxUnresolved++
|
|
logFxUnresolved('Sales invoice', mappedBatch[i].dto.invoiceNumber, fx)
|
|
}
|
|
if (mappedBatch[i].vatUnresolved) {
|
|
vatUnresolved++
|
|
console.warn(
|
|
`[migration] Sales invoice ${mappedBatch[i].dto.invoiceNumber}: no VAT in provider payload; `
|
|
+ 'imported with gross as subtotal and a null rate.'
|
|
)
|
|
}
|
|
if (mappedBatch[i].creditNoteUnlinked) {
|
|
creditNotesUnlinked++
|
|
}
|
|
imported++
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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, runState)
|
|
}
|
|
}
|
|
|
|
// ── Step 5: Supplier invoices (bulk) ──────────────────────────
|
|
if (options.importSupplierInvoices !== false) {
|
|
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörsfakturor...', progress: 80 })
|
|
try {
|
|
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.
|
|
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 = {}
|
|
let errorSample: string | null = null
|
|
|
|
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 && orgNumberToSupplierId.has(supplierOrgNumber)) {
|
|
supplierId = orgNumberToSupplierId.get(supplierOrgNumber)!
|
|
} else if (nameToSupplierId.has(inv.supplier.name)) {
|
|
supplierId = nameToSupplierId.get(inv.supplier.name)!
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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,
|
|
company_id: companyId,
|
|
name: inv.supplier.name,
|
|
supplier_type: supplierType,
|
|
default_payment_terms: 30,
|
|
default_currency: 'SEK',
|
|
country:
|
|
inv.supplier.postalAddress?.countryCode ||
|
|
(supplierType === 'swedish_business' ? 'SE' : null),
|
|
org_number: supplierOrgNumber,
|
|
}
|
|
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 outcome = await insertWithPerRowFallback(
|
|
supabase, 'suppliers', batch.map((s) => s.row), 'id, org_number, name'
|
|
)
|
|
|
|
if (outcome.failedCount > 0) {
|
|
console.error(
|
|
`[migration] Supplier invoice supplier stub insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
|
|
outcome.firstError
|
|
)
|
|
errorSample ??= outcome.firstError
|
|
}
|
|
|
|
for (let i = 0; i < batch.length; i++) {
|
|
const insertedRow = outcome.returned[i]
|
|
if (!insertedRow) {
|
|
for (const idx of batch[i].waitingInvoiceIndices) {
|
|
resolved[idx] = { ...resolved[idx], supplierId: '__FAILED__' }
|
|
}
|
|
continue
|
|
}
|
|
const newId = insertedRow.id as string
|
|
if (insertedRow.org_number) orgNumberToSupplierId.set(insertedRow.org_number as string, newId)
|
|
if (insertedRow.name) nameToSupplierId.set(insertedRow.name as string, newId)
|
|
for (const idx of batch[i].waitingInvoiceIndices) {
|
|
resolved[idx] = { ...resolved[idx], supplierId: newId }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// After stubs, do a final dedupe pass against existing supplier invoices
|
|
// using the now-resolved supplierId. The in-run key set catches the
|
|
// same (supplier, number) pair appearing twice in the fetched data,
|
|
// which the UNIQUE (company_id, supplier_id, supplier_invoice_number)
|
|
// index would otherwise reject mid-insert. NULL/empty numbers are
|
|
// exempt: the index treats NULLs as distinct.
|
|
const seenSuppInvKeys = new Set<string>()
|
|
const ready = resolved.filter((r) => {
|
|
if (r.supplierId === '__FAILED__' || !r.supplierId) {
|
|
// Failed stub insert = DB failure with errorSample set, so count
|
|
// it as failed; noMatch would hide the error in the result row.
|
|
if (r.supplierId === '__FAILED__') {
|
|
skipReasons.failed = (skipReasons.failed ?? 0) + 1
|
|
skipped++
|
|
}
|
|
return false
|
|
}
|
|
if (r.dto.invoiceNumber) {
|
|
const dupKey = `${r.supplierId}::${r.dto.invoiceNumber}`
|
|
if (existingSuppInvKeys.has(dupKey) || seenSuppInvKeys.has(dupKey)) {
|
|
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
|
|
skipped++
|
|
return false
|
|
}
|
|
seenSuppInvKeys.add(dupKey)
|
|
}
|
|
return true
|
|
})
|
|
|
|
// Resolve the SEK conversion for every foreign-currency invoice at the
|
|
// rate valid on its OWN issue date (see the sales-invoice step).
|
|
const fxRates = await buildFxRateIndex(
|
|
supabase,
|
|
ready.map((r) => ({ currencyCode: r.dto.currencyCode, issueDate: r.dto.issueDate }))
|
|
)
|
|
let fxUnresolved = 0
|
|
let vatUnresolved = 0
|
|
|
|
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
|
|
const mappedBatch = batch.map((r) => {
|
|
const { invoice, items, fxUnresolved: fx, vatUnresolved: vatMissing } = mapSupplierInvoice(
|
|
r.dto, userId, companyId, r.supplierId, fxRates
|
|
)
|
|
invoice.arrival_number = nextArrivalNumber++
|
|
return { invoice, items, fxUnresolved: fx, vatUnresolved: vatMissing, dto: r.dto }
|
|
})
|
|
|
|
const outcome = await insertWithPerRowFallback(
|
|
supabase, 'supplier_invoices', mappedBatch.map((m) => m.invoice), 'id'
|
|
)
|
|
|
|
if (outcome.failedCount > 0) {
|
|
console.error(
|
|
`[migration] Supplier invoice insert failed for ${outcome.failedCount} of ${batch.length} rows:`,
|
|
outcome.firstError
|
|
)
|
|
skipReasons.failed = (skipReasons.failed ?? 0) + outcome.failedCount
|
|
skipped += outcome.failedCount
|
|
errorSample ??= outcome.firstError
|
|
// A failed row leaves a hole in the arrival numbering. That is
|
|
// acceptable: ankomstnummer is an internal sequence, not a
|
|
// verifikationsnummer, and rewinding the counter after a PARTIAL
|
|
// success would hand out numbers that already landed.
|
|
}
|
|
|
|
const allItems: Record<string, unknown>[] = []
|
|
for (let i = 0; i < mappedBatch.length; i++) {
|
|
const insertedRow = outcome.returned[i]
|
|
if (!insertedRow) continue
|
|
const invoiceId = insertedRow.id
|
|
for (const item of mappedBatch[i].items) {
|
|
allItems.push({ ...item, supplier_invoice_id: invoiceId })
|
|
}
|
|
registrationLinkInputs.push({
|
|
invoiceId: String(invoiceId),
|
|
kind: 'supplier',
|
|
sourceVoucher: mappedBatch[i].dto.sourceVoucher ?? null,
|
|
refNotFetched: unhydratedIds.has(mappedBatch[i].dto.id),
|
|
invoiceDate: mappedBatch[i].dto.issueDate,
|
|
totalSek: mappedBatch[i].invoice.total_sek as number | null,
|
|
currencyCode: mappedBatch[i].dto.currencyCode || 'SEK',
|
|
invoiceNumber: mappedBatch[i].dto.invoiceNumber || null,
|
|
})
|
|
const fx = mappedBatch[i].fxUnresolved
|
|
if (fx) {
|
|
fxUnresolved++
|
|
logFxUnresolved('Supplier invoice', mappedBatch[i].dto.invoiceNumber, fx)
|
|
}
|
|
if (mappedBatch[i].vatUnresolved) {
|
|
vatUnresolved++
|
|
console.warn(
|
|
`[migration] Supplier invoice ${mappedBatch[i].dto.invoiceNumber}: no VAT in provider payload; `
|
|
+ 'imported with gross as subtotal.'
|
|
)
|
|
}
|
|
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, fxUnresolved, vatUnresolved, hydration, errorSample: errorSample ?? undefined }
|
|
} catch (err) {
|
|
console.error('Failed to import supplier invoices:', err)
|
|
recordStepError(results, 'supplierInvoices', err, runState)
|
|
}
|
|
}
|
|
|
|
// ── Step 5b: Link imported invoices to their registration vouchers ──
|
|
// The provider named the verifikat that booked each invoice ("A329");
|
|
// the SIE import preserved that source ref on the entry it created. Link
|
|
// the two where the match is exact and amount-corroborated, so migrated
|
|
// invoices stop reading as unbooked. Writes only the invoice-side FK from
|
|
// NULL; never touches journal entries. Best-effort like step 6: the
|
|
// invoices are already persisted, and /reconcile can re-run this.
|
|
if (registrationLinkInputs.length > 0) {
|
|
emitProgress(options, { status: 'importing', currentStep: 'Kopplar fakturor till verifikationer...', progress: 90 })
|
|
try {
|
|
const links = await linkMigratedRegistrationVouchers({
|
|
supabase,
|
|
companyId,
|
|
invoices: registrationLinkInputs,
|
|
})
|
|
results.registrationLinks = {
|
|
scanned: links.scanned,
|
|
linked: links.linked,
|
|
noRef: links.noRef,
|
|
refNotFetched: links.refNotFetched,
|
|
unresolved: links.unresolved,
|
|
ambiguous: links.ambiguous,
|
|
amountMismatch: links.amountMismatch,
|
|
alreadyLinked: links.alreadyLinked,
|
|
}
|
|
console.log(
|
|
`[migration] Registration vouchers: ${links.linked} linked, ${links.noRef} without ref, ${links.refNotFetched} ref not fetched, ${links.unresolved} unresolved, `
|
|
+ `${links.ambiguous} ambiguous, ${links.amountMismatch} amount mismatch, ${links.alreadyLinked} already linked (${links.scanned} scanned)`,
|
|
)
|
|
} catch (err) {
|
|
console.error('Failed to link registration vouchers:', err)
|
|
recordStepError(results, 'registrationLinks', err, runState)
|
|
}
|
|
}
|
|
|
|
// ── Step 5c: Asset register (Fortnox only) ─────────────────────
|
|
// Register metadata only: the bookkeeping values (12xx anskaffning and
|
|
// ackumulerade avskrivningar) already arrived via SIE, so this step never
|
|
// writes journal entries. A consent without the Fortnox assets scope
|
|
// reports scopesMissing instead of failing the migration.
|
|
if (options.importAssets !== false) {
|
|
emitProgress(options, { status: 'importing', currentStep: 'Importerar anläggningstillgångar...', progress: 92 })
|
|
try {
|
|
const assets = await importProviderAssets({ supabase, companyId, userId, consentId })
|
|
if (assets) results.assets = assets
|
|
} catch (err) {
|
|
if (err instanceof FortnoxAssetScopesRequiredError) {
|
|
log.warn('asset register skipped: consent lacks the Fortnox assets scope')
|
|
results.assets = { total: 0, imported: 0, skipped: 0, scopesMissing: true }
|
|
} else {
|
|
console.error('Failed to import assets:', err)
|
|
recordStepError(results, 'assets', err, runState)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Step 6: Reconcile supplier invoices to GL payment vouchers ────
|
|
// The GL (incl. the Dr 2440 / Cr 1930 bank-payment vouchers) is imported
|
|
// separately via SIE. Supplier invoices arrive (via ?filter=unpaid) as open
|
|
// payables with no link to those vouchers, so settled invoices would surface
|
|
// as overdue. Auto-link the unambiguous matches. Best-effort: a failure here
|
|
// must never fail the migration: the imported data is already persisted.
|
|
if (options.reconcileVouchers !== false) {
|
|
emitProgress(options, { status: 'importing', currentStep: 'Stämmer av betalningar mot verifikationer...', progress: 95 })
|
|
try {
|
|
const recon = await reconcileSupplierInvoiceVouchers({ supabase, companyId, userId })
|
|
results.reconciliation = {
|
|
scanned: recon.scanned,
|
|
autoLinked: recon.autoLinked,
|
|
ambiguous: recon.ambiguous,
|
|
unmatched: recon.unmatched,
|
|
}
|
|
console.log(
|
|
`[migration] Reconcile: ${recon.autoLinked} auto-linked, ${recon.ambiguous} need review, ${recon.unmatched} unmatched (${recon.scanned} scanned)`,
|
|
)
|
|
} catch (err) {
|
|
console.error('Failed to reconcile supplier invoice payments:', err)
|
|
recordStepError(results, 'reconciliation', err, runState)
|
|
}
|
|
}
|
|
|
|
emitProgress(options, { status: 'completed', progress: 100, results })
|
|
return results
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Migration failed'
|
|
emitProgress(options, { status: 'failed', progress: 0, error: message })
|
|
throw error
|
|
}
|
|
}
|