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:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
+144
@@ -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) {
|
||||
|
||||
+10
@@ -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)) {
|
||||
|
||||
+197
@@ -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)
|
||||
})
|
||||
})
|
||||
+128
-11
@@ -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 },
|
||||
)
|
||||
|
||||
+102
@@ -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) {
|
||||
|
||||
+164
@@ -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) },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
+286
-1
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
@@ -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,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,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,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,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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user