Files
accounted/extensions/export/currency-receivables/lib/receivables-engine.ts
T
EmilandClaude Opus 4.6 0764bc11a6 feat: implement 4 export extensions with engines, API routes, and workspaces
- EU Sales List: engine, CSV/XML generators, Skatteverket format, tests
- VAT Monitor: moms box mapping, revenue breakdown, validation, tests
- Intrastat: product registry, SCB CSV generator, threshold tracking, tests
- Currency Receivables: FX exposure, unrealized gain/loss calc, tests
- Shared utilities: EU country list, moms box mapping
- API routes for report generation and file downloads
- Workspace UI components for all 4 extensions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:10:16 +01:00

360 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Multi-Currency Receivables Engine
*
* Pure-function engine that calculates foreign currency exposure from
* open receivables, unrealized FX gains/losses vs. current Riksbanken
* rates, and realized FX gains/losses from GL accounts 3960/7960.
*
* This extension is READ-ONLY — it does not create journal entries.
* The revaluation preview is informational only.
*/
// ── Types ──────────────────────────────────────────────────────
/** Minimal invoice data needed by the engine */
export interface ReceivableInvoice {
id: string
invoice_number: string
invoice_date: string
due_date: string
status: string
currency: string
total: number
total_sek: number | null
exchange_rate: number | null
customer_id: string
}
/** Minimal customer data */
export interface ReceivableCustomer {
id: string
name: string
country: string
}
/** GL line for realized FX */
export interface GLLine {
account_number: string
debit: number
credit: number
entry_date: string
}
/** Current exchange rate */
export interface ExchangeRateInfo {
currency: string
rate: number
date: string
}
/** Engine options */
export interface ReceivablesOptions {
/** Open (unpaid) foreign-currency invoices */
invoices: ReceivableInvoice[]
/** Customers for those invoices */
customers: ReceivableCustomer[]
/** Current exchange rates from Riksbanken */
currentRates: ExchangeRateInfo[]
/** GL lines on accounts 3960 and 7960 for the selected year */
realizedFXLines: GLLine[]
/** Reference date (for days outstanding calculation) */
referenceDate: string
/** Selected year for realized FX */
year: number
}
// ── Output types ───────────────────────────────────────────────
export interface CurrencyExposure {
currency: string
totalForeignAmount: number
bookedSekValue: number
currentSekValue: number
unrealizedGainLoss: number
invoiceCount: number
averageBookedRate: number
currentRate: number
}
export interface ForeignReceivable {
invoiceId: string
invoiceNumber: string
customerName: string
customerCountry: string
currency: string
foreignAmount: number
bookedSekAmount: number
bookedRate: number
currentSekAmount: number
currentRate: number
unrealizedGainLoss: number
invoiceDate: string
dueDate: string
daysOutstanding: number
}
export interface MonthlyFXTrend {
month: string // 'YYYY-MM'
realizedGains: number
realizedLosses: number
netRealized: number
}
export interface RevalPreview {
totalUnrealizedGainLoss: number
gains: number // Positive: amount for account 3969
losses: number // Positive: amount for account 7969
}
export interface ReceivablesReport {
referenceDate: string
exchangeRates: ExchangeRateInfo[]
exposureByCurrency: CurrencyExposure[]
receivables: ForeignReceivable[]
realizedGainLoss: {
year: number
gains: number
losses: number
net: number
}
monthlyTrend: MonthlyFXTrend[]
revalPreview: RevalPreview
totals: {
bookedSekValue: number
currentSekValue: number
totalUnrealizedGainLoss: number
receivableCount: number
currencyCount: number
}
}
// ── Constants ──────────────────────────────────────────────────
const ACCOUNT_FX_GAINS = '3960'
const ACCOUNT_FX_LOSSES = '7960'
const OPEN_STATUSES = ['sent', 'overdue']
// ── Engine ─────────────────────────────────────────────────────
export function generateReceivablesReport(options: ReceivablesOptions): ReceivablesReport {
const { invoices, customers, currentRates, realizedFXLines, referenceDate, year } = options
const rateMap = new Map<string, ExchangeRateInfo>()
for (const r of currentRates) {
rateMap.set(r.currency.toUpperCase(), r)
}
const customerMap = new Map<string, ReceivableCustomer>()
for (const c of customers) {
customerMap.set(c.id, c)
}
// Filter to open foreign-currency invoices
const foreignInvoices = invoices.filter(inv =>
OPEN_STATUSES.includes(inv.status) &&
inv.currency !== 'SEK' &&
inv.total > 0
)
// Build receivable details
const receivables: ForeignReceivable[] = []
for (const inv of foreignInvoices) {
const customer = customerMap.get(inv.customer_id)
const rateInfo = rateMap.get(inv.currency.toUpperCase())
const currentRate = rateInfo?.rate ?? 0
const bookedSekAmount = getBookedSekAmount(inv)
const bookedRate = inv.total > 0 ? bookedSekAmount / inv.total : 0
const currentSekAmount = round2(inv.total * currentRate)
const unrealizedGainLoss = round2(currentSekAmount - bookedSekAmount)
const daysOutstanding = daysBetween(inv.invoice_date, referenceDate)
receivables.push({
invoiceId: inv.id,
invoiceNumber: inv.invoice_number,
customerName: customer?.name ?? 'Okänd kund',
customerCountry: customer?.country ?? '',
currency: inv.currency,
foreignAmount: inv.total,
bookedSekAmount,
bookedRate: round4(bookedRate),
currentSekAmount,
currentRate: round4(currentRate),
unrealizedGainLoss,
invoiceDate: inv.invoice_date,
dueDate: inv.due_date,
daysOutstanding,
})
}
// Sort by unrealized gain/loss (largest absolute first)
receivables.sort((a, b) => Math.abs(b.unrealizedGainLoss) - Math.abs(a.unrealizedGainLoss))
// Aggregate by currency
const exposureMap = new Map<string, {
totalForeign: number
bookedSek: number
currentSek: number
count: number
}>()
for (const r of receivables) {
const existing = exposureMap.get(r.currency) || { totalForeign: 0, bookedSek: 0, currentSek: 0, count: 0 }
existing.totalForeign += r.foreignAmount
existing.bookedSek += r.bookedSekAmount
existing.currentSek += r.currentSekAmount
existing.count += 1
exposureMap.set(r.currency, existing)
}
const exposureByCurrency: CurrencyExposure[] = []
for (const [currency, agg] of exposureMap.entries()) {
const rateInfo = rateMap.get(currency.toUpperCase())
exposureByCurrency.push({
currency,
totalForeignAmount: round2(agg.totalForeign),
bookedSekValue: round2(agg.bookedSek),
currentSekValue: round2(agg.currentSek),
unrealizedGainLoss: round2(agg.currentSek - agg.bookedSek),
invoiceCount: agg.count,
averageBookedRate: agg.totalForeign > 0 ? round4(agg.bookedSek / agg.totalForeign) : 0,
currentRate: rateInfo?.rate ?? 0,
})
}
// Sort exposures by absolute unrealized gain/loss descending
exposureByCurrency.sort((a, b) => Math.abs(b.unrealizedGainLoss) - Math.abs(a.unrealizedGainLoss))
// Calculate realized FX from GL
const { gains, losses } = calculateRealizedFX(realizedFXLines)
// Monthly trend
const monthlyTrend = calculateMonthlyTrend(realizedFXLines, year)
// Revaluation preview
const totalBooked = receivables.reduce((sum, r) => sum + r.bookedSekAmount, 0)
const totalCurrent = receivables.reduce((sum, r) => sum + r.currentSekAmount, 0)
const totalUnrealized = round2(totalCurrent - totalBooked)
const revalPreview: RevalPreview = {
totalUnrealizedGainLoss: totalUnrealized,
gains: round2(Math.max(0, totalUnrealized)),
losses: round2(Math.abs(Math.min(0, totalUnrealized))),
}
const currencies = new Set(receivables.map(r => r.currency))
return {
referenceDate,
exchangeRates: currentRates,
exposureByCurrency,
receivables,
realizedGainLoss: {
year,
gains: round2(gains),
losses: round2(losses),
net: round2(gains - losses),
},
monthlyTrend,
revalPreview,
totals: {
bookedSekValue: round2(totalBooked),
currentSekValue: round2(totalCurrent),
totalUnrealizedGainLoss: totalUnrealized,
receivableCount: receivables.length,
currencyCount: currencies.size,
},
}
}
// ── Helpers ────────────────────────────────────────────────────
function getBookedSekAmount(inv: ReceivableInvoice): number {
// Prefer the explicitly stored SEK amount
if (inv.total_sek !== null && inv.total_sek !== undefined) {
return round2(inv.total_sek)
}
// Fallback: total × exchange_rate at invoice date
if (inv.exchange_rate !== null && inv.exchange_rate !== undefined) {
return round2(inv.total * inv.exchange_rate)
}
// No rate info — return 0 (will show as warning in UI)
return 0
}
function calculateRealizedFX(lines: GLLine[]): { gains: number; losses: number } {
let gains = 0
let losses = 0
for (const line of lines) {
if (line.account_number === ACCOUNT_FX_GAINS) {
// FX gains are booked as credits on 3960
gains += line.credit - line.debit
} else if (line.account_number === ACCOUNT_FX_LOSSES) {
// FX losses are booked as debits on 7960
losses += line.debit - line.credit
}
}
return { gains: Math.max(0, gains), losses: Math.max(0, losses) }
}
function calculateMonthlyTrend(lines: GLLine[], year: number): MonthlyFXTrend[] {
const monthData = new Map<string, { gains: number; losses: number }>()
// Initialize all 12 months
for (let m = 1; m <= 12; m++) {
const key = `${year}-${String(m).padStart(2, '0')}`
monthData.set(key, { gains: 0, losses: 0 })
}
for (const line of lines) {
const monthKey = line.entry_date.substring(0, 7) // 'YYYY-MM'
const data = monthData.get(monthKey)
if (!data) continue
if (line.account_number === ACCOUNT_FX_GAINS) {
data.gains += line.credit - line.debit
} else if (line.account_number === ACCOUNT_FX_LOSSES) {
data.losses += line.debit - line.credit
}
}
const trend: MonthlyFXTrend[] = []
for (const [month, data] of monthData.entries()) {
const gains = Math.max(0, round2(data.gains))
const losses = Math.max(0, round2(data.losses))
trend.push({
month,
realizedGains: gains,
realizedLosses: losses,
netRealized: round2(gains - losses),
})
}
// Sort chronologically
trend.sort((a, b) => a.month.localeCompare(b.month))
return trend
}
function daysBetween(dateStr: string, refDateStr: string): number {
const d1 = new Date(dateStr)
const d2 = new Date(refDateStr)
const diffMs = d2.getTime() - d1.getTime()
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)))
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
function round4(n: number): number {
return Math.round(n * 10000) / 10000
}