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:
co-authored by
Claude Fable 5
parent
8e8b63a200
commit
db8983ba9e
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user