Files
accounted/lib/bookkeeping/__tests__/invoice-accrual-booking.test.ts
T
MattssonandClaude Fable 5 db8983ba9e 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>
2026-06-12 16:35:30 +02:00

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 }),
)
})
})