fix(reports): convert FX to SEK in supplier/AR ledger reconciliation (#396)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-05 22:30:40 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent e31e7567f4
commit 2c03dac981
13 changed files with 803 additions and 41 deletions
+28 -3
View File
@@ -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 }) {
<CardContent>
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
{ledger.unconverted_fx_count > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen.
</p>
)}
</CardContent>
</Card>
<Card>
@@ -1759,12 +1766,17 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
{formatAmount(reconciliation.difference)} kr
</span>
</div>
<div className="pt-2">
<div className="pt-2 space-y-2">
{reconciliation.is_reconciled ? (
<Badge className="bg-success/10 text-success">Avstämd</Badge>
) : (
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
)}
{reconciliation.unconverted_fx_count > 0 && (
<p className="text-xs text-muted-foreground">
{reconciliation.unconverted_fx_count} leverantörsfaktura i utländsk valuta saknar växelkurs — differensen kan bero på saknade kursuppgifter snarare än felbokning.
</p>
)}
</div>
</div>
</CardContent>
@@ -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 }) {
<CardContent>
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
{ledger.unconverted_fx_count > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen.
</p>
)}
</CardContent>
</Card>
<Card>
@@ -2400,7 +2420,7 @@ function ARLedgerView({ periodId }: { periodId: string }) {
<span className="font-mono">{formatAmount(reconciliation.ar_ledger_total)} kr</span>
</div>
<div className="flex justify-between">
<span><AccountNumber number="1510" /> saldo (huvudbok)</span>
<span>Kundfordringar (<AccountNumber number="1510" /> + <AccountNumber number="1513" />) saldo</span>
<span className="font-mono">{formatAmount(reconciliation.account_1510_balance)} kr</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
@@ -2409,12 +2429,17 @@ function ARLedgerView({ periodId }: { periodId: string }) {
{formatAmount(reconciliation.difference)} kr
</span>
</div>
<div className="pt-2">
<div className="pt-2 space-y-2">
{reconciliation.is_reconciled ? (
<Badge className="bg-success/10 text-success">Avstämd</Badge>
) : (
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
)}
{reconciliation.unconverted_fx_count > 0 && (
<p className="text-xs text-muted-foreground">
{reconciliation.unconverted_fx_count} kundfaktura i utländsk valuta saknar växelkurs — differensen kan bero på saknade kursuppgifter snarare än felbokning.
</p>
)}
</div>
</div>
</CardContent>
@@ -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<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown> = {}
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')
})
})
+54
View File
@@ -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 })
}
@@ -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() {
<Badge variant="destructive">Ej avstämd</Badge>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">
Endast konto <AccountNumber number="1930" /> ingår i denna avstämning. Övriga bankkonton (t.ex. Plusgiro <AccountNumber number="1920" />, kreditkort <AccountNumber number="1940" /> eller valutakonton) måste avstämmas separat.
</p>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
+7 -15
View File
@@ -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<UnlinkedGLLine[]> {
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[]
}
+112
View File
@@ -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 = [
{
@@ -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 = [
{
@@ -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 = [
{
@@ -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 = [
{
+44 -8
View File
@@ -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<string, ARLedgerEntry>()
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,
}
}
+52 -8
View File
@@ -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<ARReconciliationResult> {
// 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,
}
}
+28 -1
View File
@@ -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<string, SupplierLedgerEntry>()
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,
}
}
+43 -5
View File
@@ -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<ReconciliationResult> {
// 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,
}
}