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:
@@ -1427,6 +1427,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-01] multi_user skeptic fixes: Stripe cancel EXPIRES the multi_user stripe grant instead of deleting it (grace anchor; other grants still deleted per freeze-and-retain); app-side state checks go RPC-first via SECURITY DEFINER company_multi_user_state (capability_grants RLS hides team rows from non-team users, byrå clients would misread as frozen); byra-kind teams get a standing team-scoped multi_user grant via backfill + teams trigger (WL-10 assumption made real; partner billing is out-of-band); PGRST202 on resolution fails OPEN (pre-migration DB has no multi_user rows: gated fallback would freeze all non-owners); /api/v1 got the same dormancy gate as MCP. RLS-level enforcement and the mid-session API fallback write-back window stay v2 follow-ups (documented, same class as pre-existing stale-preference fallback).
|
||||
[2026-09-01] Declined CodeRabbit's UpgradeNote suggestion (PR #1758 follow-up) to append the self-host connector sentence to children instead of replacing them: every caller's children is hosted subscription copy ("... kräver ett abonnemang"), so appending would show subscription wording on a self-host, the exact thing the branch exists to avoid; the "CSV/SIE import stays free" text it cited is a code comment in BankSyncNowButton, not children. Replace-on-self-host stays; a dedicated selfHosted children prop can come when a caller actually needs per-panel reassurance there.
|
||||
[2026-09-01] getConnectorConfig() rebuilds baseUrl as origin + path (userinfo/query/fragment stripped, warn-logged without the raw value): /api/connector/status echoes baseUrl to the operator and the sync/proxy URLs get paths appended, so nothing secret-shaped pasted into GNUBOK_CONNECT_URL may survive; the stripped parts were never meaningful in a base URL. The status route is also Cache-Control: no-store (key prefix + wiring layout out of shared browser caches).
|
||||
[2026-09-01] Provider-migration kreditfakturor import as ordinary invoice rows with reversed amounts and status 'credited', always unlinked. No provider DTO carries a reference to the credited invoice, so exact-match linking has no input to match on; guessing would corrupt the AR ledger. The wizard now says so rather than burying them in `skipped`.
|
||||
[2026-09-01] OAuth callback replay is fixed with no-store plus history replacement only, with no "Anslutningen ar redan klar" page. Keeps the anti-oracle property that consumed-vs-unknown state stays unobservable to an unauthenticated caller.
|
||||
[2026-09-01] PostgREST embeds between a table pair with more than one foreign key must name the relationship. Enforced by scripts/checks/ambiguous-embed.mjs in the ratchet guard, because neither test layer can see this class: a mocked Supabase client never resolves a relationship, and pg-real bypasses PostgREST entirely. Third appearance, second shipped instance.
|
||||
[2026-09-01] WORM integrity check moved to a new document_integrity_checks table instead of narrowing enforce_period_lock_documents(). CLAUDE.md declares migration 017's enforcement triggers legally required and never-touch, and the nightly stamp does not need to write document_attachments at all. Trades one extra table for leaving the BFL enforcement surface untouched.
|
||||
[2026-09-01] processing_history PII: the sender address and mail subject are dropped outright from RateLimitedDropped and AttachmentsTruncated rather than hashed, and the strip ships in the same commit as, and ahead of, the migration registering those event types. Registering first would start persisting PII into an append-only table the archive's erasure path excludes.
|
||||
|
||||
@@ -74,7 +74,10 @@ vi.mock('@/extensions/general/enable-banking/lib/api-client', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
import { REAUTH_REQUIRED_MESSAGE } from '@/extensions/general/enable-banking/lib/api-client'
|
||||
import {
|
||||
REAUTH_REQUIRED_MESSAGE,
|
||||
SessionExpiredError,
|
||||
} from '@/extensions/general/enable-banking/lib/api-client'
|
||||
import { GET } from '../route'
|
||||
|
||||
function makeClient(state: ClientState) {
|
||||
@@ -402,3 +405,46 @@ describe('GET /api/extensions/enable-banking/sync/cron: session health probe', (
|
||||
await expect(response.json()).resolves.toMatchObject({ processed: 0, probedDead: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The cron used to log every per-connection failure at error level before it
|
||||
* decided what the failure was, so an expired PSD2 consent (the normal end of
|
||||
* a bank grant, answered with a reconnect prompt) filled the error panel the
|
||||
* same way a broken sync does. The non-cron path was fixed first; this locks
|
||||
* the cron half.
|
||||
*/
|
||||
describe('GET /api/extensions/enable-banking/sync/cron: failure log level', () => {
|
||||
it('logs an expired bank session as a warning, not an error', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
state.active = [connection()]
|
||||
mocks.syncAccountTransactions.mockRejectedValue(
|
||||
new SessionExpiredError(401, '{"message":"Session has expired"}'),
|
||||
)
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
|
||||
// The connection still gets the re-auth state and its Swedish message.
|
||||
expect(state.updates[0].payload).toMatchObject({
|
||||
status: 'expired',
|
||||
error_message: REAUTH_REQUIRED_MESSAGE,
|
||||
})
|
||||
await expect(response.json()).resolves.toMatchObject({ processed: 1 })
|
||||
|
||||
const lines = consoleError.mock.calls.map(call => String(call[0]))
|
||||
expect(lines.some(line => line.includes('sync failed for connection'))).toBe(false)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('still logs a genuine sync failure at error level', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
state.active = [connection()]
|
||||
mocks.syncAccountTransactions.mockRejectedValue(new Error('ASPSP 500'))
|
||||
|
||||
await GET(cronRequest())
|
||||
|
||||
expect(state.updates[0].payload).toMatchObject({ status: 'error' })
|
||||
const lines = consoleError.mock.calls.map(call => String(call[0]))
|
||||
expect(lines.some(line => line.includes('sync failed for connection'))).toBe(true)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -345,14 +345,6 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
||||
daysUntilExpiry: daysLeft,
|
||||
})
|
||||
} catch (error) {
|
||||
ctx.log.error('sync failed for connection', error as Error, {
|
||||
connectionId: connection.id,
|
||||
userId: connection.user_id,
|
||||
bankName: connection.bank_name,
|
||||
consentExpires: connection.consent_expires,
|
||||
lastSyncedAt: connection.last_synced_at,
|
||||
})
|
||||
|
||||
// A dead PSD2 session (closed/expired/invalid consent) is a re-auth
|
||||
// condition, not a transient failure: flip it to 'expired' (same state
|
||||
// the consent-elapsed branch uses) so the UI offers a reconnect instead
|
||||
@@ -360,11 +352,30 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
||||
//
|
||||
// error_message is rendered verbatim on the settings panel, so it gets
|
||||
// the short Swedish user message in both cases: the raw Enable Banking
|
||||
// error body (an English JSON envelope) stays in the server log above.
|
||||
// error body (an English JSON envelope) stays in the server log below.
|
||||
const isSessionDead = error instanceof SessionExpiredError
|
||||
const failureStatus = isSessionDead ? 'expired' : 'error'
|
||||
const failureMessage = isSessionDead ? REAUTH_REQUIRED_MESSAGE : SYNC_FAILED_MESSAGE
|
||||
|
||||
// An expired PSD2 consent is the normal end of a bank grant and the row
|
||||
// is flipped to 'expired' for the user to reconnect: a warning, not an
|
||||
// error. Only genuine sync failures belong in the error panel.
|
||||
const failureContext = {
|
||||
connectionId: connection.id,
|
||||
userId: connection.user_id,
|
||||
bankName: connection.bank_name,
|
||||
consentExpires: connection.consent_expires,
|
||||
lastSyncedAt: connection.last_synced_at,
|
||||
}
|
||||
if (isSessionDead) {
|
||||
ctx.log.warn('bank session expired for connection', {
|
||||
...failureContext,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
} else {
|
||||
ctx.log.error('sync failed for connection', error as Error, failureContext)
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: failureStatus, error_message: failureMessage })
|
||||
|
||||
@@ -1844,6 +1844,19 @@ function ResultStep({
|
||||
failed: entityRowStatus(results.salesInvoices.imported, results.salesInvoices.skipReasons) === 'error',
|
||||
})
|
||||
}
|
||||
if (results.salesInvoices?.creditNotesUnlinked) {
|
||||
// Credit notes land as ordinary invoice rows with reversed amounts. The
|
||||
// pairing to the invoice they credit cannot be resolved at import time:
|
||||
// no provider DTO carries a reference to it. Say so rather than leaving
|
||||
// the user to notice a kreditfaktura that points at nothing.
|
||||
const count = results.salesInvoices.creditNotesUnlinked
|
||||
entityLines.push({
|
||||
label: t('ext_arcim_credit_notes_label'),
|
||||
value: `${count} importerade`,
|
||||
detail: t('ext_arcim_credit_notes_unlinked_detail', { count }),
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
if (results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0)) {
|
||||
entityLines.push({
|
||||
label: 'Leverantörsfakturor',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getErrorMessage } from '../get-error-message'
|
||||
import { getErrorMessage, getProviderResourceForbiddenMessage } from '../get-error-message'
|
||||
import { getErrorEntry } from '../structured-errors'
|
||||
import {
|
||||
AccountsNotInChartError,
|
||||
BookkeepingDatabaseError,
|
||||
@@ -538,3 +539,27 @@ describe('getErrorMessage: GoTrue auth error patterns', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProviderResourceForbiddenMessage', () => {
|
||||
it('builds on the registry copy and appends the provider\'s own sentence', () => {
|
||||
const entry = getErrorEntry('PROVIDER_RESOURCE_FORBIDDEN')!
|
||||
const msg = getProviderResourceForbiddenMessage('Saknar behörighet för leverantörsregister.')
|
||||
|
||||
// One copy of the sentence, in the registry: the toast, the API envelope
|
||||
// and the public error catalogue all have to say the same thing.
|
||||
expect(msg.startsWith(entry.message_sv)).toBe(true)
|
||||
// The provider's own words are the only part that names the register.
|
||||
expect(msg).toContain('Leverantörens svar: "Saknar behörighet för leverantörsregister."')
|
||||
})
|
||||
|
||||
it('falls back to the base sentence alone when the provider sent an opaque body', () => {
|
||||
const entry = getErrorEntry('PROVIDER_RESOURCE_FORBIDDEN')!
|
||||
expect(getProviderResourceForbiddenMessage(null)).toBe(entry.message_sv)
|
||||
expect(getProviderResourceForbiddenMessage(' ')).toBe(entry.message_sv)
|
||||
expect(getProviderResourceForbiddenMessage(null, 'en')).toBe(entry.message_en)
|
||||
})
|
||||
|
||||
it('never tells the user to reconnect: the same grant meets the same 403', () => {
|
||||
expect(getProviderResourceForbiddenMessage(null)).not.toMatch(/återanslut för att fortsätta/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,6 +70,25 @@ describe('structured-errors registry', () => {
|
||||
expect(entry?.retryable).toBeFalsy()
|
||||
})
|
||||
|
||||
it('registers PROVIDER_RESOURCE_FORBIDDEN as a 403 that never tells the user to reconnect', () => {
|
||||
// The provider refused one register on a grant that keeps working, so
|
||||
// "Återanslut" is the one thing this message must not say: reconnecting
|
||||
// re-mints the same grant and meets the same 403. This entry is also the
|
||||
// single source of that copy (get-error-message.ts reads it for the toast,
|
||||
// lib/docs/content/errors.ts publishes it), so it has to exist.
|
||||
const entry = getErrorEntry('PROVIDER_RESOURCE_FORBIDDEN')
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry?.httpStatus).toBe(403)
|
||||
// Says reconnecting does not help; never the "Återanslut för att
|
||||
// fortsätta" imperative PROVIDER_AUTH_EXPIRED carries.
|
||||
expect(entry?.message_sv).toMatch(/återansluta hjälper inte/i)
|
||||
expect(entry?.message_sv).not.toMatch(/återanslut för att fortsätta/i)
|
||||
expect(entry?.message_sv).toMatch(/behörighet/i)
|
||||
expect(entry?.message_en).toBeTruthy()
|
||||
// Retrying the same call hits the same permission gap: not transient.
|
||||
expect(entry?.retryable).toBeFalsy()
|
||||
})
|
||||
|
||||
it('listErrorCodes returns at least the bookkeeping + generic + provider codes', () => {
|
||||
const codes = listErrorCodes()
|
||||
expect(codes.length).toBeGreaterThan(20)
|
||||
|
||||
@@ -643,6 +643,38 @@ export function getBankConnectionErrorMessage(
|
||||
return description && description !== code ? `${base} (${description})` : base
|
||||
}
|
||||
|
||||
const PROVIDER_REASON_PREFIX: Bilingual = {
|
||||
sv: 'Leverantörens svar',
|
||||
en: 'Provider response',
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider refused ONE register while the grant itself keeps working: a
|
||||
* Fortnox account without rights to leverantörsregistret, a Bokio token with a
|
||||
* narrower scope. Never say "återanslut" here, the reconnect re-mints the same
|
||||
* grant and hits the same 403.
|
||||
*
|
||||
* The base copy is the registry's PROVIDER_RESOURCE_FORBIDDEN entry, not a
|
||||
* second copy of it: the same sentence has to reach the toast, the API
|
||||
* envelope and the public error catalogue (lib/docs/content/errors.ts renders
|
||||
* the registry verbatim). The entry's existence is locked by
|
||||
* lib/errors/__tests__/structured-errors.test.ts.
|
||||
*
|
||||
* `reason` is the provider's own sentence (e.g. Fortnox'
|
||||
* "Saknar behörighet för leverantörsregister."), appended verbatim because it
|
||||
* is the only part that names the register. Omitted when the provider sent an
|
||||
* opaque body, which Bokio does.
|
||||
*/
|
||||
export function getProviderResourceForbiddenMessage(
|
||||
reason?: string | null,
|
||||
locale: ErrorLocale = 'sv',
|
||||
): string {
|
||||
const entry = getErrorEntry('PROVIDER_RESOURCE_FORBIDDEN')!
|
||||
const base = pick({ sv: entry.message_sv, en: entry.message_en }, locale)
|
||||
const detail = reason?.trim()
|
||||
return detail ? `${base} ${pick(PROVIDER_REASON_PREFIX, locale)}: "${detail}"` : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that parses a Response body and returns a user-friendly error message.
|
||||
*/
|
||||
|
||||
@@ -3167,6 +3167,17 @@ const PROVIDER: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Anslutningen till leverantören har gått ut. Återanslut för att fortsätta.',
|
||||
message_en: 'Provider authentication expired or refresh failed.',
|
||||
},
|
||||
// The provider refused ONE register while the same access token keeps
|
||||
// answering for the rest (Fortnox: "Saknar behörighet för
|
||||
// leverantörsregister.", code 2003275). Never "återanslut" here: the
|
||||
// reconnect re-mints the same grant and meets the same 403.
|
||||
PROVIDER_RESOURCE_FORBIDDEN: {
|
||||
httpStatus: 403,
|
||||
message_sv:
|
||||
'Leverantören nekade åtkomst till en del av uppgifterna, men anslutningen fungerar. Att återansluta hjälper inte: kontrollera behörigheterna för det registret hos leverantören (i Fortnox användarens rättigheter och licens, i Bokio rättigheterna på integrationstoken) och försök igen.',
|
||||
message_en:
|
||||
'The provider refused access to part of the data, but the connection itself works. Reconnecting will not help: check that register\'s permissions with the provider (in Fortnox the user rights and licence, in Bokio the integration token rights) and try again.',
|
||||
},
|
||||
PROVIDER_LICENSE_MISSING: {
|
||||
httpStatus: 403,
|
||||
message_sv:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Locks fetchCompanyInfoDirect's failure contract.
|
||||
*
|
||||
* It used to wrap the whole body in try/catch and return null on ANY error, so
|
||||
* a Visma company whose api_standard module is off looked exactly like a
|
||||
* company with no details: /preview answered 200 with companyInfo: null and
|
||||
* its classify-and-rethrow remediation (PROVIDER_API_MODULE_INACTIVE, with the
|
||||
* "Appar och tillägg" instructions) was unreachable code. The customer read
|
||||
* "connected" and only found out after an empty migration.
|
||||
*
|
||||
* Contract now: provider errors propagate, and null keeps its one meaning,
|
||||
* "there is nothing to fetch here".
|
||||
*/
|
||||
|
||||
const { vismaGet, bokioGetCompany } = vi.hoisted(() => ({
|
||||
vismaGet: vi.fn(),
|
||||
bokioGetCompany: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../visma/client', () => ({
|
||||
VismaClient: class {
|
||||
get = vismaGet
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../bokio/client', () => ({
|
||||
BokioClient: class {
|
||||
getCompany = bokioGetCompany
|
||||
},
|
||||
BokioApiError: class BokioApiError extends Error {},
|
||||
}))
|
||||
|
||||
import { fetchCompanyInfoDirect } from '../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
|
||||
}
|
||||
|
||||
describe('fetchCompanyInfoDirect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('propagates a provider failure instead of swallowing it into null', async () => {
|
||||
vismaGet.mockRejectedValue(vismaError(403, VISMA_MODULE_BODY))
|
||||
|
||||
await expect(fetchCompanyInfoDirect('visma', 'tok')).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
body: VISMA_MODULE_BODY,
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates transient failures too: the caller decides what is soft', async () => {
|
||||
vismaGet.mockRejectedValue(vismaError(500))
|
||||
|
||||
await expect(fetchCompanyInfoDirect('visma', 'tok')).rejects.toMatchObject({ statusCode: 500 })
|
||||
})
|
||||
|
||||
it('still returns null when there is nothing to fetch, without calling the provider', async () => {
|
||||
// Bokio needs the provider company id to address the company endpoint.
|
||||
await expect(fetchCompanyInfoDirect('bokio', 'tok')).resolves.toBeNull()
|
||||
expect(bokioGetCompany).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
classifyProviderError,
|
||||
isApiModuleInactiveError,
|
||||
ProviderCallError,
|
||||
type ProviderCallErrorCode,
|
||||
} from '../with-provider-call'
|
||||
import { FortnoxApiError } from '../fortnox/client'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
|
||||
/**
|
||||
@@ -19,6 +21,10 @@ import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
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}}'
|
||||
|
||||
/** Mirror of VismaApiError's shape: statusCode + body on a plain Error. */
|
||||
function vismaError(statusCode: number, body?: string): Error {
|
||||
const e = new Error(`Visma API error: ${statusCode}`) as Error & {
|
||||
@@ -37,8 +43,71 @@ describe('classifyProviderError', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a bare 403 (no module body) as PROVIDER_AUTH_EXPIRED', () => {
|
||||
it('keeps a bare 403 on the first call of a run as PROVIDER_AUTH_EXPIRED', () => {
|
||||
// Nothing has proven the grant yet and the body says nothing: a revoked
|
||||
// grant and a closed register look identical, so "reconnect" stays.
|
||||
expect(classifyProviderError(vismaError(403))).toBe('PROVIDER_AUTH_EXPIRED')
|
||||
expect(classifyProviderError(vismaError(403), { grantProven: false })).toBe(
|
||||
'PROVIDER_AUTH_EXPIRED',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads a bare 403 as PROVIDER_RESOURCE_FORBIDDEN once the run has proven the grant', () => {
|
||||
// Bokio sends an empty 403 body. The same token answered an earlier step
|
||||
// in this run, so the grant is alive and one register is closed: aborting
|
||||
// the run and telling the user to reconnect can never help.
|
||||
expect(classifyProviderError(vismaError(403), { grantProven: true })).toBe(
|
||||
'PROVIDER_RESOURCE_FORBIDDEN',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads the Fortnox per-register 403 as PROVIDER_RESOURCE_FORBIDDEN even on the first call', () => {
|
||||
// Fortnox answers 401 for a dead token and 403 only for a resource the
|
||||
// account may not read, so its own 403 is proof enough. Classified off the
|
||||
// typed error, not off the Swedish sentence in the body: the same denial
|
||||
// in another locale, or reworded, must classify identically.
|
||||
const err = new FortnoxApiError('Fortnox API error: 403', 403, FORTNOX_SUPPLIER_BODY)
|
||||
expect(classifyProviderError(err)).toBe('PROVIDER_RESOURCE_FORBIDDEN')
|
||||
|
||||
const localised = new FortnoxApiError(
|
||||
'Fortnox API error: 403',
|
||||
403,
|
||||
'{"ErrorInformation":{"Error":1,"Message":"No permission for the supplier register.","Code":2003275}}',
|
||||
)
|
||||
expect(classifyProviderError(localised)).toBe('PROVIDER_RESOURCE_FORBIDDEN')
|
||||
|
||||
// Fortnox sometimes answers with no body at all; still a per-resource 403.
|
||||
expect(classifyProviderError(new FortnoxApiError('Fortnox API error: 403', 403))).toBe(
|
||||
'PROVIDER_RESOURCE_FORBIDDEN',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not read a Fortnox 401 as a per-resource denial', () => {
|
||||
// 401 IS the dead token: the migration must keep aborting on it.
|
||||
expect(classifyProviderError(new FortnoxApiError('Fortnox API error: 401', 401))).toBe(
|
||||
'PROVIDER_AUTH_EXPIRED',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not read another provider\'s "saknar behörighet" body as a per-resource denial', () => {
|
||||
// The Swedish sentence alone proves nothing: only Fortnox is known to
|
||||
// reserve 403 for the resource, and the run's own history covers the rest.
|
||||
expect(classifyProviderError(vismaError(403, FORTNOX_SUPPLIER_BODY))).toBe(
|
||||
'PROVIDER_AUTH_EXPIRED',
|
||||
)
|
||||
})
|
||||
|
||||
it('never downgrades a 401: a dead token is a dead token, proven grant or not', () => {
|
||||
expect(classifyProviderError(vismaError(401))).toBe('PROVIDER_AUTH_EXPIRED')
|
||||
expect(classifyProviderError(vismaError(401), { grantProven: true })).toBe(
|
||||
'PROVIDER_AUTH_EXPIRED',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a module/licence 403 fatal even after the grant is proven', () => {
|
||||
expect(classifyProviderError(vismaError(403, VISMA_MODULE_BODY), { grantProven: true })).toBe(
|
||||
'PROVIDER_API_MODULE_INACTIVE',
|
||||
)
|
||||
})
|
||||
|
||||
it('maps a Fortnox missing-license message to PROVIDER_LICENSE_MISSING', () => {
|
||||
@@ -57,6 +126,19 @@ describe('classifyProviderError', () => {
|
||||
expect(classifyProviderError(err)).toBe('PROVIDER_API_MODULE_INACTIVE')
|
||||
})
|
||||
|
||||
it('re-reads a ProviderCallError 403 with the run context mapResponseError lacked', () => {
|
||||
const err = new ProviderCallError('PROVIDER_AUTH_EXPIRED', 'bokio', 'Forbidden', {
|
||||
status: 403,
|
||||
})
|
||||
expect(classifyProviderError(err)).toBe('PROVIDER_AUTH_EXPIRED')
|
||||
expect(classifyProviderError(err, { grantProven: true })).toBe('PROVIDER_RESOURCE_FORBIDDEN')
|
||||
|
||||
const unauthorized = new ProviderCallError('PROVIDER_AUTH_EXPIRED', 'bokio', 'Unauthorized', {
|
||||
status: 401,
|
||||
})
|
||||
expect(classifyProviderError(unauthorized, { grantProven: true })).toBe('PROVIDER_AUTH_EXPIRED')
|
||||
})
|
||||
|
||||
it('returns null for an unclassifiable error', () => {
|
||||
expect(classifyProviderError(new Error('boom'))).toBeNull()
|
||||
expect(classifyProviderError('not an error')).toBeNull()
|
||||
@@ -82,4 +164,27 @@ describe('structured error registry wiring', () => {
|
||||
expect(entry!.message_sv).toContain('Appar och tillägg')
|
||||
expect(entry!.message_en).toBeTruthy()
|
||||
})
|
||||
|
||||
it('every code classifyProviderError can return has a registry entry', () => {
|
||||
// A code with no entry falls through entryFor() to INTERNAL_ERROR and the
|
||||
// route answers 500 with "Något gick fel", which is how
|
||||
// PROVIDER_RESOURCE_FORBIDDEN shipped the first time. Keyed by the union
|
||||
// rather than listed in an array, so the next code added to
|
||||
// ProviderCallErrorCode fails to compile until it is checked here too.
|
||||
const codes: Record<ProviderCallErrorCode, true> = {
|
||||
PROVIDER_AUTH_EXPIRED: true,
|
||||
PROVIDER_RESOURCE_FORBIDDEN: true,
|
||||
PROVIDER_LICENSE_MISSING: true,
|
||||
PROVIDER_API_MODULE_INACTIVE: true,
|
||||
PROVIDER_RATE_LIMITED: true,
|
||||
PROVIDER_UNREACHABLE: true,
|
||||
PROVIDER_UPSTREAM_ERROR: true,
|
||||
}
|
||||
for (const code of Object.keys(codes) as ProviderCallErrorCode[]) {
|
||||
const entry = getErrorEntry(code)
|
||||
expect(entry, `missing registry entry for ${code}`).toBeDefined()
|
||||
expect(entry!.message_sv).toBeTruthy()
|
||||
expect(entry!.message_en).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,60 +74,77 @@ async function blPaginate<T>(
|
||||
|
||||
// ── Public fetch functions ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Company information straight from the provider.
|
||||
*
|
||||
* Throws whatever the provider client threw. It used to catch everything and
|
||||
* return null, which made a Visma company whose api_standard module is off
|
||||
* look like a company with no details: the preview answered 200 with
|
||||
* companyInfo: null and its classify-and-rethrow remediation was unreachable
|
||||
* code. Callers decide what is soft (the preview keeps transient failures
|
||||
* soft, the migration records a step error). `null` still means "nothing to
|
||||
* fetch here" (no resource config, or no provider company id), never "the
|
||||
* call failed".
|
||||
*/
|
||||
export async function fetchCompanyInfoDirect(
|
||||
provider: ProviderName,
|
||||
accessToken: string,
|
||||
providerCompanyId?: string,
|
||||
): Promise<CompanyInformationDto | null> {
|
||||
try {
|
||||
if (provider === 'fortnox') {
|
||||
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await fortnoxClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
const data = response[config.detailKey];
|
||||
return data ? config.mapper(data as Record<string, unknown>) as CompanyInformationDto : null;
|
||||
}
|
||||
|
||||
if (provider === 'visma') {
|
||||
const config = VISMA_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await vismaClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
if (provider === 'briox') {
|
||||
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await brioxClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
if (provider === 'bokio') {
|
||||
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.CompanyInformation];
|
||||
if (!config || !providerCompanyId) return null;
|
||||
const response = await bokioClient.getCompany<Record<string, unknown>>(accessToken, providerCompanyId);
|
||||
return response ? config.mapper(response) as CompanyInformationDto : null;
|
||||
}
|
||||
|
||||
if (provider === 'bjornlunden') {
|
||||
const config = BL_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
if (!providerCompanyId) return null;
|
||||
const response = await bjornLundenClient.get<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
if (provider === 'wint') {
|
||||
// The WINT token is company-scoped: GET /api/Auth describes the company
|
||||
// the token opens, no providerCompanyId needed on the request.
|
||||
const config = WINT_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await wintClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[provider-data-fetcher] Failed to fetch company info from ${provider}:`, error);
|
||||
return null;
|
||||
if (provider === 'fortnox') {
|
||||
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await fortnoxClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
const data = response[config.detailKey];
|
||||
return data ? config.mapper(data as Record<string, unknown>) as CompanyInformationDto : null;
|
||||
}
|
||||
|
||||
if (provider === 'visma') {
|
||||
const config = VISMA_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await vismaClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
if (provider === 'briox') {
|
||||
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await brioxClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
if (provider === 'bokio') {
|
||||
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.CompanyInformation];
|
||||
if (!config || !providerCompanyId) return null;
|
||||
const response = await bokioClient.getCompany<Record<string, unknown>>(accessToken, providerCompanyId);
|
||||
return response ? config.mapper(response) as CompanyInformationDto : null;
|
||||
}
|
||||
|
||||
if (provider === 'bjornlunden') {
|
||||
const config = BL_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
if (!providerCompanyId) return null;
|
||||
const response = await bjornLundenClient.get<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
if (provider === 'wint') {
|
||||
// The WINT token is company-scoped: GET /api/Auth describes the company
|
||||
// the token opens, no providerCompanyId needed on the request.
|
||||
const config = WINT_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await wintClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List fetches. Every one of them can answer `[]` WITHOUT issuing a request:
|
||||
* Bokio and Björn Lundén key their list endpoints on a provider company id
|
||||
* this consent may not carry, WINT has no supplier register, and Bokio's
|
||||
* supplier 404 is swallowed on purpose. So an empty array means "nothing to
|
||||
* import", never "the provider answered nothing", and callers must not read a
|
||||
* resolved promise as proof that the access token works (see the migration
|
||||
* orchestrator's ProviderRunState.grantProven, which counts rows instead).
|
||||
* A failed request still throws; only genuinely absent resources return [].
|
||||
*/
|
||||
export async function fetchCustomersDirect(
|
||||
provider: ProviderName,
|
||||
accessToken: string,
|
||||
|
||||
@@ -10,9 +10,16 @@
|
||||
*/
|
||||
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
// Fortnox is the one provider with a typed predicate for "this account may not
|
||||
// read this resource" (403, or 400 with a permission body). classifyProviderError
|
||||
// reuses it instead of pattern-matching the Swedish sentence in the body, which
|
||||
// changes with the provider's locale and copy; import-documents.ts already
|
||||
// treats a Fortnox 403 the same way.
|
||||
import { isFortnoxPermissionError } from './fortnox/client'
|
||||
|
||||
export type ProviderCallErrorCode =
|
||||
| 'PROVIDER_AUTH_EXPIRED'
|
||||
| 'PROVIDER_RESOURCE_FORBIDDEN'
|
||||
| 'PROVIDER_LICENSE_MISSING'
|
||||
| 'PROVIDER_API_MODULE_INACTIVE'
|
||||
| 'PROVIDER_RATE_LIMITED'
|
||||
@@ -172,12 +179,31 @@ function isNetworkError(err: Error): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export interface ClassifyProviderErrorOptions {
|
||||
/**
|
||||
* True when an earlier provider call in the same run already returned data.
|
||||
* The access token is then provably alive, so a 403 after that point is the
|
||||
* provider closing ONE resource, not the grant dying. Leave it false (the
|
||||
* default) when the failing call is the first one: an opaque 403 there is
|
||||
* indistinguishable from a revoked grant and must keep saying "reconnect".
|
||||
*
|
||||
* Data returned, not a promise that resolved: several fetchers answer with
|
||||
* an empty list before issuing any request (a provider company id we do not
|
||||
* have, a register the provider does not expose), and those prove nothing.
|
||||
*/
|
||||
grantProven?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an error from a provider client (Fortnox/Bokio/Visma/Briox/BL) into
|
||||
* a structured error code. Reads `statusCode` (Fortnox client) or `status`
|
||||
* (other clients) off the thrown error and maps:
|
||||
*
|
||||
* 401/403 → PROVIDER_AUTH_EXPIRED
|
||||
* 401 → PROVIDER_AUTH_EXPIRED
|
||||
* 403 → PROVIDER_RESOURCE_FORBIDDEN when the provider answers 403 only
|
||||
* for the resource (Fortnox, see isFortnoxPermissionError), or
|
||||
* when `options.grantProven` says an earlier call in the same run
|
||||
* already succeeded on this token; otherwise PROVIDER_AUTH_EXPIRED
|
||||
* 429 → PROVIDER_RATE_LIMITED
|
||||
* 5xx → PROVIDER_UPSTREAM_ERROR
|
||||
* network → PROVIDER_UNREACHABLE
|
||||
@@ -189,8 +215,16 @@ function isNetworkError(err: Error): boolean {
|
||||
* gått ut. Återanslut för att fortsätta." vs. "Försök igen om en stund.")
|
||||
* instead of the same generic message for every cause.
|
||||
*/
|
||||
export function classifyProviderError(error: unknown): ProviderCallErrorCode | null {
|
||||
export function classifyProviderError(
|
||||
error: unknown,
|
||||
options: ClassifyProviderErrorOptions = {},
|
||||
): ProviderCallErrorCode | null {
|
||||
if (error instanceof ProviderCallError) {
|
||||
// mapResponseError() sees one response and cannot know the run's history,
|
||||
// so a 403 it already labelled AUTH_EXPIRED is re-read here with it.
|
||||
if (error.code === 'PROVIDER_AUTH_EXPIRED' && error.status === 403 && options.grantProven) {
|
||||
return 'PROVIDER_RESOURCE_FORBIDDEN'
|
||||
}
|
||||
return error.code
|
||||
}
|
||||
if (!(error instanceof Error)) return null
|
||||
@@ -212,6 +246,14 @@ export function classifyProviderError(error: unknown): ProviderCallErrorCode | n
|
||||
if (isMissingLicenseError(haystack)) return 'PROVIDER_LICENSE_MISSING'
|
||||
|
||||
if (typeof status === 'number') {
|
||||
// A 403 is only a dead grant when nothing else says otherwise: a provider
|
||||
// that reserves 403 for the resource and answers 401 for a dead token
|
||||
// (Fortnox), or the same token having already answered earlier in this
|
||||
// run, both mean the grant is alive and one register is closed. 401 is
|
||||
// never downgraded: that IS a dead token.
|
||||
if (status === 403 && (options.grantProven || isFortnoxPermissionError(error))) {
|
||||
return 'PROVIDER_RESOURCE_FORBIDDEN'
|
||||
}
|
||||
if (status === 401 || status === 403) return 'PROVIDER_AUTH_EXPIRED'
|
||||
if (status === 429) return 'PROVIDER_RATE_LIMITED'
|
||||
if (status >= 500) return 'PROVIDER_UPSTREAM_ERROR'
|
||||
|
||||
@@ -5763,6 +5763,8 @@
|
||||
"ext_arcim_registration_links_label": "Voucher links",
|
||||
"ext_arcim_registration_links_value": "{linked} of {scanned} invoices linked to their booking voucher",
|
||||
"ext_arcim_registration_links_detail": "{unlinked} not linked: {noRef} without a voucher number at the provider, {refNotFetched} whose provider details could not be fetched in time, {unresolved} without an unambiguous booking voucher, {amountMismatch} with a differing amount",
|
||||
"ext_arcim_credit_notes_label": "Credit notes",
|
||||
"ext_arcim_credit_notes_unlinked_detail": "{count, plural, one {# credit note was imported without a link to the invoice it credits: your old system sends no reference to the original invoice. Its amounts are reversed and the record is complete, but the link to the original invoice is missing.} other {# credit notes were imported without a link to the invoices they credit: your old system sends no reference to the original invoice. Their amounts are reversed and the records are complete, but the link to the original invoice is missing.}}",
|
||||
"ext_arcim_migration_description": "Migrate bookkeeping from Fortnox, Visma, Bokio, Björn Lundén or Briox",
|
||||
"ext_arcim_migration_long_description": "Move all bookkeeping data from your old system to accounted. Imports chart of accounts, vouchers, customers, suppliers and open invoices automatically via a secure API integration directly with the provider.",
|
||||
"ext_arcim_bokio_token_description": "Enter the integration token and company ID from Bokio to let {appName} read your bookkeeping data.",
|
||||
|
||||
@@ -5763,6 +5763,8 @@
|
||||
"ext_arcim_registration_links_label": "Verifikatkoppling",
|
||||
"ext_arcim_registration_links_value": "{linked} av {scanned} fakturor kopplade till bokföringsverifikat",
|
||||
"ext_arcim_registration_links_detail": "{unlinked} utan koppling: {noRef} saknar verifikatnummer hos leverantören, {refNotFetched} vars detaljer inte hann hämtas från leverantören, {unresolved} utan entydigt bokföringsverifikat, {amountMismatch} med avvikande belopp",
|
||||
"ext_arcim_credit_notes_label": "Kreditfakturor",
|
||||
"ext_arcim_credit_notes_unlinked_detail": "{count, plural, one {# kreditfaktura importerades utan koppling till fakturan den krediterar: ditt gamla system skickar ingen referens till ursprungsfakturan. Beloppen är omvända och posten är komplett, men kopplingen till ursprungsfakturan saknas.} other {# kreditfakturor importerades utan koppling till fakturorna de krediterar: ditt gamla system skickar ingen referens till ursprungsfakturan. Beloppen är omvända och posterna är kompletta, men kopplingen till ursprungsfakturan saknas.}}",
|
||||
"ext_arcim_migration_description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox",
|
||||
"ext_arcim_migration_long_description": "Flytta all bokföringsdata från ditt gamla system till accounted. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration direkt med leverantören.",
|
||||
"ext_arcim_bokio_token_description": "Ange integrationstoken och företags-ID från Bokio för att ge {appName} tillgång att läsa din bokföringsdata.",
|
||||
|
||||
Reference in New Issue
Block a user