Bug/gh issues fiz (#1103)

* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
This commit is contained in:
Mattsson
2026-07-21 23:00:15 +02:00
committed by GitHub
parent 702512437a
commit e11f70b347
467 changed files with 17569 additions and 2221 deletions
+47 -1
View File
@@ -262,8 +262,52 @@ export const TaxDeadlineTypeSchema = z.enum([
'arsredovisning',
'arsstamma',
'periodisk_sammanstallning',
'kvarskatt',
])
export const TaxAssessmentDecisionTypeSchema = z.enum(['final', 'reassessment'])
export const CreateTaxAssessmentNoticeSchema = z
.object({
fiscal_period_id: uuid,
decision_type: TaxAssessmentDecisionTypeSchema,
decision_date: saneIsoDate,
payment_due_date: saneIsoDate,
})
.refine((data) => data.payment_due_date >= data.decision_date, {
message: 'Förfallodagen får inte vara tidigare än beslutsdagen',
path: ['payment_due_date'],
})
export const UpdateTaxAssessmentNoticeSchema = z
.object({
fiscal_period_id: uuid.optional(),
decision_type: TaxAssessmentDecisionTypeSchema.optional(),
decision_date: saneIsoDate.optional(),
payment_due_date: saneIsoDate.optional(),
archived: z.boolean().optional(),
})
.refine((data) => Object.values(data).some((value) => value !== undefined), {
message: 'Minst ett fält måste anges',
})
.refine(
(data) => !data.decision_date || !data.payment_due_date || data.payment_due_date >= data.decision_date,
{
message: 'Förfallodagen får inte vara tidigare än beslutsdagen',
path: ['payment_due_date'],
},
)
export const UpdateInitialSetupStateSchema = z
.object({
path: z.enum(['migration', 'bank', 'fresh']).nullable().optional(),
completed: z.boolean().optional(),
dismissed: z.boolean().optional(),
})
.refine((data) => Object.values(data).some((value) => value !== undefined), {
message: 'Minst ett fält måste anges',
})
export const DeadlineSourceSchema = z.enum(['system', 'user'])
export const MomsPeriodSchema = z.enum(['monthly', 'quarterly', 'yearly'])
@@ -1574,7 +1618,9 @@ export const UpdateSettingsSchema = z.object({
.string()
.regex(/^#[0-9A-Fa-f]{6}$/, 'Ange en giltig hex-färg (#RRGGBB)')
.optional(),
invoice_font_family: z.enum(['Helvetica', 'Times-Roman', 'Courier']).optional(),
invoice_font_family: z
.enum(['Helvetica', 'Times-Roman', 'Courier', 'Source Sans 3', 'Source Serif 4', 'Custom'])
.optional(),
invoice_header_text: z.string().max(200).nullable().optional(),
invoice_footer_text: z.string().max(500).nullable().optional(),
// Automation
+10 -1
View File
@@ -10,7 +10,7 @@ export const API_KEY_SCOPES = {
'transactions:read': { label: 'Transaktioner: läs', description: 'Lista transaktioner, mallförslag, kategoriförslag (3 verktyg)' },
'transactions:write': { label: 'Transaktioner: skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura (4 verktyg)' },
'customers:read': { label: 'Kunder: läs', description: 'Lista kunder (1 verktyg)' },
'customers:write': { label: 'Kunder: skriv', description: 'Skapa kunder (1 verktyg)' },
'customers:write': { label: 'Kunder: skriv', description: 'Skapa och uppdatera kunder (2 verktyg)' },
'articles:read': { label: 'Artiklar: läs', description: 'Lista artiklar i artikelregistret (1 verktyg)' },
'articles:write': { label: 'Artiklar: skriv', description: 'Skapa och uppdatera artiklar (2 verktyg)' },
'invoices:read': { label: 'Fakturor: läs', description: 'Lista fakturor (1 verktyg)' },
@@ -23,6 +23,7 @@ export const API_KEY_SCOPES = {
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
// v1 REST API: added Phase 1
'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
'companies:write': { label: 'Företag: skriv', description: 'Uppdatera företagsinställningar via stagade verktyg' },
'events:read': { label: 'Händelser: läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' },
'webhooks:manage': { label: 'Webhooks: hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' },
'operations:read': { label: 'Operationer: läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
@@ -119,6 +120,7 @@ export const STAGING_SCOPES: ApiKeyScope[] = [
'bookkeeping:write',
'payroll:write',
'documents:write',
'companies:write',
// Skatteverket submit tools stage submit_vat_declaration / submit_agi, so a
// key holding both this and pending_operations:approve is a SoD conflict:
// findStageApproveConflict picks it up automatically from this list.
@@ -143,6 +145,7 @@ export function findStageApproveConflict(scopes: ApiKeyScope[]): ApiKeyScope | n
/** Scope domain groups for UI rendering */
export const SCOPE_GROUPS = [
{ domain: 'companies', label: 'Företag', read: 'companies:read' as const, write: 'companies:write' as const },
{ domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const },
{ domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const },
{ domain: 'articles', label: 'Artiklar', read: 'articles:read' as const, write: 'articles:write' as const },
@@ -160,6 +163,8 @@ export const SCOPE_GROUPS = [
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
// Companies
gnubok_list_companies: 'companies:read',
gnubok_get_company_settings: 'companies:read',
gnubok_update_company_settings: 'companies:write',
// Transactions
gnubok_list_uncategorized_transactions: 'transactions:read',
gnubok_list_transactions_without_documents: 'transactions:read',
@@ -177,6 +182,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
// Customers
gnubok_list_customers: 'customers:read',
gnubok_create_customer: 'customers:write',
gnubok_update_customer: 'customers:write',
// Articles (artikelregister)
gnubok_list_articles: 'articles:read',
gnubok_create_article: 'articles:write',
@@ -320,6 +326,9 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_propose_accruals: 'reports:read',
gnubok_propose_annual_depreciation: 'reports:read',
gnubok_preview_arsredovisning: 'reports:read',
gnubok_validate_arsredovisning: 'reports:read',
gnubok_list_arsredovisning_versions: 'reports:read',
gnubok_get_arsredovisning_filing_status: 'reports:read',
gnubok_preview_ef_declaration: 'reports:read',
// Deliberately UNSCOPED (available to any authenticated key):
// gnubok_search_tools, gnubok_list_skills, gnubok_load_skill,
@@ -107,6 +107,16 @@ describe('calculateBolagsskatt', () => {
expect(generateIncomeStatement).not.toHaveBeenCalled()
})
it('calculates Miles tax after both non-deductible account addbacks', async () => {
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
resultBeforeTaxOverride: 592_722.21,
manualAdjustments: { nonDeductibleExpenses: 2_994 + 2_250 },
})
expect(result?.computation?.taxableResultClamped).toBe(597_960)
expect(result?.amount).toBe(123_180)
})
/** Table-keyed FIFO client: consumption order per table mirrors the
* two-step entry-lines fetch plus the reversed-ids lookup. */
function makeQueuedClient(queues: Record<string, { data: unknown; error: unknown }[]>) {
@@ -150,6 +160,7 @@ describe('calculateBolagsskatt', () => {
const effect = await sumPostedYearEndDispositions(client, 'co', 'fp')
expect(effect.total).toBe(-174_000) // -150k + 20k - 39k - 5k
expect(effect.slpPortion).toBe(-5_000)
expect(effect.taxProvisionPortion).toBe(123_600)
})
it('sumPostedYearEndDispositions counts the replacement of a corrected year_end entry', async () => {
@@ -177,16 +188,17 @@ describe('calculateBolagsskatt', () => {
const effect = await sumPostedYearEndDispositions(client, 'co', 'fp')
expect(effect.total).toBe(-90_000)
expect(effect.slpPortion).toBe(0)
expect(effect.taxProvisionPortion).toBe(0)
})
it('truncates taxable result to whole krona before applying tax', async () => {
it('rounds taxable result down to a whole 10 SEK before applying tax', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 100_999.99,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
// floor(100_999.99) = 100_999, × 0.206 = 20805.794 → round = 20_806
expect(result!.amount).toBe(20_806)
expect(result?.computation?.taxableResultClamped).toBe(100_990)
expect(result!.amount).toBe(20_804)
})
})
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { loadTaxAdjustmentSnapshot } from '../tax-provision/tax-adjustment-service'
function makeClient(rows: unknown[] = []) {
const result = { data: rows, error: null }
const handler: ProxyHandler<object> = {
get(_target, property) {
if (property === 'then') {
return (resolve: (value: unknown) => void) => resolve(result)
}
return () => new Proxy({}, handler)
},
}
return {
from: () => new Proxy({}, handler),
} as unknown as Parameters<typeof loadTaxAdjustmentSnapshot>[0]
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [
{
account_number: '6992',
closing_debit: 2_994,
closing_credit: 0,
},
{
account_number: '8423',
closing_debit: 2_250,
closing_credit: 0,
},
],
totalDebit: 5_244,
totalCredit: 0,
isBalanced: false,
} as Awaited<ReturnType<typeof generateTrialBalance>>)
})
describe('loadTaxAdjustmentSnapshot', () => {
it('detects Miles account balances as non-deductible expenses', async () => {
const snapshot = await loadTaxAdjustmentSnapshot(makeClient(), 'company-1', 'period-1')
expect(snapshot.nonDeductibleExpenses).toBe(5_244)
expect(snapshot.nonTaxableIncome).toBe(0)
expect(snapshot.items.find((item) => item.accountNumber === '6992')).toMatchObject({
amount: 2_994,
included: true,
})
expect(snapshot.items.find((item) => item.accountNumber === '8423')).toMatchObject({
amount: 2_250,
included: true,
})
})
it('honors saved exclusions and includes manual adjustments', async () => {
const snapshot = await loadTaxAdjustmentSnapshot(
makeClient([
{
source_key: 'account:8423',
adjustment_type: 'non_deductible_expense',
source: 'detected',
description: 'Räntekostnader för skatter och avgifter',
account_number: '8423',
amount: 2_250,
included: false,
},
{
source_key: 'manual:non_deductible_expenses',
adjustment_type: 'non_deductible_expense',
source: 'manual',
description: 'Ytterligare ej avdragsgilla kostnader',
account_number: null,
amount: 100,
included: true,
},
]),
'company-1',
'period-1',
)
expect(snapshot.nonDeductibleExpenses).toBe(3_094)
expect(snapshot.items.find((item) => item.accountNumber === '8423')?.included).toBe(false)
})
})
@@ -21,6 +21,7 @@ function makeMinimalK3Data(): ArsredovisningData {
company: {
name: 'Testbolaget AB',
org_number: '556677-8899',
entity_type: 'aktiebolag',
city: 'Stockholm',
},
fiscal_period: {
@@ -43,7 +44,18 @@ function makeMinimalK3Data(): ArsredovisningData {
{ label: 'Årets resultat', amount: 300_000 },
],
resultatdisposition: 'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
proposed_dividend: 0,
resultatdisposition_amounts: {
retained_earnings: 0,
share_premium_reserve: 0,
current_year_result: 0,
total: 0,
proposed_dividend: 0,
carried_forward: 0,
},
agm_date: '2026-06-15',
agm_disposition_outcome: 'proposal_approved',
agm_disposition_decision: null,
},
resultatrakning: [
{
@@ -147,6 +159,12 @@ function makeMinimalK3Data(): ArsredovisningData {
parent_company_name: null,
parent_company_org_number: null,
parent_company_city: null,
confirmations: {
long_term_debt_over_five_years: true,
securities_pledged: true,
contingent_liabilities: true,
parent_company: true,
},
},
}
}
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getAnnualReportCapabilities } from '../capabilities'
afterEach(() => vi.unstubAllEnvs())
describe('getAnnualReportCapabilities', () => {
it('keeps direct filing closed unless the release gate is explicit', () => {
vi.stubEnv('NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED', '')
const result = getAnnualReportCapabilities('k2', {
k2_eligible: true,
digital_filing_eligible: true,
digital_issues: [],
size_classification: 'smaller',
k2_relief_rule: 'eligible',
issues: [],
})
expect(result.paper.enabled).toBe(true)
expect(result.ixbrl_preview.enabled).toBe(true)
expect(result.connected_filing.enabled).toBe(false)
})
it('opens connected filing only when release and eligibility gates pass', () => {
vi.stubEnv('NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED', 'true')
const result = getAnnualReportCapabilities('k2', {
k2_eligible: true,
digital_filing_eligible: true,
digital_issues: [],
size_classification: 'smaller',
k2_relief_rule: 'eligible',
issues: [],
})
expect(result.connected_filing.enabled).toBe(true)
})
it('does not present the current K3 draft as paper-filing ready', () => {
const result = getAnnualReportCapabilities('k3')
expect(result.paper.enabled).toBe(false)
expect(result.paper.reason).toMatch(/granskningsutkast/i)
})
})
@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest'
import type { ArsredovisningData } from '../types'
import {
emptyAnnualReportProfile,
type AnnualReportEligibilityResult,
type AnnualReportProfile,
} from '../compliance-types'
import { validateAnnualReportCompleteness } from '../completeness'
const eligibility: AnnualReportEligibilityResult = {
k2_eligible: true,
digital_filing_eligible: true,
size_classification: 'smaller',
k2_relief_rule: 'eligible',
issues: [],
digital_issues: [],
}
function report(): ArsredovisningData {
return {
accounting_framework: 'k2',
company: { name: 'Test AB', org_number: '556012-5790', city: 'Stockholm' },
fiscal_period: {
id: 'period-1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
},
previous_period: null,
forvaltningsberattelse: {
description: 'Bolaget bedriver konsultverksamhet.',
important_events: 'Inga väsentliga händelser.',
resultatdisposition: 'Resultatet balanseras i ny räkning.',
proposed_dividend: 0,
resultatdisposition_amounts: {
retained_earnings: 80,
share_premium_reserve: 0,
current_year_result: 20,
total: 100,
proposed_dividend: 0,
carried_forward: 100,
},
agm_date: '2026-03-15',
},
balansrakning: {
total_assets: 100,
total_equity_liabilities: 100,
total_assets_previous: null,
total_equity_liabilities_previous: null,
assets: [{ label: 'Bank', amount: 100 }],
},
resultatrakning: [{ label: 'Nettoomsättning', amount: 100 }],
noter: [{ number: 1, title: 'Principer', body: 'K2' }],
signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: '2026-03-01' }],
warnings: [],
} as unknown as ArsredovisningData
}
function input(stage: 'draft' | 'signing' | 'filing') {
const profile: AnnualReportProfile = {
...emptyAnnualReportProfile('company-1', 'period-1'),
k2_assessment_confirmed_at: '2026-02-01T10:00:00Z',
narrative_confirmed_at: '2026-02-01T10:00:00Z',
signer_roster_confirmed_at: '2026-02-01T10:00:00Z',
is_in_liquidation: false,
auditor_report_required: false,
}
return {
report: report(),
profile,
eligibility,
stage,
todayIso: '2026-03-20',
disclosures: {
long_term_debt_over_five_years_confirmed: true,
securities_pledged_confirmed: true,
contingent_liabilities_confirmed: true,
parent_company_confirmed: true,
agm_disposition_outcome: 'proposal_approved' as
| 'proposal_approved'
| 'alternative_decision'
| null,
agm_disposition_decision: null,
},
}
}
describe('validateAnnualReportCompleteness', () => {
it('accepts a complete filing model', () => {
const result = validateAnnualReportCompleteness(input('filing'))
expect(result.ok).toBe(true)
expect(result.error_count).toBe(0)
})
it('does not infer an unanswered disclosure confirmation', () => {
const value = input('draft')
value.disclosures.securities_pledged_confirmed = false
const result = validateAnnualReportCompleteness(value)
expect(result.ok).toBe(false)
expect(result.issues.some((issue) => issue.code === 'AR-NOTE-SECURITIES-UNCONFIRMED')).toBe(
true,
)
})
it('requires signers before a version can be locked', () => {
const value = input('signing')
value.report.signatures = []
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code === 'AR-SIGNERS-MISSING')).toBe(true)
})
it('validates the organisation number check digit', () => {
const value = input('draft')
value.report.company.org_number = '556012-5791'
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code === 'AR-COMPANY-ORGNR')).toBe(true)
})
it('requires confirmation that the signer roster matches Bolagsverket', () => {
const value = input('signing')
value.profile.signer_roster_confirmed_at = null
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code === 'AR-SIGNER-ROSTER-UNCONFIRMED')).toBe(true)
})
it('blocks impossible signature and AGM chronology', () => {
const value = input('filing')
value.report.signatures[0].signed_at = '2025-12-30'
value.report.forvaltningsberattelse.agm_date = '2025-12-29'
const result = validateAnnualReportCompleteness(value)
expect(result.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining(['AR-SIGNATURE-BEFORE-PERIOD-END', 'AR-AGM-BEFORE-PERIOD-END']),
)
})
it('keeps K3 output draft-only until the complete K3 disclosure matrix exists', () => {
const value = input('signing')
value.report.accounting_framework = 'k3'
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code === 'AR-K3-DRAFT-ONLY')).toBe(true)
})
it('requires the AGM decision and evidence dates at filing stage', () => {
const value = input('filing')
value.report.signatures[0].signed_at = null
value.disclosures.agm_disposition_outcome = null
const result = validateAnnualReportCompleteness(value)
expect(result.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining(['AR-SIGNATURES-INCOMPLETE', 'AR-AGM-DISPOSITION-OUTCOME']),
)
})
it('blocks a proposed dividend above distributable equity', () => {
const value = input('draft')
value.report.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend = 101
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code === 'AR-DIVIDEND-EXCEEDS-EQUITY')).toBe(true)
})
it('requires a documented prudence assessment for a positive dividend', () => {
const value = input('draft')
value.report.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend = 50
const result = validateAnnualReportCompleteness(value)
expect(
result.issues.some((issue) => issue.code === 'AR-DIVIDEND-PRUDENCE-UNCONFIRMED'),
).toBe(true)
value.profile.dividend_prudence_confirmed = true
const confirmed = validateAnnualReportCompleteness(value)
expect(
confirmed.issues.some((issue) => issue.code === 'AR-DIVIDEND-PRUDENCE-UNCONFIRMED'),
).toBe(false)
})
it('does not report a dividend error for zero dividend and negative equity', () => {
const value = input('draft')
value.report.forvaltningsberattelse.resultatdisposition_amounts.total = -100
value.report.forvaltningsberattelse.resultatdisposition_amounts.carried_forward = -100
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code.startsWith('AR-DIVIDEND-'))).toBe(false)
})
})
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { emptyAnnualReportProfile } from '../compliance-types'
import { evaluateAnnualReportEligibility } from '../eligibility'
const metrics = {
current: { employees: 2, balance_sheet_total: 1_000_000, net_revenue: 2_000_000 },
previous: { employees: 2, balance_sheet_total: 900_000, net_revenue: 1_800_000 },
}
function completeProfile() {
return {
...emptyAnnualReportProfile('company-1', 'period-1'),
is_public_limited_company: false,
is_in_liquidation: false,
securities_traded_on_regulated_market: false,
is_parent_company: false,
has_foreign_branch: false,
has_crypto_assets: false,
has_share_based_payments: false,
has_convertible_debt: false,
building_revenue_share_pct: 0,
has_material_deferred_tax: false,
auditor_report_required: false,
}
}
describe('evaluateAnnualReportEligibility', () => {
it('accepts a confirmed smaller private K2 company without auditor report', () => {
const result = evaluateAnnualReportEligibility({
entityType: 'aktiebolag',
framework: 'k2',
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
profile: completeProfile(),
metrics,
})
expect(result.k2_eligible).toBe(true)
expect(result.digital_filing_eligible).toBe(true)
expect(result.issues).toEqual([])
})
it('does not treat unanswered legal facts as false', () => {
const result = evaluateAnnualReportEligibility({
entityType: 'aktiebolag',
framework: 'k2',
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
profile: emptyAnnualReportProfile('company-1', 'period-1'),
metrics,
})
expect(result.k2_eligible).toBe(false)
expect(result.issues.some((item) => item.code === 'AR-K2-PUBLIC-UNKNOWN')).toBe(true)
expect(result.digital_issues.some((item) => item.code === 'AR-DIGITAL-AUDIT-UNKNOWN')).toBe(
true,
)
})
it('blocks a larger company after two consecutive threshold years', () => {
const result = evaluateAnnualReportEligibility({
entityType: 'aktiebolag',
framework: 'k2',
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
profile: completeProfile(),
metrics: {
current: { employees: 51, balance_sheet_total: 41_000_000, net_revenue: 10_000_000 },
previous: { employees: 52, balance_sheet_total: 42_000_000, net_revenue: 10_000_000 },
},
})
expect(result.size_classification).toBe('larger')
expect(result.issues.some((item) => item.code === 'AR-K2-LARGE')).toBe(true)
})
it('blocks liquidation reports because they need a separate measurement model', () => {
const profile = completeProfile()
profile.is_in_liquidation = true
const result = evaluateAnnualReportEligibility({
entityType: 'aktiebolag',
framework: 'k2',
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
profile,
metrics,
})
expect(result.issues.some((item) => item.code === 'AR-SCOPE-LIQUIDATION')).toBe(true)
})
it('applies the new K2 exclusions to financial years starting in 2026', () => {
const profile = completeProfile()
profile.has_crypto_assets = true
const result = evaluateAnnualReportEligibility({
entityType: 'aktiebolag',
framework: 'k2',
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
profile,
metrics,
})
expect(result.issues.some((item) => item.code === 'AR-K2-CRYPTO')).toBe(true)
})
it('keeps K3 available for paper but does not claim digital K3 support', () => {
const result = evaluateAnnualReportEligibility({
entityType: 'aktiebolag',
framework: 'k3',
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
profile: completeProfile(),
metrics,
})
expect(result.k2_eligible).toBe(false)
expect(result.digital_filing_eligible).toBe(false)
expect(result.digital_issues.some((item) => item.code === 'AR-DIGITAL-FRAMEWORK')).toBe(true)
})
})
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
import type { CanonicalAnnualReport } from '../compliance-types'
import { annualReportContentHash, createAnnualReportVersion } from '../version-service'
function model(signedAt: string | null): CanonicalAnnualReport {
return {
schema_version: '1.0',
generated_at: '2026-07-21T10:00:00Z',
company_id: 'company-1',
fiscal_period_id: 'period-1',
entity_type: 'aktiebolag',
report: {
accounting_framework: 'k2',
signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: signedAt }],
},
profile: { reporting_currency: 'SEK' },
disclosures: {},
eligibility: {
digital_filing_eligible: true,
digital_issues: [],
},
validation: { ok: true },
ixbrl: {
entryPointId: 'k2-ab-risbs-2024-09-12',
underskrifter: {
dateringsdatum: signedAt,
signers: [
{
firstName: 'Anna',
lastName: 'Andersson',
role: 'Styrelseledamot',
signedDate: signedAt,
},
],
},
faststallelseintyg: {
signerFirstName: 'Anna',
signerLastName: 'Andersson',
signerRole: 'Styrelseledamot',
genereratDatum: '2026-07-21',
},
},
} as unknown as CanonicalAnnualReport
}
describe('annualReportContentHash', () => {
it('does not change when evidence dates are overlaid after locking', () => {
expect(annualReportContentHash(model(null))).toBe(
annualReportContentHash(model('2026-03-01T10:00:00Z')),
)
})
it('changes when signed content changes', () => {
const original = model(null)
const changed = model(null)
changed.report.signatures[0].name = 'Bertil Andersson'
expect(annualReportContentHash(changed)).not.toBe(annualReportContentHash(original))
})
it('locks the digital eligibility decision into the validation snapshot', async () => {
const rpc = vi.fn().mockResolvedValue({
data: [{ id: 'version-1', version_number: 1, status: 'draft' }],
error: null,
})
await createAnnualReportVersion({ rpc } as never, 'user-1', model(null), false)
expect(rpc).toHaveBeenCalledWith(
'create_annual_report_version',
expect.objectContaining({
p_validation_summary: expect.objectContaining({
digital_filing_eligible: true,
digital_issues: [],
profile: expect.objectContaining({ reporting_currency: 'SEK' }),
disclosures: {},
eligibility: expect.objectContaining({ digital_filing_eligible: true }),
}),
}),
)
})
})
@@ -213,6 +213,17 @@ function PageChrome({
}
export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
const reportSignatureDate = data.signatures
.map((signature) => signature.signed_at?.slice(0, 10) ?? null)
.filter((date): date is string => date !== null)
.sort()
.at(-1)
const agmDispositionDecision =
data.forvaltningsberattelse.agm_disposition_outcome === 'proposal_approved'
? `Årsstämman beslutade att godkänna styrelsens förslag: ${data.forvaltningsberattelse.resultatdisposition}`
: data.forvaltningsberattelse.agm_disposition_outcome === 'alternative_decision'
? data.forvaltningsberattelse.agm_disposition_decision
: null
return (
<Document>
{/* Cover */}
@@ -272,6 +283,22 @@ export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
<Text style={styles.sectionTitle}>Förslag till resultatdisposition</Text>
<Text style={styles.paragraph}>{data.forvaltningsberattelse.resultatdisposition}</Text>
{[
['Balanserat resultat', data.forvaltningsberattelse.resultatdisposition_amounts.retained_earnings],
['Fri överkursfond', data.forvaltningsberattelse.resultatdisposition_amounts.share_premium_reserve],
['Årets resultat', data.forvaltningsberattelse.resultatdisposition_amounts.current_year_result],
['Summa till årsstämmans förfogande', data.forvaltningsberattelse.resultatdisposition_amounts.total],
['Föreslagen utdelning', -data.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend],
['Balanseras i ny räkning', data.forvaltningsberattelse.resultatdisposition_amounts.carried_forward],
].map(([label, amount], index) => (
<View
key={String(label)}
style={index === 3 || index === 5 ? styles.tableRowTotal : styles.tableRow}
>
<Text style={styles.colLabel}>{label}</Text>
<Text style={styles.colAmount}>{fmt(Number(amount))}</Text>
</View>
))}
</Page>
{/* Resultaträkning — ÅRL post level, no account numbers. */}
@@ -502,7 +529,7 @@ export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
<Text style={styles.sectionTitle}>Underskrifter</Text>
<Text style={styles.paragraph}>
{data.company.city ? `${data.company.city}, ` : ''}
{data.fiscal_period.period_end}
{reportSignatureDate ?? '____________________'}
</Text>
{(data.signatures.length > 0
? data.signatures
@@ -543,7 +570,7 @@ export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
</Text>
<Text style={styles.sectionTitle}>Stämmans beslut om resultatdisposition</Text>
<Text style={styles.paragraph}>
{data.forvaltningsberattelse.resultatdisposition}
{agmDispositionDecision ?? 'Årsstämmans beslut har ännu inte registrerats.'}
</Text>
<View style={styles.signatureLine}>
<View style={styles.signatureSlot}>
@@ -171,6 +171,17 @@ function PageChrome({
}
export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
const reportSignatureDate = data.signatures
.map((signature) => signature.signed_at?.slice(0, 10) ?? null)
.filter((date): date is string => date !== null)
.sort()
.at(-1)
const agmDispositionDecision =
data.forvaltningsberattelse.agm_disposition_outcome === 'proposal_approved'
? `Årsstämman beslutade att godkänna styrelsens förslag: ${data.forvaltningsberattelse.resultatdisposition}`
: data.forvaltningsberattelse.agm_disposition_outcome === 'alternative_decision'
? data.forvaltningsberattelse.agm_disposition_decision
: null
return (
<Document>
{/* Cover */}
@@ -237,6 +248,22 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
<Text style={styles.sectionTitle}>Förslag till resultatdisposition</Text>
<Text style={styles.paragraph}>{data.forvaltningsberattelse.resultatdisposition}</Text>
{[
['Balanserat resultat', data.forvaltningsberattelse.resultatdisposition_amounts.retained_earnings],
['Fri överkursfond', data.forvaltningsberattelse.resultatdisposition_amounts.share_premium_reserve],
['Årets resultat', data.forvaltningsberattelse.resultatdisposition_amounts.current_year_result],
['Summa till årsstämmans förfogande', data.forvaltningsberattelse.resultatdisposition_amounts.total],
['Föreslagen utdelning', -data.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend],
['Balanseras i ny räkning', data.forvaltningsberattelse.resultatdisposition_amounts.carried_forward],
].map(([label, amount], index) => (
<View
key={String(label)}
style={index === 3 || index === 5 ? styles.tableRowTotal : styles.tableRow}
>
<Text style={styles.colLabel}>{label}</Text>
<Text style={styles.colAmount}>{fmt(Number(amount))}</Text>
</View>
))}
</Page>
{/* Resultaträkning — ÅRL post level, no account numbers. */}
@@ -292,7 +319,7 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
<Text style={styles.sectionTitle}>Underskrifter</Text>
<Text style={styles.paragraph}>
{data.company.city ? `${data.company.city}, ` : ''}
{data.fiscal_period.period_end}
{reportSignatureDate ?? '____________________'}
</Text>
{(data.signatures.length > 0
? data.signatures
@@ -345,7 +372,7 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
</Text>
<Text style={styles.sectionTitle}>Stämmans beslut om resultatdisposition</Text>
<Text style={styles.paragraph}>
{data.forvaltningsberattelse.resultatdisposition}
{agmDispositionDecision ?? 'Årsstämmans beslut har ännu inte registrerats.'}
</Text>
<View style={styles.signatureLine}>
<View style={styles.signatureSlot}>
+24
View File
@@ -173,6 +173,11 @@ export async function buildArsredovisningData(
)
const egen_kapital_changes = buildEquityChanges(mapping)
const proposedDividend = narrative?.proposed_dividend ?? 0
const retainedEarnings = mapping.br['BalanseratResultat']?.current ?? 0
const sharePremiumReserve = mapping.br['Overkursfond']?.current ?? 0
const currentYearResult = mapping.br['AretsResultatEgetKapital']?.current ?? 0
const distributableEquity = mapping.totals.frittEgetKapital.current
// Duplicate-value consistency with the RR (mirrors build-input.ts): the
// flerårsöversikt is computed from the income statement (ALL class-3
@@ -325,6 +330,7 @@ export async function buildArsredovisningData(
company: {
name: companyName,
org_number: orgNumber,
entity_type: entityType,
city,
},
fiscal_period: {
@@ -351,7 +357,18 @@ export async function buildArsredovisningData(
overrides.resultatdisposition ??
persistedRd ??
'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
proposed_dividend: proposedDividend,
resultatdisposition_amounts: {
retained_earnings: retainedEarnings,
share_premium_reserve: sharePremiumReserve,
current_year_result: currentYearResult,
total: distributableEquity,
proposed_dividend: proposedDividend,
carried_forward: distributableEquity - proposedDividend,
},
agm_date: persistedAgmDate,
agm_disposition_outcome: narrative?.agm_disposition_outcome ?? null,
agm_disposition_decision: narrative?.agm_disposition_decision ?? null,
},
resultatrakning,
warnings,
@@ -367,6 +384,13 @@ export async function buildArsredovisningData(
parent_company_name: narrative?.parent_company_name ?? null,
parent_company_org_number: narrative?.parent_company_org_number ?? null,
parent_company_city: narrative?.parent_company_city ?? null,
confirmations: {
long_term_debt_over_five_years:
narrative?.long_term_debt_over_five_years_confirmed ?? false,
securities_pledged: narrative?.securities_pledged_confirmed ?? false,
contingent_liabilities: narrative?.contingent_liabilities_confirmed ?? false,
parent_company: narrative?.parent_company_confirmed ?? false,
},
},
}
}
@@ -0,0 +1,55 @@
import type { AnnualReportEligibilityResult, AnnualReportFramework } from './compliance-types'
export interface AnnualReportCapabilities {
paper: {
enabled: boolean
delivery: 'post'
reason: string | null
}
ixbrl_preview: {
enabled: boolean
reason: string | null
}
connected_filing: {
enabled: boolean
release_gate_open: boolean
reason: string | null
}
}
export const CONNECTED_FILING_PUBLIC_RELEASED =
process.env.NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED === 'true'
export function getAnnualReportCapabilities(
framework: AnnualReportFramework,
eligibility?: AnnualReportEligibilityResult,
): AnnualReportCapabilities {
const releaseGateOpen = process.env.NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED === 'true'
const ixbrlEnabled = framework === 'k2'
const eligible = eligibility?.digital_filing_eligible ?? false
return {
paper: {
enabled: framework === 'k2',
delivery: 'post',
reason:
framework === 'k2'
? null
: 'K3-dokumentet är endast ett granskningsutkast tills hela upplysningsmatrisen är implementerad och granskad.',
},
ixbrl_preview: {
enabled: ixbrlEnabled,
reason: ixbrlEnabled ? null : 'iXBRL-generering stöds ännu endast för K2.',
},
connected_filing: {
enabled: releaseGateOpen && ixbrlEnabled && eligible,
release_gate_open: releaseGateOpen,
reason: !releaseGateOpen
? 'Direktinlämning öppnas först efter avtal, certifikat och godkänd acceptanstest.'
: !ixbrlEnabled
? 'Direktinlämning stöds ännu endast för K2.'
: !eligible
? 'Årsredovisningen uppfyller inte alla behörighets- och fullständighetskrav.'
: null,
},
}
}
+352
View File
@@ -0,0 +1,352 @@
import type { ArsredovisningData } from './types'
import type {
AnnualReportComplianceIssue,
AnnualReportDisclosureState,
AnnualReportEligibilityResult,
AnnualReportProfile,
AnnualReportValidationResult,
AnnualReportValidationStage,
} from './compliance-types'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
function parseIsoDate(value: string | null | undefined): Date | null {
if (!value) return null
const datePart = value.slice(0, 10)
if (!ISO_DATE.test(datePart)) return null
const parsed = new Date(`${datePart}T00:00:00.000Z`)
return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== datePart
? null
: parsed
}
function addCalendarMonths(date: Date, months: number): Date {
const year = date.getUTCFullYear()
const month = date.getUTCMonth() + months
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
return new Date(Date.UTC(year, month, Math.min(date.getUTCDate(), lastDay)))
}
function push(
issues: AnnualReportComplianceIssue[],
code: string,
severity: AnnualReportComplianceIssue['severity'],
section: AnnualReportComplianceIssue['section'],
message: string,
remediation?: string,
): void {
issues.push({ code, severity, section, message, remediation })
}
export interface ValidateAnnualReportInput {
report: ArsredovisningData
profile: AnnualReportProfile
disclosures: AnnualReportDisclosureState
eligibility: AnnualReportEligibilityResult
stage: AnnualReportValidationStage
todayIso?: string
}
export function validateAnnualReportCompleteness(
input: ValidateAnnualReportInput,
): AnnualReportValidationResult {
const { report, profile, disclosures, eligibility, stage } = input
const issues: AnnualReportComplianceIssue[] = [...eligibility.issues]
const today = parseIsoDate(input.todayIso ?? new Date().toISOString())
const periodStart = parseIsoDate(report.fiscal_period.period_start)
const periodEnd = parseIsoDate(report.fiscal_period.period_end)
if (!periodStart || !periodEnd || periodStart > periodEnd) {
push(
issues,
'AR-FISCAL-PERIOD-INVALID',
'error',
'company',
'Räkenskapsårets start- och slutdatum är ogiltiga.',
'Korrigera räkenskapsåret i företagsinställningarna.',
)
} else if (periodEnd >= addCalendarMonths(periodStart, 18)) {
push(
issues,
'AR-FISCAL-PERIOD-TOO-LONG',
'error',
'company',
'Räkenskapsåret är längre än 18 månader.',
'Korrigera räkenskapsåret innan årsredovisningen upprättas.',
)
}
if (report.accounting_framework === 'k2' && !profile.k2_assessment_confirmed_at) {
push(
issues,
'AR-K2-ASSESSMENT-UNCONFIRMED',
'error',
'scope',
'Bedömningen att bolaget får använda K2 är inte uttryckligen bekräftad.',
'Besvara frågorna om omfattning och regelverk och spara bedömningen.',
)
}
if (!report.company.name.trim()) {
push(issues, 'AR-COMPANY-NAME', 'error', 'company', 'Företagsnamn saknas.')
}
if (!normalizeOrgNumber(report.company.org_number)) {
push(
issues,
'AR-COMPANY-ORGNR',
'error',
'company',
'Ett giltigt organisationsnummer saknas.',
'Komplettera företagsinställningarna.',
)
}
if (!report.company.city?.trim()) {
push(
issues,
'AR-COMPANY-CITY',
'error',
'company',
'Bolagets registrerade säte saknas.',
'Komplettera företagsinställningarna eller hämta grunduppgifter från Bolagsverket.',
)
}
if (!report.forvaltningsberattelse.description.trim()) {
push(issues, 'AR-MANAGEMENT-DESCRIPTION', 'error', 'management_report', 'Verksamhetsbeskrivning saknas.')
}
if (!report.forvaltningsberattelse.important_events.trim()) {
push(issues, 'AR-MANAGEMENT-EVENTS', 'error', 'management_report', 'Uppgift om väsentliga händelser saknas.')
}
if (!report.forvaltningsberattelse.resultatdisposition.trim()) {
push(issues, 'AR-MANAGEMENT-DISPOSITION', 'error', 'management_report', 'Styrelsens förslag till resultatdisposition saknas.')
}
const disposition = report.forvaltningsberattelse.resultatdisposition_amounts
const proposedDividendCents = Math.round(disposition.proposed_dividend * 100)
const distributableEquityCents = Math.round(disposition.total * 100)
if (proposedDividendCents > 0 && proposedDividendCents > distributableEquityCents) {
push(
issues,
'AR-DIVIDEND-EXCEEDS-EQUITY',
'error',
'management_report',
'Föreslagen utdelning överstiger det fria egna kapitalet.',
'Sänk utdelningen och kontrollera resultatdispositionen.',
)
}
if (disposition.proposed_dividend > 0 && profile.dividend_prudence_confirmed !== true) {
push(
issues,
'AR-DIVIDEND-PRUDENCE-UNCONFIRMED',
'error',
'management_report',
'Försiktighetsregeln för föreslagen utdelning är inte bekräftad.',
'Bedöm bolagets kapitalbehov, likviditet, ställning och risker enligt ABL 17 kap. 3 §.',
)
}
if (!profile.narrative_confirmed_at) {
push(
issues,
'AR-MANAGEMENT-UNCONFIRMED',
'error',
'management_report',
'Förvaltningsberättelsens texter är inte uttryckligen granskade.',
'Granska texterna och markera dem som bekräftade.',
)
}
if (
Math.round(report.balansrakning.total_assets * 100) !==
Math.round(report.balansrakning.total_equity_liabilities * 100)
) {
push(
issues,
'AR-BALANCE-MISMATCH',
'error',
'statements',
'Balansräkningen balanserar inte i årsredovisningen.',
)
}
if (
report.previous_period &&
(report.balansrakning.total_assets_previous === null ||
report.balansrakning.total_equity_liabilities_previous === null)
) {
push(
issues,
'AR-COMPARATIVE-MISSING',
'error',
'statements',
'Jämförelsetal saknas trots att ett föregående räkenskapsår finns.',
)
}
if (report.resultatrakning.length === 0 || report.balansrakning.assets.length === 0) {
push(issues, 'AR-STATEMENTS-EMPTY', 'error', 'statements', 'Resultat- eller balansräkningen saknar rader.')
}
if (report.noter.length === 0) {
push(issues, 'AR-NOTES-EMPTY', 'error', 'notes', 'Årsredovisningen saknar noter.')
}
const disclosureChecks: Array<[boolean, string, string]> = [
[
disclosures.long_term_debt_over_five_years_confirmed,
'AR-NOTE-LONG-DEBT-UNCONFIRMED',
'Uppgiften om långfristiga skulder som förfaller efter mer än fem år är inte bekräftad.',
],
[
disclosures.securities_pledged_confirmed,
'AR-NOTE-SECURITIES-UNCONFIRMED',
'Uppgiften om ställda säkerheter är inte bekräftad.',
],
[
disclosures.contingent_liabilities_confirmed,
'AR-NOTE-CONTINGENT-UNCONFIRMED',
'Uppgiften om eventualförpliktelser är inte bekräftad.',
],
[
disclosures.parent_company_confirmed,
'AR-NOTE-PARENT-UNCONFIRMED',
'Uppgiften om koncern- och moderföretagsförhållanden är inte bekräftad.',
],
]
for (const [confirmed, code, message] of disclosureChecks) {
if (!confirmed) push(issues, code, 'error', 'notes', message)
}
if (report.accounting_framework === 'k3') {
push(
issues,
'AR-K3-DRAFT-ONLY',
'error',
'scope',
'Accounteds K3-dokument är ännu ett granskningsutkast och kan inte låsas som en komplett K3-årsredovisning.',
'Slutför K3:s upplysningsmatris och oberoende regelverksgranskning innan versionen låses.',
)
}
if (stage !== 'draft') {
if (report.signatures.length === 0) {
push(
issues,
'AR-SIGNERS-MISSING',
'error',
'signatures',
'Styrelsens och eventuell VD:s undertecknare är inte registrerade.',
)
}
if (!profile.signer_roster_confirmed_at) {
push(
issues,
'AR-SIGNER-ROSTER-UNCONFIRMED',
'error',
'signatures',
'Det är inte bekräftat att samtliga aktuella styrelseledamöter och eventuell VD finns med.',
'Kontrollera namn och roller mot Bolagsverket och bekräfta undertecknarlistan.',
)
}
}
if (stage === 'filing') {
if (report.signatures.some((signature) => !signature.signed_at)) {
push(
issues,
'AR-SIGNATURES-INCOMPLETE',
'error',
'signatures',
'Alla registrerade undertecknare har inte ett verifierat underskriftsdatum.',
)
}
if (!report.forvaltningsberattelse.agm_date) {
push(issues, 'AR-AGM-DATE', 'error', 'agm', 'Datum för årsstämman saknas.')
}
const signedDates = report.signatures
.map((signature) => parseIsoDate(signature.signed_at))
.filter((date): date is Date => date !== null)
if (report.signatures.some((signature) => signature.signed_at && !parseIsoDate(signature.signed_at))) {
push(issues, 'AR-SIGNATURE-DATE-INVALID', 'error', 'signatures', 'Ett underskriftsdatum är ogiltigt.')
}
if (periodEnd && signedDates.some((date) => date <= periodEnd)) {
push(
issues,
'AR-SIGNATURE-BEFORE-PERIOD-END',
'error',
'signatures',
'Årsredovisningen har undertecknats innan räkenskapsåret avslutades.',
)
}
if (today && signedDates.some((date) => date > today)) {
push(issues, 'AR-SIGNATURE-IN-FUTURE', 'error', 'signatures', 'Ett underskriftsdatum ligger i framtiden.')
}
const agmDate = parseIsoDate(report.forvaltningsberattelse.agm_date)
if (report.forvaltningsberattelse.agm_date && !agmDate) {
push(issues, 'AR-AGM-DATE-INVALID', 'error', 'agm', 'Datumet för årsstämman är ogiltigt.')
}
if (agmDate && periodEnd && agmDate <= periodEnd) {
push(issues, 'AR-AGM-BEFORE-PERIOD-END', 'error', 'agm', 'Årsstämman ligger före räkenskapsårets slut.')
}
if (agmDate && today && agmDate > today) {
push(issues, 'AR-AGM-IN-FUTURE', 'error', 'agm', 'Datumet för årsstämman ligger i framtiden.')
}
if (agmDate && signedDates.some((date) => date > agmDate)) {
push(
issues,
'AR-AGM-BEFORE-SIGNATURES',
'error',
'agm',
'Årsstämman har registrerats före den senaste underskriften av årsredovisningen.',
)
}
if (agmDate && periodEnd && agmDate > addCalendarMonths(periodEnd, 6)) {
push(
issues,
'AR-AGM-DEADLINE-PASSED',
'warning',
'agm',
'Årsstämman hölls senare än sex månader efter räkenskapsårets slut.',
'Dokumentera förseningen. En sen årsredovisning ska ändå lämnas in så snart som möjligt.',
)
}
if (!disclosures.agm_disposition_outcome) {
push(
issues,
'AR-AGM-DISPOSITION-OUTCOME',
'error',
'agm',
'Årsstämmans beslut om resultatdisposition är inte registrerat.',
)
}
if (
disclosures.agm_disposition_outcome === 'alternative_decision' &&
!disclosures.agm_disposition_decision?.trim()
) {
push(
issues,
'AR-AGM-ALTERNATIVE-MISSING',
'error',
'agm',
'Årsstämmans alternativa beslut om resultatdisposition saknar text.',
)
}
if (profile.auditor_report_required && !profile.auditor_report_included) {
push(
issues,
'AR-AUDITOR-REPORT-MISSING',
'error',
'filing',
'Revisionsberättelse krävs men är inte markerad som inkluderad i inlämningspaketet.',
)
}
}
for (const warning of report.warnings) {
push(issues, 'AR-SOURCE-WARNING', 'warning', 'statements', warning)
}
const errorCount = issues.filter((item) => item.severity === 'error').length
const warningCount = issues.filter((item) => item.severity === 'warning').length
return {
stage,
ok: errorCount === 0,
error_count: errorCount,
warning_count: warningCount,
issues,
}
}
@@ -0,0 +1,176 @@
import type { ArsredovisningData } from './types'
import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
export const ANNUAL_REPORT_SCHEMA_VERSION = '1.0' as const
export type AnnualReportFramework = 'k2' | 'k3'
export type AnnualReportFilingMode = 'paper' | 'digital'
export type AnnualReportValidationStage = 'draft' | 'signing' | 'filing'
export type ParentGroupSize = 'none' | 'small' | 'large'
/**
* Facts that determine whether the selected framework may be used. Nullable
* booleans are intentional: an unanswered eligibility question must never be
* interpreted as a legal assertion that the condition does not apply.
*/
export interface AnnualReportProfile {
id: string | null
company_id: string
fiscal_period_id: string
is_public_limited_company: boolean | null
is_in_liquidation: boolean | null
securities_traded_on_regulated_market: boolean | null
is_parent_company: boolean | null
parent_group_size: ParentGroupSize | null
prepares_consolidated_accounts: boolean | null
has_foreign_branch: boolean | null
has_crypto_assets: boolean | null
has_share_based_payments: boolean | null
has_convertible_debt: boolean | null
building_revenue_share_pct: number | null
has_material_deferred_tax: boolean | null
reporting_currency: 'SEK' | 'EUR'
auditor_report_required: boolean | null
auditor_report_included: boolean
dividend_prudence_confirmed: boolean | null
narrative_confirmed_at: string | null
k2_assessment_confirmed_at: string | null
signer_roster_confirmed_at: string | null
updated_at: string | null
}
export interface AnnualReportDisclosureState {
long_term_debt_over_five_years_confirmed: boolean
securities_pledged_confirmed: boolean
contingent_liabilities_confirmed: boolean
parent_company_confirmed: boolean
agm_disposition_outcome: 'proposal_approved' | 'alternative_decision' | null
agm_disposition_decision: string | null
}
export interface AnnualReportMetricsYear {
employees: number | null
balance_sheet_total: number | null
net_revenue: number | null
}
export interface AnnualReportSizeMetrics {
current: AnnualReportMetricsYear
previous: AnnualReportMetricsYear | null
}
export type AnnualReportIssueSeverity = 'error' | 'warning' | 'info'
export interface AnnualReportComplianceIssue {
code: string
severity: AnnualReportIssueSeverity
section:
| 'scope'
| 'company'
| 'management_report'
| 'statements'
| 'notes'
| 'signatures'
| 'agm'
| 'filing'
message: string
remediation?: string
}
export interface AnnualReportEligibilityResult {
k2_eligible: boolean
digital_filing_eligible: boolean
size_classification: 'smaller' | 'larger' | 'unknown'
k2_relief_rule: 'eligible' | 'not_eligible' | 'unknown'
issues: AnnualReportComplianceIssue[]
digital_issues: AnnualReportComplianceIssue[]
}
export interface AnnualReportValidationResult {
stage: AnnualReportValidationStage
ok: boolean
error_count: number
warning_count: number
issues: AnnualReportComplianceIssue[]
}
export interface AnnualReportVersionValidationSnapshot extends AnnualReportValidationResult {
digital_filing_eligible: boolean
digital_issues: AnnualReportComplianceIssue[]
profile: AnnualReportProfile
disclosures: AnnualReportDisclosureState
eligibility: AnnualReportEligibilityResult
}
export interface CanonicalAnnualReport {
schema_version: typeof ANNUAL_REPORT_SCHEMA_VERSION
generated_at: string
company_id: string
fiscal_period_id: string
entity_type: string
report: ArsredovisningData
profile: AnnualReportProfile
disclosures: AnnualReportDisclosureState
eligibility: AnnualReportEligibilityResult
validation: AnnualReportValidationResult
ixbrl: IxbrlArsredovisningInput | null
}
export interface AnnualReportVersionSummary {
id: string
version_number: number
status: 'draft' | 'ready_for_signature' | 'signed' | 'filed' | 'registered' | 'superseded'
framework: AnnualReportFramework
content_hash: string
taxonomy_version: string | null
entry_point: string | null
finalized_at: string | null
created_at: string
digital_filing_eligible?: boolean
certificate_signer?: {
first_name: string
last_name: string
role: string
} | null
}
export function emptyAnnualReportProfile(
companyId: string,
fiscalPeriodId: string,
): AnnualReportProfile {
return {
id: null,
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
is_public_limited_company: null,
is_in_liquidation: null,
securities_traded_on_regulated_market: null,
is_parent_company: null,
parent_group_size: null,
prepares_consolidated_accounts: null,
has_foreign_branch: null,
has_crypto_assets: null,
has_share_based_payments: null,
has_convertible_debt: null,
building_revenue_share_pct: null,
has_material_deferred_tax: null,
reporting_currency: 'SEK',
auditor_report_required: null,
auditor_report_included: false,
dividend_prudence_confirmed: null,
narrative_confirmed_at: null,
k2_assessment_confirmed_at: null,
signer_roster_confirmed_at: null,
updated_at: null,
}
}
export const EMPTY_DISCLOSURE_STATE: AnnualReportDisclosureState = {
long_term_debt_over_five_years_confirmed: false,
securities_pledged_confirmed: false,
contingent_liabilities_confirmed: false,
parent_company_confirmed: false,
agm_disposition_outcome: null,
agm_disposition_decision: null,
}
+329
View File
@@ -0,0 +1,329 @@
import type {
AnnualReportComplianceIssue,
AnnualReportEligibilityResult,
AnnualReportProfile,
AnnualReportSizeMetrics,
} from './compliance-types'
const LARGE_COMPANY_THRESHOLDS = {
employees: 50,
balanceSheetTotal: 40_000_000,
netRevenue: 80_000_000,
} as const
const K2_RELIEF_THRESHOLDS = {
employees: 3,
balanceSheetTotal: 1_500_000,
netRevenue: 3_000_000,
} as const
function issue(
code: string,
message: string,
remediation?: string,
): AnnualReportComplianceIssue {
return { code, severity: 'error', section: 'scope', message, remediation }
}
function exceededCount(
metrics: AnnualReportSizeMetrics['current'],
thresholds: {
employees: number
balanceSheetTotal: number
netRevenue: number
},
): number | null {
if (
metrics.employees === null ||
metrics.balance_sheet_total === null ||
metrics.net_revenue === null
) {
return null
}
return [
metrics.employees > thresholds.employees,
metrics.balance_sheet_total > thresholds.balanceSheetTotal,
metrics.net_revenue > thresholds.netRevenue,
].filter(Boolean).length
}
function sizeClassification(
metrics: AnnualReportSizeMetrics,
): AnnualReportEligibilityResult['size_classification'] {
if (!metrics.previous) return 'smaller'
const current = exceededCount(metrics.current, LARGE_COMPANY_THRESHOLDS)
const previous = exceededCount(metrics.previous, LARGE_COMPANY_THRESHOLDS)
if (current === null || previous === null) return 'unknown'
return current > 1 && previous > 1 ? 'larger' : 'smaller'
}
function reliefClassification(
metrics: AnnualReportSizeMetrics,
): AnnualReportEligibilityResult['k2_relief_rule'] {
if (!metrics.previous) return 'eligible'
const current = exceededCount(metrics.current, K2_RELIEF_THRESHOLDS)
const previous = exceededCount(metrics.previous, K2_RELIEF_THRESHOLDS)
if (current === null || previous === null) return 'unknown'
return current <= 1 || previous <= 1 ? 'eligible' : 'not_eligible'
}
function requireAnswer(
issues: AnnualReportComplianceIssue[],
value: boolean | null,
code: string,
label: string,
): void {
if (value === null) {
issues.push(
issue(
code,
`${label} är inte besvarad.`,
'Besvara frågan i avsnittet Omfattning och regelverk.',
),
)
}
}
function usesNewK2Restrictions(periodStart: string, periodEnd: string): boolean {
if (periodStart > '2025-12-31') return true
return periodStart > '2025-06-30' && periodEnd >= '2026-12-31'
}
export interface EvaluateAnnualReportEligibilityInput {
entityType: string
framework: 'k2' | 'k3'
periodStart: string
periodEnd: string
profile: AnnualReportProfile
metrics: AnnualReportSizeMetrics
}
/**
* Evaluate framework and current Accounted filing support. The function is
* deliberately conservative: missing legal facts produce blockers instead
* of being treated as false.
*/
export function evaluateAnnualReportEligibility(
input: EvaluateAnnualReportEligibilityInput,
): AnnualReportEligibilityResult {
const issues: AnnualReportComplianceIssue[] = []
const profile = input.profile
const size = sizeClassification(input.metrics)
const relief = reliefClassification(input.metrics)
if (input.entityType !== 'aktiebolag') {
issues.push(
issue(
'AR-SCOPE-ENTITY',
'Accounteds årsredovisningsflöde stöder för närvarande aktiebolag.',
'Använd rätt årsboksluts- eller deklarationsflöde för företagsformen.',
),
)
}
requireAnswer(
issues,
profile.is_in_liquidation,
'AR-SCOPE-LIQUIDATION-UNKNOWN',
'Om bolaget är i likvidation',
)
if (profile.is_in_liquidation) {
issues.push(
issue(
'AR-SCOPE-LIQUIDATION',
'Årsredovisningar under likvidation kräver särskilda värderings- och uppställningsregler som Accounted ännu inte stöder.',
'Upprätta årsredovisningen med hjälp av redovisningskonsult eller revisor.',
),
)
}
if (input.framework === 'k2') {
requireAnswer(
issues,
profile.is_public_limited_company,
'AR-K2-PUBLIC-UNKNOWN',
'Om bolaget är publikt',
)
requireAnswer(
issues,
profile.securities_traded_on_regulated_market,
'AR-K2-LISTED-UNKNOWN',
'Om värdepapper är upptagna till handel på en reglerad marknad',
)
requireAnswer(
issues,
profile.is_parent_company,
'AR-K2-PARENT-UNKNOWN',
'Om bolaget är moderföretag',
)
if (profile.is_public_limited_company) {
issues.push(issue('AR-K2-PUBLIC', 'Publika aktiebolag får inte tillämpa K2.', 'Välj K3.'))
}
if (profile.securities_traded_on_regulated_market) {
issues.push(
issue('AR-K2-LISTED', 'Noterade företag är större företag och får inte tillämpa K2.', 'Välj K3.'),
)
}
if (size === 'larger') {
issues.push(issue('AR-K2-LARGE', 'Bolaget klassificeras som ett större företag enligt ÅRL.', 'Välj K3.'))
}
if (size === 'unknown') {
issues.push(
issue(
'AR-K2-SIZE-UNKNOWN',
'Storleksklassificeringen kan inte avgöras eftersom ett eller flera jämförelsetal saknas.',
'Kontrollera jämförelseårets balansomslutning, nettoomsättning och medelantal anställda.',
),
)
}
if (profile.is_parent_company) {
if (profile.parent_group_size === null) {
issues.push(issue('AR-K2-GROUP-SIZE-UNKNOWN', 'Koncernens storlek är inte angiven.'))
}
if (profile.prepares_consolidated_accounts === null) {
issues.push(issue('AR-K2-CONSOLIDATION-UNKNOWN', 'Det är inte angivet om koncernredovisning upprättas.'))
}
if (profile.parent_group_size === 'large') {
issues.push(issue('AR-K2-LARGE-GROUP', 'Moderföretag i en större koncern får inte tillämpa K2.', 'Välj K3.'))
}
if (profile.prepares_consolidated_accounts) {
issues.push(
issue(
'AR-K2-CONSOLIDATED',
'Ett moderföretag som upprättar koncernredovisning får inte upprätta årsredovisningen enligt K2.',
'Välj K3.',
),
)
}
}
if (usesNewK2Restrictions(input.periodStart, input.periodEnd)) {
const restrictedFacts: Array<{
value: boolean | null
unknownCode: string
blockedCode: string
label: string
}> = [
{
value: profile.has_foreign_branch,
unknownCode: 'AR-K2-FOREIGN-BRANCH-UNKNOWN',
blockedCode: 'AR-K2-FOREIGN-BRANCH',
label: 'filial i utlandet',
},
{
value: profile.has_crypto_assets,
unknownCode: 'AR-K2-CRYPTO-UNKNOWN',
blockedCode: 'AR-K2-CRYPTO',
label: 'kryptotillgångar som omfattas av K2-begränsningen',
},
{
value: profile.has_share_based_payments,
unknownCode: 'AR-K2-SHARE-PAYMENT-UNKNOWN',
blockedCode: 'AR-K2-SHARE-PAYMENT',
label: 'aktierelaterade ersättningar',
},
{
value: profile.has_convertible_debt,
unknownCode: 'AR-K2-CONVERTIBLE-UNKNOWN',
blockedCode: 'AR-K2-CONVERTIBLE',
label: 'emitterade sammansatta finansiella instrument',
},
]
for (const fact of restrictedFacts) {
requireAnswer(issues, fact.value, fact.unknownCode, `Om bolaget har ${fact.label}`)
if (fact.value) {
issues.push(
issue(
fact.blockedCode,
`Bolaget har ${fact.label} och får därför inte tillämpa K2 för det här räkenskapsåret.`,
'Välj K3.',
),
)
}
}
if (relief !== 'eligible') {
if (profile.building_revenue_share_pct === null) {
issues.push(
issue(
'AR-K2-PROPERTY-UNKNOWN',
'Andelen nettoomsättning från byggnader är inte angiven.',
),
)
} else if (profile.building_revenue_share_pct >= 75) {
issues.push(
issue(
'AR-K2-PROPERTY',
'Minst 75 procent av nettoomsättningen kommer normalt från byggnader och bolaget omfattas inte av lättnadsregeln.',
'Välj K3.',
),
)
}
requireAnswer(
issues,
profile.has_material_deferred_tax,
'AR-K2-DEFERRED-TAX-UNKNOWN',
'Om bolaget normalt har en väsentlig uppskjuten skatteskuld',
)
if (profile.has_material_deferred_tax) {
issues.push(
issue(
'AR-K2-DEFERRED-TAX',
'Bolaget har normalt en väsentlig uppskjuten skatteskuld och omfattas inte av lättnadsregeln.',
'Välj K3.',
),
)
}
}
}
}
const k2Eligible = input.framework === 'k2' && issues.every((item) => item.severity !== 'error')
const digitalIssues = [...issues]
if (input.framework !== 'k2') {
digitalIssues.push(
issue(
'AR-DIGITAL-FRAMEWORK',
'Accounteds digitala Bolagsverket-flöde stöder ännu endast K2 för aktiebolag.',
'Använd pappersflödet tills K3-taxonomin är implementerad och godkänd.',
),
)
}
if (profile.reporting_currency !== 'SEK') {
digitalIssues.push(
issue(
'AR-DIGITAL-CURRENCY',
'Digital inlämning stöds ännu endast för årsredovisningar i SEK.',
),
)
}
if (profile.auditor_report_required === null) {
digitalIssues.push(
issue(
'AR-DIGITAL-AUDIT-UNKNOWN',
'Det är inte bekräftat om en revisionsberättelse krävs.',
'Hämta grunduppgifter från Bolagsverket eller bekräfta uppgiften manuellt.',
),
)
} else if (profile.auditor_report_required) {
digitalIssues.push(
issue(
'AR-DIGITAL-AUDIT',
'Det här bolaget kräver revisionsberättelse, vilket Accounteds digitala paket ännu inte skickar.',
'Använd pappersflödet eller vänta tills separat revisionsberättelse stöds.',
),
)
}
return {
k2_eligible: k2Eligible,
digital_filing_eligible: digitalIssues.every((item) => item.severity !== 'error'),
size_classification: size,
k2_relief_rule: relief,
issues,
digital_issues: digitalIssues,
}
}
export { K2_RELIEF_THRESHOLDS, LARGE_COMPANY_THRESHOLDS, usesNewK2Restrictions }
+149
View File
@@ -0,0 +1,149 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { buildArsredovisningData } from './build-data'
import { listSignatureRequests } from './signature-service'
import { getAnnualReportProfile } from './profile-service'
import { evaluateAnnualReportEligibility } from './eligibility'
import { validateAnnualReportCompleteness } from './completeness'
import {
ANNUAL_REPORT_SCHEMA_VERSION,
type AnnualReportDisclosureState,
type AnnualReportSizeMetrics,
type AnnualReportValidationStage,
type CanonicalAnnualReport,
} from './compliance-types'
import { buildIxbrlInput, type BuildIxbrlOptions } from '@/lib/bokslut/ixbrl/build-input'
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
export interface BuildCanonicalAnnualReportOptions extends BuildIxbrlOptions {
stage?: AnnualReportValidationStage
generatedAt?: string
includeIxbrl?: boolean
}
interface EmployeeRow {
employment_start: string
employment_end: string | null
employment_degree: number
}
function reportMetrics(
report: Awaited<ReturnType<typeof buildArsredovisningData>>,
employees: EmployeeRow[],
): AnnualReportSizeMetrics {
const currentOverview = report.forvaltningsberattelse.flerarsoversikt.find(
(row) => row.year === report.fiscal_period.name,
)
const previousOverview = report.previous_period
? report.forvaltningsberattelse.flerarsoversikt.find(
(row) => row.year === report.previous_period?.name,
)
: null
return {
current: {
employees: computeMedelantalAnstallda(
employees,
report.fiscal_period.period_start,
report.fiscal_period.period_end,
),
balance_sheet_total: report.balansrakning.total_assets,
net_revenue: currentOverview?.net_revenue ?? null,
},
previous: report.previous_period
? {
employees: computeMedelantalAnstallda(
employees,
report.previous_period.period_start,
report.previous_period.period_end,
),
balance_sheet_total: report.balansrakning.total_assets_previous,
net_revenue: previousOverview?.net_revenue ?? null,
}
: null,
}
}
function disclosureState(
report: Awaited<ReturnType<typeof buildArsredovisningData>>,
): AnnualReportDisclosureState {
return {
long_term_debt_over_five_years_confirmed:
report.disclosures.confirmations.long_term_debt_over_five_years,
securities_pledged_confirmed: report.disclosures.confirmations.securities_pledged,
contingent_liabilities_confirmed:
report.disclosures.confirmations.contingent_liabilities,
parent_company_confirmed: report.disclosures.confirmations.parent_company,
agm_disposition_outcome: report.forvaltningsberattelse.agm_disposition_outcome,
agm_disposition_decision: report.forvaltningsberattelse.agm_disposition_decision,
}
}
export async function buildCanonicalAnnualReport(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options: BuildCanonicalAnnualReportOptions = {},
): Promise<CanonicalAnnualReport> {
const [report, profile, signatures, employeesResult] = await Promise.all([
buildArsredovisningData(supabase, companyId, fiscalPeriodId),
getAnnualReportProfile(supabase, companyId, fiscalPeriodId),
listSignatureRequests(supabase, companyId, fiscalPeriodId),
supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId),
])
if (employeesResult.error) {
throw new Error(`Failed to load annual report employee metrics: ${employeesResult.error.message}`)
}
const activeSignatures = signatures.filter((signature) => signature.status !== 'declined')
report.signatures = activeSignatures.map((signature) => ({
role: signature.role,
name: signature.signer_name,
signed_at: signature.signed_at,
}))
const metrics = reportMetrics(report, (employeesResult.data ?? []) as EmployeeRow[])
const eligibility = evaluateAnnualReportEligibility({
entityType: report.company.entity_type,
framework: report.accounting_framework,
periodStart: report.fiscal_period.period_start,
periodEnd: report.fiscal_period.period_end,
profile,
metrics,
})
const disclosures = disclosureState(report)
const validation = validateAnnualReportCompleteness({
report,
profile,
disclosures,
eligibility,
stage: options.stage ?? 'draft',
todayIso: options.todayIso,
})
let ixbrl = null
if (report.accounting_framework === 'k2' && options.includeIxbrl !== false) {
ixbrl = await buildIxbrlInput(supabase, companyId, fiscalPeriodId, {
undertecknare: options.undertecknare,
proposedDividend: options.proposedDividend,
todayIso: options.todayIso,
reportData: report,
signatureRequests: activeSignatures,
})
}
return {
schema_version: ANNUAL_REPORT_SCHEMA_VERSION,
generated_at: options.generatedAt ?? new Date().toISOString(),
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
entity_type: report.company.entity_type,
report,
profile,
disclosures,
eligibility,
validation,
ixbrl,
}
}
+19 -12
View File
@@ -4,6 +4,7 @@ export interface NarrativeOverrides {
description: string | null
important_events: string | null
resultatdisposition: string | null
proposed_dividend: number | null
/** ISO date of the AGM (årsstämma) where the årsredovisning was adopted.
* Populates the fastställelseintyg date blank: without it the PDF
* cannot be filed at Bolagsverket without manual pen-and-ink edit. */
@@ -24,6 +25,12 @@ export interface NarrativeOverrides {
parent_company_name: string | null
parent_company_org_number: string | null
parent_company_city: string | null
long_term_debt_over_five_years_confirmed: boolean
securities_pledged_confirmed: boolean
contingent_liabilities_confirmed: boolean
parent_company_confirmed: boolean
agm_disposition_outcome: 'proposal_approved' | 'alternative_decision' | null
agm_disposition_decision: string | null
}
/**
@@ -38,6 +45,7 @@ export interface NarrativeRow {
description: string | null
important_events: string | null
resultatdisposition: string | null
proposed_dividend: number | null
agm_date: string | null
long_term_debt_over_five_years: number | null
securities_pledged: string | null
@@ -45,6 +53,12 @@ export interface NarrativeRow {
parent_company_name: string | null
parent_company_org_number: string | null
parent_company_city: string | null
long_term_debt_over_five_years_confirmed: boolean
securities_pledged_confirmed: boolean
contingent_liabilities_confirmed: boolean
parent_company_confirmed: boolean
agm_disposition_outcome: 'proposal_approved' | 'alternative_decision' | null
agm_disposition_decision: string | null
updated_at: string
}
@@ -54,7 +68,7 @@ const TABLE = 'arsredovisning_narratives'
// of API responses. GDPR Art.25.2 / ISO A.8.3 data-minimization: callers
// only need the narrative content + last-updated timestamp.
const NARRATIVE_API_COLUMNS =
'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, agm_date, long_term_debt_over_five_years, securities_pledged, contingent_liabilities, parent_company_name, parent_company_org_number, parent_company_city, updated_at'
'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, proposed_dividend, agm_date, long_term_debt_over_five_years, securities_pledged, contingent_liabilities, parent_company_name, parent_company_org_number, parent_company_city, long_term_debt_over_five_years_confirmed, securities_pledged_confirmed, contingent_liabilities_confirmed, parent_company_confirmed, agm_disposition_outcome, agm_disposition_decision, updated_at'
/**
* Load persisted narrative overrides for a fiscal period. Returns null when
@@ -90,20 +104,13 @@ export async function upsertNarrative(
fiscalPeriodId: string,
input: Partial<NarrativeOverrides>,
): Promise<NarrativeRow> {
const payload = {
const payload: Record<string, unknown> = {
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
description: input.description ?? null,
important_events: input.important_events ?? null,
resultatdisposition: input.resultatdisposition ?? null,
agm_date: input.agm_date ?? null,
long_term_debt_over_five_years: input.long_term_debt_over_five_years ?? null,
securities_pledged: input.securities_pledged ?? null,
contingent_liabilities: input.contingent_liabilities ?? null,
parent_company_name: input.parent_company_name ?? null,
parent_company_org_number: input.parent_company_org_number ?? null,
parent_company_city: input.parent_company_city ?? null,
}
for (const [key, value] of Object.entries(input)) {
payload[key] = value ?? null
}
const { data, error } = await supabase
.from(TABLE)
@@ -0,0 +1,57 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import {
emptyAnnualReportProfile,
type AnnualReportProfile,
} from './compliance-types'
const PROFILE_COLUMNS =
'id, company_id, fiscal_period_id, is_public_limited_company, is_in_liquidation, securities_traded_on_regulated_market, is_parent_company, parent_group_size, prepares_consolidated_accounts, has_foreign_branch, has_crypto_assets, has_share_based_payments, has_convertible_debt, building_revenue_share_pct, has_material_deferred_tax, reporting_currency, auditor_report_required, auditor_report_included, dividend_prudence_confirmed, narrative_confirmed_at, k2_assessment_confirmed_at, signer_roster_confirmed_at, updated_at'
export type AnnualReportProfileUpdate = Partial<
Omit<AnnualReportProfile, 'id' | 'company_id' | 'fiscal_period_id' | 'updated_at'>
>
export async function getAnnualReportProfile(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<AnnualReportProfile> {
const { data, error } = await supabase
.from('annual_report_profiles')
.select(PROFILE_COLUMNS)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.maybeSingle()
if (error) throw new Error(`Failed to load annual report profile: ${error.message}`)
return data
? (data as AnnualReportProfile)
: emptyAnnualReportProfile(companyId, fiscalPeriodId)
}
export async function upsertAnnualReportProfile(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
input: AnnualReportProfileUpdate,
): Promise<AnnualReportProfile> {
const payload: Record<string, unknown> = {
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
user_id: userId,
...input,
}
if (input.is_parent_company === false) {
payload.parent_group_size = null
payload.prepares_consolidated_accounts = null
}
const { data, error } = await supabase
.from('annual_report_profiles')
.upsert(payload, { onConflict: 'company_id,fiscal_period_id' })
.select(PROFILE_COLUMNS)
.single()
if (error || !data) {
throw new Error(`Failed to save annual report profile: ${error?.message ?? 'unknown'}`)
}
return data as AnnualReportProfile
}
@@ -2,7 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
export interface SignatureRequest {
id: string
user_id: string
user_id: string | null
company_id: string
fiscal_period_id: string
role: string
@@ -11,6 +11,10 @@ export interface SignatureRequest {
signed_at: string | null
created_at: string
updated_at: string
annual_report_version_id: string | null
signing_method: 'paper_original' | 'advanced_e_signature' | 'bankid' | 'bolagsverket' | null
evidence_reference: string | null
evidence_recorded_at: string | null
}
export interface CreateSignatureRequestInput {
@@ -28,12 +32,24 @@ export async function listSignatureRequests(
): Promise<SignatureRequest[]> {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.select('id, user_id, company_id, fiscal_period_id, role, signer_name, status, signed_at, created_at, updated_at')
.select('id, user_id, company_id, fiscal_period_id, role, signer_name, status, signed_at, created_at, updated_at, annual_report_version_id, signing_method, evidence_reference, evidence_recorded_at')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.order('created_at', { ascending: true })
if (error) throw new Error(`Failed to list signature requests: ${error.message}`)
return (data ?? []) as SignatureRequest[]
const rows = (data ?? []) as SignatureRequest[]
const unbound = rows.filter(
(request) => request.annual_report_version_id === null && request.status === 'pending',
)
if (unbound.length > 0) return unbound
const latestBound = [...rows]
.filter((request) => request.annual_report_version_id !== null)
.sort((left, right) => right.created_at.localeCompare(left.created_at))[0]
return latestBound
? rows.filter(
(request) => request.annual_report_version_id === latestBound.annual_report_version_id,
)
: rows
}
/**
@@ -76,17 +92,35 @@ export async function markSignatureSigned(
supabase: SupabaseClient,
companyId: string,
requestId: string,
options: { bankidSignatureData?: Record<string, unknown> } = {},
options: {
fiscalPeriodId: string
annualReportVersionId: string
signingMethod: SignatureRequest['signing_method']
evidenceReference: string
evidenceRecordedBy: string
signedAt?: string
bankidSignatureData?: Record<string, unknown>
},
): Promise<SignatureRequest> {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.update({
status: 'signed',
signed_at: new Date().toISOString(),
signed_at: options.signedAt ?? new Date().toISOString(),
annual_report_version_id: options.annualReportVersionId,
signing_method: options.signingMethod,
evidence_reference: options.evidenceReference,
evidence_recorded_by: options.evidenceRecordedBy,
evidence_recorded_at: new Date().toISOString(),
bankid_signature_data: options.bankidSignatureData ?? null,
})
.eq('id', requestId)
.eq('company_id', companyId)
.eq('fiscal_period_id', options.fiscalPeriodId)
.eq('status', 'pending')
.or(
`annual_report_version_id.is.null,annual_report_version_id.eq.${options.annualReportVersionId}`,
)
.select('*')
.single()
if (error || !data) {
+19
View File
@@ -55,6 +55,7 @@ export interface ArsredovisningData {
company: {
name: string
org_number: string
entity_type: string
/** Företagets säte (Bolagsverket-registered registered office city).
* Used in the underskrifter "Stad, datum" line and the fastställelseintyg. */
city: string | null
@@ -90,10 +91,22 @@ export interface ArsredovisningData {
egen_kapital_changes: EgenKapitalRow[]
/** Styrelsens förslag till resultatdisposition (manual input). */
resultatdisposition: string
proposed_dividend: number
resultatdisposition_amounts: {
retained_earnings: number
share_premium_reserve: number
current_year_result: number
total: number
proposed_dividend: number
carried_forward: number
}
/** ISO date of the årsstämma where the årsredovisning was adopted.
* Populates the fastställelseintyg date blank. Null means "not yet
* recorded": PDF then leaves the blank. */
agm_date: string | null
/** What the AGM actually decided, distinct from the board's proposal. */
agm_disposition_outcome: 'proposal_approved' | 'alternative_decision' | null
agm_disposition_decision: string | null
}
resultatrakning: StatementRow[]
balansrakning: {
@@ -138,6 +151,12 @@ export interface ArsredovisningData {
parent_company_name: string | null
parent_company_org_number: string | null
parent_company_city: string | null
confirmations: {
long_term_debt_over_five_years: boolean
securities_pledged: boolean
contingent_liabilities: boolean
parent_company: boolean
}
}
}
@@ -0,0 +1,44 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
import { getAnnualReportVersion } from './version-service'
/** Load immutable version data and overlay only its separately stored signatures. */
export async function getVersionIxbrlInput(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
versionId: string,
): Promise<IxbrlArsredovisningInput | null> {
const version = await getAnnualReportVersion(supabase, companyId, fiscalPeriodId, versionId)
if (!version?.ixbrl_data) return null
const input = structuredClone(version.ixbrl_data)
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.select('signer_name, role, signed_at')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('annual_report_version_id', versionId)
.order('created_at', { ascending: true })
if (error) throw new Error(`Failed to load version signatures: ${error.message}`)
if ((data ?? []).length === 0) return input
input.underskrifter.signers = (data ?? []).map((signature) => {
const parts = signature.signer_name.trim().split(/\s+/)
return {
firstName: parts.length > 1 ? parts.slice(0, -1).join(' ') : parts[0],
lastName: parts.length > 1 ? parts.at(-1) ?? parts[0] : parts[0],
role: signature.role,
signedDate: signature.signed_at?.slice(0, 10) ?? null,
}
})
input.underskrifter.harVd = input.underskrifter.signers.some((signer) =>
/verkställande direktör|^vd$/i.test(signer.role ?? ''),
)
input.underskrifter.dateringsdatum =
input.underskrifter.signers
.map((signer) => signer.signedDate)
.filter((date): date is string => date !== null)
.sort()
.at(-1) ?? null
return input
}
@@ -0,0 +1,182 @@
import { createHash } from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
AnnualReportVersionValidationSnapshot,
AnnualReportVersionSummary,
CanonicalAnnualReport,
} from './compliance-types'
import { getEntryPoint } from '@/lib/bokslut/ixbrl/taxonomy/entry-points'
function stableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableValue)
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, child]) => child !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, child]) => [key, stableValue(child)]),
)
}
return value
}
export function annualReportContentHash(model: CanonicalAnnualReport): string {
const reportForSigning = {
...model.report,
signatures: model.report.signatures.map((signature) => ({
role: signature.role,
name: signature.name,
signed_at: null,
})),
}
const ixbrlForSigning = model.ixbrl
? {
...model.ixbrl,
underskrifter: {
...model.ixbrl.underskrifter,
dateringsdatum: null,
signers: model.ixbrl.underskrifter.signers.map((signer) => ({
...signer,
signedDate: null,
})),
},
faststallelseintyg: {
...model.ixbrl.faststallelseintyg,
genereratDatum: null,
},
}
: null
const content = {
schema_version: model.schema_version,
company_id: model.company_id,
fiscal_period_id: model.fiscal_period_id,
entity_type: model.entity_type,
report: reportForSigning,
profile: model.profile,
disclosures: model.disclosures,
ixbrl: ixbrlForSigning,
}
return createHash('sha256').update(JSON.stringify(stableValue(content))).digest('hex')
}
export async function listAnnualReportVersions(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<AnnualReportVersionSummary[]> {
const { data, error } = await supabase
.from('annual_report_versions')
.select(
'id, version_number, status, framework, content_hash, taxonomy_version, entry_point, finalized_at, created_at, ixbrl_data, validation_summary',
)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.order('version_number', { ascending: false })
if (error) throw new Error(`Failed to list annual report versions: ${error.message}`)
return (data ?? []).map((row) => {
const version = row as AnnualReportVersionSummary & {
ixbrl_data?: CanonicalAnnualReport['ixbrl']
validation_summary?: AnnualReportVersionValidationSnapshot
}
const signer = version.ixbrl_data?.faststallelseintyg
return {
id: version.id,
version_number: version.version_number,
status: version.status,
framework: version.framework,
content_hash: version.content_hash,
taxonomy_version: version.taxonomy_version,
entry_point: version.entry_point,
finalized_at: version.finalized_at,
created_at: version.created_at,
digital_filing_eligible:
version.validation_summary?.digital_filing_eligible === true,
certificate_signer: signer
? {
first_name: signer.signerFirstName,
last_name: signer.signerLastName,
role: signer.signerRole,
}
: null,
}
})
}
export async function getAnnualReportVersion(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
versionId: string,
): Promise<{
summary: AnnualReportVersionSummary
report_data: CanonicalAnnualReport['report']
ixbrl_data: CanonicalAnnualReport['ixbrl']
validation_summary: AnnualReportVersionValidationSnapshot
} | null> {
const { data, error } = await supabase
.from('annual_report_versions')
.select(
'id, version_number, status, framework, content_hash, taxonomy_version, entry_point, finalized_at, created_at, report_data, ixbrl_data, validation_summary',
)
.eq('id', versionId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.maybeSingle()
if (error) throw new Error(`Failed to load annual report version: ${error.message}`)
if (!data) return null
const row = data as AnnualReportVersionSummary & {
report_data: CanonicalAnnualReport['report']
ixbrl_data: CanonicalAnnualReport['ixbrl']
validation_summary: AnnualReportVersionValidationSnapshot
}
return {
summary: row,
report_data: row.report_data,
ixbrl_data: row.ixbrl_data,
validation_summary: row.validation_summary,
}
}
export async function createAnnualReportVersion(
supabase: SupabaseClient,
userId: string,
model: CanonicalAnnualReport,
finalize: boolean,
): Promise<AnnualReportVersionSummary> {
if (finalize && !model.validation.ok) {
throw new Error('Annual report has blocking validation errors and cannot be finalized')
}
const hash = annualReportContentHash(model)
const entryPoint = model.ixbrl?.entryPointId ?? null
const taxonomyVersion = entryPoint ? getEntryPoint(entryPoint).taxonomyVersion : null
const rpcName = finalize
? 'create_annual_report_version_with_signatures'
: 'create_annual_report_version'
const { data, error } = await supabase.rpc(rpcName, {
p_company_id: model.company_id,
p_fiscal_period_id: model.fiscal_period_id,
p_schema_version: model.schema_version,
p_framework: model.report.accounting_framework,
p_status: finalize ? 'ready_for_signature' : 'draft',
p_report_data: model.report,
p_ixbrl_data: model.ixbrl,
p_content_hash: hash,
p_taxonomy_version: taxonomyVersion,
p_entry_point: entryPoint,
p_validation_summary: {
...model.validation,
digital_filing_eligible: model.eligibility.digital_filing_eligible,
digital_issues: model.eligibility.digital_issues,
profile: model.profile,
disclosures: model.disclosures,
eligibility: model.eligibility,
} satisfies AnnualReportVersionValidationSnapshot,
p_user_id: userId,
})
if (error || !data) {
throw new Error(`Failed to create annual report version: ${error?.message ?? 'unknown'}`)
}
const row = Array.isArray(data) ? data[0] : data
const version = row as AnnualReportVersionSummary
return version
}
+53 -10
View File
@@ -3,8 +3,10 @@ import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import {
calculateBolagsskatt,
getBookedBolagsskatt,
sumPostedYearEndDispositions,
} from './tax-provision/bolagsskatt-calculator'
import { loadTaxAdjustmentSnapshot } from './tax-provision/tax-adjustment-service'
import { calculateSarskildLoneskatt } from './tax-provision/sarskild-loneskatt-calculator'
import {
computeLatentTax,
@@ -19,7 +21,7 @@ import {
proposeAvsattning,
proposeAteforing,
} from './reserves/periodiseringsfond-service'
import type { DispositionsProposal, ProposedDisposition } from './types'
import type { CompletedDisposition, DispositionsProposal, ProposedDisposition } from './types'
import type { AccountingFramework } from '@/types'
/**
@@ -86,6 +88,7 @@ export async function buildDispositionsProposal(
const resultBeforeTax = incomeStatement.net_result
const proposals: ProposedDisposition[] = []
const completedDispositions: CompletedDisposition[] = []
// Dispositions already POSTED in this period (a partially completed
// bokslut run) are excluded from resultBeforeTax like all year_end
@@ -97,6 +100,15 @@ export async function buildDispositionsProposal(
companyId,
fiscalPeriodId,
)
const [taxAdjustments, bookedTax] = await Promise.all([
loadTaxAdjustmentSnapshot(supabase, companyId, fiscalPeriodId),
getBookedBolagsskatt(supabase, companyId, fiscalPeriodId),
])
// Income statement excludes tax posted by this year-end flow, but includes
// manually posted 8910. Add back only the latter to get a stable pre-tax
// result on reload.
const manuallyBookedTax = Math.max(0, bookedTax - postedEffect.taxProvisionPortion)
const normalizedResultBeforeTax = resultBeforeTax + manuallyBookedTax
const existingFonder = await listExistingPeriodiseringsfonder(
supabase,
@@ -137,14 +149,25 @@ export async function buildDispositionsProposal(
// headroom effect is alreadyProvisioned, not a base reduction), plus
// proposed återföringar and schablonintäkt, minus deductible SLP.
const taxableBeforeAvsattning =
resultBeforeTax + postedEffect.total + alreadyProvisioned + ateforingTotal
normalizedResultBeforeTax + postedEffect.total + alreadyProvisioned + ateforingTotal
+ ateforing.schablonintaktAmount - (slp?.amount ?? 0)
const avsattning = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning,
fiscalYear,
alreadyProvisioned,
})
+ taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome
const avsattning = alreadyProvisioned > 0
? null
: proposeAvsattning({
skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning,
fiscalYear,
})
if (avsattning) proposals.push(avsattning)
if (alreadyProvisioned > 0) {
completedDispositions.push({
kind: 'periodiseringsfond_avsattning',
label: 'Avsättning till periodiseringsfond',
amount: alreadyProvisioned,
status: 'booked',
warnings: [],
})
}
if (slp) proposals.push(slp)
@@ -158,16 +181,34 @@ export async function buildDispositionsProposal(
// Without this, the previewed tax ignores the avsättning (tax too high) and
// diverges from what the sequential commit books and from ÅR/INK2.
const resultAfterDispositions =
resultBeforeTax + postedEffect.total + ateforingTotal
normalizedResultBeforeTax + postedEffect.total + ateforingTotal
- (avsattning?.amount ?? 0) - (slp?.amount ?? 0)
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
resultBeforeTaxOverride: resultAfterDispositions,
manualAdjustments: {
nonDeductibleExpenses: taxAdjustments.nonDeductibleExpenses,
nonTaxableIncome: taxAdjustments.nonTaxableIncome,
schablonintaktPeriodiseringsfond: ateforing.schablonintaktAmount,
},
})
if (bolagsskatt) proposals.push(bolagsskatt)
if (bookedTax > 0) {
const expectedTax = bolagsskatt?.amount ?? 0
const matches = bookedTax === expectedTax
completedDispositions.push({
kind: 'bolagsskatt',
label: 'Bolagsskatt 20,6 %',
amount: bookedTax,
status: matches ? 'booked' : 'needs_correction',
warnings: matches
? []
: [
`Bokförd skatt är ${bookedTax} kr, men aktuellt underlag ger ${expectedTax} kr. Rätta den bokförda skatten innan bokslutet verkställs.`,
],
})
} else if (bolagsskatt && bolagsskatt.amount > 0) {
proposals.push(bolagsskatt)
}
// K3 only: split obeskattade reserver into the 79.4 % equity portion and
// the 20.6 % uppskjuten skatteskuld. We sum the projected 21xx balance
@@ -187,8 +228,10 @@ export async function buildDispositionsProposal(
return {
entityType,
fiscalPeriod: period,
netResultBefore: resultBeforeTax,
netResultBefore: normalizedResultBeforeTax,
proposals,
taxAdjustments,
completedDispositions,
}
}
+15
View File
@@ -204,7 +204,20 @@ export function makeInput(): IxbrlArsredovisningInput {
},
{ number: 2, title: 'Medelantal anställda', body: 'Medelantalet anställda har uppgått till 2.' },
{ number: 3, title: 'Långfristiga skulder', body: 'Inga skulder förfaller senare än fem år efter balansdagen.' },
{ number: 4, title: 'Ställda säkerheter', body: 'Inga.' },
{ number: 5, title: 'Eventualförpliktelser', body: 'Inga.' },
{
number: 6,
title: 'Koncernförhållanden',
body: 'Moderföretag: Moderbolaget AB.',
},
],
disclosures: {
longTermDebtOverFiveYears: 0,
securitiesPledged: 'Inga.',
contingentLiabilities: 'Inga.',
parentCompany: 'Moderföretag: Moderbolaget AB.',
},
medelantalAnstallda: { current: 2, previous: 1 },
underskrifter: {
ort: 'Sundsvall',
@@ -222,6 +235,8 @@ export function makeInput(): IxbrlArsredovisningInput {
},
faststallelseintyg: {
arsstammaDatum: '2026-03-15',
resultatdispositionOutcome: 'proposal_approved',
resultatdispositionDecision: null,
signerFirstName: 'Karl',
signerLastName: 'Karlsson',
signerRole: 'Styrelseledamot',
@@ -107,6 +107,15 @@ describe('generateK2IxbrlDocument', () => {
expect(xhtml).toContain('<xbrli:measure>se-k2-type:AntalAnstallda</xbrli:measure>')
})
it('tags the long-term debt disclosure instead of leaving the note as plain XHTML', () => {
expect(xhtml).toMatch(
/contextRef="balans0" name="se-gen-base:LangfristigaSkulderForfallerSenare5Ar"[^>]*>0<\/ix:nonFraction>/,
)
expect(xhtml).toContain('name="se-gen-base:NotStalldaSakerheter"')
expect(xhtml).toContain('name="se-gen-base:NotEventualforpliktelser"')
expect(xhtml).toContain('name="se-gen-base:NotUpplysningModerforetag"')
})
it('tags resultatdisposition (BÖR: förslag) consistently with BR', () => {
expect(xhtml).toMatch(/name="se-gen-base:ForslagDispositionBalanserasINyRakning"[^>]*>220 000/)
expect(xhtml).toMatch(/name="se-gen-base:ForslagDisposition"[^>]*>220 000/)
+53 -12
View File
@@ -22,6 +22,7 @@ import type {
IxbrlSigner,
Resultatdisposition,
} from './types'
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
/** TA §4.3.4-4.3.5: "<leverantör> - <produkt>", version "<huvud>.<revision>". */
export const PROGRAMVARA_NAMN = 'Accounted - Accounted'
@@ -35,6 +36,10 @@ export interface BuildIxbrlOptions {
proposedDividend?: number
/** Override "today" for deterministic tests (ISO date). */
todayIso?: string
/** Reuse a canonical report instance so PDF and iXBRL cannot diverge. */
reportData?: ArsredovisningData
/** Reuse the version-bound signer roster when a canonical model is built. */
signatureRequests?: Awaited<ReturnType<typeof listSignatureRequests>>
}
function splitName(fullName: string): { firstName: string; lastName: string } {
@@ -59,7 +64,7 @@ export async function buildIxbrlInput(
// without it the BR would not tie.
const [pdfData, periodRow, currentTbFull, currentTbPreClosing, signatureRequests] =
await Promise.all([
buildArsredovisningData(supabase, companyId, fiscalPeriodId),
options.reportData ?? buildArsredovisningData(supabase, companyId, fiscalPeriodId),
supabase
.from('fiscal_periods')
.select('id, period_start, period_end, previous_period_id')
@@ -68,7 +73,7 @@ export async function buildIxbrlInput(
.single(),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
listSignatureRequests(supabase, companyId, fiscalPeriodId),
options.signatureRequests ?? listSignatureRequests(supabase, companyId, fiscalPeriodId),
])
if (periodRow.error || !periodRow.data) throw new Error('Fiscal period not found')
@@ -226,11 +231,17 @@ export async function buildIxbrlInput(
// same context: the disposition row must carry the identical value
// (TA §2.7.3), so fri överkursfond (2097) is its own row tagged with the
// separate Overkursfond concept instead of being folded into balanserat.
const proposedDividend = Math.max(0, Math.round(options.proposedDividend ?? 0))
const dispBalanserat = br['BalanseratResultat']?.current ?? 0
const dispOverkursfond = br['Overkursfond']?.current ?? 0
const dispArets = br['AretsResultatEgetKapital']?.current ?? 0
const dispSumma = mapping.totals.frittEgetKapital.current
const proposedDividend = Math.max(
0,
Math.round(
options.proposedDividend ?? pdfData.forvaltningsberattelse.proposed_dividend,
),
)
const dispositionAmounts = pdfData.forvaltningsberattelse.resultatdisposition_amounts
const dispBalanserat = dispositionAmounts.retained_earnings
const dispOverkursfond = dispositionAmounts.share_premium_reserve
const dispArets = dispositionAmounts.current_year_result
const dispSumma = dispositionAmounts.total
if (proposedDividend > dispSumma) {
warnings.push(
`Föreslagen utdelning (${proposedDividend} kr) överstiger fritt eget kapital (${dispSumma} kr).`,
@@ -247,14 +258,17 @@ export async function buildIxbrlInput(
}
// ---- underskrifter ---------------------------------------------------------
// Every signature request becomes a signer row (the board must appear in
// the document), but ONLY actually-signed requests get a date: an unsigned
// Every active signature request becomes a signer row (the board must appear
// in the document), but ONLY actually-signed requests get a date: an unsigned
// request keeps signedDate null. Legal dates are never fabricated: the
// missing date renders as an omitted fact in the preview and preflight 1214
// blocks the submission path until everyone has signed.
const signedRequests = signatureRequests.filter((request) => request.status === 'signed')
const activeSignatureRequests = signatureRequests.filter(
(request) => request.status !== 'declined',
)
const signedRequests = activeSignatureRequests.filter((request) => request.status === 'signed')
const today = options.todayIso ?? new Date().toISOString().slice(0, 10)
const signers: IxbrlSigner[] = signatureRequests.map((request) => {
const signers: IxbrlSigner[] = activeSignatureRequests.map((request) => {
const { firstName, lastName } = splitName(request.signer_name)
return {
firstName,
@@ -268,7 +282,7 @@ export async function buildIxbrlInput(
'Inga underskrifter är registrerade: årsredovisningen måste skrivas under av styrelsen (och ev. VD) innan inlämning (kontrollera-kod 1107/1201).',
)
}
if (signedRequests.length !== signatureRequests.length) {
if (signedRequests.length !== activeSignatureRequests.length) {
warnings.push('Alla underskriftsförfrågningar är inte signerade ännu.')
}
const harVd = signers.some((signer) => /verkställande direktör|^vd$/i.test(signer.role ?? ''))
@@ -290,6 +304,11 @@ export async function buildIxbrlInput(
'Datum för årsstämma saknas: fastställelseintyget kan inte fyllas i (kontrollera-kod 1103).',
)
}
const agmDispositionOutcome = pdfData.forvaltningsberattelse.agm_disposition_outcome
const agmDispositionDecision = pdfData.forvaltningsberattelse.agm_disposition_decision
if (!agmDispositionOutcome) {
warnings.push('Årsstämmans beslut om resultatdisposition saknas i fastställelseintyget.')
}
const fallbackSigner = signers[0] ?? { firstName: '', lastName: '', role: null }
const undertecknare = options.undertecknare ?? {
firstName: fallbackSigner.firstName,
@@ -352,6 +371,26 @@ export async function buildIxbrlInput(
resultatdisposition,
},
noter: pdfData.noter.map((note) => ({ number: note.number, title: note.title, body: note.body })),
disclosures: {
longTermDebtOverFiveYears:
pdfData.disclosures.long_term_debt_over_five_years ?? 0,
securitiesPledged: pdfData.disclosures.securities_pledged?.trim() || 'Inga.',
contingentLiabilities:
pdfData.disclosures.contingent_liabilities?.trim() || 'Inga.',
parentCompany: pdfData.disclosures.parent_company_name
? [
`Moderföretag: ${pdfData.disclosures.parent_company_name}.`,
pdfData.disclosures.parent_company_org_number
? `Organisationsnummer: ${pdfData.disclosures.parent_company_org_number}.`
: '',
pdfData.disclosures.parent_company_city
? `Säte: ${pdfData.disclosures.parent_company_city}.`
: '',
]
.filter(Boolean)
.join(' ')
: null,
},
medelantalAnstallda,
underskrifter: {
ort: pdfData.company.city ?? '',
@@ -361,6 +400,8 @@ export async function buildIxbrlInput(
},
faststallelseintyg: {
arsstammaDatum: agmDate ?? null,
resultatdispositionOutcome: agmDispositionOutcome,
resultatdispositionDecision: agmDispositionDecision,
signerFirstName: undertecknare.firstName,
signerLastName: undertecknare.lastName,
signerRole: undertecknare.role,
+60 -5
View File
@@ -175,6 +175,26 @@ export function generateK2IxbrlDocument(input: IxbrlArsredovisningInput): Genera
fb.arsstammaDatum !== null
? writer.date('Arsstamma', 'balans0', fb.arsstammaDatum)
: escapeText('[datum för årsstämma saknas]')
const dispositionFact =
fb.resultatdispositionOutcome === 'proposal_approved'
? writer.textPlain(
'ArsstammaResultatDispositionGodkannaStyrelsensForslag',
'balans0',
'Årsstämman beslöt att godkänna styrelsens förslag till resultatdisposition.',
)
: fb.resultatdispositionOutcome === 'alternative_decision'
? writer.textPlain(
'ArsstammaResultatDispositionInteGodkannaStyrelsensForslag',
'balans0',
'Årsstämman beslöt att inte godkänna styrelsens förslag till resultatdisposition.',
) +
'<br/>' +
writer.textPlain(
'ArsstammaResultatDispositionBeslutstext',
'balans0',
fb.resultatdispositionDecision ?? '[årsstämmans beslut saknas]',
)
: escapeText('[årsstämmans beslut om resultatdisposition saknas]')
const fi = el(
'div',
{ class: 'ar-cert', id: 'id-innehall-faststallelseintyg' },
@@ -194,11 +214,7 @@ export function generateK2IxbrlDocument(input: IxbrlArsredovisningInput): Genera
' ' +
arsstammaFact +
'.<br/>' +
writer.textPlain(
'ArsstammaResultatDispositionGodkannaStyrelsensForslag',
'balans0',
'Årsstämman beslöt att godkänna styrelsens förslag till resultatdisposition.',
),
dispositionFact,
{ continuedAt: 'intygande_forts' },
),
),
@@ -1080,6 +1096,10 @@ export function generateK2IxbrlDocument(input: IxbrlArsredovisningInput): Genera
)
const isPrinciples = /redovisnings.*principer/i.test(note.title)
const isMedelantal = /medelantal.*anst/i.test(note.title)
const isLongTermDebt = /långfristiga skulder/i.test(note.title)
const isSecurities = /ställda säkerheter/i.test(note.title)
const isContingencies = /eventualförpliktelser/i.test(note.title)
const isParentCompany = /koncernförhållanden/i.test(note.title)
if (isPrinciples) {
parts.push(
writer.textHtml('RedovisningsVarderingsprinciper', 'period0', paragraphs(note.body)),
@@ -1121,6 +1141,41 @@ export function generateK2IxbrlDocument(input: IxbrlArsredovisningInput): Genera
),
),
)
} else if (isLongTermDebt) {
parts.push(
paragraphs(note.body) +
el(
'p',
{ class: 'note-fact' },
'Belopp som förfaller senare än fem år efter balansdagen: ' +
writer.money(
'LangfristigaSkulderForfallerSenare5Ar',
'balans0',
input.disclosures.longTermDebtOverFiveYears,
) +
' kr.',
),
)
} else if (isSecurities) {
parts.push(
writer.textPlain(
'NotStalldaSakerheter',
'balans0',
input.disclosures.securitiesPledged,
),
)
} else if (isContingencies) {
parts.push(
writer.textPlain(
'NotEventualforpliktelser',
'balans0',
input.disclosures.contingentLiabilities,
),
)
} else if (isParentCompany && input.disclosures.parentCompany) {
parts.push(
writer.textPlain('NotUpplysningModerforetag', 'period0', input.disclosures.parentCompany),
)
} else {
parts.push(paragraphs(note.body))
}
+8
View File
@@ -109,6 +109,12 @@ export interface IxbrlArsredovisningInput {
}
noter: IxbrlNote[]
disclosures: {
longTermDebtOverFiveYears: number
securitiesPledged: string
contingentLiabilities: string
parentCompany: string | null
}
/** Medelantal anställda (FTE) for current + previous year. */
medelantalAnstallda: { current: number; previous: number | null }
@@ -129,6 +135,8 @@ export interface IxbrlArsredovisningInput {
* placeholder instead of a fabricated date and preflight 1103 blocks
* filing (Bolagsverket kontrollera 1103 semantics). */
arsstammaDatum: string | null
resultatdispositionOutcome: 'proposal_approved' | 'alternative_decision' | null
resultatdispositionDecision: string | null
/** The företrädare who will sign at Bolagsverket. */
signerFirstName: string
signerLastName: string
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { validateIxbrlWithArelle } from '../arelle-client'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('validateIxbrlWithArelle', () => {
it('returns unavailable when no validator is configured', async () => {
const result = await validateIxbrlWithArelle('<html />', { url: '' })
expect(result.status).toBe('unavailable')
expect(result.issues[0].code).toBe('ARELLE-NOT-CONFIGURED')
})
it('sends the exact artifact and accepts a clean validation', async () => {
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(String(init.body)) as { content_base64: string }
expect(Buffer.from(body.content_base64, 'base64').toString('utf8')).toBe('<html />')
return new Response(JSON.stringify({ ok: true, validator_version: '2.37.50', issues: [] }))
})
vi.stubGlobal('fetch', fetchMock)
const result = await validateIxbrlWithArelle('<html />', {
url: 'https://validator.example/validate',
token: 'secret',
})
expect(result.status).toBe('passed')
expect(result.validator_version).toBe('2.37.50')
expect(fetchMock).toHaveBeenCalledOnce()
})
it('normalizes Arelle errors into a blocking result', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(
JSON.stringify({
ok: false,
issues: [{ code: 'xbrl.5.2.5.2', severity: 'error', message: 'Invalid fact' }],
}),
),
),
)
const result = await validateIxbrlWithArelle('<html />', {
url: 'https://validator.example/validate',
})
expect(result.status).toBe('failed')
expect(result.issues).toEqual([
{ code: 'xbrl.5.2.5.2', severity: 'error', message: 'Invalid fact' },
])
})
})
+107
View File
@@ -0,0 +1,107 @@
export interface ArelleValidationIssue {
code: string
severity: 'error' | 'warning' | 'info'
message: string
}
export interface ArelleValidationResult {
status: 'passed' | 'warnings' | 'failed' | 'unavailable'
validator_version: string | null
issues: ArelleValidationIssue[]
}
interface ArelleValidatorResponse {
ok?: boolean
validator_version?: string
issues?: Array<{
code?: string
severity?: string
message?: string
}>
}
/**
* Validate exact iXBRL bytes through the configured Arelle service. The
* service boundary keeps the Vercel application free from a Python runtime
* while still making taxonomy validation mandatory for connected filing.
*/
export async function validateIxbrlWithArelle(
xhtml: string,
options: {
url?: string
token?: string
timeoutMs?: number
} = {},
): Promise<ArelleValidationResult> {
const url = options.url ?? process.env.BOLAGSVERKET_ARELLE_VALIDATOR_URL
if (!url) {
return {
status: 'unavailable',
validator_version: null,
issues: [
{
code: 'ARELLE-NOT-CONFIGURED',
severity: 'error',
message: 'Arelle validator service is not configured.',
},
],
}
}
try {
const token = options.token ?? process.env.BOLAGSVERKET_ARELLE_VALIDATOR_TOKEN
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
filename: 'arsredovisning.xhtml',
content_base64: Buffer.from(xhtml, 'utf8').toString('base64'),
}),
signal: AbortSignal.timeout(options.timeoutMs ?? 60_000),
})
if (!response.ok) {
return {
status: 'unavailable',
validator_version: null,
issues: [
{
code: `ARELLE-HTTP-${response.status}`,
severity: 'error',
message: `Arelle validator returned HTTP ${response.status}.`,
},
],
}
}
const body = (await response.json()) as ArelleValidatorResponse
const issues: ArelleValidationIssue[] = (body.issues ?? []).map((item) => ({
code: item.code?.trim() || 'ARELLE',
severity:
item.severity === 'warning' || item.severity === 'info'
? item.severity
: 'error',
message: item.message?.trim() || 'Arelle reported a validation issue.',
}))
const hasErrors = body.ok === false || issues.some((item) => item.severity === 'error')
return {
status: hasErrors ? 'failed' : issues.length > 0 ? 'warnings' : 'passed',
validator_version: body.validator_version ?? null,
issues,
}
} catch (error) {
return {
status: 'unavailable',
validator_version: null,
issues: [
{
code: 'ARELLE-UNAVAILABLE',
severity: 'error',
message: error instanceof Error ? error.message : 'Arelle validator is unavailable.',
},
],
}
}
}
+13
View File
@@ -98,6 +98,19 @@ const RULES: Rule[] = [
input.faststallelseintyg.arsstammaDatum
? null
: issue('1103', 'error', 'Datum för årsstämman saknas i fastställelseintyget.'),
(input) =>
input.faststallelseintyg.resultatdispositionOutcome
? null
: issue('ACC-AGM-DISP', 'error', 'Årsstämmans beslut om resultatdisposition saknas.'),
(input) =>
input.faststallelseintyg.resultatdispositionOutcome === 'alternative_decision' &&
!input.faststallelseintyg.resultatdispositionDecision?.trim()
? issue(
'ACC-AGM-DISP-TEXT',
'error',
'Årsstämmans alternativa resultatdisposition saknar beslutstext.',
)
: null,
// ---- date ordering ------------------------------------------------------
(input, today) =>
@@ -3,6 +3,7 @@ import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-l
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { roundOre } from '@/lib/money'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import type { ProposedDisposition } from '../types'
/** Bolagsskatt rate. 20.6 % since 2021 (gäller räkenskapsår påbörjat efter 31 dec 2020). */
@@ -45,7 +46,7 @@ export interface BolagsskattComputation {
schablonintaktPeriodiseringsfond: number
otherAdjustments: number
taxableResult: number
/** Taxable result before tax: equals max(taxableResult, 0). */
/** Taxable result before tax, floored to a whole 10 SEK and clamped at zero. */
taxableResultClamped: number
taxRate: number
taxAmount: number
@@ -59,6 +60,9 @@ export interface PostedDispositionsEffect {
* detect an already-posted SLP so it is neither re-proposed nor
* double-counted on a resumed bokslut run. Negative when SLP is posted. */
slpPortion: number
/** Tax provision booked by the year-end flow. This is excluded from
* `total`, but lets callers distinguish it from manually posted 8910. */
taxProvisionPortion: number
}
/**
@@ -148,26 +152,47 @@ export async function sumPostedYearEndDispositions(
}
let effect = 0
let slp = 0
let taxProvision = 0
for (const row of data) {
const acc = row.account_number
if (!(acc.startsWith('88') || acc === '7533')) continue
const delta = (Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
effect += delta
if (acc === '7533') slp += delta
if (acc.startsWith('88') || acc === '7533') {
effect += delta
if (acc === '7533') slp += delta
}
if (acc === '8910') taxProvision += -delta
}
return {
total: roundOre(effect),
slpPortion: roundOre(slp),
taxProvisionPortion: roundOre(taxProvision),
}
}
/**
* Return the effective tax expense on 8910 for an open fiscal period.
* Trial balance already nets storno and correction entries, which makes this
* suitable as the idempotency check before a new tax voucher is posted.
*/
export async function getBookedBolagsskatt(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<number> {
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const amount = trialBalance.rows
.filter((row) => row.account_number === '8910')
.reduce((sum, row) => sum + row.closing_debit - row.closing_credit, 0)
return roundOre(Math.max(0, amount))
}
/**
* Compute bolagsskatt 20.6 % on the company's taxable result.
*
* Reads income-statement result before tax and adds the manual adjustments
* the user provided (non-deductible expenses, schablonintäkt, etc.). The
* resulting taxable result is rounded down to nearest whole krona before
* applying the tax rate, per SFL 22 kap 1 §.
* resulting taxable result is rounded down to the nearest whole 10 SEK before
* applying the tax rate, per IL 1 kap 7 §.
*
* If the period shows a loss, no tax is proposed: Swedish AB accumulate
* inrullat underskott for future offset, but that bookkeeping is handled
@@ -199,9 +224,9 @@ export async function calculateBolagsskatt(
schablonintaktPeriodiseringsfond +
otherAdjustments
// Truncate to whole krona before applying rate. Negative taxable result =
// no tax provision (handled as inrullat underskott in INK2, not here).
const taxableResultClamped = Math.max(0, Math.floor(taxableResult))
// Round down to a whole 10 SEK before applying the rate. Negative taxable
// result means no tax provision (handled as inrullat underskott in INK2).
const taxableResultClamped = Math.floor(Math.max(0, taxableResult) / 10) * 10
const taxAmount = Math.round(taxableResultClamped * BOLAGSSKATT_RATE)
const computation: BolagsskattComputation = {
@@ -0,0 +1,195 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import type {
TaxAdjustmentItem,
TaxAdjustmentSnapshot,
TaxAdjustmentType,
} from '../types'
export const DETECTED_TAX_ADJUSTMENT_ACCOUNTS = [
{
accountNumber: '6992',
sourceKey: 'account:6992',
adjustmentType: 'non_deductible_expense' as const,
description: 'Övriga externa kostnader, ej avdragsgilla',
},
{
accountNumber: '8423',
sourceKey: 'account:8423',
adjustmentType: 'non_deductible_expense' as const,
description: 'Räntekostnader för skatter och avgifter',
},
] as const
const MANUAL_ADJUSTMENTS = [
{
sourceKey: 'manual:non_deductible_expenses',
adjustmentType: 'non_deductible_expense' as const,
description: 'Ytterligare ej avdragsgilla kostnader',
},
{
sourceKey: 'manual:non_taxable_income',
adjustmentType: 'non_taxable_income' as const,
description: 'Ej skattepliktiga intäkter',
},
] as const
interface PersistedAdjustmentRow {
source_key: string
adjustment_type: TaxAdjustmentType
source: 'detected' | 'manual'
description: string
account_number: string | null
amount: number | string
included: boolean
}
export interface SaveTaxAdjustmentsInput {
manualAdjustments: {
nonDeductibleExpenses: number
nonTaxableIncome: number
}
detectedAccounts: Record<(typeof DETECTED_TAX_ADJUSTMENT_ACCOUNTS)[number]['accountNumber'], boolean>
}
export async function loadTaxAdjustmentSnapshot(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<TaxAdjustmentSnapshot> {
const [trialBalance, persistedResult] = await Promise.all([
generateTrialBalance(supabase, companyId, fiscalPeriodId, {
excludeYearEndClosing: true,
}),
supabase
.from('fiscal_period_tax_adjustments')
.select('source_key, adjustment_type, source, description, account_number, amount, included')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId),
])
if (persistedResult.error) {
throw new Error(`Failed to load tax adjustments: ${persistedResult.error.message}`)
}
const persistedByKey = new Map(
((persistedResult.data ?? []) as PersistedAdjustmentRow[]).map((row) => [row.source_key, row]),
)
const trialBalanceByAccount = new Map(
trialBalance.rows.map((row) => [row.account_number, row]),
)
const detectedItems: TaxAdjustmentItem[] = DETECTED_TAX_ADJUSTMENT_ACCOUNTS.map((config) => {
const row = trialBalanceByAccount.get(config.accountNumber)
const amount = roundOre(Math.max(0, (row?.closing_debit ?? 0) - (row?.closing_credit ?? 0)))
const persisted = persistedByKey.get(config.sourceKey)
return {
sourceKey: config.sourceKey,
source: 'detected',
adjustmentType: config.adjustmentType,
description: config.description,
accountNumber: config.accountNumber,
amount,
included: persisted?.included ?? amount > 0,
}
})
const manualItems: TaxAdjustmentItem[] = MANUAL_ADJUSTMENTS.map((config) => {
const persisted = persistedByKey.get(config.sourceKey)
const amount = roundOre(Math.max(0, Number(persisted?.amount) || 0))
return {
sourceKey: config.sourceKey,
source: 'manual',
adjustmentType: config.adjustmentType,
description: config.description,
accountNumber: null,
amount,
included: amount > 0,
}
})
return summarizeTaxAdjustments([...detectedItems, ...manualItems])
}
export async function saveTaxAdjustments(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
userId: string,
input: SaveTaxAdjustmentsInput,
): Promise<void> {
const current = await loadTaxAdjustmentSnapshot(supabase, companyId, fiscalPeriodId)
const detectedAmounts = new Map(
current.items
.filter((item) => item.source === 'detected')
.map((item) => [item.sourceKey, item.amount]),
)
const rows = [
...DETECTED_TAX_ADJUSTMENT_ACCOUNTS.map((config) => ({
company_id: companyId,
user_id: userId,
fiscal_period_id: fiscalPeriodId,
adjustment_type: config.adjustmentType,
source: 'detected',
source_key: config.sourceKey,
description: config.description,
account_number: config.accountNumber,
amount: detectedAmounts.get(config.sourceKey) ?? 0,
included: input.detectedAccounts[config.accountNumber],
})),
{
company_id: companyId,
user_id: userId,
fiscal_period_id: fiscalPeriodId,
adjustment_type: 'non_deductible_expense',
source: 'manual',
source_key: 'manual:non_deductible_expenses',
description: 'Ytterligare ej avdragsgilla kostnader',
account_number: null,
amount: roundOre(input.manualAdjustments.nonDeductibleExpenses),
included: input.manualAdjustments.nonDeductibleExpenses > 0,
},
{
company_id: companyId,
user_id: userId,
fiscal_period_id: fiscalPeriodId,
adjustment_type: 'non_taxable_income',
source: 'manual',
source_key: 'manual:non_taxable_income',
description: 'Ej skattepliktiga intäkter',
account_number: null,
amount: roundOre(input.manualAdjustments.nonTaxableIncome),
included: input.manualAdjustments.nonTaxableIncome > 0,
},
]
const { error } = await supabase
.from('fiscal_period_tax_adjustments')
.upsert(rows, { onConflict: 'company_id,fiscal_period_id,source_key' })
if (error) {
throw new Error(`Failed to save tax adjustments: ${error.message}`)
}
}
function summarizeTaxAdjustments(items: TaxAdjustmentItem[]): TaxAdjustmentSnapshot {
let nonDeductibleExpenses = 0
let nonTaxableIncome = 0
for (const item of items) {
if (!item.included) continue
if (item.adjustmentType === 'non_deductible_expense') {
nonDeductibleExpenses += item.amount
} else {
nonTaxableIncome += item.amount
}
}
return {
items,
nonDeductibleExpenses: roundOre(nonDeductibleExpenses),
nonTaxableIncome: roundOre(nonTaxableIncome),
}
}
+29
View File
@@ -34,6 +34,33 @@ export interface ProposedDisposition {
required?: boolean
}
export type TaxAdjustmentType = 'non_deductible_expense' | 'non_taxable_income'
export type TaxAdjustmentSource = 'detected' | 'manual'
export interface TaxAdjustmentItem {
sourceKey: string
source: TaxAdjustmentSource
adjustmentType: TaxAdjustmentType
description: string
accountNumber: string | null
amount: number
included: boolean
}
export interface TaxAdjustmentSnapshot {
items: TaxAdjustmentItem[]
nonDeductibleExpenses: number
nonTaxableIncome: number
}
export interface CompletedDisposition {
kind: DispositionKind
label: string
amount: number
status: 'booked' | 'needs_correction'
warnings: string[]
}
/**
* Snapshot of all proposed dispositions for a fiscal period, returned by the
* dispositions API. Order is the suggested user-flow order: p-fond återföring
@@ -52,4 +79,6 @@ export interface DispositionsProposal {
/** Result before any new dispositions, from the income statement (positive = profit). */
netResultBefore: number
proposals: ProposedDisposition[]
taxAdjustments?: TaxAdjustmentSnapshot
completedDispositions?: CompletedDisposition[]
}
+2 -2
View File
@@ -11,6 +11,7 @@ import {
} from '@/lib/tax/deadline-generator'
import type { CompanySettingsForDeadlines } from '@/lib/tax/deadline-config'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* Switch the active company. Returns an error *code* (translated by the
@@ -76,8 +77,7 @@ export async function createCompanyFromOnboarding(params: {
// and a redacted message in prod. Logging the full error here gives
// us a server-side trace and returns a localized fallback to the UI.
console.error('[createCompanyFromOnboarding] unexpected error', err)
const message = err instanceof Error ? err.message : String(err)
return { error: message || 'Något gick fel när företaget skulle skapas. Försök igen.' }
return { error: getErrorMessage(err, { context: 'settings' }) }
}
}
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
import { getAuditLog } from '../audit-service'
function createQuery(result: { data: unknown[]; error: unknown; count: number | null }) {
const query = {
select: vi.fn(),
eq: vi.fn(),
order: vi.fn(),
range: vi.fn(),
}
query.select.mockReturnValue(query)
query.eq.mockReturnValue(query)
query.order.mockReturnValue(query)
query.range.mockResolvedValue(result)
return query
}
describe('getAuditLog', () => {
it('requests an exact count for interactive pagination', async () => {
const query = createQuery({ data: [], error: null, count: 12 })
const supabase = { from: vi.fn(() => query) }
const result = await getAuditLog(supabase as never, 'company-1')
expect(query.select).toHaveBeenCalledWith('*', { count: 'exact' })
expect(result.count).toBe(12)
})
it('skips the exact count for full archive exports', async () => {
const query = createQuery({ data: [], error: null, count: null })
const supabase = { from: vi.fn(() => query) }
const result = await getAuditLog(supabase as never, 'company-1', {
includeCount: false,
pageSize: 500,
})
expect(query.select).toHaveBeenCalledWith('*')
expect(query.order).toHaveBeenNthCalledWith(1, 'created_at', { ascending: false })
expect(query.order).toHaveBeenNthCalledWith(2, 'id', { ascending: false })
expect(result.count).toBe(0)
})
})
+9 -4
View File
@@ -16,6 +16,8 @@ export interface AuditLogFilters {
to_date?: string
page?: number
pageSize?: number
/** Full exports can skip the expensive exact count and stop on a short page. */
includeCount?: boolean
}
/**
@@ -28,13 +30,16 @@ export async function getAuditLog(
): Promise<{ data: AuditLogEntry[]; count: number }> {
const page = filters.page ?? 1
const pageSize = filters.pageSize ?? 50
const includeCount = filters.includeCount ?? true
const offset = (page - 1) * pageSize
let query = supabase
.from('audit_log')
.select('*', { count: 'exact' })
const auditTable = supabase.from('audit_log')
let query = (includeCount
? auditTable.select('*', { count: 'exact' })
: auditTable.select('*'))
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.range(offset, offset + pageSize - 1)
if (filters.action) {
@@ -61,7 +66,7 @@ export async function getAuditLog(
return {
data: (data as AuditLogEntry[]) || [],
count: count ?? 0,
count: includeCount ? count ?? 0 : 0,
}
}
+100
View File
@@ -1181,6 +1181,14 @@ const YEAR_END: Record<string, StructuredErrorEntry> = {
message_sv: 'Nästa räkenskapsperiod har redan ingående balanser bokförda. Storno dem innan du kör om bokslutet.',
message_en: 'Next fiscal period already has opening balances posted; reverse them before re-running year-end.',
},
YEAR_END_NO_ACTIVITY: {
httpStatus: 409,
message_sv:
'Räkenskapsperioden saknar bokförd aktivitet och kan därför inte skapa en bokslutsverifikation. Bokför eller importera periodens affärshändelser innan du kör bokslutet.',
message_en:
'The fiscal period has no posted activity, so no year-end voucher can be created. Post or import the period activity before running year-end closing.',
retryable: false,
},
}
const OPENING_BAL: Record<string, StructuredErrorEntry> = {
@@ -2775,6 +2783,98 @@ const BOLAGSVERKET: Record<string, StructuredErrorEntry> = {
message_en:
'No Bolagsverket event subscription exists for this company yet. One is created on the first submission.',
},
BOLAGSVERKET_NOT_RELEASED: {
httpStatus: 503,
message_sv:
'Direktinlämning till Bolagsverket är inte öppnad i den här installationen. Använd pappersflödet tills anslutningen är godkänd.',
message_en:
'Connected filing to Bolagsverket is not enabled for this installation. Use the paper flow until acceptance is complete.',
retryable: false,
},
BOLAGSVERKET_VERSION_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Den valda versionen av årsredovisningen finns inte.',
message_en: 'The selected annual report version was not found.',
retryable: false,
},
BOLAGSVERKET_VERSION_NOT_SIGNED: {
httpStatus: 409,
message_sv: 'Årsredovisningsversionen måste vara låst och undertecknad före inlämning.',
message_en: 'The annual report version must be finalized and signed before submission.',
retryable: false,
},
BOLAGSVERKET_DIGITAL_INELIGIBLE: {
httpStatus: 409,
message_sv:
'Den låsta årsredovisningsversionen är inte godkänd för digital inlämning. Använd pappersflödet och följ kontrollpunkterna i årsredovisningsstudion.',
message_en:
'The locked annual report version is not eligible for connected filing. Use the paper workflow and review the Annual Report Studio checks.',
retryable: false,
},
BOLAGSVERKET_SIGNATURE_EVIDENCE_INCOMPLETE: {
httpStatus: 409,
message_sv: 'Verifierbart underskriftsunderlag saknas för en eller flera undertecknare.',
message_en: 'Verifiable signature evidence is missing for one or more required signers.',
retryable: false,
},
BOLAGSVERKET_CERTIFICATE_SIGNER_MISMATCH: {
httpStatus: 409,
message_sv:
'Undertecknaren av fastställelseintyget stämmer inte med den person som låstes i årsredovisningsversionen.',
message_en:
'The certificate signer does not match the person locked into the annual report version.',
retryable: false,
},
BOLAGSVERKET_ARELLE_UNAVAILABLE: {
httpStatus: 503,
message_sv:
'Taxonomivalideringen med Arelle är inte tillgänglig. Inlämningen har stoppats innan något skickades.',
message_en:
'Arelle taxonomy validation is unavailable. Filing was stopped before anything was sent.',
retryable: true,
},
BOLAGSVERKET_ARELLE_FAILED: {
httpStatus: 409,
message_sv:
'Arelle hittade blockerande fel i iXBRL-dokumentet. Rätta felen och skapa en ny version.',
message_en:
'Arelle found blocking errors in the iXBRL document. Correct them and create a new version.',
retryable: false,
},
ARSREDOVISNING_INCOMPLETE: {
httpStatus: 409,
message_sv: 'Årsredovisningen har blockerande kontrollfel och kan inte låsas ännu.',
message_en: 'The annual report has blocking validation errors and cannot be finalized yet.',
retryable: false,
},
ARSREDOVISNING_VERSION_NOT_SIGNABLE: {
httpStatus: 409,
message_sv: 'Den valda årsredovisningsversionen är inte öppen för underskrift.',
message_en: 'The selected annual report version is not open for signing.',
retryable: false,
},
ARSREDOVISNING_SIGNATURE_DATE_INVALID: {
httpStatus: 400,
message_sv:
'Underskriftsdatumet måste vara samma dag som eller senare än versionens låsdatum och får inte ligga i framtiden.',
message_en:
'The signature date must be on or after the version finalization date and cannot be in the future.',
retryable: false,
},
ARSREDOVISNING_SIGNER_ROSTER_LOCKED: {
httpStatus: 409,
message_sv:
'Undertecknarlistan är låst eftersom en årsredovisningsversion redan väntar på underskrift.',
message_en:
'The signer roster is locked because an annual report version is already awaiting signatures.',
retryable: false,
},
ARSREDOVISNING_SIGNER_ALREADY_EXISTS: {
httpStatus: 409,
message_sv: 'Undertecknaren finns redan i den aktuella undertecknarlistan.',
message_en: 'The signer is already present in the current signer roster.',
retryable: false,
},
ARSREDOVISNING_REGISTERED: {
httpStatus: 409,
message_sv:
@@ -12,10 +12,21 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import sharp from 'sharp'
import { prepareInvoicePdfRender, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { makeCompanySettings, makeInvoice } from '@/tests/helpers'
const fontDownloadMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({
storage: {
from: () => ({ download: fontDownloadMock }),
},
}),
}))
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
const SVG_LOGO = Buffer.from(
@@ -47,9 +58,11 @@ async function expectValidEmbeddedPng(logoUrl: string | null | undefined) {
describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
beforeEach(() => {
vi.unstubAllGlobals()
fontDownloadMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
@@ -128,7 +141,7 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
ok: true,
headers: {
get: (h: string) =>
h.toLowerCase() === 'content-length' ? String(6 * 1024 * 1024) : 'image/png',
h.toLowerCase() === 'content-length' ? String(11 * 1024 * 1024) : 'image/png',
},
arrayBuffer: async () => new ArrayBuffer(0),
})
@@ -178,6 +191,77 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
})
})
describe('prepareInvoicePdfRender: invoice fonts', () => {
beforeEach(() => {
vi.unstubAllGlobals()
fontDownloadMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('registers a bundled font before returning it to the PDF template', async () => {
const company = makeCompanySettings({ invoice_font_family: 'Source Sans 3' })
const { branding } = await prepareInvoicePdfRender(company)
expect(branding.fontFamily).toBe('Source Sans 3')
})
it('registers a valid stored custom font under an isolated render family', async () => {
const bytes = await readFile(
join(process.cwd(), 'public', 'fonts', 'invoice', 'SourceSans3-Regular.ttf'),
)
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
)
fontDownloadMock.mockResolvedValue({
data: new Blob([arrayBuffer]),
error: null,
})
const company = makeCompanySettings({
invoice_font_family: 'Custom',
invoice_custom_font_path: 'company-1/invoice-font-1.ttf',
})
const { branding } = await prepareInvoicePdfRender(company)
expect(branding.fontFamily).toMatch(/^InvoiceCustom-[0-9a-f]{12}$/)
})
it('falls back to Helvetica when a custom font cannot be parsed', async () => {
const corruptedFont = new Uint8Array([0x00, 0x01, 0x00, 0x00, 0xff, 0xff])
fontDownloadMock.mockResolvedValue({
data: new Blob([corruptedFont]),
error: null,
})
const company = makeCompanySettings({
invoice_font_family: 'Custom',
invoice_custom_font_path: 'company-1/invoice-font-2.ttf',
})
const { branding } = await prepareInvoicePdfRender(company)
expect(branding.fontFamily).toBe('Helvetica')
})
it('does not download a custom font from another company path', async () => {
const company = makeCompanySettings({
invoice_font_family: 'Custom',
invoice_custom_font_path: 'company-2/invoice-font-3.ttf',
})
const { branding } = await prepareInvoicePdfRender(company)
expect(fontDownloadMock).not.toHaveBeenCalled()
expect(branding.fontFamily).toBe('Helvetica')
})
})
describe('buildPaymentLinkQrDataUrl', () => {
it('encodes the payment link as a PNG QR data URL for a real invoice', async () => {
const invoice = makeInvoice({ payment_link_url: 'https://buy.stripe.com/test_abc123' })
+31
View File
@@ -0,0 +1,31 @@
import type { InvoiceFontFamily } from '@/types'
export const LOGO_UPLOAD_MAX_MB = 10
export const LOGO_UPLOAD_MAX_BYTES = LOGO_UPLOAD_MAX_MB * 1024 * 1024
export const INVOICE_LOGO_MAX_WIDTH_PT = 240
export const INVOICE_LOGO_MAX_HEIGHT_PT = 80
export const INVOICE_FONT_FAMILIES = [
'Helvetica',
'Times-Roman',
'Courier',
'Source Sans 3',
'Source Serif 4',
'Custom',
] as const satisfies readonly InvoiceFontFamily[]
export const STANDARD_PDF_FONT_FAMILIES = [
'Helvetica',
'Times-Roman',
'Courier',
] as const satisfies readonly InvoiceFontFamily[]
export const BUNDLED_INVOICE_FONT_FAMILIES = [
'Source Sans 3',
'Source Serif 4',
] as const satisfies readonly InvoiceFontFamily[]
export const CUSTOM_INVOICE_FONT_FAMILY = 'Custom' as const
export const INVOICE_FONT_UPLOAD_MAX_MB = 5
export const INVOICE_FONT_UPLOAD_MAX_BYTES = INVOICE_FONT_UPLOAD_MAX_MB * 1024 * 1024
+32
View File
@@ -0,0 +1,32 @@
export type InvoiceFontFileFormat = 'ttf' | 'woff'
export function detectInvoiceFontFileFormat(
bytes: Uint8Array,
): InvoiceFontFileFormat | null {
if (bytes.byteLength < 4) return null
if (
bytes[0] === 0x00 &&
bytes[1] === 0x01 &&
bytes[2] === 0x00 &&
bytes[3] === 0x00
) {
return 'ttf'
}
const signature = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3])
if (signature === 'true') return 'ttf'
if (signature === 'wOFF') return 'woff'
return null
}
export function getInvoiceFontContentType(format: InvoiceFontFileFormat): string {
return format === 'ttf' ? 'font/ttf' : 'font/woff'
}
export function toInvoiceFontDataUrl(
bytes: Uint8Array,
format: InvoiceFontFileFormat,
): string {
return `data:${getInvoiceFontContentType(format)};base64,${Buffer.from(bytes).toString('base64')}`
}
+220
View File
@@ -0,0 +1,220 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { Font } from '@react-pdf/renderer'
import type { CompanySettings, InvoiceFontFamily } from '@/types'
import type { InvoiceBranding } from '@/lib/invoices/pdf-template'
import {
BUNDLED_INVOICE_FONT_FAMILIES,
CUSTOM_INVOICE_FONT_FAMILY,
INVOICE_FONT_UPLOAD_MAX_BYTES,
STANDARD_PDF_FONT_FAMILIES,
} from '@/lib/invoices/branding-constants'
import {
detectInvoiceFontFileFormat,
toInvoiceFontDataUrl,
} from '@/lib/invoices/font-files'
import { createLogger } from '@/lib/logger'
import { createServiceClient } from '@/lib/supabase/server'
export const CUSTOM_INVOICE_FONT_RENDER_PREFIX = 'InvoiceCustom-'
type BundledInvoiceFontFamily = (typeof BUNDLED_INVOICE_FONT_FAMILIES)[number]
interface FontVariantFiles {
regular: string
bold: string
italic: string
boldItalic: string
}
const BUNDLED_FONT_FILES: Record<BundledInvoiceFontFamily, FontVariantFiles> = {
'Source Sans 3': {
regular: 'SourceSans3-Regular.ttf',
bold: 'SourceSans3-Bold.ttf',
italic: 'SourceSans3-Italic.ttf',
boldItalic: 'SourceSans3-BoldItalic.ttf',
},
'Source Serif 4': {
regular: 'SourceSerif4-Regular.ttf',
bold: 'SourceSerif4-Bold.ttf',
italic: 'SourceSerif4-Italic.ttf',
boldItalic: 'SourceSerif4-BoldItalic.ttf',
},
}
const log = createLogger('invoice.pdf-fonts')
const registeredFamilies = new Set<string>()
const bundledRegistration = new Map<string, Promise<void>>()
const customRegistration = new Map<string, Promise<string | null>>()
const invalidCustomFontPaths = new Set<string>()
function isStandardPdfFont(
family: InvoiceFontFamily,
): family is (typeof STANDARD_PDF_FONT_FAMILIES)[number] {
return STANDARD_PDF_FONT_FAMILIES.includes(
family as (typeof STANDARD_PDF_FONT_FAMILIES)[number],
)
}
function isBundledInvoiceFont(
family: InvoiceFontFamily,
): family is BundledInvoiceFontFamily {
return BUNDLED_INVOICE_FONT_FAMILIES.includes(
family as BundledInvoiceFontFamily,
)
}
async function readBundledFont(fileName: string): Promise<string> {
const bytes = await readFile(join(process.cwd(), 'public', 'fonts', 'invoice', fileName))
const format = detectInvoiceFontFileFormat(bytes)
if (!format) throw new Error(`Invalid bundled invoice font: ${fileName}`)
return toInvoiceFontDataUrl(bytes, format)
}
async function registerBundledFont(family: BundledInvoiceFontFamily): Promise<void> {
if (registeredFamilies.has(family)) return
let registration = bundledRegistration.get(family)
if (!registration) {
registration = (async () => {
const files = BUNDLED_FONT_FILES[family]
const [regular, bold, italic, boldItalic] = await Promise.all([
readBundledFont(files.regular),
readBundledFont(files.bold),
readBundledFont(files.italic),
readBundledFont(files.boldItalic),
])
Font.register({
family,
fonts: [
{ src: regular, fontWeight: 400, fontStyle: 'normal' },
{ src: bold, fontWeight: 700, fontStyle: 'normal' },
{ src: italic, fontWeight: 400, fontStyle: 'italic' },
{ src: boldItalic, fontWeight: 700, fontStyle: 'italic' },
],
})
await Promise.all([
Font.load({ fontFamily: family, fontWeight: 400, fontStyle: 'normal' }),
Font.load({ fontFamily: family, fontWeight: 700, fontStyle: 'normal' }),
Font.load({ fontFamily: family, fontWeight: 400, fontStyle: 'italic' }),
Font.load({ fontFamily: family, fontWeight: 700, fontStyle: 'italic' }),
])
registeredFamilies.add(family)
})()
bundledRegistration.set(family, registration)
}
try {
await registration
} finally {
bundledRegistration.delete(family)
}
}
async function downloadCustomFontDataUrl(
companyId: string,
storagePath: string,
): Promise<string | null> {
const pathPrefix = `${companyId}/`
const fileName = storagePath.slice(pathPrefix.length)
if (
!storagePath.startsWith(pathPrefix) ||
!/^invoice-font-\d+\.(ttf|woff)$/.test(fileName)
) {
return null
}
try {
const serviceClient = createServiceClient()
const { data, error } = await serviceClient.storage
.from('invoice-fonts')
.download(storagePath)
if (error || !data || data.size > INVOICE_FONT_UPLOAD_MAX_BYTES) return null
const bytes = new Uint8Array(await data.arrayBuffer())
if (bytes.byteLength > INVOICE_FONT_UPLOAD_MAX_BYTES) return null
const format = detectInvoiceFontFileFormat(bytes)
return format ? toInvoiceFontDataUrl(bytes, format) : null
} catch {
return null
}
}
async function registerCustomFont(company: CompanySettings): Promise<string | null> {
const storagePath = company.invoice_custom_font_path
if (!storagePath || invalidCustomFontPaths.has(storagePath)) return null
const digest = createHash('sha256').update(storagePath).digest('hex').slice(0, 12)
const family = `${CUSTOM_INVOICE_FONT_RENDER_PREFIX}${digest}`
if (registeredFamilies.has(family)) return family
let registration = customRegistration.get(storagePath)
if (!registration) {
registration = (async () => {
const src = await downloadCustomFontDataUrl(company.company_id, storagePath)
if (!src) return null
Font.register({
family,
fonts: [
{ src, fontWeight: 400, fontStyle: 'normal' },
{ src, fontWeight: 700, fontStyle: 'normal' },
{ src, fontWeight: 400, fontStyle: 'italic' },
{ src, fontWeight: 700, fontStyle: 'italic' },
],
})
try {
await Font.load({ fontFamily: family, fontWeight: 400, fontStyle: 'normal' })
} catch {
invalidCustomFontPaths.add(storagePath)
return null
}
registeredFamilies.add(family)
return family
})()
customRegistration.set(storagePath, registration)
}
try {
return await registration
} finally {
customRegistration.delete(storagePath)
}
}
function withHelveticaFallback(branding: InvoiceBranding): InvoiceBranding {
return { ...branding, fontFamily: 'Helvetica' }
}
export async function prepareInvoiceFont(
company: CompanySettings,
branding: InvoiceBranding,
): Promise<InvoiceBranding> {
const family = branding.fontFamily ?? 'Helvetica'
if (isStandardPdfFont(family as InvoiceFontFamily)) return branding
if (isBundledInvoiceFont(family as InvoiceFontFamily)) {
try {
await registerBundledFont(family as BundledInvoiceFontFamily)
return branding
} catch (error) {
log.warn('bundled invoice font registration failed', {
family,
error: error instanceof Error ? error.message : String(error),
})
return withHelveticaFallback(branding)
}
}
if (family === CUSTOM_INVOICE_FONT_FAMILY) {
const registeredFamily = await registerCustomFont(company)
if (registeredFamily) return { ...branding, fontFamily: registeredFamily }
log.warn('custom invoice font unavailable, using Helvetica', {
companyId: company.company_id,
})
}
return withHelveticaFallback(branding)
}
+10 -7
View File
@@ -25,6 +25,8 @@ import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBrandin
import { buildSwishQrPayload } from '@/lib/payments/swish'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { createLogger } from '@/lib/logger'
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
import { prepareInvoiceFont } from '@/lib/invoices/pdf-fonts'
const log = createLogger('invoice.swish-qr')
const paymentLinkLog = createLogger('invoice.payment-link-qr')
@@ -50,9 +52,8 @@ const LOGO_CACHE_TTL_MS = 5 * 60 * 1000
const LOGO_CACHE_MAX = 50
const logoDataUrlCache = new Map<string, { dataUrl: string; at: number }>()
// The invoice draws the logo at maxWidth 150pt / maxHeight 40pt (~200px at
// print resolution), so 600px keeps it crisp while bounding the embedded
// base64 payload.
// The invoice draws the logo at up to 240pt by 80pt, so 600px keeps it crisp
// while bounding the embedded base64 payload.
const LOGO_MAX_PX = 600
// Bound the logo fetch so a slow or oversized response can't hang or balloon an
@@ -60,7 +61,6 @@ const LOGO_MAX_PX = 600
// URL (set only by the upload route), so SSRF is not reachable today: these
// caps are defense-in-depth for that invariant plus plain robustness.
const LOGO_FETCH_TIMEOUT_MS = 5_000
const LOGO_MAX_BYTES = 5 * 1024 * 1024 // 5 MB: generous for a logo, bounds memory
// Coalesce concurrent renders of the same logo (preflight + final on a send, and
// every invoice in a recurring/batch loop) onto one in-flight fetch+encode
@@ -103,9 +103,9 @@ async function encodeLogo(logoUrl: string): Promise<string | null> {
// Reject oversized payloads up front when the server declares a length, and
// again after reading in case the header lied or was absent.
const declared = Number(res.headers.get('content-length') ?? '')
if (Number.isFinite(declared) && declared > LOGO_MAX_BYTES) return null
if (Number.isFinite(declared) && declared > LOGO_UPLOAD_MAX_BYTES) return null
const input = Buffer.from(await res.arrayBuffer())
if (input.byteLength > LOGO_MAX_BYTES) return null
if (input.byteLength > LOGO_UPLOAD_MAX_BYTES) return null
// SVGs must be rasterized at a higher density or sharp renders them at
// their intrinsic (often tiny) pixel size and the result looks blurry.
@@ -145,7 +145,10 @@ async function encodeLogo(logoUrl: string): Promise<string | null> {
export async function prepareInvoicePdfRender(
company: CompanySettings,
): Promise<InvoicePdfRenderExtras> {
const branding = brandingFromCompanySettings(company)
const branding = await prepareInvoiceFont(
company,
brandingFromCompanySettings(company),
)
if (!company.logo_url) return { branding, company }
const dataUrl = await resolveLogoDataUrl(company.logo_url)
+25 -8
View File
@@ -9,6 +9,13 @@ import {
} from '@react-pdf/renderer'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { generateOcrReference } from '@/lib/bankgiro/luhn'
import {
BUNDLED_INVOICE_FONT_FAMILIES,
INVOICE_LOGO_MAX_HEIGHT_PT,
INVOICE_LOGO_MAX_WIDTH_PT,
STANDARD_PDF_FONT_FAMILIES,
} from '@/lib/invoices/branding-constants'
import { CUSTOM_INVOICE_FONT_RENDER_PREFIX } from '@/lib/invoices/pdf-fonts'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { isTextLikeLine } from '@/lib/invoices/display'
@@ -188,8 +195,7 @@ export interface InvoiceBranding {
/** Accent color: used for muted labels and section headings.
* Default '#666666' (the existing hardcoded value). */
accentColor?: string
/** Font family: must be one of react-pdf's built-in PostScript fonts.
* Default 'Helvetica'. */
/** Registered react-pdf font family. Default 'Helvetica'. */
fontFamily?: string
/** Optional banner text rendered above the document title. */
headerText?: string | null
@@ -203,10 +209,10 @@ interface ResolvedBranding {
fontFamily: string
}
// react-pdf only ships these three PostScript fonts. Anything else would
// require registerFont() with a binary file, out of scope for AGPL-clean
// branding and a fingerprinting risk besides.
const ALLOWED_FONTS = new Set(['Helvetica', 'Times-Roman', 'Courier'])
const ALLOWED_FONTS = new Set<string>([
...STANDARD_PDF_FONT_FAMILIES,
...BUNDLED_INVOICE_FONT_FAMILIES,
])
/**
* Extract the InvoicePDF branding shape from a CompanySettings row. Tolerates
@@ -238,7 +244,9 @@ const DEFAULT_BRANDING: ResolvedBranding = {
function resolveBranding(branding: InvoiceBranding | undefined): ResolvedBranding {
if (!branding) return DEFAULT_BRANDING
const fontFamily =
branding.fontFamily && ALLOWED_FONTS.has(branding.fontFamily)
branding.fontFamily &&
(ALLOWED_FONTS.has(branding.fontFamily) ||
branding.fontFamily.startsWith(CUSTOM_INVOICE_FONT_RENDER_PREFIX))
? branding.fontFamily
: DEFAULT_BRANDING.fontFamily
return {
@@ -730,7 +738,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View style={styles.header}>
<View style={styles.companyInfo}>
{company.logo_url && (company.invoice_show_logo ?? true) && (
<Image src={company.logo_url} style={{ maxHeight: 40, maxWidth: 150, marginBottom: 6, alignSelf: 'flex-start' }} />
<Image
src={company.logo_url}
style={{
maxHeight: INVOICE_LOGO_MAX_HEIGHT_PT,
maxWidth: INVOICE_LOGO_MAX_WIDTH_PT,
marginBottom: 6,
alignSelf: 'flex-start',
objectFit: 'contain',
}}
/>
)}
{(company.invoice_show_company_name ?? true) &&
(company.invoice_company_name_position ?? 'header') === 'header' && (
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PendingOperation } from '@/types'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { commitPendingOperation } from '../commit'
function makePendingOp(params: Record<string, unknown>): PendingOperation {
return {
id: 'op-settings-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'update_company_settings',
status: 'pending',
title: 'Update company settings',
params,
preview_data: {},
result_data: null,
actor_type: 'api_key',
actor_id: 'key-1',
actor_label: 'Test key',
risk_level: 'medium',
agent_metadata: null,
rejection_category: null,
rejection_reason: null,
created_at: '2026-07-21T00:00:00Z',
resolved_at: null,
updated_at: '2026-07-21T00:00:00Z',
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('commitPendingOperation: update_company_settings', () => {
it('updates only validated settings for the selected company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-settings-1' } })
enqueue({
data: {
bank_name: 'Testbanken',
clearing_number: '1234',
account_number: '1234567',
bankgiro: '5050-1055',
plusgiro: null,
swish: null,
iban: null,
bic: null,
default_our_reference: 'Test Contact',
},
})
enqueue({ data: null })
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
changes: {
bank_name: 'Testbanken',
bankgiro: '5050-1055',
default_our_reference: 'Test Contact',
},
}),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
company_id: 'company-1',
bankgiro: '5050-1055',
contact_person: 'Test Contact',
})
expect(supabase.from).toHaveBeenNthCalledWith(2, 'company_settings')
})
it('rejects tampered staged fields at the commit boundary', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-settings-1' } })
enqueue({ data: null })
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
changes: {
company_id: 'other-company',
bankgiro: '5050-1055',
},
}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/unrecognized key/i)
expect(supabase.from).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PendingOperation } from '@/types'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { commitPendingOperation } from '../commit'
import { validateVatNumber } from '@/lib/vat/vies-client'
vi.mock('@/lib/vat/vies-client', () => ({
validateVatNumber: vi.fn(),
}))
const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111'
function makePendingOp(params: Record<string, unknown>): PendingOperation {
return {
id: 'op-customer-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'update_customer',
status: 'pending',
title: 'Update customer',
params,
preview_data: {},
result_data: null,
actor_type: 'api_key',
actor_id: 'key-1',
actor_label: 'Test key',
risk_level: 'low',
agent_metadata: null,
rejection_category: null,
rejection_reason: null,
created_at: '2026-07-21T00:00:00Z',
resolved_at: null,
updated_at: '2026-07-21T00:00:00Z',
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('commitPendingOperation: update_customer', () => {
it('updates the selected customer and returns the qualified id', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-customer-1' } })
enqueue({ data: { customer_type: 'swedish_business' } })
enqueue({
data: {
id: CUSTOMER_ID,
name: 'Test Customer AB',
customer_type: 'swedish_business',
customer_number: '1001',
email: 'billing@example.test',
phone: '0701234567',
address_line1: 'Testgatan 1',
address_line2: null,
postal_code: '12345',
city: 'New City',
country: 'Sweden',
org_number: '556000-0000',
vat_number: null,
vat_number_validated: false,
language: 'sv',
default_payment_terms: 14,
notes: null,
},
})
enqueue({ data: null })
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
customer_id: CUSTOMER_ID,
changes: { city: 'New City', default_payment_terms: 14 },
}),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
customer_id: CUSTOMER_ID,
city: 'New City',
default_payment_terms: 14,
})
expect(supabase.from).toHaveBeenNthCalledWith(2, 'customers')
expect(supabase.from).toHaveBeenNthCalledWith(3, 'customers')
})
it('auto-rejects when the customer no longer exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-customer-1' } })
enqueue({ data: null })
enqueue({ data: null })
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
customer_id: CUSTOMER_ID,
changes: { city: 'New City' },
}),
)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(404)
})
it('revalidates an updated EU VAT number at commit time', async () => {
vi.mocked(validateVatNumber).mockResolvedValueOnce({
valid: true,
name: 'Test Customer GmbH',
address: 'Teststrasse 1',
country_code: 'DE',
vat_number: 'DE123456789',
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-customer-1' } })
enqueue({ data: { customer_type: 'eu_business' } })
enqueue({
data: {
id: CUSTOMER_ID,
name: 'Test Customer GmbH',
customer_type: 'eu_business',
customer_number: null,
email: null,
phone: null,
address_line1: 'Teststrasse 1',
address_line2: null,
postal_code: '10115',
city: 'Berlin',
country: 'Germany',
org_number: null,
vat_number: 'DE123456789',
vat_number_validated: true,
language: 'en',
default_payment_terms: 30,
notes: null,
},
})
enqueue({ data: null })
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
customer_id: CUSTOMER_ID,
changes: { vat_number: 'DE123456789' },
}),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
customer_id: CUSTOMER_ID,
vat_number: 'DE123456789',
vat_number_validated: true,
})
expect(validateVatNumber).toHaveBeenCalledWith('DE123456789')
})
it('clears the personal number when an individual becomes a business', async () => {
const results = [
{ data: { id: 'op-customer-1' }, error: null },
{ data: { customer_type: 'individual' }, error: null },
{
data: {
id: CUSTOMER_ID,
name: 'Test Customer AB',
customer_type: 'swedish_business',
customer_number: null,
email: null,
phone: null,
address_line1: null,
address_line2: null,
postal_code: null,
city: null,
country: 'Sweden',
org_number: null,
vat_number: null,
vat_number_validated: false,
language: 'sv',
default_payment_terms: 30,
notes: null,
},
error: null,
},
{ data: null, error: null },
]
const customerUpdates: Record<string, unknown>[] = []
const supabase = {
from: vi.fn((table: string) => {
const result = results.shift() ?? { data: null, error: null }
const chain: Record<string, unknown> = new Proxy({}, {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (value: unknown) => void) => resolve(result)
}
if (prop === 'update') {
return (payload: Record<string, unknown>) => {
if (table === 'customers') customerUpdates.push(payload)
return chain
}
}
return () => chain
},
})
return chain
}),
}
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
customer_id: CUSTOMER_ID,
changes: { customer_type: 'swedish_business' },
}),
)
expect(result.status).toBe('committed')
expect(customerUpdates).toEqual([
expect.objectContaining({
customer_type: 'swedish_business',
personal_number: null,
}),
])
})
it('rejects tampered fields before reading the customer', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-customer-1' } })
enqueue({ data: null })
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
customer_id: CUSTOMER_ID,
changes: { company_id: 'other-company', city: 'New City' },
}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/unrecognized key/i)
expect(supabase.from).toHaveBeenCalledTimes(2)
})
})
+156
View File
@@ -81,6 +81,8 @@ import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schem
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
import { CreateAccountParamsSchema, UpdateAccountParamsSchema } from '@/lib/pending-operations/schemas/account'
import { SetVoucherNoteParamsSchema } from '@/lib/pending-operations/schemas/voucher-note'
import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings'
import { UpdateCustomerParamsSchema } from '@/lib/pending-operations/schemas/customer'
import { BulkBookInboxSchema } from '@/lib/api/schemas'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
@@ -279,6 +281,154 @@ async function commitCreateCustomer(
return { data: { customer_id: data.id } }
}
async function commitUpdateCustomer(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>,
): Promise<ExecutorResult> {
let validated
try {
validated = UpdateCustomerParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return {
error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`,
status: 400,
}
}
throw err
}
const { customer_id: customerId, changes } = validated
const { data: current, error: currentError } = await supabase
.from('customers')
.select('customer_type')
.eq('id', customerId)
.eq('company_id', companyId)
.maybeSingle()
if (currentError) return { error: currentError.message, status: 500 }
if (!current) return { error: 'Customer not found', status: 404 }
const updateData: Record<string, unknown> = { ...changes }
if (changes.customer_number !== undefined) {
updateData.customer_number = changes.customer_number || null
}
const effectiveType = changes.customer_type ?? current.customer_type
if (changes.customer_type !== undefined && effectiveType !== 'individual') {
updateData.personal_number = null
}
if (changes.vat_number !== undefined) {
if (effectiveType === 'eu_business') {
if (changes.vat_number) {
try {
const vatResult = await validateVatNumber(changes.vat_number)
updateData.vat_number_validated = vatResult.valid
updateData.vat_number_validated_at = vatResult.valid
? new Date().toISOString()
: null
} catch (err) {
log.warn('Auto-VIES validation failed on staged customer update:', err)
updateData.vat_number_validated = false
updateData.vat_number_validated_at = null
}
} else {
updateData.vat_number_validated = false
updateData.vat_number_validated_at = null
}
}
}
const { data, error } = await supabase
.from('customers')
.update(updateData)
.eq('id', customerId)
.eq('company_id', companyId)
.select('id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, language, default_payment_terms, notes')
.maybeSingle()
if (error) {
if (error.code === '23505') {
return { error: 'A customer with this organization number already exists', status: 409 }
}
return { error: error.message, status: 500 }
}
if (!data) return { error: 'Customer not found', status: 404 }
return {
data: {
customer_id: data.id,
name: data.name,
customer_type: data.customer_type,
customer_number: data.customer_number ?? null,
email: data.email ?? null,
phone: data.phone ?? null,
address_line1: data.address_line1 ?? null,
address_line2: data.address_line2 ?? null,
postal_code: data.postal_code ?? null,
city: data.city ?? null,
country: data.country,
org_number: data.org_number ?? null,
vat_number: data.vat_number ?? null,
vat_number_validated: data.vat_number_validated ?? false,
language: data.language ?? 'sv',
default_payment_terms: data.default_payment_terms,
notes: data.notes ?? null,
},
}
}
async function commitUpdateCompanySettings(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>,
): Promise<ExecutorResult> {
let validated
try {
validated = UpdateCompanySettingsParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return {
error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`,
status: 400,
}
}
throw err
}
const { data, error } = await supabase
.from('company_settings')
.update(validated.changes)
.eq('company_id', companyId)
.select('bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, default_our_reference')
.single()
if (error) {
if (error.code === 'PGRST116') {
return { error: 'Company settings not found', status: 404 }
}
return { error: error.message, status: 500 }
}
return {
data: {
company_id: companyId,
bank_name: data.bank_name ?? null,
clearing_number: data.clearing_number ?? null,
account_number: data.account_number ?? null,
bankgiro: data.bankgiro ?? null,
plusgiro: data.plusgiro ?? null,
swish: data.swish ?? null,
iban: data.iban ?? null,
bic: data.bic ?? null,
contact_person: data.default_our_reference ?? null,
},
}
}
async function commitCreateArticle(
supabase: SupabaseClient,
userId: string,
@@ -4292,6 +4442,12 @@ async function commitPendingOperationInner(
case 'create_customer':
result = await commitCreateCustomer(supabase, userId, companyId, pendingOp.params)
break
case 'update_customer':
result = await commitUpdateCustomer(supabase, companyId, pendingOp.params)
break
case 'update_company_settings':
result = await commitUpdateCompanySettings(supabase, companyId, pendingOp.params)
break
case 'create_article':
result = await commitCreateArticle(supabase, userId, companyId, pendingOp.params)
break
+5
View File
@@ -21,6 +21,7 @@ export type RiskLevel = 'low' | 'medium' | 'high'
export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// ── Low: pure data, no booking impact ─────────────────────────────
create_customer: 'low',
update_customer: 'low',
// Article catalog (artikelregister) is app-level master data: no journal
// impact, no external side-effect. Unlike create_supplier it carries no
// payment-routing fields, so there's no BEC/fraud surface; both create and
@@ -66,6 +67,10 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// (silently rerouting payment), so always require explicit human approval
// rather than auto-commit.
create_supplier: 'medium',
// Company payment settings control where customers send money on future
// invoices. Treat changes like supplier payment-routing data: reversible,
// but never eligible for silent low-risk auto-commit.
update_company_settings: 'medium',
// Pinning a doc to a tx is reversible while pre-categorization, but the link
// becomes part of the verifikation underlag (BFL 5 kap 6 §) once categorize
// propagates it. A wrong attachment requires a rättelse, so require human
@@ -0,0 +1,54 @@
import { z } from 'zod'
import { UpdateSettingsSchema } from '@/lib/api/schemas'
import {
validateBankgiroNumber,
validatePlusgiroNumber,
} from '@/lib/bankgiro/luhn'
const CompanySettingsChangesSchema = z
.object({
bank_name: UpdateSettingsSchema.shape.bank_name,
clearing_number: UpdateSettingsSchema.shape.clearing_number,
account_number: UpdateSettingsSchema.shape.account_number,
bankgiro: UpdateSettingsSchema.shape.bankgiro,
plusgiro: UpdateSettingsSchema.shape.plusgiro,
swish: UpdateSettingsSchema.shape.swish,
iban: UpdateSettingsSchema.shape.iban,
bic: UpdateSettingsSchema.shape.bic,
default_our_reference: UpdateSettingsSchema.shape.default_our_reference,
})
.strict()
.superRefine((changes, ctx) => {
if (Object.keys(changes).length === 0) {
ctx.addIssue({
code: 'custom',
message: 'At least one company setting must be supplied',
})
}
if (changes.bankgiro && !validateBankgiroNumber(changes.bankgiro)) {
ctx.addIssue({
code: 'custom',
path: ['bankgiro'],
message: 'Invalid Bankgiro number',
})
}
if (changes.plusgiro && !validatePlusgiroNumber(changes.plusgiro)) {
ctx.addIssue({
code: 'custom',
path: ['plusgiro'],
message: 'Invalid Plusgiro number',
})
}
})
export const UpdateCompanySettingsParamsSchema = z
.object({
changes: CompanySettingsChangesSchema,
})
.strict()
export type UpdateCompanySettingsParams = z.infer<
typeof UpdateCompanySettingsParamsSchema
>
@@ -0,0 +1,39 @@
import { z } from 'zod'
import { UpdateCustomerSchema } from '@/lib/api/schemas'
const CustomerChangesSchema = z
.object({
name: UpdateCustomerSchema.shape.name,
customer_type: UpdateCustomerSchema.shape.customer_type,
customer_number: UpdateCustomerSchema.shape.customer_number,
email: UpdateCustomerSchema.shape.email,
phone: UpdateCustomerSchema.shape.phone,
address_line1: UpdateCustomerSchema.shape.address_line1,
address_line2: UpdateCustomerSchema.shape.address_line2,
postal_code: UpdateCustomerSchema.shape.postal_code,
city: UpdateCustomerSchema.shape.city,
country: UpdateCustomerSchema.shape.country,
org_number: UpdateCustomerSchema.shape.org_number,
vat_number: UpdateCustomerSchema.shape.vat_number,
language: UpdateCustomerSchema.shape.language,
default_payment_terms: UpdateCustomerSchema.shape.default_payment_terms,
notes: UpdateCustomerSchema.shape.notes,
})
.strict()
.superRefine((changes, ctx) => {
if (Object.keys(changes).length === 0) {
ctx.addIssue({
code: 'custom',
message: 'At least one customer field must be supplied',
})
}
})
export const UpdateCustomerParamsSchema = z
.object({
customer_id: z.string().uuid(),
changes: CustomerChangesSchema,
})
.strict()
export type UpdateCustomerParams = z.infer<typeof UpdateCustomerParamsSchema>
+21 -4
View File
@@ -846,7 +846,13 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
{ name: 'rot_rut_payout_request_items', file: 'rot_rut_payout_request_items.json', via: { parent: 'rot_rut_payout_requests', fk: 'request_id' } },
{ name: 'fiscal_period_tax_adjustments', file: 'fiscal_period_tax_adjustments.json', orderBy: 'created_at' },
{ name: 'tax_assessment_notices', file: 'tax_assessment_notices.json', orderBy: 'created_at' },
{ name: 'arsredovisning_narratives', file: 'arsredovisning_narratives.json' },
{ name: 'annual_report_profiles', file: 'annual_report_profiles.json', orderBy: 'created_at' },
{ name: 'annual_report_versions', file: 'annual_report_versions.json', orderBy: 'created_at' },
{ name: 'annual_report_validation_runs', file: 'annual_report_validation_runs.json', orderBy: 'created_at' },
{ name: 'arsredovisning_signature_requests', file: 'arsredovisning_signature_requests.json', orderBy: 'created_at' },
{ name: 'arsredovisning_submissions', file: 'arsredovisning_submissions.json' },
// Settings
{ name: 'company_settings', file: 'company_settings.json' },
@@ -877,7 +883,6 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
agent_memory: 'AI assistant state, not räkenskapsinformation',
agent_profiles: 'AI assistant state, not räkenskapsinformation',
api_keys: 'secrets',
arsredovisning_signature_requests: 'signing workflow state',
bank_connections: 'PSD2 connection state and tokens, not portable',
bolagsverket_avtal_acceptances: 'service agreement acceptance state',
bolagsverket_subscriptions: 'integration subscription state',
@@ -1131,9 +1136,14 @@ async function fetchAllAuditEntries(
const pageSize = 500
while (true) {
const result = await getAuditLog(supabase, companyId, { ...filters, page, pageSize })
const result = await getAuditLog(supabase, companyId, {
...filters,
page,
pageSize,
includeCount: false,
})
all.push(...result.data)
if (all.length >= result.count || result.data.length < pageSize) {
if (result.data.length < pageSize) {
break
}
page++
@@ -1189,11 +1199,18 @@ async function buildSystemDoc(
rls_aktiv: true,
},
arkivering: {
lagringstid_ar: 7,
lagringsregel: 'Till och med utgången av det sjunde kalenderåret efter det kalenderår då räkenskapsåret avslutades',
gallring_tidigare_an: '1 januari det åttonde efterföljande kalenderåret',
format: 'WORM (Write Once, Read Many)',
integritetskontroll: 'SHA-256 hashning vid uppladdning, regelbunden verifiering',
lagringsplats: 'Supabase Storage (krypterad)',
},
arsredovisning: {
versionering: 'Låsta versioner är oföränderliga och SHA-256-hashade',
kontrollunderlag: 'Regelverksprofil, upplysningsbekräftelser och valideringsresultat sparas med versionen',
underskrifter: 'Undertecknarlista, metod, datum och bevisreferens binds till exakt version',
inlamning: 'Exakt skickad iXBRL-fil och Bolagsverkets kvittens arkiveras före och efter överföring',
},
integrationer: {
bank: 'Enable Banking (PSD2)',
email: 'Resend',
@@ -49,6 +49,8 @@ function makeDeclaration(overrides?: Partial<INK2Declaration>): INK2Declaration
'7650': 302000,
'7750': 0,
'7651': 0,
'7653': 0,
'7754': 0,
'8020': 302000,
'8021': 0,
},
@@ -248,6 +250,22 @@ describe('INK2 SRU Generator', () => {
expect(ink2sBlock).not.toContain('#UPPGIFT 8021')
})
it('includes saved non-deductible and non-taxable adjustments in INK2S', () => {
const base = makeDeclaration()
const declaration = makeDeclaration({
ink2s: {
...base.ink2s,
'7653': 5_244,
'7754': 1_000,
},
})
const submission = generateSRUSubmission(declaration)
const ink2sBlock = extractBlock(submission.blanketterSru, 'INK2S')
expect(ink2sBlock).toContain('#UPPGIFT 7653 5244')
expect(ink2sBlock).toContain('#UPPGIFT 7754 1000')
})
it('INK2 block includes överskott', () => {
const declaration = makeDeclaration()
const submission = generateSRUSubmission(declaration)
+18 -2
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { loadTaxAdjustmentSnapshot } from '@/lib/bokslut/tax-provision/tax-adjustment-service'
import type {
FiscalPeriod,
JournalEntry,
@@ -727,6 +728,12 @@ export async function generateINK2Declaration(
throw new Error('INK2 declaration is only for aktiebolag (limited company)')
}
const taxAdjustments = await loadTaxAdjustmentSnapshot(
supabase,
companyId,
fiscalPeriodId,
)
// Fetch all posted journal entries with lines for this period.
// Paginated: a period can exceed PostgREST's 1000-row cap, and a silent
// truncation here would under-report the INK2 tax declaration. PostgREST
@@ -890,10 +897,17 @@ export async function generateINK2Declaration(
const fyEnd = (period.period_end as string).replace(/-/g, '')
// Build INK2 (huvudblankett)
// Auto-derive from INK2S result (simplified: result + non-deductible tax)
// Auto-derive from INK2S result and the saved tax-only adjustments.
// 7528 is already positive per Skatteverket convention
const taxAmount = ink2r['7528']
const taxableResult = resultAfterFinancial + taxAmount
// INK2/SRU amounts are declared in whole kronor with ören omitted. Use the
// same whole-krona values in both the adjustment fields and the tax result
// so the worksheet remains internally consistent.
const nonDeductibleExpenses = Math.trunc(taxAdjustments.nonDeductibleExpenses)
const nonTaxableIncome = Math.trunc(taxAdjustments.nonTaxableIncome)
const taxableResult =
resultAfterFinancial + taxAmount
+ nonDeductibleExpenses - nonTaxableIncome
const ink2: INK2Rutor = {
'7011': fyStart,
@@ -909,6 +923,8 @@ export async function generateINK2Declaration(
'7650': resultAfterFinancial >= 0 ? resultAfterFinancial : 0,
'7750': resultAfterFinancial < 0 ? Math.abs(resultAfterFinancial) : 0,
'7651': taxAmount, // Skatt (ej avdragsgill)
'7653': nonDeductibleExpenses,
'7754': nonTaxableIncome,
'8020': taxableResult >= 0 ? taxableResult : 0,
'8021': taxableResult < 0 ? Math.abs(taxableResult) : 0,
}
+9 -1
View File
@@ -200,7 +200,15 @@ function generateBlanketterSru(declaration: INK2Declaration, now: Date): string
lines.push(`#UPPGIFT 7012 ${declaration.ink2s['7012']}`)
// INK2S numeric fields: emit non-zero values only
const ink2sNumericFields: (keyof INK2SRutor)[] = ['7650', '7750', '7651', '8020', '8021']
const ink2sNumericFields: (keyof INK2SRutor)[] = [
'7650',
'7750',
'7651',
'7653',
'7754',
'8020',
'8021',
]
for (const code of ink2sNumericFields) {
const value = declaration.ink2s[code]
if (typeof value === 'number' && value !== 0) {
+3 -1
View File
@@ -101,7 +101,7 @@ export interface INK2Rutor {
/**
* INK2S: Skattemässiga justeringar (page 4)
* Auto-derived fields only. Manual tax adjustments are handled by the accountant.
* Auto-derived fields plus the tax adjustments saved in the year-end flow.
*/
export interface INK2SRutor {
'7011': string // Räkenskapsår fr.o.m. (YYYYMMDD)
@@ -109,6 +109,8 @@ export interface INK2SRutor {
'7650': number // 4.1 Årets resultat, vinst
'7750': number // 4.2 Årets resultat, förlust
'7651': number // 4.3a Skatt på årets resultat (ej avdragsgill)
'7653': number // 4.3c Andra ej avdragsgilla kostnader
'7754': number // 4.5c Andra ej skattepliktiga intäkter
'8020': number // 4.15 Överskott → punkt 1.1
'8021': number // 4.16 Underskott → punkt 1.2
}
+24
View File
@@ -30,6 +30,7 @@ function makeSettings(overrides: Partial<CompanySettingsForDeadlines> = {}): Com
intrastat_enabled: false,
punktskatt_enabled: false,
fyllnadsinbetalning_enabled: false,
tax_assessment_notices: [],
...overrides,
}
}
@@ -276,6 +277,29 @@ describe('rot_rut_begaran: 31 January after the payment year (Lag 2009:194 8 §)
})
describe('long-tail opt-in deadlines', () => {
it('kvarskatt copies the exact notice date and never applies a banking-day shift', () => {
const config = getConfig('kvarskatt')
const settings = makeSettings({
tax_assessment_notices: [{
id: 'notice-1',
fiscalPeriodName: '2029',
decisionType: 'final',
paymentDueDate: '2030-03-31',
}],
})
expect(config.condition(settings)).toBe(true)
expect(config.skipBankingDayAdjustment).toBe(true)
expect(config.generateDates(2030, settings)).toEqual([{
day: 31,
month: 2,
year: 2030,
period: 'notice:notice-1',
periodLabel: 'slutskattebesked, 2029',
taxAssessmentNoticeId: 'notice-1',
}])
})
it('OSS: quarterly, last day of the month after the quarter, opt-in, no banking-day shift', () => {
const config = getConfig('oss_quarterly')
expect(config.condition(makeSettings())).toBe(false)
@@ -33,6 +33,7 @@ const SETTINGS: CompanySettingsForDeadlines = {
intrastat_enabled: false,
punktskatt_enabled: false,
fyllnadsinbetalning_enabled: false,
tax_assessment_notices: [],
}
// Current + next year (the generator's own default): with the rolling
@@ -230,6 +231,34 @@ describe('generateTaxDeadlinesForUser', () => {
)
expect(other?.status).not.toBe('in_progress')
})
it('links a kvarskatt deadline to the notice that supplied its exact date', async () => {
const due = new Date()
due.setDate(due.getDate() + 30)
const paymentDueDate = [
due.getFullYear(),
String(due.getMonth() + 1).padStart(2, '0'),
String(due.getDate()).padStart(2, '0'),
].join('-')
const { supabase, getInsertPayload } = makeRecordingSupabase()
await generateTaxDeadlinesForUser(supabase, 'company-1', {
...SETTINGS,
tax_assessment_notices: [{
id: 'notice-1',
fiscalPeriodName: '2025',
decisionType: 'reassessment',
paymentDueDate,
}],
}, GEN_YEARS)
expect(getInsertPayload()).toContainEqual(expect.objectContaining({
tax_deadline_type: 'kvarskatt',
tax_period: 'notice:notice-1',
due_date: paymentDueDate,
tax_assessment_notice_id: 'notice-1',
}))
})
})
describe('getExpectedUpcomingDeadlineKeys: banking-day handling', () => {
@@ -253,6 +282,21 @@ describe('getExpectedUpcomingDeadlineKeys: banking-day handling', () => {
expect(keys.has('f_skatt:2030-05:2030-05-13')).toBe(true)
expect(keys.has('f_skatt:2030-05:2030-05-12')).toBe(false)
})
it('keeps an exact kvarskatt notice date on a Sunday', () => {
const keys = getExpectedUpcomingDeadlineKeys({
...SETTINGS,
tax_assessment_notices: [{
id: 'notice-1',
fiscalPeriodName: '2029',
decisionType: 'final',
paymentDueDate: '2030-03-31',
}],
}, [2030], new Date(2030, 0, 1))
expect(keys.has('kvarskatt:notice:notice-1:2030-03-31')).toBe(true)
expect(keys.has('kvarskatt:notice:notice-1:2030-04-01')).toBe(false)
})
})
describe('findSettingsMissingUpcomingDeadlines', () => {
+35
View File
@@ -9,6 +9,13 @@ import { isBankingDay } from './swedish-holidays'
// Condition function type for determining if a deadline applies
export type DeadlineCondition = (settings: CompanySettingsForDeadlines) => boolean
export interface TaxAssessmentNoticeForDeadline {
id: string
fiscalPeriodName: string
decisionType: 'final' | 'reassessment'
paymentDueDate: string
}
// Subset of company settings needed for deadline generation
export interface CompanySettingsForDeadlines {
entity_type: EntityType
@@ -43,6 +50,8 @@ export interface CompanySettingsForDeadlines {
* detection), where rot_rut_begaran rows are simply never expected.
*/
rot_rut_payment_years?: number[]
/** Derived from active tax_assessment_notices rows by the generator. */
tax_assessment_notices?: TaxAssessmentNoticeForDeadline[]
}
// Configuration for a single tax deadline type
@@ -71,6 +80,7 @@ export interface DeadlineInstance {
year: number
period: string // e.g., "2025-Q1", "2025-01", "2025"
periodLabel: string // Human-readable, e.g., "Q1 2025", "januari 2025"
taxAssessmentNoticeId?: string
}
/**
@@ -524,6 +534,31 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
},
},
// Kvarskatt: the payment date is copied exactly from the final tax notice
// or reassessment decision. It must not be estimated or moved to a banking
// day because Skatteverket has already determined the statutory due date.
{
type: 'kvarskatt',
titleTemplate: 'Kvarskatt {periodLabel}',
description: 'Kvarskatt enligt slutskattebesked eller omprövningsbeslut',
condition: (s) => (s.tax_assessment_notices?.length ?? 0) > 0,
priority: 'critical',
linkedReportType: null,
skipBankingDayAdjustment: true,
generateDates: (year, settings) => (settings.tax_assessment_notices ?? [])
.filter((notice) => Number(notice.paymentDueDate.slice(0, 4)) === year)
.map((notice) => ({
day: Number(notice.paymentDueDate.slice(8, 10)),
month: Number(notice.paymentDueDate.slice(5, 7)) - 1,
year,
period: `notice:${notice.id}`,
periodLabel: notice.decisionType === 'reassessment'
? `omprövning, ${notice.fiscalPeriodName}`
: `slutskattebesked, ${notice.fiscalPeriodName}`,
taxAssessmentNoticeId: notice.id,
})),
},
// Kontrolluppgifter (KU10/KU20/KU31): annual income statements to
// Skatteverket, due 31 January after the income year (SFL 24 kap. 1 §).
// KU31 (utdelning) is never covered by the monthly AGI, so a fåmansbolag
+80 -5
View File
@@ -12,6 +12,7 @@ import {
getApplicableDeadlineConfigs,
type CompanySettingsForDeadlines,
type DeadlineInstance,
type TaxAssessmentNoticeForDeadline,
} from './deadline-config'
import { adjustDeadlineToNextBankingDay } from './swedish-holidays'
@@ -136,9 +137,64 @@ export function toDeadlineSettings(
intrastat_enabled: settings.intrastat_enabled ?? false,
punktskatt_enabled: settings.punktskatt_enabled ?? false,
fyllnadsinbetalning_enabled: settings.fyllnadsinbetalning_enabled ?? false,
tax_assessment_notices: settings.tax_assessment_notices,
}
}
interface TaxAssessmentNoticeRow {
id: string
company_id: string
decision_type: 'final' | 'reassessment'
payment_due_date: string
fiscal_periods: { name: string } | Array<{ name: string }> | null
}
async function fetchActiveTaxAssessmentNotices(
supabase: SupabaseClient,
companyId?: string,
): Promise<TaxAssessmentNoticeRow[]> {
return fetchAllRows<TaxAssessmentNoticeRow>(({ from, to }) => {
let query = supabase
.from('tax_assessment_notices')
.select('id, company_id, decision_type, payment_due_date, fiscal_periods(name)')
.is('archived_at', null)
.order('id', { ascending: true })
.range(from, to)
if (companyId) query = query.eq('company_id', companyId)
return query
})
}
function toDeadlineNotice(row: TaxAssessmentNoticeRow): TaxAssessmentNoticeForDeadline {
const fiscalPeriod = Array.isArray(row.fiscal_periods)
? row.fiscal_periods[0]
: row.fiscal_periods
return {
id: row.id,
fiscalPeriodName: fiscalPeriod?.name ?? '',
decisionType: row.decision_type,
paymentDueDate: row.payment_due_date,
}
}
async function hydrateTaxAssessmentNotices(
supabase: SupabaseClient,
settingsRows: DeadlineSettingsRow[],
): Promise<DeadlineSettingsRow[]> {
const notices = await fetchActiveTaxAssessmentNotices(supabase)
const byCompany = new Map<string, TaxAssessmentNoticeForDeadline[]>()
for (const notice of notices) {
const current = byCompany.get(notice.company_id) ?? []
current.push(toDeadlineNotice(notice))
byCompany.set(notice.company_id, current)
}
return settingsRows.map((settings) => ({
...settings,
tax_assessment_notices: byCompany.get(settings.company_id) ?? [],
}))
}
/**
* Decide whether a settings save should (re)generate tax deadlines.
*
@@ -175,10 +231,23 @@ export async function generateTaxDeadlinesForUser(
settings: CompanySettingsForDeadlines,
years: number[] = []
): Promise<{ created: number; deleted: number }> {
// Default to current and next year if not specified
if (settings.tax_assessment_notices === undefined) {
const notices = await fetchActiveTaxAssessmentNotices(supabase, companyId)
settings = {
...settings,
tax_assessment_notices: notices.map(toDeadlineNotice),
}
}
// Recurring deadlines use the current rolling window. Explicit tax notices
// also include their own due-date years so a newly entered overdue notice is
// represented instead of disappearing only because its exact date has passed.
if (years.length === 0) {
const currentYear = new Date().getFullYear()
years = [currentYear, currentYear + 1]
const noticeYears = (settings.tax_assessment_notices ?? [])
.map((notice) => Number(notice.paymentDueDate.slice(0, 4)))
.filter(Number.isInteger)
years = Array.from(new Set([currentYear, currentYear + 1, ...noticeYears]))
}
// The ROT/RUT begäran deadline is data-dependent: a row for year Y only
@@ -289,6 +358,7 @@ export async function generateTaxDeadlinesForUser(
linked_report_period: Record<string, unknown> | null
reminder_offsets: number[]
is_auto_generated: boolean
tax_assessment_notice_id: string | null
}> = []
for (const config of applicableConfigs) {
@@ -308,7 +378,7 @@ export async function generateTaxDeadlinesForUser(
const dueDate = formatDateISO(adjustedDate)
// Skip if the deadline is in the past
if (adjustedDate < today) {
if (adjustedDate < today && !instance.taxAssessmentNoticeId) {
continue
}
@@ -351,6 +421,7 @@ export async function generateTaxDeadlinesForUser(
linked_report_period: linkedReportPeriod,
reminder_offsets: [14, 7, 1, 0],
is_auto_generated: true,
tax_assessment_notice_id: instance.taxAssessmentNoticeId ?? null,
})
}
}
@@ -598,7 +669,10 @@ export async function generateNewYearDeadlines(
supabase: SupabaseClient
): Promise<{ usersProcessed: number; totalCreated: number }> {
const newYear = new Date().getFullYear()
const allSettings = await fetchAllDeadlineSettings(supabase)
const allSettings = await hydrateTaxAssessmentNotices(
supabase,
await fetchAllDeadlineSettings(supabase),
)
let usersProcessed = 0
let totalCreated = 0
@@ -634,7 +708,7 @@ export async function backfillMissingTaxDeadlines(
// refuses to recreate a filed obligation. Matches the generator's own
// completed-row floor.
const pastFloor = `${new Date().getFullYear() - 1}-01-01`
const [allSettings, upcomingDeadlineRows] = await Promise.all([
const [rawSettings, upcomingDeadlineRows] = await Promise.all([
fetchAllDeadlineSettings(supabase),
fetchAllRows<UpcomingDeadlineCompanyRow>(({ from, to }) =>
supabase
@@ -647,6 +721,7 @@ export async function backfillMissingTaxDeadlines(
.range(from, to),
),
])
const allSettings = await hydrateTaxAssessmentNotices(supabase, rawSettings)
const missingSettings = findSettingsMissingUpcomingDeadlines(allSettings, upcomingDeadlineRows)
let companiesRepaired = 0