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
+1 -1
View File
@@ -76,7 +76,7 @@ describe('engine.pg — triggers & RPCs that mocks cannot catch', () => {
// the voucher_sequences user_id NOT NULL check before ON CONFLICT could
// arbitrate (commit_journal_entry got the fallback in 20260421170500;
// next_voucher_number — the storno/correction path — did not until
// 20260611130000).
// 20260623130000).
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const first = await getPool().query<{ n: number }>(
@@ -0,0 +1,370 @@
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 }),
)
})
})
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
isValidBalanceAccount,
suggestBalanceAccount,
} from '@/lib/bookkeeping/accruals/account-suggestions'
describe('suggestBalanceAccount', () => {
it('maps cost accounts to the BAS-conventional interim account', () => {
expect(suggestBalanceAccount('expense', '5010')).toBe('1710') // lokalhyra
expect(suggestBalanceAccount('expense', '5220')).toBe('1720') // hyra inventarier
expect(suggestBalanceAccount('expense', '5615')).toBe('1720') // leasing personbil
expect(suggestBalanceAccount('expense', '6310')).toBe('1730') // försäkring
expect(suggestBalanceAccount('expense', '8410')).toBe('1740') // ränta
expect(suggestBalanceAccount('expense', '6540')).toBe('1790') // IT-tjänster -> övrigt
})
it('always suggests 2970 for revenue', () => {
expect(suggestBalanceAccount('revenue', '3001')).toBe('2970')
expect(suggestBalanceAccount('revenue', '3041')).toBe('2970')
})
})
describe('isValidBalanceAccount', () => {
it('mirrors the DB CHECK ranges', () => {
expect(isValidBalanceAccount('expense', '1730')).toBe(true)
expect(isValidBalanceAccount('expense', '1790')).toBe(true)
expect(isValidBalanceAccount('expense', '2970')).toBe(false)
expect(isValidBalanceAccount('expense', '5010')).toBe(false)
expect(isValidBalanceAccount('revenue', '2970')).toBe(true)
expect(isValidBalanceAccount('revenue', '2990')).toBe(true)
expect(isValidBalanceAccount('revenue', '1790')).toBe(false)
})
})
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
computeInstallmentAmounts,
computeInstallments,
countCalendarMonths,
dayAfter,
firstOfMonth,
listCalendarMonths,
maxIsoDate,
} from '@/lib/bookkeeping/accruals/compute'
import { sumOre } from '@/lib/money'
describe('firstOfMonth', () => {
it('truncates to the first of the month', () => {
expect(firstOfMonth('2026-01-15')).toBe('2026-01-01')
expect(firstOfMonth('2026-12-31')).toBe('2026-12-01')
expect(firstOfMonth('2026-06-01')).toBe('2026-06-01')
})
})
describe('countCalendarMonths', () => {
it('counts months touched, inclusive', () => {
expect(countCalendarMonths('2026-01-01', '2026-01-31')).toBe(1)
expect(countCalendarMonths('2026-01-10', '2026-12-31')).toBe(12)
// Mid-month start still touches both end months.
expect(countCalendarMonths('2026-01-31', '2026-02-01')).toBe(2)
})
it('handles year boundaries and brutet räkenskapsår', () => {
expect(countCalendarMonths('2026-11-15', '2027-02-14')).toBe(4)
expect(countCalendarMonths('2026-07-01', '2027-06-30')).toBe(12)
})
it('throws when the end month precedes the start month', () => {
expect(() => countCalendarMonths('2026-06-01', '2026-01-31')).toThrow()
})
})
describe('listCalendarMonths', () => {
it('lists first-of-month dates across a year boundary', () => {
expect(listCalendarMonths('2026-11-15', '2027-02-14')).toEqual([
'2026-11-01',
'2026-12-01',
'2027-01-01',
'2027-02-01',
])
})
})
describe('computeInstallmentAmounts', () => {
it('splits evenly when the amount divides cleanly', () => {
expect(computeInstallmentAmounts(12000, 12)).toEqual(Array(12).fill(1000))
})
it('distributes remainder öre from the first month and sums exactly', () => {
const amounts = computeInstallmentAmounts(10000, 12)
expect(amounts.slice(0, 4)).toEqual([833.34, 833.34, 833.34, 833.34])
expect(amounts.slice(4)).toEqual(Array(8).fill(833.33))
const sum = Math.round(amounts.reduce((a, b) => a + b, 0) * 100) / 100
expect(sum).toBe(10000)
})
it('sums exactly for awkward totals (property check)', () => {
const cases: Array<[number, number]> = [
[0.13, 12],
[1, 3],
[99.99, 7],
[12345.67, 11],
[50000, 36],
[3333.33, 2],
]
for (const [total, months] of cases) {
const amounts = computeInstallmentAmounts(total, months)
expect(amounts).toHaveLength(months)
for (const amount of amounts) {
expect(amount).toBeGreaterThan(0)
}
const sum = sumOre(amounts)
expect(sum).toBe(total)
// No installment differs by more than 1 öre from any other.
const min = Math.min(...amounts)
const max = Math.max(...amounts)
expect(Math.round((max - min) * 100)).toBeLessThanOrEqual(1)
}
})
it('rejects totals too small to give every month an öre', () => {
expect(() => computeInstallmentAmounts(0.05, 12)).toThrow(/too small/i)
})
})
describe('computeInstallments', () => {
it('pairs months with amounts', () => {
const plan = computeInstallments(12000, '2026-01-15', '2026-12-31')
expect(plan).toHaveLength(12)
expect(plan[0]).toEqual({ period_month: '2026-01-01', amount: 1000 })
expect(plan[11]).toEqual({ period_month: '2026-12-01', amount: 1000 })
})
})
describe('maxIsoDate / dayAfter', () => {
it('returns the latest date and ignores null/undefined', () => {
expect(maxIsoDate('2026-01-01', '2026-03-15', null, undefined)).toBe('2026-03-15')
expect(maxIsoDate('2026-01-01')).toBe('2026-01-01')
expect(() => maxIsoDate(null, undefined)).toThrow()
})
it('computes the day after across month and year boundaries', () => {
expect(dayAfter('2026-03-31')).toBe('2026-04-01')
expect(dayAfter('2026-12-31')).toBe('2027-01-01')
expect(dayAfter('2026-02-28')).toBe('2026-03-01')
expect(dayAfter('2028-02-28')).toBe('2028-02-29')
expect(dayAfter('2026-06-14')).toBe('2026-06-15')
})
})
@@ -0,0 +1,569 @@
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)
})
})
@@ -0,0 +1,67 @@
/**
* Default interim (balance) account per P&L account for periodisering.
*
* Förutbetalda kostnader sit on 17xx interimsfordringar; the specific account
* follows BAS convention by cost type. Förutbetalda intäkter sit on 2970
* (29xx interimsskulder). The user can always override in the form — these
* are suggestions, and the DB CHECK only enforces the 17xx/29xx range.
*/
import type { AccrualDirection } from '@/types'
export const DEFAULT_PREPAID_EXPENSE_ACCOUNT = '1790' // Övriga förutbetalda kostnader och upplupna intäkter
export const DEFAULT_DEFERRED_REVENUE_ACCOUNT = '2970' // Förutbetalda intäkter
/** Suggest the interim account for a deferred line booked to targetAccount. */
export function suggestBalanceAccount(
direction: AccrualDirection,
targetAccount: string,
): string {
if (direction === 'revenue') {
return DEFAULT_DEFERRED_REVENUE_ACCOUNT
}
// Hyror (lokalkostnader 50xx) → 1710 Förutbetalda hyreskostnader
if (targetAccount.startsWith('50')) return '1710'
// Hyra/leasing av anläggningstillgångar (52xx) and leasing personbilar
// (5615) → 1720 Förutbetalda leasingavgifter
if (targetAccount.startsWith('52') || targetAccount === '5615') return '1720'
// Försäkringar (63xx, primarily 6310) → 1730 Förutbetalda försäkringspremier
if (targetAccount.startsWith('63')) return '1730'
// Räntekostnader (84xx) → 1740 Förutbetalda räntekostnader
if (targetAccount.startsWith('84')) return '1740'
return DEFAULT_PREPAID_EXPENSE_ACCOUNT
}
/** DB CHECK mirror: 17xx for expense schedules, 29xx for revenue schedules. */
export function isValidBalanceAccount(
direction: AccrualDirection,
account: string,
): boolean {
return direction === 'expense' ? /^17\d{2}$/.test(account) : /^29\d{2}$/.test(account)
}
interface AccrualItemFields {
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
}
/** True when the line carries a complete periodisering period. */
export function itemHasAccrual(item: AccrualItemFields): boolean {
return Boolean(item.accrual_period_start && item.accrual_period_end)
}
/**
* The account a line's net amount is actually booked to: the interim account
* for deferred lines, otherwise the line's own P&L account. Used by the
* entry generators so VAT/AP/AR lines stay untouched while the net moves to
* 17xx/29xx.
*/
export function resolveBookingAccount(
direction: AccrualDirection,
item: AccrualItemFields,
plAccount: string,
): string {
if (!itemHasAccrual(item)) return plAccount
return item.accrual_balance_account ?? suggestBalanceAccount(direction, plAccount)
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Pure date/amount math for periodisering (accrual schedules).
*
* All dates are ISO strings (YYYY-MM-DD) handled numerically — never via
* `new Date()` — so the result is independent of server timezone. Amounts
* round via `roundOre` from `@/lib/money`, never `toFixed()`.
*/
export interface InstallmentPlan {
/** First day of the calendar month, ISO date. */
period_month: string
amount: number
}
function parseIso(date: string): { year: number; month: number } {
const year = Number(date.slice(0, 4))
const month = Number(date.slice(5, 7))
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
throw new Error(`Invalid ISO date: ${date}`)
}
return { year, month }
}
function toMonthIso(year: number, month: number): string {
return `${year}-${String(month).padStart(2, '0')}-01`
}
/** '2026-01-15' → '2026-01-01' */
export function firstOfMonth(date: string): string {
const { year, month } = parseIso(date)
return toMonthIso(year, month)
}
/** Number of calendar months touched by [periodStart, periodEnd], inclusive. */
export function countCalendarMonths(periodStart: string, periodEnd: string): number {
const start = parseIso(periodStart)
const end = parseIso(periodEnd)
const months = (end.year - start.year) * 12 + (end.month - start.month) + 1
if (months < 1) {
throw new Error(`Period end ${periodEnd} precedes period start ${periodStart}`)
}
return months
}
/** First-of-month ISO dates for every calendar month in the period. */
export function listCalendarMonths(periodStart: string, periodEnd: string): string[] {
const months = countCalendarMonths(periodStart, periodEnd)
const start = parseIso(periodStart)
const result: string[] = []
for (let i = 0; i < months; i++) {
const total = start.year * 12 + (start.month - 1) + i
result.push(toMonthIso(Math.floor(total / 12), (total % 12) + 1))
}
return result
}
/**
* Split a total over N months so the installments sum to the total EXACTLY.
* Even split in öre; the remainder öre are distributed one per month from
* the first month, so no installment differs by more than 1 öre.
*
* Throws when the total is too small to give every month at least 1 öre —
* the DB CHECK requires every installment amount > 0.
*/
export function computeInstallmentAmounts(totalAmount: number, months: number): number[] {
if (!Number.isInteger(months) || months < 1) {
throw new Error(`Invalid month count: ${months}`)
}
const totalOre = Math.round(totalAmount * 100)
if (totalOre < months) {
throw new Error(
`Amount ${totalAmount} is too small to spread over ${months} months`,
)
}
const baseOre = Math.floor(totalOre / months)
const remainder = totalOre - baseOre * months
const amounts: number[] = []
for (let i = 0; i < months; i++) {
amounts.push((baseOre + (i < remainder ? 1 : 0)) / 100)
}
return amounts
}
/** Full plan: one installment per calendar month in the period. */
export function computeInstallments(
totalAmount: number,
periodStart: string,
periodEnd: string,
): InstallmentPlan[] {
const monthList = listCalendarMonths(periodStart, periodEnd)
const amounts = computeInstallmentAmounts(totalAmount, monthList.length)
return monthList.map((period_month, i) => ({ period_month, amount: amounts[i] }))
}
/** Latest of any number of ISO dates (lexicographic compare is safe). */
export function maxIsoDate(...dates: Array<string | null | undefined>): string {
const present = dates.filter((d): d is string => Boolean(d))
if (present.length === 0) throw new Error('maxIsoDate requires at least one date')
return present.reduce((a, b) => (a >= b ? a : b))
}
/** '2026-03-31' → '2026-04-01' (day after, calendar-correct). */
export function dayAfter(date: string): string {
const year = Number(date.slice(0, 4))
const month = Number(date.slice(5, 7))
const day = Number(date.slice(8, 10))
// Date.UTC handles month/year rollover; we only ever format back to ISO.
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Typed domain errors for the periodisering (accrual schedule) service.
*
* Mirrors the lib/bookkeeping/errors.ts pattern: each class carries a stable
* `code` so API routes dispatch on the code instead of matching Swedish prose.
* The Swedish messages are user-facing and intentionally unchanged from the
* original plain-Error throws.
*/
export const ACCRUAL_SCHEDULE_NOT_FOUND = 'ACCRUAL_SCHEDULE_NOT_FOUND' as const
export const ACCRUAL_SCHEDULE_NOT_ACTIVE = 'ACCRUAL_SCHEDULE_NOT_ACTIVE' as const
export const ACCRUAL_NOTHING_TO_DISSOLVE = 'ACCRUAL_NOTHING_TO_DISSOLVE' as const
export class AccrualScheduleNotFoundError extends Error {
readonly code = ACCRUAL_SCHEDULE_NOT_FOUND
constructor() {
super('Periodiseringen hittades inte')
this.name = 'AccrualScheduleNotFoundError'
}
}
export class AccrualScheduleNotActiveError extends Error {
readonly code = ACCRUAL_SCHEDULE_NOT_ACTIVE
constructor(public readonly currentStatus: string) {
super('Periodiseringen är inte aktiv')
this.name = 'AccrualScheduleNotActiveError'
}
}
export class AccrualNothingToDissolveError extends Error {
readonly code = ACCRUAL_NOTHING_TO_DISSOLVE
constructor() {
super('Det finns inget kvar att lösa upp')
this.name = 'AccrualNothingToDissolveError'
}
}
export function isAccrualError(
err: unknown,
): err is
| AccrualScheduleNotFoundError
| AccrualScheduleNotActiveError
| AccrualNothingToDissolveError {
return (
err instanceof AccrualScheduleNotFoundError ||
err instanceof AccrualScheduleNotActiveError ||
err instanceof AccrualNothingToDissolveError
)
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Bridges invoice flows to accrual schedules: after the registration/revenue
* entry is committed, every line that carries a periodisering period gets a
* schedule (+ synchronous catch-up posting for months that already began).
*
* Idempotent per line: lines already covered by a schedule are skipped, so
* event replays (supplier_invoice.confirmed) can never double-schedule.
* Failures are logged and counted, never thrown — the origin entry is already
* committed and must not be rolled back by a schedule hiccup; the caller
* surfaces a warning instead.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Invoice, InvoiceItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { createAccrualSchedule } from '@/lib/bookkeeping/accruals/service'
import {
itemHasAccrual,
suggestBalanceAccount,
} from '@/lib/bookkeeping/accruals/account-suggestions'
import { getRevenueAccount } from '@/lib/bookkeeping/invoice-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import type { EntityType } from '@/types'
import { createLogger } from '@/lib/logger'
const log = createLogger('bookkeeping.accruals.from-invoices')
export interface ScheduleCreationResult {
created: number
failed: number
}
export async function createSchedulesForSupplierInvoice(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
originJournalEntryId: string,
): Promise<ScheduleCreationResult> {
const accrualItems = items.filter(itemHasAccrual)
const result: ScheduleCreationResult = { created: 0, failed: 0 }
if (accrualItems.length === 0) return result
const { data: existing } = await supabase
.from('accrual_schedules')
.select('supplier_invoice_item_id')
.eq('company_id', companyId)
.eq('supplier_invoice_id', invoice.id)
const covered = new Set(
((existing ?? []) as Array<{ supplier_invoice_item_id: string | null }>).map(
(row) => row.supplier_invoice_item_id,
),
)
for (const item of accrualItems) {
if (item.id && covered.has(item.id)) continue
try {
const totalSek =
Math.round(
resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate) *
100,
) / 100
await createAccrualSchedule(
supabase,
companyId,
userId,
{
direction: 'expense',
supplierInvoiceId: invoice.id,
supplierInvoiceItemId: item.id ?? null,
balanceAccount:
item.accrual_balance_account ??
suggestBalanceAccount('expense', item.account_number),
targetAccount: item.account_number,
totalAmountSek: totalSek,
periodStart: item.accrual_period_start as string,
periodEnd: item.accrual_period_end as string,
description: `${item.description} (leverantörsfaktura ${invoice.supplier_invoice_number})`,
},
{
originJournalEntryId,
// The registration entry is dated invoice_date — dissolutions may
// never precede it.
postingFloorDate: invoice.invoice_date,
},
)
result.created++
} catch (error) {
result.failed++
log.error('failed to create accrual schedule for supplier invoice line', error, {
companyId,
entityId: invoice.id,
})
}
}
return result
}
export async function createSchedulesForCustomerInvoice(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: Invoice,
items: InvoiceItem[],
originJournalEntryId: string,
entityType: EntityType = 'enskild_firma',
): Promise<ScheduleCreationResult> {
const accrualItems = items.filter(
(item) => itemHasAccrual(item) && item.line_type !== 'text' && !item.deduction_type,
)
const result: ScheduleCreationResult = { created: 0, failed: 0 }
if (accrualItems.length === 0) return result
const { data: existing } = await supabase
.from('accrual_schedules')
.select('invoice_item_id')
.eq('company_id', companyId)
.eq('invoice_id', invoice.id)
const covered = new Set(
((existing ?? []) as Array<{ invoice_item_id: string | null }>).map(
(row) => row.invoice_item_id,
),
)
for (const item of accrualItems) {
if (item.id && covered.has(item.id)) continue
const target = resolveRevenueTarget(item, invoice.vat_treatment, entityType)
// reverse_charge/export lines keep their statutory account (3308/3305) so
// ruta 39/40 in the momsdeklaration stay correct — never deferred. The
// generator applies the same exclusion, so the net stays on 3308/3305.
if (target.special) continue
try {
const targetAccount = target.account
const totalSek =
Math.round(
resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate) *
100,
) / 100
await createAccrualSchedule(
supabase,
companyId,
userId,
{
direction: 'revenue',
invoiceId: invoice.id,
invoiceItemId: item.id ?? null,
balanceAccount:
item.accrual_balance_account ?? suggestBalanceAccount('revenue', targetAccount),
targetAccount,
totalAmountSek: totalSek,
periodStart: item.accrual_period_start as string,
periodEnd: item.accrual_period_end as string,
description: `${item.description} (faktura ${invoice.invoice_number ?? ''})`.trim(),
},
{
originJournalEntryId,
postingFloorDate: invoice.invoice_date,
},
)
result.created++
} catch (error) {
result.failed++
log.error('failed to create accrual schedule for invoice line', error, {
companyId,
entityId: invoice.id,
})
}
}
return result
}
/**
* Resolve a line's revenue account exactly the way generatePerRateLines does:
* per-line override only for ordinary domestic rates; reverse_charge/export
* force the statutory account and are flagged `special` (never deferrable).
*/
function resolveRevenueTarget(
item: InvoiceItem,
invoiceTreatment: Invoice['vat_treatment'],
entityType: EntityType,
): { account: string; special: boolean } {
const rate = item.vat_rate ?? 0
const treatment =
rate === 0 && (invoiceTreatment === 'reverse_charge' || invoiceTreatment === 'export')
? invoiceTreatment
: getVatTreatmentForRate(rate)
const special = treatment === 'reverse_charge' || treatment === 'export'
const account =
!special && item.revenue_account
? item.revenue_account
: getRevenueAccount(treatment, entityType)
return { account, special }
}
+691
View File
@@ -0,0 +1,691 @@
/**
* Periodisering (accrual schedule) service.
*
* A schedule spreads one invoice line's net amount over the calendar months
* of a service period. The origin entry (supplier invoice registration /
* customer invoice revenue entry) books the net to an interim account
* (17xx/29xx); this service creates and posts the monthly dissolution
* entries:
*
* expense: Dr target (5xxx/6xxx) / Cr balance (17xx)
* revenue: Dr balance (29xx) / Cr target (3xxx)
*
* All entries go through the engine (source_type 'accrual') — never direct
* inserts. Posting dates are max(period_month, posting_floor_date,
* company lock date + 1) so catch-up months book correctly and the interim
* account never goes negative.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
AccrualDirection,
AccrualSchedule,
AccrualScheduleInstallment,
CreateJournalEntryLineInput,
} from '@/types'
import {
createJournalEntry,
findFiscalPeriod,
reverseEntry,
} from '@/lib/bookkeeping/engine'
import {
CannotReverseNonPostedError,
EntryAlreadyReversedError,
} from '@/lib/bookkeeping/errors'
import {
AccrualNothingToDissolveError,
AccrualScheduleNotActiveError,
AccrualScheduleNotFoundError,
} from '@/lib/bookkeeping/accruals/errors'
import {
computeInstallments,
dayAfter,
firstOfMonth,
maxIsoDate,
} from '@/lib/bookkeeping/accruals/compute'
import { roundOre, sumOre } from '@/lib/money'
import { createLogger } from '@/lib/logger'
const log = createLogger('bookkeeping.accruals')
export interface AccrualScheduleSpec {
direction: AccrualDirection
supplierInvoiceId?: string
supplierInvoiceItemId?: string | null
invoiceId?: string
invoiceItemId?: string | null
/** Interim account: 17xx (expense) / 29xx (revenue). */
balanceAccount: string
/** The P&L account the amount dissolves to. */
targetAccount: string
/** Net amount in SEK as booked on the origin entry (ex VAT). */
totalAmountSek: number
periodStart: string
periodEnd: string
description: string
}
export interface PostDueResult {
posted: number
failed: number
skipped: number
errors: Array<{ installmentId: string; message: string }>
}
type ScheduleRow = AccrualSchedule
type InstallmentRow = AccrualScheduleInstallment & { schedule?: ScheduleRow | null }
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
async function fetchLockDateFloor(
supabase: SupabaseClient,
companyId: string,
): Promise<string | null> {
const { data } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', companyId)
.maybeSingle()
const lockedThrough = (data as { bookkeeping_locked_through?: string | null } | null)
?.bookkeeping_locked_through
return lockedThrough ? dayAfter(lockedThrough) : null
}
/**
* Earliest OPEN fiscal period starting after `date`. Used to clamp a posting
* date forward when the computed date falls inside a closed period (bokslut
* done) — same spirit as the company lock-date floor.
*/
async function findNextOpenPeriodStart(
supabase: SupabaseClient,
companyId: string,
date: string,
): Promise<{ fiscalPeriodId: string; periodStart: string } | null> {
const { data } = await supabase
.from('fiscal_periods')
.select('id, period_start')
.eq('company_id', companyId)
.eq('is_closed', false)
.gt('period_start', date)
.order('period_start', { ascending: true })
.limit(1)
.maybeSingle()
const row = data as { id: string; period_start: string } | null
return row ? { fiscalPeriodId: row.id, periodStart: row.period_start } : null
}
function dissolutionLines(
schedule: Pick<ScheduleRow, 'direction' | 'balance_account' | 'target_account'>,
amount: number,
lineDescription: string,
): CreateJournalEntryLineInput[] {
if (schedule.direction === 'expense') {
return [
{
account_number: schedule.target_account,
debit_amount: amount,
credit_amount: 0,
line_description: lineDescription,
},
{
account_number: schedule.balance_account,
debit_amount: 0,
credit_amount: amount,
line_description: lineDescription,
},
]
}
return [
{
account_number: schedule.balance_account,
debit_amount: amount,
credit_amount: 0,
line_description: lineDescription,
},
{
account_number: schedule.target_account,
debit_amount: 0,
credit_amount: amount,
line_description: lineDescription,
},
]
}
/**
* Create a schedule + its monthly installments for one deferred invoice
* line, then immediately post every installment whose month has already
* begun (catch-up). Catch-up failures do not throw — they are recorded on
* the installment (last_error) and retried by the daily cron.
*/
export async function createAccrualSchedule(
supabase: SupabaseClient,
companyId: string,
userId: string,
spec: AccrualScheduleSpec,
options: {
originJournalEntryId?: string | null
/** Entry date of the origin entry; floors all dissolution dates. */
postingFloorDate: string
/** Skip synchronous catch-up posting (tests / bulk imports). */
postCatchUp?: boolean
today?: string
},
): Promise<AccrualSchedule> {
const plan = computeInstallments(spec.totalAmountSek, spec.periodStart, spec.periodEnd)
const { data: schedule, error: scheduleError } = await supabase
.from('accrual_schedules')
.insert({
user_id: userId,
company_id: companyId,
direction: spec.direction,
supplier_invoice_id: spec.supplierInvoiceId ?? null,
supplier_invoice_item_id: spec.supplierInvoiceItemId ?? null,
invoice_id: spec.invoiceId ?? null,
invoice_item_id: spec.invoiceItemId ?? null,
balance_account: spec.balanceAccount,
target_account: spec.targetAccount,
total_amount: roundOre(spec.totalAmountSek),
period_start: spec.periodStart,
period_end: spec.periodEnd,
months: plan.length,
origin_journal_entry_id: options.originJournalEntryId ?? null,
posting_floor_date: options.postingFloorDate,
status: 'active',
description: spec.description,
})
.select('*')
.single()
if (scheduleError || !schedule) {
throw new Error(
`Failed to create accrual schedule: ${scheduleError?.message ?? 'no row returned'}`,
)
}
const scheduleRow = schedule as ScheduleRow
const { error: installmentError } = await supabase
.from('accrual_schedule_installments')
.insert(
plan.map((installment) => ({
user_id: userId,
company_id: companyId,
schedule_id: scheduleRow.id,
period_month: installment.period_month,
amount: installment.amount,
status: 'pending',
})),
)
if (installmentError) {
// Schedule without installments is inert but confusing — clean it up.
await supabase.from('accrual_schedules').delete().eq('id', scheduleRow.id)
throw new Error(`Failed to create accrual installments: ${installmentError.message}`)
}
if (options.postCatchUp !== false) {
const result = await postDueInstallments(supabase, companyId, {
userId,
scheduleId: scheduleRow.id,
today: options.today,
})
if (result.failed > 0) {
log.warn('accrual catch-up posting failed for some installments', {
companyId,
scheduleId: scheduleRow.id,
failed: result.failed,
})
}
}
return scheduleRow
}
/**
* Post every pending installment whose calendar month has begun.
* Used by the daily cron, the manual "Bokför förfallna" action, and the
* synchronous catch-up at schedule creation (scheduleId filter).
*
* Each installment books independently; one failure never blocks the rest.
*/
export async function postDueInstallments(
supabase: SupabaseClient,
companyId: string,
options: {
/** Falls back to each schedule's creator for cron runs. */
userId?: string
scheduleId?: string
today?: string
} = {},
): Promise<PostDueResult> {
const today = options.today ?? todayIso()
const result: PostDueResult = { posted: 0, failed: 0, skipped: 0, errors: [] }
let query = supabase
.from('accrual_schedule_installments')
.select('*, schedule:accrual_schedules(*)')
.eq('company_id', companyId)
.eq('status', 'pending')
.lte('period_month', firstOfMonth(today))
.order('period_month', { ascending: true })
if (options.scheduleId) {
query = query.eq('schedule_id', options.scheduleId)
}
const { data, error } = await query
if (error) {
throw new Error(`Failed to load due accrual installments: ${error.message}`)
}
const due = ((data ?? []) as InstallmentRow[]).filter(
(installment) => installment.schedule?.status === 'active',
)
if (due.length === 0) return result
const lockFloor = await fetchLockDateFloor(supabase, companyId)
const touchedSchedules = new Set<string>()
for (const installment of due) {
const schedule = installment.schedule as ScheduleRow
try {
let entryDate = maxIsoDate(
installment.period_month,
schedule.posting_floor_date,
lockFloor,
)
let fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
if (!fiscalPeriodId) {
// The computed date can fall inside a CLOSED fiscal period (bokslut
// done while the company lock date lags behind). Clamp forward to the
// start of the earliest open period — same spirit as the lockFloor —
// instead of retrying the same impossible date forever.
const clamped = await findNextOpenPeriodStart(supabase, companyId, entryDate)
if (!clamped) {
throw new Error(
`Ingen öppen räkenskapsperiod för ${entryDate} eller senare — ` +
'skapa nästa räkenskapsår för att kunna bokföra periodiseringen',
)
}
entryDate = clamped.periodStart
fiscalPeriodId = clamped.fiscalPeriodId
}
const monthLabel = installment.period_month.slice(0, 7)
const description = schedule.description
? `Periodisering ${monthLabel}: ${schedule.description}`
: `Periodisering ${monthLabel}`
const entry = await createJournalEntry(
supabase,
companyId,
options.userId ?? schedule.user_id,
{
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description,
source_type: 'accrual',
source_id: schedule.id,
lines: dissolutionLines(schedule, installment.amount, description),
},
)
// CAS claim: only the runner that flips pending→posted keeps its entry.
// A concurrent runner (cron + manual button) loses the race and stornos
// its own entry so the ledger nets to a single dissolution.
const { data: claimed, error: claimError } = await supabase
.from('accrual_schedule_installments')
.update({
status: 'posted',
journal_entry_id: entry.id,
posted_at: new Date().toISOString(),
last_error: null,
})
.eq('id', installment.id)
.eq('status', 'pending')
.select('id')
if (claimError) {
// The journal entry is already committed but the installment was NOT
// flipped to posted — without a storno the next run would book the
// same month twice. Mirror the dissolveScheduleNow handling: reverse
// our own entry best-effort, then record the failure (the catch below
// writes last_error on the installment).
let reversalNote = ''
try {
await reverseEntry(
supabase,
companyId,
options.userId ?? schedule.user_id,
entry.id,
)
} catch (reversalError) {
log.error('failed to reverse accrual entry after claim error', reversalError, {
companyId,
installmentId: installment.id,
entryId: entry.id,
})
reversalNote =
`; storno av verifikat ${entry.id} misslyckades också: ` +
getErrorMessage(reversalError)
}
throw new Error(
`Failed to mark installment posted: ${claimError.message}${reversalNote}`,
)
}
if (!claimed || claimed.length === 0) {
log.warn('accrual installment claimed concurrently; reversing duplicate entry', {
companyId,
installmentId: installment.id,
entryId: entry.id,
})
await reverseEntry(
supabase,
companyId,
options.userId ?? schedule.user_id,
entry.id,
)
result.skipped++
continue
}
touchedSchedules.add(schedule.id)
result.posted++
} catch (error) {
const message = getErrorMessage(error)
log.error('failed to post accrual installment', error, {
companyId,
installmentId: installment.id,
scheduleId: schedule.id,
})
result.failed++
result.errors.push({ installmentId: installment.id, message })
await supabase
.from('accrual_schedule_installments')
.update({ last_error: message })
.eq('id', installment.id)
.eq('status', 'pending')
}
}
for (const scheduleId of touchedSchedules) {
await completeScheduleIfDone(supabase, companyId, scheduleId)
}
return result
}
async function completeScheduleIfDone(
supabase: SupabaseClient,
companyId: string,
scheduleId: string,
): Promise<void> {
const { count, error } = await supabase
.from('accrual_schedule_installments')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('schedule_id', scheduleId)
.eq('status', 'pending')
if (error || count === null || count > 0) return
await supabase
.from('accrual_schedules')
.update({ status: 'completed' })
.eq('id', scheduleId)
.eq('company_id', companyId)
.eq('status', 'active')
}
/**
* Dissolve everything that remains on a schedule in one entry, dated today
* (clamped by lock date / posting floor). Used when the service period ends
* early or the user simply wants the rest expensed now.
*/
export async function dissolveScheduleNow(
supabase: SupabaseClient,
companyId: string,
userId: string,
scheduleId: string,
options: { today?: string } = {},
): Promise<{ journalEntryId: string; amount: number }> {
const today = options.today ?? todayIso()
const { data: scheduleData, error: scheduleError } = await supabase
.from('accrual_schedules')
.select('*')
.eq('company_id', companyId)
.eq('id', scheduleId)
.single()
if (scheduleError || !scheduleData) {
throw new AccrualScheduleNotFoundError()
}
const schedule = scheduleData as ScheduleRow
if (schedule.status !== 'active') {
throw new AccrualScheduleNotActiveError(schedule.status)
}
const { data: pendingData, error: pendingError } = await supabase
.from('accrual_schedule_installments')
.select('*')
.eq('company_id', companyId)
.eq('schedule_id', scheduleId)
.eq('status', 'pending')
if (pendingError) {
throw new Error(`Failed to load installments: ${pendingError.message}`)
}
const pending = (pendingData ?? []) as InstallmentRow[]
if (pending.length === 0) {
await completeScheduleIfDone(supabase, companyId, scheduleId)
throw new AccrualNothingToDissolveError()
}
const amount = sumOre(pending.map((installment) => installment.amount))
const lockFloor = await fetchLockDateFloor(supabase, companyId)
const entryDate = maxIsoDate(today, schedule.posting_floor_date, lockFloor)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
if (!fiscalPeriodId) {
throw new Error(`Ingen öppen räkenskapsperiod för ${entryDate}`)
}
const description = schedule.description
? `Periodisering, slutupplösning: ${schedule.description}`
: 'Periodisering, slutupplösning'
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description,
source_type: 'accrual',
source_id: schedule.id,
lines: dissolutionLines(schedule, amount, description),
})
const pendingIds = pending.map((installment) => installment.id)
const { data: claimed, error: claimError } = await supabase
.from('accrual_schedule_installments')
.update({
status: 'posted',
journal_entry_id: entry.id,
posted_at: new Date().toISOString(),
last_error: null,
})
.in('id', pendingIds)
.eq('status', 'pending')
.select('id')
if (claimError || !claimed || claimed.length !== pendingIds.length) {
// Concurrent posting changed the set under us — undo our combined entry
// and let the caller retry against the new state. If the storno itself
// fails, the combined entry stands while some installments point at the
// cron's entries — the interim account would dissolve twice. Surface
// both failures and pin the alert on the installments so the
// periodiseringar UI shows the stuck state instead of nothing.
try {
await reverseEntry(supabase, companyId, userId, entry.id)
} catch (reversalError) {
const message = getErrorMessage(reversalError)
log.error('failed to reverse combined dissolution entry after lost claim race', reversalError, {
companyId,
scheduleId,
entryId: entry.id,
})
await supabase
.from('accrual_schedule_installments')
.update({
last_error: `Storno av samlad upplösning (verifikat ${entry.id}) misslyckades — kontrollera interimskontot: ${message}`.slice(0, 2_000),
})
.in('id', pendingIds)
.eq('company_id', companyId)
throw new Error(
`Periodiseringen ändrades samtidigt och vändningen av det samlade verifikatet misslyckades: ${message}`,
)
}
throw new Error('Periodiseringen ändrades samtidigt — försök igen')
}
await supabase
.from('accrual_schedules')
.update({ status: 'completed' })
.eq('id', scheduleId)
.eq('company_id', companyId)
.eq('status', 'active')
return { journalEntryId: entry.id, amount }
}
/**
* Cancel all schedules belonging to a credited/cancelled invoice:
* pending installments are cancelled, already-posted dissolutions are
* reversed (storno), and the schedule is marked cancelled. The caller's
* credit-note entry reverses the interim account at its full original
* amount, so the net of origin + dissolutions + stornos + credit is zero.
*
* A schedule is only marked cancelled when ALL its posted dissolutions
* reversed cleanly — otherwise it stays 'active' (so the UI keeps showing
* the un-reversed remainder instead of remaining=0 while 17xx/29xx is still
* unbalanced) and the stuck installments get a descriptive last_error.
* Callers should surface `failedReversals > 0` as a warning.
*/
export async function cancelSchedulesForSource(
supabase: SupabaseClient,
companyId: string,
userId: string,
source: { supplierInvoiceId?: string; invoiceId?: string },
options: { reversalDate?: string } = {},
): Promise<{
cancelledSchedules: number
reversedEntries: number
failedReversals: number
}> {
if (!source.supplierInvoiceId && !source.invoiceId) {
throw new Error('cancelSchedulesForSource requires a source invoice id')
}
let query = supabase
.from('accrual_schedules')
.select('*')
.eq('company_id', companyId)
.neq('status', 'cancelled')
query = source.supplierInvoiceId
? query.eq('supplier_invoice_id', source.supplierInvoiceId)
: query.eq('invoice_id', source.invoiceId as string)
const { data, error } = await query
if (error) {
throw new Error(`Failed to load accrual schedules: ${error.message}`)
}
const schedules = (data ?? []) as ScheduleRow[]
let cancelledSchedules = 0
let reversedEntries = 0
let failedReversals = 0
for (const schedule of schedules) {
// Cancel pending months first so the posting cron cannot book new
// dissolutions for a credited invoice while (or after) we storno the
// already-posted ones.
await supabase
.from('accrual_schedule_installments')
.update({ status: 'cancelled' })
.eq('company_id', companyId)
.eq('schedule_id', schedule.id)
.eq('status', 'pending')
const { data: postedData } = await supabase
.from('accrual_schedule_installments')
.select('id, journal_entry_id')
.eq('company_id', companyId)
.eq('schedule_id', schedule.id)
.eq('status', 'posted')
let scheduleFailures = 0
for (const installment of (postedData ?? []) as Array<{
id: string
journal_entry_id: string | null
}>) {
if (!installment.journal_entry_id) continue
try {
await reverseEntry(
supabase,
companyId,
userId,
installment.journal_entry_id,
options.reversalDate,
)
reversedEntries++
} catch (error) {
// A dissolution stornoed by an earlier (partially failed) cancel run
// is fine — idempotent re-credit, treated as success.
if (
error instanceof EntryAlreadyReversedError ||
(error instanceof CannotReverseNonPostedError &&
error.currentStatus === 'reversed')
) {
continue
}
scheduleFailures++
const message = getErrorMessage(error)
log.warn('could not reverse accrual dissolution during cancel', {
companyId,
scheduleId: schedule.id,
journalEntryId: installment.journal_entry_id,
message,
})
// Posted installments freeze their financial fields, but last_error
// stays writable — surface the stuck storno in the periodiseringar UI.
await supabase
.from('accrual_schedule_installments')
.update({
last_error: `Storno vid kreditering misslyckades: ${message}`,
})
.eq('id', installment.id)
.eq('company_id', companyId)
}
}
if (scheduleFailures > 0) {
failedReversals += scheduleFailures
continue
}
await supabase
.from('accrual_schedules')
.update({ status: 'cancelled' })
.eq('id', schedule.id)
.eq('company_id', companyId)
cancelledSchedules++
}
return { cancelledSchedules, reversedEntries, failedReversals }
}
@@ -2,6 +2,7 @@ import { eventBus } from '@/lib/events/bus'
import type { EventPayload } from '@/lib/events/types'
import { createClient } from '@/lib/supabase/server'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { createLogger } from '@/lib/logger'
@@ -83,6 +84,23 @@ async function handleSupplierInvoiceConfirmed(
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', supplierInvoice.id)
// Lines with a periodisering period get their schedule + catch-up
// dissolutions. Idempotent per line, so a replayed event is safe.
const scheduleResult = await createSchedulesForSupplierInvoice(
supabase,
companyId,
userId,
supplierInvoice,
items as SupplierInvoiceItem[],
journalEntry.id,
)
if (scheduleResult.failed > 0) {
log.error('accrual schedule creation failed for confirmed supplier invoice', {
supplierInvoiceId: supplierInvoice.id,
failed: scheduleResult.failed,
})
}
}
} catch (err) {
log.error('Failed to create registration journal entry:', err)
+26 -4
View File
@@ -1,5 +1,6 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { resolveBookingAccount } from './accruals/account-suggestions'
import { generateSalesVatLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
@@ -48,6 +49,12 @@ function buildInvoiceDescription(
/**
* Group invoice items by VAT rate and generate per-rate revenue + VAT lines.
* Returns credit lines only (revenue + VAT). The caller adds the debit side.
*
* options.deferAccruals: substitute the 29xx interim account for lines with a
* periodisering period. Only the callers that also create/cancel accrual
* schedules may pass true (invoice entry + credit note) — the cash-method
* entry books revenue directly even if a line carries stale accrual fields,
* since no schedule would ever dissolve the interim balance.
*/
function generatePerRateLines(
items: InvoiceItem[],
@@ -55,7 +62,8 @@ function generatePerRateLines(
entityType: EntityType,
invoiceTagText: string,
currency?: string | null,
exchangeRate?: number | null
exchangeRate?: number | null,
options?: { deferAccruals?: boolean }
): CreateJournalEntryLineInput[] {
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
@@ -132,9 +140,16 @@ function generatePerRateLines(
// a per-line override only applies to ordinary domestic rates so EU/export
// sales keep landing in the right VAT-declaration ruta.
const isSpecialTreatment = treatment === 'reverse_charge' || treatment === 'export'
const account = !isSpecialTreatment && item.revenue_account
const plAccount = !isSpecialTreatment && item.revenue_account
? item.revenue_account
: getRevenueAccount(treatment, entityType)
// Periodiserade lines credit the 29xx interim account (förutbetalda
// intäkter) instead of revenue; the schedule dissolves it monthly. Output
// VAT below is untouched — moms is never deferred. Special treatments are
// never deferred (ruta 39/40 must reflect the full period's sales).
const account = isSpecialTreatment || !options?.deferAccruals
? plAccount
: resolveBookingAccount('revenue', item, plAccount)
const group = rateGroups.get(rate) ?? { vatAmount: 0, byAccount: new Map<string, number>() }
group.vatAmount += item.vat_amount || 0
@@ -297,7 +312,10 @@ export async function createInvoiceJournalEntry(
if (invoice.items && invoice.items.length > 0) {
creditLines.push(...generatePerRateLines(
invoice.items, invoice.vat_treatment, entityType, tag,
invoice.currency, invoice.exchange_rate
invoice.currency, invoice.exchange_rate,
// Schedules are created right after this entry commits (send/mark-sent
// flows), so deferring to 29xx here is safe.
{ deferAccruals: true }
))
} else {
// Fallback: no items available, use invoice-level amounts
@@ -523,7 +541,11 @@ export async function createCreditNoteJournalEntry(
// Use absolute items for generatePerRateLines, then swap debit/credit
const creditLines = generatePerRateLines(
creditNote.items, creditNote.vat_treatment, entityType, tag,
creditNote.currency, creditNote.exchange_rate
creditNote.currency, creditNote.exchange_rate,
// Credit-note items carry the original's accrual fields so the reversal
// hits the same 29xx interim account; the original's schedule is
// cancelled/stornoed by the credit flow.
{ deferAccruals: true }
)
for (const line of creditLines) {
debitLines.push({
+15 -6
View File
@@ -1,5 +1,6 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { resolveBookingAccount } from './accruals/account-suggestions'
import {
generateReverseChargeLines,
generateReverseChargeBasisLines,
@@ -68,12 +69,16 @@ export async function createSupplierInvoiceRegistrationEntry(
const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const isForeign = invoice.currency !== 'SEK'
// Aggregate expense amounts by account number and convert to SEK
// Aggregate expense amounts by account number and convert to SEK.
// Periodiserade lines book their net to the 17xx interim account instead
// of the cost account (resolveBookingAccount); VAT and 2440 are untouched —
// moms is never deferred (redovisas på fakturadatum).
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
const current = expenseByAccount.get(bookingAccount) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(item.account_number, current + itemSek)
expenseByAccount.set(bookingAccount, current + itemSek)
}
// Debit: Expense accounts (in SEK)
@@ -538,13 +543,17 @@ export async function createSupplierCreditNoteEntry(
const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomstnr ${creditNote.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Credit: Expense accounts (reverse, in SEK)
// Credit: Expense accounts (reverse, in SEK). The caller passes the
// ORIGINAL invoice's items so deferred lines reverse against the same 17xx
// interim account they were registered on (the schedule's posted
// dissolutions are stornoed separately by cancelSchedulesForSource).
const creditLines: CreateJournalEntryLineInput[] = []
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
const current = expenseByAccount.get(bookingAccount) || 0
const itemSek = Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate))
expenseByAccount.set(item.account_number, current + itemSek)
expenseByAccount.set(bookingAccount, current + itemSek)
}
for (const [accountNumber, amount] of expenseByAccount) {