mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:44:02 +00:00
feat(finance): add settlement currency and cycle fx rates
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
157
apps/bot/src/nbg-exchange-rates.ts
Normal file
157
apps/bot/src/nbg-exchange-rates.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<Locale>('en')
|
||||
const [session, setSession] = createSignal<SessionState>({
|
||||
@@ -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() {
|
||||
<article class="ledger-item">
|
||||
<header>
|
||||
<strong>{entry.title}</strong>
|
||||
<span>
|
||||
{entry.amountMajor} {entry.currency}
|
||||
</span>
|
||||
<span>{ledgerPrimaryAmount(entry)}</span>
|
||||
</header>
|
||||
<Show when={ledgerSecondaryAmount(entry)}>
|
||||
{(secondary) => <p>{secondary()}</p>}
|
||||
</Show>
|
||||
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
||||
</article>
|
||||
))}
|
||||
@@ -936,10 +972,11 @@ function App() {
|
||||
<article class="ledger-item">
|
||||
<header>
|
||||
<strong>{entry.title}</strong>
|
||||
<span>
|
||||
{entry.amountMajor} {entry.currency}
|
||||
</span>
|
||||
<span>{ledgerPrimaryAmount(entry)}</span>
|
||||
</header>
|
||||
<Show when={ledgerSecondaryAmount(entry)}>
|
||||
{(secondary) => <p>{secondary()}</p>}
|
||||
</Show>
|
||||
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
||||
</article>
|
||||
))}
|
||||
@@ -973,6 +1010,17 @@ function App() {
|
||||
cycleState()?.cycle?.currency ?? cycleForm().currency
|
||||
)}
|
||||
</p>
|
||||
<Show when={dashboard()}>
|
||||
{(data) => (
|
||||
<p>
|
||||
{copy().shareRent}: {data().rentSourceAmountMajor}{' '}
|
||||
{data().rentSourceCurrency}
|
||||
{data().rentSourceCurrency !== data().currency
|
||||
? ` -> ${data().rentDisplayAmountMajor} ${data().currency}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
<div class="settings-grid">
|
||||
<label class="settings-field">
|
||||
<span>{copy().rentAmount}</span>
|
||||
@@ -1099,7 +1147,7 @@ function App() {
|
||||
/>
|
||||
</label>
|
||||
<label class="settings-field">
|
||||
<span>{copy().shareRent}</span>
|
||||
<span>{copy().settlementCurrency}</span>
|
||||
<select
|
||||
value={cycleForm().currency}
|
||||
onChange={(event) =>
|
||||
@@ -1130,6 +1178,21 @@ function App() {
|
||||
<strong>{copy().billingSettingsTitle}</strong>
|
||||
</header>
|
||||
<div class="settings-grid">
|
||||
<label class="settings-field">
|
||||
<span>{copy().settlementCurrency}</span>
|
||||
<select
|
||||
value={billingForm().settlementCurrency}
|
||||
onChange={(event) =>
|
||||
setBillingForm((current) => ({
|
||||
...current,
|
||||
settlementCurrency: event.currentTarget.value as 'USD' | 'GEL'
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="GEL">GEL</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="settings-field">
|
||||
<span>{copy().rentAmount}</span>
|
||||
<input
|
||||
@@ -1519,6 +1582,18 @@ function App() {
|
||||
</span>
|
||||
</header>
|
||||
<p>{copy().yourBalanceBody}</p>
|
||||
<ShowDashboard
|
||||
dashboard={dashboard()}
|
||||
fallback={null}
|
||||
render={(data) => (
|
||||
<p>
|
||||
{copy().shareRent}: {data.rentSourceAmountMajor} {data.rentSourceCurrency}
|
||||
{data.rentSourceCurrency !== data.currency
|
||||
? ` -> ${data.rentDisplayAmountMajor} ${data.currency}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
<div class="balance-breakdown">
|
||||
<div class="stat-card">
|
||||
<span>{copy().baseDue}</span>
|
||||
@@ -1565,10 +1640,11 @@ function App() {
|
||||
<article class="ledger-item">
|
||||
<header>
|
||||
<strong>{entry.title}</strong>
|
||||
<span>
|
||||
{entry.amountMajor} {entry.currency}
|
||||
</span>
|
||||
<span>{ledgerPrimaryAmount(entry)}</span>
|
||||
</header>
|
||||
<Show when={ledgerSecondaryAmount(entry)}>
|
||||
{(secondary) => <p>{secondary()}</p>}
|
||||
</Show>
|
||||
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -66,6 +66,7 @@ export const dictionary = {
|
||||
householdSettingsTitle: 'Household settings',
|
||||
householdSettingsBody: 'Control household defaults and approve roommates who requested access.',
|
||||
billingSettingsTitle: 'Billing settings',
|
||||
settlementCurrency: 'Settlement currency',
|
||||
billingCycleTitle: 'Current billing cycle',
|
||||
billingCycleEmpty: 'No open cycle',
|
||||
billingCycleStatus: 'Current cycle currency: {currency}',
|
||||
@@ -182,6 +183,7 @@ export const dictionary = {
|
||||
householdSettingsTitle: 'Настройки household',
|
||||
householdSettingsBody: 'Здесь можно менять язык household и подтверждать новых соседей.',
|
||||
billingSettingsTitle: 'Настройки биллинга',
|
||||
settlementCurrency: 'Валюта расчёта',
|
||||
billingCycleTitle: 'Текущий billing cycle',
|
||||
billingCycleEmpty: 'Нет открытого цикла',
|
||||
billingCycleStatus: 'Валюта текущего цикла: {currency}',
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface MiniAppMember {
|
||||
|
||||
export interface MiniAppBillingSettings {
|
||||
householdId: string
|
||||
settlementCurrency: 'USD' | 'GEL'
|
||||
rentAmountMinor: string | null
|
||||
rentCurrency: 'USD' | 'GEL'
|
||||
rentDueDay: number
|
||||
@@ -67,6 +68,11 @@ export interface MiniAppDashboard {
|
||||
period: string
|
||||
currency: 'USD' | 'GEL'
|
||||
totalDueMajor: string
|
||||
rentSourceAmountMajor: string
|
||||
rentSourceCurrency: 'USD' | 'GEL'
|
||||
rentDisplayAmountMajor: string
|
||||
rentFxRateMicros: string | null
|
||||
rentFxEffectiveDate: string | null
|
||||
members: {
|
||||
memberId: string
|
||||
displayName: string
|
||||
@@ -82,6 +88,10 @@ export interface MiniAppDashboard {
|
||||
title: string
|
||||
amountMajor: string
|
||||
currency: 'USD' | 'GEL'
|
||||
displayAmountMajor: string
|
||||
displayCurrency: 'USD' | 'GEL'
|
||||
fxRateMicros: string | null
|
||||
fxEffectiveDate: string | null
|
||||
actorDisplayName: string | null
|
||||
occurredAt: string | null
|
||||
}[]
|
||||
@@ -359,6 +369,7 @@ export async function fetchMiniAppAdminSettings(
|
||||
export async function updateMiniAppBillingSettings(
|
||||
initData: string,
|
||||
input: {
|
||||
settlementCurrency?: 'USD' | 'GEL'
|
||||
rentAmountMajor?: string
|
||||
rentCurrency: 'USD' | 'GEL'
|
||||
rentDueDay: number
|
||||
|
||||
Reference in New Issue
Block a user