* 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>
166 lines
6.1 KiB
TypeScript
166 lines
6.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createMockSupabase } from '@/tests/helpers'
|
|
|
|
const { supabase, mockResult } = createMockSupabase()
|
|
|
|
import { generateMonthlyBreakdown } from '../monthly-breakdown'
|
|
|
|
// Minimal chainable query mock: every filter/order method returns the same
|
|
// object; .single()/.range() resolve to the queued result. Tolerant of
|
|
// query-shape changes such as an added .order() (see fetch-all.ts ordering
|
|
// invariant) so the tests don't hardcode the exact method chain.
|
|
function chain(result: unknown) {
|
|
const c: Record<string, unknown> = {}
|
|
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'neq', 'order']) {
|
|
c[m] = () => c
|
|
}
|
|
c.single = () => Promise.resolve(result)
|
|
c.range = () => Promise.resolve(result)
|
|
return c
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('generateMonthlyBreakdown', () => {
|
|
it('returns empty months when no fiscal period found', async () => {
|
|
mockResult({ data: null, error: { message: 'not found' } })
|
|
|
|
const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1')
|
|
expect(result.months).toEqual([])
|
|
})
|
|
|
|
it('returns empty months when no journal entries exist', async () => {
|
|
// First call: fiscal period
|
|
mockResult({
|
|
data: { period_start: '2024-01-01', period_end: '2024-12-31' },
|
|
error: null,
|
|
})
|
|
|
|
// We need two sequential calls with different results.
|
|
// The proxy-based mock returns the same result for all calls,
|
|
// so we re-mock after the first await completes.
|
|
// Instead, test that an empty lines result returns initialized months.
|
|
|
|
// For this test, override at the supabase.from level to return different chains
|
|
let callCount = 0
|
|
supabase.from.mockImplementation(() => {
|
|
callCount++
|
|
return callCount === 1
|
|
? chain({ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null })
|
|
: chain({ data: [], error: null })
|
|
})
|
|
|
|
const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1')
|
|
expect(result.months.length).toBe(12)
|
|
expect(result.months[0].label).toBe('Jan')
|
|
expect(result.months[0].income).toBe(0)
|
|
expect(result.months[0].expenses).toBe(0)
|
|
expect(result.months[11].label).toBe('Dec')
|
|
})
|
|
|
|
it('correctly classifies revenue (class 3) and expense (class 4-7) accounts', async () => {
|
|
let callCount = 0
|
|
supabase.from.mockImplementation(() => {
|
|
callCount++
|
|
return callCount === 1
|
|
? chain({ data: { period_start: '2024-01-01', period_end: '2024-03-31' }, error: null })
|
|
: chain({
|
|
data: [
|
|
{
|
|
account_number: '3001',
|
|
debit_amount: 0,
|
|
credit_amount: 10000,
|
|
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
|
},
|
|
{
|
|
account_number: '5010',
|
|
debit_amount: 3000,
|
|
credit_amount: 0,
|
|
journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
|
},
|
|
{
|
|
account_number: '3001',
|
|
debit_amount: 0,
|
|
credit_amount: 5000,
|
|
journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
|
},
|
|
{
|
|
account_number: '6200',
|
|
debit_amount: 1500,
|
|
credit_amount: 0,
|
|
journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
})
|
|
|
|
const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1')
|
|
|
|
// January
|
|
const jan = result.months.find((m) => m.label === 'Jan')!
|
|
expect(jan.income).toBe(10000)
|
|
expect(jan.expenses).toBe(3000)
|
|
expect(jan.net).toBe(7000)
|
|
|
|
// February
|
|
const feb = result.months.find((m) => m.label === 'Feb')!
|
|
expect(feb.income).toBe(5000)
|
|
expect(feb.expenses).toBe(1500)
|
|
expect(feb.net).toBe(3500)
|
|
|
|
// March should be zero
|
|
const mar = result.months.find((m) => m.label === 'Mar')!
|
|
expect(mar.income).toBe(0)
|
|
expect(mar.expenses).toBe(0)
|
|
})
|
|
|
|
it('ignores balance sheet accounts (class 1, 2) but includes class 8 financial items', async () => {
|
|
let callCount = 0
|
|
supabase.from.mockImplementation(() => {
|
|
callCount++
|
|
return callCount === 1
|
|
? chain({ data: { period_start: '2024-01-01', period_end: '2024-01-31' }, error: null })
|
|
: chain({
|
|
data: [
|
|
{
|
|
account_number: '1930',
|
|
debit_amount: 10000,
|
|
credit_amount: 0,
|
|
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
|
},
|
|
{
|
|
account_number: '2611',
|
|
debit_amount: 0,
|
|
credit_amount: 2500,
|
|
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
|
},
|
|
{
|
|
account_number: '8400',
|
|
debit_amount: 500,
|
|
credit_amount: 0,
|
|
journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
|
},
|
|
{
|
|
account_number: '8300',
|
|
debit_amount: 0,
|
|
credit_amount: 200,
|
|
journal_entry: { entry_date: '2024-01-25', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
})
|
|
|
|
const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1')
|
|
const jan = result.months.find((m) => m.label === 'Jan')!
|
|
// Class 1 and 2 are ignored
|
|
// Class 8 debit (8400 interest expense) → expense
|
|
expect(jan.expenses).toBe(500)
|
|
// Class 8 credit (8300 interest income) → income
|
|
expect(jan.income).toBe(200)
|
|
})
|
|
})
|