fix: audit batch — pagination truncation, MFA/dead-code cleanup, mark-paid fail-closed (#841)

* fix(reports): paginate 8 more report/ledger queries (1000-row truncation)

Raw .select() without fetchAllRows() silently caps at PostgREST's 1000-row
limit, producing wrong statutory output for high-volume companies. Following
#806 (trial-balance/VAT), wrap the remaining offenders in
fetchAllRows + a stable .order('id') + dedupeBy:

- ink2-engine / ne-engine: INK2 & NE-bilaga tax declarations under-counted
- ar-reconciliation (1510/1513), supplier-reconciliation (2440): phantom
  "Ej avstämd" gaps
- full-archive-export: 7-year DR archive (added a unique total order so rows
  are not silently skipped/duplicated across pages)
- avgifter-basis, currency-revaluation, vat-declaration

Adds a regression guard test asserting >1000 ledger lines are summed, not
truncated at 1000.

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

* fix(api): close extension-dispatcher MFA gap, scope /api/events to API key, sweep dead code

Security/correctness:
- ext/[...path] dispatcher now uses requireAuth() instead of inline
  supabase.auth.getUser(), enforcing MFA (AAL2) on hosted across the whole
  enabled-extension surface (banking sync, document upload/booking, supplier
  invoices, migration). Ratchets antipatterns-baseline raw-route-auth 168->165.
- /api/events now filters by the API key's bound company_id instead of the
  user's active company (was a cross-company read with a scoped key).
- enable-banking OAuth callback calls ensureInitialized() at module load so
  the PSD2 consent audit event (ASVS V16 / GDPR Art.30) isn't dropped on a
  cold-start instance.

Dead-code sweep (all confirmed zero importers):
- delete lib/tax/calculator.ts, lib/salary/engangsskatt.ts (+test),
  lib/email/resend.ts, lib/salary/salary-transaction-matcher.ts,
  lib/webhooks/diff.ts, lib/salary/effective-values.ts,
  lib/bookkeeping/template-prompt.ts
- trim unused lib/vat/eu-countries.ts helpers (keep EU_COUNTRIES)
- remove dead getAutomaticStatus() and the abandoned Activepieces CSP entry

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

* fix(invoices): fail closed when a payment journal entry doesn't post

Three mark-paid paths (legacy route, v1 API, agent commit) diverged on the
"mark paid but the JE failed" case — two would flip the invoice to paid (or
leave an orphaned posted voucher) with no booking, silently diverging the GL
from the AR/AP sub-ledger. Unify on fail-closed:

- legacy + v1 + agent commitMarkInvoicePaid: never mark paid without a posted
  voucher; on a null/failed JE return INVOICE_PAID_BOOK_FAILED before any
  state mutation (v1 mirrors the match-invoice strict mode).
- agent path: add the .in('status',[...]).select('id') CAS guard and cancel
  the orphaned voucher (cancelOrphanedPaymentEntry) on a lost race or update
  error, matching the web route.
- legacy route: cancel the orphan on a non-race update error too (was only
  handled on the race branch).
- supplier mark-paid: stop swallowing a failed supplier_invoice_payments
  insert — that row drives the reversal amount in payment-sync; roll back the
  status flip and cancel the voucher instead.
- pending-ops orchestrator: error-check the terminal 'committed' write so an
  op stranded in 'committing' (the expire sweep only targets 'pending') is at
  least logged loudly.

Adds a guard test for the legacy fail-closed path. Full unit suite green.

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

* fix(ci): unblock core build + address compliance-review findings

- avgifter-basis.ts: fix the core-build TypeScript error — PostgREST's
  type-level select parser models the salary_run embed as an array, which
  wasn't assignable to the object-typed generic. Type it `unknown` (rows are
  read via an explicit cast), making it robust across postgrest-js versions.
- /api/events: add a non-null companyId guard before the event_log query
  (defense-in-depth for the API-key-bound scope) — addresses ASVS V8.2.1 /
  ISO A.5.15.
