Files
accounted/lib/errors/get-error-message.ts
T
Mattsson 02f94ef631 Fix/critical issues (#351)
* fix: add 15s timeout to accounting provider HTTP clients

Node's built-in fetch has no default timeout, so a stalled provider
could hold a serverless worker open for many minutes — worse with
withRetry (6x on Fortnox, 3x on others) and getPaginated stacking
across pages.

Wrap each fetch() in the Fortnox, Visma, Bokio, Briox, and Björn
Lundén clients with signal: AbortSignal.timeout(15_000), and treat
TimeoutError/AbortError as retryable so a single stalled attempt
retries cleanly instead of hanging the request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: add timeouts to OAuth token endpoints

Wrap every OAuth2 token exchange, refresh, and revoke POST in an
AbortController via a new fetchWithTimeout helper. Without this, a
hung provider endpoint holds the request thread indefinitely — worst
case being Skatteverket, where refreshAccessToken sits on the hot
path of every bookkeeping action and exchangeCodeForTokens races the
5-minute BankID auth-code TTL.

On timeout, the Skatteverket OAuth callback now redirects to
/reports?tab=vat-declaration with a Swedish retry message instead
of leaving the user stranded on the callback URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: close RLS escalation on membership and settings tables

Any authenticated user who was a member (including viewer) could issue a
direct PostgREST PATCH against company_members and promote themselves to
owner, bypassing the app-layer requireWritePermission guard entirely.
Reproduced on prod, then verified the fix on staging.

Tighten INSERT/UPDATE/DELETE policies on company_members, team_members,
api_keys, company_invitations, team_invitations, companies, teams, and
company_settings to require the caller to hold role IN ('owner','admin')
in the target company/team. Role check is wrapped in SECURITY DEFINER
helpers (user_is_company_admin, user_is_team_admin, user_role_in_company)
to avoid RLS recursion when a policy on company_members references
company_members in its subquery.

Add a BEFORE UPDATE trigger on company_members that rejects any role
change unless the caller already holds role='owner', so admins cannot
mint further owners even though they can otherwise write.

Legitimate write paths are unaffected: company creation goes through the
create_company_with_owner SECURITY DEFINER RPC, invite acceptance uses
the service role, and team->company membership syncs via SECURITY
DEFINER triggers. All bypass RLS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): resolve duplicate schema_migrations version 20260421160000

Two migration files shared timestamp 20260421160000 on main
(booking_template_usage.sql and opening_balances_rpc.sql), causing
supabase_migrations.schema_migrations PK collisions on any fresh CI run:

  duplicate key value violates unique constraint "schema_migrations_pkey"
  Key (version)=(20260421160000) already exists.

Bump opening_balances_rpc.sql to 20260421160500. booking_template_usage
keeps 20260421160000 because its table already exists on prod; the
renamed file has an idempotent CREATE OR REPLACE FUNCTION body and has
not yet been deployed to prod, so moving its version is free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): make booking_template_usage migration idempotent

The table already exists on prod (applied out-of-band) but prod's
schema_migrations does not track version 20260421160000, so the next
PR-driven deploy would re-run this migration and fail on
`CREATE TABLE public.booking_template_usage` with a duplicate-relation
error.

Add IF NOT EXISTS to CREATE TABLE and CREATE INDEX, and DROP POLICY
IF EXISTS before each CREATE POLICY. No functional change on fresh
databases; prod just silently no-ops the table/index creates and
re-declares policies without dropping-then-missing them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: implement isTimeoutError utility and enforce role restrictions on company_members insert

* fix: implement fallback for user_id in commit_journal_entry function when auth.uid() is NULL

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:14:01 +02:00

280 lines
9.2 KiB
TypeScript

