Files
accounted/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts
T
Mattsson e11f70b347 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
2026-07-21 23:00:15 +02:00

475 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from 'next/server'
import { z } from 'zod'
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,
getSchablonintaktRate,
listExistingPeriodiseringsfonder,
proposeAvsattning,
proposeAteforing,
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-service'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import {
buildDispositionsProposal,
buildLatentTaxProposal,
} from '@/lib/bokslut/dispositions-proposal-builder'
import type { ProposedDisposition } from '@/lib/bokslut/types'
import type { JournalEntry } from '@/types'
/**
* The schablonintäkt rate (IL 30 kap 6a §) defaults per fiscal year via
* getSchablonintaktRate (statslåneräntan 30 nov året före det kalenderår
* beskattningsåret går ut, lägst 0.5 %). Caller can override per request
* via `schablonintaktRate` in the POST body; a future Riksbanken
* integration will fetch the rate automatically.
*
* Canonical bokslut order. Each calculator re-reads the trial balance to
* derive its base, so earlier items must post before later items see their
* effect: återföring → överavskrivningar → SLP → avsättning → bolagsskatt.
* SLP posts before avsättning because it is deductible: the 25 % avsättning
* cap applies to the result AFTER the SLP cost (IL 30 kap 5 §). The POST
* handler enforces this order regardless of how the client sends its items
* array, so the avsättning cap can never be evaluated against a stale
* (pre-återföring, pre-SLP) net result.
*/
const DISPOSITION_ORDER: Record<string, number> = {
periodiseringsfond_ateforing: 0,
overavskrivningar: 1,
sarskild_loneskatt: 2,
periodiseringsfond_avsattning: 3,
bolagsskatt: 4,
// K3 only: posts last because it depends on the closing 21xx balance,
// which only stabilises once avsättning / återföring have been applied.
uppskjuten_skatt: 5,
}
// ============================================================
// GET: return proposal snapshot with defaults
// ============================================================
export const GET = withRouteContext(
'period.bokslutsdispositioner_preview',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const opLog = log.child({ periodId: id })
try {
const data = await buildDispositionsProposal(supabase, companyId, id)
return NextResponse.json({ data })
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
opLog.error('bokslutsdispositioner preview failed', err as Error)
return errorResponse(err, opLog, { requestId })
}
},
)
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'),
}).strict(),
z.object({
kind: z.literal('sarskild_loneskatt'),
manualAdjustment: z.number().optional(),
}),
z.object({
kind: z.literal('periodiseringsfond_avsattning'),
/** Optional override for the SLR-based schablonintäkt rate; defaults to
* the server-side constant. Used both to compute the cap base and to
* feed back into bolagsskatt's adjustment if present in the same batch.
* Bounded to a sane range — an inflated rate would inflate the cap base
* and let the caller exceed the legal 25 % avsättning limit (IL 30 kap). */
schablonintaktRate: z.number().min(0).max(0.2).optional(),
desiredAmount: z.number().positive().optional(),
}),
z.object({
kind: z.literal('periodiseringsfond_ateforing'),
returns: z.record(z.string(), z.number().nonnegative()).default({}),
schablonintaktRate: z.number().min(0).max(0.2).optional(),
}),
z.object({
kind: z.literal('overavskrivningar'),
additionalAmount: z.number(),
/** Asset category for BAS account selection: defaults to maskiner &
* inventarier (8853/2153), the dominant K2 case. */
category: z
.enum(['machinery_equipment', 'building', 'immaterial', 'group'])
.optional(),
}),
// K3 only: uppskjuten skatt provision. Server recomputes the amount from
// current 2240 + 21xx state so the client cannot override it.
z.object({
kind: z.literal('uppskjuten_skatt'),
}),
])
const PostBodySchema = z.object({
items: z.array(ItemSchema).min(1),
})
export const POST = withRouteContext(
'period.bokslutsdispositioner_post',
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, PostBodySchema)
if (!validation.success) return validation.response
try {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, opening_balance_entry_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.closing_entry_id || period.locked_at) {
return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId })
}
const fiscalYear = parseInt(period.period_end.slice(0, 4), 10)
const created: { kind: string; entry: JournalEntry }[] = []
// Process items in canonical bokslut order regardless of client array
// ordering: each computation pulls the current income statement, so
// återföring must post before avsättning sees its cap base; över-
// avskrivningar must post before bolagsskatt; SLP and bolagsskatt last.
const sortedItems = [...validation.data.items].sort(
(a, b) => DISPOSITION_ORDER[a.kind] - DISPOSITION_ORDER[b.kind],
)
// KNOWN LIMITATION (SOC 2 PI1.3): the loop is not wrapped in a database
// transaction: each item posts its own journal entry via the engine.
// A failure midway leaves earlier items committed and later ones not.
// Recovery: the UI can re-POST omitting already-committed kinds; each
// calculator re-derives from the current trial balance so the next run
// produces correct amounts on top of what's already there. A future
// RPC-level wrapper (Phase 5+) will make this atomic.
for (const item of sortedItems) {
const proposal = await computeProposal(item, supabase, companyId, period, fiscalYear)
if (!proposal) continue
const entry = await createJournalEntry(supabase, companyId, user.id, {
fiscal_period_id: id,
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,
})
created.push({ kind: item.kind, entry })
}
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 })
}
},
{ requireWrite: true },
)
type PostItem = z.infer<typeof ItemSchema>
/** The period row the POST handler already validated. Passed through so the
* per-item computations never re-fetch it: a transient DB failure on a
* re-fetch must fail the request, not silently skip a disposition. */
interface ValidatedPeriod {
id: string
period_start: string
period_end: string
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],
companyId: string,
period: ValidatedPeriod,
fiscalYear: number,
): Promise<ProposedDisposition | null> {
const fiscalPeriodId = period.id
switch (item.kind) {
case 'bolagsskatt': {
// Dispositioner are booked as source_type='year_end', which the income
// statement excludes, so net_result alone overstates resultat före skatt.
// Add the already-posted dispositions back (avsättning , återföring +,
// 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, 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,
)
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
// calculator is not posted-aware and would book the full SLP again.
const posted = await sumPostedYearEndDispositions(supabase, companyId, fiscalPeriodId)
if (posted.slpPortion !== 0) return null
return calculateSarskildLoneskatt(supabase, companyId, fiscalPeriodId, {
manualAdjustment: item.manualAdjustment,
})
}
case 'periodiseringsfond_avsattning': {
// Re-derive the cap base from current state so the user can't sneak in
// a higher desiredAmount than 25 % of actual skattemässigt resultat.
const incomeStatement = await generateIncomeStatement(
supabase,
companyId,
fiscalPeriodId,
)
const existing = await listExistingPeriodiseringsfonder(
supabase,
companyId,
period.period_end,
period.period_start,
period.opening_balance_entry_id,
)
const schablonintaktRate = item.schablonintaktRate ?? getSchablonintaktRate(fiscalYear)
// Schablonintäkt applies to the fond balance at the START of the tax
// year (IL 30 kap 6a §): opening balances, regardless of what has
// been avsatt or återfört during the period.
const schablonintakt = existing.reduce(
(sum, f) => sum + Math.max(0, f.opening_balance) * schablonintaktRate,
0,
)
// A previous avsättning in this bokslut consumes 25 %-cap headroom:
// without this, re-running the flow books the fond twice. Measured as
// the current cohort ACCOUNT's growth during the period so a
// prior-year fond sharing the account (shortened brutet räkenskapsår,
// decade wrap) does not consume this year's headroom.
const currentCohort = existing.find(
(f) => f.account_number === getPeriodiseringsfondCohortAccount(fiscalYear),
)
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
// their signed effect back, then add back any posted avsättning itself
// (the cap applies to the result BEFORE avsättning; headroom is
// handled via alreadyProvisioned).
const postedEffect = await sumPostedYearEndDispositions(
supabase,
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,
fiscalYear,
alreadyProvisioned,
})
}
case 'periodiseringsfond_ateforing': {
// Recompute existing fonder server-side so the user can't return more
// than is on the books.
const existing = await listExistingPeriodiseringsfonder(
supabase,
companyId,
period.period_end,
period.period_start,
period.opening_balance_entry_id,
)
const result = proposeAteforing(existing, {
returns: item.returns,
schablonintaktRate: item.schablonintaktRate ?? getSchablonintaktRate(fiscalYear),
})
// Combine multiple cohort reversals into a single voucher with multiple
// lines so we don't blow up voucher numbering, but each fond is its own
// line pair already. Build a merged ProposedDisposition.
if (result.proposals.length === 0) return null
return mergeAteforingProposals(result.proposals)
}
case 'overavskrivningar':
return proposeOveravskrivningar({
additionalAmount: item.additionalAmount,
category: item.category,
})
case 'uppskjuten_skatt':
// Server-only: recompute from current TB (which already reflects any
// 21xx postings that committed earlier in this batch). The client
// sends no amount: the calculator owns the K3 split.
return buildLatentTaxProposal({
supabase,
companyId,
fiscalPeriodId,
})
}
}
function mergeAteforingProposals(proposals: ProposedDisposition[]): ProposedDisposition {
const lines = proposals.flatMap((p) => p.lines)
const totalAmount = proposals.reduce((sum, p) => sum + p.amount, 0)
const warnings = proposals.flatMap((p) => p.warnings)
return {
kind: 'periodiseringsfond_ateforing',
label: 'Återföring periodiseringsfond',
description: proposals.map((p) => p.label).join(', '),
amount: totalAmount,
lines,
warnings,
}
}