feat(finance): add settlement currency and cycle fx rates

This commit is contained in:
2026-03-10 16:46:59 +04:00
parent 4c0508f618
commit fb85219409
38 changed files with 3546 additions and 114 deletions

View File

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

View File

@@ -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<string, ReturnType<typeof createDbFinanceRepository>>()
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
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
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

@@ -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
}))

View File

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

View File

@@ -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<string, Promise<{ gelRateMicros: bigint; effectiveDate: string }>>()
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'
}
}
}
}

View File

@@ -51,7 +51,8 @@ export type PurchaseMessageIngestionResult =
export interface PurchaseMessageIngestionRepository {
save(
record: PurchaseTopicRecord,
llmFallback?: PurchaseParserLlmFallback
llmFallback?: PurchaseParserLlmFallback,
defaultCurrency?: 'GEL' | 'USD'
): Promise<PurchaseMessageIngestionResult>
}
@@ -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
)