Files
accounted/lib/reports/opening-balances.ts
T
Jakob WennbergandClaude Opus 4.7 44082ff845 Fix SIE multi-year P&L accumulation, Fortnox supplier filter, processing_history (#267)
* fix: prevent P&L accumulation when importing multi-year SIE files

The opening-balance fallback summed all prior journal lines without
distinguishing balance sheet (class 1-2) from P&L (class 3-8). When
users imported one SIE file per year without running year-end closing
between them, resultatkonton accumulated across years instead of
resetting at each räkenskapsårsskifte. Reported by a customer.

Skip class 3-8 in the fallback path. P&L accounts must reset to zero
each fiscal year (årets resultat → 2099 → equity).

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

* fix: only import unpaid supplier invoices from Fortnox

Fortnox's /supplierinvoices list endpoint doesn't reliably expose
FullyPaid, which caused historic paid invoices to be imported as
unpaid. Switch to the ?filter=unpaid query and surface that scope
in the migration options UI.

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

* feat: add processing_history table for behandlingshistorik

Append-only event log per BFNAR 2013:2 kap 8. Includes:
- processing_history table with seq, correlation/causation chaining,
  aggregate (Document/BankTransaction/MatchProposal/Verifikation/etc.),
  open event_type validated against processing_event_types registry.
- Immutability via audit_log_immutable trigger (no UPDATE/DELETE).
- RLS scoped to user_company_ids; writes via service role only.
- appendProcessingHistory() helper with PII guard rejecting payloads
  containing personnummer/orgnr patterns.
- Shared TS types in types/index.ts.

No consumers wired yet — this is the persistence layer only.

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

* chore: add swedish-project-accounting skill

Reference skill covering projektredovisning: dimensional tagging,
WIP accounting, K2/K3 revenue recognition (successiv vinstavräkning,
färdigställandemetoden), entreprenadavtal, BAS patterns (1470,
1620, 2420, 2450, 4970), and SIE4 #DIM 6 encoding.

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

* fix: rename processing_history migration to avoid timestamp collision

Main already has 20260418120000_allow_retroactive_first_fiscal_year.sql
from #265. Bumping this migration's timestamp to 20260418130000 to
keep schema_migrations.version unique.

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

* fix: address Greptile review on processing_history

- Add BEFORE DELETE immutability trigger so the service role can't
  silently remove rows. Mirrors the pattern from migration 014
  (audit_log_no_update + audit_log_no_delete) and satisfies the
  immutability claim in BFNAR 2013:2 kap 8. Delivered as a follow-up
  migration since the original was already applied in some envs.

- Tighten PII patterns with \b word boundaries to avoid false
  positives on Bankgiro numbers (123456-7890) and invoice references
  like 202312-1234.

- Extend PII validation to actor.label, which previously bypassed
  the payload guard despite the docblock explicitly forbidding
  names/emails/personnummer there.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:26:41 +02:00

96 lines
3.8 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* Get opening balances (ingående balans) for a fiscal period.
*
* Uses the opening_balance_entry set by year-end closing when available
* (O(accounts) — typically ~50 rows). Falls back to summing all entries
* prior to the period start date via a joined query (O(all_prior_lines) —
* expensive for companies that haven't run year-end closing).
*
* Returns per-account debit/credit opening balances and the OB entry ID
* (if any) so the caller can exclude it from period queries to prevent
* double-counting.
*
* NOTE: The account range filter (accountFrom/accountTo in the GL) is
* applied post-hoc by the caller, not here. This is consistent with the
* existing behavior and avoids complicating the queries for the common
* unfiltered case.
*/
export async function getOpeningBalances(
supabase: SupabaseClient,
companyId: string,
period: { period_start: string; opening_balance_entry_id: string | null } | null
): Promise<{
balances: Map<string, { debit: number; credit: number }>
obEntryId: string | null
}> {
const balances = new Map<string, { debit: number; credit: number }>()
if (!period) {
return { balances, obEntryId: null }
}
const obEntryId = period.opening_balance_entry_id
if (obEntryId) {
// Use the explicit opening balance entry (set by year-end closing).
// Typically ~50 rows — one per balance sheet account. Uses fetchAllRows
// for consistency (avoids silent truncation) and joins journal_entries
// to enforce company_id ownership (defense in depth alongside RLS).
const obLines = await fetchAllRows<{
account_number: string
debit_amount: number
credit_amount: number
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id)')
.eq('journal_entry_id', obEntryId)
.eq('journal_entries.company_id', companyId)
.range(from, to)
)
for (const line of obLines) {
const existing = balances.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
balances.set(line.account_number, existing)
}
} else {
// Fallback: compute from all entries dated before this period's start.
// This is expensive for multi-year companies that haven't run year-end
// closing — consider prompting the user to close prior periods.
const priorLines = await fetchAllRows<{
account_number: string
debit_amount: number
credit_amount: number
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)')
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
.lt('journal_entries.entry_date', period.period_start)
.range(from, to)
)
for (const line of priorLines) {
// P&L accounts (class 3-8) reset to zero at each year transition —
// their balances are absorbed into årets resultat (2099) and rolled
// into equity. Carrying them forward as IB causes resultatkonton to
// accumulate across years (BFNAR 2013:2 violation).
const cls = parseInt(line.account_number.charAt(0), 10)
if (cls >= 3 && cls <= 8) continue
const existing = balances.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
balances.set(line.account_number, existing)
}
}
return { balances, obEntryId }
}