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