288915c152
* fix(invoices): return attachment filename in delivery history summaries The 20260723003000 hardening dropped attachment_filename from list_invoice_delivery_summaries, so the delivery history UI always fell back to the generic "faktura.pdf" label. Recreate the RPC with the filename included: it is derived from company name, customer name, invoice number, and date, all already visible to every company member, so the minimization boundary is unchanged. Addresses stay masked and message content, BCC, and checksums stay server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): surface own-account transfer legs in match-to-voucher by default The second (incoming) leg of a transfer between two of the company's own bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog because the voucher counted as 'already matched' once its outgoing leg was linked, even though the incoming account's line had no settling transaction. Users read the empty default list as 'the app won't let me link this'. get_account_gl_lines_for_matching now counts links per settlement account: a transaction provably on another cash account no longer marks the voucher as matched for the requested account, so the unsettled transfer leg surfaces by default (and auto-selects on an exact match). Same-account N:1 stays behind the 'Visa aven matchade verifikationer' opt-in, and transactions without a resolvable cash account conservatively keep counting everywhere. get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile). Companion guard: mark_entry_as_opening_balance now refuses entries with linked bank transactions, since half-settled transfer vouchers became reachable in the reconciliation view's unmatched table where 'Mark som IB' renders; re-tagging one would strand its transaction against a movement- excluded entry. getReconciliationStatus counts unmatched GL lines with the account-scoped RPC so the status card agrees with the table. Fixes #1026 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of requests over 300ms. Target: p95 under 300ms. - requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of a second network getUser per request; getUser fallback keeps HS256 self-hosted and existing test mocks working; middleware still revocation-checks every /api request - resolve_active_company RPC (20260723161000): one round trip replaces 2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall back to the legacy query path - arsredovisning build-data: ~33 sequential round trips down to ~7, output byte-identical (snapshot-proven) - currency rate route: stop bypassing the exchange_rates cache (missing supabase arg caused an external Riksbanken call on every request) - document.get: parallelize row fetch, signed URL and audit event - list_company_accounts RPC (20260723170000): accounts list in one round trip instead of paging past PostgREST's 1000-row cap - vat-declaration route: drop a dead sequential company_settings query - get_kpi_report_aggregates RPC (20260723180000): KPI report's three full-period line scans collapsed into one aggregate call; dimension- filtered path unchanged - lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to warn, zero the eslint baseline ratchet All four gates green: lint 0 errors, 9163 tests, check:guards, build. Migrations applied idempotently to staging only; prod receives them via Supabase branching on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): resolve PR review findings across auth, VAT declaration, and IB retag - requireAuth getClaims fast path: pin iss (project URL) and aud ('authenticated'), log every fallback to getUser (ASVS V9.1 finding) - remove the ignored accountingMethod parameter from calculateVatDeclaration and the dead company_settings.accounting_method reads in xlsx/pdf/eskd routes; v1 API keeps accepting the query param but documents it as a no-op - close the mark_entry_as_opening_balance TOCTOU race with a transactions trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests; applied to staging and smoke-verified both directions - re-add the 42501 tenant guard to branch-local migration 20260723160000 (function body had silently reverted to the pre-20260619130100 definition) - document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt opening balance per BFNAR 2012:1 ch.29) - add KPI VAT-liability test covering reduced-rate output accounts 2621/2631 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard The re-added tenant guard carried the pre-20260703180000 raw NOT IN (SELECT user_company_ids()) pattern, which the null-safe-tenant-guards ratchet blocks. Staging re-synced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
762 lines
26 KiB
TypeScript
762 lines
26 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// ============================================================
|
|
// Mock: table-keyed result queues
|
|
// Each table has its own FIFO queue. Calls to the same table
|
|
// consume results in order, regardless of global query ordering.
|
|
// ============================================================
|
|
|
|
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', 'lte', 'gte', 'neq', 'or', 'order', 'range']) {
|
|
b[m] = vi.fn().mockReturnValue(b)
|
|
}
|
|
const consume = (): MockResult => {
|
|
const queue = mockResults[tableName]
|
|
if (!queue || queue.length === 0) {
|
|
// The two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts) reads
|
|
// journal_entries before journal_entry_lines. Tests queue line rows
|
|
// directly, so default the entries step to one generic entry: the mock
|
|
// ignores filters and the reports under test only consume line rows.
|
|
if (tableName === 'journal_entries') {
|
|
return { data: [{ id: 'entry-1' }], error: null }
|
|
}
|
|
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 { generateTrialBalance } from '../trial-balance'
|
|
|
|
let supabase: ReturnType<typeof makeClient>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockResults = {}
|
|
supabase = makeClient()
|
|
})
|
|
|
|
describe('generateTrialBalance', () => {
|
|
it('returns empty report when no lines exist', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: null, error: null },
|
|
],
|
|
// getOpeningBalances gets null period → returns empty
|
|
// period lines query → empty
|
|
journal_entry_lines: [
|
|
{ data: [], error: null },
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
|
|
|
expect(result.rows).toEqual([])
|
|
expect(result.totalDebit).toBe(0)
|
|
expect(result.totalCredit).toBe(0)
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
it('aggregates lines by account and sorts by account_number', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
// period lines (prior lines now come from RPC: defaults to empty)
|
|
{
|
|
data: [
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
|
{ account_number: '1930', debit_amount: 300, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 200 },
|
|
{ account_number: '1930', debit_amount: 450, credit_amount: 0 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Försäljning', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
|
|
|
expect(result.rows).toHaveLength(2)
|
|
// Sorted by account number
|
|
expect(result.rows[0].account_number).toBe('1930')
|
|
expect(result.rows[1].account_number).toBe('3001')
|
|
|
|
// Aggregated correctly: opening is 0 (first year)
|
|
expect(result.rows[0].opening_debit).toBe(0)
|
|
expect(result.rows[0].opening_credit).toBe(0)
|
|
expect(result.rows[0].period_debit).toBe(750)
|
|
expect(result.rows[0].closing_debit).toBe(750)
|
|
expect(result.rows[0].closing_credit).toBe(0)
|
|
expect(result.rows[1].closing_debit).toBe(0)
|
|
expect(result.rows[1].closing_credit).toBe(700)
|
|
})
|
|
|
|
it('computes opening balances from prior period entries', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2025-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
'rpc:compute_prior_opening_balances': [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit: 10000, credit: 0 },
|
|
{ account_number: '2099', debit: 0, credit: 10000 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entry_lines: [
|
|
// period lines
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 0, credit_amount: 500 },
|
|
{ account_number: '5410', debit_amount: 500, credit_amount: 0 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
|
{ account_number: '2099', account_name: 'Årets resultat', account_class: 2 },
|
|
{ account_number: '5410', account_name: 'Förbrukningsinventarier', account_class: 5 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-2')
|
|
|
|
// 1930: opening debit 10000, period credit 500 → closing debit 10000, credit 500
|
|
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
|
expect(acc1930.opening_debit).toBe(10000)
|
|
expect(acc1930.opening_credit).toBe(0)
|
|
expect(acc1930.period_debit).toBe(0)
|
|
expect(acc1930.period_credit).toBe(500)
|
|
expect(acc1930.closing_debit).toBe(10000)
|
|
expect(acc1930.closing_credit).toBe(500)
|
|
|
|
// 2099: opening credit 10000, no period activity → closing credit 10000
|
|
const acc2099 = result.rows.find((r) => r.account_number === '2099')!
|
|
expect(acc2099.opening_debit).toBe(0)
|
|
expect(acc2099.opening_credit).toBe(10000)
|
|
expect(acc2099.period_debit).toBe(0)
|
|
expect(acc2099.closing_debit).toBe(0)
|
|
expect(acc2099.closing_credit).toBe(10000)
|
|
|
|
// 5410: no opening, period debit 500
|
|
const acc5410 = result.rows.find((r) => r.account_number === '5410')!
|
|
expect(acc5410.opening_debit).toBe(0)
|
|
expect(acc5410.period_debit).toBe(500)
|
|
expect(acc5410.closing_debit).toBe(500)
|
|
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
it('uses opening_balance_entry when available', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2025-01-01', opening_balance_entry_id: 'ob-entry-1' }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
// OB entry lines (from getOpeningBalances)
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 8000, credit_amount: 0 },
|
|
{ account_number: '2099', debit_amount: 0, credit_amount: 8000 },
|
|
],
|
|
error: null,
|
|
},
|
|
// period lines (OB entry excluded via .neq)
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
|
{ account_number: '2099', account_name: 'Årets resultat', account_class: 2 },
|
|
{ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-2')
|
|
|
|
// 1930: opening 8000 debit + period 1000 debit = closing 9000 debit
|
|
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
|
expect(acc1930.opening_debit).toBe(8000)
|
|
expect(acc1930.closing_debit).toBe(9000)
|
|
expect(acc1930.closing_credit).toBe(0)
|
|
|
|
// 2099: opening 8000 credit, no period activity
|
|
const acc2099 = result.rows.find((r) => r.account_number === '2099')!
|
|
expect(acc2099.opening_credit).toBe(8000)
|
|
expect(acc2099.closing_credit).toBe(8000)
|
|
|
|
// 3001: no opening, period 1000 credit
|
|
const acc3001 = result.rows.find((r) => r.account_number === '3001')!
|
|
expect(acc3001.opening_debit).toBe(0)
|
|
expect(acc3001.closing_credit).toBe(1000)
|
|
})
|
|
|
|
it('falls back to "Konto {number}" when account not in chart_of_accounts', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '9999', debit_amount: 100, credit_amount: 0 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{ data: [], error: null },
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
|
|
|
expect(result.rows[0].account_name).toBe('Konto 9999')
|
|
})
|
|
|
|
it('derives account_class from first digit when account not in chart', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '5410', debit_amount: 200, credit_amount: 0 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{ data: [], error: null },
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
|
|
|
expect(result.rows[0].account_class).toBe(5)
|
|
})
|
|
|
|
it('uses Math.round for monetary precision', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
|
|
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
|
|
{ account_number: '1930', debit_amount: 33.34, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 100 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
|
|
|
expect(result.rows[0].closing_debit).toBe(100)
|
|
expect(result.totalDebit).toBe(100)
|
|
expect(result.totalCredit).toBe(100)
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
it('detects unbalanced entries (isBalanced=false)', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 999 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
|
|
|
expect(result.totalDebit).toBe(1000)
|
|
expect(result.totalCredit).toBe(999)
|
|
expect(result.isBalanced).toBe(false)
|
|
})
|
|
|
|
it('throws when lines query errors', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{ data: null, error: { message: 'DB error' } },
|
|
],
|
|
}
|
|
|
|
await expect(generateTrialBalance(supabase, 'company-1', 'period-1')).rejects.toThrow('DB error')
|
|
})
|
|
|
|
it('handles balanced two-account entry', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 5000 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
|
|
|
expect(result.rows).toHaveLength(2)
|
|
expect(result.totalDebit).toBe(5000)
|
|
expect(result.totalCredit).toBe(5000)
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
// ── Date-range tests ─────────────────────────────────────────────
|
|
// The 4 reports (resultatrapport/balansrapport/income-statement/balance-
|
|
// sheet) thread an optional { fromDate, toDate } through to the trial
|
|
// balance. The engine must (a) skip the roll-forward query when fromDate
|
|
// equals period_start, (b) roll prior in-period lines into IB when
|
|
// fromDate is later, and (c) clamp period activity to the window.
|
|
|
|
it('treats omitted range as parity with the full 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: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
|
|
|
// Same as the existing "balanced two-account" case: no roll-forward query
|
|
// is consumed because no range is requested.
|
|
expect(result.rows).toHaveLength(2)
|
|
expect(result.totalDebit).toBe(1000)
|
|
expect(result.totalCredit).toBe(1000)
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
it('skips the roll-forward query when fromDate equals period_start', 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: [
|
|
// Only the period query: no roll-forward fetch should be triggered.
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
fromDate: '2024-01-01',
|
|
toDate: '2024-06-30',
|
|
})
|
|
|
|
expect(result.rows[0].opening_debit).toBe(0)
|
|
expect(result.rows[0].closing_debit).toBe(500)
|
|
})
|
|
|
|
it('rolls prior in-period lines into IB when fromDate is after period_start', 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: [
|
|
// 1st consumption: roll-forward query for [2024-01-01, 2024-04-01).
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 2000, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 2000 },
|
|
],
|
|
error: null,
|
|
},
|
|
// 2nd consumption: period activity for [2024-04-01, 2024-06-30].
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
fromDate: '2024-04-01',
|
|
toDate: '2024-06-30',
|
|
})
|
|
|
|
// 1930: IB carries 2000 from Q1, period adds 500 → UB 2500
|
|
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
|
expect(acc1930.opening_debit).toBe(2000)
|
|
expect(acc1930.period_debit).toBe(500)
|
|
expect(acc1930.closing_debit).toBe(2500)
|
|
|
|
// 3001: IB carries 2000 from Q1, period adds 500 → UB 2500
|
|
const acc3001 = result.rows.find((r) => r.account_number === '3001')!
|
|
expect(acc3001.opening_credit).toBe(2000)
|
|
expect(acc3001.period_credit).toBe(500)
|
|
expect(acc3001.closing_credit).toBe(2500)
|
|
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
// ── final-closing precision ──────────────────────────────────────
|
|
// Tax and appropriations are also source_type='year_end'. The statutory
|
|
// pre-closing report must exclude only fiscal_periods.closing_entry_id.
|
|
|
|
it('excludes only the fiscal period closing entry', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: 'closing-1',
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [{ data: [{ id: 'tax-1' }, { id: 'appropriation-1' }], error: null }],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
|
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
excludeFinalClosingEntry: true,
|
|
})
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
|
|
expect(neqCalls).not.toContainEqual(['source_type', 'year_end'])
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
|
|
expect(orCalls).toContainEqual(['id.neq.closing-1,status.neq.posted'])
|
|
})
|
|
|
|
it('fails closed when a closed period has no linked final closing entry', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: null,
|
|
is_closed: true,
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [{ data: [{ id: 'tax-1' }, { id: 'appropriation-1' }], error: null }],
|
|
journal_entry_lines: [{ data: [], error: null }],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
await expect(
|
|
generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
excludeFinalClosingEntry: true,
|
|
}),
|
|
).rejects.toThrow(/missing closing_entry_id/i)
|
|
|
|
// The guard throws before any journal data is read. The chart of
|
|
// accounts is part of the first parallel wave (alongside the period
|
|
// fetch), so it may have been queried; the entry/line tables must not be.
|
|
const tables = supabase.from.mock.calls.map((c: unknown[]) => c[0])
|
|
expect(tables).not.toContain('journal_entries')
|
|
expect(tables).not.toContain('journal_entry_lines')
|
|
})
|
|
|
|
it('keeps year-end adjustments for an open period without a final closing entry', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: null,
|
|
is_closed: false,
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [{ data: [{ id: 'tax-1' }], error: null }],
|
|
journal_entry_lines: [{ data: [], error: null }],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
excludeFinalClosingEntry: true,
|
|
})
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
|
|
expect(neqCalls).not.toContainEqual(['source_type', 'year_end'])
|
|
})
|
|
|
|
it('keeps a linked reversed closing together with its storno', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: 'closing-1',
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [{ data: [{ id: 'closing-1' }, { id: 'storno-1' }], error: null }],
|
|
journal_entry_lines: [{ data: [], error: null }],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
excludeFinalClosingEntry: true,
|
|
})
|
|
|
|
// The OR excludes closing-1 only while status is posted. If it is
|
|
// reversed during an administrative undo, both it and its storno remain.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
|
|
expect(orCalls).toContainEqual(['id.neq.closing-1,status.neq.posted'])
|
|
})
|
|
|
|
it('preserves the broad year-end exclusion for operational reports', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: 'closing-1',
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [
|
|
{ data: [{ id: 'reversed-year-end-1' }], error: null },
|
|
{ data: [{ id: 'ordinary-1' }], error: null },
|
|
],
|
|
journal_entry_lines: [{ data: [], error: null }],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
excludeYearEndClosing: true,
|
|
})
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
|
|
expect(neqCalls).toContainEqual(['source_type', 'year_end'])
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
|
|
expect(orCalls).toContainEqual([
|
|
'reverses_id.is.null,reverses_id.not.in.(reversed-year-end-1)',
|
|
])
|
|
expect(orCalls).toContainEqual([
|
|
'correction_of_id.is.null,correction_of_id.not.in.(reversed-year-end-1)',
|
|
])
|
|
})
|
|
|
|
it('returns empty period activity when the range matches no lines', 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: [
|
|
// Roll-forward query: has prior activity
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 750, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 750 },
|
|
],
|
|
error: null,
|
|
},
|
|
// Period query: no lines inside [2024-11-01, 2024-11-30]
|
|
{ data: [], error: null },
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
fromDate: '2024-11-01',
|
|
toDate: '2024-11-30',
|
|
})
|
|
|
|
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
|
expect(acc1930.opening_debit).toBe(750)
|
|
expect(acc1930.period_debit).toBe(0)
|
|
expect(acc1930.closing_debit).toBe(750)
|
|
})
|
|
})
|