* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export New mileage_trips table (RLS, booked-delete trigger per BFL retention), lib/mileage service reusing the payroll schablon rates, /api/mileage routes (trips CRUD, period booking to 7331, salary-run push, körjournal CSV), Körjournal dashboard page + nav, and three staged MCP tools (search-only catalog). Trips book as one verifikat per period via the engine; salary path inserts mileage_taxfree line items. mileage_trips classified in the full-archive export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(mileage): use shared roundOre helper per tightened ratchet baseline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): pending_operations op-type migration + Swedish review findings - New migration pair adds log_mileage_trip/book_mileage_period to the pending_operations operation_type CHECK (pg-real audit). - bookMileagePeriod refuses a period spanning several employees and names the employee in the verifikationstext when scoped (BFL motpart). - vehicle_registration required for förmånsbil trips (schema, service, MCP staging, UI surfaces the field). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): claim-first booking, CSV injection guard and driver column - bookMileagePeriod claims trips (draft to booked CAS) before creating the verifikat, so a concurrent second booking loses the race instead of double-booking; claim reverts if verifikat creation fails. - Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a Förare column naming the employee per trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening - Copying a round trip no longer re-doubles the stored distance. - pushMileageToSalaryRun claims trips before inserting line items (retry can no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races. - Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration 20260807113215): only claim/link/revert transitions and notes edits pass. - Cross-year periods rejected (schablon rates are per calendar year); payroll config year read from the date string, not TZ-dependent getFullYear(). - MCP staged bookings freeze the previewed trip set (trip_ids in params) and the commit fails on drift; validation errors return 400, not 500. - PATCH enforces the förmånsbil regnr rule on the effective row; export validates dates before they reach the Content-Disposition header; employee_id is verified company-scoped on trip creation; stale orphaned claims released. - UI: fetch flags reset in finally; ICU plural for draft summary; distance stored at the column's 1-decimal precision. - Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift, cross-year and update-trigger pg cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): revert-to-draft must clear salary_run_id at the trigger level New migration 20260807114924 replaces the booked-immutability function: a booked -> draft revert now rejects rows keeping salary_run_id, closing the DB-level double-pay path CodeRabbit flagged. pg test pins both directions; the CLAIM_LOST unit test now asserts the revert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): company-scope employee_id on PATCH (Superagent P2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mileage): valid v4 uuid in cross-company employee PATCH test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(providers): surface migration step errors instead of silent empty syncs A Visma company without the API module activated (403 ErrorCode 4002, "No access to module: api_standard") failed every provider call during migration, yet the wizard reported success with zero rows and mapped the 403 to "reconnect", which loops forever since OAuth succeeds against Visma's shared identity server. A real user burned time re-syncing and reconnecting, then filed the config issue as a bug. - New PROVIDER_API_MODULE_INACTIVE code; classifyProviderError reads the error body and recognizes the module error before the 403 to AUTH_EXPIRED mapping. Registry entry carries the remediation in Swedish and English (activate the API under Appar och tillagg, paid add-on on smaller plans, clear standardforetag, SIE fallback). - Orchestrator: connection-level failures (auth expired, license missing, module inactive) rethrow and abort the doomed run so /migrate answers with the typed code; other step failures stay non-fatal but land on results.stepErrors instead of only in server logs. - /preview fails fast on the two subscription codes so the user reads the remediation at connect time, before any sync. - Wizard: preview treats the new code like the Fortnox license case (CTA + SIE fallback); the result step renders error cards per cause and says "Migrering delvis genomford" instead of "Allt ar uppdaterat"; the completion toast is honest on partial failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ink2): SRU field 1.1 is 7104, not 7113 (Skatteverket rejects 7113) The INK2 huvudblankett code for 1.1 Overskott av naringsverksamhet is 7104 per Skatteverket's official 2025P4 faltkoder (INK2_SKV2002-33-01-24-04). We emitted 7113, which does not exist on INK2, so filoverforing rejected every profitable company's BLANKETTER.SRU with 'UPPGIFT 7113 ar inte ett giltigt postnamn' (reported by a user for FY 2024-10-07..2025-12-31). Underskott (7114) was already correct. The wrong code originated in the swedish-sru-filing skill reference; fixed there too and regenerated the atom seed. All other emitted INK2/INK2R/INK2S codes verified against the official 2025P4 lists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): keep the AI chat usable over the new-invoice dialog The new-invoice dialog was a modal Radix dialog: modal mode sets body pointer-events: none, aria-hidden on body siblings, and a focus trap, so the agent sheet (z-60, painted above the dialog) was visible but dead: clicks swallowed, input unfocusable, and all three dismiss paths preventDefaulted, leaving no way out except the header X. Now non-modal: page modality is restored by hand instead. A new DialogVeil primitive supplies the backdrop (Radix renders no overlay in non-modal mode) at z-40, under dialog content (z-50) and the agent sheet (z-60), and inert on #dash-shell blocks pointer, keyboard, and AT access to the page behind while the sheet (a body-level sibling) stays live. The lazy-load fallback dialog on /invoices gets the same treatment so a hung or 404'd chunk cannot dead-lock the route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
267 lines
9.5 KiB
TypeScript
267 lines
9.5 KiB
TypeScript
/**
|
|
* Wraps a single external HTTP call to a third-party provider (Fortnox, Bokio,
|
|
* Visma, Briox, BL/Björn Lundén, Enable Banking, etc.) with structured
|
|
* logging and code-mapped errors.
|
|
*
|
|
* Translates HTTP failures and network errors into ProviderCallError, which
|
|
* the route wrapper's errorResponse() recognises as a structured code. This
|
|
* keeps the user message + remediation consistent across providers without
|
|
* each call site having to repeat the mapping.
|
|
*/
|
|
|
|
import { createLogger, type Logger } from '@/lib/logger'
|
|
|
|
export type ProviderCallErrorCode =
|
|
| 'PROVIDER_AUTH_EXPIRED'
|
|
| 'PROVIDER_LICENSE_MISSING'
|
|
| 'PROVIDER_API_MODULE_INACTIVE'
|
|
| 'PROVIDER_RATE_LIMITED'
|
|
| 'PROVIDER_UNREACHABLE'
|
|
| 'PROVIDER_UPSTREAM_ERROR'
|
|
|
|
export class ProviderCallError extends Error {
|
|
readonly code: ProviderCallErrorCode
|
|
readonly provider: string
|
|
readonly status?: number
|
|
readonly retryAfterSeconds?: number
|
|
|
|
constructor(
|
|
code: ProviderCallErrorCode,
|
|
provider: string,
|
|
message: string,
|
|
extras: { status?: number; retryAfterSeconds?: number } = {},
|
|
) {
|
|
super(message)
|
|
this.name = 'ProviderCallError'
|
|
this.code = code
|
|
this.provider = provider
|
|
this.status = extras.status
|
|
this.retryAfterSeconds = extras.retryAfterSeconds
|
|
}
|
|
}
|
|
|
|
export function isProviderCallError(err: unknown): err is ProviderCallError {
|
|
return err instanceof ProviderCallError
|
|
}
|
|
|
|
interface ProviderCallOptions {
|
|
/** Provider id ('fortnox', 'bokio', 'visma', etc.). */
|
|
provider: string
|
|
/** Short label for what this call does, e.g. 'fetch_invoices'. */
|
|
operation: string
|
|
/** Optional logger; if omitted a `provider/<provider>` logger is created. */
|
|
log?: Logger
|
|
/** Extra context merged into the log line. */
|
|
context?: Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* Run an async callable that performs the actual HTTP request and translate
|
|
* its failures. The callable should throw a `Response` (preferred) or a
|
|
* regular Error; ProviderCallError is mapped from the response status.
|
|
*
|
|
* Example:
|
|
* await withProviderCall(
|
|
* { provider: 'fortnox', operation: 'fetch_invoices' },
|
|
* async () => {
|
|
* const res = await fetch(url, { headers })
|
|
* if (!res.ok) throw res
|
|
* return res.json()
|
|
* },
|
|
* )
|
|
*/
|
|
export async function withProviderCall<T>(
|
|
options: ProviderCallOptions,
|
|
call: () => Promise<T>,
|
|
): Promise<T> {
|
|
const log = (options.log ?? createLogger(`provider/${options.provider}`)).child({
|
|
provider: options.provider,
|
|
providerOp: options.operation,
|
|
...options.context,
|
|
})
|
|
|
|
const start = Date.now()
|
|
try {
|
|
const result = await call()
|
|
log.info('provider call ok', { latencyMs: Date.now() - start })
|
|
return result
|
|
} catch (raw) {
|
|
const latencyMs = Date.now() - start
|
|
|
|
if (raw instanceof Response) {
|
|
const mapped = mapResponseError(raw, options.provider)
|
|
log.error('provider call failed (http)', mapped, {
|
|
latencyMs,
|
|
status: raw.status,
|
|
})
|
|
throw mapped
|
|
}
|
|
|
|
if (raw instanceof ProviderCallError) {
|
|
log.error('provider call failed', raw, { latencyMs })
|
|
throw raw
|
|
}
|
|
|
|
if (raw instanceof Error && isNetworkError(raw)) {
|
|
const wrapped = new ProviderCallError(
|
|
'PROVIDER_UNREACHABLE',
|
|
options.provider,
|
|
raw.message,
|
|
)
|
|
log.error('provider call unreachable', wrapped, { latencyMs })
|
|
throw wrapped
|
|
}
|
|
|
|
// Unknown shape: re-throw so the outer handler can decide. We still log it.
|
|
log.error('provider call failed (unknown)', raw as Error, { latencyMs })
|
|
throw raw
|
|
}
|
|
}
|
|
|
|
function mapResponseError(res: Response, provider: string): ProviderCallError {
|
|
if (res.status === 401 || res.status === 403) {
|
|
return new ProviderCallError(
|
|
'PROVIDER_AUTH_EXPIRED',
|
|
provider,
|
|
`Provider authentication failed: ${res.status} ${res.statusText}`,
|
|
{ status: res.status },
|
|
)
|
|
}
|
|
if (res.status === 429) {
|
|
const retryAfter = parseRetryAfter(res.headers.get('retry-after'))
|
|
return new ProviderCallError(
|
|
'PROVIDER_RATE_LIMITED',
|
|
provider,
|
|
`Provider rate limit hit: ${res.status} ${res.statusText}`,
|
|
{ status: res.status, retryAfterSeconds: retryAfter },
|
|
)
|
|
}
|
|
if (res.status >= 500) {
|
|
return new ProviderCallError(
|
|
'PROVIDER_UPSTREAM_ERROR',
|
|
provider,
|
|
`Provider upstream error: ${res.status} ${res.statusText}`,
|
|
{ status: res.status },
|
|
)
|
|
}
|
|
// 4xx other than 401/403/429 is application-level: surface as upstream so
|
|
// the user gets a meaningful Swedish message; the actual cause is in logs.
|
|
return new ProviderCallError(
|
|
'PROVIDER_UPSTREAM_ERROR',
|
|
provider,
|
|
`Provider rejected request: ${res.status} ${res.statusText}`,
|
|
{ status: res.status },
|
|
)
|
|
}
|
|
|
|
function parseRetryAfter(value: string | null): number | undefined {
|
|
if (!value) return undefined
|
|
const n = parseInt(value, 10)
|
|
return Number.isFinite(n) ? n : undefined
|
|
}
|
|
|
|
function isNetworkError(err: Error): boolean {
|
|
// node-undici throws TypeError('fetch failed') with a `cause` for DNS/TCP issues.
|
|
if (err.name === 'TypeError' && /fetch failed/i.test(err.message)) return true
|
|
if (err.name === 'AbortError') return true
|
|
// Known undici error codes
|
|
const cause = (err as Error & { cause?: { code?: string } }).cause
|
|
if (cause?.code && ['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(cause.code)) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* 429 → PROVIDER_RATE_LIMITED
|
|
* 5xx → PROVIDER_UPSTREAM_ERROR
|
|
* network → PROVIDER_UNREACHABLE
|
|
* other → null (caller falls back to its domain-specific code, e.g.
|
|
* `PROVIDER_SIE_FETCH_FAILED`)
|
|
*
|
|
* Use at the boundary where a provider call's failure becomes a user-facing
|
|
* response. Lets the toast show a specific Swedish message ("Anslutningen har
|
|
* 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 {
|
|
if (error instanceof ProviderCallError) {
|
|
return error.code
|
|
}
|
|
if (!(error instanceof Error)) return null
|
|
|
|
const status =
|
|
(error as Error & { statusCode?: number; status?: number }).statusCode ??
|
|
(error as Error & { statusCode?: number; status?: number }).status
|
|
|
|
// Provider clients (e.g. VismaApiError) carry the response body separately
|
|
// from the Error message; both can hold the discriminating string.
|
|
const body = (error as Error & { body?: unknown }).body
|
|
const haystack = typeof body === 'string' ? `${error.message} ${body}` : error.message
|
|
|
|
// Order matters: 401/403 with a module/license body is a subscription
|
|
// problem, not a dead token. Mapping it to AUTH_EXPIRED would send the user
|
|
// into a reconnect loop that can never succeed (the exact failure mode this
|
|
// classification exists to prevent).
|
|
if (isApiModuleInactiveError(haystack)) return 'PROVIDER_API_MODULE_INACTIVE'
|
|
if (isMissingLicenseError(haystack)) return 'PROVIDER_LICENSE_MISSING'
|
|
|
|
if (typeof status === 'number') {
|
|
if (status === 401 || status === 403) return 'PROVIDER_AUTH_EXPIRED'
|
|
if (status === 429) return 'PROVIDER_RATE_LIMITED'
|
|
if (status >= 500) return 'PROVIDER_UPSTREAM_ERROR'
|
|
}
|
|
if (isNetworkError(error)) return 'PROVIDER_UNREACHABLE'
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* True when a provider token/OAuth failure means the integration license is
|
|
* missing or inactive, NOT an ordinary expired/revoked grant.
|
|
*
|
|
* Fortnox answers its token endpoint with `error_missing_license` when the
|
|
* customer's Fortnox account no longer carries the integration license. The
|
|
* stored refresh token cannot be revived by re-authorizing: re-auth loops until
|
|
* the customer re-orders the "Fortnox Integration" add-on. Distinguishing this
|
|
* from a plain dead token lets callers say "activate the license, then
|
|
* reconnect" instead of a bare "reconnect" that just fails again.
|
|
*
|
|
* Matches on the raw provider message string because the underlying refresh
|
|
* helpers bake the body into the Error message; deliberately does NOT match
|
|
* `invalid_grant` (that IS a revivable reconnect → PROVIDER_AUTH_EXPIRED).
|
|
*/
|
|
export function isMissingLicenseError(message: string): boolean {
|
|
const haystack = message.toLowerCase()
|
|
return (
|
|
haystack.includes('error_missing_license') ||
|
|
haystack.includes('missing_license') ||
|
|
haystack.includes('missing license') ||
|
|
haystack.includes('not have enough licenses')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* True when a provider 403 means the customer's subscription has API access
|
|
* switched off or not included, NOT an expired/revoked grant.
|
|
*
|
|
* Visma eAccounting (Spiris) answers every data endpoint with
|
|
* `ForbiddenRequestException - No access to module: api_standard`
|
|
* (ErrorCode 4002) when the company's plan lacks the API module or it is not
|
|
* activated under "Appar och tillägg". OAuth still succeeds (the identity
|
|
* server is shared), so the stored tokens are valid; re-authorizing loops
|
|
* forever. The fix is on the customer's side: activate the API module (an
|
|
* add-on on smaller plans) and clear any "standardföretag" selection.
|
|
*
|
|
* Matches the raw provider body/message string, same approach as
|
|
* isMissingLicenseError above.
|
|
*/
|
|
export function isApiModuleInactiveError(message: string): boolean {
|
|
return /no access to module/i.test(message)
|
|
}
|