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:
+47
-1
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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.',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user