- supplier mark-paid: add a CAS guard (.eq('status', newStatus)) to the
  payment-insert-failure rollback so a concurrent settlement can't be
  clobbered — addresses ASVS V2.3.
- dispatcher: add an AAL2 regression test asserting a non-MFA session is
  rejected (403) and the extension handler never runs — addresses the
  GDPR Art.32 review ask for the single extension chokepoint.

Verified deletions are safe: effective-values.ts was a dead duplicate — the
live AGI/payslip path inlines the same `?? override` coalescing
(generate-declaration.ts), so AGI correctness is unaffected.

next build: exit 0. Full unit suite: 6147 passing. ESLint clean.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-30 14:34:23 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b800dcd403
commit f8504f3bd0
32 changed files with 522 additions and 870 deletions
@@ -10,7 +10,7 @@ let calls: Array<{ method: string; args: unknown[] }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
b[m] = vi.fn().mockImplementation((...args: unknown[]) => {
calls.push({ method: m, args })
return b
@@ -10,7 +10,7 @@ let calls: Array<{ method: string; args: unknown[] }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
b[m] = vi.fn().mockImplementation((...args: unknown[]) => {
calls.push({ method: m, args })
return b
@@ -71,6 +71,25 @@ describe('generateReconciliation', () => {
expect(result.is_reconciled).toBe(true)
})
it('paginates the 2440 ledger query — sums >1000 lines instead of truncating at 1000', async () => {
// Regression guard for the silent PostgREST 1000-row cap: fetchAllRows must
// page through ALL ledger lines. A full first page (length === PAGE_SIZE)
// forces a second fetch.
// Unique ids so the dedupe-by-id safety net doesn't collapse rows.
const page1 = Array.from({ length: 1000 }, (_, i) => ({ id: `p1-${i}`, debit_amount: 0, credit_amount: 10 }))
const page2 = Array.from({ length: 500 }, (_, i) => ({ id: `p2-${i}`, debit_amount: 0, credit_amount: 10 }))
results = [
{ data: [], error: null }, // 0: supplier_invoices — none open
{ data: page1, error: null }, // 1: 2440 lines page 1 (full → triggers next page)
{ data: page2, error: null }, // 2: 2440 lines page 2 (partial → stop)
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
// 1500 lines × 10 = 15 000. A 1000-row truncation would wrongly yield 10 000.
expect(result.account_2440_balance).toBe(15000)
})
it('detects mismatch when difference != 0', async () => {
results = [
// 0: supplier_invoices — total 5000
+46 -24
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface ARReconciliationResult {
ar_ledger_total: number
@@ -40,11 +41,23 @@ export async function generateARReconciliation(
// total/paid_amount are stored in invoice currency; account 1510 is in SEK
// (booked at invoice-date rate), so convert each row before summing.
const { data: invoices } = await supabase
.from('invoices')
.select('total, paid_amount, currency, exchange_rate')
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
// Paginated: a company with >1000 open invoices would otherwise be silently
// truncated, manufacturing a phantom reconciliation gap.
const invoices = await fetchAllRows<{
id: string
total: number | null
paid_amount: number | null
currency: string | null
exchange_rate: number | null
}>(({ from, to }) =>
supabase
.from('invoices')
.select('id, total, paid_amount, currency, exchange_rate')
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.order('id', { ascending: true })
.range(from, to)
)
let unconvertedFxCount = 0
const arLedgerTotal = (invoices || [])
@@ -73,28 +86,37 @@ export async function generateARReconciliation(
// trial balance / balance sheet use. A corrected invoice flips its original to
// status='reversed'; that reversed leg is cancelled by the posted storno, so
// both must be summed or a corrected invoice manufactures a phantom gap.
const { data: journalLines } = await supabase
.from('journal_entry_lines')
.select(`
debit_amount,
credit_amount,
journal_entry:journal_entries!inner(
status,
company_id,
fiscal_period_id
)
`)
.in('account_number', ['1510', '1513'])
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
// Paginated with a stable id order (+ dedupe defense) so a period with >1000
// ledger lines on 1510/1513 isn't silently truncated into a phantom gap.
const journalLines = await fetchAllRows<{
id: string
debit_amount: number | null
credit_amount: number | null
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
id,
debit_amount,
credit_amount,
journal_entry:journal_entries!inner(
status,
company_id,
fiscal_period_id
)
`)
.in('account_number', ['1510', '1513'])
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (l) => l.id })
// Both 1510 and 1513 are debit-normal assets: balance = debits - credits
let account1510Balance = 0
if (journalLines) {
for (const line of journalLines) {
account1510Balance = Math.round((account1510Balance + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)) * 100) / 100
}
for (const line of journalLines) {
account1510Balance = Math.round((account1510Balance + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)) * 100) / 100
}
const difference = Math.round((arLedgerTotal - account1510Balance) * 100) / 100
+29 -13
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* Arbetsgivaravgiftsunderlag — Employer contribution basis report.
@@ -53,21 +54,36 @@ export async function generateAvgifterBasis(
): Promise<AvgifterBasisReport> {
const r = (x: number) => Math.round(x * 100) / 100
// Load all salary run employees for booked runs this year
const { data: runEmployees, error } = await supabase
.from('salary_run_employees')
.select(`
avgifter_basis,
avgifter_amount,
avgifter_rate,
salary_run:salary_runs!inner(period_year, period_month, status)
`)
.eq('company_id', companyId)
if (error) throw new Error(`Failed to load avgifter data: ${error.message}`)
// Load all salary run employees for booked runs this year.
// Paginated with a stable id order: a multi-year/high-headcount company can
// exceed PostgREST's 1000-row cap, and a silent truncation here would
// under-report the arbetsgivaravgifter basis reconciled against AGI filings.
const runEmployees = await fetchAllRows<{
id: string
avgifter_basis: number
avgifter_amount: number
avgifter_rate: number
// PostgREST's type-level select parser models an embedded resource as an
// array, so keep this `unknown` (the rows are read via an explicit cast
// below) to stay assignable regardless of postgrest-js version.
salary_run: unknown
}>(({ from, to }) =>
supabase
.from('salary_run_employees')
.select(`
id,
avgifter_basis,
avgifter_amount,
avgifter_rate,
salary_run:salary_runs!inner(period_year, period_month, status)
`)
.eq('company_id', companyId)
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (e) => e.id })
// Filter to booked runs for the year
const bookedForYear = (runEmployees || []).filter(sre => {
const bookedForYear = runEmployees.filter(sre => {
const run = sre.salary_run as unknown as { period_year: number; period_month: number; status: string } | null
return run && run.period_year === year && run.status === 'booked'
})
+8 -3
View File
@@ -686,13 +686,18 @@ async function writeMasterData(
await Promise.all(
tables.map(async (t) => {
try {
const rows = await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
const rows = await fetchAllRows<{ id: string } & Record<string, unknown>>(({ from, to }) => {
let q = supabase.from(t.name).select('*').eq('company_id', companyId)
if (t.orderBy) {
q = q.order(t.orderBy, { ascending: true })
}
return q.range(from, to)
})
// Always end on the unique PK so paging has a stable TOTAL order. A
// non-unique display order (e.g. created_at) or no order at all
// silently SKIPS/DUPLICATES rows across page boundaries — data loss in
// a statutory 7-year retention archive. (All these tables have an
// `id uuid` PK.) dedupeBy is defense-in-depth against the duplicate case.
return q.order('id', { ascending: true }).range(from, to)
}, { dedupeBy: (r) => r.id })
data.file(t.file, JSON.stringify(rows, null, 2))
} catch (err) {
data.file(
+14 -11
View File
@@ -727,17 +727,20 @@ export async function generateINK2Declaration(
throw new Error('INK2 declaration is only for aktiebolag (limited company)')
}
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch all posted journal entries with lines for this period.
// Paginated: a period can exceed PostgREST's 1000-row cap, and a silent
// truncation here would under-report the INK2 tax declaration. PostgREST
// ranges count parent rows, so the embedded lines come with each entry.
const entries = await fetchAllRows<JournalEntry>(({ from, to }) =>
supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (e) => e.id })
// Fetch chart of accounts for account names
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
+14 -11
View File
@@ -195,17 +195,20 @@ export async function generateNEDeclaration(
throw new Error('NE declaration is only for enskild firma (sole proprietorship)')
}
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch all posted journal entries with lines for this period.
// Paginated: a period can exceed PostgREST's 1000-row cap, and a silent
// truncation here would under-report the NE-bilaga tax declaration. PostgREST
// ranges count parent rows, so the embedded lines come with each entry.
const entries = await fetchAllRows<JournalEntry>(({ from, to }) =>
supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (e) => e.id })
// Fetch chart of accounts for account names
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
+45 -24
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface ReconciliationResult {
supplier_ledger_total: number
@@ -33,11 +34,22 @@ export async function generateReconciliation(
// remaining_amount is stored in invoice currency; account 2440 is in SEK
// (booked at invoice-date rate), so convert each row before summing.
const { data: invoices } = await supabase
.from('supplier_invoices')
.select('remaining_amount, currency, exchange_rate')
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
// Paginated: a company with >1000 open supplier invoices would otherwise be
// silently truncated, manufacturing a phantom reconciliation gap.
const invoices = await fetchAllRows<{
id: string
remaining_amount: number | null
currency: string | null
exchange_rate: number | null
}>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select('id, remaining_amount, currency, exchange_rate')
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
.order('id', { ascending: true })
.range(from, to)
)
let unconvertedFxCount = 0
const supplierLedgerTotal = (invoices || [])
@@ -68,29 +80,38 @@ export async function generateReconciliation(
// debit balance. (This is exactly the false −41 121,25 kr "Ej avstämd" gap a
// fully-paid, fully-corrected company hit: posted-only = −41 121,25, but
// posted+reversed = 0, matching the leverantörsreskontra.)
const { data: journalLines } = await supabase
.from('journal_entry_lines')
.select(`
debit_amount,
credit_amount,
journal_entry:journal_entries!inner(
status,
company_id,
fiscal_period_id
)
`)
.eq('account_number', '2440')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
// Paginated with a stable id order (+ dedupe defense) so a period with >1000
// ledger lines on 2440 isn't silently truncated into a phantom gap.
const journalLines = await fetchAllRows<{
id: string
debit_amount: number | null
credit_amount: number | null
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
id,
debit_amount,
credit_amount,
journal_entry:journal_entries!inner(
status,
company_id,
fiscal_period_id
)
`)
.eq('account_number', '2440')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (l) => l.id })
// Account 2440 is a liability: credit normal balance
// Balance = credits - debits
let account2440Balance = 0
if (journalLines) {
for (const line of journalLines) {
account2440Balance = Math.round((account2440Balance + (line.credit_amount || 0) - (line.debit_amount || 0)) * 100) / 100
}
for (const line of journalLines) {
account2440Balance = Math.round((account2440Balance + (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0)) * 100) / 100
}
const difference = Math.round((supplierLedgerTotal - account2440Balance) * 100) / 100
+15 -9
View File
@@ -337,21 +337,27 @@ export async function calculateVatDeclaration(
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
// Count journal entries by source type for metadata
const { data: entryCounts } = await supabase
.from('journal_entries')
.select('source_type')
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.gte('entry_date', start)
.lte('entry_date', end)
// Count journal entries by source type for metadata.
// Paginated with a stable id order so the invoice/transaction counts don't
// silently truncate at 1000 entries for a busy VAT period.
const entryCounts = await fetchAllRows<{ id: string; source_type: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id, source_type')
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.gte('entry_date', start)
.lte('entry_date', end)
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (e) => e.id })
const invoiceSources = new Set([
'invoice_created', 'invoice_paid', 'invoice_cash_payment', 'credit_note',
])
let invoiceCount = 0
let transactionCount = 0
for (const e of entryCounts || []) {
for (const e of entryCounts) {
if (invoiceSources.has(e.source_type)) invoiceCount++
else if (e.source_type === 'bank_transaction') transactionCount++
}