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:
co-authored by
Claude Opus 4.8
parent
b800dcd403
commit
f8504f3bd0
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user