Files
accounted/lib/reports/__tests__/general-ledger.test.ts
T
Jakob WennbergandClaude Opus 4.8 fce6faff2c fix(api): stabilize report pagination + declare real { data, meta } envelope on v1 single/write endpoints (#811)
* fix(reports): stabilize fetchAllRows paging to stop doubled/dropped balances (#790, #791)

PostgREST `.range()` paging is only correct when the underlying query has a
stable TOTAL order. Several aggregating report queries (general ledger, trial
balance, grundbok, supplier/AR ledgers, etc.) paginated without `.order()`, so
on datasets larger than one 1000-row page Postgres could return rows in a
different order between requests — silently DUPLICATING or SKIPPING rows on a
page boundary and doubling or dropping financial totals.

- fetch-all.ts: document the ordering invariant and add an optional
  `dedupeBy` defense-in-depth that drops cross-page duplicates and warns when
  it fires (surfaces a missing `.order()` in logs instead of corrupting money).
- Add a stable `.order()` (line PK or account_number) to every paginated query
  in lib/reports/ and the account-balances route; pass `dedupeBy` on the
  money-aggregating line queries.
- Add fetch-all unit tests and update report test fixtures to carry row ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): declare the real { data, meta } envelope on v1 single/write/204 endpoints (#794)

The OpenAPI generator derives each endpoint's documented body purely from its
registered `response.success` Zod schema, and that schema is never validated at
runtime — so a route could advertise a shape its handler never sends. #802
fixed this for list endpoints; the same drift was latent on single-resource and
write endpoints, which declared the bare resource schema instead of the
`{ data, meta }` envelope the handlers actually return.

- registry.ts: extend `ResponseMetaSchema` with the optional `audit` block and
  `partial_expansions` list that writes/expansions emit; add the `NoBodyResponse`
  sentinel so 204 DELETE handlers document a bare 204 instead of a phantom 200.
- Wrap every single/write endpoint's `response.success` in `dataEnvelope(...)`
  (or `NoBodyResponse` for 204s) across the v1 routes.
- Add a response-envelope contract test that fails CI if any JSON endpoint
  forgets to wrap its schema, with binary downloads and 204s as the only
  exemptions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reports): extend paging dedupeBy to rc-basis-gaps and opening-balances

Address PR review: these two money-aggregating line queries already had the
stable `.order('id')` (so paging was correct) but didn't carry `id` in the
select, so they couldn't use the `dedupeBy` defense-in-depth that general-ledger
and trial-balance got. Select `id` and pass `dedupeBy: r => r.id` so the whole
report layer applies the ordering invariant consistently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:42:50 +02:00

296 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — table-keyed result queues
// ============================================================
type MockResult = { data?: unknown; error?: unknown }
let mockResults: Record<string, MockResult[]>
function makeBuilder(tableName: string) {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
const consume = (): MockResult => {
const queue = mockResults[tableName]
if (!queue || queue.length === 0) return { data: null, error: null }
return queue.shift()!
}
b.single = vi.fn().mockImplementation(async () => consume())
b.then = (resolve: (v: unknown) => void) => resolve(consume())
return b
}
function makeClient() {
const rpc = vi.fn().mockImplementation(async (fn: string) => {
const queue = mockResults[`rpc:${fn}`]
if (!queue || queue.length === 0) return { data: [], error: null }
return queue.shift()!
})
return {
from: vi.fn().mockImplementation((table: string) => makeBuilder(table)),
rpc,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
import { generateGeneralLedger } from '../general-ledger'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
mockResults = {}
supabase = makeClient()
})
describe('generateGeneralLedger', () => {
it('returns empty report when no fiscal period found', async () => {
mockResults = {
fiscal_periods: [{ data: null, error: null }],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '', end: '' })
})
it('returns empty report when no entries in period', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
// period lines — empty (prior lines come from RPC, defaults to empty)
{ data: [], error: null },
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
})
it('groups lines by account with correct totals and running balance', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
// period lines (joined with entry data)
{
data: [
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' } },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' } },
{ account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' } },
{ account_number: '1930', debit_amount: 1250, credit_amount: 0, journal_entries: { entry_date: '2024-02-10', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction' } },
{ account_number: '1510', debit_amount: 0, credit_amount: 1250, journal_entries: { entry_date: '2024-02-10', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction' } },
],
error: null,
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1510', account_name: 'Kundfordringar' },
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '2611', account_name: 'Utgående moms 25%' },
{ account_number: '3001', account_name: 'Försäljning 25%' },
],
error: null,
},
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
expect(report.accounts).toHaveLength(4)
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930', '2611', '3001'])
// Account 1510: debit 1250, credit 1250 → closing 0
const acc1510 = report.accounts.find((a) => a.account_number === '1510')!
expect(acc1510.total_debit).toBe(1250)
expect(acc1510.total_credit).toBe(1250)
expect(acc1510.closing_balance).toBe(0)
expect(acc1510.lines).toHaveLength(2)
expect(acc1510.lines[0].balance).toBe(1250)
expect(acc1510.lines[1].balance).toBe(0)
// Account 1930: debit 1250, credit 0 → closing 1250
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
expect(acc1930.total_debit).toBe(1250)
expect(acc1930.total_credit).toBe(0)
expect(acc1930.closing_balance).toBe(1250)
})
it('does not double a balance when an unstable page boundary re-serves a line (#790/#791)', async () => {
// Reproduces the doubling bug's mechanism: a paginated query whose order
// was not stable can return the same journal_entry_line on two pages.
// Page 1 must be a FULL page (PAGE_SIZE rows) so fetchAllRows fetches a
// second page; page 2 re-serves the 5010 line. dedupeBy(line id) must
// collapse it so the single 4000 posting totals 4000, not 8000.
const PAGE_SIZE = 1000
const filler = Array.from({ length: PAGE_SIZE - 1 }, (_, i) => ({
id: `f${i}`,
account_number: '1930',
debit_amount: 0,
credit_amount: 0,
journal_entries: { entry_date: '2024-01-02', voucher_number: 1, voucher_series: 'A', description: 'filler', source_type: 'manual' },
}))
const rentLine = {
id: 'rent-line-1',
account_number: '5010',
debit_amount: 4000,
credit_amount: 0,
journal_entries: { entry_date: '2024-01-15', voucher_number: 2, voucher_series: 'A', description: 'Lokalhyra', source_type: 'manual' },
}
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
{ data: [...filler, rentLine], error: null }, // page 1 — full → triggers page 2
{ data: [rentLine], error: null }, // page 2 — duplicate of the 5010 line
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '5010', account_name: 'Lokalhyra' },
],
error: null,
},
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
const acc5010 = report.accounts.find((a) => a.account_number === '5010')!
expect(acc5010.total_debit).toBe(4000) // not 8000
expect(acc5010.lines).toHaveLength(1) // verifikat listed once, not twice
})
it('computes opening balance from prior period entries', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null }, error: null },
],
'rpc:compute_prior_opening_balances': [
{
data: [{ account_number: '1930', debit: 10000, credit: 0 }],
error: null,
},
],
journal_entry_lines: [
// period lines
{
data: [
{ account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entries: { entry_date: '2025-03-01', voucher_number: 1, voucher_series: 'A', description: 'Purchase', source_type: 'manual' } },
{ account_number: '5410', debit_amount: 500, credit_amount: 0, journal_entries: { entry_date: '2025-03-01', voucher_number: 1, voucher_series: 'A', description: 'Purchase', source_type: 'manual' } },
],
error: null,
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '5410', account_name: 'Förbrukningsinventarier' },
],
error: null,
},
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-2')
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
expect(acc1930.opening_balance).toBe(10000)
expect(acc1930.closing_balance).toBe(9500) // 10000 - 500
expect(acc1930.lines[0].balance).toBe(9500)
})
it('filters accounts by account_from and account_to', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
// period lines across multiple accounts
{
data: [
{ account_number: '1510', debit_amount: 1000, credit_amount: 0, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' } },
{ account_number: '3001', debit_amount: 0, credit_amount: 500, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' } },
],
error: null,
},
],
chart_of_accounts: [
{ data: [], error: null },
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1', '1500', '1999')
// Only accounts in 1500–1999 range
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930'])
})
it('sorts lines within account by date then voucher number', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
// period lines — out of order
{
data: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entries: { entry_date: '2024-01-10', voucher_number: 2, voucher_series: 'A', description: 'Second', source_type: 'manual' } },
{ account_number: '1930', debit_amount: 200, credit_amount: 0, journal_entries: { entry_date: '2024-01-10', voucher_number: 1, voucher_series: 'A', description: 'First', source_type: 'manual' } },
{ account_number: '1930', debit_amount: 300, credit_amount: 0, journal_entries: { entry_date: '2024-01-05', voucher_number: 3, voucher_series: 'A', description: 'Earlier date', source_type: 'manual' } },
],
error: null,
},
],
chart_of_accounts: [
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null },
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
const acc = report.accounts[0]
// e3 (Jan 5) first, then e1 (Jan 10, #1), then e2 (Jan 10, #2)
expect(acc.lines[0].description).toBe('Earlier date')
expect(acc.lines[1].description).toBe('First')
expect(acc.lines[2].description).toBe('Second')
})
it('uses Math.round for monetary precision', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
{
data: [
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' } },
],
error: null,
},
],
chart_of_accounts: [
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null },
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
const acc = report.accounts[0]
expect(acc.total_debit).toBe(33.33)
expect(acc.closing_balance).toBe(33.33)
})
})