* 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>
200 lines
5.9 KiB
TypeScript
200 lines
5.9 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
fetchEntryLines,
|
|
fetchLinesByEntryIds,
|
|
type EntryLinesQuery,
|
|
} from '@/lib/bookkeeping/entry-lines'
|
|
import { calculatePeriodDates } from './vat-declaration'
|
|
import type { VatPeriodType } from '@/types'
|
|
|
|
/**
|
|
* Per-voucher detection of FK004: reverse-charge output VAT booked
|
|
* (2614/2624/2634) without a matching basbelopp pair on 44xx/45xx.
|
|
*
|
|
* Used by the momsdeklaration UI to give the user a concrete list of
|
|
* verifikationer to correct, rather than a generic "ruta 30-32 utan
|
|
* ruta 20-24" warning that doesn't tell them what to fix.
|
|
*/
|
|
|
|
const RC_OUTPUT_ACCOUNTS = ['2614', '2624', '2634'] as const
|
|
type RcOutputAccount = typeof RC_OUTPUT_ACCOUNTS[number]
|
|
|
|
const RC_BASIS_ACCOUNTS = new Set([
|
|
'4515', '4516', '4517', // EU goods 25/12/6%
|
|
'4531', '4532', '4533', // non-EU services 25/12/6%
|
|
'4535', '4536', '4537', // EU services 25/12/6%
|
|
'4415', '4416', '4417', // domestic goods RC
|
|
'4425', '4426', '4427', // domestic services RC
|
|
])
|
|
|
|
const RATE_BY_OUTPUT: Record<RcOutputAccount, number> = {
|
|
'2614': 0.25,
|
|
'2624': 0.12,
|
|
'2634': 0.06,
|
|
}
|
|
|
|
// Default to EU services (matches the booking-template default
|
|
// reverse_charge_supplier_type = 'eu_business'). The user can pick a
|
|
// different supplier type on the Korrigera form if needed.
|
|
const DEFAULT_BASIS_BY_OUTPUT: Record<RcOutputAccount, string> = {
|
|
'2614': '4535',
|
|
'2624': '4536',
|
|
'2634': '4537',
|
|
}
|
|
|
|
export interface RcBasisGap {
|
|
entryId: string
|
|
voucherNumber: number
|
|
voucherSeries: string
|
|
entryDate: string
|
|
description: string
|
|
rcOutputAccount: RcOutputAccount
|
|
rcOutputAmount: number
|
|
expectedBasisAmount: number
|
|
suggestedBasisAccount: string
|
|
rate: number
|
|
}
|
|
|
|
interface RcLineRow {
|
|
journal_entry_id: string
|
|
account_number: string
|
|
debit_amount: number
|
|
credit_amount: number
|
|
// Supabase typings unpredictably model joined relations as either an object
|
|
// or an array depending on the FK; we accept both and normalize below.
|
|
journal_entries:
|
|
| {
|
|
id: string
|
|
voucher_number: number
|
|
voucher_series: string
|
|
entry_date: string
|
|
description: string
|
|
}
|
|
| {
|
|
id: string
|
|
voucher_number: number
|
|
voucher_series: string
|
|
entry_date: string
|
|
description: string
|
|
}[]
|
|
}
|
|
|
|
interface EntryFields {
|
|
id: string
|
|
voucher_number: number
|
|
voucher_series: string
|
|
entry_date: string
|
|
description: string
|
|
}
|
|
|
|
function pickEntry(row: RcLineRow): EntryFields | null {
|
|
const j = row.journal_entries
|
|
if (Array.isArray(j)) return j.length > 0 ? j[0] : null
|
|
return j ?? null
|
|
}
|
|
|
|
interface SiblingLineRow {
|
|
id: string
|
|
journal_entry_id: string
|
|
account_number: string
|
|
debit_amount: number
|
|
credit_amount: number
|
|
}
|
|
|
|
export async function findRcBasisGaps(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
periodType: VatPeriodType,
|
|
year: number,
|
|
period: number,
|
|
): Promise<RcBasisGap[]> {
|
|
const { start, end } = calculatePeriodDates(periodType, year, period)
|
|
|
|
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
|
const rcLines = (await fetchEntryLines<unknown>({
|
|
supabase,
|
|
entryColumns:
|
|
'id, voucher_number, voucher_series, entry_date, description, status, company_id',
|
|
lineColumns: 'journal_entry_id, account_number, debit_amount, credit_amount',
|
|
filterEntries: (q: EntryLinesQuery) =>
|
|
q
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'posted')
|
|
.gte('entry_date', start)
|
|
.lte('entry_date', end),
|
|
filterLines: (q: EntryLinesQuery) =>
|
|
q.in('account_number', RC_OUTPUT_ACCOUNTS as unknown as string[]),
|
|
})) as RcLineRow[]
|
|
|
|
if (rcLines.length === 0) return []
|
|
|
|
const entryIds = [...new Set(rcLines.map((l) => l.journal_entry_id))]
|
|
|
|
const siblingLines = await fetchLinesByEntryIds<SiblingLineRow>(
|
|
supabase,
|
|
entryIds,
|
|
'id, journal_entry_id, account_number, debit_amount, credit_amount',
|
|
)
|
|
|
|
const basisByEntry = new Map<string, number>()
|
|
for (const line of siblingLines) {
|
|
if (RC_BASIS_ACCOUNTS.has(line.account_number)) {
|
|
const prev = basisByEntry.get(line.journal_entry_id) || 0
|
|
basisByEntry.set(
|
|
line.journal_entry_id,
|
|
prev + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Aggregate RC output per (entry, account): a voucher may have multiple
|
|
// 2614 lines (rare) and we want to flag the total shortfall.
|
|
const aggregated = new Map<string, { row: RcLineRow; amount: number }>()
|
|
for (const line of rcLines) {
|
|
const key = `${line.journal_entry_id}:${line.account_number}`
|
|
const amount = (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0)
|
|
const existing = aggregated.get(key)
|
|
if (existing) {
|
|
existing.amount += amount
|
|
} else {
|
|
aggregated.set(key, { row: line, amount })
|
|
}
|
|
}
|
|
|
|
const eps = 0.5
|
|
const gaps: RcBasisGap[] = []
|
|
for (const { row, amount } of aggregated.values()) {
|
|
if (amount <= eps) continue
|
|
const account = row.account_number as RcOutputAccount
|
|
const rate = RATE_BY_OUTPUT[account]
|
|
if (!rate) continue
|
|
const expectedBasis = Math.round((amount / rate) * 100) / 100
|
|
const actualBasis = basisByEntry.get(row.journal_entry_id) || 0
|
|
if (actualBasis + eps >= expectedBasis) continue
|
|
|
|
const entry = pickEntry(row)
|
|
if (!entry) continue
|
|
gaps.push({
|
|
entryId: row.journal_entry_id,
|
|
voucherNumber: entry.voucher_number,
|
|
voucherSeries: entry.voucher_series,
|
|
entryDate: entry.entry_date,
|
|
description: entry.description,
|
|
rcOutputAccount: account,
|
|
rcOutputAmount: amount,
|
|
expectedBasisAmount: expectedBasis,
|
|
suggestedBasisAccount: DEFAULT_BASIS_BY_OUTPUT[account],
|
|
rate,
|
|
})
|
|
}
|
|
|
|
gaps.sort((a, b) => {
|
|
if (a.voucherSeries !== b.voucherSeries) {
|
|
return a.voucherSeries.localeCompare(b.voucherSeries)
|
|
}
|
|
return a.voucherNumber - b.voucherNumber
|
|
})
|
|
|
|
return gaps
|
|
}
|