Broad update across dashboard pages, components, extensions, and lib code. Includes ESLint config additions, onboarding flow redesign, settings page refactor, help page content expansion, dead code removal, and test mock fixes. Adds dev docs and public assets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
316 lines
9.3 KiB
TypeScript
316 lines
9.3 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
|
|
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
|
import type {
|
|
Currency,
|
|
Invoice,
|
|
SupplierInvoice,
|
|
RevaluationItem,
|
|
CurrencyRevaluationPreview,
|
|
CurrencyRevaluationResult,
|
|
CreateJournalEntryLineInput,
|
|
} from '@/types'
|
|
|
|
/**
|
|
* Fetch open foreign-currency receivables (invoices).
|
|
* Returns invoices with status 'sent' or 'overdue', non-SEK currency,
|
|
* and a known exchange rate.
|
|
*/
|
|
export async function getOpenForeignCurrencyReceivables(
|
|
supabase: SupabaseClient,
|
|
userId: string
|
|
): Promise<Invoice[]> {
|
|
const { data, error } = await supabase
|
|
.from('invoices')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.in('status', ['sent', 'overdue'])
|
|
.neq('currency', 'SEK')
|
|
.not('exchange_rate', 'is', null)
|
|
|
|
if (error) {
|
|
throw new Error(`Failed to fetch foreign currency receivables: ${error.message}`)
|
|
}
|
|
|
|
return (data || []) as Invoice[]
|
|
}
|
|
|
|
/**
|
|
* Fetch open foreign-currency payables (supplier invoices).
|
|
* Returns supplier invoices with open statuses, non-SEK currency,
|
|
* and a known exchange rate. Uses remaining_amount for partial payments.
|
|
*/
|
|
export async function getOpenForeignCurrencyPayables(
|
|
supabase: SupabaseClient,
|
|
userId: string
|
|
): Promise<SupplierInvoice[]> {
|
|
const { data, error } = await supabase
|
|
.from('supplier_invoices')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
|
|
.neq('currency', 'SEK')
|
|
.not('exchange_rate', 'is', null)
|
|
|
|
if (error) {
|
|
throw new Error(`Failed to fetch foreign currency payables: ${error.message}`)
|
|
}
|
|
|
|
return (data || []) as SupplierInvoice[]
|
|
}
|
|
|
|
/**
|
|
* Preview currency revaluation without persisting.
|
|
* Computes per-item differences and aggregated journal lines.
|
|
*
|
|
* Receivables (1510):
|
|
* closing > original → gain: Debit 1510, Credit 3960
|
|
* closing < original → loss: Credit 1510, Debit 7960
|
|
*
|
|
* Payables (2440):
|
|
* closing > original → loss (liability grew): Debit 7960, Credit 2440
|
|
* closing < original → gain (liability shrank): Debit 2440, Credit 3960
|
|
*/
|
|
export async function previewCurrencyRevaluation(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
closingDate: string
|
|
): Promise<CurrencyRevaluationPreview> {
|
|
const [receivables, payables] = await Promise.all([
|
|
getOpenForeignCurrencyReceivables(supabase, userId),
|
|
getOpenForeignCurrencyPayables(supabase, userId),
|
|
])
|
|
|
|
// Collect distinct currencies
|
|
const currencies = new Set<Currency>()
|
|
for (const inv of receivables) {
|
|
currencies.add(inv.currency)
|
|
}
|
|
for (const si of payables) {
|
|
currencies.add(si.currency as Currency)
|
|
}
|
|
|
|
if (currencies.size === 0) {
|
|
return {
|
|
items: [],
|
|
lines: [],
|
|
closingRates: {},
|
|
totalGain: 0,
|
|
totalLoss: 0,
|
|
netEffect: 0,
|
|
}
|
|
}
|
|
|
|
// Fetch closing rates
|
|
const rateMap = await fetchMultipleRates(
|
|
Array.from(currencies),
|
|
new Date(closingDate)
|
|
)
|
|
|
|
const closingRates: Record<string, number> = {}
|
|
for (const [currency, rate] of rateMap) {
|
|
closingRates[currency] = rate.rate
|
|
}
|
|
|
|
const items: RevaluationItem[] = []
|
|
|
|
// Process receivables
|
|
for (const inv of receivables) {
|
|
const closingRate = rateMap.get(inv.currency)?.rate
|
|
if (!closingRate || !inv.exchange_rate) continue
|
|
|
|
const amountInCurrency = inv.total
|
|
const originalSek = Math.round(amountInCurrency * inv.exchange_rate * 100) / 100
|
|
const closingSek = Math.round(amountInCurrency * closingRate * 100) / 100
|
|
const difference = Math.round((closingSek - originalSek) * 100) / 100
|
|
|
|
if (Math.abs(difference) < 0.01) continue
|
|
|
|
items.push({
|
|
type: 'receivable',
|
|
source_id: inv.id,
|
|
reference: inv.invoice_number,
|
|
currency: inv.currency,
|
|
amount_in_currency: amountInCurrency,
|
|
original_rate: inv.exchange_rate,
|
|
closing_rate: closingRate,
|
|
original_sek: originalSek,
|
|
closing_sek: closingSek,
|
|
difference_sek: difference,
|
|
})
|
|
}
|
|
|
|
// Process payables (use remaining_amount for partial payments)
|
|
for (const si of payables) {
|
|
const closingRate = rateMap.get(si.currency as Currency)?.rate
|
|
if (!closingRate || !si.exchange_rate) continue
|
|
|
|
const amountInCurrency = si.remaining_amount
|
|
if (amountInCurrency <= 0) continue
|
|
|
|
const originalSek = Math.round(amountInCurrency * si.exchange_rate * 100) / 100
|
|
const closingSek = Math.round(amountInCurrency * closingRate * 100) / 100
|
|
const difference = Math.round((closingSek - originalSek) * 100) / 100
|
|
|
|
if (Math.abs(difference) < 0.01) continue
|
|
|
|
items.push({
|
|
type: 'payable',
|
|
source_id: si.id,
|
|
reference: si.supplier_invoice_number,
|
|
currency: si.currency as Currency,
|
|
amount_in_currency: amountInCurrency,
|
|
original_rate: si.exchange_rate,
|
|
closing_rate: closingRate,
|
|
original_sek: originalSek,
|
|
closing_sek: closingSek,
|
|
difference_sek: difference,
|
|
})
|
|
}
|
|
|
|
// Build aggregated journal lines
|
|
let debit1510 = 0 // Receivable gain (revalue up)
|
|
let credit1510 = 0 // Receivable loss (revalue down)
|
|
let debit2440 = 0 // Payable gain (liability shrank)
|
|
let credit2440 = 0 // Payable loss (liability grew)
|
|
let credit3960 = 0 // Gains
|
|
let debit7960 = 0 // Losses
|
|
|
|
for (const item of items) {
|
|
if (item.type === 'receivable') {
|
|
if (item.difference_sek > 0) {
|
|
// Closing > original → gain: Debit 1510, Credit 3960
|
|
debit1510 += item.difference_sek
|
|
credit3960 += item.difference_sek
|
|
} else {
|
|
// Closing < original → loss: Credit 1510, Debit 7960
|
|
credit1510 += Math.abs(item.difference_sek)
|
|
debit7960 += Math.abs(item.difference_sek)
|
|
}
|
|
} else {
|
|
// Payable
|
|
if (item.difference_sek > 0) {
|
|
// Closing > original → loss (liability grew): Debit 7960, Credit 2440
|
|
debit7960 += item.difference_sek
|
|
credit2440 += item.difference_sek
|
|
} else {
|
|
// Closing < original → gain (liability shrank): Debit 2440, Credit 3960
|
|
debit2440 += Math.abs(item.difference_sek)
|
|
credit3960 += Math.abs(item.difference_sek)
|
|
}
|
|
}
|
|
}
|
|
|
|
const lines: CreateJournalEntryLineInput[] = []
|
|
|
|
if (debit1510 > 0) {
|
|
lines.push({
|
|
account_number: '1510',
|
|
debit_amount: Math.round(debit1510 * 100) / 100,
|
|
credit_amount: 0,
|
|
line_description: 'Omvärdering kundfordringar — orealiserad kursvinst',
|
|
})
|
|
}
|
|
if (credit1510 > 0) {
|
|
lines.push({
|
|
account_number: '1510',
|
|
debit_amount: 0,
|
|
credit_amount: Math.round(credit1510 * 100) / 100,
|
|
line_description: 'Omvärdering kundfordringar — orealiserad kursförlust',
|
|
})
|
|
}
|
|
if (debit2440 > 0) {
|
|
lines.push({
|
|
account_number: '2440',
|
|
debit_amount: Math.round(debit2440 * 100) / 100,
|
|
credit_amount: 0,
|
|
line_description: 'Omvärdering leverantörsskulder — orealiserad kursvinst',
|
|
})
|
|
}
|
|
if (credit2440 > 0) {
|
|
lines.push({
|
|
account_number: '2440',
|
|
debit_amount: 0,
|
|
credit_amount: Math.round(credit2440 * 100) / 100,
|
|
line_description: 'Omvärdering leverantörsskulder — orealiserad kursförlust',
|
|
})
|
|
}
|
|
if (credit3960 > 0) {
|
|
lines.push({
|
|
account_number: '3960',
|
|
debit_amount: 0,
|
|
credit_amount: Math.round(credit3960 * 100) / 100,
|
|
line_description: 'Orealiserade valutakursvinster',
|
|
})
|
|
}
|
|
if (debit7960 > 0) {
|
|
lines.push({
|
|
account_number: '7960',
|
|
debit_amount: Math.round(debit7960 * 100) / 100,
|
|
credit_amount: 0,
|
|
line_description: 'Orealiserade valutakursförluster',
|
|
})
|
|
}
|
|
|
|
const totalGain = Math.round(credit3960 * 100) / 100
|
|
const totalLoss = Math.round(debit7960 * 100) / 100
|
|
const netEffect = Math.round((totalGain - totalLoss) * 100) / 100
|
|
|
|
return {
|
|
items,
|
|
lines,
|
|
closingRates,
|
|
totalGain,
|
|
totalLoss,
|
|
netEffect,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute currency revaluation for a fiscal period.
|
|
* Creates a journal entry with source_type 'currency_revaluation'.
|
|
*
|
|
* Returns null if no foreign-currency items exist.
|
|
* Throws if a revaluation entry already exists for this period (idempotency).
|
|
*/
|
|
export async function executeCurrencyRevaluation(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
closingDate: string,
|
|
fiscalPeriodId: string
|
|
): Promise<CurrencyRevaluationResult | null> {
|
|
// Idempotency check: prevent double revaluation
|
|
const { count, error: checkError } = await supabase
|
|
.from('journal_entries')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('user_id', userId)
|
|
.eq('fiscal_period_id', fiscalPeriodId)
|
|
.eq('source_type', 'currency_revaluation')
|
|
.eq('status', 'posted')
|
|
|
|
if (checkError) {
|
|
throw new Error(`Failed to check existing revaluation: ${checkError.message}`)
|
|
}
|
|
|
|
if ((count ?? 0) > 0) {
|
|
throw new Error('Currency revaluation already exists for this period')
|
|
}
|
|
|
|
const preview = await previewCurrencyRevaluation(supabase, userId, closingDate)
|
|
|
|
if (preview.items.length === 0 || preview.lines.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const entry = await createJournalEntry(supabase, userId, {
|
|
fiscal_period_id: fiscalPeriodId,
|
|
entry_date: closingDate,
|
|
description: `Omvärdering utländsk valuta ${closingDate}`,
|
|
source_type: 'currency_revaluation',
|
|
voucher_series: 'A',
|
|
lines: preview.lines,
|
|
})
|
|
|
|
return { entry, preview }
|
|
}
|