fix(providers): surface migration step errors; INK2 SRU 7104; non-modal invoice dialog (#1465)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export New mileage_trips table (RLS, booked-delete trigger per BFL retention), lib/mileage service reusing the payroll schablon rates, /api/mileage routes (trips CRUD, period booking to 7331, salary-run push, körjournal CSV), Körjournal dashboard page + nav, and three staged MCP tools (search-only catalog). Trips book as one verifikat per period via the engine; salary path inserts mileage_taxfree line items. mileage_trips classified in the full-archive export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(mileage): use shared roundOre helper per tightened ratchet baseline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): pending_operations op-type migration + Swedish review findings - New migration pair adds log_mileage_trip/book_mileage_period to the pending_operations operation_type CHECK (pg-real audit). - bookMileagePeriod refuses a period spanning several employees and names the employee in the verifikationstext when scoped (BFL motpart). - vehicle_registration required for förmånsbil trips (schema, service, MCP staging, UI surfaces the field). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): claim-first booking, CSV injection guard and driver column - bookMileagePeriod claims trips (draft to booked CAS) before creating the verifikat, so a concurrent second booking loses the race instead of double-booking; claim reverts if verifikat creation fails. - Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a Förare column naming the employee per trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening - Copying a round trip no longer re-doubles the stored distance. - pushMileageToSalaryRun claims trips before inserting line items (retry can no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races. - Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration 20260807113215): only claim/link/revert transitions and notes edits pass. - Cross-year periods rejected (schablon rates are per calendar year); payroll config year read from the date string, not TZ-dependent getFullYear(). - MCP staged bookings freeze the previewed trip set (trip_ids in params) and the commit fails on drift; validation errors return 400, not 500. - PATCH enforces the förmånsbil regnr rule on the effective row; export validates dates before they reach the Content-Disposition header; employee_id is verified company-scoped on trip creation; stale orphaned claims released. - UI: fetch flags reset in finally; ICU plural for draft summary; distance stored at the column's 1-decimal precision. - Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift, cross-year and update-trigger pg cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): revert-to-draft must clear salary_run_id at the trigger level New migration 20260807114924 replaces the booked-immutability function: a booked -> draft revert now rejects rows keeping salary_run_id, closing the DB-level double-pay path CodeRabbit flagged. pg test pins both directions; the CLAIM_LOST unit test now asserts the revert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): company-scope employee_id on PATCH (Superagent P2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mileage): valid v4 uuid in cross-company employee PATCH test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(providers): surface migration step errors instead of silent empty syncs A Visma company without the API module activated (403 ErrorCode 4002, "No access to module: api_standard") failed every provider call during migration, yet the wizard reported success with zero rows and mapped the 403 to "reconnect", which loops forever since OAuth succeeds against Visma's shared identity server. A real user burned time re-syncing and reconnecting, then filed the config issue as a bug. - New PROVIDER_API_MODULE_INACTIVE code; classifyProviderError reads the error body and recognizes the module error before the 403 to AUTH_EXPIRED mapping. Registry entry carries the remediation in Swedish and English (activate the API under Appar och tillagg, paid add-on on smaller plans, clear standardforetag, SIE fallback). - Orchestrator: connection-level failures (auth expired, license missing, module inactive) rethrow and abort the doomed run so /migrate answers with the typed code; other step failures stay non-fatal but land on results.stepErrors instead of only in server logs. - /preview fails fast on the two subscription codes so the user reads the remediation at connect time, before any sync. - Wizard: preview treats the new code like the Fortnox license case (CTA + SIE fallback); the result step renders error cards per cause and says "Migrering delvis genomford" instead of "Allt ar uppdaterat"; the completion toast is honest on partial failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ink2): SRU field 1.1 is 7104, not 7113 (Skatteverket rejects 7113) The INK2 huvudblankett code for 1.1 Overskott av naringsverksamhet is 7104 per Skatteverket's official 2025P4 faltkoder (INK2_SKV2002-33-01-24-04). We emitted 7113, which does not exist on INK2, so filoverforing rejected every profitable company's BLANKETTER.SRU with 'UPPGIFT 7113 ar inte ett giltigt postnamn' (reported by a user for FY 2024-10-07..2025-12-31). Underskott (7114) was already correct. The wrong code originated in the swedish-sru-filing skill reference; fixed there too and regenerated the atom seed. All other emitted INK2/INK2R/INK2S codes verified against the official 2025P4 lists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): keep the AI chat usable over the new-invoice dialog The new-invoice dialog was a modal Radix dialog: modal mode sets body pointer-events: none, aria-hidden on body siblings, and a focus trap, so the agent sheet (z-60, painted above the dialog) was visible but dead: clicks swallowed, input unfocusable, and all three dismiss paths preventDefaulted, leaving no way out except the header X. Now non-modal: page modality is restored by hand instead. A new DialogVeil primitive supplies the backdrop (Radix renders no overlay in non-modal mode) at z-40, under dialog content (z-50) and the agent sheet (z-60), and inert on #dash-shell blocks pointer, keyboard, and AT access to the page behind while the sheet (a body-level sibling) stays live. The lazy-load fallback dialog on /invoices gets the same treatment so a hung or 404'd chunk cannot dead-lock the route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c7dd78b0a3
commit
39f4ecdad4
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
|
||||
import { createMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
/**
|
||||
* Locks the /migrate route's mapping of a doomed provider run to a structured
|
||||
* error response. When the orchestrator rethrows a connection-level failure
|
||||
* (here: Visma's "No access to module: api_standard"), the route must answer
|
||||
* with PROVIDER_API_MODULE_INACTIVE and its remediation, not a generic
|
||||
* PROVIDER_MIGRATE_FAILED or a misleading success envelope.
|
||||
*/
|
||||
|
||||
vi.mock('../lib/migration-orchestrator', () => ({
|
||||
executeMigration: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/provider-client', () => ({
|
||||
createConsent: vi.fn(),
|
||||
getConsent: vi.fn(),
|
||||
listConsents: vi.fn(),
|
||||
generateOtc: vi.fn(),
|
||||
consumeOAuthState: vi.fn(),
|
||||
getAuthUrl: vi.fn(),
|
||||
exchangeAuthToken: vi.fn(),
|
||||
submitProviderToken: vi.fn(),
|
||||
acceptConsent: vi.fn().mockResolvedValue(undefined),
|
||||
deleteConsent: vi.fn(),
|
||||
resolveConsent: vi.fn(),
|
||||
fetchCompanyInfoDirect: vi.fn(),
|
||||
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
|
||||
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
|
||||
}))
|
||||
|
||||
import { arcimMigrationExtension } from '../index'
|
||||
import { executeMigration } from '../lib/migration-orchestrator'
|
||||
import { getConsent } from '../lib/provider-client'
|
||||
|
||||
const migrateRoute = (arcimMigrationExtension.apiRoutes ?? []).find(
|
||||
(r) => r.method === 'POST' && r.path === '/migrate',
|
||||
)!
|
||||
|
||||
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
|
||||
const handler = migrateRoute.handler as RouteHandler
|
||||
|
||||
function buildCtx(): ExtensionContext {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
// The SIE guard awaits `from('sie_imports').select(..,{count,head}).eq().eq()`.
|
||||
mockResult({ count: 1 })
|
||||
;(supabase as unknown as { auth: unknown }).auth = {
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
|
||||
}
|
||||
return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
|
||||
}
|
||||
|
||||
function migrateRequest() {
|
||||
return createMockRequest('http://localhost/api/extensions/ext/arcim-migration/migrate', {
|
||||
method: 'POST',
|
||||
body: { consentId: 'consent-1' },
|
||||
})
|
||||
}
|
||||
|
||||
function vismaModuleError(): Error {
|
||||
const e = new Error('Visma API error: 403 Forbidden') as Error & {
|
||||
statusCode: number
|
||||
body: string
|
||||
}
|
||||
e.statusCode = 403
|
||||
e.body =
|
||||
'{"ErrorCode":4002,"DeveloperErrorMessage":"ForbiddenRequestException - No access to module: api_standard","ErrorId":"x","Errors":[]}'
|
||||
return e
|
||||
}
|
||||
|
||||
describe('POST /migrate: provider error mapping', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('answers 403 PROVIDER_API_MODULE_INACTIVE when the orchestrator hits an inactive Visma API module', async () => {
|
||||
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
|
||||
;(executeMigration as Mock).mockRejectedValue(vismaModuleError())
|
||||
|
||||
const res = await handler(migrateRequest(), buildCtx())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(res)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('PROVIDER_API_MODULE_INACTIVE')
|
||||
expect(body.error.message).toContain('Appar och tillägg')
|
||||
})
|
||||
|
||||
it('still returns success with stepErrors passed through for non-fatal partial failures', async () => {
|
||||
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
|
||||
;(executeMigration as Mock).mockResolvedValue({
|
||||
customers: { total: 5, imported: 5, updated: 0, skipped: 0, skipReasons: {} },
|
||||
stepErrors: [
|
||||
{
|
||||
step: 'suppliers',
|
||||
code: 'PROVIDER_UPSTREAM_ERROR',
|
||||
message: 'Leverantören svarade med ett fel. Försök igen om en stund.',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const res = await handler(migrateRequest(), buildCtx())
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
results: { stepErrors?: { step: string; code: string | null }[] }
|
||||
}>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.results.stepErrors).toHaveLength(1)
|
||||
expect(body.results.stepErrors![0].step).toBe('suppliers')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Locks the orchestrator's error surfacing.
|
||||
*
|
||||
* Before this, every step's catch was log-and-continue, so a migration where
|
||||
* every provider call failed (e.g. Visma's "No access to module: api_standard"
|
||||
* when the customer's plan lacks the API module) reported success with zero
|
||||
* rows. The user saw "Allt är uppdaterat", retried, reconnected, and finally
|
||||
* filed the config issue as a bug.
|
||||
*
|
||||
* Contract:
|
||||
* - Connection-level failures (auth expired, license missing, API module
|
||||
* inactive) doom every remaining call: the orchestrator RETHROWS so the
|
||||
* route answers with the structured code and its remediation.
|
||||
* - Other failures stay non-fatal (one bad step must not discard the other
|
||||
* steps' persisted rows) but are recorded on results.stepErrors so the
|
||||
* result UI renders them instead of implying success.
|
||||
*/
|
||||
|
||||
vi.mock('@/lib/providers/resolve-consent', () => ({
|
||||
resolveConsent: vi.fn().mockResolvedValue({
|
||||
consent: { provider: 'visma' },
|
||||
accessToken: 'tok',
|
||||
providerCompanyId: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/providers/provider-data-fetcher', () => ({
|
||||
fetchCompanyInfoDirect: vi.fn(),
|
||||
fetchCustomersDirect: vi.fn(),
|
||||
fetchSuppliersDirect: vi.fn(),
|
||||
fetchSalesInvoicesDirect: vi.fn(),
|
||||
fetchSupplierInvoicesDirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invoices/bulk-reconcile-supplier-vouchers', () => ({
|
||||
reconcileSupplierInvoiceVouchers: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/fetch-all', () => ({
|
||||
fetchAllRows: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
import { executeMigration } from '../lib/migration-orchestrator'
|
||||
import {
|
||||
fetchCompanyInfoDirect,
|
||||
fetchCustomersDirect,
|
||||
} from '@/lib/providers/provider-data-fetcher'
|
||||
|
||||
const VISMA_MODULE_BODY =
|
||||
'{"ErrorCode":4002,"DeveloperErrorMessage":"ForbiddenRequestException - No access to module: api_standard","ErrorId":"x","Errors":[]}'
|
||||
|
||||
function vismaError(statusCode: number, body?: string): Error {
|
||||
const e = new Error(`Visma API error: ${statusCode}`) as Error & {
|
||||
statusCode: number
|
||||
body?: string
|
||||
}
|
||||
e.statusCode = statusCode
|
||||
e.body = body
|
||||
return e
|
||||
}
|
||||
|
||||
function baseOptions(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
consentId: 'consent-1',
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
supabase: {} as unknown as SupabaseClient,
|
||||
importCompanyInfo: false,
|
||||
importCustomers: false,
|
||||
importSuppliers: false,
|
||||
importSalesInvoices: false,
|
||||
importSupplierInvoices: false,
|
||||
reconcileVouchers: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('executeMigration: step error surfacing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('rethrows when a step fails with an inactive API module (Visma 4002): the run is doomed', async () => {
|
||||
;(fetchCustomersDirect as Mock).mockRejectedValue(vismaError(403, VISMA_MODULE_BODY))
|
||||
|
||||
await expect(
|
||||
executeMigration(baseOptions({ importCustomers: true })),
|
||||
).rejects.toMatchObject({ statusCode: 403 })
|
||||
})
|
||||
|
||||
it('rethrows a doomed company-info step too, instead of settling for imported:false', async () => {
|
||||
;(fetchCompanyInfoDirect as Mock).mockRejectedValue(vismaError(403, VISMA_MODULE_BODY))
|
||||
|
||||
await expect(
|
||||
executeMigration(baseOptions({ importCompanyInfo: true })),
|
||||
).rejects.toMatchObject({ statusCode: 403 })
|
||||
})
|
||||
|
||||
it('records a non-fatal classified failure on results.stepErrors with the registry Swedish message', async () => {
|
||||
;(fetchCustomersDirect as Mock).mockRejectedValue(vismaError(500))
|
||||
|
||||
const results = await executeMigration(baseOptions({ importCustomers: true }))
|
||||
|
||||
expect(results.stepErrors).toEqual([
|
||||
{
|
||||
step: 'customers',
|
||||
code: 'PROVIDER_UPSTREAM_ERROR',
|
||||
message: 'Leverantören svarade med ett fel. Försök igen om en stund.',
|
||||
},
|
||||
])
|
||||
// The step failed before producing a result: no customers section.
|
||||
expect(results.customers).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records an unclassified failure with a generic Swedish sentence carrying the raw reason', async () => {
|
||||
;(fetchCustomersDirect as Mock).mockRejectedValue(new Error('boom'))
|
||||
|
||||
const results = await executeMigration(baseOptions({ importCustomers: true }))
|
||||
|
||||
expect(results.stepErrors).toEqual([
|
||||
{ step: 'customers', code: null, message: 'Leverantören svarade med ett fel: boom' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no stepErrors when every enabled step succeeds', async () => {
|
||||
;(fetchCustomersDirect as Mock).mockResolvedValue([])
|
||||
|
||||
const results = await executeMigration(baseOptions({ importCustomers: true }))
|
||||
|
||||
expect(results.stepErrors).toBeUndefined()
|
||||
expect(results.customers).toEqual({
|
||||
total: 0,
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
skipReasons: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -640,6 +640,16 @@ export const arcimMigrationExtension: Extension = {
|
||||
const companyInfo = await fetchCompanyInfoDirect(provider, resolved.accessToken, resolved.providerCompanyId)
|
||||
mapped = companyInfo ? mapCompanyInfo(companyInfo) : null
|
||||
} catch (err) {
|
||||
// A missing integration license / inactive API module dooms every
|
||||
// later call in the wizard too: fail the preview with the typed
|
||||
// code (via the outer catch) so the user reads the remediation at
|
||||
// connect time, not after a silently empty migration. Other
|
||||
// failures stay soft: the preview is still useful without company
|
||||
// info (e.g. SIE-over-API can work with narrower scopes).
|
||||
const classified = classifyProviderError(err)
|
||||
if (classified === 'PROVIDER_API_MODULE_INACTIVE' || classified === 'PROVIDER_LICENSE_MISSING') {
|
||||
throw err
|
||||
}
|
||||
log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { MigrationProgress, MigrationResults, SkipReasons } from '../types'
|
||||
import type { MigrationProgress, MigrationResults, MigrationStepError, SkipReasons } from '../types'
|
||||
import type { ProviderName } from '@/lib/providers/types'
|
||||
import { classifyProviderError } from '@/lib/providers/with-provider-call'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
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'
|
||||
@@ -84,6 +86,42 @@ function chunk<T>(arr: T[], size: number): T[][] {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const FATAL_STEP_ERROR_CODES = new Set([
|
||||
'PROVIDER_AUTH_EXPIRED',
|
||||
'PROVIDER_LICENSE_MISSING',
|
||||
'PROVIDER_API_MODULE_INACTIVE',
|
||||
])
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): void {
|
||||
const code = classifyProviderError(err)
|
||||
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}`
|
||||
|
||||
results.stepErrors = results.stepErrors ?? []
|
||||
results.stepErrors.push({ step, code, message })
|
||||
}
|
||||
|
||||
function getOrgNumberFromParty(party: PartyDto): string | null {
|
||||
return (
|
||||
party.legalEntity?.companyId ||
|
||||
@@ -178,6 +216,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +380,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +480,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,6 +693,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, errorSample: errorSample ?? undefined }
|
||||
} catch (err) {
|
||||
console.error('Failed to import sales invoices:', err)
|
||||
recordStepError(results, 'salesInvoices', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,6 +920,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, errorSample: errorSample ?? undefined }
|
||||
} catch (err) {
|
||||
console.error('Failed to import supplier invoices:', err)
|
||||
recordStepError(results, 'supplierInvoices', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,6 +945,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to reconcile supplier invoice payments:', err)
|
||||
recordStepError(results, 'reconciliation', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,19 @@ export interface SkipReasons {
|
||||
noMatch?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A migration step that failed against the provider API, surfaced to the user
|
||||
* instead of being swallowed into a "successful" empty sync. `message` is the
|
||||
* user-facing Swedish text (mapped from the structured error registry when the
|
||||
* failure classifies, otherwise a generic sentence with the provider's reply).
|
||||
*/
|
||||
export interface MigrationStepError {
|
||||
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'reconciliation'
|
||||
/** Structured code when the failure classifies (e.g. PROVIDER_API_MODULE_INACTIVE), else null. */
|
||||
code: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreign-currency invoices that were imported but whose SEK value could not
|
||||
* be established (currency outside Riksbanken's series, or no observation for
|
||||
@@ -89,6 +102,12 @@ export interface MigrationResults {
|
||||
* candidate voucher.
|
||||
*/
|
||||
reconciliation?: { scanned: number; autoLinked: number; ambiguous: number; unmatched: number }
|
||||
/**
|
||||
* Steps that failed against the provider API. Present (non-empty) whenever a
|
||||
* step's fetch or import threw: the result step must render these instead of
|
||||
* implying the sync succeeded with zero rows.
|
||||
*/
|
||||
stepErrors?: MigrationStepError[]
|
||||
}
|
||||
|
||||
// ── Consent flow ────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user