Bug/gh issues fiz (#1103)

* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
This commit is contained in:
Mattsson
2026-07-21 23:00:15 +02:00
committed by GitHub
parent 702512437a
commit e11f70b347
467 changed files with 17569 additions and 2221 deletions
@@ -194,7 +194,7 @@ describe('POST /api/account/password', () => {
)
expect(status).toBe(400)
expect(body.error).toContain('Password too weak')
expect(body.error).toBe('Något gick fel. Försök igen.')
expect(updateUser).not.toHaveBeenCalled()
expect(flagFlipCall(updateUserById)).toBeUndefined()
})
@@ -273,7 +273,7 @@ describe('POST /api/account/password', () => {
)
expect(status).toBe(400)
expect(body.error).toContain('Password too similar')
expect(body.error).toBe('Något gick fel. Försök igen.')
expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD })
expect(flagFlipCall(updateUserById)).toBeUndefined()
})
+2 -1
View File
@@ -4,6 +4,7 @@ import { z } from 'zod'
import { requireAuth } from '@/lib/auth/require-auth'
import { validateBody } from '@/lib/api/validate'
import { createLogger } from '@/lib/logger'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const log = createLogger('api/account/password')
@@ -85,7 +86,7 @@ export async function POST(request: Request) {
return NextResponse.json(
{
error:
updateError.message ||
getUserErrorMessage(updateError) ||
'Kunde inte uppdatera lösenord. Försök igen.',
},
{ status: 400 },
+4 -3
View File
@@ -10,6 +10,7 @@ import { runChatTurn, friendlyModelError } from '@/lib/agent/chat/run-turn'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Make sure extensions are loaded: the chat loop dispatches against the
// agent tool registry which is populated by the mcp-server extension at load.
@@ -87,7 +88,7 @@ export async function POST(request: Request) {
body = BodySchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
{ status: 400 },
)
}
@@ -164,7 +165,7 @@ export async function POST(request: Request) {
.single()
if (convErr || !newConv) {
return NextResponse.json(
{ error: convErr?.message ?? 'Failed to create conversation' },
{ error: getUserErrorMessage(convErr) ?? 'Failed to create conversation' },
{ status: 500 },
)
}
@@ -202,7 +203,7 @@ export async function POST(request: Request) {
{
error:
err instanceof Error
? `Capture failed: ${err.message}`
? `Capture failed: ${getUserErrorMessage(err)}`
: 'Capture failed',
},
{ status: 500 },
+4 -3
View File
@@ -15,6 +15,7 @@ import { preWarmAtomCache } from '@/lib/agent/composer/prewarm'
import { OPUS_MODEL } from '@/lib/agent/composer/client'
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
import type { AtomSelection } from '@/lib/agent/composer/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BodySchema = z.object({
company_id: z.string().uuid().optional(),
@@ -82,7 +83,7 @@ export async function POST(request: Request) {
body = BodySchema.parse(await request.json().catch(() => ({})))
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
{ status: 400 },
)
}
@@ -229,7 +230,7 @@ export async function POST(request: Request) {
{ onConflict: 'company_id' },
)
if (upsertErr) {
send({ step: 'error', status: 'error', message: upsertErr.message })
send({ step: 'error', status: 'error', message: getUserErrorMessage(upsertErr) })
return
}
@@ -273,7 +274,7 @@ export async function POST(request: Request) {
send({
step: 'error',
status: 'error',
message: err instanceof Error ? err.message : 'Composer pipeline failed',
message: err instanceof Error ? getUserErrorMessage(err) : 'Composer pipeline failed',
})
} finally {
try {
+6 -5
View File
@@ -8,6 +8,7 @@ import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Article } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -32,7 +33,7 @@ export const GET = withRouteContext(
opLog.error('article fetch failed', error)
return errorResponseFromCode('INTERNAL_ERROR', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -97,7 +98,7 @@ export const PATCH = withRouteContext(
opLog.error('article update failed', error)
return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -135,7 +136,7 @@ export const DELETE = withRouteContext(
opLog.error('article lookup before delete failed', articleError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
details: { reason: articleError.message },
details: { reason: getUserErrorMessage(articleError) },
})
}
@@ -149,7 +150,7 @@ export const DELETE = withRouteContext(
opLog.error('article usage check failed', usageError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
details: { reason: usageError.message },
details: { reason: getUserErrorMessage(usageError) },
})
}
@@ -170,7 +171,7 @@ export const DELETE = withRouteContext(
opLog.error('article delete failed', deleteError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
details: { reason: deleteError.message },
details: { reason: getUserErrorMessage(deleteError) },
})
}
+2 -1
View File
@@ -10,6 +10,7 @@ import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookk
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { Article } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -99,7 +100,7 @@ export const POST = withRouteContext(
log.error('article insert failed', error)
return errorResponseFromCode('ARTICLE_CREATE_FAILED', log, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/reports/opening-balances', () => ({
getOpeningBalances: vi.fn(),
}))
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { getOpeningBalances } from '@/lib/reports/opening-balances'
import { GET } from '../route'
function request(searchParams: Record<string, string>) {
return createMockRequest('/api/bookkeeping/account-balances', { searchParams })
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getOpeningBalances).mockResolvedValue({
balances: new Map(),
obEntryId: null,
})
})
describe('GET /api/bookkeeping/account-balances', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect((await GET(request({ accounts: '1930', as_of: '2026-06-30' }), {
params: Promise.resolve({}),
})).status).toBe(401)
})
it('returns 400 for an invalid account number', async () => {
const { supabase } = createQueuedMockSupabase()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
expect((await GET(request({ accounts: 'not-an-account', as_of: '2026-06-30' }), {
params: Promise.resolve({}),
})).status).toBe(400)
})
it('returns zero balances when no fiscal period contains the date', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const { status, body } = await parseJsonResponse<{
data: Array<{ account_number: string; balance: number }>
}>(await GET(request({ accounts: '1930', as_of: '2026-06-30' }), {
params: Promise.resolve({}),
}))
expect(status).toBe(200)
expect(body.data).toEqual([{ account_number: '1930', balance: 0 }])
})
it('combines opening balances with aggregated period activity', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'period-1',
period_start: '2026-01-01',
period_end: '2026-12-31',
opening_balance_entry_id: 'opening-1',
},
},
{ data: [{ account_number: '1930', account_class: 1 }] },
{ data: [{ account_number: '1930', debit: 250, credit: 50 }] },
])
vi.mocked(getOpeningBalances).mockResolvedValue({
balances: new Map([['1930', { debit: 1_000, credit: 0 }]]),
obEntryId: 'opening-1',
})
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const { status, body } = await parseJsonResponse<{
data: Array<{ account_number: string; balance: number }>
}>(await GET(request({ accounts: '1930', as_of: '2026-06-30' }), {
params: Promise.resolve({}),
}))
expect(status).toBe(200)
expect(body.data).toEqual([{ account_number: '1930', balance: 1_200 }])
expect(supabase.rpc).toHaveBeenCalledWith('get_account_period_activity', {
p_company_id: 'company-1',
p_start: '2026-01-01',
p_end: '2026-06-30',
p_accounts: ['1930'],
p_exclude_journal_entry_id: 'opening-1',
})
})
})
+17 -32
View File
@@ -3,7 +3,6 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { AccountBalancesQuerySchema } from '@/lib/api/schemas'
import { getOpeningBalances } from '@/lib/reports/opening-balances'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* Per-account saldo as of a date. Used by the journal-entry form to show
@@ -90,46 +89,32 @@ export const GET = withRouteContext('bookkeeping.account_balances', async (reque
// Sum activity from period_start through as_of, excluding the OB entry
// (its lines are already in openingBalances).
let lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
try {
lines = await fetchAllRows<{
account_number: string
debit_amount: number
credit_amount: number
}>(({ from, to }) => {
let query = supabase
.from('journal_entry_lines')
.select(
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)'
)
.eq('journal_entries.company_id', companyId)
.in('account_number', accounts)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', period.period_start)
.lte('journal_entries.entry_date', as_of)
const { data: activityRows, error: activityError } = await supabase.rpc(
'get_account_period_activity',
{
p_company_id: companyId,
p_start: period.period_start,
p_end: as_of,
p_accounts: accounts,
p_exclude_journal_entry_id: obEntryId,
},
)
if (obEntryId) {
query = query.neq('journal_entry_id', obEntryId)
}
// Stable total order for correct paging (see fetch-all.ts).
return query.order('id', { ascending: true }).range(from, to)
})
} catch (err) {
if (activityError) {
log.error('period activity lookup failed', {
companyId,
period_id: period.id,
error: err instanceof Error ? err.message : String(err),
error: activityError.message,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
const periodActivity = new Map<string, { debit: number; credit: number }>()
for (const line of lines) {
const existing = periodActivity.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
periodActivity.set(line.account_number, existing)
for (const row of activityRows ?? []) {
periodActivity.set(row.account_number, {
debit: Number(row.debit) || 0,
credit: Number(row.credit) || 0,
})
}
return NextResponse.json({
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateAccountSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// DELETE hard-deletes an unused, non-system account; accounts referenced by
// this company's journal entries must be deactivated instead (PUT is_active).
@@ -57,7 +58,7 @@ export const DELETE = withRouteContext(
.eq('company_id', companyId)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(deleteError) }, { status: 500 })
}
return NextResponse.json({ success: true })
@@ -95,7 +96,7 @@ export const PUT = withRouteContext(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/bookkeeping/accounts/activate
@@ -41,7 +42,7 @@ export const POST = withRouteContext(
.in('account_number', uniqueNumbers)
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
const existingByNumber = new Map<string, boolean>(
@@ -79,7 +80,7 @@ export const POST = withRouteContext(
.in('account_number', toReactivate)
.select('account_number')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
reactivatedRows = data || []
}
@@ -91,7 +92,7 @@ export const POST = withRouteContext(
.insert(toInsert)
.select('account_number')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
insertedRows = data || []
}
+4 -3
View File
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
import { PruneAccountsSchema } from '@/lib/api/schemas'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// POST /api/bookkeeping/accounts/prune — bulk cleanup of unused accounts
// ("Rensa oanvända konton"), for charts bloated by an import from an old
@@ -78,7 +79,7 @@ export const POST = withRouteContext(
{ p_company_id: companyId },
)
if (usageError) {
return NextResponse.json({ error: usageError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(usageError) }, { status: 500 })
}
const usageByAccount = new Map<string, number>(
@@ -134,7 +135,7 @@ export const POST = withRouteContext(
// Report what was already deleted so the UI can refresh honestly.
return NextResponse.json(
{
error: deleteError.message,
error: getUserErrorMessage(deleteError),
data: { deleted: toDelete.slice(0, i), skipped, not_found: notFound },
},
{ status: 500 },
@@ -153,7 +154,7 @@ export const POST = withRouteContext(
})
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to prune accounts' },
{ error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to prune accounts' },
{ status: 500 },
)
}
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/bookkeeping/accounts/reference
@@ -33,6 +34,6 @@ export const GET = withRouteContext('bookkeeping.accounts.reference', async (_re
return NextResponse.json({ data: userAccounts })
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
return NextResponse.json({ error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to fetch accounts' }, { status: 500 })
}
})
+3 -2
View File
@@ -4,6 +4,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { CreateAccountSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Response shapes are legacy `{ data }` / `{ error: string }` — several pages
// (import, supplier-invoices, article form) consume the list directly.
@@ -46,7 +47,7 @@ export const GET = withRouteContext('bookkeeping.accounts.list', async (request,
return NextResponse.json({ data })
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch accounts' },
{ error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to fetch accounts' },
{ status: 500 },
)
}
@@ -93,7 +94,7 @@ export const POST = withRouteContext(
{ status: 409 },
)
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// GET /api/bookkeeping/accounts/usage — per-account posting counts for the
// active company, from the get_account_usage_counts RPC. Accounts that have
@@ -17,7 +18,7 @@ export const GET = withRouteContext('bookkeeping.accounts.usage', async (_reques
})
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: data ?? [] })
@@ -106,6 +106,6 @@ describe('POST /api/bookkeeping/accruals/[id]/dissolve', () => {
expect(status).toBe(400)
expect(body.error.code).toBe('ACCRUAL_DISSOLVE_FAILED')
expect(body.error.details.reason).toMatch(/Ingen öppen räkenskapsperiod/)
expect(body.error.details.reason).toBe('Något gick fel. Försök igen.')
})
})
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { dissolveScheduleNow } from '@/lib/bookkeeping/accruals/service'
import {
@@ -39,7 +40,6 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
})
return NextResponse.json({ data: result })
} catch (err) {
const reason = err instanceof Error ? err.message : 'unknown'
// Typed domain errors carry a stable code: never match Swedish prose.
if (isAccrualError(err)) {
switch (err.code) {
@@ -57,7 +57,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
log.error('accrual dissolve failed', err as Error, { entityId: id })
return errorResponseFromCode('ACCRUAL_DISSOLVE_FAILED', log, {
requestId,
details: { reason },
details: { reason: getErrorMessage(err) },
})
}
},
@@ -5,6 +5,7 @@ import { createServiceClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { postDueInstallments } from '@/lib/bookkeeping/accruals/service'
import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -42,7 +43,7 @@ export const GET = withCronContext('cron.accrual_postings', async (_request, ctx
} catch (error) {
ctx.log.error('failed to load due accrual installments', error as Error)
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'unknown' },
{ success: false, error: error instanceof Error ? getUserErrorMessage(error) : 'unknown' },
{ status: 500 },
)
}
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
const requireAuthMock = vi.fn()
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/bokslut/arsredovisning/model', () => ({
buildCanonicalAnnualReport: vi.fn(),
}))
vi.mock('@/lib/bokslut/arsredovisning/profile-service', () => ({
upsertAnnualReportProfile: vi.fn(),
}))
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import { upsertAnnualReportProfile } from '@/lib/bokslut/arsredovisning/profile-service'
import { GET, PATCH } from '../route'
const params = { params: Promise.resolve({ id: 'period-1' }) }
const model = {
report: {
accounting_framework: 'k2',
forvaltningsberattelse: {
proposed_dividend: 0,
resultatdisposition_amounts: { total: 100 },
},
},
profile: { company_id: 'company-1', fiscal_period_id: 'period-1' },
disclosures: {},
eligibility: {
k2_eligible: true,
digital_filing_eligible: true,
size_classification: 'smaller',
k2_relief_rule: 'eligible',
issues: [],
digital_issues: [],
},
validation: { stage: 'draft', ok: true, error_count: 0, warning_count: 0, issues: [] },
}
function setup() {
const mock = createQueuedMockSupabase()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: mock.supabase,
error: null,
})
vi.mocked(buildCanonicalAnnualReport).mockResolvedValue(model as never)
vi.mocked(upsertAnnualReportProfile).mockResolvedValue(model.profile as never)
return mock
}
beforeEach(() => {
vi.clearAllMocks()
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('annual report compliance route', () => {
it('returns 401 without authentication', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect((await GET(createMockRequest('/x'), params)).status).toBe(401)
})
it('returns 400 for an empty patch', async () => {
setup()
const response = await PATCH(
createMockRequest('/x', { method: 'PATCH', body: {} }),
params,
)
expect(response.status).toBe(400)
})
it('returns 404 for another company period', async () => {
const { enqueue } = setup()
enqueue({ data: null })
expect((await GET(createMockRequest('/x'), params)).status).toBe(404)
})
it('returns the canonical compliance result', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
const { status, body } = await parseJsonResponse<{ data: typeof model }>(
await GET(createMockRequest('/x'), params),
)
expect(status).toBe(200)
expect(body.data.validation.ok).toBe(true)
})
it('persists legal facts and confirmation timestamps', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
const response = await PATCH(
createMockRequest('/x', {
method: 'PATCH',
body: { is_public_limited_company: false, k2_assessment_confirmed: true },
}),
params,
)
expect(response.status).toBe(200)
expect(upsertAnnualReportProfile).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'period-1',
expect.objectContaining({
is_public_limited_company: false,
k2_assessment_confirmed_at: expect.any(String),
}),
)
})
it('records explicit representative roster confirmation', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
const response = await PATCH(
createMockRequest('/x', {
method: 'PATCH',
body: { signer_roster_confirmed: true },
}),
params,
)
expect(response.status).toBe(200)
expect(upsertAnnualReportProfile).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'period-1',
expect.objectContaining({ signer_roster_confirmed_at: expect.any(String) }),
)
})
})
@@ -0,0 +1,127 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import { getAnnualReportCapabilities } from '@/lib/bokslut/arsredovisning/capabilities'
import { upsertAnnualReportProfile } from '@/lib/bokslut/arsredovisning/profile-service'
const NullableBoolean = z.boolean().nullable()
const PatchSchema = z
.object({
is_public_limited_company: NullableBoolean.optional(),
is_in_liquidation: NullableBoolean.optional(),
securities_traded_on_regulated_market: NullableBoolean.optional(),
is_parent_company: NullableBoolean.optional(),
parent_group_size: z.enum(['none', 'small', 'large']).nullable().optional(),
prepares_consolidated_accounts: NullableBoolean.optional(),
has_foreign_branch: NullableBoolean.optional(),
has_crypto_assets: NullableBoolean.optional(),
has_share_based_payments: NullableBoolean.optional(),
has_convertible_debt: NullableBoolean.optional(),
building_revenue_share_pct: z.number().min(0).max(100).nullable().optional(),
has_material_deferred_tax: NullableBoolean.optional(),
reporting_currency: z.enum(['SEK', 'EUR']).optional(),
auditor_report_required: NullableBoolean.optional(),
auditor_report_included: z.boolean().optional(),
dividend_prudence_confirmed: NullableBoolean.optional(),
narrative_confirmed: z.boolean().optional(),
k2_assessment_confirmed: z.boolean().optional(),
signer_roster_confirmed: z.boolean().optional(),
})
.strict()
.refine((value) => Object.keys(value).length > 0, { message: 'At least one field is required' })
async function periodExists(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<boolean> {
const { data } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle()
return Boolean(data)
}
function responseData(model: Awaited<ReturnType<typeof buildCanonicalAnnualReport>>) {
return {
profile: model.profile,
disclosures: model.disclosures,
eligibility: model.eligibility,
validation: model.validation,
capabilities: getAnnualReportCapabilities(model.report.accounting_framework, model.eligibility),
report_summary: {
proposed_dividend: model.report.forvaltningsberattelse.proposed_dividend,
distributable_equity:
model.report.forvaltningsberattelse.resultatdisposition_amounts.total,
},
}
}
export const GET = withRouteContext(
'period.arsredovisning_compliance_get',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
if (!(await periodExists(supabase, companyId, id))) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
stage: 'draft',
includeIxbrl: false,
})
return NextResponse.json({ data: responseData(model) })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
)
export const PATCH = withRouteContext(
'period.arsredovisning_compliance_patch',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, PatchSchema)
if (!validation.success) return validation.response
try {
if (!(await periodExists(supabase, companyId, id))) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
const {
narrative_confirmed,
k2_assessment_confirmed,
signer_roster_confirmed,
...profileFields
} = validation.data
const now = new Date().toISOString()
await upsertAnnualReportProfile(supabase, companyId, user.id, id, {
...profileFields,
...(narrative_confirmed !== undefined
? { narrative_confirmed_at: narrative_confirmed ? now : null }
: {}),
...(k2_assessment_confirmed !== undefined
? { k2_assessment_confirmed_at: k2_assessment_confirmed ? now : null }
: {}),
...(signer_roster_confirmed !== undefined
? { signer_roster_confirmed_at: signer_roster_confirmed ? now : null }
: {}),
})
const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
stage: 'draft',
includeIxbrl: false,
})
return NextResponse.json({ data: responseData(model) })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)
@@ -2,14 +2,17 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document'
import { getVersionIxbrlInput } from '@/lib/bokslut/arsredovisning/version-ixbrl'
/**
* GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl
*
* Generates the iXBRL (XHTML) årsredovisning for the period. The document IS
* the presentation (per TILLAMPNINGSANVISNING): the wizard renders it in an
* iframe as the authoritative preview, and `?download=1` hands the same bytes
* to the user for manual filing at bolagsverket.se (the self-hosted path).
* iframe as the authoritative preview, and `?download=1` exports the same
* bytes for validation and the company's archive. The XHTML file is not a
* standalone manual filing path: digital filing goes through connected
* software and the paper fallback is a certified copy sent by post.
*
* Query params:
* - download=1 → Content-Disposition: attachment
@@ -23,12 +26,19 @@ export const GET = withRouteContext(
try {
const url = new URL(request.url)
const download = url.searchParams.get('download') === '1'
const versionId = url.searchParams.get('version')
const utdelningRaw = url.searchParams.get('utdelning')
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : 0
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : undefined
const input = await buildIxbrlInput(supabase, companyId, id, {
proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0,
})
const input = versionId
? await getVersionIxbrlInput(supabase, companyId, id, versionId)
: await buildIxbrlInput(supabase, companyId, id, {
proposedDividend:
proposedDividend !== undefined && Number.isFinite(proposedDividend)
? proposedDividend
: undefined,
})
if (!input) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const { xhtml, warnings } = generateK2IxbrlDocument(input)
const safePeriodEnd = input.period.end.replace(/[^\w.-]/g, '_')
@@ -43,6 +53,7 @@ export const GET = withRouteContext(
Pragma: 'no-cache',
// Generation warnings surfaced without disturbing the body.
'X-Ixbrl-Warning-Count': String(warnings.length),
...(versionId ? { 'X-Annual-Report-Version': versionId } : {}),
},
})
} catch (err) {
@@ -4,6 +4,8 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document'
import { runPreflightChecks, type PreflightIssue } from '@/lib/bokslut/ixbrl/validate/rules'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { getVersionIxbrlInput } from '@/lib/bokslut/arsredovisning/version-ixbrl'
/**
* GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl/validate
@@ -21,12 +23,19 @@ export const GET = withRouteContext(
const { supabase, companyId, log, requestId } = ctx
try {
const url = new URL(request.url)
const versionId = url.searchParams.get('version')
const utdelningRaw = url.searchParams.get('utdelning')
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : 0
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : undefined
const input = await buildIxbrlInput(supabase, companyId, id, {
proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0,
})
const input = versionId
? await getVersionIxbrlInput(supabase, companyId, id, versionId)
: await buildIxbrlInput(supabase, companyId, id, {
proposedDividend:
proposedDividend !== undefined && Number.isFinite(proposedDividend)
? proposedDividend
: undefined,
})
if (!input) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const result = runPreflightChecks(input)
// Generation dry-run: a document that cannot even be generated must
@@ -47,7 +56,7 @@ export const GET = withRouteContext(
issues.push({
code: 'ACC-GEN',
severity: 'error',
message: `iXBRL-dokumentet kunde inte genereras: ${genErr instanceof Error ? genErr.message : 'okänt fel'}`,
message: `iXBRL-dokumentet kunde inte genereras: ${genErr instanceof Error ? getUserErrorMessage(genErr) : 'okänt fel'}`,
})
}
@@ -61,6 +70,7 @@ export const GET = withRouteContext(
generated_bytes: generatedBytes,
entry_point: input.entryPointId,
period: input.period,
annual_report_version_id: versionId,
},
})
} catch (err) {
@@ -121,6 +121,15 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', (
expect(res.status).toBe(400)
})
it('returns 400 when the payload contains an unknown field', async () => {
setupSupabase()
const res = await POST(
postReq({ description: 'x', annual_report_version_id: 'forged-version' }),
idParams,
)
expect(res.status).toBe(400)
})
it('returns 404 when the period does not belong to the company', async () => {
const { enqueue } = setupSupabase()
enqueue({ data: null }) // fiscal_periods ownership check
@@ -135,6 +144,7 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', (
enqueue({ data: { id: 'period-1' } }) // fiscal_periods ownership check
enqueue({ data: null }) // no registrerad submission
enqueue({ data: narrativeRow }) // upsert
enqueue({ data: null }) // clear narrative confirmation
const { status, body } = await parseJsonResponse<{ data: typeof narrativeRow }>(
await POST(postReq({ description: 'Bolaget bedriver konsultverksamhet.' }), idParams),
)
@@ -30,6 +30,15 @@ const PostSchema = z.object({
description: sanitizedText(4000).nullable().optional(),
important_events: sanitizedText(4000).nullable().optional(),
resultatdisposition: sanitizedText(2000).nullable().optional(),
proposed_dividend: z
.number()
.min(0)
.max(1_000_000_000_000)
.nullable()
.optional()
.transform((value) =>
value === null || value === undefined ? value : Math.round(value * 100) / 100,
),
// ISO YYYY-MM-DD per the DATE column; null clears it. Validate as a
// real calendar date (not just regex) so '2024-13-99' returns 400 from
// the API instead of bubbling up as a Postgres 500.
@@ -75,6 +84,26 @@ const PostSchema = z.object({
.nullable()
.optional(),
parent_company_city: sanitizedText(100).nullable().optional(),
long_term_debt_over_five_years_confirmed: z.boolean().optional(),
securities_pledged_confirmed: z.boolean().optional(),
contingent_liabilities_confirmed: z.boolean().optional(),
parent_company_confirmed: z.boolean().optional(),
agm_disposition_outcome: z
.enum(['proposal_approved', 'alternative_decision'])
.nullable()
.optional(),
agm_disposition_decision: sanitizedText(2000).nullable().optional(),
}).strict().superRefine((value, ctx) => {
if (
value.agm_disposition_outcome === 'alternative_decision' &&
!value.agm_disposition_decision?.trim()
) {
ctx.addIssue({
code: 'custom',
path: ['agm_disposition_decision'],
message: 'Årsstämmans alternativa beslut måste beskrivas.',
})
}
})
export const GET = withRouteContext(
@@ -150,6 +179,14 @@ export const POST = withRouteContext(
return errorResponseFromCode('ARSREDOVISNING_REGISTERED', log, { requestId })
}
const data = await upsertNarrative(supabase, companyId, user.id, id, validation.data)
const { error: confirmationError } = await supabase
.from('annual_report_profiles')
.update({ narrative_confirmed_at: null })
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
if (confirmationError) {
throw new Error(`Failed to clear narrative confirmation: ${confirmationError.message}`)
}
return NextResponse.json({ data })
} catch (err) {
return errorResponse(err, log, { requestId })
@@ -1,20 +1,53 @@
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import { getAnnualReportVersion } from '@/lib/bokslut/arsredovisning/version-service'
import { ArsredovisningPDF } from '@/lib/bokslut/arsredovisning/arsredovisning-pdf'
import { ArsredovisningK3PDF } from '@/lib/bokslut/arsredovisning/arsredovisning-k3-pdf'
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
export const GET = withRouteContext(
'period.arsredovisning_pdf',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
// Narrative edits come from arsredovisning_narratives now, loaded
// inside buildArsredovisningData. The URL stays clean, no narrative
// text in query params, access logs, or browser history.
const data = await buildArsredovisningData(supabase, companyId, id)
const versionId = new URL(request.url).searchParams.get('version')
let versionStatus: string | null = null
let data: ArsredovisningData
if (versionId) {
const version = await getAnnualReportVersion(supabase, companyId, id, versionId)
if (!version) {
return errorResponseFromCode('NOT_FOUND', log, { requestId })
}
versionStatus = version.summary.status
data = structuredClone(version.report_data)
const { data: signatureRows, error: signatureError } = await supabase
.from('arsredovisning_signature_requests')
.select('role, signer_name, signed_at')
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
.eq('annual_report_version_id', versionId)
.order('created_at', { ascending: true })
if (signatureError) {
throw new Error(`Failed to load version signatures: ${signatureError.message}`)
}
data.signatures = (signatureRows ?? []).map((signature) => ({
role: signature.role,
name: signature.signer_name,
signed_at: signature.signed_at,
}))
} else {
const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
stage: 'draft',
includeIxbrl: false,
})
data = model.report
}
// Dispatch on the framework recorded in the data. K3 documents need
// the additional kassaflöde + equity-changes pages + richer noter
// that ArsredovisningK3PDF renders. K2 (the default) keeps the
@@ -29,7 +62,10 @@ export const GET = withRouteContext(
// Sanitize the dynamic segment so a stray quote / newline in the date
// (defensive, unlikely to ever happen) can't break the header.
const safePeriodEnd = data.fiscal_period.period_end.replace(/[^\w.-]/g, '_')
const filename = `arsredovisning-${safePeriodEnd}-utkast.pdf`
const suffix = versionStatus && ['signed', 'filed', 'registered'].includes(versionStatus)
? 'papperskopia'
: 'utkast'
const filename = `arsredovisning-${safePeriodEnd}-${suffix}.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
@@ -38,6 +74,7 @@ export const GET = withRouteContext(
// intermediary cache the document.
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
...(versionId ? { 'X-Annual-Report-Version': versionId } : {}),
},
})
} catch (err) {
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import { getAnnualReportCapabilities } from '@/lib/bokslut/arsredovisning/capabilities'
export const GET = withRouteContext(
'period.arsredovisning_data',
@@ -9,8 +10,23 @@ export const GET = withRouteContext(
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const data = await buildArsredovisningData(supabase, companyId, id)
return NextResponse.json({ data })
const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
stage: 'draft',
includeIxbrl: false,
})
return NextResponse.json({
data: model.report,
compliance: {
profile: model.profile,
disclosures: model.disclosures,
eligibility: model.eligibility,
validation: model.validation,
capabilities: getAnnualReportCapabilities(
model.report.accounting_framework,
model.eligibility,
),
},
})
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
const requireAuthMock = vi.fn()
const requireWriteMock = vi.fn()
const createServiceClientMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => createServiceClientMock(),
}))
import { DELETE, PATCH } from '../route'
const versionId = '123e4567-e89b-12d3-a456-426614174000'
const params = {
params: Promise.resolve({ id: 'period-1', signatureId: 'signature-1' }),
}
const signedBody = {
status: 'signed',
annual_report_version_id: versionId,
signing_method: 'paper_original',
evidence_reference: 'archive:A-1',
signed_at: '2026-03-01T10:00:00.000Z',
}
function setup() {
const mock = createQueuedMockSupabase()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: mock.supabase,
error: null,
})
createServiceClientMock.mockReturnValue(mock.supabase)
return mock
}
beforeEach(() => {
vi.clearAllMocks()
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('annual report signature transition', () => {
it('returns 401 without authentication', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect(
(
await PATCH(
createMockRequest('/x', { method: 'PATCH', body: signedBody }),
params,
)
).status,
).toBe(401)
})
it('returns 400 when signed evidence is missing', async () => {
setup()
const response = await PATCH(
createMockRequest('/x', { method: 'PATCH', body: { status: 'signed' } }),
params,
)
expect(response.status).toBe(400)
})
it('returns 400 for a free-text evidence reference', async () => {
setup()
const response = await PATCH(
createMockRequest('/x', {
method: 'PATCH',
body: { ...signedBody, evidence_reference: 'Original i arkiv A-1' },
}),
params,
)
expect(response.status).toBe(400)
})
it('returns 409 when the version is not ready for signature', async () => {
const { enqueue } = setup()
enqueue({ data: null })
const response = await PATCH(
createMockRequest('/x', { method: 'PATCH', body: signedBody }),
params,
)
expect(response.status).toBe(409)
})
it('stores version-bound signature evidence', async () => {
const { enqueue } = setup()
enqueue({
data: {
id: versionId,
status: 'ready_for_signature',
finalized_at: '2026-03-01T08:00:00.000Z',
},
})
enqueue({ data: { id: 'signature-1', annual_report_version_id: versionId, status: 'pending' } })
enqueue({
data: {
id: 'signature-1',
company_id: 'company-1',
fiscal_period_id: 'period-1',
status: 'signed',
signing_method: 'paper_original',
evidence_reference: 'archive:A-1',
},
})
const { status, body } = await parseJsonResponse<{
data: { status: string; evidence_reference: string }
}>(
await PATCH(
createMockRequest('/x', { method: 'PATCH', body: signedBody }),
params,
),
)
expect(status).toBe(200)
expect(body.data.status).toBe('signed')
expect(body.data.evidence_reference).toBe('archive:A-1')
expect(createServiceClientMock).toHaveBeenCalledOnce()
})
it('rejects a signature date before the version was finalized', async () => {
const { enqueue } = setup()
enqueue({
data: {
id: versionId,
status: 'ready_for_signature',
finalized_at: '2026-03-02T08:00:00.000Z',
},
})
enqueue({ data: { id: 'signature-1', annual_report_version_id: versionId, status: 'pending' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await PATCH(
createMockRequest('/x', { method: 'PATCH', body: signedBody }),
params,
),
)
expect(status).toBe(400)
expect(body.error.code).toBe('ARSREDOVISNING_SIGNATURE_DATE_INVALID')
expect(createServiceClientMock).not.toHaveBeenCalled()
})
it('rejects a future signature date', async () => {
const { enqueue } = setup()
enqueue({
data: {
id: versionId,
status: 'ready_for_signature',
finalized_at: '2026-03-01T08:00:00.000Z',
},
})
enqueue({ data: { id: 'signature-1', annual_report_version_id: versionId, status: 'pending' } })
const response = await PATCH(
createMockRequest('/x', {
method: 'PATCH',
body: { ...signedBody, signed_at: '2999-03-01T10:00:00.000Z' },
}),
params,
)
expect(response.status).toBe(400)
})
})
describe('annual report draft signer removal', () => {
it('returns 401 without authentication', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect((await DELETE(createMockRequest('/x', { method: 'DELETE' }), params)).status).toBe(401)
})
it('returns 409 when the signer is already bound to a version', async () => {
const { enqueue } = setup()
enqueue({ data: null, error: null })
expect((await DELETE(createMockRequest('/x', { method: 'DELETE' }), params)).status).toBe(409)
})
it('removes an unbound pending signer', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'signature-1' }, error: null })
expect((await DELETE(createMockRequest('/x', { method: 'DELETE' }), params)).status).toBe(204)
})
})
@@ -1,8 +1,10 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { markSignatureSigned } from '@/lib/bokslut/arsredovisning/signature-service'
import { createServiceClient } from '@/lib/supabase/server'
// PATCH transitions: pending → signed (manual entry for the paper / outside-
// BankID flow) or pending → declined. Real BankID wiring lands in a future
@@ -14,9 +16,39 @@ import { validateBody } from '@/lib/api/validate'
// /periods/A/signatures/SIG_FROM_B can't bypass the path scope
// - .eq('status', 'pending'): state-machine guard so a signed or declined
// row can't be flipped back
const PatchSchema = z.object({
status: z.enum(['signed', 'declined']),
})
const EvidenceReferenceSchema = z
.string()
.trim()
.min(1)
.max(128)
.regex(/^(archive|document|receipt):[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$/)
const PatchSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('signed'),
annual_report_version_id: z.string().uuid(),
signing_method: z.enum(['paper_original', 'advanced_e_signature', 'bankid']),
evidence_reference: EvidenceReferenceSchema,
signed_at: z.string().datetime().optional(),
}).strict(),
z.object({ status: z.literal('declined') }).strict(),
])
function stockholmDate(instant: string | Date): string {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Stockholm',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(typeof instant === 'string' ? new Date(instant) : instant)
}
function isSignatureDateAllowed(signedAt: string, finalizedAt: string): boolean {
const signedDate = stockholmDate(signedAt)
const finalizedDate = stockholmDate(finalizedAt)
const today = stockholmDate(new Date())
return signedDate >= finalizedDate && signedDate <= today
}
export const PATCH = withRouteContext(
'period.arsredovisning_signature_patch',
@@ -30,15 +62,65 @@ export const PATCH = withRouteContext(
const validation = await validateBody(request, PatchSchema)
if (!validation.success) return validation.response
const update =
validation.data.status === 'signed'
? { status: 'signed' as const, signed_at: new Date().toISOString() }
: { status: 'declined' as const }
try {
const { data, error } = await supabase
if (validation.data.status === 'signed') {
const { data: version, error: versionError } = await supabase
.from('annual_report_versions')
.select('id, status, finalized_at')
.eq('id', validation.data.annual_report_version_id)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'ready_for_signature')
.maybeSingle()
if (versionError) {
throw new Error(`Failed to load annual report version: ${versionError.message}`)
}
if (!version?.finalized_at) {
return errorResponseFromCode('ARSREDOVISNING_VERSION_NOT_SIGNABLE', log, {
requestId,
})
}
const { data: requestRow, error: requestError } = await supabase
.from('arsredovisning_signature_requests')
.select('id, annual_report_version_id, status')
.eq('id', signatureId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'pending')
.maybeSingle()
if (requestError) {
throw new Error(`Failed to load annual report signature: ${requestError.message}`)
}
if (
!requestRow ||
(requestRow.annual_report_version_id !== null &&
requestRow.annual_report_version_id !== validation.data.annual_report_version_id)
) {
return NextResponse.json(
{ error: { code: 'SIGNATURE_INVALID_TRANSITION' } },
{ status: 409 },
)
}
const signedAt = validation.data.signed_at ?? new Date().toISOString()
if (!isSignatureDateAllowed(signedAt, version.finalized_at)) {
return errorResponseFromCode('ARSREDOVISNING_SIGNATURE_DATE_INVALID', log, {
requestId,
})
}
const data = await markSignatureSigned(createServiceClient(), companyId, signatureId, {
fiscalPeriodId,
annualReportVersionId: validation.data.annual_report_version_id,
signingMethod: validation.data.signing_method,
evidenceReference: validation.data.evidence_reference,
evidenceRecordedBy: ctx.user.id,
signedAt,
})
return NextResponse.json({ data })
}
const { data, error } = await createServiceClient()
.from('arsredovisning_signature_requests')
.update(update)
.update({ status: 'declined' as const })
.eq('id', signatureId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
@@ -65,3 +147,38 @@ export const PATCH = withRouteContext(
},
{ requireWrite: true },
)
export const DELETE = withRouteContext(
'period.arsredovisning_signature_delete',
async (
_request,
ctx,
{ params }: { params: Promise<{ id: string; signatureId: string }> },
) => {
const { id: fiscalPeriodId, signatureId } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.delete()
.eq('id', signatureId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'pending')
.is('annual_report_version_id', null)
.select('id')
.maybeSingle()
if (error) throw new Error(`Failed to remove annual report signer: ${error.message}`)
if (!data) {
return NextResponse.json(
{ error: { code: 'ARSREDOVISNING_SIGNER_ROSTER_LOCKED' } },
{ status: 409 },
)
}
return new NextResponse(null, { status: 204 })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
const requireAuthMock = vi.fn()
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
import { GET, POST } from '../route'
const params = { params: Promise.resolve({ id: 'period-1' }) }
const validBody = { role: 'Styrelseledamot', signer_name: 'Anna Andersson' }
function setup() {
const mock = createQueuedMockSupabase()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: mock.supabase,
error: null,
})
return mock
}
beforeEach(() => {
vi.clearAllMocks()
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('annual report signer roster route', () => {
it('returns 401 without authentication', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect((await GET(createMockRequest('/x'), params)).status).toBe(401)
})
it('returns 400 for an invalid signer', async () => {
setup()
const response = await POST(
createMockRequest('/x', { method: 'POST', body: { role: 'Administrator' } }),
params,
)
expect(response.status).toBe(400)
})
it('returns 404 for another company period', async () => {
const { enqueue } = setup()
enqueue({ data: null, error: null })
const response = await POST(
createMockRequest('/x', { method: 'POST', body: validBody }),
params,
)
expect(response.status).toBe(404)
})
it('returns 409 for a duplicate unbound signer', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' }, error: null })
enqueue({ data: { id: 'signature-existing' }, error: null })
const response = await POST(
createMockRequest('/x', { method: 'POST', body: validBody }),
params,
)
expect(response.status).toBe(409)
})
it('creates a new unbound signer roster slot', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' }, error: null })
enqueue({ data: null, error: null })
enqueue({
data: {
id: 'signature-1',
company_id: 'company-1',
fiscal_period_id: 'period-1',
role: 'Styrelseledamot',
signer_name: 'Anna Andersson',
status: 'pending',
annual_report_version_id: null,
},
error: null,
})
const { status, body } = await parseJsonResponse<{
data: { id: string; annual_report_version_id: null }
}>(
await POST(createMockRequest('/x', { method: 'POST', body: validBody }), params),
)
expect(status).toBe(200)
expect(body.data).toMatchObject({ id: 'signature-1', annual_report_version_id: null })
})
})
@@ -14,7 +14,7 @@ import {
// (Art.25.2): Swedish names are well under that; bound is a defense.
const CreateSchema = z.object({
role: z.enum(['Styrelseledamot', 'Styrelseordförande', 'VD', 'Verkställande direktör']),
signer_name: z.string().min(1).max(200),
signer_name: z.string().trim().min(1).max(200),
})
export const GET = withRouteContext(
@@ -43,15 +43,35 @@ export const POST = withRouteContext(
// authenticated company before writing. RLS would catch a cross-tenant
// insert anyway, but rejecting at the route layer gives a cleaner
// 404 + avoids the noisy RLS error in the structured-error envelope.
const { data: period } = await supabase
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (periodError) throw new Error(`Failed to load fiscal period: ${periodError.message}`)
if (!period) {
return NextResponse.json({ error: { code: 'PERIOD_NOT_FOUND' } }, { status: 404 })
}
const { data: duplicate, error: duplicateError } = await supabase
.from('arsredovisning_signature_requests')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
.eq('status', 'pending')
.is('annual_report_version_id', null)
.ilike('role', validation.data.role.trim())
.ilike('signer_name', validation.data.signer_name.trim())
.maybeSingle()
if (duplicateError) {
throw new Error(`Failed to check annual report signer roster: ${duplicateError.message}`)
}
if (duplicate) {
return NextResponse.json(
{ error: { code: 'ARSREDOVISNING_SIGNER_ALREADY_EXISTS' } },
{ status: 409 },
)
}
const data = await createSignatureRequest(supabase, companyId, user.id, id, validation.data)
return NextResponse.json({ data })
} catch (err) {
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
const requireAuthMock = vi.fn()
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(() => ({ rpc: vi.fn() })),
}))
vi.mock('@/lib/bokslut/arsredovisning/model', () => ({
buildCanonicalAnnualReport: vi.fn(),
}))
vi.mock('@/lib/bokslut/arsredovisning/version-service', () => ({
createAnnualReportVersion: vi.fn(),
listAnnualReportVersions: vi.fn(),
}))
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import {
createAnnualReportVersion,
listAnnualReportVersions,
} from '@/lib/bokslut/arsredovisning/version-service'
import { GET, POST } from '../route'
const params = { params: Promise.resolve({ id: 'period-1' }) }
const version = {
id: 'version-1',
version_number: 1,
status: 'draft',
framework: 'k2',
content_hash: 'a'.repeat(64),
taxonomy_version: '2024-09-12',
entry_point: 'k2-ab-risbs-2024-09-12',
finalized_at: null,
created_at: '2026-07-21T10:00:00Z',
}
function setup() {
const mock = createQueuedMockSupabase()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: mock.supabase,
error: null,
})
vi.mocked(listAnnualReportVersions).mockResolvedValue([version] as never)
vi.mocked(createAnnualReportVersion).mockResolvedValue(version as never)
vi.mocked(buildCanonicalAnnualReport).mockResolvedValue({
validation: { ok: true },
} as never)
return mock
}
beforeEach(() => {
vi.clearAllMocks()
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('annual report versions route', () => {
it('returns 401 without authentication', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect((await GET(createMockRequest('/x'), params)).status).toBe(401)
})
it('returns 400 for an invalid action', async () => {
setup()
const response = await POST(
createMockRequest('/x', { method: 'POST', body: { action: 'delete' } }),
params,
)
expect(response.status).toBe(400)
})
it('rejects a client-supplied dividend override', async () => {
setup()
const response = await POST(
createMockRequest('/x', {
method: 'POST',
body: { action: 'finalize', proposed_dividend: 100 },
}),
params,
)
expect(response.status).toBe(400)
expect(buildCanonicalAnnualReport).not.toHaveBeenCalled()
})
it('returns 404 for another company period', async () => {
const { enqueue } = setup()
enqueue({ data: null })
expect((await GET(createMockRequest('/x'), params)).status).toBe(404)
})
it('lists immutable versions', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
const { body } = await parseJsonResponse<{ data: Array<typeof version> }>(
await GET(createMockRequest('/x'), params),
)
expect(body.data[0].content_hash).toHaveLength(64)
})
it('creates a finalized version through the canonical model', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
const response = await POST(
createMockRequest('/x', { method: 'POST', body: { action: 'finalize' } }),
params,
)
expect(response.status).toBe(201)
expect(buildCanonicalAnnualReport).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'period-1',
expect.objectContaining({ stage: 'signing' }),
)
expect(createAnnualReportVersion).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.anything(),
true,
)
})
it('accepts a VD as the fastställelseintyg signer', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
const response = await POST(
createMockRequest('/x', {
method: 'POST',
body: {
action: 'finalize',
certificate_signer: {
first_name: 'Anna',
last_name: 'Andersson',
role: 'VD',
},
},
}),
params,
)
expect(response.status).toBe(201)
expect(buildCanonicalAnnualReport).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'period-1',
expect.objectContaining({
undertecknare: expect.objectContaining({ role: 'VD' }),
}),
)
})
})
@@ -0,0 +1,109 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createServiceClient } from '@/lib/supabase/server'
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import {
createAnnualReportVersion,
listAnnualReportVersions,
} from '@/lib/bokslut/arsredovisning/version-service'
const PostSchema = z
.object({
action: z.enum(['snapshot', 'finalize']),
certificate_signer: z
.object({
first_name: z.string().min(1).max(100),
last_name: z.string().min(1).max(100),
role: z.enum([
'Styrelseledamot',
'Styrelseordförande',
'VD',
'Verkställande direktör',
]),
})
.strict()
.optional(),
})
.strict()
async function ownsPeriod(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<boolean> {
const { data } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle()
return Boolean(data)
}
export const GET = withRouteContext(
'period.arsredovisning_versions_list',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
if (!(await ownsPeriod(supabase, companyId, id))) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
const data = await listAnnualReportVersions(supabase, companyId, id)
return NextResponse.json({ data })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
)
export const POST = withRouteContext(
'period.arsredovisning_versions_create',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, PostSchema)
if (!validation.success) return validation.response
try {
if (!(await ownsPeriod(supabase, companyId, id))) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
const signer = validation.data.certificate_signer
const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
stage: validation.data.action === 'finalize' ? 'signing' : 'draft',
undertecknare: signer
? {
firstName: signer.first_name,
lastName: signer.last_name,
role: signer.role,
}
: undefined,
})
if (validation.data.action === 'finalize' && !model.validation.ok) {
return NextResponse.json(
{
error: {
code: 'ARSREDOVISNING_INCOMPLETE',
details: model.validation,
},
},
{ status: 409 },
)
}
const data = await createAnnualReportVersion(
validation.data.action === 'finalize' ? createServiceClient() : supabase,
user.id,
model,
validation.data.action === 'finalize',
)
return NextResponse.json({ data }, { status: 201 })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildBokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
@@ -27,7 +28,7 @@ export const GET = withRouteContext(
}
return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, {
requestId,
details: { reason: message },
details: { reason: getErrorMessage(err) },
})
}
},
@@ -8,6 +8,37 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/bokslut/dispositions-proposal-builder', () => ({
buildDispositionsProposal: vi.fn(),
buildLatentTaxProposal: vi.fn(),
}))
vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({
loadTaxAdjustmentSnapshot: vi.fn(),
saveTaxAdjustments: vi.fn(),
}))
vi.mock('@/lib/bokslut/tax-provision/bolagsskatt-calculator', () => ({
calculateBolagsskatt: vi.fn(),
getBookedBolagsskatt: vi.fn(),
sumPostedYearEndDispositions: vi.fn(),
}))
vi.mock('@/lib/reports/income-statement', () => ({
generateIncomeStatement: vi.fn(),
}))
vi.mock('@/lib/bokslut/reserves/periodiseringsfond-service', async (importOriginal) => {
const actual = await importOriginal<
typeof import('@/lib/bokslut/reserves/periodiseringsfond-service')
>()
return { ...actual, listExistingPeriodiseringsfonder: vi.fn() }
})
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
}))
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
@@ -23,7 +54,20 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
import { POST } from '../route'
import { buildDispositionsProposal } from '@/lib/bokslut/dispositions-proposal-builder'
import {
loadTaxAdjustmentSnapshot,
saveTaxAdjustments,
} from '@/lib/bokslut/tax-provision/tax-adjustment-service'
import {
calculateBolagsskatt,
getBookedBolagsskatt,
sumPostedYearEndDispositions,
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { listExistingPeriodiseringsfonder } from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { POST, PUT } from '../route'
const idParams = { params: Promise.resolve({ id: 'period-1' }) }
@@ -37,10 +81,152 @@ function post(body: unknown) {
)
}
function put(body: unknown) {
return PUT(
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/bokslutsdispositioner', {
method: 'PUT',
body,
}),
idParams,
)
}
function periodClient(period: unknown, error: unknown = null) {
const builder = {
select: vi.fn(),
eq: vi.fn(),
single: vi.fn().mockResolvedValue({ data: period, error }),
}
builder.select.mockReturnValue(builder)
builder.eq.mockReturnValue(builder)
return { from: vi.fn().mockReturnValue(builder) }
}
beforeEach(() => {
vi.clearAllMocks()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
vi.mocked(buildDispositionsProposal).mockResolvedValue({
entityType: 'aktiebolag',
fiscalPeriod: {
id: 'period-1',
name: '2025',
period_start: '2024-10-07',
period_end: '2025-12-31',
},
netResultBefore: 592_722.21,
proposals: [],
})
vi.mocked(saveTaxAdjustments).mockResolvedValue()
vi.mocked(loadTaxAdjustmentSnapshot).mockResolvedValue({
items: [],
nonDeductibleExpenses: 5_244,
nonTaxableIncome: 0,
})
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 592_722.21,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
vi.mocked(sumPostedYearEndDispositions).mockResolvedValue({
total: 0,
slpPortion: 0,
taxProvisionPortion: 0,
})
vi.mocked(getBookedBolagsskatt).mockResolvedValue(0)
vi.mocked(listExistingPeriodiseringsfonder).mockResolvedValue([])
vi.mocked(calculateBolagsskatt).mockResolvedValue({
kind: 'bolagsskatt',
label: 'Bolagsskatt 20,6 %',
description: 'Skatt på årets skattemässiga resultat.',
amount: 123_180,
lines: [
{ account_number: '8910', debit_amount: 123_180, credit_amount: 0 },
{ account_number: '2512', debit_amount: 0, credit_amount: 123_180 },
],
warnings: [],
})
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'entry-tax' } as Awaited<
ReturnType<typeof createJournalEntry>
>)
})
describe('PUT /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () => {
const validBody = {
manualAdjustments: { nonDeductibleExpenses: 0, nonTaxableIncome: 0 },
detectedAccounts: { '6992': true, '8423': true },
}
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect((await put(validBody)).status).toBe(401)
})
it('returns 400 for a negative manual adjustment', async () => {
const res = await put({
...validBody,
manualAdjustments: { nonDeductibleExpenses: -1, nonTaxableIncome: 0 },
})
expect(res.status).toBe(400)
})
it('returns 404 when the fiscal period is missing', async () => {
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: periodClient(null, { message: 'not found' }),
error: null,
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await put(validBody),
)
expect(status).toBe(404)
expect(body.error.code).toBe('PERIOD_NOT_FOUND')
})
it('returns PERIOD_LOCKED when the fiscal period is locked', async () => {
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: periodClient({
id: 'period-1',
is_closed: false,
locked_at: '2026-07-21T08:00:00Z',
closing_entry_id: null,
}),
error: null,
})
const { body } = await parseJsonResponse<{ error: { code: string } }>(await put(validBody))
expect(body.error.code).toBe('PERIOD_LOCKED')
})
it('saves the adjustments and returns the recalculated proposal', async () => {
const supabase = periodClient({
id: 'period-1',
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
const { status, body } = await parseJsonResponse<{ data: { netResultBefore: number } }>(
await put(validBody),
)
expect(status).toBe(200)
expect(saveTaxAdjustments).toHaveBeenCalledWith(
supabase,
'company-1',
'period-1',
'user-1',
validBody,
)
expect(body.data.netResultBefore).toBe(592_722.21)
})
})
describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () => {
@@ -85,4 +271,103 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () =
const { status } = await parseJsonResponse(await post({ items: [] }))
expect(status).toBe(400)
})
it('rejects legacy client-supplied tax adjustments with 400', async () => {
const { status } = await parseJsonResponse(
await post({
items: [{
kind: 'bolagsskatt',
manualAdjustments: { nonDeductibleExpenses: 999_999 },
}],
}),
)
expect(status).toBe(400)
})
it('posts the calculated tax through the bookkeeping engine', async () => {
const supabase = periodClient({
id: 'period-1',
name: '2025',
period_start: '2024-10-07',
period_end: '2025-12-31',
opening_balance_entry_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
const { status, body } = await parseJsonResponse<{
data: { created: Array<{ kind: string }> }
}>(await post({ items: [{ kind: 'bolagsskatt' }] }))
expect(status).toBe(200)
expect(body.data.created).toHaveLength(1)
expect(createJournalEntry).toHaveBeenCalledOnce()
expect(createJournalEntry).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({ source_id: 'period-1' }),
)
})
it('does not post a duplicate when the same tax is already booked', async () => {
const supabase = periodClient({
id: 'period-1',
name: '2025',
period_start: '2024-10-07',
period_end: '2025-12-31',
opening_balance_entry_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
vi.mocked(getBookedBolagsskatt).mockResolvedValue(123_180)
const { status, body } = await parseJsonResponse<{
data: { created: Array<{ kind: string }> }
}>(await post({ items: [{ kind: 'bolagsskatt' }] }))
expect(status).toBe(200)
expect(body.data.created).toEqual([])
expect(createJournalEntry).not.toHaveBeenCalled()
})
it('returns 409 instead of posting over a different booked tax amount', async () => {
const supabase = periodClient({
id: 'period-1',
name: '2025',
period_start: '2024-10-07',
period_end: '2025-12-31',
opening_balance_entry_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
vi.mocked(getBookedBolagsskatt).mockResolvedValue(123_181)
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { bookedAmount: number; expectedAmount: number } }
}>(await post({ items: [{ kind: 'bolagsskatt' }] }))
expect(status).toBe(409)
expect(body.error.code).toBe('CONFLICT')
expect(body.error.details).toEqual({ bookedAmount: 123_181, expectedAmount: 123_180 })
expect(createJournalEntry).not.toHaveBeenCalled()
})
})
@@ -4,10 +4,16 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { BookkeepingDatabaseError } from '@/lib/bookkeeping/errors'
import {
calculateBolagsskatt,
getBookedBolagsskatt,
sumPostedYearEndDispositions,
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
import {
loadTaxAdjustmentSnapshot,
saveTaxAdjustments,
} from '@/lib/bokslut/tax-provision/tax-adjustment-service'
import { calculateSarskildLoneskatt } from '@/lib/bokslut/tax-provision/sarskild-loneskatt-calculator'
import {
getPeriodiseringsfondCohortAccount,
@@ -76,21 +82,68 @@ export const GET = withRouteContext(
},
)
const PutBodySchema = z.object({
manualAdjustments: z.object({
nonDeductibleExpenses: z.number().nonnegative().max(1_000_000_000_000),
nonTaxableIncome: z.number().nonnegative().max(1_000_000_000_000),
}),
detectedAccounts: z.object({
'6992': z.boolean(),
'8423': z.boolean(),
}),
})
export const PUT = withRouteContext(
'period.bokslutsdispositioner_adjustments',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ periodId: id })
const validation = await validateBody(request, PutBodySchema)
if (!validation.success) return validation.response
try {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, is_closed, locked_at, closing_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
if (period.is_closed || period.locked_at || period.closing_entry_id) {
return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId })
}
await saveTaxAdjustments(
supabase,
companyId,
id,
user.id,
validation.data,
)
const data = await buildDispositionsProposal(supabase, companyId, id)
return NextResponse.json({ data })
} catch (err) {
if (err instanceof Error && /locked for tax adjustments/i.test(err.message)) {
return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId })
}
opLog.error('bokslutsdispositioner adjustments failed', err as Error)
return errorResponse(err, opLog, { requestId })
}
},
{ requireWrite: true },
)
// ============================================================
// POST: commit a list of dispositions chosen by the user
// ============================================================
const ItemSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('bolagsskatt'),
manualAdjustments: z
.object({
nonDeductibleExpenses: z.number().optional(),
nonTaxableIncome: z.number().optional(),
schablonintaktPeriodiseringsfond: z.number().optional(),
other: z.number().optional(),
})
.optional(),
}),
}).strict(),
z.object({
kind: z.literal('sarskild_loneskatt'),
manualAdjustment: z.number().optional(),
@@ -181,6 +234,7 @@ export const POST = withRouteContext(
entry_date: period.period_end,
description: `Bokslutsdisposition: ${proposal.label}`,
source_type: 'year_end',
source_id: item.kind === 'bolagsskatt' ? id : undefined,
voucher_series: 'A',
lines: proposal.lines,
})
@@ -189,6 +243,30 @@ export const POST = withRouteContext(
return NextResponse.json({ data: { created } })
} catch (err) {
if (err instanceof TaxProvisionConflictError) {
return errorResponseFromCode('CONFLICT', opLog, {
requestId,
messageSv:
'Bolagsskatt finns redan bokförd med ett annat belopp. Rätta den befintliga verifikationen med en ändringsverifikation innan du fortsätter.',
messageEn:
'Corporate tax is already posted with a different amount. Correct the existing voucher before continuing.',
details: {
bookedAmount: err.bookedAmount,
expectedAmount: err.expectedAmount,
},
})
}
if (
err instanceof BookkeepingDatabaseError
&& err.operation === 'create_draft_entry'
&& err.cause?.includes('uq_year_end_corporate_tax_per_period')
) {
return errorResponseFromCode('CONFLICT', opLog, {
requestId,
messageSv: 'Bolagsskatten bokförs redan. Ladda om sidan innan du fortsätter.',
messageEn: 'Corporate tax is already being posted. Reload the page before continuing.',
})
}
opLog.error('bokslutsdispositioner post failed', err as Error)
return errorResponse(err, opLog, { requestId })
}
@@ -208,6 +286,15 @@ interface ValidatedPeriod {
opening_balance_entry_id: string | null
}
class TaxProvisionConflictError extends Error {
constructor(
readonly bookedAmount: number,
readonly expectedAmount: number,
) {
super('Booked corporate tax differs from the current calculation')
}
}
async function computeProposal(
item: PostItem,
supabase: Parameters<typeof calculateBolagsskatt>[0],
@@ -224,16 +311,42 @@ async function computeProposal(
// SLP −, överavskrivningar −); bolagsskatt is sorted LAST so they are
// committed by now. Without this the booked tax ignores the avsättning
// (the original customer bug, too-high tax, ÅR/INK2 mismatch).
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const dispositionsEffect = await sumPostedYearEndDispositions(
supabase,
companyId,
fiscalPeriodId,
const [incomeStatement, dispositionsEffect, taxAdjustments, bookedTax, existingFonder] =
await Promise.all([
generateIncomeStatement(supabase, companyId, fiscalPeriodId),
sumPostedYearEndDispositions(supabase, companyId, fiscalPeriodId),
loadTaxAdjustmentSnapshot(supabase, companyId, fiscalPeriodId),
getBookedBolagsskatt(supabase, companyId, fiscalPeriodId),
listExistingPeriodiseringsfonder(
supabase,
companyId,
period.period_end,
period.period_start,
period.opening_balance_entry_id,
),
])
const schablonintakt = existingFonder.reduce(
(sum, fund) =>
sum + Math.max(0, fund.opening_balance) * getSchablonintaktRate(fiscalYear),
0,
)
return calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
resultBeforeTaxOverride: incomeStatement.net_result + dispositionsEffect.total,
manualAdjustments: item.manualAdjustments,
const manuallyBookedTax = Math.max(
0,
bookedTax - dispositionsEffect.taxProvisionPortion,
)
const proposal = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
resultBeforeTaxOverride:
incomeStatement.net_result + dispositionsEffect.total + manuallyBookedTax,
manualAdjustments: {
nonDeductibleExpenses: taxAdjustments.nonDeductibleExpenses,
nonTaxableIncome: taxAdjustments.nonTaxableIncome,
schablonintaktPeriodiseringsfond: Math.round(schablonintakt),
},
})
const expectedTax = proposal?.amount ?? 0
if (bookedTax > 0 && bookedTax === expectedTax) return null
if (bookedTax > 0) throw new TaxProvisionConflictError(bookedTax, expectedTax)
return proposal && proposal.amount > 0 ? proposal : null
}
case 'sarskild_loneskatt': {
// Already posted in this period (resumed run / duplicate POST): the
@@ -278,6 +391,10 @@ async function computeProposal(
const alreadyProvisioned = currentCohort
? Math.max(0, currentCohort.balance - Math.max(0, currentCohort.opening_balance))
: 0
// A posted current-year allocation is a completed user decision. New
// tax adjustments may increase the legal ceiling, but must never cause
// the page to propose an unsolicited incremental allocation on reload.
if (alreadyProvisioned > 0) return null
// Dispositions posted earlier in this batch (återföring, över-
// avskrivningar, SLP: all sorted before avsättning) are year_end-typed
// and thus invisible in net_result, yet they move the cap base. Add
@@ -289,9 +406,15 @@ async function computeProposal(
companyId,
fiscalPeriodId,
)
const taxAdjustments = await loadTaxAdjustmentSnapshot(
supabase,
companyId,
fiscalPeriodId,
)
const base =
incomeStatement.net_result + postedEffect.total + alreadyProvisioned
+ Math.round(schablonintakt)
+ taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome
return proposeAvsattning({
skattemassigtResultatBeforeAvsattning: base,
desiredAmount: item.desiredAmount,
@@ -57,13 +57,13 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/close', () => {
expect(mockClosePeriod).not.toHaveBeenCalled()
})
it('maps a service refusal to 400 with the message', async () => {
it('maps a service refusal to 400 with a safe message', async () => {
mockClosePeriod.mockRejectedValue(new Error('Period contains draft entries'))
const { status, body } = await parseJsonResponse<{ error: string }>(
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
)
expect(status).toBe(400)
expect(body.error).toBe('Period contains draft entries')
expect(body.error).toBe('Något gick fel. Försök igen.')
})
it('closes the period on the happy path', async () => {
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Response shapes are legacy `{ error: string }` — kept for the year-end UI.
// closePeriod throws plain Errors for every refusal (period not found, drafts
@@ -16,7 +17,7 @@ export const POST = withRouteContext(
return NextResponse.json({ data: period })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to close period' },
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to close period' },
{ status: 400 }
)
}
@@ -5,6 +5,7 @@ import {
} from '@/lib/bookkeeping/currency-revaluation'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** GET: preview currency revaluation for a fiscal period. */
export const GET = withRouteContext(
@@ -76,7 +77,7 @@ export const POST = withRouteContext(
if (fallback.status === 500) {
return errorResponseFromCode('FX_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
return fallback
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'period.entry_count',
@@ -26,7 +27,7 @@ export const GET = withRouteContext(
.in('status', ['posted', 'reversed'])
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(countError) }, { status: 500 })
}
return NextResponse.json({ data: { posted_count: count ?? 0 } })
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
export const POST = withRouteContext(
@@ -27,7 +28,7 @@ export const POST = withRouteContext(
if (/draft/i.test(message)) {
return errorResponseFromCode('PERIOD_LOCK_HAS_DRAFTS', opLog, {
requestId,
details: { reason: message },
details: { reason: getErrorMessage(err) },
})
}
// lockPeriod() refuses to lock a period that still has uncategorized
@@ -36,7 +37,7 @@ export const POST = withRouteContext(
if (/saknar bokföring|okategoriserade affärstransaktion/i.test(message)) {
return errorResponseFromCode('PERIOD_HAS_UNBOOKED_TRANSACTIONS', opLog, {
requestId,
details: { reason: message },
details: { reason: getErrorMessage(err) },
})
}
return errorResponse(err, opLog, { requestId })
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
validateYearEndReadiness: vi.fn(),
previewYearEndClosing: vi.fn(),
executeYearEndClosing: vi.fn(),
}))
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { executeYearEndClosing } from '@/lib/core/bookkeeping/year-end-service'
import { POST } from '../route'
const params = createMockRouteParams({ id: 'period-1' })
const request = () => createMockRequest('/api/bookkeeping/fiscal-periods/period-1/year-end', {
method: 'POST',
})
beforeEach(() => {
vi.clearAllMocks()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: {},
error: null,
})
requireWriteMock.mockResolvedValue({ ok: true })
vi.mocked(executeYearEndClosing).mockResolvedValue({
closingEntry: { id: 'closing-1' },
nextPeriod: { id: 'period-2' },
} as never)
})
describe('POST /api/bookkeeping/fiscal-periods/[id]/year-end', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect((await POST(request(), params)).status).toBe(401)
})
it('returns a specific 409 for a period without posted result activity', async () => {
vi.mocked(executeYearEndClosing).mockRejectedValue(
new Error('No result accounts to close: period has no activity'),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await POST(request(), params),
)
expect(status).toBe(409)
expect(body.error.code).toBe('YEAR_END_NO_ACTIVITY')
})
it('returns 404 when the fiscal period is missing', async () => {
vi.mocked(executeYearEndClosing).mockRejectedValue(new Error('Fiscal period not found'))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await POST(request(), params),
)
expect(status).toBe(404)
expect(body.error.code).toBe('PERIOD_NOT_FOUND')
})
it('returns the completed year-end result', async () => {
const { status, body } = await parseJsonResponse<{ data: { closingEntry: { id: string } } }>(
await POST(request(), params),
)
expect(status).toBe(200)
expect(body.data.closingEntry.id).toBe('closing-1')
expect(executeYearEndClosing).toHaveBeenCalledWith(
{},
'company-1',
'user-1',
'period-1',
)
})
})
@@ -44,7 +44,6 @@ export const POST = withRouteContext(
const result = await executeYearEndClosing(supabase, companyId!, user.id, id)
return NextResponse.json({ data: result })
} catch (err) {
opLog.error('year-end execution failed', err as Error)
const message = err instanceof Error ? err.message : ''
// The downstream errors below are matched on stable English keywords
// emitted by year-end-service. Do NOT include the raw message in
@@ -53,6 +52,12 @@ export const POST = withRouteContext(
if (/Next fiscal period already has opening balance/i.test(message)) {
return errorResponseFromCode('YEAR_END_NEXT_PERIOD_HAS_IB', opLog, { requestId })
}
if (/No result accounts to close: period has no activity/i.test(message)) {
return errorResponseFromCode('YEAR_END_NO_ACTIVITY', opLog, {
requestId,
reason: 'year-end blocked because the period has no posted result activity',
})
}
if (/prior.*open/i.test(message)) {
return errorResponseFromCode('YEAR_END_PRIOR_PERIOD_OPEN', opLog, { requestId })
}
@@ -62,12 +67,10 @@ export const POST = withRouteContext(
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
// Fall through bookkeeping/Zod/etc to errorResponse, but cap to YEAR_END_FAILED.
const fallback = errorResponse(err, opLog, { requestId })
if (fallback.status === 500) {
return errorResponseFromCode('YEAR_END_FAILED', opLog, { requestId })
}
return fallback
// Unknown failures stay 5xx and retain the original Error object and
// stack in the log. Expected domain outcomes above are structured 4xx
// warnings, so Vercel runtime-error clusters remain actionable.
return errorResponse(err, opLog, { requestId })
}
},
{ requireWrite: true },
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/bookkeeping/fiscal-periods/period-status?date=YYYY-MM-DD
@@ -45,7 +46,7 @@ export const GET = withRouteContext('period.status_for_date', async (request, ct
{
error: {
code: 'PERIOD_STATUS_ERROR',
message: err instanceof Error ? err.message : 'Kunde inte hämta periodstatus',
message: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte hämta periodstatus',
},
},
{ status: 500 }
+3 -2
View File
@@ -4,6 +4,7 @@ import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-durati
import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Response shapes are legacy `{ error: string }` (plus one envelope code for
// the blocked-by-open-periods dialog) — kept for the räkenskapsår UI.
@@ -18,7 +19,7 @@ export const GET = withRouteContext('period.list', async (_request, ctx) => {
.order('period_start', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -202,7 +203,7 @@ export const POST = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Keep the continuity chain intact for the period that now follows the new one:
@@ -32,6 +32,7 @@ import { reverseEntry } from '@/lib/bookkeeping/engine'
import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init'
import type { Invoice } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -237,7 +238,7 @@ export const POST = withRouteContext(
payment_id: t.payment_id,
ok: false,
old_journal_entry_id: t.payment_journal_entry_id,
error: err instanceof Error ? err.message : 'Unknown error',
error: err instanceof Error ? getUserErrorMessage(err) : 'Unknown error',
})
}
}
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const SetNoDocSchema = z.object({
reason: z.string().trim().max(200).nullable().optional(),
@@ -39,7 +40,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
return NextResponse.json({ data: { exempted: true } })
@@ -64,7 +65,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
return NextResponse.json({ data: { exempted: false } })
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const UpdateNotesSchema = z.object({
notes: z.string().max(2000).nullable(),
@@ -27,7 +28,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
.maybeSingle()
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
// Zero rows = the entry doesn't exist in this company — report it instead
// of a phantom success.
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { RetagLineDimensionsSchema } from '@/lib/api/schemas'
import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/bookkeeping/journal-entry-lines/[lineId]/retag
@@ -39,10 +40,10 @@ export const POST = withRouteContext<{ params: Promise<{ lineId: string }> }>(
// Anything else is unexpected infrastructure failure → 500 + log.
const message = error.message ?? 'Kunde inte ändra dimensioner'
if (error.code === 'P0001') {
return NextResponse.json({ error: message }, { status: 409 })
return NextResponse.json({ error: getErrorMessage(error) }, { status: 409 })
}
if (error.code === '42501') {
return NextResponse.json({ error: message }, { status: 403 })
return NextResponse.json({ error: getErrorMessage(error) }, { status: 403 })
}
log.error('retag_line_dimensions failed', new Error(message), { lineId })
return NextResponse.json({ error: 'Kunde inte ändra dimensioner' }, { status: 500 })
@@ -4,6 +4,7 @@ import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
import type { Transaction } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const POST = withRouteContext('mapping_rules.evaluate', async (request, ctx) => {
const { supabase, companyId, log } = ctx
@@ -42,7 +43,7 @@ export const POST = withRouteContext('mapping_rules.evaluate', async (request, c
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Evaluation failed' },
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Evaluation failed' },
{ status: 500 }
)
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('mapping_rules.list', async (_request, ctx) => {
const { supabase, companyId } = ctx
@@ -14,7 +15,7 @@ export const GET = withRouteContext('mapping_rules.list', async (_request, ctx)
.order('priority')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -59,7 +60,7 @@ export const POST = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BatchNoDocSchema = z.object({
journal_entry_ids: z.array(z.string().uuid()).min(1).max(500),
@@ -40,7 +41,7 @@ export const POST = withRouteContext(
.in('id', chunk)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
ownedIds.push(...(data ?? []).map((r) => r.id))
}
@@ -6,6 +6,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// A real calendar date in YYYY-MM-DD form. Rejects shaped-but-invalid values
// (e.g. 9999-99-99 or 2026-02-30) that a bare /^\d{4}-\d{2}-\d{2}$/ regex would
@@ -106,10 +107,10 @@ export const POST = withRouteContext(
.in('journal_entry_id', chunk),
])
if (docRes.error) {
return NextResponse.json({ error: docRes.error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(docRes.error) }, { status: 400 })
}
if (exemptRes.error) {
return NextResponse.json({ error: exemptRes.error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(exemptRes.error) }, { status: 400 })
}
for (const r of (docRes.data ?? []) as { journal_entry_id: string }[]) {
withDoc.add(r.journal_entry_id)
+4 -3
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Voucher gap detection + explanations (BFNAR 2013:2 — gaps in voucher
// sequences must be documented). Response shapes are legacy `{ data }` /
@@ -31,7 +32,7 @@ export const GET = withRouteContext('voucher_gaps.list', async (request, ctx) =>
const { data: seriesRows, error: seriesError } = await seriesQuery
if (seriesError) {
log.error('voucher series lookup failed', seriesError)
return NextResponse.json({ error: seriesError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(seriesError) }, { status: 500 })
}
if (!seriesRows || seriesRows.length === 0) {
@@ -59,7 +60,7 @@ export const GET = withRouteContext('voucher_gaps.list', async (request, ctx) =>
// render "no gaps" on a compliance view when the check didn't run.
if (gapsError) {
log.error('detect_voucher_gaps failed', gapsError, { series: row.voucher_series })
return NextResponse.json({ error: gapsError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(gapsError) }, { status: 500 })
}
for (const gap of (gaps ?? []) as Array<{ gap_start: number; gap_end: number }>) {
@@ -148,7 +149,7 @@ export const POST = withRouteContext(
{ status: 403 }
)
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
+5 -4
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Only the two content toggles are user-settable. Strict: the previous
// implementation passed the raw JSON body into .update(), which would have
@@ -41,7 +42,7 @@ export const GET = withRouteContext('calendar_feed.get', async (_request, ctx) =
if (error && error.code !== 'PGRST116') {
// PGRST116 = no rows returned, which is fine
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
if (feed) {
@@ -90,7 +91,7 @@ export const POST = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
@@ -123,7 +124,7 @@ export const PUT = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
@@ -155,7 +156,7 @@ export const DELETE = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/company/check-org-number?org_number=XXXXXXXXXX
@@ -48,7 +49,7 @@ export async function GET(request: Request) {
.is('archived_at', null)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const companies = (data ?? []).map((c: { id: string; name: string }) => ({
+2 -1
View File
@@ -7,6 +7,7 @@ import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { createLogger } from '@/lib/logger'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const log = createLogger('api/company/current')
@@ -124,7 +125,7 @@ export const PATCH = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// When opting in to K3, ensure the two latent-tax (uppskjuten skatt)
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/company?owned=true&archived=false
@@ -32,7 +33,7 @@ export async function GET(request: Request) {
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const companies = (data ?? []).map((row) => {
+4 -3
View File
@@ -5,6 +5,7 @@ import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'customer.get',
@@ -27,7 +28,7 @@ export const GET = withRouteContext(
opLog.error('customer fetch failed', error)
return errorResponseFromCode('INTERNAL_ERROR', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -120,7 +121,7 @@ export const PATCH = withRouteContext(
opLog.error('customer update failed', error)
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -181,7 +182,7 @@ export const DELETE = withRouteContext(
opLog.error('customer delete failed', error)
return errorResponseFromCode('CUSTOMER_DELETE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Customer } from '@/types'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -78,7 +79,7 @@ export const POST = withRouteContext(
log.error('customer insert failed', error)
return errorResponseFromCode('CUSTOMER_CREATE_FAILED', log, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/deadlines/[id]/complete
@@ -23,7 +24,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
// Toggle completion
@@ -40,7 +41,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
+6 -5
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Sparse update: every Create field, optional. Validated — the previous
// implementation type-asserted the raw JSON, so malformed values reached
@@ -29,7 +30,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -80,7 +81,7 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -112,7 +113,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.maybeSingle()
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
if (!existing) {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
@@ -128,7 +129,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.select('id')
if (dismissError) {
return NextResponse.json({ error: dismissError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(dismissError) }, { status: 500 })
}
// The row can vanish between lookup and update (generator cleanup
// during a concurrent regeneration); report 404 rather than a
@@ -146,7 +147,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Zero rows = wrong id / another company's deadline — not a success.
if (count === 0) {
+3 -2
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/deadlines
@@ -52,7 +53,7 @@ export const GET = withRouteContext('deadline.list', async (request, ctx) => {
const { data, error } = await query.order('due_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -89,7 +90,7 @@ export const POST = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
+2 -1
View File
@@ -14,6 +14,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateDimensionSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -68,7 +69,7 @@ export const PATCH = withRouteContext(
opLog.error('dimension update failed', error)
return errorResponseFromCode('DIMENSION_UPDATE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -16,6 +16,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateDimensionValueSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -82,7 +83,7 @@ export const PATCH = withRouteContext(
opLog.error('dimension value update failed', error)
return errorResponseFromCode('DIMENSION_VALUE_UPDATE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -118,7 +119,7 @@ export const DELETE = withRouteContext(
opLog.error('dimension value delete failed', error)
return errorResponseFromCode('DIMENSION_VALUE_DELETE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
+2 -1
View File
@@ -14,6 +14,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateDimensionValueSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -81,7 +82,7 @@ export const POST = withRouteContext(
opLog.error('dimension value insert failed', error)
return errorResponseFromCode('DIMENSION_VALUE_CREATE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
Binary file not shown.
+2 -1
View File
@@ -21,6 +21,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { DimensionTaggingApplySchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -53,7 +54,7 @@ export const POST = withRouteContext(
})
if (error) {
failed.push({ line_id: lineId, error: error.message })
failed.push({ line_id: lineId, error: getUserErrorMessage(error) })
continue
}
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// GET /api/documents/:id/extraction-status
//
@@ -29,7 +30,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const extractedAt = data.extracted_at as string | null
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
import { contentDisposition } from '@/lib/api/content-disposition'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/documents/:id/inline
@@ -83,7 +84,7 @@ export async function GET(
if (downloadError || !blob) {
return NextResponse.json(
{ error: `Failed to download document: ${downloadError?.message ?? 'unknown error'}` },
{ error: `Failed to download document: ${getUserErrorMessage(downloadError) ?? 'unknown error'}` },
{ status: 500 }
)
}
+4 -3
View File
@@ -4,6 +4,7 @@ import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { LinkDocumentSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -73,7 +74,7 @@ export const POST = withRouteContext(
// Non-fatal: the verifikat ↔ underlag link already succeeded.
opLog.warn('inbox item stamp after link failed', {
inboxItemId: body.inbox_item_id,
reason: inboxError.message,
reason: getUserErrorMessage(inboxError),
})
} else if (!stamped || stamped.length === 0) {
// Zero rows updated means the supplied inbox_item_id / document_id
@@ -100,7 +101,7 @@ export const POST = withRouteContext(
// pin is row-level UX on the /transactions list.
opLog.warn('transaction pin after link failed', {
transactionId: body.transaction_id,
reason: pinError.message,
reason: getUserErrorMessage(pinError),
})
}
}
@@ -125,7 +126,7 @@ export const POST = withRouteContext(
}
return errorResponseFromCode('DOC_LINK_FAILED', opLog, {
requestId,
details: { reason: message || 'unknown' },
details: { reason: getUserErrorMessage(err) },
})
}
},
+3 -2
View File
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { deleteDocument } from '@/lib/core/documents/document-service'
import { eventBus } from '@/lib/events'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -34,7 +35,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
if (signError) {
return NextResponse.json(
{ error: `Failed to create download URL: ${signError.message}` },
{ error: `Failed to create download URL: ${getUserErrorMessage(signError)}` },
{ status: 500 }
)
}
@@ -80,7 +81,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
} catch (error) {
console.error('[documents/DELETE] Failed to delete document:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to delete document' },
{ error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to delete document' },
{ status: 500 }
)
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { verifyIntegrity } from '@/lib/core/documents/document-service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -21,7 +22,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
} catch (error) {
console.error('[documents/verify/POST] Verification failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Verification failed' },
{ error: error instanceof Error ? getUserErrorMessage(error) : 'Verification failed' },
{ status: 500 }
)
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createNewVersion, validateDocumentFile } from '@/lib/core/documents/document-service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -42,7 +43,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
} catch (error) {
console.error('[documents/versions/POST] Version creation failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Version creation failed' },
{ error: error instanceof Error ? getUserErrorMessage(error) : 'Version creation failed' },
{ status: 500 }
)
}
@@ -83,7 +84,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.order('version', { ascending: true })
if (versionsError) {
return NextResponse.json({ error: versionsError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(versionsError) }, { status: 500 })
}
return NextResponse.json({ data: versions })
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
@@ -34,7 +35,7 @@ export const GET = withRouteContext('document.counts', async (request, ctx) => {
.in('journal_entry_id', ids)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Group and count by journal_entry_id
+3 -2
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { uploadDocument, validateDocumentFile } from '@/lib/core/documents/document-service'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { DocumentUploadSource } from '@/types'
@@ -78,7 +79,7 @@ export const POST = withRouteContext(
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(message)) {
return errorResponseFromCode('DOC_UPLOAD_PERIOD_LOCKED', opLog, {
requestId,
details: { reason: message },
details: { reason: getErrorMessage(err) },
})
}
// Magic-byte validation rejections (validateDocumentMagicBytes) are a
@@ -88,7 +89,7 @@ export const POST = withRouteContext(
opLog.warn('document upload rejected by content validation', { reason: message })
return errorResponseFromCode('DOC_UPLOAD_INVALID_CONTENT', opLog, {
requestId,
details: { reason: message },
details: { reason: getErrorMessage(err) },
})
}
// Full error is logged above; the raw message can leak storage-layer
+2 -1
View File
@@ -5,6 +5,7 @@ import { validateQuery } from '@/lib/api/validate'
import { EventsQuerySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/events
@@ -80,7 +81,7 @@ export async function GET(request: Request) {
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const events = data ?? []
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>(
'extension.data.get',
@@ -28,7 +29,7 @@ export const GET = withRouteContext<{ params: Promise<{ sector: string; slug: st
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -65,7 +66,7 @@ export const POST = withRouteContext<{ params: Promise<{ sector: string; slug: s
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -95,7 +96,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ sector: string; slug:
.eq('key', key)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ success: true })
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>(
'extension.settings.get',
@@ -55,7 +56,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ sector: string; slug:
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: data.value })
@@ -270,7 +270,7 @@ describe('cloud-backup auto-sync cron', () => {
companyId: 'c-1',
kind: 'repeated_failures',
consecutiveFailures: 3,
errorMessage: 'Drive upload failed: 500',
errorMessage: 'Något gick fel. Försök igen.',
})
)
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
@@ -330,7 +330,7 @@ describe('cloud-backup auto-sync cron', () => {
)
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
expect((value as any).last_auto_sync_status).toBe('error')
expect((value as any).last_auto_sync_error).toContain('Drive quota exceeded')
expect((value as any).last_auto_sync_error).toBe('Något gick fel. Försök igen.')
expect((value as any).consecutive_failures).toBe(3)
})
@@ -2,6 +2,7 @@ import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import {
performSync,
CONNECTION_KEY,
@@ -210,6 +211,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
const consecutiveFailures = syncResult.ok
? 0
: (schedule.consecutive_failures ?? 0) + 1
const safeSyncError = syncResult.ok ? null : getErrorMessage(syncResult.message)
let lastAlertAt = schedule.last_alert_at ?? null
if (!syncResult.ok) {
lastAlertAt = await maybeAlert({
@@ -217,7 +219,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
userId,
kind: syncResult.reason === 'needs_reauth' ? 'needs_reauth' : 'repeated_failures',
consecutiveFailures,
errorMessage: syncResult.message,
errorMessage: safeSyncError,
lastAlertAt,
})
}
@@ -226,7 +228,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
...schedule,
last_auto_sync_at: new Date().toISOString(),
last_auto_sync_status: syncResult.ok ? 'success' : 'error',
last_auto_sync_error: syncResult.ok ? null : syncResult.message,
last_auto_sync_error: safeSyncError,
consecutive_failures: consecutiveFailures,
last_alert_at: lastAlertAt,
}
@@ -235,10 +237,10 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
results.push({
companyId,
status: syncResult.ok ? 'success' : 'error',
error: syncResult.ok ? undefined : syncResult.message,
error: safeSyncError ?? undefined,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
const safeMessage = getErrorMessage(err)
ctx.log.error('cloud backup sync failed for company', err as Error, {
companyId,
})
@@ -249,7 +251,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
userId,
kind: 'repeated_failures',
consecutiveFailures,
errorMessage: message.slice(0, 200),
errorMessage: safeMessage.slice(0, 200),
lastAlertAt: schedule.last_alert_at,
})
@@ -257,7 +259,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
...schedule,
last_auto_sync_at: new Date().toISOString(),
last_auto_sync_status: 'error',
last_auto_sync_error: message.slice(0, 200),
last_auto_sync_error: safeMessage.slice(0, 200),
consecutive_failures: consecutiveFailures,
last_alert_at: lastAlertAt,
}
@@ -267,7 +269,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
},
)
results.push({ companyId, status: 'error', error: message })
results.push({ companyId, status: 'error', error: safeMessage })
}
}
@@ -459,7 +459,10 @@ describe('AGI kvittenser cron', () => {
const body = await res.json()
expect(body.errors).toBe(1)
expect(body.results[0]).toMatchObject({ status: 'error', error: 'fetch failed' })
expect(body.results[0]).toMatchObject({
status: 'error',
error: 'Något gick fel. Försök igen.',
})
expect(errorRecorder).toHaveBeenCalledTimes(1)
expect(String(errorRecorder.mock.calls[0][0])).toContain('Reconciliation failed')
})
@@ -11,6 +11,7 @@ import { reconcileAgiDeclaration } from '@/extensions/general/skatteverket/lib/a
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -204,7 +205,7 @@ export async function GET(request: Request) {
}
log.error('Reconciliation failed', { declarationId, companyId, period, message })
results.push({ declarationId, period, status: 'error', error: message })
results.push({ declarationId, period, status: 'error', error: getErrorMessage(err) })
}
}
@@ -12,6 +12,7 @@ import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config'
import { listVerifiedCompanies, markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { currentSkvEnvironment, hasVerifiedGrant } from '@/extensions/general/skatteverket/lib/resolve-auth'
ensureInitialized()
@@ -261,7 +262,7 @@ export async function GET(request: Request) {
message,
felkod,
})
results.push({ userId, companyId, source, status: 'error', error: message })
results.push({ userId, companyId, source, status: 'error', error: getErrorMessage(err) })
}
}
@@ -298,7 +298,10 @@ describe('VAT kvittenser cron', () => {
const body = await res.json()
expect(body.errors).toBe(1)
expect(body.results[0]).toMatchObject({ status: 'error', error: 'fetch failed' })
expect(body.results[0]).toMatchObject({
status: 'error',
error: 'Något gick fel. Försök igen.',
})
expect(errorSpy).toHaveBeenCalledTimes(1)
})
@@ -12,6 +12,7 @@ import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { SkatteverketInlamnatResponse } from '@/extensions/general/skatteverket/types'
import type { VatPeriodType } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -183,7 +184,7 @@ export async function GET(request: Request) {
companyId,
period,
status: 'error',
error: `Failed to persist signed state: ${updateError.message}`,
error: `Failed to persist signed state: ${getUserErrorMessage(updateError)}`,
})
continue
}
@@ -283,7 +284,7 @@ export async function GET(request: Request) {
}
console.error('[vat-kvittenser-cron] Reconciliation failed', { companyId, period, message })
results.push({ companyId, period, status: 'error', error: message })
results.push({ companyId, period, status: 'error', error: getUserErrorMessage(err) })
}
}
+4 -3
View File
@@ -10,6 +10,7 @@ import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { checkRevenueAccount, type RevenueAccountStatus } from '@/lib/articles/validate-revenue-account'
import type { Article } from '@/types'
import type { ArticleImportExecuteResult } from '@/lib/import/articles/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -140,7 +141,7 @@ export const POST = withRouteContext(
.single()
if (error) {
errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
if (data) updated.push(data as Article)
@@ -176,7 +177,7 @@ export const POST = withRouteContext(
skipped++
continue
}
errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
@@ -225,7 +226,7 @@ export const POST = withRouteContext(
opLog.error('article import execute failed', err as Error)
return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
+2 -1
View File
@@ -9,6 +9,7 @@ import type {
ArticleImportParseResult,
DetectedArticleColumns,
} from '@/lib/import/articles/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
@@ -127,7 +128,7 @@ export const POST = withRouteContext(
opLog.error('article import parse failed', err as Error)
return errorResponseFromCode('REG_IMPORT_PARSE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
+3 -2
View File
@@ -9,6 +9,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
import type { Transaction } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -87,7 +88,7 @@ export const POST = withRouteContext(
opLog.error('failed to create bank_file_imports record', importError)
return errorResponseFromCode('BANK_FILE_IMPORT_RECORD_FAILED', opLog, {
requestId,
details: { reason: importError.message },
details: { reason: getUserErrorMessage(importError) },
})
}
@@ -164,7 +165,7 @@ export const POST = withRouteContext(
opLog.error('bank file execute failed', err as Error)
return errorResponseFromCode('BANK_FILE_EXECUTE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
+2 -1
View File
@@ -4,6 +4,7 @@ import { decodeFileContent } from '@/lib/import/shared/encoding'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/import/bank-file/parse
@@ -91,7 +92,7 @@ export const POST = withRouteContext(
opLog.error('bank file parse failed', err as Error)
return errorResponseFromCode('BANK_FILE_PARSE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
+4 -3
View File
@@ -9,6 +9,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Customer } from '@/types'
import type { CustomerImportExecuteResult } from '@/lib/import/customers/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -126,7 +127,7 @@ export const POST = withRouteContext(
.single()
if (error) {
errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
if (data) updated.push(data as Customer)
@@ -162,7 +163,7 @@ export const POST = withRouteContext(
skipped++
continue
}
errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
if (data) {
@@ -200,7 +201,7 @@ export const POST = withRouteContext(
opLog.error('customer import execute failed', err as Error)
return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
+2 -1
View File
@@ -9,6 +9,7 @@ import type {
CustomerImportParseResult,
DetectedCustomerColumns,
} from '@/lib/import/customers/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
@@ -113,7 +114,7 @@ export const POST = withRouteContext(
opLog.error('customer import parse failed', err as Error)
return errorResponseFromCode('REG_IMPORT_PARSE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
@@ -11,6 +11,7 @@ import {
} from '@/lib/import/opening-balance/execute-helpers'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -244,7 +245,11 @@ export const POST = withRouteContext(
return errorResponseFromCode('OB_CORRECT_FAILED', opLog, {
requestId,
details: { reason, newEntryId: newEntry.id, oldEntryId },
details: {
reason: getUserErrorMessage(seqErr),
newEntryId: newEntry.id,
oldEntryId,
},
})
}
@@ -267,7 +272,7 @@ export const POST = withRouteContext(
if (/Bokföringen är låst/i.test(message)) {
return errorResponseFromCode('OB_COMPANY_LOCK_DATE', opLog, {
requestId,
details: { reason: message },
details: { reason: getUserErrorMessage(err) },
})
}
if (isBookkeepingError(err)) {
@@ -276,7 +281,7 @@ export const POST = withRouteContext(
opLog.error('opening balance correct failed', err as Error)
return errorResponseFromCode('OB_CORRECT_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
@@ -11,6 +11,7 @@ import {
} from '@/lib/import/opening-balance/execute-helpers'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -129,7 +130,7 @@ export const POST = withRouteContext(
opLog.error('opening balance execute failed', err as Error)
return errorResponseFromCode('OB_EXECUTE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
@@ -3,6 +3,7 @@ import { parseOpeningBalanceFile } from '@/lib/import/opening-balance/parser'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { DetectedColumns } from '@/lib/import/opening-balance/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
@@ -60,7 +61,7 @@ export const POST = withRouteContext(
opLog.error('opening balance parse failed', err as Error)
return errorResponseFromCode('OB_PARSE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
+3 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/import/sie/[id]
@@ -18,7 +19,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
if (!data) {
@@ -68,7 +69,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ success: true })
+3 -2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import type { SIEAccount } from '@/lib/import/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* Determine account type based on account class (first digit)
@@ -106,7 +107,7 @@ export const POST = withRouteContext(
if (error) {
console.error('Error upserting accounts batch:', error)
return NextResponse.json({
error: `Kunde inte skapa konton (batch ${Math.floor(i / batchSize) + 1}): ${error.message}. ${totalCreated} konton skapades innan felet.`,
error: `Kunde inte skapa konton (batch ${Math.floor(i / batchSize) + 1}): ${getUserErrorMessage(error)}. ${totalCreated} konton skapades innan felet.`,
created: totalCreated,
}, { status: 500 })
}
@@ -123,7 +124,7 @@ export const POST = withRouteContext(
} catch (error) {
console.error('Create accounts error:', error)
return NextResponse.json(
{ error: `Kunde inte skapa konton: ${error instanceof Error ? error.message : 'Okänt fel'}. Försök igen.` },
{ error: `Kunde inte skapa konton: ${error instanceof Error ? getUserErrorMessage(error) : 'Okänt fel'}. Försök igen.` },
{ status: 500 }
)
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// SIE imports with many vouchers need extended execution time
export const maxDuration = 300
@@ -125,7 +126,7 @@ export const POST = withRouteContext(
opLog.error('sie execute unexpected error', err as Error)
return errorResponseFromCode('SIE_IMPORT_UNEXPECTED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
+6 -5
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { saveMappings } from '@/lib/import/sie-import'
import type { AccountMapping } from '@/lib/import/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/import/sie/mappings
@@ -17,7 +18,7 @@ export const GET = withRouteContext(
.order('source_account')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -43,7 +44,7 @@ export const POST = withRouteContext(
return NextResponse.json({ success: true })
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to save mappings' },
{ error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to save mappings' },
{ status: 500 }
)
}
@@ -84,7 +85,7 @@ export const PUT = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -111,7 +112,7 @@ export const DELETE = withRouteContext(
.eq('source_account', sourceAccount)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
} else {
// Delete all mappings
@@ -121,7 +122,7 @@ export const DELETE = withRouteContext(
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
}
+2 -1
View File
@@ -12,6 +12,7 @@ import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { SIEAccountMappingRecord } from '@/lib/import/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/import/sie/parse
@@ -143,7 +144,7 @@ export const POST = withRouteContext(
opLog.error('sie parse failed', err as Error)
return errorResponseFromCode('SIE_PARSE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/import/sie
@@ -28,7 +29,7 @@ export const GET = withRouteContext(
const { data, error, count } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
+4 -3
View File
@@ -9,6 +9,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Supplier } from '@/types'
import type { SupplierImportExecuteResult } from '@/lib/import/suppliers/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -110,7 +111,7 @@ export const POST = withRouteContext(
.single()
if (error) {
errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
if (data) updated.push(data as Supplier)
@@ -150,7 +151,7 @@ export const POST = withRouteContext(
skipped++
continue
}
errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
if (data) {
@@ -185,7 +186,7 @@ export const POST = withRouteContext(
opLog.error('supplier import execute failed', err as Error)
return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},

Some files were not shown because too many files have changed in this diff Show More