Files
accounted/app/api/sandbox/seed/__tests__/ledger-history.test.ts
T
Mattsson 7c36d471b5 fix(sandbox): use posting engine and recover failed seeds (#2297)
* fix(sandbox): seed through posting engine and recover failed attempts

* test(sandbox): align CI auth schema for anonymous users
2026-09-05 10:42:14 +02:00

356 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { roundOre } from '@/lib/money'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import {
buildSandboxLedgerHistory,
SANDBOX_LEDGER_ACCOUNT_NUMBERS,
type SandboxLedgerLineRow,
} from '../ledger-history'
/**
* The journal_entries.source_type allowlist as of
* 20260712100500_journal_source_type_stripe_payout.sql. A value outside this
* set is rejected by Postgres with 23514, so the seed would fail at runtime.
*/
const ALLOWED_SOURCE_TYPES = [
'manual',
'bank_transaction',
'invoice_created',
'invoice_paid',
'invoice_cash_payment',
'credit_note',
'salary_payment',
'opening_balance',
'year_end',
'storno',
'correction',
'import',
'system',
'inbox_item',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'supplier_credit_note',
'currency_revaluation',
'supplier_invoice_privately_paid',
'reminder_fee',
'accrual',
'result_appropriation',
'rot_rut_payout',
'vat_settlement',
'stripe_payout',
]
const BAS_ACCOUNT_NUMBERS = new Set(BAS_REFERENCE.map((a) => a.account_number))
const accountMap = Object.fromEntries(
SANDBOX_LEDGER_ACCOUNT_NUMBERS.map((n) => [n, `account-${n}`]),
)
/** August 6th: the history then covers January through July. */
const input = {
userId: 'user-1',
companyId: 'company-1',
fiscalPeriodId: 'fp-1',
today: new Date(2026, 7, 6),
accountMap,
}
function sums(lines: SandboxLedgerLineRow[]) {
return {
debit: roundOre(lines.reduce((n, l) => n + l.debit_amount, 0)),
credit: roundOre(lines.reduce((n, l) => n + l.credit_amount, 0)),
}
}
function allLines(history: ReturnType<typeof buildSandboxLedgerHistory>) {
return history.linesByEntryIndex.flat()
}
describe('sandbox ledger history', () => {
it('emits one line bucket per entry', () => {
const history = buildSandboxLedgerHistory(input)
expect(history.entries.length).toBe(history.linesByEntryIndex.length)
expect(history.entries.length).toBeGreaterThan(0)
})
it('covers January through the month before today, 4 to 7 entries per month', () => {
const history = buildSandboxLedgerHistory(input)
const byMonth = new Map<string, number>()
for (const entry of history.entries) {
const month = entry.entry_date.slice(0, 7)
byMonth.set(month, (byMonth.get(month) ?? 0) + 1)
}
expect([...byMonth.keys()]).toEqual([
'2026-01',
'2026-02',
'2026-03',
'2026-04',
'2026-05',
'2026-06',
'2026-07',
])
// January has no customer payment and no eget uttag yet (no cash in), and
// no periodic cost: 4. Every other month is 5 or 6, plus one for the
// quarter-close momsredovisning (March, June) and one for the Q1 payment
// (May). July has no periodic cost, so it stays at 5.
expect(byMonth.get('2026-01')).toBe(4)
expect(byMonth.get('2026-03')).toBe(7)
expect(byMonth.get('2026-05')).toBe(7)
expect(byMonth.get('2026-06')).toBe(7)
for (const [, count] of byMonth) {
expect(count).toBeGreaterThanOrEqual(4)
expect(count).toBeLessThanOrEqual(7)
}
expect(history.entries).toHaveLength(42)
})
it('produces no entries before the first full month has passed', () => {
const january = buildSandboxLedgerHistory({ ...input, today: new Date(2026, 0, 20) })
expect(january.entries).toEqual([])
expect(january.linesByEntryIndex).toEqual([])
})
it('balances every entry exactly, both sides above zero', () => {
const history = buildSandboxLedgerHistory(input)
history.linesByEntryIndex.forEach((lines, index) => {
const { debit, credit } = sums(lines)
expect(debit, `entry ${index} (${history.entries[index].description})`).toBe(credit)
expect(debit).toBeGreaterThan(0)
expect(credit).toBeGreaterThan(0)
// A line is either a debit or a credit, never both.
for (const line of lines) {
expect(line.debit_amount === 0 || line.credit_amount === 0).toBe(true)
expect(line.debit_amount).toBeGreaterThanOrEqual(0)
expect(line.credit_amount).toBeGreaterThanOrEqual(0)
}
})
})
it('keeps every amount rounded to whole öre', () => {
for (const line of allLines(buildSandboxLedgerHistory(input))) {
expect(line.debit_amount).toBe(roundOre(line.debit_amount))
expect(line.credit_amount).toBe(roundOre(line.credit_amount))
}
})
it('uses string BAS account numbers that exist in class 1 to 8', () => {
for (const line of allLines(buildSandboxLedgerHistory(input))) {
expect(typeof line.account_number).toBe('string')
expect(line.account_number).toMatch(/^[1-8]\d{3}$/)
expect(BAS_ACCOUNT_NUMBERS.has(line.account_number)).toBe(true)
expect(SANDBOX_LEDGER_ACCOUNT_NUMBERS).toContain(line.account_number)
}
})
it('resolves account_id through the caller-supplied map', () => {
const history = buildSandboxLedgerHistory(input)
for (const line of allLines(history)) {
expect(line.account_id).toBe(`account-${line.account_number}`)
}
// A chart lookup that came back short must degrade to null, not undefined:
// undefined would be dropped from the insert payload.
const partial = buildSandboxLedgerHistory({ ...input, accountMap: {} })
for (const line of allLines(partial)) {
expect(line.account_id).toBeNull()
}
})
it('sets an explicit dimensions bag on every single line', () => {
for (const line of allLines(buildSandboxLedgerHistory(input))) {
// NOT NULL on journal_entry_lines.dimensions, and PostgREST sends NULL
// for a key one row omits while another sets it.
expect(Object.prototype.hasOwnProperty.call(line, 'dimensions')).toBe(true)
expect(line.dimensions).toBeTypeOf('object')
expect(line.dimensions).not.toBeNull()
}
})
it('tags revenue lines with the seeded demo dimensions on some months only', () => {
const history = buildSandboxLedgerHistory(input)
const revenueLines = allLines(history).filter((l) => l.account_number === '3001')
const tagged = revenueLines.filter((l) => Object.keys(l.dimensions).length > 0)
expect(tagged.length).toBeGreaterThan(0)
expect(tagged.length).toBeLessThan(revenueLines.length)
// Only the four dimension_values the seed creates, on SIE dims 1 and 6.
for (const line of tagged) {
expect(Object.keys(line.dimensions).sort()).toEqual(['1', '6'])
expect(['BUTIK', 'WEBB']).toContain(line.dimensions['1'])
expect(['P001', 'P002']).toContain(line.dimensions['6'])
}
expect(tagged.some((l) => l.dimensions['1'] === 'BUTIK' && l.dimensions['6'] === 'P001')).toBe(true)
// Nothing but revenue carries dimensions.
const otherTagged = allLines(history).filter(
(l) => l.account_number !== '3001' && Object.keys(l.dimensions).length > 0,
)
expect(otherTagged).toEqual([])
})
it('keeps dates inside the fiscal year and leaves posting metadata to the engine', () => {
const history = buildSandboxLedgerHistory(input)
for (const entry of history.entries) {
expect(entry.entry_date >= '2026-01-01').toBe(true)
expect(entry.entry_date <= '2026-12-31').toBe(true)
expect(entry.entry_date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(ALLOWED_SOURCE_TYPES).toContain(entry.source_type)
expect(entry).not.toHaveProperty('status')
expect(entry).not.toHaveProperty('committed_at')
expect(entry).not.toHaveProperty('commit_method')
expect(entry.voucher_series).toBe('A')
expect(entry.fiscal_period_id).toBe('fp-1')
expect(entry.user_id).toBe('user-1')
expect(entry.company_id).toBe('company-1')
expect(entry.description.length).toBeGreaterThan(0)
}
})
it('carries no voucher_number: the caller assigns those via the RPC', () => {
const history = buildSandboxLedgerHistory(input)
for (const entry of history.entries) {
expect(entry).not.toHaveProperty('voucher_number')
expect(entry).not.toHaveProperty('id')
}
for (const line of allLines(history)) {
expect(line).not.toHaveProperty('journal_entry_id')
}
})
it('emits the entries in date order so the voucher sequence stays chronological', () => {
const dates = buildSandboxLedgerHistory(input).entries.map((e) => e.entry_date)
expect([...dates].sort()).toEqual(dates)
})
it('leaves a plausibly profitable year to date', () => {
const lines = allLines(buildSandboxLedgerHistory(input))
const revenue = roundOre(
lines
.filter((l) => l.account_number.startsWith('3'))
.reduce((n, l) => n + l.credit_amount - l.debit_amount, 0),
)
const costs = roundOre(
lines
.filter((l) => /^[4-7]/.test(l.account_number))
.reduce((n, l) => n + l.debit_amount - l.credit_amount, 0),
)
expect(revenue).toBeGreaterThan(0)
expect(costs).toBeGreaterThan(0)
expect(revenue).toBeGreaterThan(costs * 2)
})
it('keeps the bank account positive throughout the year to date', () => {
const history = buildSandboxLedgerHistory(input)
// A demo Balansrapport with a negative företagskonto reads as a bug. The
// January owner contribution is what prevents it: the fiscal year is the
// company's first period, so 1930 starts at zero.
let balance = 0
history.linesByEntryIndex.forEach((lines) => {
for (const line of lines) {
if (line.account_number !== '1930') continue
balance = roundOre(balance + line.debit_amount - line.credit_amount)
}
expect(balance).toBeGreaterThan(0)
})
})
it('books output VAT on 2611 and input VAT on 2641', () => {
const lines = allLines(buildSandboxLedgerHistory(input))
const outputVat = lines.filter((l) => l.account_number === '2611')
const inputVat = lines.filter((l) => l.account_number === '2641')
expect(outputVat.length).toBeGreaterThan(0)
expect(inputVat.length).toBeGreaterThan(0)
// Trading entries only: sales credit 2611, purchases debit 2641. The one
// exception each quarter is the momsredovisning, which clears both the
// other way. Identify it by the 2650 line rather than by sign, so this
// stays a real assertion about direction.
const settlementIndexes = new Set(
buildSandboxLedgerHistory(input)
.linesByEntryIndex.map((entryLines, index) =>
entryLines.some((l) => l.account_number === '2650') ? index : -1,
)
.filter((index) => index >= 0),
)
const tradingLines = buildSandboxLedgerHistory(input).linesByEntryIndex.flatMap(
(entryLines, index) => (settlementIndexes.has(index) ? [] : entryLines),
)
expect(
tradingLines
.filter((l) => l.account_number === '2611')
.every((l) => l.credit_amount > 0 && l.debit_amount === 0),
).toBe(true)
expect(
tradingLines
.filter((l) => l.account_number === '2641')
.every((l) => l.debit_amount > 0 && l.credit_amount === 0),
).toBe(true)
})
it('settles each closed VAT quarter to 2650 and pays it from the bank', () => {
const history = buildSandboxLedgerHistory(input)
// Each closed quarter clears its 26xx accounts to 2650 on the last day of
// the quarter: the ordinary period-close entry, not the filing itself.
// Today is 6 August 2026, so Q1 is cleared (31 March) and paid (12 May),
// and Q2 is cleared (30 June) but unpaid: its deadline is 17 August.
const declaration = history.entries.findIndex(e => e.description === 'Momsredovisning 2026 Q1')
const payment = history.entries.findIndex(e => e.description === 'Betald moms 2026 Q1')
expect(declaration).toBeGreaterThanOrEqual(0)
expect(payment).toBeGreaterThan(declaration)
expect(history.entries[declaration].entry_date).toBe('2026-03-31')
expect(history.entries[payment].entry_date).toBe('2026-05-12')
const q2Declaration = history.entries.find(e => e.description === 'Momsredovisning 2026 Q2')
expect(q2Declaration?.entry_date).toBe('2026-06-30')
expect(history.entries.some(e => e.description === 'Betald moms 2026 Q2')).toBe(false)
// The declaration carries the settlement SHAPE get_vat_declaration_totals
// looks for: a VAT-account line plus a 2650 line. Without it the quarter
// would be counted again in the next declaration.
const declarationLines = history.linesByEntryIndex[declaration]
expect(declarationLines.find(l => l.account_number === '2611')!.debit_amount).toBeGreaterThan(0)
expect(declarationLines.find(l => l.account_number === '2641')!.credit_amount).toBeGreaterThan(0)
const netPayable = declarationLines.find(l => l.account_number === '2650')!.credit_amount
expect(netPayable).toBeGreaterThan(0)
// The payment moves exactly that amount off the bank, and touches no VAT
// account, so it is not itself settlement-shaped.
const paymentLines = history.linesByEntryIndex[payment]
expect(paymentLines.find(l => l.account_number === '2650')!.debit_amount).toBe(netPayable)
expect(paymentLines.find(l => l.account_number === '1930')!.credit_amount).toBe(netPayable)
expect(paymentLines.some(l => ['2611', '2641'].includes(l.account_number))).toBe(false)
})
it('is deterministic', () => {
const first = buildSandboxLedgerHistory(input)
const second = buildSandboxLedgerHistory(input)
expect(first).toEqual(second)
expect(JSON.stringify(first)).toBe(JSON.stringify(second))
})
it('does not depend on the day of the month or the local clock', () => {
const early = buildSandboxLedgerHistory({ ...input, today: new Date(2026, 7, 1) })
const late = buildSandboxLedgerHistory({ ...input, today: new Date(2026, 7, 31) })
expect(early).toEqual(late)
})
})