* fix(reports): drive report line queries from journal_entries, not the unfiltered lines side
Every report generator fetched journal_entry_lines with a
journal_entries!inner(...) embed and put the tenant filter on the
embedded side (.eq('journal_entries.company_id', ...)). PostgREST
compiles that to a correlated INNER JOIN LATERAL with a parameterized
LIMIT inside, which blocks join reordering: Postgres walked the ENTIRE
journal_entry_lines table (603k rows, all tenants) per report query.
Measured in production: 13.6 s vs 2.7 ms for the equivalent plain join,
against Supabase's 8 s statement_timeout; nightly cloud backups failed
for 5 of 11 companies on 2026-07-09 and a GL report 500'd.
Introduce lib/bookkeeping/entry-lines.ts with a shared two-step fetch:
1. fetch matching journal_entries (id + caller-selected columns)
filtered by company_id / fiscal_period_id / status / entry_date /
source_type, paginated via fetchAllRows;
2. fetch journal_entry_lines with .in('journal_entry_id', chunk) in
chunks of 100 ids (URL-length safety), paginated per chunk;
3. reattach the parent entry to each line under the embed's key shape
(line.journal_entries = {...}, aliasable) and sort lines by id
ascending to preserve the old .order('id') semantics.
Converted call sites (selected columns and filters preserved):
trial-balance (x2), general-ledger, journal-register, sie-export
(reuses its existing entry list via fetchLinesByEntryIds),
vat-declaration, dimension-pnl, opening-balances, monthly-breakdown,
periodisk-sammanstallning, rc-basis-gaps (sibling-line fetch now also
chunked), ar-reconciliation, supplier-reconciliation,
bank-reconciliation, asset-service (x2), bolagsskatt-calculator,
sarskild-loneskatt-calculator.
Tests: unit tests for the helper (chunk size, reattachment shape,
forced id/journal_entry_id columns, empty result, cross-chunk sort,
error propagation); existing report/reconciliation/bokslut test mocks
updated to the two-step query shape, preserving every assertion about
report output.
From the 2026-07-09 production log triage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reports): stop echoing raw error messages from the general-ledger route
The catch handler returned err.message to the client in
details.reason; internal error strings (SQL fragments, table names,
timeout messages) must not reach the browser. The error is already
logged server-side with the request id, so the client envelope keeps
only the REPORT_GENERATION_FAILED code.
From the 2026-07-09 production log triage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
81 lines
2.8 KiB
TypeScript
81 lines
2.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import {
|
||
calculateSarskildLoneskatt,
|
||
SLP_RATE,
|
||
} from '../tax-provision/sarskild-loneskatt-calculator'
|
||
|
||
function makeSupabaseWithPensionLines(rows: Array<{ debit_amount: number; credit_amount: number }>) {
|
||
// The calculator uses the two-step entry-lines fetch
|
||
// (lib/bookkeeping/entry-lines.ts): call 1 reads journal_entries, call 2
|
||
// reads journal_entry_lines for those entry ids.
|
||
const responses: Array<{ data: unknown; error: unknown }> = [
|
||
{ data: [{ id: 'entry-1' }], error: null },
|
||
{ data: rows, error: null },
|
||
]
|
||
let call = 0
|
||
const makeBuilder = () => {
|
||
const result = responses[call++] ?? { data: null, error: null }
|
||
const b: Record<string, unknown> = {}
|
||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'order', 'range']) {
|
||
b[m] = vi.fn().mockReturnValue(b)
|
||
}
|
||
b.then = (resolve: (v: { data: unknown; error: unknown }) => void) => resolve(result)
|
||
return b
|
||
}
|
||
return { from: vi.fn().mockImplementation(() => makeBuilder()) } as unknown as Parameters<
|
||
typeof calculateSarskildLoneskatt
|
||
>[0]
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
})
|
||
|
||
describe('calculateSarskildLoneskatt', () => {
|
||
it('applies 24.26% to pension costs and posts 7533/2514', async () => {
|
||
const supabase = makeSupabaseWithPensionLines([
|
||
{ debit_amount: 50_000, credit_amount: 0 },
|
||
{ debit_amount: 30_000, credit_amount: 0 },
|
||
])
|
||
|
||
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
|
||
|
||
expect(result).not.toBeNull()
|
||
// base = 80_000, × 0.2426 = 19_408
|
||
expect(result!.amount).toBe(19_408)
|
||
expect(result!.lines[0].account_number).toBe('7533')
|
||
expect(result!.lines[1].account_number).toBe('2514')
|
||
})
|
||
|
||
it('returns null when there are no pension costs', async () => {
|
||
const supabase = makeSupabaseWithPensionLines([])
|
||
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
|
||
expect(result).toBeNull()
|
||
})
|
||
|
||
it('honors manual adjustment for pensionsavsättning on 2210', async () => {
|
||
const supabase = makeSupabaseWithPensionLines([])
|
||
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp', {
|
||
manualAdjustment: 100_000,
|
||
})
|
||
expect(result).not.toBeNull()
|
||
// 100_000 × 0.2426 = 24_260
|
||
expect(result!.amount).toBe(24_260)
|
||
})
|
||
|
||
it('nets debits against credits (refund of pension premium reduces base)', async () => {
|
||
const supabase = makeSupabaseWithPensionLines([
|
||
{ debit_amount: 50_000, credit_amount: 0 },
|
||
{ debit_amount: 0, credit_amount: 10_000 },
|
||
])
|
||
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
|
||
expect(result).not.toBeNull()
|
||
// base = 40_000, × 0.2426 = 9_704
|
||
expect(result!.amount).toBe(9_704)
|
||
})
|
||
|
||
it('exposes the SLP rate constant', () => {
|
||
expect(SLP_RATE).toBe(0.2426)
|
||
})
|
||
})
|