From 2c03dac9810d0ca6d69104976bf7e5f27cc98e7a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 5 May 2026 22:30:40 +0200 Subject: [PATCH] fix(reports): convert FX to SEK in supplier/AR ledger reconciliation (#396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(reports): convert FX to SEK in supplier/AR ledger reconciliation The supplier and AR ledger reports were summing remaining_amount directly without converting foreign-currency invoices, so a EUR/USD invoice would land in the aging total at face value while the corresponding 2440 / 1510 GL line was already posted in SEK. This produced false reconciliation discrepancies (e.g. 496,25 kr ledger vs 952,50 kr GL with four EUR/USD invoices). Apply resolveSekAmount(remaining, null, currency, exchange_rate) in supplier-ledger, supplier-reconciliation, ar-ledger, and ar-reconciliation. Per-invoice detail rows on the AR ledger keep the original currency for display; only aging buckets and totals become SEK. Adds mixed-currency test cases to all four files. Also bundles unrelated WIP from the working tree: - bank-reconciliation: log the swallowed catch error and drop the fallback path for the deleted get_unlinked_bank_lines RPC, using get_unlinked_1930_lines directly. - new GET /api/transactions list endpoint with unmatched/reconciled/ currency/date filters and full route tests. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(reports): address PR #396 review findings Compliance and Greptile review identified four issues; all four are addressed here. - Reconciliation: surface unconverted_fx_count on ReconciliationResult and ARReconciliationResult. When > 0 the difference field may be a data gap (FX invoice with no exchange_rate) rather than a true reconciliation break. UI now renders a Swedish caveat below the Avstämd / Ej avstämd badge so users understand the cause. New tests assert the count is set on legacy FX rows. - Reconciliation: document the invoice-date-rate assumption explicitly in the JSDoc of both reconciliation generators. Per ML 8 kap 21–23 §, the report uses each invoice's stored exchange_rate; partial payments settled at a different rate produce a delta correctly booked to 3960/7960 as valutakursvinst/-förlust, but the GL will diverge from the report by that amount until a subledger-derived total is wired up (deferred follow-up). - fetchUnlinkedGLLines: drop the misleading bankAccount parameter. It was advertised as configurable but the function silently returned [] for any value other than '1930'. Now the signature is honest: 1930-only until proper multi-account support is built. - /api/transactions: query MAX_ROWS+1 rows so the response can include has_more and limit fields. Callers can now detect truncation, which matters once a company crosses 500 unmatched transactions in the selected range. New test asserts has_more=true when 501 rows are returned by the DB and the response is sliced to MAX_ROWS. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(reports): address PR #396 round-2 compliance review Compliance bot v2 review surfaced four findings on the previous commit; three are addressed here. The fourth (an fx_rate_diff_amount indicator distinguishing real reconciliation breaks from correctly-booked valutakursvinst/-förlust) requires a subledger-derived total against 3960/7960 — already documented as a deferred follow-up in the JSDoc. Changes: - Exclude unconvertible FX rows from SEK sums. resolveSekAmount's null-rate fallback returned the raw foreign amount, so a 100 EUR invoice with no rate was being added to a SEK total as if it were 100 SEK. All four generators (supplier-ledger, supplier-reconciliation, ar-ledger, ar-reconciliation) now skip rows where currency != SEK and exchange_rate is missing/zero, and count them in unconverted_fx_count. - Surface unconverted_fx_count on SupplierLedgerReport / ARLedgerReport (not just the reconciliation result). UI shows a Swedish caption beneath the "Totalt utestående" card whenever the count is positive, so users see the warning even if they don't enable the reconciliation panel. - Add outstanding_sek: number | null to ARInvoiceDetail. The per-invoice detail row keeps `outstanding` in invoice currency for display, but now also exposes the converted SEK value (or null when unconvertible). Defensive against future callers that sum across customers — they should use outstanding_sek to avoid mixing currencies (a real momsdeklaration foot-gun otherwise). - Add a 1930-only notice to BankReconciliationView. The reconciliation is scoped to account 1930; users with Plusgiro 1920, kreditkort 1940, or valutakonton now see a Swedish caption explaining those are reconciled separately. Tests: - supplier-ledger: previous "falls back to original amount" case flipped to assert the row is excluded and counted; assertion that the legacy supplier disappears from the list when their only row is unconvertible. - supplier-reconciliation: previous "1 100 ledger vs 1 000 GL" case flipped to assert ledger=1 000 and is_reconciled=true with unconverted_fx_count=1. - ar-reconciliation: same pattern. - ar-ledger: existing FX-mix test extended to assert outstanding_sek on each detail row; new test covering the null-rate exclusion path asserts the detail row is still pushed (with outstanding_sek=null) but excluded from buckets and the grand total. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(reports): address PR #396 round-3 compliance review Compliance bot v3 surfaced four findings on the previous commit; two are addressed here, two are deliberately skipped (rationale in JSDoc / this message). Addressed: - is_reconciled now returns false whenever unconverted_fx_count > 0, even if the numeric difference is zero. Per BFL 5 kap, the reconciliation must cover all affärshändelser; if a row was excluded for a missing exchange rate, the calculation is incomplete by construction and the period cannot honestly be stamped Avstämd. The fix is for the user to fill in the missing rate, not for the system to claim balance on partial data. - AR reconciliation now sums account 1510 + 1513 in the GL balance comparison. Forward-looking defense for ROT/RUT fakturamodellen invoices that split AR receivables across the customer portion (1510) and the Skatteverket claim (1513). Today no production code posts to 1513 so the value is unchanged in practice; once fakturamodellen invoicing is added, the reconciliation will continue to balance without requiring another fix. UI label updated to "Kundfordringar (1510 + 1513) saldo" so the inclusion is visible. Field name and shape are unchanged for back-compat with the supplier-side parallel. Skipped: - "Block is_reconciled=true when any FX invoice exists in an open period" — overly aggressive; would block reconciliation for any FX-using company even when their books are correctly matched. The proper solution is the deferred subledger-derived total against 3960/7960 (already documented in JSDoc on both reconciliation generators), which can compute the *expected* FX rate difference and either subtract it from `difference` or expose it as `fx_rate_difference`. Not in scope for this PR. - Plusgiro 1920 wording in BankReconciliationView — bot itself marked this "not a hard finding". The 1920 reference is accurate per BAS 2026. Tests: - supplier-reconciliation: existing v3 exclusion test updated — asserts is_reconciled=false despite numbers matching, with comment explaining BFL rationale. - ar-reconciliation: same update + new test covering the 1510 + 1513 sum (1 200 on 1510, 300 on 1513, ledger total 1 500 → reconciled). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(dashboard)/reports/page.tsx | 31 ++- app/api/transactions/__tests__/route.test.ts | 192 ++++++++++++++++++ app/api/transactions/route.ts | 54 +++++ components/reports/BankReconciliationView.tsx | 6 +- lib/reconciliation/bank-reconciliation.ts | 22 +- lib/reports/__tests__/ar-ledger.test.ts | 112 ++++++++++ .../__tests__/ar-reconciliation.test.ts | 87 ++++++++ lib/reports/__tests__/supplier-ledger.test.ts | 87 ++++++++ .../__tests__/supplier-reconciliation.test.ts | 64 ++++++ lib/reports/ar-ledger.ts | 52 ++++- lib/reports/ar-reconciliation.ts | 60 +++++- lib/reports/supplier-ledger.ts | 29 ++- lib/reports/supplier-reconciliation.ts | 48 ++++- 13 files changed, 803 insertions(+), 41 deletions(-) create mode 100644 app/api/transactions/__tests__/route.test.ts create mode 100644 app/api/transactions/route.ts diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 71c621da..07113398 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -1589,12 +1589,14 @@ interface SupplierLedgerData { total_current: number total_overdue: number unpaid_count: number + unconverted_fx_count: number } reconciliation: { supplier_ledger_total: number account_2440_balance: number difference: number is_reconciled: boolean + unconverted_fx_count: number } | null } @@ -1669,6 +1671,11 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {

{formatAmount(ledger.total_outstanding)} kr

{ledger.unpaid_count} fakturor

+ {ledger.unconverted_fx_count > 0 && ( +

+ {ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen. +

+ )}
@@ -1759,12 +1766,17 @@ function SupplierLedgerView({ periodId }: { periodId: string }) { {formatAmount(reconciliation.difference)} kr -
+
{reconciliation.is_reconciled ? ( Avstämd ) : ( Ej avstämd - kontrollera bokföring )} + {reconciliation.unconverted_fx_count > 0 && ( +

+ {reconciliation.unconverted_fx_count} leverantörsfaktura i utländsk valuta saknar växelkurs — differensen kan bero på saknade kursuppgifter snarare än felbokning. +

+ )}
@@ -2175,6 +2187,7 @@ interface ARLedgerData { total: number paid_amount: number outstanding: number + outstanding_sek: number | null days_overdue: number currency: string }[] @@ -2189,12 +2202,14 @@ interface ARLedgerData { total_current: number total_overdue: number unpaid_count: number + unconverted_fx_count: number } reconciliation: { ar_ledger_total: number account_1510_balance: number difference: number is_reconciled: boolean + unconverted_fx_count: number } | null } @@ -2282,6 +2297,11 @@ function ARLedgerView({ periodId }: { periodId: string }) {

{formatAmount(ledger.total_outstanding)} kr

{ledger.unpaid_count} fakturor

+ {ledger.unconverted_fx_count > 0 && ( +

+ {ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen. +

+ )}
@@ -2400,7 +2420,7 @@ function ARLedgerView({ periodId }: { periodId: string }) { {formatAmount(reconciliation.ar_ledger_total)} kr
- saldo (huvudbok) + Kundfordringar ( + ) saldo {formatAmount(reconciliation.account_1510_balance)} kr
@@ -2409,12 +2429,17 @@ function ARLedgerView({ periodId }: { periodId: string }) { {formatAmount(reconciliation.difference)} kr
-
+
{reconciliation.is_reconciled ? ( Avstämd ) : ( Ej avstämd - kontrollera bokföring )} + {reconciliation.unconverted_fx_count > 0 && ( +

+ {reconciliation.unconverted_fx_count} kundfaktura i utländsk valuta saknar växelkurs — differensen kan bero på saknade kursuppgifter snarare än felbokning. +

+ )}
diff --git a/app/api/transactions/__tests__/route.test.ts b/app/api/transactions/__tests__/route.test.ts new file mode 100644 index 00000000..8b6f1efd --- /dev/null +++ b/app/api/transactions/__tests__/route.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, + makeTransaction, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +describe('GET /api/transactions', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + const originalFrom = mockSupabase.from + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.from = originalFrom + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/transactions') + const response = await GET(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns transactions for the active company with has_more=false when below the cap', async () => { + const txs = [ + makeTransaction({ id: 'tx-1', amount: -100 }), + makeTransaction({ id: 'tx-2', amount: 250 }), + ] + enqueue({ data: txs, error: null }) + + const request = createMockRequest('/api/transactions') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ + data: typeof txs + has_more: boolean + limit: number + }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(2) + expect(body.data[0].id).toBe('tx-1') + expect(body.has_more).toBe(false) + expect(body.limit).toBe(500) + }) + + it('signals has_more=true and truncates to the cap when more rows exist', async () => { + // Server requests MAX_ROWS+1 = 501 rows; if the DB returns 501 we know there's more. + const txs = Array.from({ length: 501 }, (_, i) => + makeTransaction({ id: `tx-${i}`, amount: i }), + ) + enqueue({ data: txs, error: null }) + + const request = createMockRequest('/api/transactions') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ + data: typeof txs + has_more: boolean + limit: number + }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(500) + expect(body.has_more).toBe(true) + expect(body.limit).toBe(500) + }) + + it('filters by unmatched=true', async () => { + const fromSpy = vi.fn(() => { + const chain: Record = {} + const methods = ['select', 'eq', 'is', 'not', 'gte', 'lte', 'order', 'limit'] + const calls: { method: string; args: unknown[] }[] = [] + for (const m of methods) { + chain[m] = vi.fn((...args: unknown[]) => { + calls.push({ method: m, args }) + return chain + }) + } + ;(chain as { then: unknown }).then = (resolve: (v: unknown) => void) => + resolve({ data: [], error: null }) + ;(chain as { __calls: typeof calls }).__calls = calls + return chain + }) + mockSupabase.from = fromSpy as unknown as typeof mockSupabase.from + + const request = createMockRequest('/api/transactions?unmatched=true') + await GET(request) + + expect(fromSpy).toHaveBeenCalledWith('transactions') + const chain = fromSpy.mock.results[0].value as { __calls: { method: string; args: unknown[] }[] } + const isCall = chain.__calls.find((c) => c.method === 'is') + expect(isCall).toEqual({ method: 'is', args: ['journal_entry_id', null] }) + }) + + it('filters by reconciled=true', async () => { + const fromSpy = vi.fn(() => { + const chain: Record = {} + const methods = ['select', 'eq', 'is', 'not', 'gte', 'lte', 'order', 'limit'] + const calls: { method: string; args: unknown[] }[] = [] + for (const m of methods) { + chain[m] = vi.fn((...args: unknown[]) => { + calls.push({ method: m, args }) + return chain + }) + } + ;(chain as { then: unknown }).then = (resolve: (v: unknown) => void) => + resolve({ data: [], error: null }) + ;(chain as { __calls: typeof calls }).__calls = calls + return chain + }) + mockSupabase.from = fromSpy as unknown as typeof mockSupabase.from + + const request = createMockRequest('/api/transactions?reconciled=true') + await GET(request) + + const chain = fromSpy.mock.results[0].value as { __calls: { method: string; args: unknown[] }[] } + const notCall = chain.__calls.find((c) => c.method === 'not') + expect(notCall).toEqual({ method: 'not', args: ['journal_entry_id', 'is', null] }) + // unmatched and reconciled are mutually exclusive — when reconciled is set, no .is() filter + const isCall = chain.__calls.find((c) => c.method === 'is') + expect(isCall).toBeUndefined() + }) + + it('applies currency, date_from, and date_to filters', async () => { + const fromSpy = vi.fn(() => { + const chain: Record = {} + const methods = ['select', 'eq', 'is', 'not', 'gte', 'lte', 'order', 'limit'] + const calls: { method: string; args: unknown[] }[] = [] + for (const m of methods) { + chain[m] = vi.fn((...args: unknown[]) => { + calls.push({ method: m, args }) + return chain + }) + } + ;(chain as { then: unknown }).then = (resolve: (v: unknown) => void) => + resolve({ data: [], error: null }) + ;(chain as { __calls: typeof calls }).__calls = calls + return chain + }) + mockSupabase.from = fromSpy as unknown as typeof mockSupabase.from + + const request = createMockRequest( + '/api/transactions?currency=SEK&date_from=2024-01-01&date_to=2024-12-31' + ) + await GET(request) + + const chain = fromSpy.mock.results[0].value as { __calls: { method: string; args: unknown[] }[] } + const eqCalls = chain.__calls.filter((c) => c.method === 'eq') + // company_id and currency + expect(eqCalls).toEqual( + expect.arrayContaining([ + { method: 'eq', args: ['company_id', 'company-1'] }, + { method: 'eq', args: ['currency', 'SEK'] }, + ]) + ) + expect(chain.__calls).toEqual( + expect.arrayContaining([ + { method: 'gte', args: ['date', '2024-01-01'] }, + { method: 'lte', args: ['date', '2024-12-31'] }, + ]) + ) + }) + + it('returns 500 when the query errors', async () => { + enqueue({ data: null, error: { message: 'boom' } }) + + const request = createMockRequest('/api/transactions') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('boom') + }) +}) diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts new file mode 100644 index 00000000..8ca6bf40 --- /dev/null +++ b/app/api/transactions/route.ts @@ -0,0 +1,54 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' + +const MAX_ROWS = 500 + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + + const { searchParams } = new URL(request.url) + const unmatched = searchParams.get('unmatched') === 'true' + const reconciled = searchParams.get('reconciled') === 'true' + const currency = searchParams.get('currency') || undefined + const dateFrom = searchParams.get('date_from') || undefined + const dateTo = searchParams.get('date_to') || undefined + + let query = supabase + .from('transactions') + .select('id, date, description, amount, currency, reference, journal_entry_id, reconciliation_method') + .eq('company_id', companyId) + + // unmatched and reconciled are mutually exclusive — unmatched wins if both set + if (unmatched) { + query = query.is('journal_entry_id', null) + } else if (reconciled) { + query = query.not('journal_entry_id', 'is', null) + } + + if (currency) query = query.eq('currency', currency) + if (dateFrom) query = query.gte('date', dateFrom) + if (dateTo) query = query.lte('date', dateTo) + + // Fetch one extra row so we can tell the caller whether the result was truncated. + query = query.order('date', { ascending: false }).limit(MAX_ROWS + 1) + + const { data, error } = await query + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + const rows = data || [] + const hasMore = rows.length > MAX_ROWS + const truncated = hasMore ? rows.slice(0, MAX_ROWS) : rows + + return NextResponse.json({ data: truncated, has_more: hasMore, limit: MAX_ROWS }) +} diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index 3f15026c..e21aa7bc 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -131,7 +131,8 @@ export function BankReconciliationView() { setGlLines(glData.data || []) setUnmatchedTx(unmatchedData.data || []) setMatchedTx(matchedData.data || []) - } catch { + } catch (e) { + console.error('[reconciliation] fetchAll failed', e) setError('Kunde inte hämta avstämningsdata') } finally { setLoading(false) @@ -287,6 +288,9 @@ export function BankReconciliationView() { Ej avstämd )} +

+ Endast konto ingår i denna avstämning. Övriga bankkonton (t.ex. Plusgiro , kreditkort eller valutakonton) måste avstämmas separat. +

diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index 66e020ae..11eb23fb 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -440,32 +440,24 @@ export async function unlinkReconciliation( // Helpers // ============================================================ -/** Fetch unlinked bank GL lines via the RPC function */ +/** + * Fetch unlinked bank GL lines for account 1930. Multi-account support + * (Plusgiro 1920, kreditkort 1940, EUR-konto 1931, etc.) requires a different + * RPC and is not yet implemented — until then this helper is intentionally + * scoped to 1930 so callers cannot silently lose data on other accounts. + */ export async function fetchUnlinkedGLLines( supabase: SupabaseClient, companyId: string, dateFrom?: string, dateTo?: string, - bankAccount = '1930' ): Promise { - const { data, error } = await supabase.rpc('get_unlinked_bank_lines', { + const { data, error } = await supabase.rpc('get_unlinked_1930_lines', { p_company_id: companyId, p_date_from: dateFrom || null, p_date_to: dateTo || null, - p_account_number: bankAccount, }) - // Fall back to legacy RPC if the new one doesn't exist yet - if (error && bankAccount === '1930') { - const { data: fallbackData, error: fallbackError } = await supabase.rpc('get_unlinked_1930_lines', { - p_company_id: companyId, - p_date_from: dateFrom || null, - p_date_to: dateTo || null, - }) - if (fallbackError || !fallbackData) return [] - return fallbackData as UnlinkedGLLine[] - } - if (error || !data) return [] return data as UnlinkedGLLine[] } diff --git a/lib/reports/__tests__/ar-ledger.test.ts b/lib/reports/__tests__/ar-ledger.test.ts index d6adf978..2426e34f 100644 --- a/lib/reports/__tests__/ar-ledger.test.ts +++ b/lib/reports/__tests__/ar-ledger.test.ts @@ -196,6 +196,118 @@ describe('generateARLedger', () => { expect(report.entries[0].invoices[1].invoice_number).toBe('F002') }) + it('aggregates foreign-currency invoices into SEK aging buckets but preserves original currency on detail rows', async () => { + // The aging totals reconcile against account 1510 (SEK), but the per-invoice + // detail row keeps `outstanding` in invoice currency for display. + results = [ + { + data: [ + // 225 EUR at 11 → 2 475 SEK + { + id: 'inv-1', + customer_id: 'cust-a', + customer: { id: 'cust-a', name: 'Foreign AB' }, + invoice_number: 'F100', + invoice_date: '2024-05-01', + due_date: '2024-06-01', // 14 days overdue at 2024-06-15 + total: 225, + paid_amount: 0, + currency: 'EUR', + exchange_rate: 11, + status: 'overdue', + }, + // 1 000 SEK (control) + { + id: 'inv-2', + customer_id: 'cust-a', + customer: { id: 'cust-a', name: 'Foreign AB' }, + invoice_number: 'F101', + invoice_date: '2024-05-01', + due_date: '2024-06-01', + total: 1000, + paid_amount: 0, + currency: 'SEK', + exchange_rate: null, + status: 'overdue', + }, + ], + error: null, + }, + ] + + const report = await generateARLedger(supabase, 'company-1', '2024-06-15') + + const entry = report.entries[0] + // Aging bucket sums in SEK: 2 475 + 1 000 = 3 475 + expect(entry.days_1_30).toBe(3475) + expect(entry.total_outstanding).toBe(3475) + + // Per-invoice detail keeps original currency for display, with the + // converted SEK value alongside so callers don't accidentally mix. + const eurInv = entry.invoices.find(i => i.invoice_number === 'F100')! + expect(eurInv.outstanding).toBe(225) + expect(eurInv.currency).toBe('EUR') + expect(eurInv.outstanding_sek).toBe(2475) + + const sekInv = entry.invoices.find(i => i.invoice_number === 'F101')! + expect(sekInv.outstanding_sek).toBe(1000) + + expect(report.total_outstanding).toBe(3475) + expect(report.unconverted_fx_count).toBe(0) + }) + + it('lists FX invoices without exchange_rate but excludes them from totals (outstanding_sek = null)', async () => { + results = [ + { + data: [ + // 100 EUR with no rate — listed in detail but excluded from buckets + { + id: 'inv-1', + customer_id: 'cust-a', + customer: { id: 'cust-a', name: 'Foreign AB' }, + invoice_number: 'F200', + invoice_date: '2024-05-01', + due_date: '2024-06-01', + total: 100, + paid_amount: 0, + currency: 'EUR', + exchange_rate: null, + status: 'overdue', + }, + // 500 SEK control + { + id: 'inv-2', + customer_id: 'cust-a', + customer: { id: 'cust-a', name: 'Foreign AB' }, + invoice_number: 'F201', + invoice_date: '2024-05-01', + due_date: '2024-06-01', + total: 500, + paid_amount: 0, + currency: 'SEK', + exchange_rate: null, + status: 'overdue', + }, + ], + error: null, + }, + ] + + const report = await generateARLedger(supabase, 'company-1', '2024-06-15') + + expect(report.unconverted_fx_count).toBe(1) + // EUR row excluded from total — only the 500 SEK invoice contributes + expect(report.total_outstanding).toBe(500) + + const entry = report.entries[0] + expect(entry.total_outstanding).toBe(500) + // Both detail rows are still visible to the user + expect(entry.invoices).toHaveLength(2) + const eurInv = entry.invoices.find(i => i.invoice_number === 'F200')! + expect(eurInv.outstanding).toBe(100) + expect(eurInv.outstanding_sek).toBeNull() + }) + it('uses Math.round for monetary precision', async () => { results = [ { diff --git a/lib/reports/__tests__/ar-reconciliation.test.ts b/lib/reports/__tests__/ar-reconciliation.test.ts index 26c4990e..954af3b7 100644 --- a/lib/reports/__tests__/ar-reconciliation.test.ts +++ b/lib/reports/__tests__/ar-reconciliation.test.ts @@ -145,6 +145,93 @@ describe('generateARReconciliation', () => { expect(result.account_1510_balance).toBe(3000) }) + it('converts foreign-currency outstanding to SEK before reconciliation', async () => { + results = [ + // 0: invoices — 225 EUR at 11 (with 25 EUR paid) → 200 EUR → 2 200 SEK, + // plus 1 000 SEK invoice (no payment) + { + data: [ + { total: 225, paid_amount: 25, currency: 'EUR', exchange_rate: 11 }, + { total: 1000, paid_amount: 0, currency: 'SEK', exchange_rate: null }, + ], + error: null, + }, + // 1: 1510 balance = 3 200 SEK + { + data: [ + { debit_amount: 3200, credit_amount: 0, journal_entry_id: 'e1' }, + ], + error: null, + }, + ] + + const result = await generateARReconciliation(supabase, 'company-1', 'period-1') + + expect(result.ar_ledger_total).toBe(3200) + expect(result.account_1510_balance).toBe(3200) + expect(result.difference).toBe(0) + expect(result.is_reconciled).toBe(true) + expect(result.unconverted_fx_count).toBe(0) + }) + + it('excludes FX invoices without exchange_rate from the SEK total and counts them', async () => { + results = [ + // 0: invoices — 100 EUR without rate (excluded), 500 SEK control + { + data: [ + { total: 100, paid_amount: 0, currency: 'EUR', exchange_rate: null }, + { total: 500, paid_amount: 0, currency: 'SEK', exchange_rate: null }, + ], + error: null, + }, + // 1: 1510 balance reflects only the SEK invoice + { + data: [ + { debit_amount: 500, credit_amount: 0, journal_entry_id: 'e1' }, + ], + error: null, + }, + ] + + const result = await generateARReconciliation(supabase, 'company-1', 'period-1') + + expect(result.unconverted_fx_count).toBe(1) + // EUR row excluded → ledger total is just the SEK 500 + expect(result.ar_ledger_total).toBe(500) + expect(result.account_1510_balance).toBe(500) + // Numbers match, but the calculation is incomplete (a row was excluded); + // BFL 5 kap requires the period not be stamped Avstämd until the missing + // exchange rate is filled in. + expect(result.is_reconciled).toBe(false) + }) + + it('sums 1510 + 1513 in the GL balance for ROT/RUT fakturamodellen', async () => { + // Forward-looking: today no postings hit 1513, but if a fakturamodellen + // invoice ever splits the AR receivable across 1510 (customer portion) + // and 1513 (Skatteverket claim), both must be included to reconcile. + results = [ + // 0: invoices — single 1 500 SEK invoice + { + data: [{ total: 1500, paid_amount: 0, currency: 'SEK', exchange_rate: null }], + error: null, + }, + // 1: GL — 1 200 on 1510, 300 on 1513 → combined 1 500 + { + data: [ + { debit_amount: 1200, credit_amount: 0, journal_entry_id: 'e1' }, + { debit_amount: 300, credit_amount: 0, journal_entry_id: 'e2' }, + ], + error: null, + }, + ] + + const result = await generateARReconciliation(supabase, 'company-1', 'period-1') + + expect(result.ar_ledger_total).toBe(1500) + expect(result.account_1510_balance).toBe(1500) + expect(result.is_reconciled).toBe(true) + }) + it('uses Math.round for monetary precision', async () => { results = [ { diff --git a/lib/reports/__tests__/supplier-ledger.test.ts b/lib/reports/__tests__/supplier-ledger.test.ts index 26faf623..a969610c 100644 --- a/lib/reports/__tests__/supplier-ledger.test.ts +++ b/lib/reports/__tests__/supplier-ledger.test.ts @@ -212,6 +212,93 @@ describe('generateSupplierLedger', () => { expect(report.unpaid_count).toBe(2) }) + it('converts foreign-currency invoices to SEK using exchange_rate', async () => { + // Reproduces the production bug: EUR/USD invoices were summed as if SEK, + // making the ledger total drift from the 2440 GL balance. + results = [ + { + data: [ + // 225 EUR at 11.00 → 2 475 SEK + { + supplier_id: 'sup-1', + supplier: { id: 'sup-1', name: 'Anthropic' }, + due_date: '2024-06-01', + remaining_amount: 225, + currency: 'EUR', + exchange_rate: 11, + }, + // 6.25 USD at 10.00 → 62.50 SEK + { + supplier_id: 'sup-1', + supplier: { id: 'sup-1', name: 'Anthropic' }, + due_date: '2024-06-01', + remaining_amount: 6.25, + currency: 'USD', + exchange_rate: 10, + }, + // 1 000 SEK (no conversion) + { + supplier_id: 'sup-2', + supplier: { id: 'sup-2', name: 'Svensk leverantör' }, + due_date: '2024-06-01', + remaining_amount: 1000, + currency: 'SEK', + exchange_rate: null, + }, + ], + error: null, + }, + ] + + const report = await generateSupplierLedger(supabase, 'company-1', '2024-06-15') + + // Anthropic: 2 475 + 62.50 = 2 537.50 SEK (all in 1-30 days bucket) + const anthropic = report.entries.find(e => e.supplier_name === 'Anthropic')! + expect(anthropic.days_1_30).toBe(2537.5) + expect(anthropic.total_outstanding).toBe(2537.5) + + // Swedish supplier unchanged + const swedish = report.entries.find(e => e.supplier_name === 'Svensk leverantör')! + expect(swedish.days_1_30).toBe(1000) + + // Grand total in SEK: 2 537.50 + 1 000 = 3 537.50 + expect(report.total_outstanding).toBe(3537.5) + }) + + it('excludes FX invoices without exchange_rate from totals and counts them', async () => { + // Legacy data: an FX invoice without an exchange rate cannot be converted + // to SEK without falsifying the total. The row is excluded from sums and + // surfaced via unconverted_fx_count so the UI can warn the user. + results = [ + { + data: [ + { + supplier_id: 'sup-1', + supplier: { id: 'sup-1', name: 'Legacy' }, + due_date: '2024-06-01', + remaining_amount: 100, + currency: 'EUR', + exchange_rate: null, + }, + { + supplier_id: 'sup-2', + supplier: { id: 'sup-2', name: 'SEK supplier' }, + due_date: '2024-06-01', + remaining_amount: 500, + currency: 'SEK', + exchange_rate: null, + }, + ], + error: null, + }, + ] + + const report = await generateSupplierLedger(supabase, 'company-1', '2024-06-15') + expect(report.total_outstanding).toBe(500) + expect(report.unconverted_fx_count).toBe(1) + expect(report.entries.map(e => e.supplier_name)).toEqual(['SEK supplier']) + }) + it('uses Math.round for monetary precision', async () => { results = [ { diff --git a/lib/reports/__tests__/supplier-reconciliation.test.ts b/lib/reports/__tests__/supplier-reconciliation.test.ts index 2e348e53..2cae83ee 100644 --- a/lib/reports/__tests__/supplier-reconciliation.test.ts +++ b/lib/reports/__tests__/supplier-reconciliation.test.ts @@ -144,6 +144,70 @@ describe('generateReconciliation', () => { expect(result.account_2440_balance).toBe(7000) }) + it('converts foreign-currency remaining_amount to SEK before reconciliation', async () => { + // Reproduces the production bug: 225 EUR + 1 000 SEK was reported as 1 225 + // against a 2440 balance of 3 475, flagging a false discrepancy. + results = [ + // 0: supplier_invoices — 225 EUR at 11, plus 1 000 SEK + { + data: [ + { remaining_amount: 225, currency: 'EUR', exchange_rate: 11 }, + { remaining_amount: 1000, currency: 'SEK', exchange_rate: null }, + ], + error: null, + }, + // 1: 2440 balance = 3 475 SEK (matches converted ledger total) + { + data: [ + { debit_amount: 0, credit_amount: 3475, journal_entry_id: 'e1' }, + ], + error: null, + }, + ] + + const result = await generateReconciliation(supabase, 'company-1', 'period-1') + + expect(result.supplier_ledger_total).toBe(3475) + expect(result.account_2440_balance).toBe(3475) + expect(result.difference).toBe(0) + expect(result.is_reconciled).toBe(true) + expect(result.unconverted_fx_count).toBe(0) + }) + + it('excludes FX invoices without exchange_rate from the SEK total and counts them', async () => { + // An FX invoice without an exchange rate cannot be converted to SEK; the + // sum must not silently add raw foreign currency. The row is excluded and + // counted, so the UI can warn that the reconciliation may be unreliable. + results = [ + // 0: supplier_invoices — 100 EUR with no rate (excluded), 1 000 SEK control + { + data: [ + { remaining_amount: 100, currency: 'EUR', exchange_rate: null }, + { remaining_amount: 1000, currency: 'SEK', exchange_rate: null }, + ], + error: null, + }, + // 1: 2440 balance reflects only the SEK invoice + { + data: [ + { debit_amount: 0, credit_amount: 1000, journal_entry_id: 'e1' }, + ], + error: null, + }, + ] + + const result = await generateReconciliation(supabase, 'company-1', 'period-1') + + expect(result.unconverted_fx_count).toBe(1) + // EUR row excluded → ledger total is just the SEK 1 000 + expect(result.supplier_ledger_total).toBe(1000) + expect(result.account_2440_balance).toBe(1000) + // Numbers match, but the calculation is incomplete (a row was excluded); + // BFL 5 kap requires the period not be stamped Avstämd until the missing + // exchange rate is filled in. + expect(result.is_reconciled).toBe(false) + }) + it('uses Math.round for monetary precision', async () => { results = [ { diff --git a/lib/reports/ar-ledger.ts b/lib/reports/ar-ledger.ts index c9998a0c..065e3fa0 100644 --- a/lib/reports/ar-ledger.ts +++ b/lib/reports/ar-ledger.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' export interface ARInvoiceDetail { invoice_id: string @@ -8,7 +9,15 @@ export interface ARInvoiceDetail { due_date: string total: number paid_amount: number + /** Outstanding in the invoice's original currency. Use for display only. */ outstanding: number + /** + * Outstanding converted to SEK using the invoice-date exchange_rate. `null` + * when conversion failed (FX invoice with no rate). Callers summing across + * customers must use this field, never `outstanding`, to avoid mixing + * currencies. + */ + outstanding_sek: number | null days_overdue: number currency: string } @@ -31,6 +40,12 @@ export interface ARLedgerReport { total_current: number total_overdue: number unpaid_count: number + /** + * Number of foreign-currency invoices excluded from the SEK totals because + * they had no exchange_rate. Their detail rows are still listed (with + * outstanding_sek = null) so the user can see them. + */ + unconverted_fx_count: number } /** @@ -63,11 +78,13 @@ export async function generateARLedger( total_current: 0, total_overdue: 0, unpaid_count: 0, + unconverted_fx_count: 0, } } // Group by customer and calculate aging const byCustomer = new Map() + let unconvertedFxCount = 0 for (const inv of invoices) { const customerId = inv.customer_id @@ -94,7 +111,22 @@ export async function generateARLedger( const total = Number(inv.total) || 0 const outstanding = Math.round((total - paidAmount) * 100) / 100 - // Add invoice detail + // Aging buckets and totals must be in SEK so they reconcile with account 1510. + // Foreign-currency invoices without an exchange_rate cannot be converted — + // adding the raw foreign amount to a SEK total is unsound, so the row is + // counted but excluded from the buckets. The detail row is still pushed so + // the user can see the invoice in the expandable list, with outstanding_sek + // = null to flag the missing conversion. + const isFx = inv.currency && inv.currency !== 'SEK' + const hasRate = inv.exchange_rate != null && Number(inv.exchange_rate) > 0 + const outstandingSek = + isFx && !hasRate + ? null + : resolveSekAmount(outstanding, null, inv.currency, inv.exchange_rate) + + if (outstandingSek === null) unconvertedFxCount += 1 + + // Add invoice detail (always — even if unconvertible, so it's visible) entry.invoices.push({ invoice_id: inv.id, invoice_number: inv.invoice_number || '', @@ -103,24 +135,27 @@ export async function generateARLedger( total, paid_amount: paidAmount, outstanding, + outstanding_sek: outstandingSek, days_overdue: Math.max(0, daysOverdue), currency: inv.currency || 'SEK', }) - // Bucket by aging + if (outstandingSek === null) continue + + // Bucket by aging (in SEK) if (daysOverdue <= 0) { - entry.current += outstanding + entry.current += outstandingSek } else if (daysOverdue <= 30) { - entry.days_1_30 += outstanding + entry.days_1_30 += outstandingSek } else if (daysOverdue <= 60) { - entry.days_31_60 += outstanding + entry.days_31_60 += outstandingSek } else if (daysOverdue <= 90) { - entry.days_61_90 += outstanding + entry.days_61_90 += outstandingSek } else { - entry.days_90_plus += outstanding + entry.days_90_plus += outstandingSek } - entry.total_outstanding += outstanding + entry.total_outstanding += outstandingSek } // Round all amounts and sort invoices within each customer. @@ -155,5 +190,6 @@ export async function generateARLedger( total_current: Math.round(total_current * 100) / 100, total_overdue: Math.round(total_overdue * 100) / 100, unpaid_count, + unconverted_fx_count: unconvertedFxCount, } } diff --git a/lib/reports/ar-reconciliation.ts b/lib/reports/ar-reconciliation.ts index 601624e6..211455ae 100644 --- a/lib/reports/ar-reconciliation.ts +++ b/lib/reports/ar-reconciliation.ts @@ -1,15 +1,36 @@ import type { SupabaseClient } from '@supabase/supabase-js' +import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' 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, @@ -17,17 +38,36 @@ export async function generateARReconciliation( periodId: string ): Promise { - // Get total outstanding from customer invoices + // 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') + .select('total, paid_amount, currency, exchange_rate') .eq('company_id', companyId) .in('status', ['sent', 'overdue']) + let unconvertedFxCount = 0 const arLedgerTotal = (invoices || []) - .reduce((sum, inv) => Math.round((sum + (Number(inv.total) || 0) - (Number(inv.paid_amount) || 0)) * 100) / 100, 0) + .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 account 1510 balance from posted journal entry lines in this period + // Get AR receivable balance from posted journal entry lines 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. const { data: journalLines } = await supabase .from('journal_entry_lines') .select(` @@ -39,13 +79,12 @@ export async function generateARReconciliation( fiscal_period_id ) `) - .eq('account_number', '1510') + .in('account_number', ['1510', '1513']) .eq('journal_entries.company_id', companyId) .eq('journal_entries.fiscal_period_id', periodId) .eq('journal_entries.status', 'posted') - // Account 1510 is an asset: debit normal balance - // Balance = debits - credits + // Both 1510 and 1513 are debit-normal assets: balance = debits - credits let account1510Balance = 0 if (journalLines) { for (const line of journalLines) { @@ -59,6 +98,11 @@ export async function generateARReconciliation( ar_ledger_total: Math.round(arLedgerTotal * 100) / 100, account_1510_balance: Math.round(account1510Balance * 100) / 100, difference, - is_reconciled: Math.abs(difference) < 0.01, + // 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, } } diff --git a/lib/reports/supplier-ledger.ts b/lib/reports/supplier-ledger.ts index 392bc3dc..72c6b21f 100644 --- a/lib/reports/supplier-ledger.ts +++ b/lib/reports/supplier-ledger.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' export interface SupplierLedgerEntry { supplier_id: string @@ -18,6 +19,12 @@ export interface SupplierLedgerReport { total_current: number total_overdue: number unpaid_count: number + /** + * Number of foreign-currency invoices excluded from the SEK totals because + * they had no exchange_rate. Adding them would mix currencies; surfacing + * the count lets the UI tell the user a row could not be converted. + */ + unconverted_fx_count: number } /** @@ -49,16 +56,28 @@ export async function generateSupplierLedger( total_current: 0, total_overdue: 0, unpaid_count: 0, + unconverted_fx_count: 0, } } // Group by supplier and calculate aging const bySupplier = new Map() + let unconvertedFxCount = 0 for (const inv of invoices) { const supplierId = inv.supplier_id const supplierName = inv.supplier?.name || 'Okänd leverantör' + // Foreign-currency invoice with no exchange_rate cannot be converted to + // SEK; adding the raw foreign amount to a SEK total would be unsound, so + // the row is excluded from sums and only counted. + const isFx = inv.currency && inv.currency !== 'SEK' + const hasRate = inv.exchange_rate != null && Number(inv.exchange_rate) > 0 + if (isFx && !hasRate) { + unconvertedFxCount += 1 + continue + } + if (!bySupplier.has(supplierId)) { bySupplier.set(supplierId, { supplier_id: supplierId, @@ -75,7 +94,14 @@ export async function generateSupplierLedger( const entry = bySupplier.get(supplierId)! const dueDate = new Date(inv.due_date) const daysOverdue = Math.floor((refDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24)) - const amount = inv.remaining_amount || 0 + // remaining_amount is stored in invoice currency. The 2440 GL line was posted + // in SEK at the invoice-date rate, so we convert here for the reconciliation. + const amount = resolveSekAmount( + Number(inv.remaining_amount) || 0, + null, + inv.currency, + inv.exchange_rate + ) if (daysOverdue <= 0) { entry.current += amount @@ -105,5 +131,6 @@ export async function generateSupplierLedger( total_current: Math.round(total_current * 100) / 100, total_overdue: Math.round(total_overdue * 100) / 100, unpaid_count: invoices.length, + unconverted_fx_count: unconvertedFxCount, } } diff --git a/lib/reports/supplier-reconciliation.ts b/lib/reports/supplier-reconciliation.ts index 93dbd82c..a9f8c787 100644 --- a/lib/reports/supplier-reconciliation.ts +++ b/lib/reports/supplier-reconciliation.ts @@ -1,14 +1,29 @@ import type { SupabaseClient } from '@supabase/supabase-js' +import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' export interface ReconciliationResult { supplier_ledger_total: number account_2440_balance: number difference: number is_reconciled: boolean + /** + * Number of foreign-currency invoices that lacked an exchange_rate, so their + * remaining_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 supplier invoices against account 2440 balance + * Compare sum of open supplier invoices against account 2440 balance. + * + * Conversion uses each invoice's stored exchange_rate (the invoice-date rate), + * which matches what was originally posted to 2440. 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 generateReconciliation( supabase: SupabaseClient, @@ -16,15 +31,33 @@ export async function generateReconciliation( periodId: string ): Promise { - // Get total outstanding from supplier invoices + // 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') + .select('remaining_amount, currency, exchange_rate') .eq('company_id', companyId) .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) + let unconvertedFxCount = 0 const supplierLedgerTotal = (invoices || []) - .reduce((sum, inv) => Math.round((sum + (inv.remaining_amount || 0)) * 100) / 100, 0) + .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 sek = resolveSekAmount( + Number(inv.remaining_amount) || 0, + null, + inv.currency, + inv.exchange_rate + ) + return Math.round((sum + sek) * 100) / 100 + }, 0) // Get account 2440 balance from posted journal entry lines in this period const { data: journalLines } = await supabase @@ -58,6 +91,11 @@ export async function generateReconciliation( supplier_ledger_total: Math.round(supplierLedgerTotal * 100) / 100, account_2440_balance: Math.round(account2440Balance * 100) / 100, difference, - is_reconciled: Math.abs(difference) < 0.01, + // 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, } }