Logs/improved logging (#398)
* feat(mcp): add create_transactions tool with /pending approval gate New MCP tool gnubok_create_transactions stages 1–10 transactions per call as pending_operations of type create_transaction (risk: medium). Each item becomes its own card on /pending; on confirm, the executor inserts the row into transactions with import_source='mcp' so MCP-staged ingestion is distinguishable from PSD2 sync. Designed for skill workflows that pull external data (e.g., Airtable) and want the user to gate the writes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(bas): strip concatenated group headers from corrupted account names A chart-data import bug had glued the next group's header onto the last account in each preceding group across all eight bas-data class files (e.g. account 2670 read "Utgående moms på försäljning inom EU, OSS 27 PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG"). The corrupted names surface in transaction dropdowns, ledgers, SIE exports and årsredovisning, and risk VAT miscategorization on the OSS (2670) and blandad-verksamhet (6999) accounts specifically. - Cleans 69 account_name and 64 description fields across class-1..8 files - Adds a regression test asserting no name contains a concatenated header - Ships an idempotent safety-net migration that updates already-seeded chart_of_accounts rows, gated on the corrupted string so user customizations are preserved Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(errors): add structured error codes and handling for various operations - Introduced a new structured error registry in `structured-errors.ts` to standardize error handling across the application. - Added Swedish and English messages for various error scenarios, including validation, authorization, and bookkeeping errors. - Implemented a client-side error toast in `use-error-toast.ts` to display user-friendly error messages with remediation hints. - Created a wrapper for recording operation outcomes in `record-operation.ts`, enhancing audit capabilities for operations. - Developed a provider call wrapper in `with-provider-call.ts` to handle external HTTP calls with structured logging and error mapping. - Added a new SQL migration to extend the processing history with new event types and aggregate types for better operational telemetry. * Refactor supplier API routes to use context-based logging and error handling - Replaced direct Supabase client usage in GET and POST routes with context-based approach using `withRouteContext`. - Enhanced error handling to provide structured error responses for supplier creation and listing. - Updated logging to include request IDs for better traceability. - Introduced new error codes for supplier-related operations. - Refactored tax deadlines cron job to utilize context and improved error handling. - Updated ESLint configuration to enforce logging practices across API and lib directories. - Enhanced arcim migration extension with structured error handling and logging. - Added classification for provider errors to improve user-facing error messages. - Introduced request ID in extension context for better log correlation. * fix(route-context): update DynamicParams type for improved type safety in route handlers * feat(transactions): add 'create_transaction' operation to PendingOperationType * fix(route): ensure companyId is non-nullable in loadAndDeriveAbsence function * fix(route-context): ensure companyId is always non-null by short-circuiting with COMPANY_CONTEXT_MISSING --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
94f15b9c6c
commit
5725c25bf1
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createTestLogger } from '../logger'
|
||||
|
||||
describe('logger', () => {
|
||||
it('emits records with module + msg + level + ts', () => {
|
||||
const sink: any[] = []
|
||||
const log = createTestLogger('test/module', sink)
|
||||
log.info('hello')
|
||||
|
||||
expect(sink).toHaveLength(1)
|
||||
expect(sink[0]).toMatchObject({
|
||||
level: 'info',
|
||||
module: 'test/module',
|
||||
msg: 'hello',
|
||||
})
|
||||
expect(typeof sink[0].ts).toBe('string')
|
||||
})
|
||||
|
||||
it('merges base context into every record', () => {
|
||||
const sink: any[] = []
|
||||
const log = createTestLogger('m', sink, { requestId: 'req_1' })
|
||||
log.info('hi')
|
||||
expect(sink[0].requestId).toBe('req_1')
|
||||
})
|
||||
|
||||
it('child() returns a logger that merges extra context', () => {
|
||||
const sink: any[] = []
|
||||
const log = createTestLogger('m', sink, { requestId: 'req_1' })
|
||||
const child = log.child({ companyId: 'co_1', userId: 'u_1' })
|
||||
child.warn('oops')
|
||||
expect(sink[0]).toMatchObject({
|
||||
requestId: 'req_1',
|
||||
companyId: 'co_1',
|
||||
userId: 'u_1',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats Error args as the err field with name/message/code', () => {
|
||||
const sink: any[] = []
|
||||
const log = createTestLogger('m', sink)
|
||||
const err = new Error('boom')
|
||||
;(err as any).code = '23505'
|
||||
log.error('insert failed', err)
|
||||
|
||||
expect(sink[0].err).toMatchObject({ name: 'Error', message: 'boom', code: '23505' })
|
||||
})
|
||||
|
||||
it('merges plain-object args into context', () => {
|
||||
const sink: any[] = []
|
||||
const log = createTestLogger('m', sink)
|
||||
log.info('done', { durationMs: 42, status: 200 })
|
||||
expect(sink[0]).toMatchObject({ durationMs: 42, status: 200 })
|
||||
})
|
||||
|
||||
it('redacts sensitive keys recursively', () => {
|
||||
const sink: any[] = []
|
||||
const log = createTestLogger('m', sink)
|
||||
log.info('login', {
|
||||
user: 'alice',
|
||||
headers: { authorization: 'Bearer secret', cookie: 'sess=xxx' },
|
||||
payload: { password: 'hunter2', token: 'tok' },
|
||||
})
|
||||
const rec = sink[0]
|
||||
expect(rec.headers.authorization).toBe('[REDACTED]')
|
||||
expect(rec.headers.cookie).toBe('[REDACTED]')
|
||||
expect(rec.payload.password).toBe('[REDACTED]')
|
||||
expect(rec.payload.token).toBe('[REDACTED]')
|
||||
expect(rec.user).toBe('alice')
|
||||
})
|
||||
|
||||
it('redacts personnummer-shaped strings while preserving UUIDs', () => {
|
||||
const sink: any[] = []
|
||||
const log = createTestLogger('m', sink)
|
||||
log.info('processing for 800101-1234', { uuid: '57484518-3409-4b29-9d23-5d22f08bda63' })
|
||||
expect(sink[0].msg).toBe('[REDACTED]')
|
||||
expect(sink[0].uuid).toBe('57484518-3409-4b29-9d23-5d22f08bda63')
|
||||
})
|
||||
|
||||
it('routes non-object, non-Error args into details', () => {
|
||||
const sink: any[] = []
|
||||
const log = createTestLogger('m', sink)
|
||||
log.warn('legacy', 'string arg', 42)
|
||||
expect(sink[0].details).toEqual(['string arg', 42])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
|
||||
export interface ValidationSuccess<T> {
|
||||
success: true
|
||||
@@ -13,6 +14,28 @@ export interface ValidationFailure {
|
||||
|
||||
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure
|
||||
|
||||
interface ValidationOptions {
|
||||
/** Optional logger; when present, validation failures are logged at warn level. */
|
||||
log?: Logger
|
||||
/** Identifier for the operation/route being validated, included in the log line. */
|
||||
operation?: string
|
||||
}
|
||||
|
||||
function logIssues(
|
||||
options: ValidationOptions | undefined,
|
||||
kind: 'body' | 'query' | 'json',
|
||||
issues: Array<{ field: string; message: string; code: string }> | string,
|
||||
) {
|
||||
if (!options?.log) return
|
||||
options.log.warn('validation failed', {
|
||||
operation: options.operation,
|
||||
kind,
|
||||
...(typeof issues === 'string'
|
||||
? { reason: issues }
|
||||
: { issueCount: issues.length, issues }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a request body against a Zod schema.
|
||||
*
|
||||
@@ -29,11 +52,13 @@ export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure
|
||||
export async function validateBody<T>(
|
||||
request: Request,
|
||||
schema: z.ZodType<T>,
|
||||
options?: ValidationOptions,
|
||||
): Promise<ValidationResult<T>> {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
logIssues(options, 'json', 'Invalid JSON in request body')
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
@@ -55,6 +80,8 @@ export async function validateBody<T>(
|
||||
code: issue.code,
|
||||
}))
|
||||
|
||||
logIssues(options, 'body', errors)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
@@ -84,6 +111,7 @@ export async function validateBody<T>(
|
||||
export function validateQuery<T>(
|
||||
request: Request,
|
||||
schema: z.ZodType<T>,
|
||||
options?: ValidationOptions,
|
||||
): ValidationResult<T> {
|
||||
const url = new URL(request.url)
|
||||
const raw = Object.fromEntries(url.searchParams.entries())
|
||||
@@ -97,6 +125,8 @@ export function validateQuery<T>(
|
||||
code: issue.code,
|
||||
}))
|
||||
|
||||
logIssues(options, 'query', errors)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Sibling of withRouteContext for cron endpoints.
|
||||
*
|
||||
* - Verifies CRON_SECRET via verifyCronSecret(); returns the standard envelope
|
||||
* on failure.
|
||||
* - Generates a parent requestId so every per-item log line for a single run
|
||||
* shares a correlation id you can grep for in Vercel logs.
|
||||
* - Provides a `forEach` helper that runs the iteratee in an isolated try/catch
|
||||
* per item and logs the outcome at info/error level. A single failing item
|
||||
* never aborts the run.
|
||||
*
|
||||
* Usage:
|
||||
* export const GET = withCronContext('cron.invoice-reminders', async (ctx) => {
|
||||
* const reminders = await loadDueReminders()
|
||||
* const summary = await ctx.forEach('reminder', reminders, async (item, itemCtx) => {
|
||||
* await sendReminder(item)
|
||||
* })
|
||||
* return NextResponse.json({ data: summary })
|
||||
* })
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
export interface CronItemContext {
|
||||
/** Per-item requestId, child of the run's parent requestId. */
|
||||
requestId: string
|
||||
log: Logger
|
||||
parentRequestId: string
|
||||
}
|
||||
|
||||
interface CronForEachResult {
|
||||
total: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
failures: Array<{ index: number; error: string }>
|
||||
}
|
||||
|
||||
export interface CronContext {
|
||||
requestId: string
|
||||
log: Logger
|
||||
/**
|
||||
* Iterate items with isolated try/catch + structured per-item logs. The
|
||||
* returned summary is suitable to ship in the response body so an operator
|
||||
* can see how many succeeded/failed at a glance.
|
||||
*/
|
||||
forEach<T>(
|
||||
label: string,
|
||||
items: T[],
|
||||
iteratee: (item: T, itemCtx: CronItemContext) => Promise<void>,
|
||||
): Promise<CronForEachResult>
|
||||
}
|
||||
|
||||
type CronHandler = (request: Request, ctx: CronContext) => Promise<NextResponse | Response>
|
||||
|
||||
function generateRequestId(prefix: 'cron' | 'cron_item' = 'cron'): string {
|
||||
return `${prefix}_${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
export function withCronContext(
|
||||
operation: string,
|
||||
handler: CronHandler,
|
||||
): (request: Request) => Promise<Response> {
|
||||
return async function wrapped(request: Request): Promise<Response> {
|
||||
const requestId = generateRequestId('cron')
|
||||
const start = Date.now()
|
||||
const log = createLogger(`cron/${operation}`, { requestId, operation })
|
||||
|
||||
const authError = verifyCronSecret(request)
|
||||
if (authError) {
|
||||
log.warn('cron auth failed')
|
||||
return errorResponseFromCode('UNAUTHORIZED', log, { requestId })
|
||||
}
|
||||
|
||||
log.info('cron run started')
|
||||
|
||||
const forEach: CronContext['forEach'] = async (label, items, iteratee) => {
|
||||
const result: CronForEachResult = {
|
||||
total: items.length,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
failures: [],
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
const itemRequestId = generateRequestId('cron_item')
|
||||
const itemLog = log.child({ itemRequestId, itemIndex: i, itemLabel: label })
|
||||
const itemCtx: CronItemContext = {
|
||||
requestId: itemRequestId,
|
||||
log: itemLog,
|
||||
parentRequestId: requestId,
|
||||
}
|
||||
|
||||
try {
|
||||
await iteratee(item, itemCtx)
|
||||
result.succeeded++
|
||||
itemLog.info('cron item ok')
|
||||
} catch (err) {
|
||||
result.failed++
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
result.failures.push({ index: i, error: errorMessage })
|
||||
itemLog.error('cron item failed', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const ctx: CronContext = { requestId, log, forEach }
|
||||
|
||||
try {
|
||||
const response = await handler(request, ctx)
|
||||
if (response instanceof Response && !response.headers.get('X-Request-Id')) {
|
||||
response.headers.set('X-Request-Id', requestId)
|
||||
}
|
||||
log.info('cron run completed', {
|
||||
durationMs: Date.now() - start,
|
||||
status: response.status,
|
||||
})
|
||||
return response
|
||||
} catch (err) {
|
||||
log.error('cron run failed', err as Error, { durationMs: Date.now() - start })
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Single wrapper that gives every API route the same shape:
|
||||
*
|
||||
* - generates a request id (`req_<uuid>`) and threads it through the logger
|
||||
* - resolves auth via requireAuth() and (by default) the active companyId
|
||||
* - emits one structured `info` log on completion with duration
|
||||
* - converts any thrown value into the canonical error envelope via
|
||||
* errorResponse(); the request id appears in the response body and the
|
||||
* X-Request-Id response header
|
||||
*
|
||||
* Usage:
|
||||
* export const POST = withRouteContext('invoice.send', async (req, ctx) => {
|
||||
* // ctx.requestId, ctx.log, ctx.user, ctx.supabase, ctx.companyId
|
||||
* const result = await sendInvoice(...)
|
||||
* return NextResponse.json({ data: result })
|
||||
* })
|
||||
*
|
||||
* For dynamic routes the second parameter is the Next.js params promise:
|
||||
* export const POST = withRouteContext('invoice.send', async (req, ctx, { params }) => {
|
||||
* const { id } = await params
|
||||
* ...
|
||||
* })
|
||||
*/
|
||||
|
||||
import type { SupabaseClient, User } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
export interface RouteContext {
|
||||
/** Stable id for this HTTP request — appears in logs, error envelope, X-Request-Id header. */
|
||||
requestId: string
|
||||
/** Logger pre-bound with { requestId, userId, companyId, operation }. */
|
||||
log: Logger
|
||||
/** Authenticated user. Always present — wrapper short-circuits with 401 otherwise. */
|
||||
user: User
|
||||
/** Authenticated Supabase client (request-scoped, RLS active). */
|
||||
supabase: SupabaseClient
|
||||
/**
|
||||
* Resolved active company id. The wrapper short-circuits with
|
||||
* COMPANY_CONTEXT_MISSING before invoking the handler when no company is
|
||||
* resolved, so handlers can treat this as guaranteed non-null. Routes that
|
||||
* need to opt out of the guarantee (e.g. onboarding) shouldn't use
|
||||
* withRouteContext.
|
||||
*/
|
||||
companyId: string
|
||||
}
|
||||
|
||||
interface RouteContextOptions {
|
||||
/**
|
||||
* Defaults to false. When true, the wrapper rejects callers whose role in
|
||||
* the active company is `viewer` (or who have no membership). Mirrors the
|
||||
* existing requireWritePermission() helper so mutating routes can drop two
|
||||
* lines of boilerplate.
|
||||
*/
|
||||
requireWrite?: boolean
|
||||
}
|
||||
|
||||
// Next.js 16 always passes a `{ params: Promise<...> }` second arg to route
|
||||
// handlers — including on non-dynamic routes, where it's `Promise<{}>`. The
|
||||
// generic defaults to that empty shape so static routes type-check without
|
||||
// having to declare any params at the call site.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
type DynamicParams = { params: Promise<Record<string, string | string[]>> } | { params: Promise<{}> }
|
||||
|
||||
type RouteHandler<P extends DynamicParams = { params: Promise<Record<string, never>> }> = (
|
||||
request: Request,
|
||||
ctx: RouteContext,
|
||||
params: P,
|
||||
) => Promise<NextResponse | Response>
|
||||
|
||||
function generateRequestId(): string {
|
||||
// crypto.randomUUID is available in Node 20+/edge runtimes used by Next.js.
|
||||
return `req_${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
export function withRouteContext<P extends DynamicParams = { params: Promise<Record<string, never>> }>(
|
||||
operation: string,
|
||||
handler: RouteHandler<P>,
|
||||
options: RouteContextOptions = {},
|
||||
): (request: Request, params: P) => Promise<Response> {
|
||||
const { requireWrite = false } = options
|
||||
|
||||
return async function wrapped(request: Request, params: P): Promise<Response> {
|
||||
const requestId = generateRequestId()
|
||||
const start = Date.now()
|
||||
const log = createLogger(`api/${operation}`, { requestId, operation })
|
||||
|
||||
try {
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) {
|
||||
log.warn('auth failed', { status: auth.error.status })
|
||||
// Pass through requireAuth's response unchanged for backwards-compat
|
||||
// with existing route tests; only inject the request id header so
|
||||
// support can still trace the request.
|
||||
if (!auth.error.headers.get('X-Request-Id')) {
|
||||
auth.error.headers.set('X-Request-Id', requestId)
|
||||
}
|
||||
return auth.error
|
||||
}
|
||||
|
||||
const { user, supabase } = auth
|
||||
const userLog = log.child({ userId: user.id })
|
||||
|
||||
let companyId: string | null = null
|
||||
try {
|
||||
companyId = await getActiveCompanyId(supabase, user.id)
|
||||
} catch (err) {
|
||||
userLog.error('failed to resolve active company', err as Error)
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
return errorResponseFromCode('COMPANY_CONTEXT_MISSING', userLog, { requestId })
|
||||
}
|
||||
|
||||
if (requireWrite) {
|
||||
// Delegate to the existing helper so tests that already mock it
|
||||
// continue to work. The helper returns its own 403 NextResponse;
|
||||
// we wrap it in our request-id header for traceability.
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) {
|
||||
userLog.warn('write permission denied')
|
||||
if (!writeCheck.response.headers.get('X-Request-Id')) {
|
||||
writeCheck.response.headers.set('X-Request-Id', requestId)
|
||||
}
|
||||
return writeCheck.response
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: RouteContext = {
|
||||
requestId,
|
||||
log: userLog.child({ companyId }),
|
||||
user,
|
||||
supabase,
|
||||
companyId,
|
||||
}
|
||||
|
||||
const response = await handler(request, ctx, params)
|
||||
|
||||
if (response instanceof Response && !response.headers.get('X-Request-Id')) {
|
||||
response.headers.set('X-Request-Id', requestId)
|
||||
}
|
||||
|
||||
ctx.log.info('op completed', {
|
||||
durationMs: Date.now() - start,
|
||||
status: response.status,
|
||||
})
|
||||
return response
|
||||
} catch (err) {
|
||||
log.error('op failed', err as Error, { durationMs: Date.now() - start })
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export const SCOPE_GROUPS = [
|
||||
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// Transactions
|
||||
gnubok_list_uncategorized_transactions: 'transactions:read',
|
||||
gnubok_create_transactions: 'transactions:write',
|
||||
gnubok_categorize_transaction: 'transactions:write',
|
||||
gnubok_receipt_matcher: 'transactions:write',
|
||||
gnubok_get_counterparty_templates: 'transactions:read',
|
||||
|
||||
@@ -44,6 +44,14 @@ describe('BAS_REFERENCE data integrity', () => {
|
||||
expect(withoutDesc).toEqual([])
|
||||
})
|
||||
|
||||
it('no account name or description has a concatenated group header', () => {
|
||||
const headerSuffix = /\s\d{2,}\s+[A-ZÅÄÖ]{2,}/
|
||||
const corrupted = BAS_REFERENCE.filter(
|
||||
(a) => headerSuffix.test(a.account_name) || headerSuffix.test(a.description ?? ''),
|
||||
)
|
||||
expect(corrupted).toEqual([])
|
||||
})
|
||||
|
||||
it('every account has a valid account_type', () => {
|
||||
const validTypes = ['asset', 'liability', 'equity', 'revenue', 'expense', 'untaxed_reserves']
|
||||
for (const account of BAS_REFERENCE) {
|
||||
|
||||
@@ -322,12 +322,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '1099',
|
||||
account_name: 'Ackumulerade avskrivningar på övriga immateriella anläggningstillgångar 11 BYGGNADER OCH MARK',
|
||||
account_name: 'Ackumulerade avskrivningar på övriga immateriella anläggningstillgångar',
|
||||
account_class: 1,
|
||||
account_group: '10',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Ackumulerade avskrivningar på övriga immateriella anläggningstillgångar 11 BYGGNADER OCH MARK',
|
||||
description: 'Ackumulerade avskrivningar på övriga immateriella anläggningstillgångar',
|
||||
sru_code: '7201',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -487,12 +487,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '1188',
|
||||
account_name: 'Förskott för byggnader och mark 12 MASKINER RESPEKTIVE INVENTARIER',
|
||||
account_name: 'Förskott för byggnader och mark',
|
||||
account_class: 1,
|
||||
account_group: '11',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Förskott för byggnader och mark 12 MASKINER RESPEKTIVE INVENTARIER',
|
||||
description: 'Förskott för byggnader och mark',
|
||||
sru_code: '7202',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -872,12 +872,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '1299',
|
||||
account_name: 'Ackumulerade avskrivningar på övriga materiella anläggningstillgångar 13 FINANSIELLA ANLÄGGNINGSTILLGÅNGAR',
|
||||
account_name: 'Ackumulerade avskrivningar på övriga materiella anläggningstillgångar',
|
||||
account_class: 1,
|
||||
account_group: '12',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Ackumulerade avskrivningar på övriga materiella anläggningstillgångar 13 FINANSIELLA ANLÄGGNINGSTILLGÅNGAR',
|
||||
description: 'Ackumulerade avskrivningar på övriga materiella anläggningstillgångar',
|
||||
sru_code: '7202',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1389,12 +1389,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '1389',
|
||||
account_name: 'Ackumulerade nedskrivningar av andra långfristiga fordringar 14 LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN',
|
||||
account_name: 'Ackumulerade nedskrivningar av andra långfristiga fordringar',
|
||||
account_class: 1,
|
||||
account_group: '13',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Ackumulerade nedskrivningar av andra långfristiga fordringar 14 LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN',
|
||||
description: 'Ackumulerade nedskrivningar av andra långfristiga fordringar',
|
||||
sru_code: '7203',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1653,12 +1653,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '1493',
|
||||
account_name: 'Djur som klassificeras som omsättningstillgång 15 KUNDFORDRINGAR',
|
||||
account_name: 'Djur som klassificeras som omsättningstillgång',
|
||||
account_class: 1,
|
||||
account_group: '14',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Djur som klassificeras som omsättningstillgång 15 KUNDFORDRINGAR',
|
||||
description: 'Djur som klassificeras som omsättningstillgång',
|
||||
sru_code: '7210',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1939,12 +1939,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '1573',
|
||||
account_name: 'Kundfordringar hos övriga företag som det finns ett ägarintresse i 16 ÖVRIGA KORTFRISTIGA FORDRINGAR',
|
||||
account_name: 'Kundfordringar hos övriga företag som det finns ett ägarintresse i',
|
||||
account_class: 1,
|
||||
account_group: '15',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kundfordringar hos övriga företag som det finns ett ägarintresse i 16 ÖVRIGA KORTFRISTIGA FORDRINGAR',
|
||||
description: 'Kundfordringar hos övriga företag som det finns ett ägarintresse i',
|
||||
sru_code: '7211',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -2258,12 +2258,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '1690',
|
||||
account_name: 'Fordringar för tecknat men ej inbetalt aktiekapital 17 FÖRUTBETALDA KOSTNADER OCH UPPLUPNA INTÄKTER',
|
||||
account_name: 'Fordringar för tecknat men ej inbetalt aktiekapital',
|
||||
account_class: 1,
|
||||
account_group: '16',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Fordringar för tecknat men ej inbetalt aktiekapital 17 FÖRUTBETALDA KOSTNADER OCH UPPLUPNA INTÄKTER',
|
||||
description: 'Fordringar för tecknat men ej inbetalt aktiekapital',
|
||||
sru_code: '7212',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -2357,7 +2357,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '1790',
|
||||
account_name: 'Övriga förutbetalda kostnader och upplupna intäkter 18 KORTFRISTIGA PLACERINGAR',
|
||||
account_name: 'Övriga förutbetalda kostnader och upplupna intäkter',
|
||||
account_class: 1,
|
||||
account_group: '17',
|
||||
account_type: 'asset',
|
||||
@@ -2445,12 +2445,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '1890',
|
||||
account_name: 'Nedskrivning av kortfristiga placeringar 19 KASSA OCH BANK',
|
||||
account_name: 'Nedskrivning av kortfristiga placeringar',
|
||||
account_class: 1,
|
||||
account_group: '18',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Nedskrivning av kortfristiga placeringar 19 KASSA OCH BANK',
|
||||
description: 'Nedskrivning av kortfristiga placeringar',
|
||||
sru_code: '7212',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -2621,12 +2621,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '1990',
|
||||
account_name: 'Redovisningsmedel 20 EGET KAPITAL',
|
||||
account_name: 'Redovisningsmedel',
|
||||
account_class: 1,
|
||||
account_group: '19',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Redovisningsmedel 20 EGET KAPITAL',
|
||||
description: 'Redovisningsmedel',
|
||||
sru_code: '7212',
|
||||
k2_excluded: false,
|
||||
},
|
||||
|
||||
@@ -608,7 +608,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '2099',
|
||||
account_name: 'Årets resultat 21 OBESKATTADE RESERVER',
|
||||
account_name: 'Årets resultat',
|
||||
account_class: 2,
|
||||
account_group: '20',
|
||||
account_type: 'equity',
|
||||
@@ -872,12 +872,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '2199',
|
||||
account_name: 'Övriga obeskattade reserver 22 AVSÄTTNINGAR',
|
||||
account_name: 'Övriga obeskattade reserver',
|
||||
account_class: 2,
|
||||
account_group: '21',
|
||||
account_type: 'untaxed_reserves',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga obeskattade reserver 22 AVSÄTTNINGAR',
|
||||
description: 'Övriga obeskattade reserver',
|
||||
sru_code: '7230',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -960,12 +960,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '2290',
|
||||
account_name: 'Övriga avsättningar 23 LÅNGFRISTIGA SKULDER',
|
||||
account_name: 'Övriga avsättningar',
|
||||
account_class: 2,
|
||||
account_group: '22',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga avsättningar 23 LÅNGFRISTIGA SKULDER',
|
||||
description: 'Övriga avsättningar',
|
||||
sru_code: '7230',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1279,12 +1279,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '2399',
|
||||
account_name: 'Övriga långfristiga skulder 24 KORTFRISTIGA SKULDER TILL KREDITINSTITUT, KUNDER OCH LEVERANTÖRER',
|
||||
account_name: 'Övriga långfristiga skulder',
|
||||
account_class: 2,
|
||||
account_group: '23',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga långfristiga skulder 24 KORTFRISTIGA SKULDER TILL KREDITINSTITUT, KUNDER OCH LEVERANTÖRER',
|
||||
description: 'Övriga långfristiga skulder',
|
||||
sru_code: '7230',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1620,12 +1620,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '2499',
|
||||
account_name: 'Andra övriga kortfristiga skulder 25 SKATTESKULDER',
|
||||
account_name: 'Andra övriga kortfristiga skulder',
|
||||
account_class: 2,
|
||||
account_group: '24',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
description: 'Andra övriga kortfristiga skulder 25 SKATTESKULDER',
|
||||
description: 'Andra övriga kortfristiga skulder',
|
||||
sru_code: '7230',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1697,12 +1697,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '2518',
|
||||
account_name: 'Betald F-skatt 26 MOMS OCH PUNKTSKATTER',
|
||||
account_name: 'Betald F-skatt',
|
||||
account_class: 2,
|
||||
account_group: '25',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
description: 'Betald F-skatt 26 MOMS OCH PUNKTSKATTER',
|
||||
description: 'Betald F-skatt',
|
||||
sru_code: '7231',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -2082,12 +2082,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '2670',
|
||||
account_name: 'Utgående moms på försäljning inom EU, OSS 27 PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG',
|
||||
account_name: 'Utgående moms på försäljning inom EU, OSS',
|
||||
account_class: 2,
|
||||
account_group: '26',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
description: 'Utgående moms på försäljning inom EU, OSS 27 PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG',
|
||||
description: 'Utgående moms på försäljning inom EU, OSS',
|
||||
sru_code: '7231',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -2258,12 +2258,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '2799',
|
||||
account_name: 'Övriga löneavdrag 28 ÖVRIGA KORTFRISTIGA SKULDER',
|
||||
account_name: 'Övriga löneavdrag',
|
||||
account_class: 2,
|
||||
account_group: '27',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga löneavdrag 28 ÖVRIGA KORTFRISTIGA SKULDER',
|
||||
description: 'Övriga löneavdrag',
|
||||
sru_code: '7231',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -2599,12 +2599,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '2899',
|
||||
account_name: 'Övriga kortfristiga skulder 29 UPPLUPNA KOSTNADER OCH FÖRUTBETALDA INTÄKTER',
|
||||
account_name: 'Övriga kortfristiga skulder',
|
||||
account_class: 2,
|
||||
account_group: '28',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga kortfristiga skulder 29 UPPLUPNA KOSTNADER OCH FÖRUTBETALDA INTÄKTER',
|
||||
description: 'Övriga kortfristiga skulder',
|
||||
sru_code: '7231',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -2907,12 +2907,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '2999',
|
||||
account_name: 'OBS-konto 30 HUVUDINTÄKTER',
|
||||
account_name: 'OBS-konto',
|
||||
account_class: 2,
|
||||
account_group: '29',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
description: 'OBS-konto 30 HUVUDINTÄKTER',
|
||||
description: 'OBS-konto',
|
||||
sru_code: '7231',
|
||||
k2_excluded: false,
|
||||
},
|
||||
|
||||
@@ -223,12 +223,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '3404',
|
||||
account_name: 'Egna uttag, momsfria 35 FAKTURERADE KOSTNADER',
|
||||
account_name: 'Egna uttag, momsfria',
|
||||
account_class: 3,
|
||||
account_group: '34',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'credit',
|
||||
description: 'Egna uttag, momsfria 35 FAKTURERADE KOSTNADER',
|
||||
description: 'Egna uttag, momsfria',
|
||||
sru_code: '7310',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -421,12 +421,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '3590',
|
||||
account_name: 'Övriga fakturerade kostnader 36 RÖRELSENS SIDOINTÄKTER',
|
||||
account_name: 'Övriga fakturerade kostnader',
|
||||
account_class: 3,
|
||||
account_group: '35',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga fakturerade kostnader 36 RÖRELSENS SIDOINTÄKTER',
|
||||
description: 'Övriga fakturerade kostnader',
|
||||
sru_code: '7310',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -575,12 +575,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '3690',
|
||||
account_name: 'Övriga sidointäkter 37 INTÄKTSKORRIGERINGAR',
|
||||
account_name: 'Övriga sidointäkter',
|
||||
account_class: 3,
|
||||
account_group: '36',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga sidointäkter 37 INTÄKTSKORRIGERINGAR',
|
||||
description: 'Övriga sidointäkter',
|
||||
sru_code: '7310',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -685,12 +685,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '3790',
|
||||
account_name: 'Övriga intäktskorrigeringar 38 AKTIVERAT ARBETE FÖR EGEN RÄKNING',
|
||||
account_name: 'Övriga intäktskorrigeringar',
|
||||
account_class: 3,
|
||||
account_group: '37',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga intäktskorrigeringar 38 AKTIVERAT ARBETE FÖR EGEN RÄKNING',
|
||||
description: 'Övriga intäktskorrigeringar',
|
||||
sru_code: '7310',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -729,12 +729,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '3870',
|
||||
account_name: 'Aktiverat arbete (personal) 39 ÖVRIGA RÖRELSEINTÄKTER',
|
||||
account_name: 'Aktiverat arbete (personal)',
|
||||
account_class: 3,
|
||||
account_group: '38',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'credit',
|
||||
description: 'Aktiverat arbete (personal) 39 ÖVRIGA RÖRELSEINTÄKTER',
|
||||
description: 'Aktiverat arbete (personal)',
|
||||
sru_code: '7310',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1092,12 +1092,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '3999',
|
||||
account_name: 'Övriga rörelseintäkter 40 INKÖP AV HANDELSVAROR',
|
||||
account_name: 'Övriga rörelseintäkter',
|
||||
account_class: 3,
|
||||
account_group: '39',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga rörelseintäkter 40 INKÖP AV HANDELSVAROR',
|
||||
description: 'Övriga rörelseintäkter',
|
||||
sru_code: '7310',
|
||||
k2_excluded: false,
|
||||
},
|
||||
|
||||
@@ -201,12 +201,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '4099',
|
||||
account_name: 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB',
|
||||
account_name: 'Övriga reduktioner av inköpspriser (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB',
|
||||
description: 'Övriga reduktioner av inköpspriser (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -245,12 +245,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '4212',
|
||||
account_name: 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_name: 'Sålda handelsvaror negativ VMB 25 %',
|
||||
account_class: 4,
|
||||
account_group: '42',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
description: 'Sålda handelsvaror negativ VMB 25 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -267,12 +267,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '4310',
|
||||
account_name: 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_name: 'Inköp av råvaror och material i Sverige',
|
||||
account_class: 4,
|
||||
account_group: '43',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
description: 'Inköp av råvaror och material i Sverige',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -366,12 +366,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '4427',
|
||||
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -564,12 +564,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '4547',
|
||||
account_name: 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_name: 'Import av råvaror och material, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
description: 'Import av råvaror och material, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -597,12 +597,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '4670',
|
||||
account_name: 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_name: 'Inköp av legoarbeten',
|
||||
account_class: 4,
|
||||
account_group: '46',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
description: 'Inköp av legoarbeten',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -652,12 +652,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '4739',
|
||||
account_name: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_name: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
description: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -718,12 +718,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '4890',
|
||||
account_name: 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN',
|
||||
account_name: 'Övriga produktionskostnader (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN',
|
||||
description: 'Övriga produktionskostnader (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -905,12 +905,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '4988',
|
||||
account_name: 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER',
|
||||
account_name: 'Återföring av nedskrivning av värdepapper (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER',
|
||||
description: 'Återföring av nedskrivning av värdepapper (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
|
||||
@@ -190,7 +190,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '5090',
|
||||
account_name: 'Övriga lokalkostnader 51 FASTIGHETSKOSTNADER',
|
||||
account_name: 'Övriga lokalkostnader',
|
||||
account_class: 5,
|
||||
account_group: '50',
|
||||
account_type: 'expense',
|
||||
@@ -410,12 +410,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '5198',
|
||||
account_name: 'Övriga fastighetskostnader 52 HYRA AV ANLÄGGNINGSTILLGÅNGAR',
|
||||
account_name: 'Övriga fastighetskostnader',
|
||||
account_class: 5,
|
||||
account_group: '51',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga fastighetskostnader 52 HYRA AV ANLÄGGNINGSTILLGÅNGAR',
|
||||
description: 'Övriga fastighetskostnader',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -465,12 +465,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '5290',
|
||||
account_name: 'Hyra av övriga anläggningstillgångar, ej datorer och fordon 53 ENERGIKOSTNADER FÖR DRIFT (EJ RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_name: 'Hyra av övriga anläggningstillgångar, ej datorer och fordon',
|
||||
account_class: 5,
|
||||
account_group: '52',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Hyra av övriga anläggningstillgångar, ej datorer och fordon 53 ENERGIKOSTNADER FÖR DRIFT (EJ RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
description: 'Hyra av övriga anläggningstillgångar, ej datorer och fordon',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -575,12 +575,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '5390',
|
||||
account_name: 'Övriga energikostnader för drift (ej råvaror och förnödenheter) 54 FÖRBRUKNINGSINVENTARIER OCH FÖRBRUKNINGSMATERIAL',
|
||||
account_name: 'Övriga energikostnader för drift (ej råvaror och förnödenheter)',
|
||||
account_class: 5,
|
||||
account_group: '53',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga energikostnader för drift (ej råvaror och förnödenheter) 54 FÖRBRUKNINGSINVENTARIER OCH FÖRBRUKNINGSMATERIAL',
|
||||
description: 'Övriga energikostnader för drift (ej råvaror och förnödenheter)',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -674,12 +674,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '5480',
|
||||
account_name: 'Arbetskläder och skyddsmaterial 55 REPARATION OCH UNDERHÅLL',
|
||||
account_name: 'Arbetskläder och skyddsmaterial',
|
||||
account_class: 5,
|
||||
account_group: '54',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Arbetskläder och skyddsmaterial 55 REPARATION OCH UNDERHÅLL',
|
||||
description: 'Arbetskläder och skyddsmaterial',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -751,12 +751,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '5590',
|
||||
account_name: 'Övriga kostnader för reparation och underhåll 56 KOSTNADER FÖR TRANSPORTMEDEL',
|
||||
account_name: 'Övriga kostnader för reparation och underhåll',
|
||||
account_class: 5,
|
||||
account_group: '55',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga kostnader för reparation och underhåll 56 KOSTNADER FÖR TRANSPORTMEDEL',
|
||||
description: 'Övriga kostnader för reparation och underhåll',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1345,12 +1345,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '5699',
|
||||
account_name: 'Övriga kostnader för övriga transportmedel 57 FRAKTER OCH TRANSPORTER',
|
||||
account_name: 'Övriga kostnader för övriga transportmedel',
|
||||
account_class: 5,
|
||||
account_group: '56',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga kostnader för övriga transportmedel 57 FRAKTER OCH TRANSPORTER',
|
||||
description: 'Övriga kostnader för övriga transportmedel',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1455,12 +1455,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '5790',
|
||||
account_name: 'Övriga kostnader för frakter och transporter 58 RESEKOSTNADER',
|
||||
account_name: 'Övriga kostnader för frakter och transporter',
|
||||
account_class: 5,
|
||||
account_group: '57',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga kostnader för frakter och transporter 58 RESEKOSTNADER',
|
||||
description: 'Övriga kostnader för frakter och transporter',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1532,12 +1532,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '5890',
|
||||
account_name: 'Övriga resekostnader 59 REKLAM OCH PR',
|
||||
account_name: 'Övriga resekostnader',
|
||||
account_class: 5,
|
||||
account_group: '58',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga resekostnader 59 REKLAM OCH PR',
|
||||
description: 'Övriga resekostnader',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1664,12 +1664,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '5990',
|
||||
account_name: 'Övriga kostnader för reklam och PR 60 ÖVRIGA FÖRSÄLJNINGSKOSTNADER',
|
||||
account_name: 'Övriga kostnader för reklam och PR',
|
||||
account_class: 5,
|
||||
account_group: '59',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga kostnader för reklam och PR 60 ÖVRIGA FÖRSÄLJNINGSKOSTNADER',
|
||||
description: 'Övriga kostnader för reklam och PR',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
|
||||
@@ -201,12 +201,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '6090',
|
||||
account_name: 'Övriga försäljningskostnader 61 KONTORSMATERIAL OCH TRYCKSAKER',
|
||||
account_name: 'Övriga försäljningskostnader',
|
||||
account_class: 6,
|
||||
account_group: '60',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga försäljningskostnader 61 KONTORSMATERIAL OCH TRYCKSAKER',
|
||||
description: 'Övriga försäljningskostnader',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -234,7 +234,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '6150',
|
||||
account_name: 'Trycksaker 62 TELE, DATA OCH POST',
|
||||
account_name: 'Trycksaker',
|
||||
account_class: 6,
|
||||
account_group: '61',
|
||||
account_type: 'expense',
|
||||
@@ -322,12 +322,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '6290',
|
||||
account_name: 'Övriga tele-, data- och postkostnader 63 FÖRETAGSFÖRSÄKRINGAR OCH ÖVRIGA RISKKOSTNADER',
|
||||
account_name: 'Övriga tele-, data- och postkostnader',
|
||||
account_class: 6,
|
||||
account_group: '62',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga tele-, data- och postkostnader 63 FÖRETAGSFÖRSÄKRINGAR OCH ÖVRIGA RISKKOSTNADER',
|
||||
description: 'Övriga tele-, data- och postkostnader',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -520,12 +520,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '6392',
|
||||
account_name: 'Övriga riskkostnader, ej avdragsgilla 64 FÖRVALTNINGSKOSTNADER',
|
||||
account_name: 'Övriga riskkostnader, ej avdragsgilla',
|
||||
account_class: 6,
|
||||
account_group: '63',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga riskkostnader, ej avdragsgilla 64 FÖRVALTNINGSKOSTNADER',
|
||||
description: 'Övriga riskkostnader, ej avdragsgilla',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -630,12 +630,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '6490',
|
||||
account_name: 'Övriga förvaltningskostnader 65 ÖVRIGA EXTERNA TJÄNSTER',
|
||||
account_name: 'Övriga förvaltningskostnader',
|
||||
account_class: 6,
|
||||
account_group: '64',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga förvaltningskostnader 65 ÖVRIGA EXTERNA TJÄNSTER',
|
||||
description: 'Övriga förvaltningskostnader',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -817,12 +817,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '6590',
|
||||
account_name: 'Övriga externa tjänster 67 SÄRSKILT FÖR IDEELLA FÖRENINGAR OCH STIFTELSER',
|
||||
account_name: 'Övriga externa tjänster',
|
||||
account_class: 6,
|
||||
account_group: '65',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga externa tjänster 67 SÄRSKILT FÖR IDEELLA FÖRENINGAR OCH STIFTELSER',
|
||||
description: 'Övriga externa tjänster',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -839,12 +839,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '6710',
|
||||
account_name: 'Lämnade bidrag 68 INHYRD PERSONAL',
|
||||
account_name: 'Lämnade bidrag',
|
||||
account_class: 6,
|
||||
account_group: '67',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Lämnade bidrag 68 INHYRD PERSONAL',
|
||||
description: 'Lämnade bidrag',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -949,12 +949,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '6890',
|
||||
account_name: 'Övrig inhyrd personal 69 ÖVRIGA EXTERNA KOSTNADER',
|
||||
account_name: 'Övrig inhyrd personal',
|
||||
account_class: 6,
|
||||
account_group: '68',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övrig inhyrd personal 69 ÖVRIGA EXTERNA KOSTNADER',
|
||||
description: 'Övrig inhyrd personal',
|
||||
sru_code: '7321',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1147,12 +1147,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '6999',
|
||||
account_name: 'Ingående moms, blandad verksamhet 70 LÖNER TILL KOLLEKTIVANSTÄLLDA',
|
||||
account_name: 'Ingående moms, blandad verksamhet',
|
||||
account_class: 6,
|
||||
account_group: '69',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Ingående moms, blandad verksamhet 70 LÖNER TILL KOLLEKTIVANSTÄLLDA',
|
||||
description: 'Ingående moms, blandad verksamhet',
|
||||
sru_code: '7330',
|
||||
k2_excluded: false,
|
||||
},
|
||||
|
||||
@@ -212,7 +212,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '7090',
|
||||
account_name: 'Förändring av semesterlöneskuld 72 LÖNER TILL TJÄNSTEMÄN OCH FÖRETAGSLEDARE',
|
||||
account_name: 'Förändring av semesterlöneskuld',
|
||||
account_class: 7,
|
||||
account_group: '70',
|
||||
account_type: 'expense',
|
||||
@@ -575,12 +575,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '7292',
|
||||
account_name: 'Förändring av semesterlöneskuld till företagsledare 73 KOSTNADSERSÄTTNINGAR OCH FÖRMÅNER',
|
||||
account_name: 'Förändring av semesterlöneskuld till företagsledare',
|
||||
account_class: 7,
|
||||
account_group: '72',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Förändring av semesterlöneskuld till företagsledare 73 KOSTNADSERSÄTTNINGAR OCH FÖRMÅNER',
|
||||
description: 'Förändring av semesterlöneskuld till företagsledare',
|
||||
sru_code: '7322',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -960,12 +960,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '7392',
|
||||
account_name: 'Kostnad för förmån av hushållsnära tjänster 74 PENSIONSKOSTNADER',
|
||||
account_name: 'Kostnad för förmån av hushållsnära tjänster',
|
||||
account_class: 7,
|
||||
account_group: '73',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnad för förmån av hushållsnära tjänster 74 PENSIONSKOSTNADER',
|
||||
description: 'Kostnad för förmån av hushållsnära tjänster',
|
||||
sru_code: '7322',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1125,12 +1125,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '7490',
|
||||
account_name: 'Övriga pensionskostnader 75 SOCIALA OCH ANDRA AVGIFTER ENLIGT LAG OCH AVTAL',
|
||||
account_name: 'Övriga pensionskostnader',
|
||||
account_class: 7,
|
||||
account_group: '74',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga pensionskostnader 75 SOCIALA OCH ANDRA AVGIFTER ENLIGT LAG OCH AVTAL',
|
||||
description: 'Övriga pensionskostnader',
|
||||
sru_code: '7322',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1411,12 +1411,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '7590',
|
||||
account_name: 'Övriga sociala och andra avgifter enligt lag och avtal 76 ÖVRIGA PERSONALKOSTNADER',
|
||||
account_name: 'Övriga sociala och andra avgifter enligt lag och avtal',
|
||||
account_class: 7,
|
||||
account_group: '75',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga sociala och andra avgifter enligt lag och avtal 76 ÖVRIGA PERSONALKOSTNADER',
|
||||
description: 'Övriga sociala och andra avgifter enligt lag och avtal',
|
||||
sru_code: '7322',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1609,12 +1609,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '7699',
|
||||
account_name: 'Övriga personalkostnader 77 NEDSKRIVNINGAR OCH ÅTERFÖRING AV NEDSKRIVNINGAR',
|
||||
account_name: 'Övriga personalkostnader',
|
||||
account_class: 7,
|
||||
account_group: '76',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga personalkostnader 77 NEDSKRIVNINGAR OCH ÅTERFÖRING AV NEDSKRIVNINGAR',
|
||||
description: 'Övriga personalkostnader',
|
||||
sru_code: '7322',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1763,12 +1763,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '7790',
|
||||
account_name: 'Återföring av nedskrivningar av vissa omsättningstillgångar 78 AVSKRIVNINGAR ENLIGT PLAN',
|
||||
account_name: 'Återföring av nedskrivningar av vissa omsättningstillgångar',
|
||||
account_class: 7,
|
||||
account_group: '77',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Återföring av nedskrivningar av vissa omsättningstillgångar 78 AVSKRIVNINGAR ENLIGT PLAN',
|
||||
description: 'Återföring av nedskrivningar av vissa omsättningstillgångar',
|
||||
sru_code: '7325',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1972,12 +1972,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '7840',
|
||||
account_name: 'Avskrivningar på förbättringsutgifter på annans fastighet 79 ÖVRIGA RÖRELSEKOSTNADER',
|
||||
account_name: 'Avskrivningar på förbättringsutgifter på annans fastighet',
|
||||
account_class: 7,
|
||||
account_group: '78',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Avskrivningar på förbättringsutgifter på annans fastighet 79 ÖVRIGA RÖRELSEKOSTNADER',
|
||||
description: 'Avskrivningar på förbättringsutgifter på annans fastighet',
|
||||
sru_code: '7325',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -2049,12 +2049,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '7990',
|
||||
account_name: 'Övriga rörelsekostnader 80 RESULTAT FRÅN ANDELAR I KONCERNFÖRETAG',
|
||||
account_name: 'Övriga rörelsekostnader',
|
||||
account_class: 7,
|
||||
account_group: '79',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga rörelsekostnader 80 RESULTAT FRÅN ANDELAR I KONCERNFÖRETAG',
|
||||
description: 'Övriga rörelsekostnader',
|
||||
sru_code: '7360',
|
||||
k2_excluded: false,
|
||||
},
|
||||
|
||||
@@ -135,12 +135,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '8087',
|
||||
account_name: 'Återföringar av nedskrivningar av långfristiga fordringar hos dotterföretag 81 RESULTAT FRÅN ANDELAR I INTRESSEFÖRETAG OCH GEMENSAMT STYRDA FÖRETAG SAMT ÖVRIGA FÖRETAG SOM DET FINNS ETT ÄGARINTRESSE I',
|
||||
account_name: 'Återföringar av nedskrivningar av långfristiga fordringar hos dotterföretag',
|
||||
account_class: 8,
|
||||
account_group: '80',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'credit',
|
||||
description: 'Återföringar av nedskrivningar av långfristiga fordringar hos dotterföretag 81 RESULTAT FRÅN ANDELAR I INTRESSEFÖRETAG OCH GEMENSAMT STYRDA FÖRETAG SAMT ÖVRIGA FÖRETAG SOM DET FINNS ETT ÄGARINTRESSE I',
|
||||
description: 'Återföringar av nedskrivningar av långfristiga fordringar hos dotterföretag',
|
||||
sru_code: '7370',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -454,12 +454,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '8187',
|
||||
account_name: 'Återföringar av nedskrivningar av långfristiga fordringar hos övriga företag som det finns ett ägarintresse i 82 RESULTAT FRÅN ÖVRIGA VÄRDEPAPPER OCH LÅNGFRISTIGA FORDRINGAR (ANLÄGGNINGSTILLGÅNGAR)',
|
||||
account_name: 'Återföringar av nedskrivningar av långfristiga fordringar hos övriga företag som det finns ett ägarintresse i',
|
||||
account_class: 8,
|
||||
account_group: '81',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'credit',
|
||||
description: 'Återföringar av nedskrivningar av långfristiga fordringar hos övriga företag som det finns ett ägarintresse i 82 RESULTAT FRÅN ÖVRIGA VÄRDEPAPPER OCH LÅNGFRISTIGA FORDRINGAR (ANLÄGGNINGSTILLGÅNGAR)',
|
||||
description: 'Återföringar av nedskrivningar av långfristiga fordringar hos övriga företag som det finns ett ägarintresse i',
|
||||
sru_code: '7370',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -795,12 +795,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '8295',
|
||||
account_name: 'Orealiserade värdeförändringar på derivatinstrument 83 ÖVRIGA RÄNTEINTÄKTER OCH LIKNANDE RESULTATPOSTER',
|
||||
account_name: 'Orealiserade värdeförändringar på derivatinstrument',
|
||||
account_class: 8,
|
||||
account_group: '82',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'credit',
|
||||
description: 'Orealiserade värdeförändringar på derivatinstrument 83 ÖVRIGA RÄNTEINTÄKTER OCH LIKNANDE RESULTATPOSTER',
|
||||
description: 'Orealiserade värdeförändringar på derivatinstrument',
|
||||
sru_code: '7370',
|
||||
k2_excluded: true,
|
||||
},
|
||||
@@ -1037,12 +1037,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '8390',
|
||||
account_name: 'Övriga finansiella intäkter 84 RÄNTEKOSTNADER OCH LIKNANDE RESULTATPOSTER',
|
||||
account_name: 'Övriga finansiella intäkter',
|
||||
account_class: 8,
|
||||
account_group: '83',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga finansiella intäkter 84 RÄNTEKOSTNADER OCH LIKNANDE RESULTATPOSTER',
|
||||
description: 'Övriga finansiella intäkter',
|
||||
sru_code: '7370',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1356,12 +1356,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '8491',
|
||||
account_name: 'Erhållet ackord på skulder till kreditinstitut m.m. 88 BOKSLUTSDISPOSITIONER',
|
||||
account_name: 'Erhållet ackord på skulder till kreditinstitut m.m.',
|
||||
account_class: 8,
|
||||
account_group: '84',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållet ackord på skulder till kreditinstitut m.m. 88 BOKSLUTSDISPOSITIONER',
|
||||
description: 'Erhållet ackord på skulder till kreditinstitut m.m.',
|
||||
sru_code: '7323',
|
||||
k2_excluded: false,
|
||||
},
|
||||
@@ -1587,12 +1587,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
|
||||
},
|
||||
{
|
||||
account_number: '8899',
|
||||
account_name: 'Övriga bokslutsdispositioner 89 SKATTER OCH ÅRETS RESULTAT',
|
||||
account_name: 'Övriga bokslutsdispositioner',
|
||||
account_class: 8,
|
||||
account_group: '88',
|
||||
account_type: 'revenue',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga bokslutsdispositioner 89 SKATTER OCH ÅRETS RESULTAT',
|
||||
description: 'Övriga bokslutsdispositioner',
|
||||
sru_code: '7380',
|
||||
k2_excluded: false,
|
||||
},
|
||||
|
||||
@@ -123,11 +123,22 @@ describe('Supplier Invoice Core Handler', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[supplier-invoice-handler]',
|
||||
'Failed to create registration journal entry:',
|
||||
expect.any(Error)
|
||||
)
|
||||
// Logger emits a structured error line; assert the handler logged the
|
||||
// failure with the right module prefix and an Error somewhere in the args.
|
||||
const calls = consoleSpy.mock.calls
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
expect(calls.some((c) => String(c[0]).includes('[supplier-invoice-handler]'))).toBe(true)
|
||||
expect(
|
||||
calls.some((c) =>
|
||||
c.some(
|
||||
(arg) =>
|
||||
arg instanceof Error ||
|
||||
(typeof arg === 'object' &&
|
||||
arg !== null &&
|
||||
(arg as { message?: unknown }).message === 'No fiscal period'),
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ZodError, z } from 'zod'
|
||||
import {
|
||||
errorResponse,
|
||||
errorResponseFromCode,
|
||||
type ErrorEnvelope,
|
||||
} from '../get-structured-error'
|
||||
import { getErrorEntry, listErrorCodes } from '../structured-errors'
|
||||
import {
|
||||
AccountsNotInChartError,
|
||||
EntryDateOutsideFiscalPeriodError,
|
||||
JournalEntryNotBalancedError,
|
||||
} from '@/lib/bookkeeping/errors'
|
||||
|
||||
const noopLogger = {
|
||||
error: () => {},
|
||||
}
|
||||
|
||||
async function readEnvelope(res: Response): Promise<ErrorEnvelope> {
|
||||
return (await res.json()) as ErrorEnvelope
|
||||
}
|
||||
|
||||
describe('structured-errors registry', () => {
|
||||
it('has entries for the canonical generic codes', () => {
|
||||
for (const code of [
|
||||
'INTERNAL_ERROR',
|
||||
'VALIDATION_ERROR',
|
||||
'UNAUTHORIZED',
|
||||
'FORBIDDEN',
|
||||
'NOT_FOUND',
|
||||
'CONFLICT',
|
||||
'RATE_LIMITED',
|
||||
'COMPANY_CONTEXT_MISSING',
|
||||
]) {
|
||||
const entry = getErrorEntry(code)
|
||||
expect(entry, `missing entry for ${code}`).toBeDefined()
|
||||
expect(entry?.message_sv).toBeTruthy()
|
||||
expect(entry?.message_en).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('listErrorCodes returns at least the bookkeeping + generic + provider codes', () => {
|
||||
const codes = listErrorCodes()
|
||||
expect(codes.length).toBeGreaterThan(20)
|
||||
expect(codes).toContain('JOURNAL_ENTRY_NOT_BALANCED')
|
||||
expect(codes).toContain('PROVIDER_AUTH_EXPIRED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('errorResponse', () => {
|
||||
it('maps BookkeepingError to its code + structured details + Swedish message', async () => {
|
||||
const err = new JournalEntryNotBalancedError(100, 90)
|
||||
const res = errorResponse(err, noopLogger, { requestId: 'req_1' })
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.headers.get('X-Request-Id')).toBe('req_1')
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('JOURNAL_ENTRY_NOT_BALANCED')
|
||||
expect(body.error.message).toMatch(/balanserar inte/i)
|
||||
expect(body.error.requestId).toBe('req_1')
|
||||
expect(body.error.details).toMatchObject({ totalDebit: 100, totalCredit: 90 })
|
||||
})
|
||||
|
||||
it('preserves AccountsNotInChartError details', async () => {
|
||||
const err = new AccountsNotInChartError(['1930', '2641'])
|
||||
const res = errorResponse(err, noopLogger, { requestId: 'req_2' })
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
|
||||
expect(body.error.details).toMatchObject({ account_numbers: ['1930', '2641'] })
|
||||
})
|
||||
|
||||
it('maps ZodError to VALIDATION_ERROR with field issues', async () => {
|
||||
let zodErr: ZodError
|
||||
try {
|
||||
z.object({ name: z.string().min(1) }).parse({ name: '' })
|
||||
throw new Error('should have thrown')
|
||||
} catch (e) {
|
||||
zodErr = e as ZodError
|
||||
}
|
||||
const res = errorResponse(zodErr, noopLogger, { requestId: 'req_3' })
|
||||
expect(res.status).toBe(400)
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details).toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ field: 'name' }),
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
it('maps Postgres unique violation to VALIDATION_ERROR with pgCode', async () => {
|
||||
const pgErr = Object.assign(new Error('duplicate key'), { code: '23505' })
|
||||
const res = errorResponse(pgErr, noopLogger, { requestId: 'req_4' })
|
||||
expect(res.status).toBe(400)
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details).toMatchObject({ pgCode: '23505' })
|
||||
})
|
||||
|
||||
it('falls back to INTERNAL_ERROR for unknown shapes', async () => {
|
||||
const res = errorResponse(new Error('boom'), noopLogger, { requestId: 'req_5' })
|
||||
expect(res.status).toBe(500)
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('INTERNAL_ERROR')
|
||||
expect(body.error.requestId).toBe('req_5')
|
||||
})
|
||||
|
||||
it('passes through entries with remediation hints', async () => {
|
||||
const res = errorResponseFromCode('PROVIDER_AUTH_EXPIRED', noopLogger, { requestId: 'req_6' })
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('PROVIDER_AUTH_EXPIRED')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('errorResponseFromCode emits requestId in header', () => {
|
||||
const res = errorResponseFromCode('NOT_FOUND', noopLogger, { requestId: 'req_7' })
|
||||
expect(res.headers.get('X-Request-Id')).toBe('req_7')
|
||||
})
|
||||
|
||||
it('preserves EntryDateOutsideFiscalPeriodError fields', async () => {
|
||||
const err = new EntryDateOutsideFiscalPeriodError(
|
||||
'2026-01-01',
|
||||
'FY2025',
|
||||
'2025-01-01',
|
||||
'2025-12-31',
|
||||
)
|
||||
const body = await readEnvelope(errorResponse(err, noopLogger, { requestId: 'req_8' }))
|
||||
expect(body.error.code).toBe('ENTRY_DATE_OUTSIDE_FISCAL_PERIOD')
|
||||
expect(body.error.details).toMatchObject({
|
||||
entryDate: '2026-01-01',
|
||||
periodName: 'FY2025',
|
||||
periodStart: '2025-01-01',
|
||||
periodEnd: '2025-12-31',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -231,6 +231,14 @@ export function getErrorMessage(
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const obj = error as Record<string, unknown>
|
||||
|
||||
// Bare envelope inner-error shape: { code, message, ... }. Happens when a
|
||||
// caller forwards `result.error` (the inner object) instead of the whole
|
||||
// `result`. Treat it the same as the wrapped form so we always end up with
|
||||
// the registry's Swedish message in the toast — never `[object Object]`.
|
||||
if (typeof obj.code === 'string' && typeof obj.message === 'string' && obj.message.trim()) {
|
||||
return obj.message
|
||||
}
|
||||
|
||||
// Structured application error: { error: { code, message, ... } }
|
||||
if (typeof obj.error === 'object' && obj.error !== null) {
|
||||
const structured = obj.error as {
|
||||
|
||||
@@ -11,17 +11,33 @@
|
||||
* that fixes the problem. Optional — only set when there's a clear
|
||||
* mechanical next step
|
||||
*
|
||||
* Used by the MCP server's tool error wrapper. UI callers continue to use the
|
||||
* string-only getErrorMessage() — this is additive.
|
||||
* Both MCP and REST consume this. errorResponse() below produces the standard
|
||||
* REST envelope so a single registry covers every entry point.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ZodError } from 'zod'
|
||||
import { getErrorMessage } from './get-error-message'
|
||||
import {
|
||||
getErrorEntry,
|
||||
type StructuredErrorEntry,
|
||||
type StructuredErrorRemediation,
|
||||
} from './structured-errors'
|
||||
import {
|
||||
AccountsNotInChartError,
|
||||
BookkeepingDatabaseError,
|
||||
CannotCorrectNonPostedError,
|
||||
CannotReverseNonPostedError,
|
||||
EntryAlreadyReversedError,
|
||||
EntryDateOutsideFiscalPeriodError,
|
||||
FiscalPeriodNotFoundError,
|
||||
InvalidMappingResultError,
|
||||
JournalEntryNotBalancedError,
|
||||
JournalEntryNotFoundError,
|
||||
CurrencyRevaluationAlreadyExistsError,
|
||||
isBookkeepingError,
|
||||
} from '../bookkeeping/errors'
|
||||
|
||||
export interface StructuredErrorRemediation {
|
||||
description: string
|
||||
tool?: string
|
||||
args?: Record<string, unknown>
|
||||
resource?: string
|
||||
}
|
||||
export type { StructuredErrorRemediation }
|
||||
|
||||
export interface StructuredError {
|
||||
code: string
|
||||
@@ -41,58 +57,6 @@ interface StructuredErrorOptions {
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
const ERROR_CODE_REMEDIATION: Record<string, StructuredErrorRemediation> = {
|
||||
ACCOUNTS_NOT_IN_CHART: {
|
||||
description: 'One or more BAS accounts referenced are not active in the chart of accounts. Activate them via the bookkeeping settings, or use a different category.',
|
||||
resource: 'gnubok://chart-of-accounts',
|
||||
},
|
||||
JOURNAL_ENTRY_NOT_BALANCED: {
|
||||
description: 'Debits and credits do not match. Recalculate the lines so totals are equal before retrying.',
|
||||
},
|
||||
FISCAL_PERIOD_NOT_FOUND: {
|
||||
description: 'No fiscal period covers the entry date. Create or extend the relevant period before retrying.',
|
||||
resource: 'gnubok://period/active',
|
||||
},
|
||||
ENTRY_DATE_OUTSIDE_FISCAL_PERIOD: {
|
||||
description: 'The entry date is outside the active fiscal period. Use a date inside an open period or create one that covers it.',
|
||||
resource: 'gnubok://period/active',
|
||||
},
|
||||
CANNOT_REVERSE_NON_POSTED: {
|
||||
description: 'Only posted entries can be reversed. Commit the draft first or pick a posted entry.',
|
||||
},
|
||||
CANNOT_CORRECT_NON_POSTED: {
|
||||
description: 'Only posted entries can be corrected. Commit the draft first or pick a posted entry.',
|
||||
},
|
||||
ENTRY_ALREADY_REVERSED: {
|
||||
description: 'Another caller reversed this entry concurrently. Re-fetch the entry list and pick a different one.',
|
||||
},
|
||||
PERIOD_NOT_LOCKED: {
|
||||
description: 'The period must be locked before it can be closed. Call gnubok_lock_period first.',
|
||||
tool: 'gnubok_lock_period',
|
||||
},
|
||||
PERIOD_HAS_UNBOOKED_TRANSACTIONS: {
|
||||
description: 'The period contains uncategorized business transactions. Categorize or mark them private before locking.',
|
||||
tool: 'gnubok_list_uncategorized_transactions',
|
||||
},
|
||||
YEAR_END_NOT_RUN: {
|
||||
description: 'Year-end closing must be executed before the period can be closed. Run the year-end procedure first.',
|
||||
},
|
||||
INSUFFICIENT_SCOPE: {
|
||||
description: 'The current API key does not have the required scope. Mint a new key with the missing scope or grant it through the API key settings.',
|
||||
resource: 'gnubok://capabilities',
|
||||
},
|
||||
TRANSACTION_ALREADY_CATEGORIZED: {
|
||||
description: 'The transaction already has a journal entry. Use gnubok_uncategorize_transaction first if you need to recategorize.',
|
||||
tool: 'gnubok_uncategorize_transaction',
|
||||
},
|
||||
INVOICE_ALREADY_SENT: {
|
||||
description: 'The invoice is already sent or paid; sending again would create a duplicate.',
|
||||
},
|
||||
IDEMPOTENCY_KEY_REUSE: {
|
||||
description: 'This idempotency_key was previously used with a different request body. Use a fresh UUID for a new operation, or send the original request body to replay.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a stable code out of various error shapes.
|
||||
*/
|
||||
@@ -164,7 +128,8 @@ export function getStructuredError(
|
||||
|
||||
const code = extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR'
|
||||
|
||||
let remediation = ERROR_CODE_REMEDIATION[code]
|
||||
const entry = getErrorEntry(code)
|
||||
let remediation = entry?.remediation
|
||||
|
||||
// Specialize INSUFFICIENT_SCOPE with the actual scope name when known.
|
||||
if (code === 'INSUFFICIENT_SCOPE' && options.attemptedScope && remediation) {
|
||||
@@ -181,3 +146,240 @@ export function getStructuredError(
|
||||
...(remediation ? { remediation } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// REST error envelope
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ErrorEnvelope {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
message_en?: string
|
||||
remediation?: StructuredErrorRemediation
|
||||
requestId?: string
|
||||
details?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorResponseContext {
|
||||
requestId?: string
|
||||
/** Additional details to attach to the response for the user/agent. */
|
||||
details?: unknown
|
||||
/** When known, override the http status from the registry entry. */
|
||||
status?: number
|
||||
}
|
||||
|
||||
interface MinimalLogger {
|
||||
error: (msg: string, ...args: unknown[]) => void
|
||||
}
|
||||
|
||||
function entryFor(code: string): StructuredErrorEntry {
|
||||
return (
|
||||
getErrorEntry(code) ??
|
||||
getErrorEntry('INTERNAL_ERROR') ?? {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Något gick fel. Försök igen.',
|
||||
message_en: 'Internal server error.',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function postgresCodeToStructured(code: string): string | null {
|
||||
switch (code) {
|
||||
case '23505':
|
||||
case '23503':
|
||||
case '23514':
|
||||
case '22P02':
|
||||
case '22003':
|
||||
return 'VALIDATION_ERROR'
|
||||
case '23502':
|
||||
return 'VALIDATION_ERROR'
|
||||
case '42501':
|
||||
return 'FORBIDDEN'
|
||||
case '42P01':
|
||||
return 'NOT_FOUND'
|
||||
case '40001':
|
||||
case '40P01':
|
||||
return 'CONFLICT'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isZodError(err: unknown): err is ZodError {
|
||||
return err instanceof ZodError || (err instanceof Error && err.name === 'ZodError')
|
||||
}
|
||||
|
||||
function isPostgresError(err: unknown): err is { code: string; message: string } {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
typeof (err as { code?: unknown }).code === 'string' &&
|
||||
/^[0-9A-Z]{5}$/.test((err as { code: string }).code)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical REST error envelope for any thrown value.
|
||||
*
|
||||
* Order of dispatch:
|
||||
* 1. typed BookkeepingError → reuses bookkeepingErrorResponse()
|
||||
* 2. ZodError → VALIDATION_ERROR with field-level details
|
||||
* 3. Postgres error code → mapped to a structured code
|
||||
* 4. Error with `code` field present in registry → use that
|
||||
* 5. Anything else → INTERNAL_ERROR
|
||||
*
|
||||
* Always logs the underlying error (no silent error returns). The caller
|
||||
* must pass a logger so the request id propagates to the log line.
|
||||
*/
|
||||
export function errorResponse(
|
||||
err: unknown,
|
||||
log: MinimalLogger,
|
||||
ctx: ErrorResponseContext = {},
|
||||
): NextResponse {
|
||||
// 1. Bookkeeping domain errors — route through the registry, preserving
|
||||
// the structured details each typed error class carries.
|
||||
if (isBookkeepingError(err)) {
|
||||
const { code, details } = extractBookkeepingDetails(err)
|
||||
log.error(code, err as Error, { requestId: ctx.requestId })
|
||||
const entry = entryFor(code)
|
||||
return buildResponse(code, entry, ctx.requestId, details ?? ctx.details)
|
||||
}
|
||||
|
||||
// 2. Zod validation errors
|
||||
if (isZodError(err)) {
|
||||
const issues = (err as ZodError).issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
code: i.code,
|
||||
}))
|
||||
log.error('validation failed', err as Error, {
|
||||
requestId: ctx.requestId,
|
||||
issueCount: issues.length,
|
||||
})
|
||||
const entry = entryFor('VALIDATION_ERROR')
|
||||
const details = mergeDetails({ issues }, ctx.details)
|
||||
return buildResponse('VALIDATION_ERROR', entry, ctx.requestId, details)
|
||||
}
|
||||
|
||||
// 3. Postgres errors
|
||||
if (isPostgresError(err)) {
|
||||
const mapped = postgresCodeToStructured(err.code)
|
||||
log.error('database error', err as unknown as Error, {
|
||||
requestId: ctx.requestId,
|
||||
pgCode: err.code,
|
||||
})
|
||||
if (mapped) {
|
||||
const entry = entryFor(mapped)
|
||||
const details = mergeDetails({ pgCode: err.code }, ctx.details)
|
||||
return buildResponse(mapped, entry, ctx.requestId, details)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Errors with a known structured code on them
|
||||
const code = extractCode(err)
|
||||
if (code && getErrorEntry(code)) {
|
||||
const entry = entryFor(code)
|
||||
log.error(`${code}`, err instanceof Error ? err : new Error(String(err)), { requestId: ctx.requestId })
|
||||
const status = ctx.status ?? entry.httpStatus
|
||||
return buildResponse(code, { ...entry, httpStatus: status }, ctx.requestId, ctx.details)
|
||||
}
|
||||
|
||||
// 5. Fallback — log the actual error so we can still debug
|
||||
log.error('unhandled error', err instanceof Error ? err : new Error(String(err)), {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
const fallback = entryFor('INTERNAL_ERROR')
|
||||
return buildResponse('INTERNAL_ERROR', fallback, ctx.requestId, ctx.details)
|
||||
}
|
||||
|
||||
function mergeDetails(
|
||||
base: Record<string, unknown>,
|
||||
extra: unknown,
|
||||
): Record<string, unknown> {
|
||||
if (extra && typeof extra === 'object' && !Array.isArray(extra)) {
|
||||
return { ...base, ...(extra as Record<string, unknown>) }
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function extractBookkeepingDetails(err: unknown): { code: string; details?: unknown } {
|
||||
if (err instanceof AccountsNotInChartError) {
|
||||
return { code: err.code, details: { account_numbers: err.accountNumbers } }
|
||||
}
|
||||
if (err instanceof JournalEntryNotBalancedError) {
|
||||
return {
|
||||
code: err.code,
|
||||
details: { totalDebit: err.totalDebit, totalCredit: err.totalCredit, kind: err.kind },
|
||||
}
|
||||
}
|
||||
if (err instanceof FiscalPeriodNotFoundError) return { code: err.code }
|
||||
if (err instanceof EntryDateOutsideFiscalPeriodError) {
|
||||
return {
|
||||
code: err.code,
|
||||
details: {
|
||||
entryDate: err.entryDate,
|
||||
periodName: err.periodName,
|
||||
periodStart: err.periodStart,
|
||||
periodEnd: err.periodEnd,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (err instanceof JournalEntryNotFoundError) return { code: err.code }
|
||||
if (err instanceof CannotReverseNonPostedError) {
|
||||
return { code: err.code, details: { currentStatus: err.currentStatus } }
|
||||
}
|
||||
if (err instanceof CannotCorrectNonPostedError) {
|
||||
return { code: err.code, details: { currentStatus: err.currentStatus } }
|
||||
}
|
||||
if (err instanceof EntryAlreadyReversedError) return { code: err.code }
|
||||
if (err instanceof CurrencyRevaluationAlreadyExistsError) return { code: err.code }
|
||||
if (err instanceof InvalidMappingResultError) {
|
||||
return {
|
||||
code: err.code,
|
||||
details: { debitAccount: err.debitAccount, creditAccount: err.creditAccount },
|
||||
}
|
||||
}
|
||||
if (err instanceof BookkeepingDatabaseError) {
|
||||
return { code: err.code, details: { operation: err.operation } }
|
||||
}
|
||||
return { code: 'INTERNAL_ERROR' }
|
||||
}
|
||||
|
||||
function buildResponse(
|
||||
code: string,
|
||||
entry: StructuredErrorEntry,
|
||||
requestId: string | undefined,
|
||||
details: unknown,
|
||||
): NextResponse {
|
||||
const body: ErrorEnvelope = {
|
||||
error: {
|
||||
code,
|
||||
message: entry.message_sv,
|
||||
message_en: entry.message_en,
|
||||
...(entry.remediation ? { remediation: entry.remediation } : {}),
|
||||
...(requestId ? { requestId } : {}),
|
||||
...(details !== undefined ? { details } : {}),
|
||||
},
|
||||
}
|
||||
const res = NextResponse.json(body, { status: entry.httpStatus })
|
||||
if (requestId) res.headers.set('X-Request-Id', requestId)
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an envelope-shaped error directly from a code (when the route
|
||||
* already knows the failure mode). Skips dispatch — useful inside a handler
|
||||
* that wants the standard shape without throwing.
|
||||
*/
|
||||
export function errorResponseFromCode(
|
||||
code: string,
|
||||
log: MinimalLogger,
|
||||
ctx: ErrorResponseContext & { reason?: string } = {},
|
||||
): NextResponse {
|
||||
const entry = entryFor(code)
|
||||
log.error(code, ctx.reason ?? entry.message_en, { requestId: ctx.requestId })
|
||||
const status = ctx.status ?? entry.httpStatus
|
||||
return buildResponse(code, { ...entry, httpStatus: status }, ctx.requestId, ctx.details)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+21
-17
@@ -1,23 +1,25 @@
|
||||
import type { CoreEvent, CoreEventType, EventHandler } from './types'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
// Internal handler type — loose enough for the Map, but type-safe at the public API
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyHandler = (payload: any) => Promise<void> | void
|
||||
|
||||
const log = createLogger('event-bus')
|
||||
|
||||
/**
|
||||
* In-process event bus.
|
||||
*
|
||||
* - Handlers run concurrently via Promise.allSettled (failing handler never crashes emitter)
|
||||
* - Module-level singleton (persists across requests in same process)
|
||||
* - One-way: core services emit, extensions subscribe
|
||||
* - Rejected handlers are logged with structured fields so they're greppable
|
||||
* in Vercel logs by event type, handler name, and the originating request id
|
||||
* (when carried in the payload).
|
||||
*/
|
||||
class EventBus {
|
||||
private handlers = new Map<string, Set<AnyHandler>>()
|
||||
|
||||
/**
|
||||
* Subscribe to an event type.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
on<T extends CoreEventType>(
|
||||
eventType: T,
|
||||
handler: EventHandler<T>
|
||||
@@ -37,31 +39,33 @@ class EventBus {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all registered handlers.
|
||||
* Uses Promise.allSettled so a failing handler never crashes the emitter.
|
||||
*/
|
||||
async emit(event: CoreEvent): Promise<void> {
|
||||
const handlerSet = this.handlers.get(event.type)
|
||||
if (!handlerSet || handlerSet.size === 0) return
|
||||
|
||||
const handlers = [...handlerSet]
|
||||
const results = await Promise.allSettled(
|
||||
[...handlerSet].map((handler) => handler(event.payload))
|
||||
handlers.map((handler) => handler(event.payload))
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i]
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
`[EventBus] Handler failed for "${event.type}":`,
|
||||
result.reason
|
||||
)
|
||||
const handler = handlers[i]
|
||||
const handlerName = handler.name || 'anonymous'
|
||||
const payload = event.payload as Record<string, unknown>
|
||||
|
||||
log.error('handler failed', result.reason, {
|
||||
eventType: event.type,
|
||||
handler: handlerName,
|
||||
companyId: typeof payload.companyId === 'string' ? payload.companyId : undefined,
|
||||
userId: typeof payload.userId === 'string' ? payload.userId : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all handlers (useful for testing).
|
||||
*/
|
||||
/** Remove all handlers (useful for testing). */
|
||||
clear(): void {
|
||||
this.handlers.clear()
|
||||
}
|
||||
|
||||
@@ -12,10 +12,13 @@ import type {
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Create a prefixed logger for an extension.
|
||||
* Create a prefixed logger for an extension. When `bind` is supplied the
|
||||
* fields (e.g. requestId, userId, companyId) are merged into every log line.
|
||||
*/
|
||||
function createExtLogger(extensionId: string): ExtensionLogger {
|
||||
const logger = createLogger(`ext:${extensionId}`)
|
||||
function createExtLogger(extensionId: string, bind?: Record<string, unknown>): ExtensionLogger {
|
||||
const logger = bind
|
||||
? createLogger(`ext:${extensionId}`, bind)
|
||||
: createLogger(`ext:${extensionId}`)
|
||||
return {
|
||||
info: (message: string, ...args: unknown[]) => logger.info(message, ...args),
|
||||
warn: (message: string, ...args: unknown[]) => logger.warn(message, ...args),
|
||||
@@ -106,22 +109,31 @@ function createServices(): ExtensionServices {
|
||||
*
|
||||
* The context gives extensions access to Supabase, event emission, settings,
|
||||
* storage, logging, and core services — without importing from core modules.
|
||||
*
|
||||
* `requestId` (when supplied by the dispatcher) flows through the bound logger
|
||||
* and is exposed on the context so handlers can pass it into
|
||||
* `errorResponseFromCode(...)` for the envelope + `X-Request-Id` header.
|
||||
*/
|
||||
export function createExtensionContext(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
extensionId: string
|
||||
extensionId: string,
|
||||
requestId?: string,
|
||||
): ExtensionContext {
|
||||
const logBindings: Record<string, unknown> = { userId, companyId, extensionId }
|
||||
if (requestId) logBindings.requestId = requestId
|
||||
|
||||
return {
|
||||
userId,
|
||||
companyId,
|
||||
extensionId,
|
||||
requestId,
|
||||
supabase,
|
||||
emit: (event: CoreEvent) => eventBus.emit(event),
|
||||
settings: createSettings(supabase, userId, companyId, extensionId),
|
||||
storage: createStorage(supabase),
|
||||
log: createExtLogger(extensionId),
|
||||
log: createExtLogger(extensionId, logBindings),
|
||||
services: createServices(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,12 @@ export interface ExtensionContext {
|
||||
userId: string
|
||||
companyId: string
|
||||
extensionId: string
|
||||
/**
|
||||
* Stable id for the inbound HTTP request — `req_<uuid>`.
|
||||
* Included in the response envelope and in the `X-Request-Id` header so
|
||||
* support staff can grep stdout logs by it.
|
||||
*/
|
||||
requestId?: string
|
||||
supabase: SupabaseClient
|
||||
emit(event: CoreEvent): Promise<void>
|
||||
settings: ExtensionSettings
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Client-side helper that turns a fetch failure into a Swedish toast with
|
||||
* remediation hint and the X-Request-Id for support reference.
|
||||
*
|
||||
* Accepts:
|
||||
* - the `{ error: {...} }` envelope produced by the route wrapper
|
||||
* - a Response object (the helper reads it for you)
|
||||
* - a raw Error / string (falls back to getErrorMessage)
|
||||
*
|
||||
* Usage:
|
||||
* const showError = useErrorToast()
|
||||
* const res = await fetch('/api/invoices/123/send', { method: 'POST' })
|
||||
* if (!res.ok) {
|
||||
* await showError(res, { context: 'invoice' })
|
||||
* return
|
||||
* }
|
||||
*/
|
||||
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { ErrorEnvelope } from '@/lib/errors/get-structured-error'
|
||||
|
||||
type ErrorContext =
|
||||
| 'invoice'
|
||||
| 'supplier_invoice'
|
||||
| 'customer'
|
||||
| 'supplier'
|
||||
| 'transaction'
|
||||
| 'journal_entry'
|
||||
| 'settings'
|
||||
| 'auth'
|
||||
| 'salary'
|
||||
|
||||
interface ShowErrorOptions {
|
||||
context?: ErrorContext
|
||||
/** Override the toast title (Swedish summary). Defaults to envelope.message. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface NormalizedError {
|
||||
message: string
|
||||
remediation?: string
|
||||
requestId?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
async function normalize(input: unknown): Promise<NormalizedError> {
|
||||
// Response: try to read JSON body and X-Request-Id header
|
||||
if (input instanceof Response) {
|
||||
const requestId = input.headers.get('X-Request-Id') ?? undefined
|
||||
let body: unknown = null
|
||||
try {
|
||||
body = await input.json()
|
||||
} catch {
|
||||
// ignore — body might be empty
|
||||
}
|
||||
const fromBody = readEnvelope(body)
|
||||
return {
|
||||
message: fromBody.message ?? getErrorMessage(body, { statusCode: input.status }),
|
||||
remediation: fromBody.remediation,
|
||||
requestId: fromBody.requestId ?? requestId,
|
||||
code: fromBody.code,
|
||||
}
|
||||
}
|
||||
|
||||
const fromBody = readEnvelope(input)
|
||||
if (fromBody.message) {
|
||||
return fromBody
|
||||
}
|
||||
return { message: getErrorMessage(input) }
|
||||
}
|
||||
|
||||
function readEnvelope(input: unknown): NormalizedError {
|
||||
if (!input || typeof input !== 'object') return { message: '' }
|
||||
const obj = input as Record<string, unknown>
|
||||
const errObj = obj.error
|
||||
if (errObj && typeof errObj === 'object') {
|
||||
const e = errObj as Partial<ErrorEnvelope['error']>
|
||||
return {
|
||||
message: typeof e.message === 'string' ? e.message : '',
|
||||
remediation:
|
||||
e.remediation && typeof e.remediation === 'object'
|
||||
? (e.remediation as { description?: string }).description
|
||||
: undefined,
|
||||
requestId: typeof e.requestId === 'string' ? e.requestId : undefined,
|
||||
code: typeof e.code === 'string' ? e.code : undefined,
|
||||
}
|
||||
}
|
||||
return { message: '' }
|
||||
}
|
||||
|
||||
export function useErrorToast() {
|
||||
const { toast } = useToast()
|
||||
|
||||
return async function showError(input: unknown, options: ShowErrorOptions = {}) {
|
||||
const norm = await normalize(input)
|
||||
const title = options.title ?? norm.message ?? getErrorMessage(input, { context: options.context })
|
||||
|
||||
const descriptionParts: string[] = []
|
||||
if (norm.remediation) descriptionParts.push(norm.remediation)
|
||||
if (norm.requestId) descriptionParts.push(`Felreferens: ${norm.requestId}`)
|
||||
if (process.env.NODE_ENV !== 'production' && norm.code) {
|
||||
descriptionParts.push(`Kod: ${norm.code}`)
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title,
|
||||
description: descriptionParts.length > 0 ? descriptionParts.join(' · ') : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
+207
-11
@@ -1,31 +1,227 @@
|
||||
/**
|
||||
* Lightweight structured logger for server-side code.
|
||||
* Structured logger for server-side code.
|
||||
*
|
||||
* Wraps console.* with module prefixes and environment-aware filtering.
|
||||
* Suppresses info/warn in test environment to reduce noise.
|
||||
* Can be swapped for an external logging service (e.g. Axiom, Datadog) later.
|
||||
* Emits JSON in production (Vercel logs ingest these), pretty text in dev.
|
||||
* Suppresses info/warn in test (preserves existing test-noise contract).
|
||||
*
|
||||
* Backward-compatible with the legacy `log.error(msg, ...args)` callers — any
|
||||
* extra args after the message are merged into the structured payload:
|
||||
* - Error instances become `err: { name, message, stack, code }`
|
||||
* - plain objects merge into the context fields (after PII redaction)
|
||||
* - everything else goes into `details: [...]`
|
||||
*
|
||||
* New code should prefer the explicit ctx form: `log.error('msg', err, ctx)`.
|
||||
*
|
||||
* Use `log.child({ requestId, companyId, ... })` to bind a context that is
|
||||
* merged into every subsequent call. The `with-route-context` wrapper relies
|
||||
* on this to thread requestId through a request lifecycle.
|
||||
*/
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error'
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return level === 'error'
|
||||
export interface LogContext {
|
||||
requestId?: string
|
||||
userId?: string
|
||||
companyId?: string
|
||||
operation?: string
|
||||
entityType?: string
|
||||
entityId?: string
|
||||
durationMs?: number
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
info(message: string, ...args: unknown[]): void
|
||||
warn(message: string, ...args: unknown[]): void
|
||||
error(message: string, ...args: unknown[]): void
|
||||
child(extra: LogContext): Logger
|
||||
}
|
||||
|
||||
const REDACTED = '[REDACTED]'
|
||||
|
||||
const REDACT_KEYS = new Set([
|
||||
'password',
|
||||
'token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'apikey',
|
||||
'api_key',
|
||||
'secret',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'bank_account',
|
||||
'bankaccount',
|
||||
'iban',
|
||||
'personnummer',
|
||||
'ssn',
|
||||
'credentials',
|
||||
])
|
||||
|
||||
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
||||
const PERSONNUMMER_PATTERN = /\b\d{6}-?\d{4}\b|\b\d{8}-?\d{4}\b/
|
||||
|
||||
function redactString(value: string): string {
|
||||
// Strip UUIDs first to avoid false-positive personnummer matches
|
||||
const stripped = value.replace(UUID_PATTERN, '')
|
||||
if (PERSONNUMMER_PATTERN.test(stripped)) {
|
||||
return REDACTED
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function redact(value: unknown, keyPath = ''): unknown {
|
||||
if (value === null || value === undefined) return value
|
||||
if (typeof value === 'string') return redactString(value)
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return value
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: redactString(value.message),
|
||||
stack: process.env.NODE_ENV === 'production' ? undefined : value.stack,
|
||||
code: (value as Error & { code?: unknown }).code,
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v, i) => redact(v, `${keyPath}[${i}]`))
|
||||
if (typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (REDACT_KEYS.has(k.toLowerCase())) {
|
||||
out[k] = REDACTED
|
||||
} else {
|
||||
out[k] = redact(v, keyPath ? `${keyPath}.${k}` : k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
!(v instanceof Error) &&
|
||||
!Array.isArray(v) &&
|
||||
!(v instanceof Date) &&
|
||||
Object.getPrototypeOf(v) === Object.prototype
|
||||
)
|
||||
}
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
if (process.env.NODE_ENV === 'test') return level === 'error'
|
||||
return true
|
||||
}
|
||||
|
||||
export function createLogger(module: string) {
|
||||
interface LogRecord {
|
||||
level: LogLevel
|
||||
module: string
|
||||
msg: string
|
||||
ts: string
|
||||
err?: unknown
|
||||
details?: unknown[]
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
function buildRecord(
|
||||
level: LogLevel,
|
||||
module: string,
|
||||
base: LogContext,
|
||||
message: string,
|
||||
args: unknown[],
|
||||
): LogRecord {
|
||||
const ctx: Record<string, unknown> = { ...base }
|
||||
let err: unknown
|
||||
const details: unknown[] = []
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg instanceof Error) {
|
||||
// First Error wins; subsequent ones land in details
|
||||
if (err === undefined) err = redact(arg)
|
||||
else details.push(redact(arg))
|
||||
} else if (isPlainObject(arg)) {
|
||||
Object.assign(ctx, redact(arg) as Record<string, unknown>)
|
||||
} else if (arg !== undefined) {
|
||||
details.push(redact(arg))
|
||||
}
|
||||
}
|
||||
|
||||
const record: LogRecord = {
|
||||
level,
|
||||
module,
|
||||
msg: redactString(message),
|
||||
ts: new Date().toISOString(),
|
||||
...(redact(ctx) as Record<string, unknown>),
|
||||
}
|
||||
if (err !== undefined) record.err = err
|
||||
if (details.length > 0) record.details = details
|
||||
return record
|
||||
}
|
||||
|
||||
function emit(record: LogRecord) {
|
||||
const fn =
|
||||
record.level === 'error' ? console.error : record.level === 'warn' ? console.warn : console.log
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
fn(JSON.stringify(record))
|
||||
return
|
||||
}
|
||||
|
||||
// Pretty dev output
|
||||
const { level, module, msg, ts: _ts, err, details, ...ctx } = record
|
||||
const ctxKeys = Object.keys(ctx)
|
||||
const ctxStr = ctxKeys.length > 0 ? ' ' + ctxKeys.map((k) => `${k}=${JSON.stringify(ctx[k])}`).join(' ') : ''
|
||||
const prefix = `[${module}]`
|
||||
const tag = level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO'
|
||||
fn(`${prefix} ${tag} ${msg}${ctxStr}`)
|
||||
if (err) fn(' err:', err)
|
||||
if (details && details.length > 0) fn(' details:', ...details)
|
||||
}
|
||||
|
||||
function makeLogger(module: string, base: LogContext): Logger {
|
||||
return {
|
||||
info(message: string, ...args: unknown[]) {
|
||||
if (shouldLog('info')) console.log(prefix, message, ...args)
|
||||
if (!shouldLog('info')) return
|
||||
emit(buildRecord('info', module, base, message, args))
|
||||
},
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
if (shouldLog('warn')) console.warn(prefix, message, ...args)
|
||||
if (!shouldLog('warn')) return
|
||||
emit(buildRecord('warn', module, base, message, args))
|
||||
},
|
||||
error(message: string, ...args: unknown[]) {
|
||||
if (shouldLog('error')) console.error(prefix, message, ...args)
|
||||
if (!shouldLog('error')) return
|
||||
emit(buildRecord('error', module, base, message, args))
|
||||
},
|
||||
child(extra: LogContext): Logger {
|
||||
return makeLogger(module, { ...base, ...extra })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(module: string, base: LogContext = {}): Logger {
|
||||
return makeLogger(module, base)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only escape hatch. Returns a logger that writes records to the supplied
|
||||
* array instead of stdout. Useful for asserting on emitted log lines.
|
||||
*/
|
||||
export function createTestLogger(module: string, sink: LogRecord[], base: LogContext = {}): Logger {
|
||||
const push = (level: LogLevel, message: string, args: unknown[]) => {
|
||||
sink.push(buildRecord(level, module, base, message, args))
|
||||
}
|
||||
return {
|
||||
info(message: string, ...args: unknown[]) {
|
||||
push('info', message, args)
|
||||
},
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
push('warn', message, args)
|
||||
},
|
||||
error(message: string, ...args: unknown[]) {
|
||||
push('error', message, args)
|
||||
},
|
||||
child(extra: LogContext): Logger {
|
||||
return createTestLogger(module, sink, { ...base, ...extra })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,74 @@ describe('commitPendingOperation: unlock_period', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── create_transaction ─────────────────────────────────────────────
|
||||
|
||||
describe('commitPendingOperation: create_transaction', () => {
|
||||
it('happy path: inserts a transactions row with import_source=mcp and returns the id', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'tx-42' }, error: null }) // executor insert
|
||||
enqueue({ data: null, error: null }) // dispatcher's update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'create_transaction',
|
||||
params: {
|
||||
date: '2026-05-01',
|
||||
amount: -129.5,
|
||||
description: 'AWS subscription',
|
||||
currency: 'USD',
|
||||
external_id: 'recAirtable123',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({ transaction_id: 'tx-42' })
|
||||
})
|
||||
|
||||
it('rejects with 400 when required fields are missing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'create_transaction',
|
||||
params: { date: '2026-05-01' }, // missing amount + description
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 409 when external_id collides with an existing row', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } as never }) // executor insert
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'create_transaction',
|
||||
params: {
|
||||
date: '2026-05-01',
|
||||
amount: 100,
|
||||
description: 'test',
|
||||
external_id: 'recAirtable123',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
// 409 collisions are treated as auto-rejected by the dispatcher.
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.auto_rejected).toBe(true)
|
||||
expect(result.http_status).toBe(409)
|
||||
expect(result.error).toMatch(/already exists/)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── import_sie ─────────────────────────────────────────────────────
|
||||
|
||||
describe('commitPendingOperation: import_sie', () => {
|
||||
|
||||
@@ -306,6 +306,52 @@ async function commitCreateCustomer(
|
||||
return { data: { customer_id: data.id } }
|
||||
}
|
||||
|
||||
async function commitCreateTransaction(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
const date = params.date as string
|
||||
const amount = Number(params.amount)
|
||||
const description = (params.description as string) ?? ''
|
||||
const currency = ((params.currency as string) || 'SEK') as Currency
|
||||
const bankConnectionId = (params.bank_connection_id as string) || null
|
||||
const externalId = (params.external_id as string) || null
|
||||
|
||||
if (!date || !description.trim() || !Number.isFinite(amount)) {
|
||||
return { error: 'date, description, and amount are required', status: 400 }
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
bank_connection_id: bankConnectionId,
|
||||
external_id: externalId,
|
||||
date,
|
||||
description: description.trim(),
|
||||
amount,
|
||||
currency,
|
||||
import_source: 'mcp',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
const isDuplicate = error.code === '23505'
|
||||
return {
|
||||
error: isDuplicate
|
||||
? `A transaction with external_id "${externalId}" already exists.`
|
||||
: error.message,
|
||||
status: isDuplicate ? 409 : 500,
|
||||
}
|
||||
}
|
||||
|
||||
return { data: { transaction_id: data.id } }
|
||||
}
|
||||
|
||||
async function commitCreateInvoice(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -1600,6 +1646,9 @@ export async function commitPendingOperation(
|
||||
case 'create_invoice':
|
||||
result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'create_transaction':
|
||||
result = await commitCreateTransaction(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'mark_invoice_paid':
|
||||
result = await commitMarkInvoicePaid(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
|
||||
@@ -26,6 +26,7 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
||||
categorize_transaction: 'medium',
|
||||
match_transaction_invoice: 'medium',
|
||||
create_invoice: 'medium', // creates as draft; sending is a separate op
|
||||
create_transaction: 'medium', // ingests an uncategorized row; reversible by delete
|
||||
// Pinning a doc to a tx is reversible while pre-categorization, but the link
|
||||
// becomes part of the verifikation underlag (BFL 5 kap 6 §) once categorize
|
||||
// propagates it. A wrong attachment requires a rättelse, so require human
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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_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
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user