fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura (#2113)

* fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura

Two independent defects in the provider migration, both customer-visible.

A per-resource 403 was classified as a dead grant. classifyProviderError mapped
any 401 or 403 to PROVIDER_AUTH_EXPIRED, which is fatal, so a Fortnox account
without leverantorsregister permission aborted the whole migration at the
suppliers step with "Anslutningen har gatt ut. Ateranslut" even though the same
token had just succeeded on the previous step. Reconnecting can never fix that,
and steps 4 and later never ran. The provider's own reason ("Saknar behorighet
for leverantorsregister.") never reached the user. A 403 is now non-fatal once
the same token has already succeeded in the run, the migration continues, and
the provider's reason is surfaced. A 401, or a 403 on the first call, keeps the
auth-expired path.

fetchCompanyInfoDirect swallowed every error and returned null, which made the
existing PROVIDER_API_MODULE_INACTIVE remediation unreachable: a Visma customer
whose api_standard module is off got a silent 200 with an empty company card
instead of the precise Swedish explanation that was already written.

Kreditfakturor were dropped entirely. entity-mapper wrote document_type
'credit_note', but invoices_document_type_check allows only invoice, proforma
and delivery_note, and credit notes are modelled by credited_invoice_id. Every
migrated kreditfaktura was rejected and counted as skipped. One customer
imported 255 sales invoices and 0 credit notes on 2026-08-31; AR and revenue
are overstated by the credited amounts, and kreditfakturor are
rakenskapsinformation. They now import as invoice rows with reversed amounts
and status 'credited', following the in-app credit convention. They import
unlinked: no provider DTO carries a reference to the invoice being credited, so
there is nothing to match on and guessing would corrupt the AR ledger. The
wizard says so instead of burying them in skipped.

Also makes the OAuth callback non-replayable from browser history (no-store
plus history replacement), which is what the "state rejected" events were: a
replay of a callback that had already succeeded seconds earlier. No
already-connected page, so consumed-vs-unknown state stays unobservable to an
unauthenticated caller. Expected PSD2 session expiry drops from error to warn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(arcim): entity line needs the failed flag

The unlinked-credit-note row omitted `failed`, which the entityLines element
type requires. Caught by the zero-extensions build, not by vitest: the unit
suite does not typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(arcim): write the missing-reference disclosure onto the credit note itself

Review finding (swedish-compliance-review-bot): ML 17 kap 22-23 § wants a
kreditfaktura to reference the invoice it credits, and BFL 5 kap 6-7 § wants a
verifikation to reference its underlag. No provider DTO carries that reference,
so the pairing cannot be resolved at import and guessing it would corrupt the
AR ledger. Reporting the count in the migration wizard is not enough: a result
screen is not rakenskapsinformation, and the gap has to be legible on the
record itself years later.

The disclosure now goes into invoices.notes and supplier_invoices.notes,
preserving whatever note the provider sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-01 14:57:48 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 1c04262e06
commit f1d76deaba
25 changed files with 1356 additions and 134 deletions
@@ -17,6 +17,11 @@ import type { SupabaseClient } from '@supabase/supabase-js'
* - 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.
* - A 403 on ONE register once the same token has already answered earlier in
* the run is not a connection-level failure at all: the Fortnox account
* that lacks leverantörsregister rights imported customers seconds before,
* so the run continues and the provider's own reason is what the user
* reads. Reconnecting could never have helped.
*/
vi.mock('@/lib/providers/resolve-consent', () => ({
@@ -51,11 +56,18 @@ import { executeMigration } from '../lib/migration-orchestrator'
import {
fetchCompanyInfoDirect,
fetchCustomersDirect,
fetchSuppliersDirect,
fetchSalesInvoicesHydrated,
} from '@/lib/providers/provider-data-fetcher'
import { FortnoxApiError } from '@/lib/providers/fortnox/client'
const VISMA_MODULE_BODY =
'{"ErrorCode":4002,"DeveloperErrorMessage":"ForbiddenRequestException - No access to module: api_standard","ErrorId":"x","Errors":[]}'
/** The live Fortnox answer for a supplier read the account may not make. */
const FORTNOX_SUPPLIER_BODY =
'{"ErrorInformation":{"Error":1,"Message":"Saknar beh\u00f6righet f\u00f6r leverant\u00f6rsregister.","Code":2003275}}'
function vismaError(statusCode: number, body?: string): Error {
const e = new Error(`Visma API error: ${statusCode}`) as Error & {
statusCode: number
@@ -129,6 +141,100 @@ describe('executeMigration: step error surfacing', () => {
])
})
it('keeps going when one register is closed on a token that already worked', async () => {
// Customers came back fine, so the grant is provably alive: the suppliers
// 403 is Fortnox refusing leverantörsregistret, not the connection dying.
// Aborting here is what left sales invoices, supplier invoices, vouchers
// and payment reconciliation unimported behind an "Återanslut" the user
// could follow forever.
;(fetchCustomersDirect as Mock).mockResolvedValue([])
;(fetchSuppliersDirect as Mock).mockRejectedValue(
new FortnoxApiError('Fortnox API error: 403', 403, FORTNOX_SUPPLIER_BODY),
)
;(fetchSalesInvoicesHydrated as Mock).mockResolvedValue({
invoices: [],
hydration: undefined,
unhydratedIds: new Set<string>(),
})
const results = await executeMigration(
baseOptions({ importCustomers: true, importSuppliers: true, importSalesInvoices: true }),
)
expect(results.stepErrors).toHaveLength(1)
expect(results.stepErrors![0].step).toBe('suppliers')
expect(results.stepErrors![0].code).toBe('PROVIDER_RESOURCE_FORBIDDEN')
// The provider's own sentence is the only part that names the register.
expect(results.stepErrors![0].message).toContain('Saknar behörighet för leverantörsregister.')
expect(results.stepErrors![0].message).not.toContain('Återanslut')
// The steps after the closed register still ran.
expect(fetchSalesInvoicesHydrated).toHaveBeenCalledTimes(1)
expect(results.salesInvoices).toBeDefined()
})
it('does not treat a step that never called the provider as proof of the grant', async () => {
// fetchCustomersDirect answers [] WITHOUT issuing any request when the
// provider needs a company id the consent has none of (Bokio, Björn
// Lundén) or does not expose the register (WINT suppliers). Reading that
// resolved promise as "the token works" would downgrade a genuine auth
// expiry on the next step to a per-register denial, and the run would
// finish "successfully" with every section empty.
;(fetchCustomersDirect as Mock).mockResolvedValue([])
;(fetchSuppliersDirect as Mock).mockRejectedValue(vismaError(403))
await expect(
executeMigration(baseOptions({ importCustomers: true, importSuppliers: true })),
).rejects.toMatchObject({ statusCode: 403 })
})
it('lets rows fetched earlier prove the grant for an opaque 403 later in the run', async () => {
// Rows can only come from a real answer on this token, so the Bokio 403
// that follows (empty body, nothing to read) is one closed register.
;(fetchCustomersDirect as Mock).mockResolvedValue([
{ id: 'cust-1', active: false, party: { name: 'Kund AB' } },
])
;(fetchSuppliersDirect as Mock).mockRejectedValue(vismaError(403))
const results = await executeMigration(
baseOptions({ importCustomers: true, importSuppliers: true }),
)
expect(results.customers).toMatchObject({ total: 1, imported: 0 })
expect(results.stepErrors).toHaveLength(1)
expect(results.stepErrors![0].step).toBe('suppliers')
expect(results.stepErrors![0].code).toBe('PROVIDER_RESOURCE_FORBIDDEN')
expect(results.stepErrors![0].message).not.toContain('Återanslut för att fortsätta')
})
it('still rethrows a 403 on the first provider call of the run: that one can be a dead grant', async () => {
// Bokio answers with an empty body, so nothing distinguishes a revoked
// grant from a closed register here. "Reconnect" stays the answer.
;(fetchCustomersDirect as Mock).mockRejectedValue(vismaError(403))
await expect(
executeMigration(baseOptions({ importCustomers: true })),
).rejects.toMatchObject({ statusCode: 403 })
})
it('does not let an opaque 403 on the company-info probe abort the run', async () => {
// Step 1 is the opening call, so nothing can have proven the grant yet.
// Company information is optional metadata (this fetch used to swallow
// every error and return null); a grant that really is dead says so on the
// next step, which is still fatal.
;(fetchCompanyInfoDirect as Mock).mockRejectedValue(vismaError(403))
;(fetchCustomersDirect as Mock).mockResolvedValue([])
const results = await executeMigration(
baseOptions({ importCompanyInfo: true, importCustomers: true }),
)
expect(results.companyInfo).toEqual({ imported: false })
expect(results.stepErrors).toHaveLength(1)
expect(results.stepErrors![0].step).toBe('companyInfo')
expect(results.stepErrors![0].code).toBe('PROVIDER_RESOURCE_FORBIDDEN')
expect(fetchCustomersDirect).toHaveBeenCalledTimes(1)
})
it('returns no stepErrors when every enabled step succeeds', async () => {
;(fetchCustomersDirect as Mock).mockResolvedValue([])
@@ -217,7 +217,9 @@ describe('GET /callback: full-page fallback when there is no opener', () => {
function fallbackNavigation(html: string): URL {
// Both arms are emitted; the opener arm postMessages instead of navigating.
expect(html).toContain('window.opener')
const match = html.match(/window\.location\.href = "([^"]+)"/)
// replace(), not href: the callback URL carries a spent one-time state and
// must not stay in session history. See the replay describe below.
const match = html.match(/window\.location\.replace\("([^"]+)"\)/)
expect(match, 'callback HTML has no no-opener navigation').not.toBeNull()
return new URL(match![1])
}
@@ -483,3 +485,61 @@ describe('GET /preview: cross-tenant consent status oracle', () => {
expect(body.error.details ?? {}).not.toHaveProperty('provider')
})
})
/**
* A callback URL is single-use: the state it carries is spent the moment
* consumeOAuthState returns. Prod caught the consequence of leaving it in
* session history: a callback that had already succeeded was delivered a
* second time 19 seconds later, and the user was told "Ingen giltig
* migrationssession hittades" about a connection that had just worked.
*
* The page therefore replaces its history entry instead of pushing one, and
* the response is no-store so no Back/reload can serve it from cache. The
* state check itself is deliberately untouched: the callback is
* unauthenticated, so it still answers consumed, expired, forged and unknown
* with the same sentence.
*/
describe('GET /callback: the spent callback URL cannot come back', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('sends no-store and replaces history on a successful callback', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
})
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
const html = await res.text()
expect(res.headers.get('Cache-Control')).toBe('no-store')
expect(html).toContain('window.location.replace(')
expect(html).not.toContain('window.location.href')
})
it('sends no-store and replaces history on a rejected callback too', async () => {
;(consumeOAuthState as Mock).mockResolvedValue(null)
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'spent-token' }),
)
const html = await res.text()
expect(res.headers.get('Cache-Control')).toBe('no-store')
expect(html).toContain('window.location.replace(')
expect(html).not.toContain('window.location.href')
// The anti-oracle property stands: a consumed state still reads exactly
// like a forged one.
expect(html).toContain(GENERIC_REJECTION)
})
})
@@ -0,0 +1,124 @@
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 what GET /preview does with a company-info failure.
*
* fetchCompanyInfoDirect used to swallow every provider error and return null,
* which made the handler's classify-and-rethrow unreachable code: a Visma
* company whose api_standard module is off got a 200 preview with
* companyInfo: null, connected happily, and only discovered the problem when
* the migration came back empty. The remediation text ("Appar och tillägg")
* existed the whole time and never reached anyone.
*
* The other half matters just as much: a transient provider failure must stay
* soft. The preview is still useful without the company card, and turning a
* hiccup into a hard error would block the connect step for no reason.
*/
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(),
deleteConsent: vi.fn(),
resolveConsent: vi.fn(),
fetchCompanyInfoDirect: vi.fn(),
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
// Visma serves no SIE over the API, but keep the fetcher stubbed so no test
// here can reach a provider over the network.
vi.mock('../lib/sie-fetcher', () => ({
providerSupportsSie: vi.fn().mockReturnValue(false),
fetchProviderSieFiles: vi.fn(),
getAllowedFiscalYears: vi.fn().mockReturnValue([]),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { arcimMigrationExtension } from '../index'
import { getConsent, resolveConsent, fetchCompanyInfoDirect } from '../lib/provider-client'
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
const previewHandler = (arcimMigrationExtension.apiRoutes ?? []).find(
(r) => r.method === 'GET' && r.path === '/preview',
)!.handler as RouteHandler
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 buildCtx(): ExtensionContext {
const { supabase, mockResult } = createMockSupabase()
// The handler counts completed SIE imports at the end of the happy path.
mockResult({ count: 0 })
;(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 previewRequest() {
return createMockRequest('http://localhost/api/extensions/ext/arcim-migration/preview', {
searchParams: { consentId: 'consent-1' },
})
}
describe('GET /preview: company-info failures', () => {
beforeEach(() => {
vi.clearAllMocks()
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
;(resolveConsent as Mock).mockResolvedValue({
consent: { provider: 'visma' },
accessToken: 'tok',
providerCompanyId: null,
})
})
it('answers 403 PROVIDER_API_MODULE_INACTIVE with the remediation instead of a silent empty preview', async () => {
;(fetchCompanyInfoDirect as Mock).mockRejectedValue(vismaError(403, VISMA_MODULE_BODY))
const res = await previewHandler(previewRequest(), 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('keeps a transient provider failure soft: the preview still answers 200', async () => {
;(fetchCompanyInfoDirect as Mock).mockRejectedValue(vismaError(503))
const res = await previewHandler(previewRequest(), buildCtx())
const { status, body } = await parseJsonResponse<{ companyInfo: unknown }>(res)
expect(status).toBe(200)
expect(body.companyInfo).toBeNull()
})
})
+70 -38
View File
@@ -39,6 +39,7 @@ import type { ProviderName } from '@/lib/providers/types'
import { FORTNOX_DOCUMENT_SCOPES_APPROVED } from '@/lib/providers/fortnox/oauth'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { classifyProviderError } from '@/lib/providers/with-provider-call'
import { getProviderResourceForbiddenMessage } from '@/lib/errors/get-error-message'
import { FortnoxApiError, fortnoxErrorMessage } from '@/lib/providers/fortnox/client'
import { createLogger } from '@/lib/logger'
@@ -144,6 +145,36 @@ async function buildArcimOAuthUrl(
return url
}
/**
* Answer a failed provider call with its classified code, falling back to the
* caller's own code when the failure is not provider-shaped.
*
* PROVIDER_RESOURCE_FORBIDDEN carries its message here instead of from the
* shared registry: the useful half is the provider's own sentence naming the
* register it refused ("Saknar behörighet för leverantörsregister."), which
* only the error itself holds. Without the override the code would fall
* through entryFor() to INTERNAL_ERROR and answer 500.
*/
function providerFailureResponse(error: unknown, fallbackCode: string): NextResponse {
const classified = classifyProviderError(error)
const ctx = {
details: {
reason: error instanceof Error ? error.message : 'unknown',
classified: classified ?? 'unclassified',
},
}
if (classified === 'PROVIDER_RESOURCE_FORBIDDEN') {
const reason = fortnoxErrorMessage(error)
return errorResponseFromCode(classified, moduleLog, {
...ctx,
status: 403,
messageSv: getProviderResourceForbiddenMessage(reason, 'sv'),
messageEn: getProviderResourceForbiddenMessage(reason, 'en'),
})
}
return errorResponseFromCode(classified ?? fallbackCode, moduleLog, ctx)
}
/**
* Map a failed /migrate run to its structured error response. Shared by the
* JSON path (returned as-is) and the NDJSON path (body re-sent as the
@@ -156,13 +187,7 @@ function migrateFailureResponse(error: unknown, consentId: string): NextResponse
details: { consentId },
})
}
const classified = classifyProviderError(error)
return errorResponseFromCode(classified ?? 'PROVIDER_MIGRATE_FAILED', moduleLog, {
details: {
reason: error instanceof Error ? error.message : 'unknown',
classified: classified ?? 'unclassified',
},
})
return providerFailureResponse(error, 'PROVIDER_MIGRATE_FAILED')
}
/**
@@ -562,31 +587,48 @@ export const arcimMigrationExtension: Extension = {
// describe as "nothing happens". Leaving the reason on screen keeps
// every error diagnosable; the wizard also shows it when the message
// does arrive.
//
// location.replace, not href: the callback URL carries a one-time
// state that is already spent, so a Back or a reload onto it can
// only fail. no-store keeps it out of the browser cache for the same
// reason.
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
} else {
window.location.href = ${jsLiteral(fallbackUrl.toString())};
window.location.replace(${jsLiteral(fallbackUrl.toString())});
}
</script><p>Anslutningen misslyckades: ${escapedReason}</p><p>Du kan stänga detta fönster.</p></body></html>`
return new Response(html, {
status: 200,
// charset is required: without it browsers default to Latin-1 and
// render the Swedish text as mojibake (the "rätt behörigheter" bug).
headers: { 'Content-Type': 'text/html; charset=utf-8' },
headers: {
// charset is required: without it browsers default to Latin-1 and
// render the Swedish text as mojibake (the "rätt behörigheter" bug).
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
})
}
// Provider returned an OAuth error (user cancelled, missing API
// subscription on the Fortnox side, invalid scope, etc.)
if (oauthError) {
log.error('OAuth callback returned provider error', {
// access_denied is the user clicking "avbryt" in the provider's
// consent screen: an expected outcome of an optional flow, so it is
// logged at warn and stays out of the error panel. Every other
// provider error is still an error.
const oauthErrorDetails = {
error: oauthError,
errorDescription: oauthErrorDescription,
hasCode: !!code,
hasState: !!stateRaw,
})
}
if (oauthError === 'access_denied') {
log.warn('OAuth callback cancelled by the user at the provider', oauthErrorDetails)
} else {
log.error('OAuth callback returned provider error', oauthErrorDetails)
}
let consentId: string | undefined
if (stateRaw) {
try {
@@ -644,20 +686,28 @@ export const arcimMigrationExtension: Extension = {
// Return an HTML page that notifies the opener tab and closes itself
const successUrl = `${appUrl}/import?migration=connected&consentId=${encodeURIComponent(consentId)}`
// location.replace + no-store: see respondWithError above. The state
// is spent the moment consumeOAuthState returns, and prod caught the
// consequence of leaving the URL in history: a second delivery 19
// seconds after a successful connect, answered with a red "ingen
// giltig migrationssession" about a connection that had just worked.
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-success', consentId: ${jsLiteral(consentId)} }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.href = ${jsLiteral(successUrl)};
window.location.replace(${jsLiteral(successUrl)});
}
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
return new Response(html, {
status: 200,
// charset is required: without it browsers default to Latin-1 and
// render the Swedish text as mojibake (the "rätt behörigheter" bug).
headers: { 'Content-Type': 'text/html; charset=utf-8' },
headers: {
// charset is required: without it browsers default to Latin-1 and
// render the Swedish text as mojibake (the "rätt behörigheter" bug).
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
})
} catch (error) {
log.error('OAuth callback exchange failed', error)
@@ -801,13 +851,7 @@ export const arcimMigrationExtension: Extension = {
}
// Classify HTTP failures into typed codes so the toast can suggest
// reconnect / retry instead of a generic "preview failed".
const classified = classifyProviderError(error)
return errorResponseFromCode(classified ?? 'PROVIDER_PREVIEW_FAILED', moduleLog, {
details: {
reason: error instanceof Error ? error.message : 'unknown',
classified: classified ?? 'unclassified',
},
})
return providerFailureResponse(error, 'PROVIDER_PREVIEW_FAILED')
}
},
},
@@ -1013,13 +1057,7 @@ export const arcimMigrationExtension: Extension = {
})
} catch (error) {
log.error('arcim sie-data fetch failed', error as Error)
const classified = classifyProviderError(error)
return errorResponseFromCode(classified ?? 'PROVIDER_SIE_FETCH_FAILED', moduleLog, {
details: {
reason: error instanceof Error ? error.message : 'unknown',
classified: classified ?? 'unclassified',
},
})
return providerFailureResponse(error, 'PROVIDER_SIE_FETCH_FAILED')
}
},
},
@@ -1128,13 +1166,7 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json(result)
} catch (error) {
log.error('arcim sie import failed', error as Error)
const classified = classifyProviderError(error)
return errorResponseFromCode(classified ?? 'SIE_IMPORT_UNEXPECTED', moduleLog, {
details: {
reason: error instanceof Error ? error.message : 'unknown',
classified: classified ?? 'unclassified',
},
})
return providerFailureResponse(error, 'SIE_IMPORT_UNEXPECTED')
}
},
},
@@ -0,0 +1,204 @@
import { describe, it, expect } from 'vitest'
import { mapSalesInvoice } from '../entity-mapper'
import type { InvoiceStatusCode, PartyDto, SalesInvoiceDto } from '@/lib/providers/dto'
/**
* Guards the kreditfaktura shape written by mapSalesInvoice.
*
* The mapper used to write `document_type: 'credit_note'`, which
* invoices_document_type_check refuses (it allows only 'invoice', 'proforma'
* and 'delivery_note'). Every migrated kreditfaktura was rejected with a 23514
* and counted as skipped, so the imported AR and revenue were overstated by
* the credited amounts while the credit notes themselves, which are
* räkenskapsinformation, never landed.
*
* Accounted models a credit note as an ordinary invoice row with reversed
* amounts (app/api/invoices/route.ts, app/api/v1/.../invoices/[id]/credit) and
* a `credited_invoice_id` pointing at the invoice it credits.
*/
const party: PartyDto = { name: 'Kund AB', identifications: [] }
/** allow-list of invoices_document_type_check */
const DOCUMENT_TYPES = ['invoice', 'proforma', 'delivery_note']
/** allow-list of invoices_status_check */
const STATUSES = ['draft', 'sent', 'paid', 'partially_paid', 'overdue', 'cancelled', 'credited']
function makeDto(over: {
status?: InvoiceStatusCode
invoiceTypeCode?: string
/** Sign of the amounts as the provider states them: Visma negates, the gateway does not. */
signOfAmounts?: 1 | -1
net?: number
vat?: number
quantity?: number
paid?: boolean
balance?: number
note?: string
} = {}): SalesInvoiceDto {
const s = over.signOfAmounts ?? 1
const net = (over.net ?? 1000) * s
const vat = (over.vat ?? 250) * s
const gross = net + vat
return {
id: 'inv-1',
invoiceNumber: 'KF-100',
issueDate: '2026-03-10',
dueDate: '2026-04-10',
invoiceTypeCode: over.invoiceTypeCode,
currencyCode: 'SEK',
status: over.status ?? 'credited',
supplier: party,
customer: party,
lines: [
{
id: '1',
description: 'Konsulttimmar',
quantity: (over.quantity ?? 2) * s,
unitCode: 'tim',
unitPrice: { value: (over.net ?? 1000) / (over.quantity ?? 2), currencyCode: 'SEK' },
lineExtensionAmount: { value: net, currencyCode: 'SEK' },
taxPercent: 25,
taxAmount: { value: vat, currencyCode: 'SEK' },
},
],
taxTotal: { taxAmount: { value: vat, currencyCode: 'SEK' } },
legalMonetaryTotal: {
lineExtensionAmount: { value: net, currencyCode: 'SEK' },
payableAmount: { value: gross, currencyCode: 'SEK' },
},
paymentStatus: {
paid: over.paid ?? false,
balance: { value: over.balance ?? gross, currencyCode: 'SEK' },
},
note: over.note,
}
}
function map(over: Parameters<typeof makeDto>[0] = {}) {
return mapSalesInvoice(makeDto(over), 'user-1', 'company-1', 'customer-1')
}
describe('mapSalesInvoice: kreditfaktura', () => {
it('writes a document_type the invoices CHECK constraint accepts', () => {
for (const invoiceTypeCode of ['381', '380', undefined]) {
const { invoice } = map({ invoiceTypeCode })
expect(invoice.document_type, `invoiceTypeCode=${invoiceTypeCode}`).toBe('invoice')
expect(DOCUMENT_TYPES).toContain(invoice.document_type as string)
}
})
it('reverses the header amounts, matching an in-app credit note', () => {
const { invoice } = map({ invoiceTypeCode: '381' })
expect(invoice.subtotal).toBe(-1000)
expect(invoice.vat_amount).toBe(-250)
expect(invoice.total).toBe(-1250)
// SEK invoice: sekFactor 1, so the SEK columns mirror the stated amounts.
expect(invoice.subtotal_sek).toBe(-1000)
expect(invoice.vat_amount_sek).toBe(-250)
expect(invoice.total_sek).toBe(-1250)
})
it('reverses quantity, line total and VAT per item, and keeps the unit price positive', () => {
const { items } = map({ invoiceTypeCode: '381' })
expect(items).toHaveLength(1)
expect(items[0].quantity).toBe(-2)
expect(items[0].line_total).toBe(-1000)
expect(items[0].vat_amount).toBe(-250)
expect(items[0].unit_price).toBe(500)
expect(items[0].vat_rate).toBe(25)
})
it('lands on the same row whether the provider states the credit as negative or positive', () => {
const stated = map({ invoiceTypeCode: '381', signOfAmounts: -1 })
const magnitude = map({ invoiceTypeCode: '381', signOfAmounts: 1 })
expect(stated.invoice).toEqual(magnitude.invoice)
expect(stated.items).toEqual(magnitude.items)
})
it('resolves the VAT rate on a credit note the provider states negatively', () => {
// resolveInvoiceVat divides VAT by subtotal, which classifies only when
// both are positive: a negatively stated credit note used to land at
// vat_rate null even though the payload said 25 %.
const { invoice, vatUnresolved } = map({ invoiceTypeCode: '381', signOfAmounts: -1 })
expect(invoice.vat_rate).toBe(25)
expect(invoice.vat_treatment).toBe('standard_25')
expect(vatUnresolved).toBe(false)
})
it('forces the terminal status whatever lifecycle status the provider sends', () => {
for (const status of ['draft', 'sent', 'booked', 'paid', 'overdue'] as InvoiceStatusCode[]) {
const { invoice } = map({ invoiceTypeCode: '381', status, paid: true, balance: 0 })
expect(invoice.status, `status=${status}`).toBe('credited')
expect(STATUSES).toContain(invoice.status as string)
}
})
it('collects nothing on a credit note', () => {
const { invoice } = map({ invoiceTypeCode: '381', paid: true, balance: 0 })
expect(invoice.paid_at).toBeNull()
expect(invoice.paid_amount).toBe(0)
expect(invoice.remaining_amount).toBe(0)
})
it('imports the credit note unlinked, and reports it as such', () => {
// No provider DTO carries a reference to the credited invoice, so there is
// nothing to match on: zero candidates, and the row is written without a
// credited_invoice_id rather than paired by guesswork. The flag is what
// the migration summary counts.
const { invoice, creditNoteUnlinked } = map({ invoiceTypeCode: '381' })
expect(creditNoteUnlinked).toBe(true)
expect(invoice).not.toHaveProperty('credited_invoice_id')
})
it('writes the missing-reference disclosure into notes, durably', () => {
// ML 17 kap 22-23 § wants a kreditfaktura to reference the invoice it
// credits, and BFL 5 kap 6-7 § wants a verifikation to reference its
// underlag. The wizard's count is an ephemeral result screen, so the gap
// has to be legible on the record itself years later.
const { invoice } = map({ invoiceTypeCode: '381' })
expect(invoice.notes).toContain('Referens till ursprungsfakturan')
})
it('preserves the provider note alongside the disclosure', () => {
const { invoice } = map({ invoiceTypeCode: '381', note: 'Kreditering enligt overenskommelse' })
expect(invoice.notes).toContain('Kreditering enligt overenskommelse')
expect(invoice.notes).toContain('Referens till ursprungsfakturan')
})
it('leaves an ordinary invoice note untouched', () => {
const { invoice } = map({ note: 'Tack for din bestallning' })
expect(invoice.notes).toBe('Tack for din bestallning')
})
it('rounds öre rather than carrying float drift', () => {
const { invoice, items } = map({ invoiceTypeCode: '381', net: 33.33, vat: 8.3325, quantity: 3 })
expect(invoice.subtotal).toBe(-33.33)
expect(invoice.vat_amount).toBe(-8.33)
expect(invoice.total).toBe(-41.66)
expect(items[0].line_total).toBe(-33.33)
expect(items[0].vat_amount).toBe(-8.33)
})
it('leaves an ordinary invoice untouched', () => {
const { invoice, items, creditNoteUnlinked } = map({ status: 'sent', invoiceTypeCode: '380' })
expect(invoice.status).toBe('sent')
expect(invoice.subtotal).toBe(1000)
expect(invoice.vat_amount).toBe(250)
expect(invoice.total).toBe(1250)
expect(invoice.remaining_amount).toBe(1250)
expect(items[0].quantity).toBe(2)
expect(items[0].line_total).toBe(1000)
expect(items[0].vat_amount).toBe(250)
expect(creditNoteUnlinked).toBe(false)
})
it('keeps a paid ordinary invoice settled', () => {
const { invoice } = map({ status: 'paid', paid: true, balance: 0 })
expect(invoice.status).toBe('paid')
expect(invoice.paid_amount).toBe(1250)
expect(invoice.remaining_amount).toBe(0)
expect(invoice.paid_at).toBe('2026-03-10')
})
})
@@ -12,6 +12,7 @@ import { normalizeVatRateToFraction } from '@/lib/vat/vat-rate-unit'
import { sumLineVat, lineVatFromPercent } from '@/lib/providers/amounts'
import type { Currency, CustomerType, ExchangeRate, SupplierType, VatTreatment } from '@/types'
import type {
AmountType,
CustomerDto,
SupplierDto,
SalesInvoiceDto,
@@ -543,6 +544,23 @@ export interface MappedInvoice {
* could not establish VAT says so instead of looking clean.
*/
vatUnresolved: boolean
/**
* True for an imported kreditfaktura that carries no pointer at the invoice
* it credits.
*
* `invoices` models that relation only through `credited_invoice_id`, and no
* provider DTO carries a reference to the credited invoice: SalesInvoiceDto
* and SupplierInvoiceDto (lib/providers/dto.ts) state the type through
* `invoiceTypeCode` 381 and nothing else. So every credit note the migration
* imports lands unlinked, and guessing the original from a number in a note
* or from the amount would put a wrong pair in the AR ledger. The row itself
* is complete räkenskapsinformation (reversed amounts, terminal status);
* only the pairing is missing. Meant to be counted into the migration
* summary the way `vatUnresolved` is, so the user is told instead of finding
* it out in the ledger; the orchestrator does not read it yet, and the copy
* that reports it waits in `ext_arcim_credit_notes_unlinked_detail`.
*/
creditNoteUnlinked: boolean
}
// ── Public mappers ──────────────────────────────────────────────────
@@ -608,6 +626,45 @@ export function mapSupplier(dto: SupplierDto, userId: string, companyId: string)
}
}
/** Reversed sign for a kreditfaktura amount, without producing -0. */
function negate(n: number): number {
return n === 0 ? 0 : -n
}
/**
* The same document stated in magnitudes.
*
* Providers disagree on the sign a kreditfaktura carries: Visma reports a
* credit invoice with a negative TotalAmount (lib/providers/visma/mapper.ts)
* while the arcim gateway states the magnitude beside invoiceTypeCode 381.
* Both have to land on the single convention Accounted stores, so the amounts
* are resolved from the magnitudes and the credit sign is applied once, at the
* end. It is also what lets resolveInvoiceVat classify the rate at all: it
* divides VAT by subtotal, which only yields a statutory rate when both are
* positive.
*/
function withAbsoluteAmounts(dto: SalesInvoiceDto): SalesInvoiceDto {
const abs = (amount: AmountType): AmountType => ({ ...amount, value: Math.abs(amount.value) })
return {
...dto,
lines: dto.lines.map((line) => ({
...line,
quantity: line.quantity != null ? Math.abs(line.quantity) : undefined,
unitPrice: line.unitPrice ? abs(line.unitPrice) : undefined,
lineExtensionAmount: abs(line.lineExtensionAmount),
taxAmount: line.taxAmount ? abs(line.taxAmount) : undefined,
})),
taxTotal: dto.taxTotal ? { ...dto.taxTotal, taxAmount: abs(dto.taxTotal.taxAmount) } : undefined,
legalMonetaryTotal: {
...dto.legalMonetaryTotal,
lineExtensionAmount: dto.legalMonetaryTotal.lineExtensionAmount
? abs(dto.legalMonetaryTotal.lineExtensionAmount)
: undefined,
payableAmount: abs(dto.legalMonetaryTotal.payableAmount),
},
}
}
export function mapSalesInvoice(
dto: SalesInvoiceDto,
userId: string,
@@ -615,10 +672,15 @@ export function mapSalesInvoice(
customerId: string,
fxRates?: FxRateIndex
): MappedInvoice {
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
const vat = resolveInvoiceVat(dto)
const subtotal = vat.subtotal
const vatAmount = vat.vatAmount
const isCreditNote = dto.invoiceTypeCode === '381'
const amounts = isCreditNote ? withAbsoluteAmounts(dto) : dto
const sign = (n: number): number => (isCreditNote ? negate(n) : n)
const total = sign(round2(amounts.legalMonetaryTotal.payableAmount.value))
const vat = resolveInvoiceVat(amounts)
const subtotal = sign(vat.subtotal)
const vatAmount = sign(vat.vatAmount)
// Map Arcim status to Accounted status
const statusMap: Record<string, string> = {
@@ -631,7 +693,30 @@ export function mapSalesInvoice(
credited: 'credited',
}
const isCreditNote = dto.invoiceTypeCode === '381'
// A kreditfaktura is never an open or a paid receivable, so it gets a
// terminal status regardless of the provider's lifecycle status:
// invoiceTypeCode is the only signal that the document IS a credit note, and
// the arcim gateway is not guaranteed to also send status='credited'. Same
// reasoning as mapSupplierInvoice.
const status = isCreditNote ? 'credited' : (statusMap[dto.status] || 'sent')
// Nothing is ever collected on a kreditfaktura: it reduces what the customer
// owes rather than settling anything. This also keeps the row clear of
// invoices_credit_note_not_paid, which forbids paid/partially_paid the moment
// the row points at the invoice it credits.
const settlement = isCreditNote
? { paidAt: null as string | null, paidAmount: 0, remainingAmount: 0 }
: {
paidAt: dto.paymentStatus.paid
? dto.paymentStatus.lastPaymentDate || dto.issueDate
: null,
paidAmount: dto.paymentStatus.paid
? total
: round2(total - dto.paymentStatus.balance.value),
remainingAmount: dto.paymentStatus.paid
? 0
: Math.max(0, round2(dto.paymentStatus.balance.value)),
}
// SEK value of a foreign invoice, at the rate valid on its own issue date.
const fx = resolveFx(dto.currencyCode, dto.issueDate, fxRates)
@@ -646,7 +731,7 @@ export function mapSalesInvoice(
invoice_number: dto.invoiceNumber || null,
invoice_date: dto.issueDate,
due_date: dto.dueDate || dto.issueDate,
status: statusMap[dto.status] || 'sent',
status,
currency: dto.currencyCode || 'SEK',
// null for a SEK invoice (no rate applies) and for a foreign invoice whose
// rate could not be established: that case is reported via fxUnresolved.
@@ -665,18 +750,52 @@ export function mapSalesInvoice(
vat_rate: vat.rate,
your_reference: null,
our_reference: null,
notes: dto.note || null,
document_type: isCreditNote ? 'credit_note' : 'invoice',
paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null,
paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value),
notes: isCreditNote ? creditNoteUnlinkedNote(dto.note) : (dto.note || null),
// Always 'invoice'. invoices_document_type_check allows only
// ('invoice', 'proforma', 'delivery_note'), and Accounted models a
// kreditfaktura as an invoice row with reversed amounts plus
// credited_invoice_id, not as a document type of its own (see
// app/api/invoices/route.ts and .../invoices/[id]/credit/route.ts).
// Writing 'credit_note' here made Postgres reject every migrated
// kreditfaktura with a 23514, and the run counted each one as skipped.
document_type: 'invoice',
paid_at: settlement.paidAt,
paid_amount: settlement.paidAmount,
// remaining_amount is NOT NULL DEFAULT 0, so omitting it makes every
// migrated open invoice look fully settled in AR aging.
remaining_amount: dto.paymentStatus.paid ? 0 : Math.max(0, round2(dto.paymentStatus.balance.value)),
remaining_amount: settlement.remainingAmount,
}
const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx, vat.rate))
const items = amounts.lines.map((line, idx) => mapSalesInvoiceLine(line, idx, vat.rate, isCreditNote))
return { invoice, items, fxUnresolved: fx.unresolved, vatUnresolved: vat.unresolved }
return {
invoice,
items,
fxUnresolved: fx.unresolved,
vatUnresolved: vat.unresolved,
creditNoteUnlinked: isCreditNote,
}
}
/**
* Durable note for a migrated kreditfaktura that carries no pointer at the
* invoice it credits.
*
* ML 17 kap 22-23 § requires a kreditfaktura to reference the original
* invoice, and BFL 5 kap 6-7 § requires a verifikation to reference its
* underlag. No provider DTO carries that reference (lib/providers/dto.ts), so
* the pairing cannot be resolved at import time and guessing it would corrupt
* the AR ledger. The wizard reports the count, but a wizard result screen is
* not rakenskapsinformation: the gap has to be legible on the record itself,
* years later, to whoever opens the invoice. So it is written into `notes`,
* preserving whatever note the provider sent.
*/
function creditNoteUnlinkedNote(providerNote: string | null | undefined): string {
const disclosure =
'Kreditfaktura importerad vid systembyte. Referens till ursprungsfakturan '
+ 'saknas: kallsystemet skickade ingen sadan referens vid migreringen.'
const existing = (providerNote || '').trim()
return existing ? `${existing}\n\n${disclosure}` : disclosure
}
/**
@@ -695,22 +814,27 @@ function mapSalesInvoiceLine(
line: SalesInvoiceLineDto,
index: number,
invoiceRate: number | null,
isCreditNote: boolean,
): Record<string, unknown> {
const lineTotal = round2(line.lineExtensionAmount.value)
const rate = line.taxPercent != null ? snapToSwedishRate(line.taxPercent) : invoiceRate
const vatAmount = line.taxAmount?.value ?? lineVatFromPercent(lineTotal, rate ?? undefined)
// A kreditfaktura reverses quantity, line total and VAT and keeps the unit
// price and the rate positive: exactly what buildCreditNoteItem writes for a
// credit note issued in-app (lib/invoices/build-credit-note-item.ts).
const sign = (n: number): number => (isCreditNote ? negate(n) : n)
return {
sort_order: index + 1,
description: line.description || line.itemName || '',
quantity: line.quantity || 1,
quantity: sign(line.quantity || 1),
unit: line.unitCode || 'st',
unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
line_total: lineTotal,
line_total: sign(lineTotal),
// 0 rather than the old hardcoded 25 when nothing established a rate: a
// 0 % line beside 0 kr of VAT is at least internally consistent.
vat_rate: rate ?? 0,
vat_amount: round2(vatAmount ?? 0),
vat_amount: sign(round2(vatAmount ?? 0)),
}
}
@@ -808,12 +932,21 @@ export function mapSupplierInvoice(
paid_amount: resolvedStatus === 'paid' ? total : Math.max(0, paidAmount),
remaining_amount: resolvedStatus === 'paid' ? 0 : Math.max(0, balance),
is_credit_note: isCreditNote,
notes: dto.note || null,
notes: isCreditNote ? creditNoteUnlinkedNote(dto.note) : (dto.note || null),
}
const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx, vat.rate))
return { invoice, items, fxUnresolved: fx.unresolved, vatUnresolved: vat.unresolved }
return {
invoice,
items,
fxUnresolved: fx.unresolved,
vatUnresolved: vat.unresolved,
// supplier_invoices carries is_credit_note, so the row still reads as a
// kreditfaktura on its own; what is missing is the same pointer at the
// original that the sales side lacks.
creditNoteUnlinked: isCreditNote,
}
}
/**
@@ -22,7 +22,9 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import type { MigrationProgress, MigrationResults, MigrationStepError, SkipReasons } from '../types'
import type { ProviderName } from '@/lib/providers/types'
import { classifyProviderError } from '@/lib/providers/with-provider-call'
import { fortnoxErrorMessage } from '@/lib/providers/fortnox/client'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { getProviderResourceForbiddenMessage } from '@/lib/errors/get-error-message'
import type { CustomerDto, SupplierDto, SalesInvoiceDto, SupplierInvoiceDto, PartyDto } from '@/lib/providers/dto'
import { resolveConsent } from '@/lib/providers/resolve-consent'
import { normalizeVatNumber, isValidSwedishVatNumber } from '@/lib/vat/vat-number'
@@ -101,6 +103,10 @@ function chunk<T>(arr: T[], size: number): T[][] {
* "successful" migration with zero rows (the failure mode that sent a real
* config issue to the bug tracker). Rethrow so /migrate answers with the
* structured code and the wizard shows the actual remediation.
*
* PROVIDER_RESOURCE_FORBIDDEN is deliberately NOT here: it means the grant
* answered other calls in this same run and only one register is closed, so
* the remaining steps have every chance of succeeding.
*/
const FATAL_STEP_ERROR_CODES = new Set([
'PROVIDER_AUTH_EXPIRED',
@@ -108,6 +114,31 @@ const FATAL_STEP_ERROR_CODES = new Set([
'PROVIDER_API_MODULE_INACTIVE',
])
/**
* What this run has learned about the grant, threaded through the steps so a
* later failure can be read in context.
*/
interface ProviderRunState {
/**
* True once any provider fetch in this run has returned data: the access
* token provably works, so a 403 after that point is the provider closing
* one register, not the grant dying.
*
* Set it from rows actually returned, never from a fetch that merely
* resolved. fetchCustomersDirect and friends answer [] without issuing any
* request when the provider needs a company id this consent has none of
* (Bokio, Björn Lundén) or does not expose the register at all (WINT
* suppliers), and a step that never spoke to the provider proves nothing
* about the grant. Reading it as proof would downgrade a genuine auth
* expiry on the NEXT step to a non-fatal per-register denial, and the run
* would report success with empty sections: exactly what
* FATAL_STEP_ERROR_CODES exists to prevent. An empty but real answer only
* costs the better message on a later 403, so under-claiming is the safe
* direction.
*/
grantProven: boolean
}
/**
* Record a failed step on the results so the UI can render it. Non-fatal
* failures keep the log-and-continue behavior (one bad step must not discard
@@ -118,13 +149,29 @@ function recordStepError(
results: MigrationResults,
step: MigrationStepError['step'],
err: unknown,
runState: ProviderRunState,
): void {
const code = classifyProviderError(err)
// Company information is step 1, so grantProven is false there by
// definition. Its 403 is still no proof of a dead grant: the fetch used to
// swallow every error and return null, and a grant that really is dead says
// so on the next step, which stays fatal. Letting the opening call abort the
// run would turn a partial import into no import at all.
const grantProven = runState.grantProven || step === 'companyInfo'
const code = classifyProviderError(err, { grantProven })
if (code && FATAL_STEP_ERROR_CODES.has(code)) throw err
const rawMessage = err instanceof Error ? err.message : String(err)
const entry = code ? getErrorEntry(code) : undefined
const message = entry?.message_sv ?? `Leverantören svarade med ett fel: ${rawMessage}`
let message: string
if (code === 'PROVIDER_RESOURCE_FORBIDDEN') {
// The registry cannot hold this copy: the useful half is the provider's
// own sentence naming the register, which only the error carries. Fortnox
// is the one provider that sends it (fortnoxErrorMessage caps it at 300
// chars); the others send an opaque body and get the base message alone.
message = getProviderResourceForbiddenMessage(fortnoxErrorMessage(err))
} else {
message = entry?.message_sv ?? `Leverantören svarade med ett fel: ${rawMessage}`
}
results.stepErrors = results.stepErrors ?? []
results.stepErrors.push({ step, code, message })
@@ -165,6 +212,9 @@ function logFxUnresolved(kind: string, invoiceNumber: string, fx: FxUnresolved):
export async function executeMigration(options: MigrationOptions): Promise<MigrationResults> {
const { consentId, companyId, userId, supabase } = options
const results: MigrationResults = {}
// What this run has proven about the grant, read by recordStepError: a 403
// once a call has already succeeded is one closed register, not a dead token.
const runState: ProviderRunState = { grantProven: false }
// Every invoice this run inserted, with the booking voucher the provider
// named for it. Linked to the SIE-imported registration verifikat after both
// invoice steps (see the registration-link step below).
@@ -183,6 +233,10 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
try {
const companyInfo = await fetchCompanyInfoDirect(provider, accessToken, providerCompanyId)
if (companyInfo) {
// A DTO means the provider answered on this token. A null does not:
// it also means "no company-information resource for this provider",
// which is decided before any request goes out.
runState.grantProven = true
const mapped = mapCompanyInfo(companyInfo)
const { data: existing } = await supabase
.from('company_settings')
@@ -228,7 +282,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
} catch (err) {
console.error('Failed to import company info:', err)
results.companyInfo = { imported: false }
recordStepError(results, 'companyInfo', err)
recordStepError(results, 'companyInfo', err, runState)
}
}
@@ -244,6 +298,8 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
emitProgress(options, { status: 'importing', currentStep: 'Importerar kunder...', progress: 20 })
try {
const customers = await fetchCustomersDirect(provider, accessToken, providerCompanyId)
// Rows, not a resolved promise: see ProviderRunState.grantProven.
if (customers.length > 0) runState.grantProven = true
// One bulk read instead of N `.eq('org_number', ...)` lookups.
type ExistingCustomer = ExistingCustomerMetadata & {
@@ -392,7 +448,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
results.customers = { total: customers.length, imported, updated, skipped, skipReasons, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import customers:', err)
recordStepError(results, 'customers', err)
recordStepError(results, 'customers', err, runState)
}
}
@@ -405,6 +461,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörer...', progress: 40 })
try {
const suppliers = await fetchSuppliersDirect(provider, accessToken, providerCompanyId)
if (suppliers.length > 0) runState.grantProven = true
const existingSuppliers = await fetchAllRows<{ id: string; org_number: string | null; name: string | null }>(
({ from, to }) =>
@@ -492,7 +549,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
results.suppliers = { total: suppliers.length, imported, skipped, skipReasons, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import suppliers:', err)
recordStepError(results, 'suppliers', err)
recordStepError(results, 'suppliers', err, runState)
}
}
@@ -505,6 +562,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { invoices, hydration, unhydratedIds } = await fetchSalesInvoicesHydrated(
provider, accessToken, providerCompanyId,
)
if (invoices.length > 0) runState.grantProven = true
console.log(`[migration] Sales invoices: ${invoices.length} total`)
// Bulk-load existing invoice numbers once.
@@ -659,6 +717,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
)
let fxUnresolved = 0
let vatUnresolved = 0
let creditNotesUnlinked = 0
// Phase C: chunk-insert invoices + their line items.
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
@@ -711,6 +770,9 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
+ 'imported with gross as subtotal and a null rate.'
)
}
if (mappedBatch[i].creditNoteUnlinked) {
creditNotesUnlinked++
}
imported++
}
@@ -724,10 +786,10 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, vatUnresolved, hydration, errorSample: errorSample ?? undefined }
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, vatUnresolved, creditNotesUnlinked, hydration, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import sales invoices:', err)
recordStepError(results, 'salesInvoices', err)
recordStepError(results, 'salesInvoices', err, runState)
}
}
@@ -738,6 +800,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { invoices, hydration, unhydratedIds } = await fetchSupplierInvoicesHydrated(
provider, accessToken, providerCompanyId,
)
if (invoices.length > 0) runState.grantProven = true
console.log(`[migration] Supplier invoices: ${invoices.length} total`)
// Load existing (supplier_invoice_number, supplier_id) pairs once.
@@ -974,7 +1037,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, vatUnresolved, hydration, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import supplier invoices:', err)
recordStepError(results, 'supplierInvoices', err)
recordStepError(results, 'supplierInvoices', err, runState)
}
}
@@ -1009,7 +1072,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
)
} catch (err) {
console.error('Failed to link registration vouchers:', err)
recordStepError(results, 'registrationLinks', err)
recordStepError(results, 'registrationLinks', err, runState)
}
}
@@ -1029,7 +1092,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
results.assets = { total: 0, imported: 0, skipped: 0, scopesMissing: true }
} else {
console.error('Failed to import assets:', err)
recordStepError(results, 'assets', err)
recordStepError(results, 'assets', err, runState)
}
}
}
@@ -1055,7 +1118,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
)
} catch (err) {
console.error('Failed to reconcile supplier invoice payments:', err)
recordStepError(results, 'reconciliation', err)
recordStepError(results, 'reconciliation', err, runState)
}
}
@@ -116,6 +116,13 @@ export interface InvoiceStepResult {
skipReasons?: SkipReasons
fxUnresolved?: number
vatUnresolved?: number
/**
* Credit notes imported without a credited_invoice_id. No provider DTO
* carries a reference to the invoice being credited, so the link cannot be
* resolved at import time; the amounts are reversed and the record is
* complete, but the pairing is missing and the user is told so.
*/
creditNotesUnlinked?: number
hydration?: HydrationReport
errorSample?: string
}
+4 -1
View File
@@ -1705,7 +1705,10 @@ export const enableBankingExtension: Extension = {
try {
await deleteSession(connection.session_id)
} catch (error) {
log.error('[enable-banking] Failed to revoke PSD2 session (may be expired)', {
// The revoke is best-effort: an expired or already-closed session
// is the normal case here, and the disconnect continues either
// way, so this is a warning and not an error.
log.warn('[enable-banking] Failed to revoke PSD2 session (may be expired)', {
message: error instanceof Error ? error.message : String(error),
sessionId: connection.session_id,
connectionId: connection_id,
@@ -18,6 +18,7 @@ import {
getAllTransactions,
getAllTransactionsWithRaw,
convertTransaction,
deleteSession,
probeSessionHealth,
startAuthorization,
createSession,
@@ -670,3 +671,73 @@ describe('connector mode', () => {
expect(headers['Authorization']).toBe('Bearer test-jwt-token')
})
})
/**
* Log levels for the two conditions that are expected rather than broken.
*
* A PSD2 consent that ran out and a session Enable Banking has already dropped
* are both handled: the sync flips the connection to 'expired' and asks for a
* re-authorization, and the disconnect carries on regardless. Logging them at
* error filled the production error panel with events nobody could act on and
* buried the genuine ASPSP failures next to them. The thrown errors are
* unchanged: only the level moves.
*/
describe('expected-condition log levels', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>
let errorSpy: ReturnType<typeof vi.spyOn>
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
fetchSpy = vi.spyOn(globalThis, 'fetch')
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
fetchSpy.mockRestore()
errorSpy.mockRestore()
warnSpy.mockRestore()
})
it('logs an expired bank session at warn, and still throws SessionExpiredError', async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ code: 'EXPIRED_SESSION' }), { status: 401 })
)
await expect(
getAllTransactionsWithRaw('acc-1', '2024-01-01', '2024-12-31')
).rejects.toThrow('Bank session expired')
expect(warnSpy).toHaveBeenCalled()
expect(errorSpy).not.toHaveBeenCalled()
})
it('still logs a genuine ASPSP failure at error', async () => {
// 500 is retried before it gives up; every attempt is the same failure.
fetchSpy.mockResolvedValue(new Response('{"message":"internal error"}', { status: 500 }))
await expect(
getAllTransactionsWithRaw('acc-1', '2024-01-01', '2024-12-31')
).rejects.toThrow('Failed to get transactions')
expect(errorSpy).toHaveBeenCalled()
})
it('logs a session that is already gone at Enable Banking at warn', async () => {
fetchSpy.mockResolvedValue(new Response('', { status: 404 }))
await expect(deleteSession('session-1')).rejects.toThrow('Failed to revoke session')
expect(warnSpy).toHaveBeenCalled()
expect(errorSpy).not.toHaveBeenCalled()
})
it('still logs an unexpected revoke failure at error', async () => {
fetchSpy.mockResolvedValue(new Response('{"message":"boom"}', { status: 500 }))
await expect(deleteSession('session-1')).rejects.toThrow('Failed to revoke session')
expect(errorSpy).toHaveBeenCalled()
})
})
@@ -697,12 +697,22 @@ export async function deleteSession(sessionId: string): Promise<void> {
if (!response.ok) {
const body = await response.text()
console.error('[enable-banking] deleteSession failed', {
// A session Enable Banking has already dropped (404, or a 401/403 naming
// an expired/closed session) is the expected answer when we revoke a
// connection whose PSD2 consent ran out: every caller catches and carries
// on, so it does not belong in the error panel.
const alreadyGone = response.status === 404 || isSessionExpiredResponse(response.status, body)
const logLine = {
status: response.status,
statusText: response.statusText,
body,
sessionId,
})
}
if (alreadyGone) {
console.warn('[enable-banking] deleteSession: session already gone at Enable Banking', logLine)
} else {
console.error('[enable-banking] deleteSession failed', logLine)
}
throw new Error(`Failed to revoke session (${response.status}): ${body}`)
}
}
@@ -1035,7 +1045,12 @@ export async function getAllTransactionsWithRaw(
activeDateFrom = recovery.dateFrom
continue
}
console.error('[enable-banking] getAllTransactionsWithRaw failed', {
// An expired PSD2 session is an expected end of life for a consent, not
// a failure: SessionExpiredError below flips the connection to 'expired'
// and asks the user to re-authorize. Log it at warn so only the genuine
// ASPSP/upstream failures reach the error panel.
const sessionExpired = isSessionExpiredResponse(response.status, body)
const logLine = {
status: response.status,
statusText: response.statusText,
body,
@@ -1045,10 +1060,12 @@ export async function getAllTransactionsWithRaw(
strategy: activeStrategy,
page,
hasContinuationKey: !!continuationKey,
})
if (isSessionExpiredResponse(response.status, body)) {
}
if (sessionExpired) {
console.warn('[enable-banking] getAllTransactionsWithRaw: bank session expired', logLine)
throw new SessionExpiredError(response.status, body)
}
console.error('[enable-banking] getAllTransactionsWithRaw failed', logLine)
throw new Error(`Failed to get transactions (${response.status}): ${body}`)
}