Add/bokslut (#718)
* feat(arcim-migration): Briox provider with SIE-over-API import - Briox auth via account ID + application token (no app-level credentials); both tokens rotate on refresh and are persisted - New sie-fetcher pulls the general ledger as SIE through the provider API for Fortnox, Briox and Bjorn Lunden - Wizard stops on a failed SIE import and surfaces the real errors instead of proceeding to the misleading migrate-guard message - PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED; new PROVIDER_TOKEN_INVALID for rejected provider credentials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices Defer revenue/costs per invoice line to 29xx/17xx interim accounts with automatic monthly dissolution (nightly cron + catch-up at registration), schedule cancellation on credit, year-end auto-detect exclusion for already-scheduled invoices, invoice-inbox service-period extraction for prefill, and an MCP tool to list schedules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing Generate the annual report as iXBRL from a generated taxonomy registry (K2 element lists, taxonomy:generate/check scripts + CI guard), expose it via the fiscal-period API, and add the bolagsverket extension for digital submission to eget utrymme with webhook-driven status tracking (submissions table + pg tests, lifecycle events, year-end wizard UI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mcp): raise origin-guard test timeout to 20s The dynamic import pulls in the full server module; the parse alone flirts with the 5s default under full-suite parallel load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add new scripts and documentation for K2 AB taxonomy generation and validation - Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models. - Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle. - Included new documentation files: - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx` - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx` - `taxonomi-paket-2024-09-12_rev20250312.zip` * Add tests for bookkeeping accruals dissolution and supplier invoices - Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios. - Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions. - Introduce tests for the Arcim migration provider client, ensuring token handling and error classification. - Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings. - Add Zod schemas for Bolagsverket response payloads to ensure proper validation. - Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping. - Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly. - Introduce typed domain errors for accrual schedules to improve error handling in the service. - Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling. * fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments * fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated * feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id * feat(bokslut): enhance compliance and financial processing features with new submission details and security measures --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8e8b63a200
commit
db8983ba9e
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
import {
|
||||
AccrualNothingToDissolveError,
|
||||
AccrualScheduleNotActiveError,
|
||||
AccrualScheduleNotFoundError,
|
||||
} from '@/lib/bookkeeping/accruals/errors'
|
||||
|
||||
const { supabase: mockSupabase, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockDissolveScheduleNow = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/accruals/service', () => ({
|
||||
dissolveScheduleNow: (...args: unknown[]) => mockDissolveScheduleNow(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/bookkeeping/accruals/[id]/dissolve', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
function dissolveRequest() {
|
||||
return POST(
|
||||
createMockRequest('/api/bookkeeping/accruals/sched-1/dissolve', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'sched-1' }),
|
||||
)
|
||||
}
|
||||
|
||||
it('returns the dissolution result on success', async () => {
|
||||
mockDissolveScheduleNow.mockResolvedValue({ journalEntryId: 'je-1', amount: 2000 })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { journalEntryId: string; amount: number }
|
||||
}>(await dissolveRequest())
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({ journalEntryId: 'je-1', amount: 2000 })
|
||||
})
|
||||
|
||||
it('maps the typed not-found error to 404 ACCRUAL_NOT_FOUND', async () => {
|
||||
mockDissolveScheduleNow.mockRejectedValue(new AccrualScheduleNotFoundError())
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await dissolveRequest(),
|
||||
)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('ACCRUAL_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('maps the typed not-active error to 400 ACCRUAL_NOT_ACTIVE', async () => {
|
||||
mockDissolveScheduleNow.mockRejectedValue(new AccrualScheduleNotActiveError('cancelled'))
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { currentStatus: string } }
|
||||
}>(await dissolveRequest())
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ACCRUAL_NOT_ACTIVE')
|
||||
expect(body.error.details.currentStatus).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('maps the typed nothing-to-dissolve error to 400 ACCRUAL_NOTHING_TO_DISSOLVE', async () => {
|
||||
mockDissolveScheduleNow.mockRejectedValue(new AccrualNothingToDissolveError())
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await dissolveRequest(),
|
||||
)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ACCRUAL_NOTHING_TO_DISSOLVE')
|
||||
})
|
||||
|
||||
it('falls back to ACCRUAL_DISSOLVE_FAILED for untyped errors', async () => {
|
||||
mockDissolveScheduleNow.mockRejectedValue(new Error('Ingen öppen räkenskapsperiod för 2026-01-01'))
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { reason: string } }
|
||||
}>(await dissolveRequest())
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ACCRUAL_DISSOLVE_FAILED')
|
||||
expect(body.error.details.reason).toMatch(/Ingen öppen räkenskapsperiod/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { dissolveScheduleNow } from '@/lib/bookkeeping/accruals/service'
|
||||
import {
|
||||
ACCRUAL_NOTHING_TO_DISSOLVE,
|
||||
ACCRUAL_SCHEDULE_NOT_ACTIVE,
|
||||
ACCRUAL_SCHEDULE_NOT_FOUND,
|
||||
isAccrualError,
|
||||
} from '@/lib/bookkeeping/accruals/errors'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/accruals/[id]/dissolve
|
||||
*
|
||||
* "Lös upp nu": books the schedule's remaining months in ONE verifikat dated
|
||||
* today (clamped by lock date) and completes the schedule. Used when the
|
||||
* underlying service ends early or the user wants the rest expensed now.
|
||||
* Cancelling-with-storno only happens via the credit flows — a standalone
|
||||
* cancel would strand the interim-account balance.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'accruals.dissolve',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
|
||||
try {
|
||||
const result = await dissolveScheduleNow(supabase, companyId!, user.id, id)
|
||||
// Manual financial write — log the acting user for auditability.
|
||||
log.info('accrual schedule dissolved', {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
scheduleId: id,
|
||||
amount: result.amount,
|
||||
journalEntryId: result.journalEntryId,
|
||||
})
|
||||
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) {
|
||||
case ACCRUAL_SCHEDULE_NOT_FOUND:
|
||||
return errorResponseFromCode('ACCRUAL_NOT_FOUND', log, { requestId })
|
||||
case ACCRUAL_SCHEDULE_NOT_ACTIVE:
|
||||
return errorResponseFromCode('ACCRUAL_NOT_ACTIVE', log, {
|
||||
requestId,
|
||||
details: { currentStatus: err.currentStatus },
|
||||
})
|
||||
case ACCRUAL_NOTHING_TO_DISSOLVE:
|
||||
return errorResponseFromCode('ACCRUAL_NOTHING_TO_DISSOLVE', log, { requestId })
|
||||
}
|
||||
}
|
||||
log.error('accrual dissolve failed', err as Error, { entityId: id })
|
||||
return errorResponseFromCode('ACCRUAL_DISSOLVE_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason },
|
||||
})
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
let installmentsResult: {
|
||||
data: Array<{ company_id: string }> | null
|
||||
error: { message: string } | null
|
||||
} = { data: [], error: null }
|
||||
|
||||
// The route loads company ids through fetchAllRows, which appends
|
||||
// .range(from, to) per page — the mock slices the fixture so pagination
|
||||
// (>1000 rows) is exercised for real.
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn(() => ({
|
||||
from: vi.fn(() => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
let from = 0
|
||||
let to = Number.MAX_SAFE_INTEGER
|
||||
chain.select = vi.fn(() => chain)
|
||||
chain.eq = vi.fn(() => chain)
|
||||
chain.lte = vi.fn(() => chain)
|
||||
chain.order = vi.fn(() => chain)
|
||||
chain.range = vi.fn((f: number, t: number) => {
|
||||
from = f
|
||||
to = t
|
||||
return chain
|
||||
})
|
||||
chain.then = (resolve: (v: unknown) => unknown) => {
|
||||
const result = installmentsResult.error
|
||||
? { data: null, error: installmentsResult.error }
|
||||
: { data: (installmentsResult.data ?? []).slice(from, to + 1), error: null }
|
||||
return Promise.resolve(result).then(resolve)
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
const mockPostDueInstallments = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/accruals/service', () => ({
|
||||
postDueInstallments: (...args: unknown[]) => mockPostDueInstallments(...args),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
function cronRequest(): Request {
|
||||
return new Request('http://localhost:3000/api/bookkeeping/accruals/post-due/cron')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
installmentsResult = { data: [], error: null }
|
||||
})
|
||||
|
||||
describe('GET /api/bookkeeping/accruals/post-due/cron', () => {
|
||||
it('runs once per distinct company and aggregates results', async () => {
|
||||
installmentsResult = {
|
||||
data: [
|
||||
{ company_id: 'company-1' },
|
||||
{ company_id: 'company-1' },
|
||||
{ company_id: 'company-2' },
|
||||
],
|
||||
error: null,
|
||||
}
|
||||
mockPostDueInstallments
|
||||
.mockResolvedValueOnce({ posted: 2, failed: 0, skipped: 0, errors: [] })
|
||||
.mockResolvedValueOnce({ posted: 1, failed: 0, skipped: 0, errors: [] })
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(mockPostDueInstallments).toHaveBeenCalledTimes(2)
|
||||
expect(mockPostDueInstallments.mock.calls.map((c) => c[1])).toEqual([
|
||||
'company-1',
|
||||
'company-2',
|
||||
])
|
||||
expect(json.success).toBe(true)
|
||||
expect(json.total).toBe(2)
|
||||
expect(json.succeeded).toBe(2)
|
||||
expect(json.results).toEqual([
|
||||
{ companyId: 'company-1', posted: 2, failed: 0, skipped: 0 },
|
||||
{ companyId: 'company-2', posted: 1, failed: 0, skipped: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('paginates past the 1000-row PostgREST cap so no company is starved', async () => {
|
||||
// 1000 rows for company-1 fill the first page exactly; company-2's single
|
||||
// row only exists on page 2 and would be dropped by an unpaginated select.
|
||||
installmentsResult = {
|
||||
data: [
|
||||
...Array.from({ length: 1000 }, () => ({ company_id: 'company-1' })),
|
||||
{ company_id: 'company-2' },
|
||||
],
|
||||
error: null,
|
||||
}
|
||||
mockPostDueInstallments.mockResolvedValue({ posted: 1, failed: 0, skipped: 0, errors: [] })
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(json.success).toBe(true)
|
||||
expect(mockPostDueInstallments).toHaveBeenCalledTimes(2)
|
||||
expect(mockPostDueInstallments.mock.calls.map((c) => c[1])).toEqual([
|
||||
'company-1',
|
||||
'company-2',
|
||||
])
|
||||
})
|
||||
|
||||
it('isolates a failing company so the rest still run', async () => {
|
||||
installmentsResult = {
|
||||
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
|
||||
error: null,
|
||||
}
|
||||
mockPostDueInstallments
|
||||
.mockRejectedValueOnce(new Error('database exploded'))
|
||||
.mockResolvedValueOnce({ posted: 1, failed: 0, skipped: 0, errors: [] })
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(json.success).toBe(true)
|
||||
expect(json.succeeded).toBe(1)
|
||||
expect(json.failed).toBe(1)
|
||||
expect(json.failures).toEqual([{ index: 0, error: 'database exploded' }])
|
||||
expect(mockPostDueInstallments).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns 500 when the due query fails', async () => {
|
||||
installmentsResult = { data: null, error: { message: 'boom' } }
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(mockPostDueInstallments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unauthorized callers', async () => {
|
||||
const { verifyCronSecret } = await import('@/lib/auth/cron')
|
||||
const { NextResponse } = await import('next/server')
|
||||
vi.mocked(verifyCronSecret).mockReturnValueOnce(
|
||||
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
)
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockPostDueInstallments).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
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'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/accruals/post-due/cron — daily 05:15 UTC.
|
||||
*
|
||||
* Posts the monthly periodiseringsverifikat (source_type 'accrual') for every
|
||||
* pending installment whose calendar month has begun. Companies run in
|
||||
* isolated try/catch; one company's failure never blocks the rest. Per-
|
||||
* installment failures are recorded on the row (last_error) by the service
|
||||
* and retried on the next run — the periodiseringar page surfaces them.
|
||||
*
|
||||
* Idempotency: posting flips the installment pending→posted with a CAS
|
||||
* claim, so a cron retry (or a concurrent manual "Bokför förfallna") can
|
||||
* never double-book a month.
|
||||
*/
|
||||
export const GET = withCronContext('cron.accrual_postings', async (_request, ctx) => {
|
||||
const supabase = createServiceClient()
|
||||
const todayIso = new Date().toISOString().slice(0, 10)
|
||||
|
||||
// fetchAllRows pages past PostgREST's 1000-row cap — a single unpaginated
|
||||
// select would silently drop companies once total due installments exceed
|
||||
// the cap, permanently starving the ones sorted last.
|
||||
let rows: Array<{ company_id: string }>
|
||||
try {
|
||||
rows = await fetchAllRows<{ company_id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.select('company_id')
|
||||
.eq('status', 'pending')
|
||||
.lte('period_month', firstOfMonth(todayIso))
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
} 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' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
const companyIds = Array.from(new Set(rows.map((row) => row.company_id)))
|
||||
|
||||
ctx.log.info('accrual posting cron starting', {
|
||||
companyCount: companyIds.length,
|
||||
todayIso,
|
||||
})
|
||||
|
||||
const results: Array<{
|
||||
companyId: string
|
||||
posted: number
|
||||
failed: number
|
||||
skipped: number
|
||||
}> = []
|
||||
|
||||
const summary = await ctx.forEach('company', companyIds, async (companyId, itemCtx) => {
|
||||
const result = await postDueInstallments(supabase, companyId)
|
||||
results.push({
|
||||
companyId,
|
||||
posted: result.posted,
|
||||
failed: result.failed,
|
||||
skipped: result.skipped,
|
||||
})
|
||||
if (result.failed > 0) {
|
||||
itemCtx.log.warn('some accrual installments failed to post', {
|
||||
companyId,
|
||||
failed: result.failed,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
ctx.log.info('accrual posting cron summary', {
|
||||
total: summary.total,
|
||||
succeeded: summary.succeeded,
|
||||
failed: summary.failed,
|
||||
posted: results.reduce((sum, r) => sum + r.posted, 0),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: summary.total,
|
||||
succeeded: summary.succeeded,
|
||||
failed: summary.failed,
|
||||
failures: summary.failures,
|
||||
results,
|
||||
})
|
||||
})
|
||||
|
||||
export const POST = GET
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { postDueInstallments } from '@/lib/bookkeeping/accruals/service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/accruals/post-due
|
||||
*
|
||||
* Manual "Bokför förfallna periodiseringar" for the active company —
|
||||
* complements the daily cron (same service, same CAS idempotency), so the
|
||||
* user never has to wait for the nightly run after creating a schedule with
|
||||
* elapsed months or after fixing a blocked installment.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'accruals.post_due',
|
||||
async (_request, ctx) => {
|
||||
const { user, supabase, companyId } = ctx
|
||||
|
||||
const result = await postDueInstallments(supabase, companyId!, {
|
||||
userId: user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute'
|
||||
import type { AccrualSchedule, AccrualScheduleInstallment } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/accruals?status=active|completed|cancelled|all
|
||||
*
|
||||
* Schedules with their installments for the periodiseringar page, plus a
|
||||
* `due_count` of pending installments whose month has begun (drives the
|
||||
* "Bokför förfallna" banner).
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'accruals.list',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status') || 'active'
|
||||
|
||||
let query = supabase
|
||||
.from('accrual_schedules')
|
||||
.select('*, installments:accrual_schedule_installments(*)')
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (status !== 'all') {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) {
|
||||
log.error('failed to list accrual schedules', error)
|
||||
return errorResponse(error, log, { requestId })
|
||||
}
|
||||
|
||||
const todayMonth = firstOfMonth(new Date().toISOString().slice(0, 10))
|
||||
let dueCount = 0
|
||||
const schedules = ((data ?? []) as Array<
|
||||
AccrualSchedule & { installments: AccrualScheduleInstallment[] }
|
||||
>).map((schedule) => {
|
||||
const installments = [...(schedule.installments ?? [])].sort((a, b) =>
|
||||
a.period_month.localeCompare(b.period_month),
|
||||
)
|
||||
if (schedule.status === 'active') {
|
||||
dueCount += installments.filter(
|
||||
(i) => i.status === 'pending' && i.period_month <= todayMonth,
|
||||
).length
|
||||
}
|
||||
return { ...schedule, installments }
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: schedules, due_count: dueCount })
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/bokslut/ixbrl/build-input', () => ({
|
||||
buildIxbrlInput: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
|
||||
import { makeInput } from '@/lib/bokslut/ixbrl/__tests__/fixtures'
|
||||
import { GET } from '../route'
|
||||
import { GET as GET_VALIDATE } from '../validate/route'
|
||||
|
||||
function mkReq(query = '') {
|
||||
return new Request(
|
||||
`http://localhost/api/bookkeeping/fiscal-periods/period-1/arsredovisning/ixbrl${query}`,
|
||||
)
|
||||
}
|
||||
|
||||
function mkParams(id = 'period-1') {
|
||||
return { params: Promise.resolve({ id }) }
|
||||
}
|
||||
|
||||
function authedSupabase() {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
vi.mocked(createClient).mockResolvedValue(supabase as never)
|
||||
return supabase
|
||||
}
|
||||
|
||||
describe('GET /api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
vi.mocked(createClient).mockResolvedValue(supabase as never)
|
||||
|
||||
const res = await GET(mkReq(), mkParams())
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when the period is missing', async () => {
|
||||
authedSupabase()
|
||||
vi.mocked(buildIxbrlInput).mockRejectedValue(new Error('Fiscal period not found'))
|
||||
|
||||
const res = await GET(mkReq(), mkParams())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns the generated XHTML inline for iframe preview', async () => {
|
||||
authedSupabase()
|
||||
vi.mocked(buildIxbrlInput).mockResolvedValue(makeInput())
|
||||
|
||||
const res = await GET(mkReq(), mkParams())
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toContain('application/xhtml+xml')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('inline')
|
||||
expect(res.headers.get('Cache-Control')).toContain('no-store')
|
||||
const body = await res.text()
|
||||
expect(body).toContain('<?xml version="1.0" encoding="utf-8"?>')
|
||||
expect(body).toContain('se-k2-ab-risbs-2024-09-12.xsd')
|
||||
expect(body).toContain('ID_DATUM_UNDERTECKNANDE_FASTSTALLELSEINTYG')
|
||||
})
|
||||
|
||||
it('serves as attachment with ?download=1 and forwards utdelning', async () => {
|
||||
authedSupabase()
|
||||
vi.mocked(buildIxbrlInput).mockResolvedValue(makeInput())
|
||||
|
||||
const res = await GET(mkReq('?download=1&utdelning=50000'), mkParams())
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Disposition')).toContain('attachment')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('arsredovisning-2025-12-31.xhtml')
|
||||
expect(vi.mocked(buildIxbrlInput)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'period-1',
|
||||
{ proposedDividend: 50000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 500 envelope when generation explodes', async () => {
|
||||
authedSupabase()
|
||||
const broken = makeInput()
|
||||
broken.entryPointId = 'okant-entry-point'
|
||||
vi.mocked(buildIxbrlInput).mockResolvedValue(broken)
|
||||
|
||||
const res = await GET(mkReq(), mkParams())
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/validate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns ok with no errors for the happy-path fixture', async () => {
|
||||
authedSupabase()
|
||||
const input = makeInput()
|
||||
// Keep date rules deterministic: the fixture period ends 2025-12-31 and
|
||||
// AGM is 2026-03-15, both in the past relative to the suite's clock.
|
||||
vi.mocked(buildIxbrlInput).mockResolvedValue(input)
|
||||
|
||||
const res = await GET_VALIDATE(mkReq('/validate'), mkParams())
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.ok).toBe(true)
|
||||
expect(body.data.error_count).toBe(0)
|
||||
expect(body.data.generated_bytes).toBeGreaterThan(10_000)
|
||||
expect(body.data.entry_point).toBe('k2-ab-risbs-2024-09-12')
|
||||
})
|
||||
|
||||
it('reports rule violations as issues without failing the request', async () => {
|
||||
authedSupabase()
|
||||
const input = makeInput()
|
||||
input.underskrifter.signers = []
|
||||
input.totals.tillgangar = { current: 1, previous: 1 }
|
||||
vi.mocked(buildIxbrlInput).mockResolvedValue(input)
|
||||
|
||||
const res = await GET_VALIDATE(mkReq('/validate'), mkParams())
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.ok).toBe(false)
|
||||
const issueCodes = body.data.issues.map((issue: { code: string }) => issue.code)
|
||||
expect(issueCodes).toContain('1107')
|
||||
expect(issueCodes).toContain('3005')
|
||||
})
|
||||
|
||||
it('surfaces generation failures as ACC-GEN issues', async () => {
|
||||
authedSupabase()
|
||||
const broken = makeInput()
|
||||
broken.rr = {} as never
|
||||
broken.totals = { ...broken.totals }
|
||||
// Force a generation error by pointing at a non-existent entry point.
|
||||
broken.entryPointId = 'okant-entry-point'
|
||||
vi.mocked(buildIxbrlInput).mockResolvedValue(broken)
|
||||
|
||||
const res = await GET_VALIDATE(mkReq('/validate'), mkParams())
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const issueCodes = body.data.issues.map((issue: { code: string }) => issue.code)
|
||||
expect(issueCodes).toContain('ACC-GEN')
|
||||
expect(body.data.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns 404 when the period is missing', async () => {
|
||||
authedSupabase()
|
||||
vi.mocked(buildIxbrlInput).mockRejectedValue(new Error('Fiscal period not found'))
|
||||
|
||||
const res = await GET_VALIDATE(mkReq('/validate'), mkParams())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* Query params:
|
||||
* - download=1 → Content-Disposition: attachment
|
||||
* - utdelning=N → proposed dividend in whole SEK for the resultatdisposition
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'period.arsredovisning_ixbrl',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const download = url.searchParams.get('download') === '1'
|
||||
const utdelningRaw = url.searchParams.get('utdelning')
|
||||
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : 0
|
||||
|
||||
const input = await buildIxbrlInput(supabase, companyId, id, {
|
||||
proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0,
|
||||
})
|
||||
const { xhtml, warnings } = generateK2IxbrlDocument(input)
|
||||
|
||||
const safePeriodEnd = input.period.end.replace(/[^\w.-]/g, '_')
|
||||
const filename = `arsredovisning-${safePeriodEnd}.xhtml`
|
||||
return new Response(xhtml, {
|
||||
headers: {
|
||||
// Served as XHTML so iframe preview renders the inline XBRL
|
||||
// document exactly as Bolagsverket will present it.
|
||||
'Content-Type': 'application/xhtml+xml; charset=utf-8',
|
||||
'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${filename}"`,
|
||||
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
// Generation warnings surfaced without disturbing the body.
|
||||
'X-Ixbrl-Warning-Count': String(warnings.length),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : ''
|
||||
if (/not found/i.test(message)) {
|
||||
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
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 { runPreflightChecks, type PreflightIssue } from '@/lib/bokslut/ixbrl/validate/rules'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl/validate
|
||||
*
|
||||
* Layer-1 validation (local mirror of Bolagsverket kontrollera, GUIDE
|
||||
* Appendix E) + a generation dry-run so taxonomy-level problems (unknown
|
||||
* concept, context mismatch) surface as issues instead of a 500 in the
|
||||
* preview. Layer 3 (the real kontrollera call) lives in the bolagsverket
|
||||
* extension and runs in the Skicka in step.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'period.arsredovisning_ixbrl_validate',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const utdelningRaw = url.searchParams.get('utdelning')
|
||||
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : 0
|
||||
|
||||
const input = await buildIxbrlInput(supabase, companyId, id, {
|
||||
proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0,
|
||||
})
|
||||
const result = runPreflightChecks(input)
|
||||
|
||||
// Generation dry-run: a document that cannot even be generated must
|
||||
// block, with the reason in the issue list rather than a raw error.
|
||||
const issues: PreflightIssue[] = [...result.issues]
|
||||
let generatedBytes = 0
|
||||
try {
|
||||
const { xhtml } = generateK2IxbrlDocument(input)
|
||||
generatedBytes = Buffer.byteLength(xhtml, 'utf8')
|
||||
if (generatedBytes >= 5 * 1024 * 1024) {
|
||||
issues.push({
|
||||
code: '5006',
|
||||
severity: 'error',
|
||||
message: 'Dokumentet överstiger Bolagsverkets maxstorlek 5 MB.',
|
||||
})
|
||||
}
|
||||
} catch (genErr) {
|
||||
issues.push({
|
||||
code: 'ACC-GEN',
|
||||
severity: 'error',
|
||||
message: `iXBRL-dokumentet kunde inte genereras: ${genErr instanceof Error ? genErr.message : 'okänt fel'}`,
|
||||
})
|
||||
}
|
||||
|
||||
const errors = issues.filter((issue) => issue.severity === 'error')
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
ok: errors.length === 0,
|
||||
issues,
|
||||
error_count: errors.length,
|
||||
warning_count: issues.length - errors.length,
|
||||
generated_bytes: generatedBytes,
|
||||
entry_point: input.entryPointId,
|
||||
period: input.period,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : ''
|
||||
if (/not found/i.test(message)) {
|
||||
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user