diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index d93ed27..698392d 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -198,6 +198,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit }), getHouseholdBillingSettings: async (householdId) => ({ householdId, + settlementCurrency: 'GEL', rentAmountMinor: null, rentCurrency: 'USD', rentDueDay: 20, @@ -208,6 +209,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, + settlementCurrency: 'GEL', rentAmountMinor: input.rentAmountMinor ?? null, rentCurrency: input.rentCurrency ?? 'USD', rentDueDay: input.rentDueDay ?? 20, diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 8c2d971..5c1620d 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -51,6 +51,7 @@ import { createMiniAppRentUpdateHandler } from './miniapp-billing' import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' +import { createNbgExchangeRateProvider } from './nbg-exchange-rates' const runtime = getBotRuntimeConfig() configureLogger({ @@ -71,6 +72,9 @@ const bot = createTelegramBot( const webhookHandler = webhookCallback(bot, 'std/http') const financeRepositoryClients = new Map>() const financeServices = new Map>() +const exchangeRateProvider = createNbgExchangeRateProvider({ + logger: getLogger('fx') +}) const householdOnboardingService = householdConfigurationRepositoryClient ? createHouseholdOnboardingService({ repository: householdConfigurationRepositoryClient.repository @@ -105,7 +109,12 @@ function financeServiceForHousehold(householdId: string) { financeRepositoryClients.set(householdId, repositoryClient) shutdownTasks.push(repositoryClient.close) - const service = createFinanceCommandService(repositoryClient.repository) + const service = createFinanceCommandService({ + householdId, + repository: repositoryClient.repository, + householdConfigurationRepository: householdConfigurationRepositoryClient!.repository, + exchangeRateProvider + }) financeServices.set(householdId, service) return service } diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index b20418f..7d004d0 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -118,6 +118,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { : null, getHouseholdBillingSettings: async (householdId) => ({ householdId, + settlementCurrency: 'GEL', rentAmountMinor: 70000n, rentCurrency: 'USD', rentDueDay: 20, @@ -128,6 +129,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, + settlementCurrency: 'GEL', rentAmountMinor: input.rentAmountMinor ?? 70000n, rentCurrency: input.rentCurrency ?? 'USD', rentDueDay: input.rentDueDay ?? 20, @@ -370,6 +372,7 @@ describe('createMiniAppSettingsHandler', () => { authorized: true, settings: { householdId: 'household-1', + settlementCurrency: 'GEL', rentAmountMinor: '70000', rentCurrency: 'USD', rentDueDay: 20, @@ -452,6 +455,7 @@ describe('createMiniAppUpdateSettingsHandler', () => { authorized: true, settings: { householdId: 'household-1', + settlementCurrency: 'GEL', rentAmountMinor: '75000', rentCurrency: 'USD', rentDueDay: 22, diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index 2dbe2ed..6cbba4f 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -42,6 +42,7 @@ async function readApprovalPayload(request: Request): Promise<{ async function readSettingsUpdatePayload(request: Request): Promise<{ initData: string + settlementCurrency?: string rentAmountMajor?: string rentCurrency?: string rentDueDay: number @@ -58,6 +59,7 @@ async function readSettingsUpdatePayload(request: Request): Promise<{ const text = await clonedRequest.text() let parsed: { + settlementCurrency?: string rentAmountMajor?: string rentCurrency?: string rentDueDay?: number @@ -89,6 +91,11 @@ async function readSettingsUpdatePayload(request: Request): Promise<{ rentAmountMajor: parsed.rentAmountMajor } : {}), + ...(typeof parsed.settlementCurrency === 'string' + ? { + settlementCurrency: parsed.settlementCurrency + } + : {}), ...(typeof parsed.rentCurrency === 'string' ? { rentCurrency: parsed.rentCurrency @@ -212,6 +219,7 @@ async function readRentWeightPayload(request: Request): Promise<{ function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) { return { householdId: settings.householdId, + settlementCurrency: settings.settlementCurrency, rentAmountMinor: settings.rentAmountMinor?.toString() ?? null, rentCurrency: settings.rentCurrency, rentDueDay: settings.rentDueDay, diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index b4d7713..535d79b 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -155,6 +155,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { }, getHouseholdBillingSettings: async (householdId) => ({ householdId, + settlementCurrency: 'GEL', rentAmountMinor: null, rentCurrency: 'USD', rentDueDay: 20, @@ -165,6 +166,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, + settlementCurrency: 'GEL', rentAmountMinor: input.rentAmountMinor ?? null, rentCurrency: input.rentCurrency ?? 'USD', rentDueDay: input.rentDueDay ?? 20, diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index 241e32d..7b280a7 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -77,6 +77,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { listHouseholdMembers: async () => [], getHouseholdBillingSettings: async (householdId) => ({ householdId, + settlementCurrency: 'GEL', rentAmountMinor: 70000n, rentCurrency: 'USD', rentDueDay: 20, @@ -87,6 +88,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, + settlementCurrency: 'GEL', rentAmountMinor: input.rentAmountMinor ?? 70000n, rentCurrency: input.rentCurrency ?? 'USD', rentDueDay: input.rentDueDay ?? 20, diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 8f0a264..797cd64 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -6,6 +6,7 @@ import { } from '@household/application' import { instantFromIso } from '@household/domain' import type { + ExchangeRateProvider, FinanceRepository, HouseholdConfigurationRepository, HouseholdTopicBindingRecord @@ -31,17 +32,19 @@ function repository( getOpenCycle: async () => ({ id: 'cycle-1', period: '2026-03', - currency: 'USD' + currency: 'GEL' }), getCycleByPeriod: async () => null, getLatestCycle: async () => ({ id: 'cycle-1', period: '2026-03', - currency: 'USD' + currency: 'GEL' }), openCycle: async () => {}, closeCycle: async () => {}, saveRentRule: async () => {}, + getCycleExchangeRate: async () => null, + saveCycleExchangeRate: async (input) => input, addUtilityBill: async () => {}, getRentRuleForPeriod: async () => ({ amountMinor: 70000n, @@ -53,7 +56,7 @@ function repository( id: 'utility-1', billName: 'Electricity', amountMinor: 12000n, - currency: 'USD', + currency: 'GEL', createdByMemberId: member?.id ?? 'member-1', createdAt: instantFromIso('2026-03-12T12:00:00.000Z') } @@ -72,6 +75,28 @@ function repository( } } +const exchangeRateProvider: ExchangeRateProvider = { + async getRate(input) { + if (input.baseCurrency === input.quoteCurrency) { + return { + baseCurrency: input.baseCurrency, + quoteCurrency: input.quoteCurrency, + rateMicros: 1_000_000n, + effectiveDate: input.effectiveDate, + source: 'nbg' + } + } + + return { + baseCurrency: input.baseCurrency, + quoteCurrency: input.quoteCurrency, + rateMicros: 2_700_000n, + effectiveDate: input.effectiveDate, + source: 'nbg' + } + } +} + function onboardingRepository(): HouseholdConfigurationRepository { const household = { householdId: 'household-1', @@ -141,6 +166,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { updateMemberPreferredLocale: async () => null, getHouseholdBillingSettings: async (householdId) => ({ householdId, + settlementCurrency: 'GEL', rentAmountMinor: null, rentCurrency: 'USD', rentDueDay: 20, @@ -151,6 +177,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, + settlementCurrency: input.settlementCurrency ?? 'GEL', rentAmountMinor: input.rentAmountMinor ?? null, rentCurrency: input.rentCurrency ?? 'USD', rentDueDay: input.rentDueDay ?? 20, @@ -176,16 +203,20 @@ function onboardingRepository(): HouseholdConfigurationRepository { describe('createMiniAppDashboardHandler', () => { test('returns a dashboard for an authenticated household member', async () => { const authDate = Math.floor(Date.now() / 1000) - const financeService = createFinanceCommandService( - repository({ + const householdRepository = onboardingRepository() + const financeService = createFinanceCommandService({ + householdId: 'household-1', + repository: repository({ id: 'member-1', telegramUserId: '123456', displayName: 'Stan', rentShareWeight: 1, isAdmin: true - }) - ) - const householdRepository = onboardingRepository() + }), + householdConfigurationRepository: householdRepository, + exchangeRateProvider + }) + householdRepository.listHouseholdMembersByTelegramUserId = async () => [ { id: 'member-1', @@ -232,13 +263,16 @@ describe('createMiniAppDashboardHandler', () => { authorized: true, dashboard: { period: '2026-03', - currency: 'USD', - totalDueMajor: '820.00', + currency: 'GEL', + totalDueMajor: '2010.00', + rentSourceAmountMajor: '700.00', + rentSourceCurrency: 'USD', + rentDisplayAmountMajor: '1890.00', members: [ { displayName: 'Stan', - netDueMajor: '820.00', - rentShareMajor: '700.00', + netDueMajor: '2010.00', + rentShareMajor: '1890.00', utilityShareMajor: '120.00', purchaseOffsetMajor: '0.00' } @@ -246,11 +280,13 @@ describe('createMiniAppDashboardHandler', () => { ledger: [ { title: 'Soap', - currency: 'GEL' + currency: 'GEL', + displayCurrency: 'GEL' }, { title: 'Electricity', - currency: 'USD' + currency: 'GEL', + displayCurrency: 'GEL' } ] } @@ -258,16 +294,20 @@ describe('createMiniAppDashboardHandler', () => { }) test('returns 400 for malformed JSON bodies', async () => { - const financeService = createFinanceCommandService( - repository({ + const householdRepository = onboardingRepository() + const financeService = createFinanceCommandService({ + householdId: 'household-1', + repository: repository({ id: 'member-1', telegramUserId: '123456', displayName: 'Stan', rentShareWeight: 1, isAdmin: true - }) - ) - const householdRepository = onboardingRepository() + }), + householdConfigurationRepository: householdRepository, + exchangeRateProvider + }) + householdRepository.listHouseholdMembersByTelegramUserId = async () => [ { id: 'member-1', diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index fc13291..8469ab7 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -89,6 +89,11 @@ export function createMiniAppDashboardHandler(options: { period: dashboard.period, currency: dashboard.currency, totalDueMajor: dashboard.totalDue.toMajorString(), + rentSourceAmountMajor: dashboard.rentSourceAmount.toMajorString(), + rentSourceCurrency: dashboard.rentSourceAmount.currency, + rentDisplayAmountMajor: dashboard.rentDisplayAmount.toMajorString(), + rentFxRateMicros: dashboard.rentFxRateMicros?.toString() ?? null, + rentFxEffectiveDate: dashboard.rentFxEffectiveDate, members: dashboard.members.map((line) => ({ memberId: line.memberId, displayName: line.displayName, @@ -104,6 +109,10 @@ export function createMiniAppDashboardHandler(options: { title: entry.title, amountMajor: entry.amount.toMajorString(), currency: entry.currency, + displayAmountMajor: entry.displayAmount.toMajorString(), + displayCurrency: entry.displayCurrency, + fxRateMicros: entry.fxRateMicros?.toString() ?? null, + fxEffectiveDate: entry.fxEffectiveDate, actorDisplayName: entry.actorDisplayName, occurredAt: entry.occurredAt })) diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts index 90b677e..9dca72f 100644 --- a/apps/bot/src/miniapp-locale.test.ts +++ b/apps/bot/src/miniapp-locale.test.ts @@ -130,6 +130,7 @@ function repository(): HouseholdConfigurationRepository { }, getHouseholdBillingSettings: async (householdId) => ({ householdId, + settlementCurrency: 'GEL', rentAmountMinor: null, rentCurrency: 'USD', rentDueDay: 20, @@ -140,6 +141,7 @@ function repository(): HouseholdConfigurationRepository { }), updateHouseholdBillingSettings: async (input) => ({ householdId: input.householdId, + settlementCurrency: 'GEL', rentAmountMinor: input.rentAmountMinor ?? null, rentCurrency: input.rentCurrency ?? 'USD', rentDueDay: input.rentDueDay ?? 20, diff --git a/apps/bot/src/nbg-exchange-rates.ts b/apps/bot/src/nbg-exchange-rates.ts new file mode 100644 index 0000000..7514ef8 --- /dev/null +++ b/apps/bot/src/nbg-exchange-rates.ts @@ -0,0 +1,157 @@ +import { FX_RATE_SCALE_MICROS, type CurrencyCode } from '@household/domain' +import type { ExchangeRateProvider } from '@household/ports' +import type { Logger } from '@household/observability' + +interface NbgCurrencyPayload { + code: string + quantity: number + rateFormated?: string + rate?: number + validFromDate?: string +} + +interface NbgDayPayload { + date?: string + currencies?: NbgCurrencyPayload[] +} + +function parseScaledDecimal(value: string, scale: number): bigint { + const trimmed = value.trim() + const match = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(trimmed) + if (!match) { + throw new Error(`Invalid decimal value: ${value}`) + } + + const [, sign, whole, fraction = ''] = match + const normalizedFraction = fraction.padEnd(scale, '0').slice(0, scale) + const digits = `${whole}${normalizedFraction}` + const parsed = BigInt(digits) + + return sign === '-' ? -parsed : parsed +} + +function divideRoundedHalfUp(dividend: bigint, divisor: bigint): bigint { + if (divisor === 0n) { + throw new Error('Division by zero') + } + + const quotient = dividend / divisor + const remainder = dividend % divisor + if (remainder * 2n >= divisor) { + return quotient + 1n + } + + return quotient +} + +export function createNbgExchangeRateProvider( + options: { + fetchImpl?: typeof fetch + logger?: Logger + } = {} +): ExchangeRateProvider { + const fetchImpl = options.fetchImpl ?? fetch + const cache = new Map>() + + async function getGelRate(currency: CurrencyCode, effectiveDate: string) { + if (currency === 'GEL') { + return { + gelRateMicros: FX_RATE_SCALE_MICROS, + effectiveDate + } + } + + const cacheKey = `${currency}:${effectiveDate}` + const existing = cache.get(cacheKey) + if (existing) { + return existing + } + + const request = (async () => { + const url = new URL('https://nbg.gov.ge/gw/api/ct/monetarypolicy/currencies/en/json/') + url.searchParams.set('currencies', currency) + url.searchParams.set('date', effectiveDate) + + const response = await fetchImpl(url) + if (!response.ok) { + throw new Error(`NBG request failed: ${response.status}`) + } + + const payload = (await response.json()) as NbgDayPayload[] + const day = payload[0] + const currencyPayload = day?.currencies?.find((entry) => entry.code === currency) + if (!currencyPayload) { + throw new Error(`NBG rate missing for ${currency} on ${effectiveDate}`) + } + + const quantity = Number(currencyPayload.quantity) + if (!Number.isFinite(quantity) || quantity <= 0) { + throw new Error(`Invalid NBG quantity for ${currency}: ${currencyPayload.quantity}`) + } + + const rateString = + currencyPayload.rateFormated ?? + (typeof currencyPayload.rate === 'number' ? currencyPayload.rate.toFixed(6) : null) + if (!rateString) { + throw new Error(`Invalid NBG rate for ${currency} on ${effectiveDate}`) + } + + const effective = + currencyPayload.validFromDate?.slice(0, 10) ?? day?.date?.slice(0, 10) ?? effectiveDate + const gelRateMicros = divideRoundedHalfUp(parseScaledDecimal(rateString, 6), BigInt(quantity)) + + options.logger?.debug( + { + event: 'fx.nbg_fetched', + currency, + requestedDate: effectiveDate, + effectiveDate: effective, + gelRateMicros: gelRateMicros.toString() + }, + 'Fetched NBG exchange rate' + ) + + return { + gelRateMicros, + effectiveDate: effective + } + })() + + cache.set(cacheKey, request) + return request + } + + return { + async getRate(input) { + if (input.baseCurrency === input.quoteCurrency) { + return { + baseCurrency: input.baseCurrency, + quoteCurrency: input.quoteCurrency, + rateMicros: FX_RATE_SCALE_MICROS, + effectiveDate: input.effectiveDate, + source: 'nbg' + } + } + + const [base, quote] = await Promise.all([ + getGelRate(input.baseCurrency, input.effectiveDate), + getGelRate(input.quoteCurrency, input.effectiveDate) + ]) + + const rateMicros = divideRoundedHalfUp( + base.gelRateMicros * FX_RATE_SCALE_MICROS, + quote.gelRateMicros + ) + const effectiveDate = + base.effectiveDate > quote.effectiveDate ? base.effectiveDate : quote.effectiveDate + + return { + baseCurrency: input.baseCurrency, + quoteCurrency: input.quoteCurrency, + rateMicros, + effectiveDate, + source: 'nbg' + } + } + } +} diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 9d97a79..5c1d06a 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -51,7 +51,8 @@ export type PurchaseMessageIngestionResult = export interface PurchaseMessageIngestionRepository { save( record: PurchaseTopicRecord, - llmFallback?: PurchaseParserLlmFallback + llmFallback?: PurchaseParserLlmFallback, + defaultCurrency?: 'GEL' | 'USD' ): Promise } @@ -121,7 +122,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): { }) const repository: PurchaseMessageIngestionRepository = { - async save(record, llmFallback) { + async save(record, llmFallback, defaultCurrency) { const matchedMember = await db .select({ id: schema.members.id }) .from(schema.members) @@ -140,11 +141,18 @@ export function createPurchaseMessageRepository(databaseUrl: string): { { rawText: record.rawText }, - llmFallback - ? { - llmFallback - } - : {} + { + ...(llmFallback + ? { + llmFallback + } + : {}), + ...(defaultCurrency + ? { + defaultCurrency + } + : {}) + } ).catch((error) => { parserError = error instanceof Error ? error.message : 'Unknown parser error' return null @@ -324,7 +332,7 @@ export function registerPurchaseTopicIngestion( } try { - const status = await repository.save(record, options.llmFallback) + const status = await repository.save(record, options.llmFallback, 'GEL') const acknowledgement = buildPurchaseAcknowledgement(status, 'en') if (status.status === 'created') { @@ -394,7 +402,14 @@ export function registerConfiguredPurchaseTopicIngestion( } try { - const status = await repository.save(record, options.llmFallback) + const billingSettings = await householdConfigurationRepository.getHouseholdBillingSettings( + record.householdId + ) + const status = await repository.save( + record, + options.llmFallback, + billingSettings.settlementCurrency + ) const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId( record.householdId ) diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index b5a2c60..8799afa 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -1,4 +1,4 @@ -import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js' +import { Match, Show, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js' import { dictionary, type Locale } from './i18n' import { @@ -160,6 +160,18 @@ function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string ) } +function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string { + return `${entry.displayAmountMajor} ${entry.displayCurrency}` +} + +function ledgerSecondaryAmount(entry: MiniAppDashboard['ledger'][number]): string | null { + if (entry.currency === entry.displayCurrency && entry.amountMajor === entry.displayAmountMajor) { + return null + } + + return `${entry.amountMajor} ${entry.currency}` +} + function App() { const [locale, setLocale] = createSignal('en') const [session, setSession] = createSignal({ @@ -184,6 +196,7 @@ function App() { const [savingCycleRent, setSavingCycleRent] = createSignal(false) const [savingUtilityBill, setSavingUtilityBill] = createSignal(false) const [billingForm, setBillingForm] = createSignal({ + settlementCurrency: 'GEL' as 'USD' | 'GEL', rentAmountMajor: '', rentCurrency: 'USD' as 'USD' | 'GEL', rentDueDay: 20, @@ -195,7 +208,7 @@ function App() { const [newCategoryName, setNewCategoryName] = createSignal('') const [cycleForm, setCycleForm] = createSignal({ period: defaultCyclePeriod(), - currency: 'USD' as 'USD' | 'GEL', + currency: 'GEL' as 'USD' | 'GEL', rentAmountMajor: '', utilityCategorySlug: '', utilityAmountMajor: '' @@ -267,12 +280,14 @@ function App() { ) setCycleForm((current) => ({ ...current, + currency: current.currency || payload.settings.settlementCurrency, utilityCategorySlug: current.utilityCategorySlug || payload.categories.find((category) => category.isActive)?.slug || '' })) setBillingForm({ + settlementCurrency: payload.settings.settlementCurrency, rentAmountMajor: payload.settings.rentAmountMinor ? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2) : '', @@ -299,7 +314,10 @@ function App() { setCycleForm((current) => ({ ...current, period: payload.cycle?.period ?? current.period, - currency: payload.cycle?.currency ?? payload.rentRule?.currency ?? current.currency, + currency: + payload.cycle?.currency ?? + adminSettings()?.settings.settlementCurrency ?? + current.currency, rentAmountMajor: payload.rentRule ? (Number(payload.rentRule.amountMinor) / 100).toFixed(2) : '', @@ -385,25 +403,30 @@ function App() { setSession(demoSession) setDashboard({ period: '2026-03', - currency: 'USD', - totalDueMajor: '414.00', + currency: 'GEL', + totalDueMajor: '1030.00', + rentSourceAmountMajor: '700.00', + rentSourceCurrency: 'USD', + rentDisplayAmountMajor: '1932.00', + rentFxRateMicros: '2760000', + rentFxEffectiveDate: '2026-03-17', members: [ { memberId: 'demo-member', displayName: 'Demo Resident', - rentShareMajor: '175.00', + rentShareMajor: '483.00', utilityShareMajor: '32.00', purchaseOffsetMajor: '-14.00', - netDueMajor: '193.00', + netDueMajor: '501.00', explanations: ['Equal utility split', 'Shared purchase offset'] }, { memberId: 'member-2', displayName: 'Alice', - rentShareMajor: '175.00', + rentShareMajor: '483.00', utilityShareMajor: '32.00', purchaseOffsetMajor: '14.00', - netDueMajor: '221.00', + netDueMajor: '529.00', explanations: ['Equal utility split'] } ], @@ -414,6 +437,10 @@ function App() { title: 'Soap', amountMajor: '30.00', currency: 'GEL', + displayAmountMajor: '30.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, actorDisplayName: 'Alice', occurredAt: '2026-03-12T11:00:00.000Z' }, @@ -423,6 +450,10 @@ function App() { title: 'Electricity', amountMajor: '120.00', currency: 'GEL', + displayAmountMajor: '120.00', + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, actorDisplayName: 'Alice', occurredAt: '2026-03-12T12:00:00.000Z' } @@ -612,6 +643,10 @@ function App() { } : current ) + setCycleForm((current) => ({ + ...current, + currency: cycleState()?.cycle?.currency ?? settings.settlementCurrency + })) } finally { setSavingBillingSettings(false) } @@ -914,10 +949,11 @@ function App() {
{entry.title} - - {entry.amountMajor} {entry.currency} - + {ledgerPrimaryAmount(entry)}
+ + {(secondary) =>

{secondary()}

} +

{entry.actorDisplayName ?? copy().ledgerActorFallback}

))} @@ -936,10 +972,11 @@ function App() {
{entry.title} - - {entry.amountMajor} {entry.currency} - + {ledgerPrimaryAmount(entry)}
+ + {(secondary) =>

{secondary()}

} +

{entry.actorDisplayName ?? copy().ledgerActorFallback}

))} @@ -973,6 +1010,17 @@ function App() { cycleState()?.cycle?.currency ?? cycleForm().currency )}

+ + {(data) => ( +

+ {copy().shareRent}: {data().rentSourceAmountMajor}{' '} + {data().rentSourceCurrency} + {data().rentSourceCurrency !== data().currency + ? ` -> ${data().rentDisplayAmountMajor} ${data().currency}` + : ''} +

+ )} +