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
-46
View File
@@ -1,46 +0,0 @@
import { describe, it, expect } from 'vitest'
import { calculateEngangsskatt } from '../engangsskatt'
describe('calculateEngangsskatt', () => {
it('calculates 0% tax for very low annual income', () => {
const result = calculateEngangsskatt(5000, 1000)
// Annual: 1000 × 12 + 5000 = 17,000 → bracket 0-20,000 → 0%
expect(result.taxRate).toBe(0.00)
expect(result.taxAmount).toBe(0)
})
it('calculates tax for moderate annual income', () => {
const result = calculateEngangsskatt(10000, 25000)
// Annual: 25000 × 12 + 10000 = 310,000 → bracket 200,001-350,000 → 30%
expect(result.taxRate).toBe(0.30)
expect(result.taxAmount).toBe(3000)
})
it('calculates higher tax when annual income crosses state tax threshold', () => {
const result = calculateEngangsskatt(50000, 55000)
// Annual: 55000 × 12 + 50000 = 710,000 → bracket 660,401-950,000 → 52%
expect(result.taxRate).toBe(0.52)
expect(result.taxAmount).toBe(26000)
})
it('uses total annual income including bonus for bracket lookup', () => {
const result = calculateEngangsskatt(100000, 40000)
// Annual: 40000 × 12 + 100000 = 580,000 → bracket 500,001-660,400 → 34%
expect(result.taxRate).toBe(0.34)
expect(result.taxAmount).toBe(34000)
})
it('returns calculation steps for transparency', () => {
const result = calculateEngangsskatt(10000, 30000)
expect(result.steps.length).toBe(2)
expect(result.steps[0].label).toBe('Beräknad årsinkomst')
expect(result.annualIncomeEstimate).toBe(370000) // 30000 × 12 + 10000
})
it('handles very high income bracket', () => {
const result = calculateEngangsskatt(200000, 120000)
// Annual: 120000 × 12 + 200000 = 1,640,000 → bracket ≥1,500,001 → 57%
expect(result.taxRate).toBe(0.57)
expect(result.taxAmount).toBe(114000)
})
})
-28
View File
@@ -1,28 +0,0 @@
/**
* Effective salary values — coalesce per-employee overrides over the
* engine-computed defaults.
*
* Used by booking (salary-entries.ts) and AGI (agi/generate-declaration.ts)
* so a manual adjustment for FoU-avdrag or jämkning flows through to both
* the ledger and the Skatteverket declaration.
*/
export interface SalaryRunEmployeeWithOverrides {
tax_withheld: number
tax_withheld_override?: number | null
avgifter_amount: number
avgifter_amount_override?: number | null
avgifter_basis: number
avgifter_basis_override?: number | null
}
export function effectiveTax(sre: SalaryRunEmployeeWithOverrides): number {
return sre.tax_withheld_override ?? sre.tax_withheld
}
export function effectiveAvgifter(sre: SalaryRunEmployeeWithOverrides): number {
return sre.avgifter_amount_override ?? sre.avgifter_amount
}
export function effectiveAvgifterBasis(sre: SalaryRunEmployeeWithOverrides): number {
return sre.avgifter_basis_override ?? sre.avgifter_basis
}
-85
View File
@@ -1,85 +0,0 @@
/**
* Engångsskatt — tax on one-time payments (bonuses, retroactive pay, etc.)
*
* Per Skatteverket: One-time payments use a percentage-based tax table,
* not the regular monthly tax table. The rate depends on the employee's
* estimated annual income level.
*
* Skatteverket publishes "Tabell för beräkning av skatteavdrag på
* engångsbelopp" annually.
*
* Simplified 2026 brackets (based on published rates):
*/
export interface EngangsskattResult {
taxRate: number
taxAmount: number
annualIncomeEstimate: number
steps: { label: string; formula: string; output: number }[]
}
/**
* 2026 engångsskatt brackets.
* Rate includes both kommunalskatt and statlig skatt.
* Based on average kommunalskatt (~32.5%).
*/
const ENGANGSSKATT_BRACKETS_2026: Array<{ fromAnnual: number; toAnnual: number; rate: number }> = [
{ fromAnnual: 0, toAnnual: 20000, rate: 0.00 },
{ fromAnnual: 20001, toAnnual: 50000, rate: 0.10 },
{ fromAnnual: 50001, toAnnual: 100000, rate: 0.20 },
{ fromAnnual: 100001, toAnnual: 200000, rate: 0.25 },
{ fromAnnual: 200001, toAnnual: 350000, rate: 0.30 },
{ fromAnnual: 350001, toAnnual: 500000, rate: 0.32 },
{ fromAnnual: 500001, toAnnual: 660400, rate: 0.34 },
{ fromAnnual: 660401, toAnnual: 950000, rate: 0.52 }, // State tax kicks in
{ fromAnnual: 950001, toAnnual: 1500000, rate: 0.55 },
{ fromAnnual: 1500001, toAnnual: Infinity, rate: 0.57 },
]
/**
* Calculate engångsskatt for a one-time payment.
*
* @param oneTimeAmount - The bonus/one-time payment amount
* @param monthlySalary - Employee's regular monthly salary (for estimating annual income)
* @param monthsWorkedThisYear - Months already worked (for pro-rata annual estimate)
*/
export function calculateEngangsskatt(
oneTimeAmount: number,
monthlySalary: number,
monthsWorkedThisYear: number = 12
): EngangsskattResult {
const r = (x: number) => Math.round(x * 100) / 100
// Estimate annual income = regular salary × 12 + one-time amount
const annualRegular = monthlySalary * 12
const annualIncomeEstimate = r(annualRegular + oneTimeAmount)
// Find the bracket based on total annual income including the one-time payment
let taxRate = 0.30 // default fallback
for (const bracket of ENGANGSSKATT_BRACKETS_2026) {
if (annualIncomeEstimate >= bracket.fromAnnual && annualIncomeEstimate <= bracket.toAnnual) {
taxRate = bracket.rate
break
}
}
const taxAmount = r(oneTimeAmount * taxRate)
return {
taxRate,
taxAmount,
annualIncomeEstimate,
steps: [
{
label: 'Beräknad årsinkomst',
formula: 'monthly × 12 + engångsbelopp',
output: annualIncomeEstimate,
},
{
label: `Engångsskatt (${(taxRate * 100).toFixed(0)}%)`,
formula: `engångsbelopp × ${(taxRate * 100).toFixed(0)}%`,
output: taxAmount,
},
],
}
}
-97
View File
@@ -1,97 +0,0 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('salary-transaction-matcher')
/**
* Auto-match salary payment bank transactions to salary journal entries.
*
* When bank transactions arrive (via Enable Banking sync or CSV import),
* this matcher looks for transactions that correspond to salary net payments:
* - Transaction date matches salary run payment_date
* - Transaction amount matches -total_net (negative = outgoing payment)
* - Transaction is not already categorized
*
* On match, links the transaction to the salary journal entry via
* the existing reconciliation system.
*
* Per BFL 5 kap: Bank statement reconciliation is required.
* Per BFNAR 2013:2: Automated matching must be logged.
*/
export async function matchSalaryTransactions(
supabase: SupabaseClient,
companyId: string,
transactionIds: string[]
): Promise<{ matched: number }> {
if (transactionIds.length === 0) return { matched: 0 }
// Load unmatched transactions
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('id, date, amount, description')
.eq('company_id', companyId)
.in('id', transactionIds)
.is('journal_entry_id', null)
.lt('amount', 0) // Only outgoing payments
if (txError || !transactions || transactions.length === 0) return { matched: 0 }
// Load booked salary runs for matching
const { data: salaryRuns, error: srError } = await supabase
.from('salary_runs')
.select('id, payment_date, total_net, salary_entry_id, status')
.eq('company_id', companyId)
.eq('status', 'booked')
if (srError || !salaryRuns || salaryRuns.length === 0) return { matched: 0 }
let matched = 0
for (const tx of transactions) {
// Look for salary run where:
// - payment_date matches transaction date
// - total_net matches |transaction.amount| (within 1 SEK tolerance for öresavrundning)
const txAmount = Math.abs(tx.amount)
const matchingRun = salaryRuns.find(run => {
if (run.payment_date !== tx.date) return false
const diff = Math.abs(run.total_net - txAmount)
return diff <= 1 // 1 SEK tolerance for rounding
})
if (matchingRun && matchingRun.salary_entry_id) {
// Link transaction to the salary journal entry
const { error: linkError } = await supabase
.from('transactions')
.update({
journal_entry_id: matchingRun.salary_entry_id,
is_business: true,
category: 'salary',
})
.eq('id', tx.id)
.is('journal_entry_id', null) // CAS guard
if (!linkError) {
matched++
log.info(`Matched salary transaction ${tx.id} to run ${matchingRun.id} (${txAmount} SEK)`)
// Log the match in payment_match_log for audit trail
await supabase.from('payment_match_log').insert({
company_id: companyId,
transaction_id: tx.id,
match_type: 'salary_payment',
matched_entity_id: matchingRun.id,
matched_entity_type: 'salary_run',
amount: txAmount,
auto_matched: true,
})
}
}
}
if (matched > 0) {
log.info(`Auto-matched ${matched} salary transaction(s) for company ${companyId}`)
}
return { matched }
}