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:
Mattsson
2026-06-12 16:35:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8e8b63a200
commit db8983ba9e
131 changed files with 33796 additions and 340 deletions
@@ -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 },
)
+59
View File
@@ -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 })
}
},
)
+20
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { ensureInitialized } from '@/lib/init'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
@@ -105,6 +106,25 @@ export async function POST(
)
if (journalEntry) {
journalEntryId = journalEntry.id
// Periodiserade lines: create schedules + catch-up dissolutions now
// that the revenue entry exists. Failures are logged, never fatal —
// the verifikat is committed.
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId,
user.id,
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
(settings?.entity_type as EntityType) || 'enskild_firma',
)
if (accrual.failed > 0) {
log.error('accrual schedule creation failed on mark-sent', {
failed: accrual.failed,
})
}
const { error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
+20
View File
@@ -11,6 +11,7 @@ import {
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -226,6 +227,25 @@ export const POST = withRouteContext(
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
// Periodiserade lines: create their schedules + catch-up
// dissolutions now that the revenue entry exists. Failures degrade
// to PARTIAL — the entry is committed and must not be rolled back.
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId!,
user.id,
invoice as Invoice,
items,
journalEntry.id,
(company as CompanySettings).entity_type,
)
if (accrual.failed > 0) {
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
}
}
} catch (err) {
opLog.error('failed to create invoice journal entry on send', err as Error)
+103 -2
View File
@@ -7,6 +7,8 @@ import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocument
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import {
computeDeduction,
@@ -139,6 +141,39 @@ export const POST = withRouteContext(
for (const item of invoiceInput.items) item.vat_rate = 0
}
// Periodisering guards. The line schema already validates the period
// shape; here we gate the flows where deferral has no meaning: cash
// method (recognition at payment), reverse charge/export (3308/3305 must
// reflect the full sale for ruta 39/40), and non-invoice document types.
const hasAccrualItems = invoiceInput.items.some(
(item) => item.accrual_period_start && item.accrual_period_end,
)
if (hasAccrualItems) {
if (documentType !== 'invoice') {
return errorResponseFromCode('INVOICE_CREATE_ACCRUAL_INVALID', log, {
requestId,
details: { reason: 'document_type', documentType },
})
}
if (vatRules.treatment === 'reverse_charge' || vatRules.treatment === 'export') {
return errorResponseFromCode('INVOICE_CREATE_ACCRUAL_INVALID', log, {
requestId,
details: { reason: 'vat_treatment', vatTreatment: vatRules.treatment },
})
}
const { data: methodSettings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId!)
.maybeSingle()
if ((methodSettings?.accounting_method || 'accrual') !== 'accrual') {
return errorResponseFromCode('INVOICE_CREATE_ACCRUAL_INVALID', log, {
requestId,
details: { reason: 'accounting_method' },
})
}
}
// Free-text rows carry no amounts and are excluded from totals + VAT.
const subtotal = invoiceInput.items.reduce(
(sum, item) => (item.line_type === 'text' ? sum : sum + item.quantity * item.unit_price),
@@ -353,6 +388,9 @@ export const POST = withRouteContext(
work_type: null,
housing_designation: null,
apartment_number: null,
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
@@ -392,6 +430,22 @@ export const POST = withRouteContext(
work_type: documentType === 'invoice' ? (item.work_type ?? null) : null,
housing_designation: documentType === 'invoice' ? (item.housing_designation ?? null) : null,
apartment_number: documentType === 'invoice' ? (item.apartment_number ?? null) : null,
// Periodisering (förutbetald intäkt): frozen onto the line. The
// schedule itself is created when the invoice is sent/booked. ROT/RUT
// lines never defer (schema-enforced); the guard above already
// restricted this to real invoices under faktureringsmetoden.
accrual_period_start:
documentType === 'invoice' && !deductionType
? (item.accrual_period_start ?? null)
: null,
accrual_period_end:
documentType === 'invoice' && !deductionType
? (item.accrual_period_end ?? null)
: null,
accrual_balance_account:
documentType === 'invoice' && !deductionType && item.accrual_period_start && item.accrual_period_end
? (item.accrual_balance_account ?? DEFAULT_DEFERRED_REVENUE_ACCOUNT)
: null,
}
})
@@ -490,6 +544,10 @@ async function createCreditNote(
log: Logger,
requestId: string,
) {
// Non-blocking issues (e.g. partial accrual cancellation) surfaced to the
// caller alongside the created credit note.
const warnings: Array<{ code: string; message: string }> = []
const { data: originalInvoice, error: originalError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
@@ -561,7 +619,7 @@ async function createCreditNote(
})
}
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null }) => ({
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null }) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
@@ -578,6 +636,14 @@ async function createCreditNote(
// balance. article_id is preserved for the usage history.
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
// Same reasoning for periodiserade lines: the credit-note verifikat must
// reverse against the 29xx interim account the original credited, not the
// revenue account. generatePerRateLines reads these fields to substitute.
// No schedule is ever created for a credit note (only send/mark-sent
// create schedules); the original's schedule is cancelled below.
accrual_period_start: item.accrual_period_start ?? null,
accrual_period_end: item.accrual_period_end ?? null,
accrual_balance_account: item.accrual_balance_account ?? null,
}))
const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems)
@@ -638,11 +704,46 @@ async function createCreditNote(
// Non-blocking — credit note still exists.
}
// Periodisering interplay: cancel remaining months and storno posted
// dissolutions so origin + dissolutions + stornos + credit net to zero on
// both 29xx and 3xxx. Best-effort — never blocks the credit itself, but
// partial reversals are surfaced as a response warning so the user knows
// the schedule stayed active.
try {
const cancelResult = await cancelSchedulesForSource(
supabase,
companyId,
userId,
{ invoiceId: input.credited_invoice_id },
{ reversalDate: creditNote.invoice_date },
)
if (cancelResult.failedReversals > 0) {
warnings.push({
code: 'ACCRUAL_CANCEL_PARTIAL',
message:
'Fakturan krediterades, men en eller flera periodiseringsverifikat ' +
'kunde inte vändas. Periodiseringen är fortfarande aktiv — ' +
'kontrollera under Bokföring → Periodiseringar.',
})
}
} catch (err) {
log.warn('failed to cancel accrual schedules for credited invoice', err as Error)
warnings.push({
code: 'ACCRUAL_CANCEL_PARTIAL',
message:
'Fakturan krediterades, men periodiseringarna kunde inte avslutas. ' +
'Kontrollera under Bokföring → Periodiseringar.',
})
}
await eventBus.emit({
type: 'credit_note.created',
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
})
}
return NextResponse.json({ data: completeCreditNote })
return NextResponse.json({
data: completeCreditNote,
...(warnings.length > 0 ? { warnings } : {}),
})
}
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
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 }),
}))
import { DELETE } from '../route'
describe('DELETE /api/supplier-invoices/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
function deleteRequest() {
return DELETE(
createMockRequest('/api/supplier-invoices/si-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'si-1' }),
)
}
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await deleteRequest()
expect(response.status).toBe(401)
})
it('returns 404 when the invoice does not exist', async () => {
enqueue({ data: null, error: null })
const response = await deleteRequest()
expect(response.status).toBe(404)
})
it('blocks deletion of credit notes', async () => {
enqueue({
data: { status: 'registered', registration_journal_entry_id: null, is_credit_note: true },
})
const { status } = await parseJsonResponse(await deleteRequest())
expect(status).toBe(400)
})
it('blocks deletion when a registration journal entry exists', async () => {
enqueue({
data: {
status: 'registered',
registration_journal_entry_id: 'je-1',
is_credit_note: false,
},
})
const response = await deleteRequest()
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { reason: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_DELETE_HAS_BOOKING')
expect(body.error.details.reason).toBe('registration_journal_entry')
// Items must NOT have been deleted (only the existence fetch ran).
expect(mockSupabase.from).toHaveBeenCalledTimes(1)
})
it('blocks deletion when an accrual schedule references the invoice', async () => {
enqueue({
data: {
status: 'registered',
registration_journal_entry_id: null,
is_credit_note: false,
},
})
// accrual_schedules lookup finds a linked schedule (ON DELETE RESTRICT
// would otherwise fail AFTER the items were already deleted).
enqueue({ data: { id: 'sched-1' } })
const response = await deleteRequest()
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { reason: string; scheduleId: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_DELETE_HAS_BOOKING')
expect(body.error.details.reason).toBe('accrual_schedule')
expect(body.error.details.scheduleId).toBe('sched-1')
// Only the existence fetch + schedule lookup ran — no item deletion.
expect(mockSupabase.from).toHaveBeenCalledTimes(2)
})
it('deletes an unbooked registered invoice', async () => {
enqueue({
data: {
status: 'registered',
registration_journal_entry_id: null,
is_credit_note: false,
},
})
enqueue({ data: null }) // accrual_schedules lookup: none
enqueue({ data: null }) // items delete
enqueue({ data: null }) // invoice delete
const response = await deleteRequest()
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
})
})
+42 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -102,10 +103,14 @@ export const POST = withRouteContext(
let journalEntryId: string | null = null
if (accountingMethod === 'accrual') {
try {
// Pass the ORIGINAL items: deferred lines carry their periodisering
// fields there, so the credit entry reverses against the same 17xx
// interim account the registration booked to. The copied credit-note
// items intentionally have no accrual fields.
const journalEntry = await createSupplierCreditNoteEntry(
supabase, companyId!, user.id,
creditNote as SupplierInvoice,
creditItems as SupplierInvoiceItem[],
(original.items || []) as SupplierInvoiceItem[],
original.supplier?.supplier_type || 'swedish_business',
original.supplier?.name,
)
@@ -135,6 +140,41 @@ export const POST = withRouteContext(
}
}
// Periodisering interplay: cancel remaining months and storno the
// already-posted dissolutions so origin + dissolutions + stornos +
// credit-note net to zero on both the interim and cost accounts.
// Best-effort: a reversal hiccup (e.g. locked period) must not block the
// credit itself — the schedule stays active and visible for follow-up,
// and the response carries a PARTIAL-style warning (same pattern as the
// supplier-create route's ACCRUAL_SCHEDULE_FAILED warning).
const warnings: Array<{ code: string; message: string }> = []
try {
const cancelResult = await cancelSchedulesForSource(
supabase,
companyId!,
user.id,
{ supplierInvoiceId: id },
{ reversalDate: creditNote.invoice_date },
)
if (cancelResult.failedReversals > 0) {
warnings.push({
code: 'ACCRUAL_CANCEL_PARTIAL',
message:
'Fakturan krediterades, men en eller flera periodiseringsverifikat ' +
'kunde inte vändas. Periodiseringen är fortfarande aktiv — ' +
'kontrollera under Bokföring → Periodiseringar.',
})
}
} catch (err) {
opLog.warn('failed to cancel accrual schedules for credited supplier invoice', err as Error)
warnings.push({
code: 'ACCRUAL_CANCEL_PARTIAL',
message:
'Fakturan krediterades, men periodiseringarna kunde inte avslutas. ' +
'Kontrollera under Bokföring → Periodiseringar.',
})
}
const newRemaining = Math.max(0, original.remaining_amount - original.total)
const newStatus = newRemaining <= 0 ? 'credited' : original.status
@@ -163,6 +203,7 @@ export const POST = withRouteContext(
return NextResponse.json({
data: creditNote,
journal_entry_id: journalEntryId,
...(warnings.length > 0 ? { warnings } : {}),
})
},
{ requireWrite: true },
+31
View File
@@ -4,6 +4,10 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
const log = createLogger('api.supplier_invoices.id')
export async function GET(
_request: Request,
@@ -143,6 +147,33 @@ export async function DELETE(
)
}
// Booked invoices must go through the credit flow (mirrors the credit-note
// guard above). Two independent blockers:
// (a) a posted registration verifikat — deleting the row would orphan it
// and silently understate 2440/2641 for the momsdeklaration;
// (b) an accrual schedule — accrual_schedules.supplier_invoice_id is
// ON DELETE RESTRICT, so the invoice DELETE below would fail AFTER the
// items were already deleted, leaving a broken invoice with zero rows.
if (existing.registration_journal_entry_id) {
return errorResponseFromCode('SI_DELETE_HAS_BOOKING', log, {
details: { reason: 'registration_journal_entry' },
})
}
const { data: linkedSchedule } = await supabase
.from('accrual_schedules')
.select('id')
.eq('company_id', companyId)
.eq('supplier_invoice_id', id)
.limit(1)
.maybeSingle()
if (linkedSchedule) {
return errorResponseFromCode('SI_DELETE_HAS_BOOKING', log, {
details: { reason: 'accrual_schedule', scheduleId: linkedSchedule.id },
})
}
// Delete items first, then invoice
await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', id)
@@ -687,6 +687,37 @@ describe('POST /api/supplier-invoices', () => {
expect(items[0].vat_amount).toBe(2500)
})
it('rejects periodisering combined with reverse_charge', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-RC-ACC',
invoice_date: '2026-01-01',
due_date: '2026-02-01',
reverse_charge: true,
items: [
{
description: 'Licens 12 mån',
amount: 12000,
account_number: '6540',
vat_rate: 0,
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
},
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_ACCRUAL_REVERSE_CHARGE')
// The guard must fire before anything is persisted or booked.
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
})
it('rejects paid_with_private_funds combined with reverse_charge', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
+79 -1
View File
@@ -4,6 +4,8 @@ import {
createSupplierInvoiceRegistrationEntry,
createSupplierInvoicePrivatelyPaidEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
@@ -69,6 +71,40 @@ export const POST = withRouteContext(
})
}
const hasAccrualItems = body.items.some(
(item) => item.accrual_period_start && item.accrual_period_end,
)
if (hasAccrualItems && body.reverse_charge) {
// Omvänd skattskyldighet: the expense line IS the VAT base for rutor
// 20–32 — deferring the net to a 17xx interim account would corrupt the
// momsdeklaration. Mirrors the customer-side reverse-charge guard.
return errorResponseFromCode('SI_CREATE_ACCRUAL_REVERSE_CHARGE', log, { requestId })
}
if (hasAccrualItems && paidPrivately) {
// Eget utlägg books the expense in one verifikat at registration —
// there is no interim-account flow to defer. UI hides the combination.
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: 'periodisering is not supported with paid_with_private_funds' },
})
}
if (hasAccrualItems) {
// Kontantmetoden recognises the cost at payment; periodisering only
// exists under faktureringsmetoden. Reject loudly instead of silently
// dropping the periods.
const { data: methodSettings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.single()
if ((methodSettings?.accounting_method || 'accrual') !== 'accrual') {
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: 'periodisering requires faktureringsmetoden (accrual)' },
})
}
}
const { data: supplier, error: supplierError } = await supabase
.from('suppliers')
.select('*')
@@ -121,6 +157,7 @@ export const POST = withRouteContext(
const vatAmount = item.vat_amount != null
? Math.round(item.vat_amount * 100) / 100
: Math.round(lineTotal * vatRate * 100) / 100
const hasAccrual = Boolean(item.accrual_period_start && item.accrual_period_end)
return {
sort_order: index,
description: item.description,
@@ -136,6 +173,15 @@ export const POST = withRouteContext(
// supplier charges no VAT (vat_rate stays 0); the engine self-assesses
// at this rate, defaulting to 25% huvudregeln when null.
reverse_charge_rate: body.reverse_charge ? (item.reverse_charge_rate ?? null) : null,
// Periodisering: frozen onto the line at create time. The balance
// account defaults from the cost account's BAS convention when the
// client leaves it blank.
accrual_period_start: hasAccrual ? item.accrual_period_start : null,
accrual_period_end: hasAccrual ? item.accrual_period_end : null,
accrual_balance_account: hasAccrual
? (item.accrual_balance_account ??
suggestBalanceAccount('expense', item.account_number))
: null,
}
})
@@ -265,9 +311,10 @@ export const POST = withRouteContext(
...item,
}))
const { error: itemsError } = await supabase
const { data: insertedItems, error: itemsError } = await supabase
.from('supplier_invoice_items')
.insert(itemInserts)
.select('id, sort_order')
if (itemsError) {
// Roll back the parent on items failure to avoid orphan rows.
@@ -378,6 +425,37 @@ export const POST = withRouteContext(
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
if (hasAccrualItems) {
// The registration entry is committed (immutable) — a schedule
// failure must not roll the invoice back. Surface a warning and
// let the user retry from the periodiseringar page instead.
const idBySortOrder = new Map(
((insertedItems ?? []) as Array<{ id: string; sort_order: number }>).map(
(row) => [row.sort_order, row.id],
),
)
const itemsWithIds = items.map((item) => ({
...item,
id: idBySortOrder.get(item.sort_order) ?? null,
}))
const scheduleResult = await createSchedulesForSupplierInvoice(
supabase,
companyId!,
user.id,
invoice as SupplierInvoice,
itemsWithIds as unknown as SupplierInvoiceItem[],
journalEntry.id,
)
if (scheduleResult.failed > 0) {
warnings.push({
code: 'ACCRUAL_SCHEDULE_FAILED',
message:
'Fakturan bokfördes, men en eller flera periodiseringar kunde inte ' +
'skapas. Kontrollera under Bokföring → Periodiseringar.',
})
}
}
} else {
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
// fiscal period covers invoice_date (every other failure throws and