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, } }