Files
accounted/lib/bookkeeping/accruals/__tests__/service.test.ts
T
Mattsson 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

570 lines
19 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
cancelSchedulesForSource,
createAccrualSchedule,
dissolveScheduleNow,
postDueInstallments,
} from '@/lib/bookkeeping/accruals/service'
import {
createJournalEntry,
findFiscalPeriod,
reverseEntry,
} from '@/lib/bookkeeping/engine'
import {
CannotReverseNonPostedError,
EntryAlreadyReversedError,
} from '@/lib/bookkeeping/errors'
import {
ACCRUAL_NOTHING_TO_DISSOLVE,
ACCRUAL_SCHEDULE_NOT_ACTIVE,
ACCRUAL_SCHEDULE_NOT_FOUND,
} from '@/lib/bookkeeping/accruals/errors'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
findFiscalPeriod: vi.fn(),
reverseEntry: vi.fn(),
}))
const mockCreateJournalEntry = vi.mocked(createJournalEntry)
const mockFindFiscalPeriod = vi.mocked(findFiscalPeriod)
const mockReverseEntry = vi.mocked(reverseEntry)
const COMPANY = 'company-1'
const USER = 'user-1'
function makeSchedule(overrides: Record<string, unknown> = {}) {
return {
id: 'sched-1',
user_id: USER,
company_id: COMPANY,
direction: 'expense',
supplier_invoice_id: 'si-1',
supplier_invoice_item_id: 'sii-1',
invoice_id: null,
invoice_item_id: null,
balance_account: '1730',
target_account: '6310',
total_amount: 12000,
period_start: '2026-01-01',
period_end: '2026-12-31',
months: 12,
origin_journal_entry_id: 'je-origin',
posting_floor_date: '2026-01-15',
status: 'active',
description: 'Försäkring 2026',
created_at: '2026-01-15T00:00:00Z',
updated_at: '2026-01-15T00:00:00Z',
...overrides,
}
}
function makeInstallment(overrides: Record<string, unknown> = {}) {
return {
id: 'inst-1',
user_id: USER,
company_id: COMPANY,
schedule_id: 'sched-1',
period_month: '2026-01-01',
amount: 1000,
status: 'pending',
journal_entry_id: null,
posted_at: null,
last_error: null,
created_at: '2026-01-15T00:00:00Z',
updated_at: '2026-01-15T00:00:00Z',
schedule: makeSchedule(),
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockFindFiscalPeriod.mockResolvedValue('fp-1')
mockCreateJournalEntry.mockResolvedValue({ id: 'je-new' } as never)
mockReverseEntry.mockResolvedValue({ id: 'je-storno' } as never)
})
describe('postDueInstallments', () => {
it('posts a due expense installment with Dr target / Cr balance', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] }, // due installments
{ data: { bookkeeping_locked_through: null } }, // company_settings
{ data: [{ id: 'inst-1' }] }, // CAS claim
{ count: 0 }, // remaining pending
{ data: null }, // schedule -> completed
])
const result = await postDueInstallments(
supabase as unknown as SupabaseClient,
COMPANY,
{ userId: USER, today: '2026-01-20' },
)
expect(result).toMatchObject({ posted: 1, failed: 0, skipped: 0 })
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.source_type).toBe('accrual')
expect(input.source_id).toBe('sched-1')
// Floor: period month 2026-01-01 but origin entry dated 2026-01-15.
expect(input.entry_date).toBe('2026-01-15')
expect(input.lines).toEqual([
expect.objectContaining({ account_number: '6310', debit_amount: 1000, credit_amount: 0 }),
expect.objectContaining({ account_number: '1730', debit_amount: 0, credit_amount: 1000 }),
])
})
it('flips the lines for revenue schedules (Dr 29xx / Cr 3xxx)', async () => {
const revenueSchedule = makeSchedule({
id: 'sched-2',
direction: 'revenue',
supplier_invoice_id: null,
invoice_id: 'inv-1',
balance_account: '2970',
target_account: '3001',
posting_floor_date: '2026-01-01',
})
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment({ schedule_id: 'sched-2', schedule: revenueSchedule })] },
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-1' }] },
{ count: 1 }, // still pending months -> schedule stays active
])
await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.lines).toEqual([
expect.objectContaining({ account_number: '2970', debit_amount: 1000, credit_amount: 0 }),
expect.objectContaining({ account_number: '3001', debit_amount: 0, credit_amount: 1000 }),
])
})
it('shifts the entry date past the company lock date', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment({ period_month: '2026-02-01' })] },
{ data: { bookkeeping_locked_through: '2026-03-31' } },
{ data: [{ id: 'inst-1' }] },
{ count: 0 },
{ data: null },
])
await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-04-10',
})
expect(mockCreateJournalEntry.mock.calls[0][3].entry_date).toBe('2026-04-01')
})
it('skips months that have not begun yet', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [] }, // the .lte filter excludes future months server-side
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result.posted).toBe(0)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('reverses its own entry when the CAS claim is lost', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: [] }, // CAS claim lost (another runner won)
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 0, skipped: 1, failed: 0 })
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY,
USER,
'je-new',
)
})
it('reverses its own entry when the CAS claim UPDATE itself errors', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: null, error: { message: 'connection reset' } }, // claim errored
{ data: null }, // last_error update in the catch
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 0, skipped: 0, failed: 1 })
expect(result.errors[0].message).toMatch(/Failed to mark installment posted/)
// Without the storno the next cron run would double-book the month.
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY,
USER,
'je-new',
)
})
it('records the reversal failure too when storno after claim error fails', async () => {
mockReverseEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: null, error: { message: 'connection reset' } }, // claim errored
{ data: null }, // last_error update in the catch
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result.failed).toBe(1)
expect(result.errors[0].message).toMatch(/misslyckades också/)
expect(result.errors[0].message).toMatch(/Bokföringen är låst/)
})
it('clamps the posting date to the next open fiscal period when the date falls in a closed one', async () => {
mockFindFiscalPeriod.mockResolvedValueOnce(null) // 2026 period is closed
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: { id: 'fp-next', period_start: '2027-01-01' } }, // next open period
{ data: [{ id: 'inst-1' }] }, // CAS claim
{ count: 0 },
{ data: null },
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 1, failed: 0 })
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.entry_date).toBe('2027-01-01')
expect(input.fiscal_period_id).toBe('fp-next')
})
it('records an actionable last_error when no open period exists at or after the date', async () => {
mockFindFiscalPeriod.mockResolvedValueOnce(null)
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: null }, // no open period after the date either
{ data: null }, // last_error update
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 0, failed: 1 })
expect(result.errors[0].message).toMatch(/eller senare/)
expect(result.errors[0].message).toMatch(/skapa nästa räkenskapsår/)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('records last_error and continues when posting fails', async () => {
mockCreateJournalEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: [
makeInstallment({ id: 'inst-1' }),
makeInstallment({ id: 'inst-2', period_month: '2026-02-01' }),
],
},
{ data: { bookkeeping_locked_through: null } },
{ data: null }, // last_error update for inst-1
{ data: [{ id: 'inst-2' }] }, // CAS claim for inst-2
{ count: 0 },
{ data: null },
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-02-20',
})
expect(result).toMatchObject({ posted: 1, failed: 1 })
expect(result.errors[0]).toMatchObject({ installmentId: 'inst-1' })
})
it('ignores installments whose schedule is not active', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: [
makeInstallment({ schedule: makeSchedule({ status: 'cancelled' }) }),
],
},
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result.posted).toBe(0)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
})
describe('createAccrualSchedule', () => {
const spec = {
direction: 'expense' as const,
supplierInvoiceId: 'si-1',
supplierInvoiceItemId: 'sii-1',
balanceAccount: '1730',
targetAccount: '6310',
totalAmountSek: 12000,
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
description: 'Försäkring 2026',
}
it('creates the schedule and 12 installments without posting when postCatchUp=false', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() }, // schedule insert
{ data: null }, // installments insert
])
const schedule = await createAccrualSchedule(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
spec,
{ originJournalEntryId: 'je-origin', postingFloorDate: '2026-01-15', postCatchUp: false },
)
expect(schedule.id).toBe('sched-1')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('cleans up the schedule when installment insert fails', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() },
{ data: null, error: { message: 'boom' } },
{ data: null }, // cleanup delete
])
await expect(
createAccrualSchedule(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
spec,
{ postingFloorDate: '2026-01-15', postCatchUp: false },
),
).rejects.toThrow(/installments/i)
})
})
describe('dissolveScheduleNow', () => {
it('posts one combined entry for the remaining months', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() }, // schedule fetch
{
data: [
makeInstallment({ id: 'inst-11', period_month: '2026-11-01', amount: 1000 }),
makeInstallment({ id: 'inst-12', period_month: '2026-12-01', amount: 1000 }),
],
},
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-11' }, { id: 'inst-12' }] }, // claim both
{ data: null }, // schedule completed
])
const result = await dissolveScheduleNow(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
'sched-1',
{ today: '2026-10-15' },
)
expect(result).toMatchObject({ journalEntryId: 'je-new', amount: 2000 })
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.entry_date).toBe('2026-10-15')
expect(input.lines[0]).toMatchObject({ account_number: '6310', debit_amount: 2000 })
})
it('reverses the combined entry when the claim is incomplete', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() },
{
data: [
makeInstallment({ id: 'inst-11', period_month: '2026-11-01', amount: 1000 }),
makeInstallment({ id: 'inst-12', period_month: '2026-12-01', amount: 1000 }),
],
},
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-11' }] }, // only one claimed
])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1', {
today: '2026-10-15',
}),
).rejects.toThrow(/ändrades samtidigt/i)
expect(mockReverseEntry).toHaveBeenCalled()
})
it('throws a typed not-found error with a stable code', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: null, error: { message: 'No rows' } }])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'missing'),
).rejects.toMatchObject({
code: ACCRUAL_SCHEDULE_NOT_FOUND,
message: 'Periodiseringen hittades inte',
})
})
it('throws a typed not-active error with a stable code', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: makeSchedule({ status: 'completed' }) }])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1'),
).rejects.toMatchObject({
code: ACCRUAL_SCHEDULE_NOT_ACTIVE,
message: 'Periodiseringen är inte aktiv',
})
})
it('throws a typed nothing-to-dissolve error with a stable code', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() }, // schedule fetch
{ data: [] }, // nothing pending
{ count: 0 }, // completeScheduleIfDone: remaining pending
{ data: null }, // schedule -> completed
])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1'),
).rejects.toMatchObject({
code: ACCRUAL_NOTHING_TO_DISSOLVE,
message: 'Det finns inget kvar att lösa upp',
})
})
})
describe('cancelSchedulesForSource', () => {
it('cancels pending months, reverses posted ones, and cancels the schedule', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeSchedule()] }, // schedules for source
{ data: null }, // pending -> cancelled
{ data: [{ id: 'inst-1', journal_entry_id: 'je-jan' }] }, // posted rows
{ data: null }, // schedule -> cancelled
])
const result = await cancelSchedulesForSource(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
{ supplierInvoiceId: 'si-1' },
{ reversalDate: '2026-05-01' },
)
expect(result).toEqual({ cancelledSchedules: 1, reversedEntries: 1, failedReversals: 0 })
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY,
USER,
'je-jan',
'2026-05-01',
)
})
it('leaves the schedule active and reports the failure when a storno fails', async () => {
mockReverseEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeSchedule()] }, // schedules for source
{ data: null }, // pending -> cancelled
{ data: [{ id: 'inst-1', journal_entry_id: 'je-jan' }] }, // posted rows
{ data: null }, // last_error update on the stuck installment
// NOTE: no schedule -> cancelled update is queued; the schedule must
// stay 'active' so the UI keeps showing the un-reversed remainder.
])
const result = await cancelSchedulesForSource(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
{ supplierInvoiceId: 'si-1' },
)
expect(result).toEqual({ cancelledSchedules: 0, reversedEntries: 0, failedReversals: 1 })
})
it('treats already-reversed dissolutions as success and still cancels the schedule', async () => {
mockReverseEntry
.mockRejectedValueOnce(new EntryAlreadyReversedError())
.mockRejectedValueOnce(new CannotReverseNonPostedError('reversed'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeSchedule()] },
{ data: null }, // pending -> cancelled
{
data: [
{ id: 'inst-1', journal_entry_id: 'je-jan' },
{ id: 'inst-2', journal_entry_id: 'je-feb' },
],
},
{ data: null }, // schedule -> cancelled
])
const result = await cancelSchedulesForSource(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
{ supplierInvoiceId: 'si-1' },
)
expect(result).toEqual({ cancelledSchedules: 1, reversedEntries: 0, failedReversals: 0 })
})
it('requires a source id', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
cancelSchedulesForSource(supabase as unknown as SupabaseClient, COMPANY, USER, {}),
).rejects.toThrow(/source invoice id/i)
})
})