Files
accounted/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/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

197 lines
7.9 KiB
TypeScript

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 {
getNarrative,
upsertNarrative,
} from '@/lib/bokslut/arsredovisning/narrative-service'
// Strip non-printable control characters that would corrupt PDF output or
// mislead a human reader of the årsredovisning. Whitelist printable ASCII
// + every byte ≥ 0x20 (covers Latin-1 + UTF-8 multi-byte sequences) while
// allowing tab/LF/CR for legitimate line breaks.
const stripControlChars = (s: string): string =>
s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
const sanitizedText = (max: number) =>
z
.string()
.max(max)
.transform(stripControlChars)
const PostSchema = z.object({
// Match the DB CHECK lengths exactly so a payload that would fail at the
// storage layer instead returns a clean 400 here. Free-text fields are
// rendered verbatim into the årsredovisning PDF, so we strip ASCII
// control bytes (NUL, ESC, etc.) at the schema layer: otherwise a
// tampered payload could corrupt PDF output or hide content from auditors.
description: sanitizedText(4000).nullable().optional(),
important_events: sanitizedText(4000).nullable().optional(),
resultatdisposition: sanitizedText(2000).nullable().optional(),
proposed_dividend: z
.number()
.min(0)
.max(1_000_000_000_000)
.nullable()
.optional()
.transform((value) =>
value === null || value === undefined ? value : Math.round(value * 100) / 100,
),
// ISO YYYY-MM-DD per the DATE column; null clears it. Validate as a
// real calendar date (not just regex) so '2024-13-99' returns 400 from
// the API instead of bubbling up as a Postgres 500.
agm_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.refine(
(s) => {
const d = new Date(`${s}T00:00:00Z`)
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s
},
{ message: 'Invalid calendar date' },
)
.nullable()
.optional(),
// Disclosure fields per ÅRL 5:13-15 § + BFNAR koncernförhållanden. All
// optional; null clears the override and the builder falls back to
// boilerplate ("Inga." / "Inga skulder förfaller efter mer än fem år.").
// Cap at 1 trillion SEK, well above any realistic Swedish company's
// long-term debt (Volvo Group ~500 G SEK), prevents overflow in PDF
// formatting and downstream numeric handling.
long_term_debt_over_five_years: z
.number()
.min(0)
.max(1_000_000_000_000)
.nullable()
.optional(),
securities_pledged: sanitizedText(4000).nullable().optional(),
contingent_liabilities: sanitizedText(4000).nullable().optional(),
parent_company_name: sanitizedText(200).nullable().optional(),
// Swedish organisationsnummer NNNNNN-NNNN. Third digit ≥ 2 distinguishes
// legal-entity org numbers from personnummer (whose third digit forms part
// of a month, 0-1). ÅRL 5:13-15 disclosure is about parent legal entities,
// so personnummer-shaped values are out of scope and a GDPR Art.5(1)(c)
// data-minimisation concern if persisted. Empty string clears the override.
parent_company_org_number: z
.union([
z.literal(''),
z.string().regex(/^\d{2}[2-9]\d{3}-\d{4}$/, {
message: 'Ogiltigt organisationsnummer (NNNNNN-NNNN, ej personnummer)',
}),
])
.nullable()
.optional(),
parent_company_city: sanitizedText(100).nullable().optional(),
long_term_debt_over_five_years_confirmed: z.boolean().optional(),
securities_pledged_confirmed: z.boolean().optional(),
contingent_liabilities_confirmed: z.boolean().optional(),
parent_company_confirmed: z.boolean().optional(),
agm_disposition_outcome: z
.enum(['proposal_approved', 'alternative_decision'])
.nullable()
.optional(),
agm_disposition_decision: sanitizedText(2000).nullable().optional(),
}).strict().superRefine((value, ctx) => {
if (
value.agm_disposition_outcome === 'alternative_decision' &&
!value.agm_disposition_decision?.trim()
) {
ctx.addIssue({
code: 'custom',
path: ['agm_disposition_decision'],
message: 'Årsstämmans alternativa beslut måste beskrivas.',
})
}
})
export const GET = withRouteContext(
'period.arsredovisning_narrative_get',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
// Mirror the POST handler's period-ownership pre-check so a valid
// JWT for company A can't probe / enumerate company B's period IDs
// through this endpoint.
const { data: period } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!period) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
const data = await getNarrative(supabase, companyId, id)
return NextResponse.json({ data })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
)
export const POST = withRouteContext(
'period.arsredovisning_narrative_post',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, PostSchema)
if (!validation.success) return validation.response
try {
// Verify the fiscal period belongs to the authenticated company before
// writing: defense-in-depth alongside RLS, gives a cleaner 404 than
// the RLS rejection envelope.
//
// Deliberately NOT gated on the bookkeeping period lock: the narrative
// is årsredovisning document text (ÅRL 6 kap.), not räkenskapsinformation
// in the journal, and the normal flow closes the period BEFORE the
// årsredovisning is written. The document freezes when it is filed:
// a Bolagsverket submission registered for this period makes the text
// read-only (what was uploaded is already immutable via the
// arsredovisning_submissions trigger + stored document).
const { data: period } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!period) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
// Only 'registrerad' freezes the text. 'avslutad' (case closed WITHOUT
// registration, e.g. withdrawn or rejected) deliberately stays
// editable: the document was never registered at Bolagsverket and a
// refiling needs amendable narrative text.
const { data: registered, error: registeredError } = await supabase
.from('arsredovisning_submissions')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
.eq('status', 'registrerad')
.limit(1)
.maybeSingle()
if (registeredError) {
throw new Error(`Failed to check submission status: ${registeredError.message}`)
}
if (registered) {
return errorResponseFromCode('ARSREDOVISNING_REGISTERED', log, { requestId })
}
const data = await upsertNarrative(supabase, companyId, user.id, id, validation.data)
const { error: confirmationError } = await supabase
.from('annual_report_profiles')
.update({ narrative_confirmed_at: null })
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
if (confirmationError) {
throw new Error(`Failed to clear narrative confirmation: ${confirmationError.message}`)
}
return NextResponse.json({ data })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)