* 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>
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type {
|
|
CreateJournalEntryInput,
|
|
Invoice,
|
|
InvoiceItem,
|
|
SupplierInvoice,
|
|
SupplierInvoiceItem,
|
|
} from '@/types'
|
|
import { makeSupplierInvoice } from '@/tests/helpers'
|
|
import { roundOre } from '@/lib/money'
|
|
|
|
// Periodisering booking behaviour: lines with an accrual period book their
|
|
// net to the 17xx/29xx interim account instead of the P&L account, while
|
|
// VAT and AR/AP lines stay untouched.
|
|
|
|
vi.mock('../engine', () => ({
|
|
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
|
|
createJournalEntry: vi.fn().mockImplementation(
|
|
async (_supabase: unknown, _companyId: string, _userId: string, input: CreateJournalEntryInput) => ({
|
|
id: 'entry-1',
|
|
...input,
|
|
lines: input.lines,
|
|
})
|
|
),
|
|
}))
|
|
|
|
vi.mock('../vat-entries', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../vat-entries')>()
|
|
return {
|
|
...actual,
|
|
generateSalesVatLines: vi.fn().mockImplementation(
|
|
({ vatTreatment, baseAmount }: { vatTreatment: string; baseAmount: number }) => {
|
|
const rate = vatTreatment === 'standard_25' ? 0.25
|
|
: vatTreatment === 'reduced_12' ? 0.12
|
|
: vatTreatment === 'reduced_6' ? 0.06 : 0
|
|
if (rate === 0) return []
|
|
const account = vatTreatment === 'standard_25' ? '2611'
|
|
: vatTreatment === 'reduced_12' ? '2621' : '2631'
|
|
return [{
|
|
account_number: account,
|
|
debit_amount: 0,
|
|
credit_amount: roundOre(baseAmount * rate),
|
|
line_description: 'Utgående moms',
|
|
}]
|
|
},
|
|
),
|
|
}
|
|
})
|
|
|
|
const { createJournalEntry } = await import('../engine')
|
|
const mockedCreateEntry = vi.mocked(createJournalEntry)
|
|
|
|
const { createSupplierInvoiceRegistrationEntry, createSupplierCreditNoteEntry } =
|
|
await import('../supplier-invoice-entries')
|
|
const { createInvoiceJournalEntry, createCreditNoteJournalEntry } =
|
|
await import('../invoice-entries')
|
|
|
|
function makeSupplierItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoiceItem {
|
|
const lineTotal = overrides.line_total ?? 12000
|
|
const vatRate = overrides.vat_rate ?? 0.25
|
|
return {
|
|
id: 'si-item-1',
|
|
supplier_invoice_id: 'si-1',
|
|
sort_order: 0,
|
|
description: 'Företagsförsäkring 2026',
|
|
quantity: 1,
|
|
unit: 'st',
|
|
unit_price: lineTotal,
|
|
line_total: lineTotal,
|
|
account_number: '6310',
|
|
vat_code: null,
|
|
vat_rate: vatRate,
|
|
vat_amount: overrides.vat_amount ?? roundOre(lineTotal * vatRate),
|
|
reverse_charge_rate: null,
|
|
created_at: '2026-01-15T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeCustomerInvoice(overrides: Partial<Invoice> & { items?: InvoiceItem[] }): Invoice {
|
|
return {
|
|
id: 'inv-1',
|
|
user_id: 'user-1',
|
|
customer_id: 'cust-1',
|
|
invoice_number: 'F-100',
|
|
invoice_date: '2026-01-15',
|
|
due_date: '2026-02-14',
|
|
currency: 'SEK',
|
|
exchange_rate: null,
|
|
subtotal: 12000,
|
|
subtotal_sek: null,
|
|
vat_amount: 3000,
|
|
vat_amount_sek: null,
|
|
total: 15000,
|
|
total_sek: null,
|
|
vat_treatment: 'standard_25',
|
|
vat_rate: 25,
|
|
moms_ruta: '05',
|
|
status: 'sent',
|
|
paid_at: null,
|
|
credited_invoice_id: null,
|
|
document_type: 'invoice',
|
|
created_at: '2026-01-15T00:00:00Z',
|
|
updated_at: '2026-01-15T00:00:00Z',
|
|
items: [],
|
|
...overrides,
|
|
} as Invoice
|
|
}
|
|
|
|
function makeCustomerItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
|
return {
|
|
id: 'item-1',
|
|
invoice_id: 'inv-1',
|
|
sort_order: 0,
|
|
description: 'Serviceavtal 2026',
|
|
quantity: 1,
|
|
unit: 'st',
|
|
unit_price: 12000,
|
|
line_total: 12000,
|
|
vat_rate: 25,
|
|
vat_amount: 3000,
|
|
created_at: '2026-01-15T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function entryLines() {
|
|
return mockedCreateEntry.mock.calls[0][3].lines
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('supplier invoice registration with periodisering', () => {
|
|
it('debits the 17xx interim account instead of the cost account', async () => {
|
|
const invoice = makeSupplierInvoice({
|
|
id: 'si-1',
|
|
subtotal: 12000,
|
|
vat_amount: 3000,
|
|
total: 15000,
|
|
}) as SupplierInvoice
|
|
const items = [
|
|
makeSupplierItem({
|
|
accrual_period_start: '2026-01-01',
|
|
accrual_period_end: '2026-12-31',
|
|
accrual_balance_account: '1730',
|
|
}),
|
|
]
|
|
|
|
await createSupplierInvoiceRegistrationEntry(
|
|
{} as never, 'company-1', 'user-1', invoice, items, 'swedish_business', 'Försäkrings AB',
|
|
)
|
|
|
|
const lines = entryLines()
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '1730', debit_amount: 12000 }),
|
|
)
|
|
expect(lines.some((l) => l.account_number === '6310')).toBe(false)
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '2641', debit_amount: 3000 }),
|
|
)
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '2440', credit_amount: 15000 }),
|
|
)
|
|
})
|
|
|
|
it('falls back to the BAS-conventional interim account when none is set', async () => {
|
|
const invoice = makeSupplierInvoice({ id: 'si-1' }) as SupplierInvoice
|
|
const items = [
|
|
makeSupplierItem({
|
|
accrual_period_start: '2026-01-01',
|
|
accrual_period_end: '2026-12-31',
|
|
accrual_balance_account: null,
|
|
}),
|
|
]
|
|
|
|
await createSupplierInvoiceRegistrationEntry(
|
|
{} as never, 'company-1', 'user-1', invoice, items, 'swedish_business',
|
|
)
|
|
|
|
// 6310 försäkring → 1730 Förutbetalda försäkringspremier
|
|
expect(entryLines()).toContainEqual(
|
|
expect.objectContaining({ account_number: '1730', debit_amount: 12000 }),
|
|
)
|
|
})
|
|
|
|
it('books mixed invoices with deferred and ordinary lines side by side', async () => {
|
|
const invoice = makeSupplierInvoice({ id: 'si-1' }) as SupplierInvoice
|
|
const items = [
|
|
makeSupplierItem({
|
|
accrual_period_start: '2026-01-01',
|
|
accrual_period_end: '2026-12-31',
|
|
accrual_balance_account: '1730',
|
|
}),
|
|
makeSupplierItem({
|
|
id: 'si-item-2',
|
|
sort_order: 1,
|
|
description: 'Kontorsmaterial',
|
|
line_total: 500,
|
|
account_number: '6110',
|
|
}),
|
|
]
|
|
|
|
await createSupplierInvoiceRegistrationEntry(
|
|
{} as never, 'company-1', 'user-1', invoice, items, 'swedish_business',
|
|
)
|
|
|
|
const lines = entryLines()
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '1730', debit_amount: 12000 }),
|
|
)
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '6110', debit_amount: 500 }),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('supplier credit note with periodisering', () => {
|
|
it('credits the interim account when given the original deferred items', async () => {
|
|
const creditNote = makeSupplierInvoice({
|
|
id: 'si-credit-1',
|
|
is_credit_note: true,
|
|
}) as SupplierInvoice
|
|
const originalItems = [
|
|
makeSupplierItem({
|
|
accrual_period_start: '2026-01-01',
|
|
accrual_period_end: '2026-12-31',
|
|
accrual_balance_account: '1730',
|
|
}),
|
|
]
|
|
|
|
await createSupplierCreditNoteEntry(
|
|
{} as never, 'company-1', 'user-1', creditNote, originalItems, 'swedish_business',
|
|
)
|
|
|
|
const lines = entryLines()
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '1730', credit_amount: 12000 }),
|
|
)
|
|
expect(lines.some((l) => l.account_number === '6310')).toBe(false)
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '2440', debit_amount: 15000 }),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('customer invoice with periodisering', () => {
|
|
it('credits 29xx instead of revenue; output VAT untouched', async () => {
|
|
const invoice = makeCustomerInvoice({
|
|
items: [
|
|
makeCustomerItem({
|
|
accrual_period_start: '2026-01-01',
|
|
accrual_period_end: '2026-12-31',
|
|
accrual_balance_account: '2970',
|
|
}),
|
|
],
|
|
})
|
|
|
|
await createInvoiceJournalEntry({} as never, 'company-1', 'user-1', invoice, 'aktiebolag')
|
|
|
|
const lines = entryLines()
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '1510', debit_amount: 15000 }),
|
|
)
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '2970', credit_amount: 12000 }),
|
|
)
|
|
expect(lines.some((l) => l.account_number === '3001')).toBe(false)
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '2611', credit_amount: 3000 }),
|
|
)
|
|
})
|
|
|
|
it('keeps ordinary lines on revenue accounts next to deferred lines', async () => {
|
|
const invoice = makeCustomerInvoice({
|
|
subtotal: 13000,
|
|
vat_amount: 3250,
|
|
total: 16250,
|
|
items: [
|
|
makeCustomerItem({
|
|
accrual_period_start: '2026-01-01',
|
|
accrual_period_end: '2026-12-31',
|
|
accrual_balance_account: '2970',
|
|
}),
|
|
makeCustomerItem({
|
|
id: 'item-2',
|
|
sort_order: 1,
|
|
description: 'Konsulttimmar',
|
|
unit_price: 1000,
|
|
line_total: 1000,
|
|
vat_amount: 250,
|
|
}),
|
|
],
|
|
})
|
|
|
|
await createInvoiceJournalEntry({} as never, 'company-1', 'user-1', invoice, 'aktiebolag')
|
|
|
|
const lines = entryLines()
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '2970', credit_amount: 12000 }),
|
|
)
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '3001', credit_amount: 1000 }),
|
|
)
|
|
})
|
|
|
|
it('never defers reverse-charge lines (3308 keeps the full amount)', async () => {
|
|
const invoice = makeCustomerInvoice({
|
|
vat_treatment: 'reverse_charge',
|
|
vat_amount: 0,
|
|
total: 12000,
|
|
items: [
|
|
makeCustomerItem({
|
|
vat_rate: 0,
|
|
vat_amount: 0,
|
|
accrual_period_start: '2026-01-01',
|
|
accrual_period_end: '2026-12-31',
|
|
accrual_balance_account: '2970',
|
|
}),
|
|
],
|
|
})
|
|
|
|
await createInvoiceJournalEntry({} as never, 'company-1', 'user-1', invoice, 'aktiebolag')
|
|
|
|
const lines = entryLines()
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '3308', credit_amount: 12000 }),
|
|
)
|
|
expect(lines.some((l) => l.account_number === '2970')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('customer credit note with periodisering', () => {
|
|
it('debits the interim account via the copied accrual fields', async () => {
|
|
const creditNote = makeCustomerInvoice({
|
|
id: 'kr-1',
|
|
invoice_number: 'KR-F-100',
|
|
subtotal: -12000,
|
|
vat_amount: -3000,
|
|
total: -15000,
|
|
credited_invoice_id: 'inv-1',
|
|
items: [
|
|
makeCustomerItem({
|
|
id: 'kr-item-1',
|
|
invoice_id: 'kr-1',
|
|
quantity: -1,
|
|
line_total: -12000,
|
|
vat_amount: -3000,
|
|
accrual_period_start: '2026-01-01',
|
|
accrual_period_end: '2026-12-31',
|
|
accrual_balance_account: '2970',
|
|
}),
|
|
],
|
|
})
|
|
|
|
await createCreditNoteJournalEntry(
|
|
{} as never, 'company-1', 'user-1', creditNote, 'aktiebolag', 'Kund AB', 'A-42',
|
|
)
|
|
|
|
const lines = entryLines()
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '2970', debit_amount: 12000 }),
|
|
)
|
|
expect(lines.some((l) => l.account_number === '3001')).toBe(false)
|
|
expect(lines).toContainEqual(
|
|
expect.objectContaining({ account_number: '1510', credit_amount: 15000 }),
|
|
)
|
|
})
|
|
})
|