Files
accounted/lib/reports/ar-reconciliation.ts
T
Jakob WennbergandClaude Opus 4.8 f8504f3bd0 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>
2026-06-30 14:34:23 +02:00

136 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/**
* Sum of posted balances on accounts 1510 (Kundfordringar) and 1513
* (Kundfordringar – delad faktura). 1513 covers the Skatteverket portion
* of ROT/RUT fakturamodellen invoices and is zero today (no fakturamodellen
* postings yet) — included for forward compatibility.
*/
account_1510_balance: number
difference: number
is_reconciled: boolean
/**
* Number of foreign-currency invoices that lacked an exchange_rate, so their
* outstanding amount could not be converted to SEK. When > 0 the difference
* field may be misleading: any reported gap could be missing-data rather
* than a true reconciliation break.
*/
unconverted_fx_count: number
}
/**
* Compare sum of open customer invoices against account 1510 balance.
* Account 1510 is debit-normal (asset): balance = debits - credits.
*
* Conversion uses each invoice's stored exchange_rate (the invoice-date rate),
* which matches what was originally posted to 1510. This means the report will
* diverge from the GL once partial payments settle at a different rate (the
* delta is correctly booked as valutakursvinst/-förlust to 3960/7960 per
* ML 8 kap 21–23 §). A subledger-derived total would reconcile through that
* difference; deferred to a follow-up.
*/
export async function generateARReconciliation(
supabase: SupabaseClient,
companyId: string,
periodId: string
): Promise<ARReconciliationResult> {
// total/paid_amount are stored in invoice currency; account 1510 is in SEK
// (booked at invoice-date rate), so convert each row before summing.
// 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 || [])
.reduce((sum, inv) => {
const isFx = inv.currency && inv.currency !== 'SEK'
const hasRate = inv.exchange_rate != null && Number(inv.exchange_rate) > 0
// Skip unconvertible FX rows from the sum — adding raw foreign amounts
// to a SEK total is arithmetically unsound. Counted instead.
if (isFx && !hasRate) {
unconvertedFxCount += 1
return sum
}
const outstanding = (Number(inv.total) || 0) - (Number(inv.paid_amount) || 0)
const sek = resolveSekAmount(outstanding, null, inv.currency, inv.exchange_rate)
return Math.round((sum + sek) * 100) / 100
}, 0)
// Get AR receivable balance from the ledger in this period. We sum 1510
// (Kundfordringar) AND 1513 (Kundfordringar – delad faktura) so the comparison
// stays correct under ROT/RUT fakturamodellen, where the customer portion sits
// on 1510 and the Skatteverket claim on 1513 — both are open AR receivable
// from the company's perspective. 1513 is zero today (no fakturamodellen
// postings yet) so this is a forward-looking defense.
//
// We count posted AND reversed entries together — the SAME inclusion rule the
// 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.
// 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
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
return {
ar_ledger_total: Math.round(arLedgerTotal * 100) / 100,
account_1510_balance: Math.round(account1510Balance * 100) / 100,
difference,
// BFL 5 kap requires the reconciliation to cover all affärshändelser. If
// any row was excluded for a missing exchange rate, the calculation is
// incomplete by construction and we cannot honestly stamp the period
// Avstämd — the user must fix the underlying data first.
is_reconciled: Math.abs(difference) < 0.01 && unconvertedFxCount === 0,
unconverted_fx_count: unconvertedFxCount,
}
}