Add/bokslut (#718)

* feat(arcim-migration): Briox provider with SIE-over-API import

- Briox auth via account ID + application token (no app-level
  credentials); both tokens rotate on refresh and are persisted
- New sie-fetcher pulls the general ledger as SIE through the
  provider API for Fortnox, Briox and Bjorn Lunden
- Wizard stops on a failed SIE import and surfaces the real errors
  instead of proceeding to the misleading migrate-guard message
- PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED;
  new PROVIDER_TOKEN_INVALID for rejected provider credentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices

Defer revenue/costs per invoice line to 29xx/17xx interim accounts with
automatic monthly dissolution (nightly cron + catch-up at registration),
schedule cancellation on credit, year-end auto-detect exclusion for
already-scheduled invoices, invoice-inbox service-period extraction for
prefill, and an MCP tool to list schedules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing

Generate the annual report as iXBRL from a generated taxonomy registry
(K2 element lists, taxonomy:generate/check scripts + CI guard), expose it
via the fiscal-period API, and add the bolagsverket extension for digital
submission to eget utrymme with webhook-driven status tracking
(submissions table + pg tests, lifecycle events, year-end wizard UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): raise origin-guard test timeout to 20s

The dynamic import pulls in the full server module; the parse alone
flirts with the 5s default under full-suite parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add new scripts and documentation for K2 AB taxonomy generation and validation

- Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models.
- Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle.
- Included new documentation files:
  - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx`
  - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx`
  - `taxonomi-paket-2024-09-12_rev20250312.zip`

* Add tests for bookkeeping accruals dissolution and supplier invoices

- Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios.
- Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions.
- Introduce tests for the Arcim migration provider client, ensuring token handling and error classification.
- Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings.
- Add Zod schemas for Bolagsverket response payloads to ensure proper validation.
- Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping.
- Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly.
- Introduce typed domain errors for accrual schedules to improve error handling in the service.
- Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling.

* fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments

* fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated

* feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id

* feat(bokslut): enhance compliance and financial processing features with new submission details and security measures

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-12 16:35:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8e8b63a200
commit db8983ba9e
131 changed files with 33796 additions and 340 deletions
+109 -1
View File
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
import { isSaneDateString } from '@/lib/utils'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
// ============================================================
// Shared primitives
@@ -39,6 +40,61 @@ const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.lit
/** Time string (HH:MM or HH:MM:SS) */
const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or HH:MM:SS time format')
/** Periodisering: interim accounts. Förutbetalda kostnader live on 17xx. */
const prepaidExpenseAccount = z
.string()
.regex(/^17\d{2}$/, 'Balanskonto för periodiserad kostnad måste vara ett 17xx-konto')
/** Periodisering: förutbetalda intäkter live on 29xx. */
const deferredRevenueAccount = z
.string()
.regex(/^29\d{2}$/, 'Balanskonto för periodiserad intäkt måste vara ett 29xx-konto')
/**
* Shared periodisering period rules for invoice line items: both dates or
* neither, end after start, and a 2–120 calendar month span. The amount-side
* rules differ per item shape and stay in each schema's superRefine.
*/
function validateAccrualPeriod(
item: { accrual_period_start?: string | null; accrual_period_end?: string | null },
ctx: z.RefinementCtx,
): void {
const start = item.accrual_period_start
const end = item.accrual_period_end
if (!start && !end) return
if (!start || !end) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
message: 'Ange både periodens start och slut för periodisering',
})
return
}
if (end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_end'],
message: 'Periodens slut måste vara efter dess start',
})
return
}
const months = countCalendarMonths(start, end)
if (months < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_end'],
message: 'Periodisering kräver minst 2 kalendermånader',
})
}
if (months > 120) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_end'],
message: 'Periodisering kan omfatta högst 120 månader',
})
}
}
// ============================================================
// Enum schemas (matching types/index.ts)
// ============================================================
@@ -145,6 +201,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
'supplier_credit_note',
'currency_revaluation',
'reminder_fee',
'accrual',
])
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
@@ -225,8 +282,39 @@ export const CreateInvoiceItemSchema = z
work_type: z.string().max(64).nullable().optional(),
housing_designation: z.string().max(128).nullable().optional(),
apartment_number: z.string().max(32).nullable().optional(),
// Periodisering (förutbetald intäkt): defer the line's net revenue over
// the service period. The revenue entry credits the 29xx interim account
// instead of the revenue account; output VAT is never deferred.
accrual_period_start: isoDate.nullable().optional(),
accrual_period_end: isoDate.nullable().optional(),
accrual_balance_account: deferredRevenueAccount.nullable().optional(),
})
.superRefine((item, ctx) => {
validateAccrualPeriod(item, ctx)
const hasAccrual = Boolean(item.accrual_period_start || item.accrual_period_end)
if (hasAccrual) {
if (item.line_type === 'text') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
message: 'Textrader kan inte periodiseras',
})
}
if (item.deduction_type) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
message: 'ROT/RUT-rader kan inte periodiseras',
})
}
if (item.quantity * item.unit_price <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
message: 'Endast rader med positivt belopp kan periodiseras',
})
}
}
// Free-text rows skip the product-line requirements (description may be
// empty for a spacer; quantity/unit/price are ignored).
if (item.line_type === 'text') return
@@ -485,6 +573,12 @@ export const CreateSupplierInvoiceItemSchema = z.object({
quantity: z.number().optional(),
unit: z.string().optional(),
unit_price: z.number().optional(),
// Periodisering (förutbetald kostnad): defer the line's net cost over the
// service period. The registration entry debits the 17xx interim account
// instead of account_number; input VAT is never deferred.
accrual_period_start: isoDate.nullable().optional(),
accrual_period_end: isoDate.nullable().optional(),
accrual_balance_account: prepaidExpenseAccount.nullable().optional(),
}).refine(
(item) => {
if (item.vat_amount == null) return true
@@ -501,7 +595,21 @@ export const CreateSupplierInvoiceItemSchema = z.object({
message: 'vat_amount cannot exceed line_total × vat_rate',
path: ['vat_amount'],
},
)
).superRefine((item, ctx) => {
validateAccrualPeriod(item, ctx)
if (item.accrual_period_start || item.accrual_period_end) {
const lineTotal = item.amount != null
? item.amount
: (item.quantity ?? 1) * (item.unit_price ?? 0)
if (lineTotal <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
message: 'Endast rader med positivt belopp kan periodiseras',
})
}
}
})
export const CreateSupplierInvoiceSchema = z.object({
supplier_id: uuid,