/**
* Maps raw errors to user-friendly Swedish messages.
*
* Priority chain:
* 1. Zod validation field errors
* 2. Postgres error code map
* 3. HTTP status code map
* 4. Context-specific fallback
* 5. Generic fallback
*/
type ErrorContext =
| 'invoice'
| 'supplier_invoice'
| 'customer'
| 'supplier'
| 'transaction'
| 'journal_entry'
| 'settings'
| 'auth'
| 'salary'
interface GetErrorMessageOptions {
context?: ErrorContext
statusCode?: number
}
// Postgres error codes -> Swedish messages
const POSTGRES_ERROR_MAP: Record<string, string> = {
'23505': 'En post med samma uppgifter finns redan.',
'23503': 'Posten kan inte ändras eftersom den refereras av annan data.',
'23502': 'Ett obligatoriskt fält saknas.',
'42501': 'Du har inte behörighet att utföra denna åtgärd.',
'42P01': 'Resursen kunde inte hittas.',
'23514': 'Värdet uppfyller inte de tillåtna kraven.',
'40001': 'En annan ändring pågick samtidigt. Försök igen.',
'40P01': 'En konflikt uppstod. Försök igen.',
'22P02': 'Ogiltigt värde angavs.',
'22003': 'Värdet är utanför tillåtet intervall.',
}
// HTTP status codes -> Swedish messages
const HTTP_STATUS_MAP: Record<number, string> = {
400: 'Förfrågan innehåller ogiltiga uppgifter.',
401: 'Din session har gått ut. Logga in igen.',
403: 'Du har inte behörighet att utföra denna åtgärd.',
404: 'Resursen kunde inte hittas.',
409: 'En konflikt uppstod. Ladda om sidan och försök igen.',
422: 'Uppgifterna kunde inte bearbetas. Kontrollera fälten och försök igen.',
429: 'För många förfrågningar. Vänta en stund och försök igen.',
500: 'Ett oväntat serverfel uppstod. Försök igen senare.',
502: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
503: 'Tjänsten är tillfälligt otillgänglig. Försök igen om en stund.',
}
// Context-specific fallbacks
const CONTEXT_FALLBACKS: Record<ErrorContext, string> = {
invoice: 'Kunde inte hantera fakturan. Försök igen.',
supplier_invoice: 'Kunde inte hantera leverantörsfakturan. Försök igen.',
customer: 'Kunde inte hantera kunden. Försök igen.',
supplier: 'Kunde inte hantera leverantören. Försök igen.',
transaction: 'Kunde inte hantera transaktionen. Försök igen.',
journal_entry: 'Kunde inte hantera verifikationen. Försök igen.',
settings: 'Kunde inte spara inställningarna. Försök igen.',
auth: 'Ett fel uppstod vid inloggningen. Försök igen.',
salary: 'Kunde inte hantera löneuppgifterna. Försök igen.',
}
const GENERIC_FALLBACK = 'Något gick fel. Försök igen.'
// Known error patterns → user-friendly Swedish messages
const ERROR_PATTERN_MAP: [RegExp, string | null][] = [
[
/locked\/closed fiscal period/i,
'Perioden är låst. Verifikationen kan inte skapas i en stängd eller låst period.',
],
[
/Bokföringen är låst t\.o\.m\./,
null, // null = extract the Swedish message directly from the raw error text
],
[
/Cannot attach documents to entries in a locked/i,
'Kan inte bifoga dokument till verifikationer i en låst period.',
],
[
/Entry date .+ is outside fiscal period/i,
'Datumet ligger utanför det valda räkenskapsåret.',
],
[
/timed out after \d+m?s/i,
'Anslutningen mot tjänsten tog för lång tid. Försök igen.',
],
]
/**
* Check if a message matches a known error pattern and return the Swedish translation.
* Returns null if no pattern matches.
*/
function tryMatchKnownError(message: string): string | null {
for (const [pattern, translation] of ERROR_PATTERN_MAP) {
if (pattern.test(message)) {
if (translation !== null) return translation
// Extract the Swedish part from the message
const match = message.match(/Bokföringen är låst t\.o\.m\. [^.]+\./)
return match ? match[0] : 'Bokföringen är låst för denna period.'
}
}
return null
}
/**
* Simple heuristic to detect already-translated Swedish messages.
* If the message contains common Swedish words/patterns, pass it through.
*/
function isSwedishUserMessage(message: string): boolean {
const swedishPatterns = [
/kunde inte/i,
/försök igen/i,
/ogiltigt?/i,
/saknas/i,
/måste/i,
/redan finns/i,
/gick fel/i,
/behörighet/i,
/session/i,
/förfrågan/i,
/obligatorisk/i,
/bokföringen är låst/i,
]
return swedishPatterns.some((p) => p.test(message))
}
/**
* Extract a user-friendly message from a Zod validation error shape.
* Returns null if the error is not a Zod error.
*/
function tryParseZodErrors(error: unknown): string | null {
if (typeof error !== 'object' || error === null) return null
const obj = error as Record<string, unknown>
// Check for Zod-style field errors: { fieldName: ["message"] } or { issues: [...] }
if (Array.isArray(obj.issues)) {
const issues = obj.issues as Array<{ message?: string; path?: string[] }>
const messages = issues
.slice(0, 3)
.map((issue) => {
const field = issue.path?.join('.') || ''
const msg = issue.message || 'ogiltigt värde'
return field ? `${field}: ${msg}` : msg
})
if (messages.length > 0) return messages.join('. ')
}
// Check for { errors: [{ field, message, code }] } shape from validateBody
if (Array.isArray(obj.errors)) {
const items = obj.errors as Array<{ field?: string; message?: string }>
const messages = items
.slice(0, 3)
.map((it) => {
const field = it.field || ''
const msg = it.message || 'ogiltigt värde'
return field ? `${field}: ${msg}` : msg
})
.filter(Boolean)
if (messages.length > 0) return messages.join('. ')
}
// Check for { errors: { field: ["msg"] } } shape (legacy)
if (typeof obj.errors === 'object' && obj.errors !== null) {
const fieldErrors = obj.errors as Record<string, string[]>
const messages: string[] = []
for (const [field, msgs] of Object.entries(fieldErrors)) {
if (Array.isArray(msgs) && msgs.length > 0) {
messages.push(`${field}: ${msgs[0]}`)
}
if (messages.length >= 3) break
}
if (messages.length > 0) return messages.join('. ')
}
return null
}
/**
* Get a user-friendly Swedish error message from a raw error.
*
* @param error - The raw error. Can be an API response body (object), Error instance, string, or unknown.
* @param options - Optional context and HTTP status code.
*/
export function getErrorMessage(
error: unknown,
options: GetErrorMessageOptions = {}
): string {
const { context, statusCode } = options
// 1. If it's a string, check if it's already Swedish
if (typeof error === 'string' && error.trim()) {
if (isSwedishUserMessage(error)) return error
}
// 2. If it's an object, try various parsing strategies
if (typeof error === 'object' && error !== null) {
const obj = error as Record<string, unknown>
// Structured application error: { error: { code, message, ... } }
if (typeof obj.error === 'object' && obj.error !== null) {
const structured = obj.error as { code?: unknown; message?: unknown; account_numbers?: unknown }
if (structured.code === 'ACCOUNTS_NOT_IN_CHART' && Array.isArray(structured.account_numbers)) {
const numbers = structured.account_numbers as string[]
return `Följande konton behöver aktiveras: ${numbers.join(', ')}`
}
if (typeof structured.message === 'string' && structured.message.trim()) {
return structured.message
}
}
// Try Zod validation errors
const zodMessage = tryParseZodErrors(obj)
if (zodMessage) return zodMessage
// Try Postgres error code
if (typeof obj.code === 'string' && POSTGRES_ERROR_MAP[obj.code]) {
return POSTGRES_ERROR_MAP[obj.code]
}
// Try known error patterns (e.g. locked period triggers)
for (const field of ['error', 'message'] as const) {
if (typeof obj[field] === 'string' && obj[field].trim()) {
const knownError = tryMatchKnownError(obj[field])
if (knownError) return knownError
}
}
// Try error.message if it's already a good Swedish message
if (typeof obj.error === 'string' && obj.error.trim()) {
if (isSwedishUserMessage(obj.error)) return obj.error
}
if (typeof obj.message === 'string' && obj.message.trim()) {
if (isSwedishUserMessage(obj.message)) return obj.message
}
}
// 3. Error instance
if (error instanceof Error && error.message.trim()) {
const knownError = tryMatchKnownError(error.message)
if (knownError) return knownError
if (isSwedishUserMessage(error.message)) return error.message
}
// 4. HTTP status code map
if (statusCode && HTTP_STATUS_MAP[statusCode]) {
return HTTP_STATUS_MAP[statusCode]
}
// 5. Context-specific fallback
if (context && CONTEXT_FALLBACKS[context]) {
return CONTEXT_FALLBACKS[context]
}
// 6. Generic fallback
return GENERIC_FALLBACK
}
/**
* Helper that parses a Response body and returns a user-friendly error message.
*/
export async function getResponseErrorMessage(
response: Response,
context?: ErrorContext
): Promise<string> {
try {
const body = await response.json()
return getErrorMessage(body, { context, statusCode: response.status })
} catch {
return getErrorMessage(null, { context, statusCode: response.status })
}
}