ade4ad5971
* feat: add fiscal period backward chaining and entry date validation Support creating fiscal periods before the earliest existing period (backward chaining) for backfill scenarios, alongside the existing forward chaining. The engine now validates that entry dates fall within the selected fiscal period, with a Swedish error message. The journal entry form auto-selects the matching period and shows a warning with a CreatePeriodDialog when no period covers the entry date. * feat: support multi-bank-account for imports and reconciliation Plumb a configurable settlement account through the entire bank import pipeline — mapping engine, transaction entries, ingest, and reconciliation — so secondary bank accounts (e.g. 1931, 1932) work correctly instead of hardcoding 1930. Adds a get_unlinked_bank_lines RPC that generalizes the existing get_unlinked_1930_lines with a fallback for backwards compatibility. The bank file import UI now shows a bank account selector when multiple 19xx accounts exist. Also adds default_vat_code/sru_code to account creation and fixes uploadDocument argument order in enable-banking sync.
248 lines
8.0 KiB
TypeScript
248 lines
8.0 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'
|
|
|
|
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.',
|
|
}
|
|
|
|
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.',
|
|
],
|
|
]
|
|
|
|
/**
|
|
* 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: ["msg"] } } shape from validateBody
|
|
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>
|
|
|
|
// 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 })
|
|
}
|
|
}
|