mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04: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) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: null,
|
rentAmountMinor: null,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -208,6 +209,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
}),
|
}),
|
||||||
updateHouseholdBillingSettings: async (input) => ({
|
updateHouseholdBillingSettings: async (input) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
rentDueDay: input.rentDueDay ?? 20,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
createMiniAppRentUpdateHandler
|
createMiniAppRentUpdateHandler
|
||||||
} from './miniapp-billing'
|
} from './miniapp-billing'
|
||||||
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
|
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
|
||||||
|
import { createNbgExchangeRateProvider } from './nbg-exchange-rates'
|
||||||
|
|
||||||
const runtime = getBotRuntimeConfig()
|
const runtime = getBotRuntimeConfig()
|
||||||
configureLogger({
|
configureLogger({
|
||||||
@@ -71,6 +72,9 @@ const bot = createTelegramBot(
|
|||||||
const webhookHandler = webhookCallback(bot, 'std/http')
|
const webhookHandler = webhookCallback(bot, 'std/http')
|
||||||
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
|
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
|
||||||
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
|
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
|
||||||
|
const exchangeRateProvider = createNbgExchangeRateProvider({
|
||||||
|
logger: getLogger('fx')
|
||||||
|
})
|
||||||
const householdOnboardingService = householdConfigurationRepositoryClient
|
const householdOnboardingService = householdConfigurationRepositoryClient
|
||||||
? createHouseholdOnboardingService({
|
? createHouseholdOnboardingService({
|
||||||
repository: householdConfigurationRepositoryClient.repository
|
repository: householdConfigurationRepositoryClient.repository
|
||||||
@@ -105,7 +109,12 @@ function financeServiceForHousehold(householdId: string) {
|
|||||||
financeRepositoryClients.set(householdId, repositoryClient)
|
financeRepositoryClients.set(householdId, repositoryClient)
|
||||||
shutdownTasks.push(repositoryClient.close)
|
shutdownTasks.push(repositoryClient.close)
|
||||||
|
|
||||||
const service = createFinanceCommandService(repositoryClient.repository)
|
const service = createFinanceCommandService({
|
||||||
|
householdId,
|
||||||
|
repository: repositoryClient.repository,
|
||||||
|
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
|
||||||
|
exchangeRateProvider
|
||||||
|
})
|
||||||
financeServices.set(householdId, service)
|
financeServices.set(householdId, service)
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
: null,
|
: null,
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: 70000n,
|
rentAmountMinor: 70000n,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -128,6 +129,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
}),
|
}),
|
||||||
updateHouseholdBillingSettings: async (input) => ({
|
updateHouseholdBillingSettings: async (input) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? 70000n,
|
rentAmountMinor: input.rentAmountMinor ?? 70000n,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
rentDueDay: input.rentDueDay ?? 20,
|
||||||
@@ -370,6 +372,7 @@ describe('createMiniAppSettingsHandler', () => {
|
|||||||
authorized: true,
|
authorized: true,
|
||||||
settings: {
|
settings: {
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: '70000',
|
rentAmountMinor: '70000',
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -452,6 +455,7 @@ describe('createMiniAppUpdateSettingsHandler', () => {
|
|||||||
authorized: true,
|
authorized: true,
|
||||||
settings: {
|
settings: {
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: '75000',
|
rentAmountMinor: '75000',
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 22,
|
rentDueDay: 22,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ async function readApprovalPayload(request: Request): Promise<{
|
|||||||
|
|
||||||
async function readSettingsUpdatePayload(request: Request): Promise<{
|
async function readSettingsUpdatePayload(request: Request): Promise<{
|
||||||
initData: string
|
initData: string
|
||||||
|
settlementCurrency?: string
|
||||||
rentAmountMajor?: string
|
rentAmountMajor?: string
|
||||||
rentCurrency?: string
|
rentCurrency?: string
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
@@ -58,6 +59,7 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
|
|||||||
|
|
||||||
const text = await clonedRequest.text()
|
const text = await clonedRequest.text()
|
||||||
let parsed: {
|
let parsed: {
|
||||||
|
settlementCurrency?: string
|
||||||
rentAmountMajor?: string
|
rentAmountMajor?: string
|
||||||
rentCurrency?: string
|
rentCurrency?: string
|
||||||
rentDueDay?: number
|
rentDueDay?: number
|
||||||
@@ -89,6 +91,11 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
|
|||||||
rentAmountMajor: parsed.rentAmountMajor
|
rentAmountMajor: parsed.rentAmountMajor
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof parsed.settlementCurrency === 'string'
|
||||||
|
? {
|
||||||
|
settlementCurrency: parsed.settlementCurrency
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(typeof parsed.rentCurrency === 'string'
|
...(typeof parsed.rentCurrency === 'string'
|
||||||
? {
|
? {
|
||||||
rentCurrency: parsed.rentCurrency
|
rentCurrency: parsed.rentCurrency
|
||||||
@@ -212,6 +219,7 @@ async function readRentWeightPayload(request: Request): Promise<{
|
|||||||
function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) {
|
function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) {
|
||||||
return {
|
return {
|
||||||
householdId: settings.householdId,
|
householdId: settings.householdId,
|
||||||
|
settlementCurrency: settings.settlementCurrency,
|
||||||
rentAmountMinor: settings.rentAmountMinor?.toString() ?? null,
|
rentAmountMinor: settings.rentAmountMinor?.toString() ?? null,
|
||||||
rentCurrency: settings.rentCurrency,
|
rentCurrency: settings.rentCurrency,
|
||||||
rentDueDay: settings.rentDueDay,
|
rentDueDay: settings.rentDueDay,
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
},
|
},
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: null,
|
rentAmountMinor: null,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -165,6 +166,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
}),
|
}),
|
||||||
updateHouseholdBillingSettings: async (input) => ({
|
updateHouseholdBillingSettings: async (input) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
rentDueDay: input.rentDueDay ?? 20,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
listHouseholdMembers: async () => [],
|
listHouseholdMembers: async () => [],
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: 70000n,
|
rentAmountMinor: 70000n,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -87,6 +88,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
}),
|
}),
|
||||||
updateHouseholdBillingSettings: async (input) => ({
|
updateHouseholdBillingSettings: async (input) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? 70000n,
|
rentAmountMinor: input.rentAmountMinor ?? 70000n,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
rentDueDay: input.rentDueDay ?? 20,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@household/application'
|
} from '@household/application'
|
||||||
import { instantFromIso } from '@household/domain'
|
import { instantFromIso } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
|
ExchangeRateProvider,
|
||||||
FinanceRepository,
|
FinanceRepository,
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
HouseholdTopicBindingRecord
|
HouseholdTopicBindingRecord
|
||||||
@@ -31,17 +32,19 @@ function repository(
|
|||||||
getOpenCycle: async () => ({
|
getOpenCycle: async () => ({
|
||||||
id: 'cycle-1',
|
id: 'cycle-1',
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'USD'
|
currency: 'GEL'
|
||||||
}),
|
}),
|
||||||
getCycleByPeriod: async () => null,
|
getCycleByPeriod: async () => null,
|
||||||
getLatestCycle: async () => ({
|
getLatestCycle: async () => ({
|
||||||
id: 'cycle-1',
|
id: 'cycle-1',
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'USD'
|
currency: 'GEL'
|
||||||
}),
|
}),
|
||||||
openCycle: async () => {},
|
openCycle: async () => {},
|
||||||
closeCycle: async () => {},
|
closeCycle: async () => {},
|
||||||
saveRentRule: async () => {},
|
saveRentRule: async () => {},
|
||||||
|
getCycleExchangeRate: async () => null,
|
||||||
|
saveCycleExchangeRate: async (input) => input,
|
||||||
addUtilityBill: async () => {},
|
addUtilityBill: async () => {},
|
||||||
getRentRuleForPeriod: async () => ({
|
getRentRuleForPeriod: async () => ({
|
||||||
amountMinor: 70000n,
|
amountMinor: 70000n,
|
||||||
@@ -53,7 +56,7 @@ function repository(
|
|||||||
id: 'utility-1',
|
id: 'utility-1',
|
||||||
billName: 'Electricity',
|
billName: 'Electricity',
|
||||||
amountMinor: 12000n,
|
amountMinor: 12000n,
|
||||||
currency: 'USD',
|
currency: 'GEL',
|
||||||
createdByMemberId: member?.id ?? 'member-1',
|
createdByMemberId: member?.id ?? 'member-1',
|
||||||
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
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 {
|
function onboardingRepository(): HouseholdConfigurationRepository {
|
||||||
const household = {
|
const household = {
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
@@ -141,6 +166,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
updateMemberPreferredLocale: async () => null,
|
updateMemberPreferredLocale: async () => null,
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: null,
|
rentAmountMinor: null,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -151,6 +177,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
}),
|
}),
|
||||||
updateHouseholdBillingSettings: async (input) => ({
|
updateHouseholdBillingSettings: async (input) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: input.settlementCurrency ?? 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
rentDueDay: input.rentDueDay ?? 20,
|
||||||
@@ -176,16 +203,20 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
describe('createMiniAppDashboardHandler', () => {
|
describe('createMiniAppDashboardHandler', () => {
|
||||||
test('returns a dashboard for an authenticated household member', async () => {
|
test('returns a dashboard for an authenticated household member', async () => {
|
||||||
const authDate = Math.floor(Date.now() / 1000)
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
const financeService = createFinanceCommandService(
|
const householdRepository = onboardingRepository()
|
||||||
repository({
|
const financeService = createFinanceCommandService({
|
||||||
|
householdId: 'household-1',
|
||||||
|
repository: repository({
|
||||||
id: 'member-1',
|
id: 'member-1',
|
||||||
telegramUserId: '123456',
|
telegramUserId: '123456',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
rentShareWeight: 1,
|
rentShareWeight: 1,
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
})
|
}),
|
||||||
)
|
householdConfigurationRepository: householdRepository,
|
||||||
const householdRepository = onboardingRepository()
|
exchangeRateProvider
|
||||||
|
})
|
||||||
|
|
||||||
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
|
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
|
||||||
{
|
{
|
||||||
id: 'member-1',
|
id: 'member-1',
|
||||||
@@ -232,13 +263,16 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
authorized: true,
|
authorized: true,
|
||||||
dashboard: {
|
dashboard: {
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'USD',
|
currency: 'GEL',
|
||||||
totalDueMajor: '820.00',
|
totalDueMajor: '2010.00',
|
||||||
|
rentSourceAmountMajor: '700.00',
|
||||||
|
rentSourceCurrency: 'USD',
|
||||||
|
rentDisplayAmountMajor: '1890.00',
|
||||||
members: [
|
members: [
|
||||||
{
|
{
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
netDueMajor: '820.00',
|
netDueMajor: '2010.00',
|
||||||
rentShareMajor: '700.00',
|
rentShareMajor: '1890.00',
|
||||||
utilityShareMajor: '120.00',
|
utilityShareMajor: '120.00',
|
||||||
purchaseOffsetMajor: '0.00'
|
purchaseOffsetMajor: '0.00'
|
||||||
}
|
}
|
||||||
@@ -246,11 +280,13 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
ledger: [
|
ledger: [
|
||||||
{
|
{
|
||||||
title: 'Soap',
|
title: 'Soap',
|
||||||
currency: 'GEL'
|
currency: 'GEL',
|
||||||
|
displayCurrency: 'GEL'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Electricity',
|
title: 'Electricity',
|
||||||
currency: 'USD'
|
currency: 'GEL',
|
||||||
|
displayCurrency: 'GEL'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -258,16 +294,20 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('returns 400 for malformed JSON bodies', async () => {
|
test('returns 400 for malformed JSON bodies', async () => {
|
||||||
const financeService = createFinanceCommandService(
|
const householdRepository = onboardingRepository()
|
||||||
repository({
|
const financeService = createFinanceCommandService({
|
||||||
|
householdId: 'household-1',
|
||||||
|
repository: repository({
|
||||||
id: 'member-1',
|
id: 'member-1',
|
||||||
telegramUserId: '123456',
|
telegramUserId: '123456',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
rentShareWeight: 1,
|
rentShareWeight: 1,
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
})
|
}),
|
||||||
)
|
householdConfigurationRepository: householdRepository,
|
||||||
const householdRepository = onboardingRepository()
|
exchangeRateProvider
|
||||||
|
})
|
||||||
|
|
||||||
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
|
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
|
||||||
{
|
{
|
||||||
id: 'member-1',
|
id: 'member-1',
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ export function createMiniAppDashboardHandler(options: {
|
|||||||
period: dashboard.period,
|
period: dashboard.period,
|
||||||
currency: dashboard.currency,
|
currency: dashboard.currency,
|
||||||
totalDueMajor: dashboard.totalDue.toMajorString(),
|
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) => ({
|
members: dashboard.members.map((line) => ({
|
||||||
memberId: line.memberId,
|
memberId: line.memberId,
|
||||||
displayName: line.displayName,
|
displayName: line.displayName,
|
||||||
@@ -104,6 +109,10 @@ export function createMiniAppDashboardHandler(options: {
|
|||||||
title: entry.title,
|
title: entry.title,
|
||||||
amountMajor: entry.amount.toMajorString(),
|
amountMajor: entry.amount.toMajorString(),
|
||||||
currency: entry.currency,
|
currency: entry.currency,
|
||||||
|
displayAmountMajor: entry.displayAmount.toMajorString(),
|
||||||
|
displayCurrency: entry.displayCurrency,
|
||||||
|
fxRateMicros: entry.fxRateMicros?.toString() ?? null,
|
||||||
|
fxEffectiveDate: entry.fxEffectiveDate,
|
||||||
actorDisplayName: entry.actorDisplayName,
|
actorDisplayName: entry.actorDisplayName,
|
||||||
occurredAt: entry.occurredAt
|
occurredAt: entry.occurredAt
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
},
|
},
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: null,
|
rentAmountMinor: null,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -140,6 +141,7 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
}),
|
}),
|
||||||
updateHouseholdBillingSettings: async (input) => ({
|
updateHouseholdBillingSettings: async (input) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
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 {
|
export interface PurchaseMessageIngestionRepository {
|
||||||
save(
|
save(
|
||||||
record: PurchaseTopicRecord,
|
record: PurchaseTopicRecord,
|
||||||
llmFallback?: PurchaseParserLlmFallback
|
llmFallback?: PurchaseParserLlmFallback,
|
||||||
|
defaultCurrency?: 'GEL' | 'USD'
|
||||||
): Promise<PurchaseMessageIngestionResult>
|
): Promise<PurchaseMessageIngestionResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const repository: PurchaseMessageIngestionRepository = {
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
async save(record, llmFallback) {
|
async save(record, llmFallback, defaultCurrency) {
|
||||||
const matchedMember = await db
|
const matchedMember = await db
|
||||||
.select({ id: schema.members.id })
|
.select({ id: schema.members.id })
|
||||||
.from(schema.members)
|
.from(schema.members)
|
||||||
@@ -140,11 +141,18 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
{
|
{
|
||||||
rawText: record.rawText
|
rawText: record.rawText
|
||||||
},
|
},
|
||||||
llmFallback
|
{
|
||||||
? {
|
...(llmFallback
|
||||||
llmFallback
|
? {
|
||||||
}
|
llmFallback
|
||||||
: {}
|
}
|
||||||
|
: {}),
|
||||||
|
...(defaultCurrency
|
||||||
|
? {
|
||||||
|
defaultCurrency
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
parserError = error instanceof Error ? error.message : 'Unknown parser error'
|
parserError = error instanceof Error ? error.message : 'Unknown parser error'
|
||||||
return null
|
return null
|
||||||
@@ -324,7 +332,7 @@ export function registerPurchaseTopicIngestion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await repository.save(record, options.llmFallback)
|
const status = await repository.save(record, options.llmFallback, 'GEL')
|
||||||
const acknowledgement = buildPurchaseAcknowledgement(status, 'en')
|
const acknowledgement = buildPurchaseAcknowledgement(status, 'en')
|
||||||
|
|
||||||
if (status.status === 'created') {
|
if (status.status === 'created') {
|
||||||
@@ -394,7 +402,14 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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(
|
const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId(
|
||||||
record.householdId
|
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 { dictionary, type Locale } from './i18n'
|
||||||
import {
|
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() {
|
function App() {
|
||||||
const [locale, setLocale] = createSignal<Locale>('en')
|
const [locale, setLocale] = createSignal<Locale>('en')
|
||||||
const [session, setSession] = createSignal<SessionState>({
|
const [session, setSession] = createSignal<SessionState>({
|
||||||
@@ -184,6 +196,7 @@ function App() {
|
|||||||
const [savingCycleRent, setSavingCycleRent] = createSignal(false)
|
const [savingCycleRent, setSavingCycleRent] = createSignal(false)
|
||||||
const [savingUtilityBill, setSavingUtilityBill] = createSignal(false)
|
const [savingUtilityBill, setSavingUtilityBill] = createSignal(false)
|
||||||
const [billingForm, setBillingForm] = createSignal({
|
const [billingForm, setBillingForm] = createSignal({
|
||||||
|
settlementCurrency: 'GEL' as 'USD' | 'GEL',
|
||||||
rentAmountMajor: '',
|
rentAmountMajor: '',
|
||||||
rentCurrency: 'USD' as 'USD' | 'GEL',
|
rentCurrency: 'USD' as 'USD' | 'GEL',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -195,7 +208,7 @@ function App() {
|
|||||||
const [newCategoryName, setNewCategoryName] = createSignal('')
|
const [newCategoryName, setNewCategoryName] = createSignal('')
|
||||||
const [cycleForm, setCycleForm] = createSignal({
|
const [cycleForm, setCycleForm] = createSignal({
|
||||||
period: defaultCyclePeriod(),
|
period: defaultCyclePeriod(),
|
||||||
currency: 'USD' as 'USD' | 'GEL',
|
currency: 'GEL' as 'USD' | 'GEL',
|
||||||
rentAmountMajor: '',
|
rentAmountMajor: '',
|
||||||
utilityCategorySlug: '',
|
utilityCategorySlug: '',
|
||||||
utilityAmountMajor: ''
|
utilityAmountMajor: ''
|
||||||
@@ -267,12 +280,14 @@ function App() {
|
|||||||
)
|
)
|
||||||
setCycleForm((current) => ({
|
setCycleForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
|
currency: current.currency || payload.settings.settlementCurrency,
|
||||||
utilityCategorySlug:
|
utilityCategorySlug:
|
||||||
current.utilityCategorySlug ||
|
current.utilityCategorySlug ||
|
||||||
payload.categories.find((category) => category.isActive)?.slug ||
|
payload.categories.find((category) => category.isActive)?.slug ||
|
||||||
''
|
''
|
||||||
}))
|
}))
|
||||||
setBillingForm({
|
setBillingForm({
|
||||||
|
settlementCurrency: payload.settings.settlementCurrency,
|
||||||
rentAmountMajor: payload.settings.rentAmountMinor
|
rentAmountMajor: payload.settings.rentAmountMinor
|
||||||
? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2)
|
? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2)
|
||||||
: '',
|
: '',
|
||||||
@@ -299,7 +314,10 @@ function App() {
|
|||||||
setCycleForm((current) => ({
|
setCycleForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
period: payload.cycle?.period ?? current.period,
|
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
|
rentAmountMajor: payload.rentRule
|
||||||
? (Number(payload.rentRule.amountMinor) / 100).toFixed(2)
|
? (Number(payload.rentRule.amountMinor) / 100).toFixed(2)
|
||||||
: '',
|
: '',
|
||||||
@@ -385,25 +403,30 @@ function App() {
|
|||||||
setSession(demoSession)
|
setSession(demoSession)
|
||||||
setDashboard({
|
setDashboard({
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'USD',
|
currency: 'GEL',
|
||||||
totalDueMajor: '414.00',
|
totalDueMajor: '1030.00',
|
||||||
|
rentSourceAmountMajor: '700.00',
|
||||||
|
rentSourceCurrency: 'USD',
|
||||||
|
rentDisplayAmountMajor: '1932.00',
|
||||||
|
rentFxRateMicros: '2760000',
|
||||||
|
rentFxEffectiveDate: '2026-03-17',
|
||||||
members: [
|
members: [
|
||||||
{
|
{
|
||||||
memberId: 'demo-member',
|
memberId: 'demo-member',
|
||||||
displayName: 'Demo Resident',
|
displayName: 'Demo Resident',
|
||||||
rentShareMajor: '175.00',
|
rentShareMajor: '483.00',
|
||||||
utilityShareMajor: '32.00',
|
utilityShareMajor: '32.00',
|
||||||
purchaseOffsetMajor: '-14.00',
|
purchaseOffsetMajor: '-14.00',
|
||||||
netDueMajor: '193.00',
|
netDueMajor: '501.00',
|
||||||
explanations: ['Equal utility split', 'Shared purchase offset']
|
explanations: ['Equal utility split', 'Shared purchase offset']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
memberId: 'member-2',
|
memberId: 'member-2',
|
||||||
displayName: 'Alice',
|
displayName: 'Alice',
|
||||||
rentShareMajor: '175.00',
|
rentShareMajor: '483.00',
|
||||||
utilityShareMajor: '32.00',
|
utilityShareMajor: '32.00',
|
||||||
purchaseOffsetMajor: '14.00',
|
purchaseOffsetMajor: '14.00',
|
||||||
netDueMajor: '221.00',
|
netDueMajor: '529.00',
|
||||||
explanations: ['Equal utility split']
|
explanations: ['Equal utility split']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -414,6 +437,10 @@ function App() {
|
|||||||
title: 'Soap',
|
title: 'Soap',
|
||||||
amountMajor: '30.00',
|
amountMajor: '30.00',
|
||||||
currency: 'GEL',
|
currency: 'GEL',
|
||||||
|
displayAmountMajor: '30.00',
|
||||||
|
displayCurrency: 'GEL',
|
||||||
|
fxRateMicros: null,
|
||||||
|
fxEffectiveDate: null,
|
||||||
actorDisplayName: 'Alice',
|
actorDisplayName: 'Alice',
|
||||||
occurredAt: '2026-03-12T11:00:00.000Z'
|
occurredAt: '2026-03-12T11:00:00.000Z'
|
||||||
},
|
},
|
||||||
@@ -423,6 +450,10 @@ function App() {
|
|||||||
title: 'Electricity',
|
title: 'Electricity',
|
||||||
amountMajor: '120.00',
|
amountMajor: '120.00',
|
||||||
currency: 'GEL',
|
currency: 'GEL',
|
||||||
|
displayAmountMajor: '120.00',
|
||||||
|
displayCurrency: 'GEL',
|
||||||
|
fxRateMicros: null,
|
||||||
|
fxEffectiveDate: null,
|
||||||
actorDisplayName: 'Alice',
|
actorDisplayName: 'Alice',
|
||||||
occurredAt: '2026-03-12T12:00:00.000Z'
|
occurredAt: '2026-03-12T12:00:00.000Z'
|
||||||
}
|
}
|
||||||
@@ -612,6 +643,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
: current
|
: current
|
||||||
)
|
)
|
||||||
|
setCycleForm((current) => ({
|
||||||
|
...current,
|
||||||
|
currency: cycleState()?.cycle?.currency ?? settings.settlementCurrency
|
||||||
|
}))
|
||||||
} finally {
|
} finally {
|
||||||
setSavingBillingSettings(false)
|
setSavingBillingSettings(false)
|
||||||
}
|
}
|
||||||
@@ -914,10 +949,11 @@ function App() {
|
|||||||
<article class="ledger-item">
|
<article class="ledger-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{entry.title}</strong>
|
<strong>{entry.title}</strong>
|
||||||
<span>
|
<span>{ledgerPrimaryAmount(entry)}</span>
|
||||||
{entry.amountMajor} {entry.currency}
|
|
||||||
</span>
|
|
||||||
</header>
|
</header>
|
||||||
|
<Show when={ledgerSecondaryAmount(entry)}>
|
||||||
|
{(secondary) => <p>{secondary()}</p>}
|
||||||
|
</Show>
|
||||||
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -936,10 +972,11 @@ function App() {
|
|||||||
<article class="ledger-item">
|
<article class="ledger-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{entry.title}</strong>
|
<strong>{entry.title}</strong>
|
||||||
<span>
|
<span>{ledgerPrimaryAmount(entry)}</span>
|
||||||
{entry.amountMajor} {entry.currency}
|
|
||||||
</span>
|
|
||||||
</header>
|
</header>
|
||||||
|
<Show when={ledgerSecondaryAmount(entry)}>
|
||||||
|
{(secondary) => <p>{secondary()}</p>}
|
||||||
|
</Show>
|
||||||
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -973,6 +1010,17 @@ function App() {
|
|||||||
cycleState()?.cycle?.currency ?? cycleForm().currency
|
cycleState()?.cycle?.currency ?? cycleForm().currency
|
||||||
)}
|
)}
|
||||||
</p>
|
</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">
|
<div class="settings-grid">
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
<span>{copy().rentAmount}</span>
|
<span>{copy().rentAmount}</span>
|
||||||
@@ -1099,7 +1147,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
<span>{copy().shareRent}</span>
|
<span>{copy().settlementCurrency}</span>
|
||||||
<select
|
<select
|
||||||
value={cycleForm().currency}
|
value={cycleForm().currency}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -1130,6 +1178,21 @@ function App() {
|
|||||||
<strong>{copy().billingSettingsTitle}</strong>
|
<strong>{copy().billingSettingsTitle}</strong>
|
||||||
</header>
|
</header>
|
||||||
<div class="settings-grid">
|
<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">
|
<label class="settings-field">
|
||||||
<span>{copy().rentAmount}</span>
|
<span>{copy().rentAmount}</span>
|
||||||
<input
|
<input
|
||||||
@@ -1519,6 +1582,18 @@ function App() {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<p>{copy().yourBalanceBody}</p>
|
<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="balance-breakdown">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>{copy().baseDue}</span>
|
<span>{copy().baseDue}</span>
|
||||||
@@ -1565,10 +1640,11 @@ function App() {
|
|||||||
<article class="ledger-item">
|
<article class="ledger-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{entry.title}</strong>
|
<strong>{entry.title}</strong>
|
||||||
<span>
|
<span>{ledgerPrimaryAmount(entry)}</span>
|
||||||
{entry.amountMajor} {entry.currency}
|
|
||||||
</span>
|
|
||||||
</header>
|
</header>
|
||||||
|
<Show when={ledgerSecondaryAmount(entry)}>
|
||||||
|
{(secondary) => <p>{secondary()}</p>}
|
||||||
|
</Show>
|
||||||
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const dictionary = {
|
|||||||
householdSettingsTitle: 'Household settings',
|
householdSettingsTitle: 'Household settings',
|
||||||
householdSettingsBody: 'Control household defaults and approve roommates who requested access.',
|
householdSettingsBody: 'Control household defaults and approve roommates who requested access.',
|
||||||
billingSettingsTitle: 'Billing settings',
|
billingSettingsTitle: 'Billing settings',
|
||||||
|
settlementCurrency: 'Settlement currency',
|
||||||
billingCycleTitle: 'Current billing cycle',
|
billingCycleTitle: 'Current billing cycle',
|
||||||
billingCycleEmpty: 'No open cycle',
|
billingCycleEmpty: 'No open cycle',
|
||||||
billingCycleStatus: 'Current cycle currency: {currency}',
|
billingCycleStatus: 'Current cycle currency: {currency}',
|
||||||
@@ -182,6 +183,7 @@ export const dictionary = {
|
|||||||
householdSettingsTitle: 'Настройки household',
|
householdSettingsTitle: 'Настройки household',
|
||||||
householdSettingsBody: 'Здесь можно менять язык household и подтверждать новых соседей.',
|
householdSettingsBody: 'Здесь можно менять язык household и подтверждать новых соседей.',
|
||||||
billingSettingsTitle: 'Настройки биллинга',
|
billingSettingsTitle: 'Настройки биллинга',
|
||||||
|
settlementCurrency: 'Валюта расчёта',
|
||||||
billingCycleTitle: 'Текущий billing cycle',
|
billingCycleTitle: 'Текущий billing cycle',
|
||||||
billingCycleEmpty: 'Нет открытого цикла',
|
billingCycleEmpty: 'Нет открытого цикла',
|
||||||
billingCycleStatus: 'Валюта текущего цикла: {currency}',
|
billingCycleStatus: 'Валюта текущего цикла: {currency}',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface MiniAppMember {
|
|||||||
|
|
||||||
export interface MiniAppBillingSettings {
|
export interface MiniAppBillingSettings {
|
||||||
householdId: string
|
householdId: string
|
||||||
|
settlementCurrency: 'USD' | 'GEL'
|
||||||
rentAmountMinor: string | null
|
rentAmountMinor: string | null
|
||||||
rentCurrency: 'USD' | 'GEL'
|
rentCurrency: 'USD' | 'GEL'
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
@@ -67,6 +68,11 @@ export interface MiniAppDashboard {
|
|||||||
period: string
|
period: string
|
||||||
currency: 'USD' | 'GEL'
|
currency: 'USD' | 'GEL'
|
||||||
totalDueMajor: string
|
totalDueMajor: string
|
||||||
|
rentSourceAmountMajor: string
|
||||||
|
rentSourceCurrency: 'USD' | 'GEL'
|
||||||
|
rentDisplayAmountMajor: string
|
||||||
|
rentFxRateMicros: string | null
|
||||||
|
rentFxEffectiveDate: string | null
|
||||||
members: {
|
members: {
|
||||||
memberId: string
|
memberId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -82,6 +88,10 @@ export interface MiniAppDashboard {
|
|||||||
title: string
|
title: string
|
||||||
amountMajor: string
|
amountMajor: string
|
||||||
currency: 'USD' | 'GEL'
|
currency: 'USD' | 'GEL'
|
||||||
|
displayAmountMajor: string
|
||||||
|
displayCurrency: 'USD' | 'GEL'
|
||||||
|
fxRateMicros: string | null
|
||||||
|
fxEffectiveDate: string | null
|
||||||
actorDisplayName: string | null
|
actorDisplayName: string | null
|
||||||
occurredAt: string | null
|
occurredAt: string | null
|
||||||
}[]
|
}[]
|
||||||
@@ -359,6 +369,7 @@ export async function fetchMiniAppAdminSettings(
|
|||||||
export async function updateMiniAppBillingSettings(
|
export async function updateMiniAppBillingSettings(
|
||||||
initData: string,
|
initData: string,
|
||||||
input: {
|
input: {
|
||||||
|
settlementCurrency?: 'USD' | 'GEL'
|
||||||
rentAmountMajor?: string
|
rentAmountMajor?: string
|
||||||
rentCurrency: 'USD' | 'GEL'
|
rentCurrency: 'USD' | 'GEL'
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
|
|||||||
23
docs/specs/HOUSEBOT-079-settlement-currency-and-nbg-fx.md
Normal file
23
docs/specs/HOUSEBOT-079-settlement-currency-and-nbg-fx.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# HOUSEBOT-079 Settlement Currency And NBG FX
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make household balances settle in a configurable household currency while preserving original entry currencies.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- add a household `settlementCurrency` setting, defaulting to `GEL`
|
||||||
|
- open new billing cycles in the settlement currency by default
|
||||||
|
- keep rent rules in their original source currency
|
||||||
|
- convert rent, utility bills, and purchase entries into the billing cycle currency for settlement
|
||||||
|
- lock cycle exchange rates in the database once the configured reminder day has passed
|
||||||
|
- use NBG as the FX source for GEL conversions
|
||||||
|
- show both original and converted amounts in the mini app when they differ
|
||||||
|
- default utility entry currency and bare purchase parsing currency to the household settlement currency
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- this slice does not add payment confirmation tracking yet
|
||||||
|
- current FX behavior uses the configured reminder day as the target lock date
|
||||||
|
- before the reminder day passes, the app may preview the latest available NBG rate without persisting it
|
||||||
|
- after the reminder day passes, the rate is persisted per cycle and currency pair for deterministic future statements
|
||||||
@@ -201,6 +201,89 @@ export function createDbFinanceRepository(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getCycleExchangeRate(cycleId, sourceCurrency, targetCurrency) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
cycleId: schema.billingCycleExchangeRates.cycleId,
|
||||||
|
sourceCurrency: schema.billingCycleExchangeRates.sourceCurrency,
|
||||||
|
targetCurrency: schema.billingCycleExchangeRates.targetCurrency,
|
||||||
|
rateMicros: schema.billingCycleExchangeRates.rateMicros,
|
||||||
|
effectiveDate: schema.billingCycleExchangeRates.effectiveDate,
|
||||||
|
source: schema.billingCycleExchangeRates.source
|
||||||
|
})
|
||||||
|
.from(schema.billingCycleExchangeRates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.billingCycleExchangeRates.cycleId, cycleId),
|
||||||
|
eq(schema.billingCycleExchangeRates.sourceCurrency, sourceCurrency),
|
||||||
|
eq(schema.billingCycleExchangeRates.targetCurrency, targetCurrency)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cycleId: row.cycleId,
|
||||||
|
sourceCurrency: toCurrencyCode(row.sourceCurrency),
|
||||||
|
targetCurrency: toCurrencyCode(row.targetCurrency),
|
||||||
|
rateMicros: row.rateMicros,
|
||||||
|
effectiveDate: row.effectiveDate,
|
||||||
|
source: 'nbg'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveCycleExchangeRate(input) {
|
||||||
|
const rows = await db
|
||||||
|
.insert(schema.billingCycleExchangeRates)
|
||||||
|
.values({
|
||||||
|
cycleId: input.cycleId,
|
||||||
|
sourceCurrency: input.sourceCurrency,
|
||||||
|
targetCurrency: input.targetCurrency,
|
||||||
|
rateMicros: input.rateMicros,
|
||||||
|
effectiveDate: input.effectiveDate,
|
||||||
|
source: input.source
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
schema.billingCycleExchangeRates.cycleId,
|
||||||
|
schema.billingCycleExchangeRates.sourceCurrency,
|
||||||
|
schema.billingCycleExchangeRates.targetCurrency
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
rateMicros: input.rateMicros,
|
||||||
|
effectiveDate: input.effectiveDate,
|
||||||
|
source: input.source,
|
||||||
|
updatedAt: instantToDate(nowInstant())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
cycleId: schema.billingCycleExchangeRates.cycleId,
|
||||||
|
sourceCurrency: schema.billingCycleExchangeRates.sourceCurrency,
|
||||||
|
targetCurrency: schema.billingCycleExchangeRates.targetCurrency,
|
||||||
|
rateMicros: schema.billingCycleExchangeRates.rateMicros,
|
||||||
|
effectiveDate: schema.billingCycleExchangeRates.effectiveDate,
|
||||||
|
source: schema.billingCycleExchangeRates.source
|
||||||
|
})
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Failed to save billing cycle exchange rate')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cycleId: row.cycleId,
|
||||||
|
sourceCurrency: toCurrencyCode(row.sourceCurrency),
|
||||||
|
targetCurrency: toCurrencyCode(row.targetCurrency),
|
||||||
|
rateMicros: row.rateMicros,
|
||||||
|
effectiveDate: row.effectiveDate,
|
||||||
|
source: 'nbg'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async addUtilityBill(input) {
|
async addUtilityBill(input) {
|
||||||
await db.insert(schema.utilityBills).values({
|
await db.insert(schema.utilityBills).values({
|
||||||
householdId,
|
householdId,
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ function toCurrencyCode(raw: string): CurrencyCode {
|
|||||||
|
|
||||||
function toHouseholdBillingSettingsRecord(row: {
|
function toHouseholdBillingSettingsRecord(row: {
|
||||||
householdId: string
|
householdId: string
|
||||||
|
settlementCurrency: string
|
||||||
rentAmountMinor: bigint | null
|
rentAmountMinor: bigint | null
|
||||||
rentCurrency: string
|
rentCurrency: string
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
@@ -188,6 +189,7 @@ function toHouseholdBillingSettingsRecord(row: {
|
|||||||
}): HouseholdBillingSettingsRecord {
|
}): HouseholdBillingSettingsRecord {
|
||||||
return {
|
return {
|
||||||
householdId: row.householdId,
|
householdId: row.householdId,
|
||||||
|
settlementCurrency: toCurrencyCode(row.settlementCurrency),
|
||||||
rentAmountMinor: row.rentAmountMinor,
|
rentAmountMinor: row.rentAmountMinor,
|
||||||
rentCurrency: toCurrencyCode(row.rentCurrency),
|
rentCurrency: toCurrencyCode(row.rentCurrency),
|
||||||
rentDueDay: row.rentDueDay,
|
rentDueDay: row.rentDueDay,
|
||||||
@@ -862,6 +864,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
householdId: schema.householdBillingSettings.householdId,
|
householdId: schema.householdBillingSettings.householdId,
|
||||||
|
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
|
||||||
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
|
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
|
||||||
rentCurrency: schema.householdBillingSettings.rentCurrency,
|
rentCurrency: schema.householdBillingSettings.rentCurrency,
|
||||||
rentDueDay: schema.householdBillingSettings.rentDueDay,
|
rentDueDay: schema.householdBillingSettings.rentDueDay,
|
||||||
@@ -888,6 +891,11 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.update(schema.householdBillingSettings)
|
.update(schema.householdBillingSettings)
|
||||||
.set({
|
.set({
|
||||||
|
...(input.settlementCurrency
|
||||||
|
? {
|
||||||
|
settlementCurrency: input.settlementCurrency
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(input.rentAmountMinor !== undefined
|
...(input.rentAmountMinor !== undefined
|
||||||
? {
|
? {
|
||||||
rentAmountMinor: input.rentAmountMinor
|
rentAmountMinor: input.rentAmountMinor
|
||||||
@@ -928,6 +936,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
.where(eq(schema.householdBillingSettings.householdId, input.householdId))
|
.where(eq(schema.householdBillingSettings.householdId, input.householdId))
|
||||||
.returning({
|
.returning({
|
||||||
householdId: schema.householdBillingSettings.householdId,
|
householdId: schema.householdBillingSettings.householdId,
|
||||||
|
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
|
||||||
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
|
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
|
||||||
rentCurrency: schema.householdBillingSettings.rentCurrency,
|
rentCurrency: schema.householdBillingSettings.rentCurrency,
|
||||||
rentDueDay: schema.householdBillingSettings.rentDueDay,
|
rentDueDay: schema.householdBillingSettings.rentDueDay,
|
||||||
|
|||||||
@@ -2,24 +2,27 @@ import { describe, expect, test } from 'bun:test'
|
|||||||
|
|
||||||
import { instantFromIso, type Instant } from '@household/domain'
|
import { instantFromIso, type Instant } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
|
ExchangeRateProvider,
|
||||||
|
FinanceCycleExchangeRateRecord,
|
||||||
FinanceCycleRecord,
|
FinanceCycleRecord,
|
||||||
FinanceMemberRecord,
|
FinanceMemberRecord,
|
||||||
FinanceParsedPurchaseRecord,
|
FinanceParsedPurchaseRecord,
|
||||||
FinanceRentRuleRecord,
|
FinanceRentRuleRecord,
|
||||||
FinanceRepository,
|
FinanceRepository,
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
SettlementSnapshotRecord
|
SettlementSnapshotRecord
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
|
|
||||||
import { createFinanceCommandService } from './finance-command-service'
|
import { createFinanceCommandService } from './finance-command-service'
|
||||||
|
|
||||||
class FinanceRepositoryStub implements FinanceRepository {
|
class FinanceRepositoryStub implements FinanceRepository {
|
||||||
|
householdId = 'household-1'
|
||||||
member: FinanceMemberRecord | null = null
|
member: FinanceMemberRecord | null = null
|
||||||
members: readonly FinanceMemberRecord[] = []
|
members: readonly FinanceMemberRecord[] = []
|
||||||
openCycleRecord: FinanceCycleRecord | null = null
|
openCycleRecord: FinanceCycleRecord | null = null
|
||||||
cycleByPeriodRecord: FinanceCycleRecord | null = null
|
cycleByPeriodRecord: FinanceCycleRecord | null = null
|
||||||
latestCycleRecord: FinanceCycleRecord | null = null
|
latestCycleRecord: FinanceCycleRecord | null = null
|
||||||
rentRule: FinanceRentRuleRecord | null = null
|
rentRule: FinanceRentRuleRecord | null = null
|
||||||
utilityTotal: bigint = 0n
|
|
||||||
purchases: readonly FinanceParsedPurchaseRecord[] = []
|
purchases: readonly FinanceParsedPurchaseRecord[] = []
|
||||||
utilityBills: readonly {
|
utilityBills: readonly {
|
||||||
id: string
|
id: string
|
||||||
@@ -29,13 +32,11 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
createdByMemberId: string | null
|
createdByMemberId: string | null
|
||||||
createdAt: Instant
|
createdAt: Instant
|
||||||
}[] = []
|
}[] = []
|
||||||
|
|
||||||
lastSavedRentRule: {
|
lastSavedRentRule: {
|
||||||
period: string
|
period: string
|
||||||
amountMinor: bigint
|
amountMinor: bigint
|
||||||
currency: 'USD' | 'GEL'
|
currency: 'USD' | 'GEL'
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
lastUtilityBill: {
|
lastUtilityBill: {
|
||||||
cycleId: string
|
cycleId: string
|
||||||
billName: string
|
billName: string
|
||||||
@@ -43,8 +44,8 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
currency: 'USD' | 'GEL'
|
currency: 'USD' | 'GEL'
|
||||||
createdByMemberId: string
|
createdByMemberId: string
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
replacedSnapshot: SettlementSnapshotRecord | null = null
|
replacedSnapshot: SettlementSnapshotRecord | null = null
|
||||||
|
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
|
||||||
|
|
||||||
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
||||||
return this.member
|
return this.member
|
||||||
@@ -84,6 +85,24 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCycleExchangeRate(
|
||||||
|
cycleId: string,
|
||||||
|
sourceCurrency: 'USD' | 'GEL',
|
||||||
|
targetCurrency: 'USD' | 'GEL'
|
||||||
|
): Promise<FinanceCycleExchangeRateRecord | null> {
|
||||||
|
return this.cycleExchangeRates.get(`${cycleId}:${sourceCurrency}:${targetCurrency}`) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCycleExchangeRate(
|
||||||
|
input: FinanceCycleExchangeRateRecord
|
||||||
|
): Promise<FinanceCycleExchangeRateRecord> {
|
||||||
|
this.cycleExchangeRates.set(
|
||||||
|
`${input.cycleId}:${input.sourceCurrency}:${input.targetCurrency}`,
|
||||||
|
input
|
||||||
|
)
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
async addUtilityBill(input: {
|
async addUtilityBill(input: {
|
||||||
cycleId: string
|
cycleId: string
|
||||||
billName: string
|
billName: string
|
||||||
@@ -99,7 +118,7 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getUtilityTotalForCycle(): Promise<bigint> {
|
async getUtilityTotalForCycle(): Promise<bigint> {
|
||||||
return this.utilityTotal
|
return this.utilityBills.reduce((sum, bill) => sum + bill.amountMinor, 0n)
|
||||||
}
|
}
|
||||||
|
|
||||||
async listUtilityBillsForCycle() {
|
async listUtilityBillsForCycle() {
|
||||||
@@ -115,16 +134,76 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const householdConfigurationRepository: Pick<
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
'getHouseholdBillingSettings'
|
||||||
|
> = {
|
||||||
|
async getHouseholdBillingSettings(householdId) {
|
||||||
|
return {
|
||||||
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
|
rentAmountMinor: 70000n,
|
||||||
|
rentCurrency: 'USD',
|
||||||
|
rentDueDay: 20,
|
||||||
|
rentWarningDay: 17,
|
||||||
|
utilitiesDueDay: 4,
|
||||||
|
utilitiesReminderDay: 3,
|
||||||
|
timezone: 'Asia/Tbilisi'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.baseCurrency === 'USD' && input.quoteCurrency === 'GEL') {
|
||||||
|
return {
|
||||||
|
baseCurrency: 'USD',
|
||||||
|
quoteCurrency: 'GEL',
|
||||||
|
rateMicros: 2_700_000n,
|
||||||
|
effectiveDate: input.effectiveDate,
|
||||||
|
source: 'nbg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseCurrency: input.baseCurrency,
|
||||||
|
quoteCurrency: input.quoteCurrency,
|
||||||
|
rateMicros: 370_370n,
|
||||||
|
effectiveDate: input.effectiveDate,
|
||||||
|
source: 'nbg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(repository: FinanceRepositoryStub) {
|
||||||
|
return createFinanceCommandService({
|
||||||
|
householdId: repository.householdId,
|
||||||
|
repository,
|
||||||
|
householdConfigurationRepository,
|
||||||
|
exchangeRateProvider
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('createFinanceCommandService', () => {
|
describe('createFinanceCommandService', () => {
|
||||||
test('setRent falls back to the open cycle period when one is active', async () => {
|
test('setRent falls back to the open cycle period when one is active', async () => {
|
||||||
const repository = new FinanceRepositoryStub()
|
const repository = new FinanceRepositoryStub()
|
||||||
repository.openCycleRecord = {
|
repository.openCycleRecord = {
|
||||||
id: 'cycle-1',
|
id: 'cycle-1',
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'USD'
|
currency: 'GEL'
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = createFinanceCommandService(repository)
|
const service = createService(repository)
|
||||||
const result = await service.setRent('700', undefined, undefined)
|
const result = await service.setRent('700', undefined, undefined)
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
@@ -143,12 +222,12 @@ describe('createFinanceCommandService', () => {
|
|||||||
repository.openCycleRecord = {
|
repository.openCycleRecord = {
|
||||||
id: 'cycle-1',
|
id: 'cycle-1',
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'USD'
|
currency: 'GEL'
|
||||||
}
|
}
|
||||||
repository.latestCycleRecord = {
|
repository.latestCycleRecord = {
|
||||||
id: 'cycle-0',
|
id: 'cycle-0',
|
||||||
period: '2026-02',
|
period: '2026-02',
|
||||||
currency: 'USD'
|
currency: 'GEL'
|
||||||
}
|
}
|
||||||
repository.rentRule = {
|
repository.rentRule = {
|
||||||
amountMinor: 70000n,
|
amountMinor: 70000n,
|
||||||
@@ -159,20 +238,20 @@ describe('createFinanceCommandService', () => {
|
|||||||
id: 'utility-1',
|
id: 'utility-1',
|
||||||
billName: 'Electricity',
|
billName: 'Electricity',
|
||||||
amountMinor: 12000n,
|
amountMinor: 12000n,
|
||||||
currency: 'USD',
|
currency: 'GEL',
|
||||||
createdByMemberId: 'alice',
|
createdByMemberId: 'alice',
|
||||||
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const service = createFinanceCommandService(repository)
|
const service = createService(repository)
|
||||||
const result = await service.getAdminCycleState()
|
const result = await service.getAdminCycleState()
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
cycle: {
|
cycle: {
|
||||||
id: 'cycle-1',
|
id: 'cycle-1',
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'USD'
|
currency: 'GEL'
|
||||||
},
|
},
|
||||||
rentRule: {
|
rentRule: {
|
||||||
amountMinor: 70000n,
|
amountMinor: 70000n,
|
||||||
@@ -184,9 +263,9 @@ describe('createFinanceCommandService', () => {
|
|||||||
billName: 'Electricity',
|
billName: 'Electricity',
|
||||||
amount: expect.objectContaining({
|
amount: expect.objectContaining({
|
||||||
amountMinor: 12000n,
|
amountMinor: 12000n,
|
||||||
currency: 'USD'
|
currency: 'GEL'
|
||||||
}),
|
}),
|
||||||
currency: 'USD',
|
currency: 'GEL',
|
||||||
createdByMemberId: 'alice',
|
createdByMemberId: 'alice',
|
||||||
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
||||||
}
|
}
|
||||||
@@ -196,7 +275,7 @@ describe('createFinanceCommandService', () => {
|
|||||||
|
|
||||||
test('addUtilityBill returns null when no open cycle exists', async () => {
|
test('addUtilityBill returns null when no open cycle exists', async () => {
|
||||||
const repository = new FinanceRepositoryStub()
|
const repository = new FinanceRepositoryStub()
|
||||||
const service = createFinanceCommandService(repository)
|
const service = createService(repository)
|
||||||
|
|
||||||
const result = await service.addUtilityBill('Electricity', '55.20', 'member-1')
|
const result = await service.addUtilityBill('Electricity', '55.20', 'member-1')
|
||||||
|
|
||||||
@@ -204,12 +283,12 @@ describe('createFinanceCommandService', () => {
|
|||||||
expect(repository.lastUtilityBill).toBeNull()
|
expect(repository.lastUtilityBill).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('generateStatement persists settlement snapshot and returns member lines', async () => {
|
test('generateStatement settles into cycle currency and persists snapshot', async () => {
|
||||||
const repository = new FinanceRepositoryStub()
|
const repository = new FinanceRepositoryStub()
|
||||||
repository.latestCycleRecord = {
|
repository.latestCycleRecord = {
|
||||||
id: 'cycle-2026-03',
|
id: 'cycle-2026-03',
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'USD'
|
currency: 'GEL'
|
||||||
}
|
}
|
||||||
repository.members = [
|
repository.members = [
|
||||||
{
|
{
|
||||||
@@ -231,13 +310,12 @@ describe('createFinanceCommandService', () => {
|
|||||||
amountMinor: 70000n,
|
amountMinor: 70000n,
|
||||||
currency: 'USD'
|
currency: 'USD'
|
||||||
}
|
}
|
||||||
repository.utilityTotal = 12000n
|
|
||||||
repository.utilityBills = [
|
repository.utilityBills = [
|
||||||
{
|
{
|
||||||
id: 'utility-1',
|
id: 'utility-1',
|
||||||
billName: 'Electricity',
|
billName: 'Electricity',
|
||||||
amountMinor: 12000n,
|
amountMinor: 12000n,
|
||||||
currency: 'USD',
|
currency: 'GEL',
|
||||||
createdByMemberId: 'alice',
|
createdByMemberId: 'alice',
|
||||||
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
||||||
}
|
}
|
||||||
@@ -253,29 +331,34 @@ describe('createFinanceCommandService', () => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const service = createFinanceCommandService(repository)
|
const service = createService(repository)
|
||||||
const dashboard = await service.generateDashboard()
|
const dashboard = await service.generateDashboard()
|
||||||
const statement = await service.generateStatement()
|
const statement = await service.generateStatement()
|
||||||
|
|
||||||
expect(dashboard).not.toBeNull()
|
expect(dashboard).not.toBeNull()
|
||||||
expect(dashboard?.members.map((line) => line.netDue.amountMinor)).toEqual([39500n, 42500n])
|
expect(dashboard?.currency).toBe('GEL')
|
||||||
|
expect(dashboard?.rentSourceAmount.toMajorString()).toBe('700.00')
|
||||||
|
expect(dashboard?.rentDisplayAmount.toMajorString()).toBe('1890.00')
|
||||||
|
expect(dashboard?.members.map((line) => line.netDue.amountMinor)).toEqual([99000n, 102000n])
|
||||||
expect(dashboard?.ledger.map((entry) => entry.title)).toEqual(['Soap', 'Electricity'])
|
expect(dashboard?.ledger.map((entry) => entry.title)).toEqual(['Soap', 'Electricity'])
|
||||||
expect(dashboard?.ledger.map((entry) => entry.currency)).toEqual(['GEL', 'USD'])
|
expect(dashboard?.ledger.map((entry) => entry.currency)).toEqual(['GEL', 'GEL'])
|
||||||
|
expect(dashboard?.ledger.map((entry) => entry.displayCurrency)).toEqual(['GEL', 'GEL'])
|
||||||
expect(statement).toBe(
|
expect(statement).toBe(
|
||||||
[
|
[
|
||||||
'Statement for 2026-03',
|
'Statement for 2026-03',
|
||||||
'- Alice: 395.00 USD',
|
'Rent: 700.00 USD (~1890.00 GEL)',
|
||||||
'- Bob: 425.00 USD',
|
'- Alice: 990.00 GEL',
|
||||||
'Total: 820.00 USD'
|
'- Bob: 1020.00 GEL',
|
||||||
|
'Total: 2010.00 GEL'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
)
|
)
|
||||||
expect(repository.replacedSnapshot).not.toBeNull()
|
expect(repository.replacedSnapshot).not.toBeNull()
|
||||||
expect(repository.replacedSnapshot?.cycleId).toBe('cycle-2026-03')
|
expect(repository.replacedSnapshot?.cycleId).toBe('cycle-2026-03')
|
||||||
expect(repository.replacedSnapshot?.currency).toBe('USD')
|
expect(repository.replacedSnapshot?.currency).toBe('GEL')
|
||||||
expect(repository.replacedSnapshot?.totalDueMinor).toBe(82000n)
|
expect(repository.replacedSnapshot?.totalDueMinor).toBe(201000n)
|
||||||
expect(repository.replacedSnapshot?.lines.map((line) => line.netDueMinor)).toEqual([
|
expect(repository.replacedSnapshot?.lines.map((line) => line.netDueMinor)).toEqual([
|
||||||
39500n,
|
99000n,
|
||||||
42500n
|
102000n
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ExchangeRateProvider,
|
||||||
FinanceCycleRecord,
|
FinanceCycleRecord,
|
||||||
FinanceMemberRecord,
|
FinanceMemberRecord,
|
||||||
FinanceRentRuleRecord,
|
FinanceRentRuleRecord,
|
||||||
FinanceRepository
|
FinanceRepository,
|
||||||
|
HouseholdConfigurationRepository
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
import {
|
import {
|
||||||
BillingCycleId,
|
BillingCycleId,
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
Money,
|
Money,
|
||||||
PurchaseEntryId,
|
PurchaseEntryId,
|
||||||
Temporal,
|
Temporal,
|
||||||
|
convertMoney,
|
||||||
nowInstant,
|
nowInstant,
|
||||||
type CurrencyCode
|
type CurrencyCode
|
||||||
} from '@household/domain'
|
} from '@household/domain'
|
||||||
@@ -57,6 +60,25 @@ async function getCycleByPeriodOrLatest(
|
|||||||
return repository.getLatestCycle()
|
return repository.getLatestCycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function billingPeriodLockDate(period: BillingPeriod, day: number): Temporal.PlainDate {
|
||||||
|
const firstDay = Temporal.PlainDate.from({
|
||||||
|
year: period.year,
|
||||||
|
month: period.month,
|
||||||
|
day: 1
|
||||||
|
})
|
||||||
|
const clampedDay = Math.min(day, firstDay.daysInMonth)
|
||||||
|
|
||||||
|
return Temporal.PlainDate.from({
|
||||||
|
year: period.year,
|
||||||
|
month: period.month,
|
||||||
|
day: clampedDay
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDateInTimezone(timezone: string): Temporal.PlainDate {
|
||||||
|
return nowInstant().toZonedDateTimeISO(timezone).toPlainDate()
|
||||||
|
}
|
||||||
|
|
||||||
export interface FinanceDashboardMemberLine {
|
export interface FinanceDashboardMemberLine {
|
||||||
memberId: string
|
memberId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -73,6 +95,10 @@ export interface FinanceDashboardLedgerEntry {
|
|||||||
title: string
|
title: string
|
||||||
amount: Money
|
amount: Money
|
||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
|
displayAmount: Money
|
||||||
|
displayCurrency: CurrencyCode
|
||||||
|
fxRateMicros: bigint | null
|
||||||
|
fxEffectiveDate: string | null
|
||||||
actorDisplayName: string | null
|
actorDisplayName: string | null
|
||||||
occurredAt: string | null
|
occurredAt: string | null
|
||||||
}
|
}
|
||||||
@@ -81,6 +107,10 @@ export interface FinanceDashboard {
|
|||||||
period: string
|
period: string
|
||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
totalDue: Money
|
totalDue: Money
|
||||||
|
rentSourceAmount: Money
|
||||||
|
rentDisplayAmount: Money
|
||||||
|
rentFxRateMicros: bigint | null
|
||||||
|
rentFxEffectiveDate: string | null
|
||||||
members: readonly FinanceDashboardMemberLine[]
|
members: readonly FinanceDashboardMemberLine[]
|
||||||
ledger: readonly FinanceDashboardLedgerEntry[]
|
ledger: readonly FinanceDashboardLedgerEntry[]
|
||||||
}
|
}
|
||||||
@@ -98,63 +128,204 @@ export interface FinanceAdminCycleState {
|
|||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FinanceCommandServiceDependencies {
|
||||||
|
householdId: string
|
||||||
|
repository: FinanceRepository
|
||||||
|
householdConfigurationRepository: Pick<
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
'getHouseholdBillingSettings'
|
||||||
|
>
|
||||||
|
exchangeRateProvider: ExchangeRateProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConvertedCycleMoney {
|
||||||
|
originalAmount: Money
|
||||||
|
settlementAmount: Money
|
||||||
|
fxRateMicros: bigint | null
|
||||||
|
fxEffectiveDate: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertIntoCycleCurrency(
|
||||||
|
dependencies: FinanceCommandServiceDependencies,
|
||||||
|
input: {
|
||||||
|
cycle: FinanceCycleRecord
|
||||||
|
period: BillingPeriod
|
||||||
|
lockDay: number
|
||||||
|
timezone: string
|
||||||
|
amount: Money
|
||||||
|
}
|
||||||
|
): Promise<ConvertedCycleMoney> {
|
||||||
|
if (input.amount.currency === input.cycle.currency) {
|
||||||
|
return {
|
||||||
|
originalAmount: input.amount,
|
||||||
|
settlementAmount: input.amount,
|
||||||
|
fxRateMicros: null,
|
||||||
|
fxEffectiveDate: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRate = await dependencies.repository.getCycleExchangeRate(
|
||||||
|
input.cycle.id,
|
||||||
|
input.amount.currency,
|
||||||
|
input.cycle.currency
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingRate) {
|
||||||
|
return {
|
||||||
|
originalAmount: input.amount,
|
||||||
|
settlementAmount: convertMoney(input.amount, input.cycle.currency, existingRate.rateMicros),
|
||||||
|
fxRateMicros: existingRate.rateMicros,
|
||||||
|
fxEffectiveDate: existingRate.effectiveDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockDate = billingPeriodLockDate(input.period, input.lockDay)
|
||||||
|
const currentLocalDate = localDateInTimezone(input.timezone)
|
||||||
|
const shouldPersist = Temporal.PlainDate.compare(currentLocalDate, lockDate) >= 0
|
||||||
|
const quote = await dependencies.exchangeRateProvider.getRate({
|
||||||
|
baseCurrency: input.amount.currency,
|
||||||
|
quoteCurrency: input.cycle.currency,
|
||||||
|
effectiveDate: lockDate.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (shouldPersist) {
|
||||||
|
await dependencies.repository.saveCycleExchangeRate({
|
||||||
|
cycleId: input.cycle.id,
|
||||||
|
sourceCurrency: quote.baseCurrency,
|
||||||
|
targetCurrency: quote.quoteCurrency,
|
||||||
|
rateMicros: quote.rateMicros,
|
||||||
|
effectiveDate: quote.effectiveDate,
|
||||||
|
source: quote.source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalAmount: input.amount,
|
||||||
|
settlementAmount: convertMoney(input.amount, input.cycle.currency, quote.rateMicros),
|
||||||
|
fxRateMicros: quote.rateMicros,
|
||||||
|
fxEffectiveDate: quote.effectiveDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function buildFinanceDashboard(
|
async function buildFinanceDashboard(
|
||||||
repository: FinanceRepository,
|
dependencies: FinanceCommandServiceDependencies,
|
||||||
periodArg?: string
|
periodArg?: string
|
||||||
): Promise<FinanceDashboard | null> {
|
): Promise<FinanceDashboard | null> {
|
||||||
const cycle = await getCycleByPeriodOrLatest(repository, periodArg)
|
const cycle = await getCycleByPeriodOrLatest(dependencies.repository, periodArg)
|
||||||
if (!cycle) {
|
if (!cycle) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await repository.listMembers()
|
const [members, rentRule, settings] = await Promise.all([
|
||||||
|
dependencies.repository.listMembers(),
|
||||||
|
dependencies.repository.getRentRuleForPeriod(cycle.period),
|
||||||
|
dependencies.householdConfigurationRepository.getHouseholdBillingSettings(
|
||||||
|
dependencies.householdId
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
throw new Error('No household members configured')
|
throw new Error('No household members configured')
|
||||||
}
|
}
|
||||||
|
|
||||||
const rentRule = await repository.getRentRuleForPeriod(cycle.period)
|
|
||||||
if (!rentRule) {
|
if (!rentRule) {
|
||||||
throw new Error('No rent rule configured for this cycle period')
|
throw new Error('No rent rule configured for this cycle period')
|
||||||
}
|
}
|
||||||
|
|
||||||
const period = BillingPeriod.fromString(cycle.period)
|
const period = BillingPeriod.fromString(cycle.period)
|
||||||
const { start, end } = monthRange(period)
|
const { start, end } = monthRange(period)
|
||||||
const purchases = await repository.listParsedPurchasesForRange(start, end)
|
const [purchases, utilityBills] = await Promise.all([
|
||||||
const utilityBills = await repository.listUtilityBillsForCycle(cycle.id)
|
dependencies.repository.listParsedPurchasesForRange(start, end),
|
||||||
const utilitiesMinor = await repository.getUtilityTotalForCycle(cycle.id)
|
dependencies.repository.listUtilityBillsForCycle(cycle.id)
|
||||||
|
])
|
||||||
|
|
||||||
|
const convertedRent = await convertIntoCycleCurrency(dependencies, {
|
||||||
|
cycle,
|
||||||
|
period,
|
||||||
|
lockDay: settings.rentWarningDay,
|
||||||
|
timezone: settings.timezone,
|
||||||
|
amount: Money.fromMinor(rentRule.amountMinor, rentRule.currency)
|
||||||
|
})
|
||||||
|
|
||||||
|
const convertedUtilityBills = await Promise.all(
|
||||||
|
utilityBills.map(async (bill) => {
|
||||||
|
const converted = await convertIntoCycleCurrency(dependencies, {
|
||||||
|
cycle,
|
||||||
|
period,
|
||||||
|
lockDay: settings.utilitiesReminderDay,
|
||||||
|
timezone: settings.timezone,
|
||||||
|
amount: Money.fromMinor(bill.amountMinor, bill.currency)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
bill,
|
||||||
|
converted
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const convertedPurchases = await Promise.all(
|
||||||
|
purchases.map(async (purchase) => {
|
||||||
|
const converted = await convertIntoCycleCurrency(dependencies, {
|
||||||
|
cycle,
|
||||||
|
period,
|
||||||
|
lockDay: settings.rentWarningDay,
|
||||||
|
timezone: settings.timezone,
|
||||||
|
amount: Money.fromMinor(purchase.amountMinor, purchase.currency)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
purchase,
|
||||||
|
converted
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const utilities = convertedUtilityBills.reduce(
|
||||||
|
(sum, current) => sum.add(current.converted.settlementAmount),
|
||||||
|
Money.zero(cycle.currency)
|
||||||
|
)
|
||||||
|
|
||||||
const settlement = calculateMonthlySettlement({
|
const settlement = calculateMonthlySettlement({
|
||||||
cycleId: BillingCycleId.from(cycle.id),
|
cycleId: BillingCycleId.from(cycle.id),
|
||||||
period,
|
period,
|
||||||
rent: Money.fromMinor(rentRule.amountMinor, rentRule.currency),
|
rent: convertedRent.settlementAmount,
|
||||||
utilities: Money.fromMinor(utilitiesMinor, rentRule.currency),
|
utilities,
|
||||||
utilitySplitMode: 'equal',
|
utilitySplitMode: 'equal',
|
||||||
members: members.map((member) => ({
|
members: members.map((member) => ({
|
||||||
memberId: MemberId.from(member.id),
|
memberId: MemberId.from(member.id),
|
||||||
active: true,
|
active: true,
|
||||||
rentWeight: member.rentShareWeight
|
rentWeight: member.rentShareWeight
|
||||||
})),
|
})),
|
||||||
purchases: purchases.map((purchase) => ({
|
purchases: convertedPurchases.map(({ purchase, converted }) => ({
|
||||||
purchaseId: PurchaseEntryId.from(purchase.id),
|
purchaseId: PurchaseEntryId.from(purchase.id),
|
||||||
payerId: MemberId.from(purchase.payerMemberId),
|
payerId: MemberId.from(purchase.payerMemberId),
|
||||||
amount: Money.fromMinor(purchase.amountMinor, rentRule.currency)
|
amount: converted.settlementAmount
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
await repository.replaceSettlementSnapshot({
|
await dependencies.repository.replaceSettlementSnapshot({
|
||||||
cycleId: cycle.id,
|
cycleId: cycle.id,
|
||||||
inputHash: computeInputHash({
|
inputHash: computeInputHash({
|
||||||
cycleId: cycle.id,
|
cycleId: cycle.id,
|
||||||
rentMinor: rentRule.amountMinor.toString(),
|
rentMinor: convertedRent.settlementAmount.amountMinor.toString(),
|
||||||
utilitiesMinor: utilitiesMinor.toString(),
|
utilitiesMinor: utilities.amountMinor.toString(),
|
||||||
purchaseCount: purchases.length,
|
purchaseMinors: convertedPurchases.map(({ purchase, converted }) => ({
|
||||||
|
id: purchase.id,
|
||||||
|
minor: converted.settlementAmount.amountMinor.toString(),
|
||||||
|
currency: converted.settlementAmount.currency
|
||||||
|
})),
|
||||||
memberCount: members.length
|
memberCount: members.length
|
||||||
}),
|
}),
|
||||||
totalDueMinor: settlement.totalDue.amountMinor,
|
totalDueMinor: settlement.totalDue.amountMinor,
|
||||||
currency: rentRule.currency,
|
currency: cycle.currency,
|
||||||
metadata: {
|
metadata: {
|
||||||
generatedBy: 'bot-command',
|
generatedBy: 'bot-command',
|
||||||
source: 'finance-service'
|
source: 'finance-service',
|
||||||
|
rentSourceMinor: convertedRent.originalAmount.amountMinor.toString(),
|
||||||
|
rentSourceCurrency: convertedRent.originalAmount.currency,
|
||||||
|
rentFxRateMicros: convertedRent.fxRateMicros?.toString() ?? null,
|
||||||
|
rentFxEffectiveDate: convertedRent.fxEffectiveDate
|
||||||
},
|
},
|
||||||
lines: settlement.lines.map((line) => ({
|
lines: settlement.lines.map((line) => ({
|
||||||
memberId: line.memberId.toString(),
|
memberId: line.memberId.toString(),
|
||||||
@@ -178,23 +349,31 @@ async function buildFinanceDashboard(
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const ledger: FinanceDashboardLedgerEntry[] = [
|
const ledger: FinanceDashboardLedgerEntry[] = [
|
||||||
...utilityBills.map((bill) => ({
|
...convertedUtilityBills.map(({ bill, converted }) => ({
|
||||||
id: bill.id,
|
id: bill.id,
|
||||||
kind: 'utility' as const,
|
kind: 'utility' as const,
|
||||||
title: bill.billName,
|
title: bill.billName,
|
||||||
amount: Money.fromMinor(bill.amountMinor, bill.currency),
|
amount: converted.originalAmount,
|
||||||
currency: bill.currency,
|
currency: bill.currency,
|
||||||
|
displayAmount: converted.settlementAmount,
|
||||||
|
displayCurrency: cycle.currency,
|
||||||
|
fxRateMicros: converted.fxRateMicros,
|
||||||
|
fxEffectiveDate: converted.fxEffectiveDate,
|
||||||
actorDisplayName: bill.createdByMemberId
|
actorDisplayName: bill.createdByMemberId
|
||||||
? (memberNameById.get(bill.createdByMemberId) ?? null)
|
? (memberNameById.get(bill.createdByMemberId) ?? null)
|
||||||
: null,
|
: null,
|
||||||
occurredAt: bill.createdAt.toString()
|
occurredAt: bill.createdAt.toString()
|
||||||
})),
|
})),
|
||||||
...purchases.map((purchase) => ({
|
...convertedPurchases.map(({ purchase, converted }) => ({
|
||||||
id: purchase.id,
|
id: purchase.id,
|
||||||
kind: 'purchase' as const,
|
kind: 'purchase' as const,
|
||||||
title: purchase.description ?? 'Shared purchase',
|
title: purchase.description ?? 'Shared purchase',
|
||||||
amount: Money.fromMinor(purchase.amountMinor, purchase.currency),
|
amount: converted.originalAmount,
|
||||||
currency: purchase.currency,
|
currency: purchase.currency,
|
||||||
|
displayAmount: converted.settlementAmount,
|
||||||
|
displayCurrency: cycle.currency,
|
||||||
|
fxRateMicros: converted.fxRateMicros,
|
||||||
|
fxEffectiveDate: converted.fxEffectiveDate,
|
||||||
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
|
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
|
||||||
occurredAt: purchase.occurredAt?.toString() ?? null
|
occurredAt: purchase.occurredAt?.toString() ?? null
|
||||||
}))
|
}))
|
||||||
@@ -208,8 +387,12 @@ async function buildFinanceDashboard(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
period: cycle.period,
|
period: cycle.period,
|
||||||
currency: rentRule.currency,
|
currency: cycle.currency,
|
||||||
totalDue: settlement.totalDue,
|
totalDue: settlement.totalDue,
|
||||||
|
rentSourceAmount: convertedRent.originalAmount,
|
||||||
|
rentDisplayAmount: convertedRent.settlementAmount,
|
||||||
|
rentFxRateMicros: convertedRent.fxRateMicros,
|
||||||
|
rentFxEffectiveDate: convertedRent.fxEffectiveDate,
|
||||||
members: dashboardMembers,
|
members: dashboardMembers,
|
||||||
ledger
|
ledger
|
||||||
}
|
}
|
||||||
@@ -244,7 +427,11 @@ export interface FinanceCommandService {
|
|||||||
generateStatement(periodArg?: string): Promise<string | null>
|
generateStatement(periodArg?: string): Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFinanceCommandService(repository: FinanceRepository): FinanceCommandService {
|
export function createFinanceCommandService(
|
||||||
|
dependencies: FinanceCommandServiceDependencies
|
||||||
|
): FinanceCommandService {
|
||||||
|
const { repository, householdConfigurationRepository } = dependencies
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getMemberByTelegramUserId(telegramUserId) {
|
getMemberByTelegramUserId(telegramUserId) {
|
||||||
return repository.getMemberByTelegramUserId(telegramUserId)
|
return repository.getMemberByTelegramUserId(telegramUserId)
|
||||||
@@ -288,7 +475,10 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
|||||||
|
|
||||||
async openCycle(periodArg, currencyArg) {
|
async openCycle(periodArg, currencyArg) {
|
||||||
const period = BillingPeriod.fromString(periodArg).toString()
|
const period = BillingPeriod.fromString(periodArg).toString()
|
||||||
const currency = parseCurrency(currencyArg, 'USD')
|
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
||||||
|
dependencies.householdId
|
||||||
|
)
|
||||||
|
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
|
||||||
|
|
||||||
await repository.openCycle(period, currency)
|
await repository.openCycle(period, currency)
|
||||||
|
|
||||||
@@ -311,13 +501,16 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
|||||||
},
|
},
|
||||||
|
|
||||||
async setRent(amountArg, currencyArg, periodArg) {
|
async setRent(amountArg, currencyArg, periodArg) {
|
||||||
const openCycle = await repository.getOpenCycle()
|
const [openCycle, settings] = await Promise.all([
|
||||||
|
repository.getOpenCycle(),
|
||||||
|
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId)
|
||||||
|
])
|
||||||
const period = periodArg ?? openCycle?.period
|
const period = periodArg ?? openCycle?.period
|
||||||
if (!period) {
|
if (!period) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const currency = parseCurrency(currencyArg, openCycle?.currency ?? 'USD')
|
const currency = parseCurrency(currencyArg, settings.rentCurrency)
|
||||||
const amount = Money.fromMajor(amountArg, currency)
|
const amount = Money.fromMajor(amountArg, currency)
|
||||||
|
|
||||||
await repository.saveRentRule(
|
await repository.saveRentRule(
|
||||||
@@ -334,12 +527,15 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
|||||||
},
|
},
|
||||||
|
|
||||||
async addUtilityBill(billName, amountArg, createdByMemberId, currencyArg) {
|
async addUtilityBill(billName, amountArg, createdByMemberId, currencyArg) {
|
||||||
const openCycle = await repository.getOpenCycle()
|
const [openCycle, settings] = await Promise.all([
|
||||||
|
repository.getOpenCycle(),
|
||||||
|
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId)
|
||||||
|
])
|
||||||
if (!openCycle) {
|
if (!openCycle) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const currency = parseCurrency(currencyArg, openCycle.currency)
|
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
|
||||||
const amount = Money.fromMajor(amountArg, currency)
|
const amount = Money.fromMajor(amountArg, currency)
|
||||||
|
|
||||||
await repository.addUtilityBill({
|
await repository.addUtilityBill({
|
||||||
@@ -358,7 +554,7 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
|||||||
},
|
},
|
||||||
|
|
||||||
async generateStatement(periodArg) {
|
async generateStatement(periodArg) {
|
||||||
const dashboard = await buildFinanceDashboard(repository, periodArg)
|
const dashboard = await buildFinanceDashboard(dependencies, periodArg)
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -367,15 +563,21 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
|||||||
return `- ${line.displayName}: ${line.netDue.toMajorString()} ${dashboard.currency}`
|
return `- ${line.displayName}: ${line.netDue.toMajorString()} ${dashboard.currency}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const rentLine =
|
||||||
|
dashboard.rentSourceAmount.currency === dashboard.rentDisplayAmount.currency
|
||||||
|
? `Rent: ${dashboard.rentDisplayAmount.toMajorString()} ${dashboard.currency}`
|
||||||
|
: `Rent: ${dashboard.rentSourceAmount.toMajorString()} ${dashboard.rentSourceAmount.currency} (~${dashboard.rentDisplayAmount.toMajorString()} ${dashboard.currency})`
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`Statement for ${dashboard.period}`,
|
`Statement for ${dashboard.period}`,
|
||||||
|
rentLine,
|
||||||
...statementLines,
|
...statementLines,
|
||||||
`Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`
|
`Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`
|
||||||
].join('\n')
|
].join('\n')
|
||||||
},
|
},
|
||||||
|
|
||||||
generateDashboard(periodArg) {
|
generateDashboard(periodArg) {
|
||||||
return buildFinanceDashboard(repository, periodArg)
|
return buildFinanceDashboard(dependencies, periodArg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ function createRepositoryStub() {
|
|||||||
},
|
},
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: null,
|
rentAmountMinor: null,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -153,6 +154,7 @@ function createRepositoryStub() {
|
|||||||
}),
|
}),
|
||||||
updateHouseholdBillingSettings: async (input) => ({
|
updateHouseholdBillingSettings: async (input) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
rentDueDay: input.rentDueDay ?? 20,
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ function createRepositoryStub() {
|
|||||||
async getHouseholdBillingSettings(householdId) {
|
async getHouseholdBillingSettings(householdId) {
|
||||||
return {
|
return {
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: null,
|
rentAmountMinor: null,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -168,6 +169,7 @@ function createRepositoryStub() {
|
|||||||
async updateHouseholdBillingSettings(input) {
|
async updateHouseholdBillingSettings(input) {
|
||||||
return {
|
return {
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
rentDueDay: input.rentDueDay ?? 20,
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ function createRepositoryStub() {
|
|||||||
async getHouseholdBillingSettings(householdId) {
|
async getHouseholdBillingSettings(householdId) {
|
||||||
return {
|
return {
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: null,
|
rentAmountMinor: null,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -262,6 +263,7 @@ function createRepositoryStub() {
|
|||||||
async updateHouseholdBillingSettings(input) {
|
async updateHouseholdBillingSettings(input) {
|
||||||
return {
|
return {
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
rentDueDay: input.rentDueDay ?? 20,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
|||||||
: null,
|
: null,
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: null,
|
rentAmountMinor: null,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -88,6 +89,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
|||||||
}),
|
}),
|
||||||
updateHouseholdBillingSettings: async (input) => ({
|
updateHouseholdBillingSettings: async (input) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
rentDueDay: input.rentDueDay ?? 20,
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
: null,
|
: null,
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: null,
|
rentAmountMinor: null,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -118,6 +119,7 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
}),
|
}),
|
||||||
updateHouseholdBillingSettings: async (input) => ({
|
updateHouseholdBillingSettings: async (input) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||||
rentCurrency: input.rentCurrency ?? 'USD',
|
rentCurrency: input.rentCurrency ?? 'USD',
|
||||||
rentDueDay: input.rentDueDay ?? 20,
|
rentDueDay: input.rentDueDay ?? 20,
|
||||||
@@ -177,6 +179,7 @@ describe('createMiniAppAdminService', () => {
|
|||||||
status: 'ok',
|
status: 'ok',
|
||||||
settings: {
|
settings: {
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: null,
|
rentAmountMinor: null,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -209,6 +212,7 @@ describe('createMiniAppAdminService', () => {
|
|||||||
status: 'ok',
|
status: 'ok',
|
||||||
settings: {
|
settings: {
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
rentAmountMinor: 70000n,
|
rentAmountMinor: 70000n,
|
||||||
rentCurrency: 'USD',
|
rentCurrency: 'USD',
|
||||||
rentDueDay: 21,
|
rentDueDay: 21,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface MiniAppAdminService {
|
|||||||
updateSettings(input: {
|
updateSettings(input: {
|
||||||
householdId: string
|
householdId: string
|
||||||
actorIsAdmin: boolean
|
actorIsAdmin: boolean
|
||||||
|
settlementCurrency?: string
|
||||||
rentAmountMajor?: string
|
rentAmountMajor?: string
|
||||||
rentCurrency?: string
|
rentCurrency?: string
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
@@ -176,6 +177,9 @@ export function createMiniAppAdminService(
|
|||||||
|
|
||||||
let rentAmountMinor: bigint | null | undefined
|
let rentAmountMinor: bigint | null | undefined
|
||||||
let rentCurrency: CurrencyCode | undefined
|
let rentCurrency: CurrencyCode | undefined
|
||||||
|
const settlementCurrency = input.settlementCurrency
|
||||||
|
? parseCurrency(input.settlementCurrency)
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (input.rentAmountMajor && input.rentAmountMajor.trim().length > 0) {
|
if (input.rentAmountMajor && input.rentAmountMajor.trim().length > 0) {
|
||||||
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
|
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
|
||||||
@@ -187,6 +191,11 @@ export function createMiniAppAdminService(
|
|||||||
|
|
||||||
const settings = await repository.updateHouseholdBillingSettings({
|
const settings = await repository.updateHouseholdBillingSettings({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
|
...(settlementCurrency
|
||||||
|
? {
|
||||||
|
settlementCurrency
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(rentAmountMinor !== undefined
|
...(rentAmountMinor !== undefined
|
||||||
? {
|
? {
|
||||||
rentAmountMinor
|
rentAmountMinor
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface ParsePurchaseInput {
|
|||||||
|
|
||||||
export interface ParsePurchaseOptions {
|
export interface ParsePurchaseOptions {
|
||||||
llmFallback?: PurchaseParserLlmFallback
|
llmFallback?: PurchaseParserLlmFallback
|
||||||
|
defaultCurrency?: 'GEL' | 'USD'
|
||||||
}
|
}
|
||||||
|
|
||||||
const CURRENCY_PATTERN = '(?:₾|gel|lari|лари|usd|\\$|доллар(?:а|ов)?)'
|
const CURRENCY_PATTERN = '(?:₾|gel|lari|лари|usd|\\$|доллар(?:а|ов)?)'
|
||||||
@@ -60,7 +61,10 @@ function normalizeDescription(rawText: string, matchedFragment: string): string
|
|||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseWithRules(rawText: string): ParsedPurchaseResult | null {
|
function parseWithRules(
|
||||||
|
rawText: string,
|
||||||
|
defaultCurrency: 'GEL' | 'USD'
|
||||||
|
): ParsedPurchaseResult | null {
|
||||||
const matches = Array.from(rawText.matchAll(AMOUNT_WITH_OPTIONAL_CURRENCY))
|
const matches = Array.from(rawText.matchAll(AMOUNT_WITH_OPTIONAL_CURRENCY))
|
||||||
|
|
||||||
if (matches.length !== 1) {
|
if (matches.length !== 1) {
|
||||||
@@ -76,7 +80,7 @@ function parseWithRules(rawText: string): ParsedPurchaseResult | null {
|
|||||||
const amountMinor = toMinorUnits(match.groups.amount)
|
const amountMinor = toMinorUnits(match.groups.amount)
|
||||||
|
|
||||||
const explicitCurrency = currency !== null
|
const explicitCurrency = currency !== null
|
||||||
const resolvedCurrency = currency ?? 'GEL'
|
const resolvedCurrency = currency ?? defaultCurrency
|
||||||
const confidence = explicitCurrency ? 92 : 78
|
const confidence = explicitCurrency ? 92 : 78
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -118,7 +122,7 @@ export async function parsePurchaseMessage(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const rulesResult = parseWithRules(rawText)
|
const rulesResult = parseWithRules(rawText, options.defaultCurrency ?? 'GEL')
|
||||||
if (rulesResult) {
|
if (rulesResult) {
|
||||||
return rulesResult
|
return rulesResult
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"0008_lowly_spiral.sql": "4f016332d60f7ef1fef0311210a0fa1a0bfc1d9b6da1da4380a60c14d54a9681",
|
"0008_lowly_spiral.sql": "4f016332d60f7ef1fef0311210a0fa1a0bfc1d9b6da1da4380a60c14d54a9681",
|
||||||
"0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087",
|
"0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087",
|
||||||
"0010_wild_molecule_man.sql": "46027a6ac770cdc2efd4c3eb5bb53f09d1b852c70fdc46a2434e5a7064587245",
|
"0010_wild_molecule_man.sql": "46027a6ac770cdc2efd4c3eb5bb53f09d1b852c70fdc46a2434e5a7064587245",
|
||||||
"0011_previous_ezekiel_stane.sql": "d996e64d3854de22e36dedeaa94e46774399163d90263bbb05e0b9199af79b70"
|
"0011_previous_ezekiel_stane.sql": "d996e64d3854de22e36dedeaa94e46774399163d90263bbb05e0b9199af79b70",
|
||||||
|
"0012_clumsy_maestro.sql": "173797fb435c6acd7c268c624942d6f19a887c329bcef409a3dde1249baaeb8a"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
packages/db/drizzle/0012_clumsy_maestro.sql
Normal file
16
packages/db/drizzle/0012_clumsy_maestro.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "billing_cycle_exchange_rates" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"cycle_id" uuid NOT NULL,
|
||||||
|
"source_currency" text NOT NULL,
|
||||||
|
"target_currency" text NOT NULL,
|
||||||
|
"rate_micros" bigint NOT NULL,
|
||||||
|
"effective_date" date NOT NULL,
|
||||||
|
"source" text DEFAULT 'nbg' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "household_billing_settings" ADD COLUMN "settlement_currency" text DEFAULT 'GEL' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "billing_cycle_exchange_rates" ADD CONSTRAINT "billing_cycle_exchange_rates_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "billing_cycle_exchange_rates_cycle_pair_unique" ON "billing_cycle_exchange_rates" USING btree ("cycle_id","source_currency","target_currency");--> statement-breakpoint
|
||||||
|
CREATE INDEX "billing_cycle_exchange_rates_cycle_idx" ON "billing_cycle_exchange_rates" USING btree ("cycle_id");
|
||||||
2514
packages/db/drizzle/meta/0012_snapshot.json
Normal file
2514
packages/db/drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
|||||||
"when": 1773096404367,
|
"when": 1773096404367,
|
||||||
"tag": "0011_previous_ezekiel_stane",
|
"tag": "0011_previous_ezekiel_stane",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773146577992,
|
||||||
|
"tag": "0012_clumsy_maestro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const householdBillingSettings = pgTable(
|
|||||||
householdId: uuid('household_id')
|
householdId: uuid('household_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => households.id, { onDelete: 'cascade' }),
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
|
settlementCurrency: text('settlement_currency').default('GEL').notNull(),
|
||||||
rentAmountMinor: bigint('rent_amount_minor', { mode: 'bigint' }),
|
rentAmountMinor: bigint('rent_amount_minor', { mode: 'bigint' }),
|
||||||
rentCurrency: text('rent_currency').default('USD').notNull(),
|
rentCurrency: text('rent_currency').default('USD').notNull(),
|
||||||
rentDueDay: integer('rent_due_day').default(20).notNull(),
|
rentDueDay: integer('rent_due_day').default(20).notNull(),
|
||||||
@@ -257,6 +258,31 @@ export const rentRules = pgTable(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const billingCycleExchangeRates = pgTable(
|
||||||
|
'billing_cycle_exchange_rates',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
cycleId: uuid('cycle_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => billingCycles.id, { onDelete: 'cascade' }),
|
||||||
|
sourceCurrency: text('source_currency').notNull(),
|
||||||
|
targetCurrency: text('target_currency').notNull(),
|
||||||
|
rateMicros: bigint('rate_micros', { mode: 'bigint' }).notNull(),
|
||||||
|
effectiveDate: date('effective_date').notNull(),
|
||||||
|
source: text('source').default('nbg').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
cyclePairUnique: uniqueIndex('billing_cycle_exchange_rates_cycle_pair_unique').on(
|
||||||
|
table.cycleId,
|
||||||
|
table.sourceCurrency,
|
||||||
|
table.targetCurrency
|
||||||
|
),
|
||||||
|
cycleIdx: index('billing_cycle_exchange_rates_cycle_idx').on(table.cycleId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const utilityBills = pgTable(
|
export const utilityBills = pgTable(
|
||||||
'utility_bills',
|
'utility_bills',
|
||||||
{
|
{
|
||||||
@@ -517,6 +543,7 @@ export type HouseholdTopicBinding = typeof householdTopicBindings.$inferSelect
|
|||||||
export type HouseholdUtilityCategory = typeof householdUtilityCategories.$inferSelect
|
export type HouseholdUtilityCategory = typeof householdUtilityCategories.$inferSelect
|
||||||
export type Member = typeof members.$inferSelect
|
export type Member = typeof members.$inferSelect
|
||||||
export type BillingCycle = typeof billingCycles.$inferSelect
|
export type BillingCycle = typeof billingCycles.$inferSelect
|
||||||
|
export type BillingCycleExchangeRate = typeof billingCycleExchangeRates.$inferSelect
|
||||||
export type UtilityBill = typeof utilityBills.$inferSelect
|
export type UtilityBill = typeof utilityBills.$inferSelect
|
||||||
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
||||||
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export { BillingPeriod } from './billing-period'
|
export { BillingPeriod } from './billing-period'
|
||||||
export { DOMAIN_ERROR_CODE, DomainError } from './errors'
|
export { DOMAIN_ERROR_CODE, DomainError } from './errors'
|
||||||
export { BillingCycleId, HouseholdId, MemberId, PurchaseEntryId } from './ids'
|
export { BillingCycleId, HouseholdId, MemberId, PurchaseEntryId } from './ids'
|
||||||
export { CURRENCIES, Money } from './money'
|
export { CURRENCIES, FX_RATE_SCALE_MICROS, Money, convertMoney } from './money'
|
||||||
export { normalizeSupportedLocale, SUPPORTED_LOCALES } from './locale'
|
export { normalizeSupportedLocale, SUPPORTED_LOCALES } from './locale'
|
||||||
export {
|
export {
|
||||||
Temporal,
|
Temporal,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
|
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
|
||||||
|
|
||||||
export const CURRENCIES = ['GEL', 'USD'] as const
|
export const CURRENCIES = ['GEL', 'USD'] as const
|
||||||
|
export const FX_RATE_SCALE_MICROS = 1_000_000n
|
||||||
|
|
||||||
export type CurrencyCode = (typeof CURRENCIES)[number]
|
export type CurrencyCode = (typeof CURRENCIES)[number]
|
||||||
|
|
||||||
@@ -73,6 +74,23 @@ function formatMajorUnits(minor: bigint): string {
|
|||||||
return `${sign}${whole.toString()}.${fractionString}`
|
return `${sign}${whole.toString()}.${fractionString}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function divideRoundedHalfUp(dividend: bigint, divisor: bigint): bigint {
|
||||||
|
if (divisor === 0n) {
|
||||||
|
throw new DomainError(DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT, 'Division by zero')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = dividend < 0n ? -1n : 1n
|
||||||
|
const absoluteDividend = dividend < 0n ? -dividend : dividend
|
||||||
|
const quotient = absoluteDividend / divisor
|
||||||
|
const remainder = absoluteDividend % divisor
|
||||||
|
|
||||||
|
if (remainder * 2n >= divisor) {
|
||||||
|
return (quotient + 1n) * sign
|
||||||
|
}
|
||||||
|
|
||||||
|
return quotient * sign
|
||||||
|
}
|
||||||
|
|
||||||
export class Money {
|
export class Money {
|
||||||
readonly amountMinor: bigint
|
readonly amountMinor: bigint
|
||||||
readonly currency: CurrencyCode
|
readonly currency: CurrencyCode
|
||||||
@@ -257,3 +275,23 @@ export class Money {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function convertMoney(
|
||||||
|
amount: Money,
|
||||||
|
targetCurrency: CurrencyCode,
|
||||||
|
rateMicros: bigint
|
||||||
|
): Money {
|
||||||
|
if (rateMicros <= 0n) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT,
|
||||||
|
`Exchange rate must be positive: ${rateMicros.toString()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount.currency === targetCurrency) {
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertedMinor = divideRoundedHalfUp(amount.amountMinor * rateMicros, FX_RATE_SCALE_MICROS)
|
||||||
|
return Money.fromMinor(convertedMinor, targetCurrency)
|
||||||
|
}
|
||||||
|
|||||||
17
packages/ports/src/exchange-rates.ts
Normal file
17
packages/ports/src/exchange-rates.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { CurrencyCode } from '@household/domain'
|
||||||
|
|
||||||
|
export interface ExchangeRateQuote {
|
||||||
|
baseCurrency: CurrencyCode
|
||||||
|
quoteCurrency: CurrencyCode
|
||||||
|
rateMicros: bigint
|
||||||
|
effectiveDate: string
|
||||||
|
source: 'nbg'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExchangeRateProvider {
|
||||||
|
getRate(input: {
|
||||||
|
baseCurrency: CurrencyCode
|
||||||
|
quoteCurrency: CurrencyCode
|
||||||
|
effectiveDate: string
|
||||||
|
}): Promise<ExchangeRateQuote>
|
||||||
|
}
|
||||||
@@ -14,6 +14,15 @@ export interface FinanceCycleRecord {
|
|||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FinanceCycleExchangeRateRecord {
|
||||||
|
cycleId: string
|
||||||
|
sourceCurrency: CurrencyCode
|
||||||
|
targetCurrency: CurrencyCode
|
||||||
|
rateMicros: bigint
|
||||||
|
effectiveDate: string
|
||||||
|
source: 'nbg'
|
||||||
|
}
|
||||||
|
|
||||||
export interface FinanceRentRuleRecord {
|
export interface FinanceRentRuleRecord {
|
||||||
amountMinor: bigint
|
amountMinor: bigint
|
||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
@@ -64,6 +73,14 @@ export interface FinanceRepository {
|
|||||||
openCycle(period: string, currency: CurrencyCode): Promise<void>
|
openCycle(period: string, currency: CurrencyCode): Promise<void>
|
||||||
closeCycle(cycleId: string, closedAt: Instant): Promise<void>
|
closeCycle(cycleId: string, closedAt: Instant): Promise<void>
|
||||||
saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise<void>
|
saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise<void>
|
||||||
|
getCycleExchangeRate(
|
||||||
|
cycleId: string,
|
||||||
|
sourceCurrency: CurrencyCode,
|
||||||
|
targetCurrency: CurrencyCode
|
||||||
|
): Promise<FinanceCycleExchangeRateRecord | null>
|
||||||
|
saveCycleExchangeRate(
|
||||||
|
input: FinanceCycleExchangeRateRecord
|
||||||
|
): Promise<FinanceCycleExchangeRateRecord>
|
||||||
addUtilityBill(input: {
|
addUtilityBill(input: {
|
||||||
cycleId: string
|
cycleId: string
|
||||||
billName: string
|
billName: string
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface HouseholdMemberRecord {
|
|||||||
|
|
||||||
export interface HouseholdBillingSettingsRecord {
|
export interface HouseholdBillingSettingsRecord {
|
||||||
householdId: string
|
householdId: string
|
||||||
|
settlementCurrency: CurrencyCode
|
||||||
rentAmountMinor: bigint | null
|
rentAmountMinor: bigint | null
|
||||||
rentCurrency: CurrencyCode
|
rentCurrency: CurrencyCode
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
@@ -140,6 +141,7 @@ export interface HouseholdConfigurationRepository {
|
|||||||
getHouseholdBillingSettings(householdId: string): Promise<HouseholdBillingSettingsRecord>
|
getHouseholdBillingSettings(householdId: string): Promise<HouseholdBillingSettingsRecord>
|
||||||
updateHouseholdBillingSettings(input: {
|
updateHouseholdBillingSettings(input: {
|
||||||
householdId: string
|
householdId: string
|
||||||
|
settlementCurrency?: CurrencyCode
|
||||||
rentAmountMinor?: bigint | null
|
rentAmountMinor?: bigint | null
|
||||||
rentCurrency?: CurrencyCode
|
rentCurrency?: CurrencyCode
|
||||||
rentDueDay?: number
|
rentDueDay?: number
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type {
|
|||||||
} from './anonymous-feedback'
|
} from './anonymous-feedback'
|
||||||
export type {
|
export type {
|
||||||
FinanceCycleRecord,
|
FinanceCycleRecord,
|
||||||
|
FinanceCycleExchangeRateRecord,
|
||||||
FinanceMemberRecord,
|
FinanceMemberRecord,
|
||||||
FinanceParsedPurchaseRecord,
|
FinanceParsedPurchaseRecord,
|
||||||
FinanceRentRuleRecord,
|
FinanceRentRuleRecord,
|
||||||
@@ -38,6 +39,7 @@ export type {
|
|||||||
SettlementSnapshotLineRecord,
|
SettlementSnapshotLineRecord,
|
||||||
SettlementSnapshotRecord
|
SettlementSnapshotRecord
|
||||||
} from './finance'
|
} from './finance'
|
||||||
|
export type { ExchangeRateProvider, ExchangeRateQuote } from './exchange-rates'
|
||||||
export {
|
export {
|
||||||
TELEGRAM_PENDING_ACTION_TYPES,
|
TELEGRAM_PENDING_ACTION_TYPES,
|
||||||
type TelegramPendingActionRecord,
|
type TelegramPendingActionRecord,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
createDbHouseholdConfigurationRepository
|
createDbHouseholdConfigurationRepository
|
||||||
} from '@household/adapters-db'
|
} from '@household/adapters-db'
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
|
import type { ExchangeRateProvider } from '@household/ports'
|
||||||
|
|
||||||
import { createTelegramBot } from '../../apps/bot/src/bot'
|
import { createTelegramBot } from '../../apps/bot/src/bot'
|
||||||
import { createFinanceCommandsService } from '../../apps/bot/src/finance-commands'
|
import { createFinanceCommandsService } from '../../apps/bot/src/finance-commands'
|
||||||
@@ -20,6 +21,27 @@ import {
|
|||||||
const chatId = '-100123456'
|
const chatId = '-100123456'
|
||||||
const purchaseTopicId = 77
|
const purchaseTopicId = 77
|
||||||
const commandChatIdNumber = -100123456
|
const commandChatIdNumber = -100123456
|
||||||
|
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 unixSeconds(year: number, month: number, day: number): number {
|
function unixSeconds(year: number, month: number, day: number): number {
|
||||||
return Math.floor(Date.UTC(year, month - 1, day, 12, 0, 0) / 1000)
|
return Math.floor(Date.UTC(year, month - 1, day, 12, 0, 0) / 1000)
|
||||||
@@ -188,7 +210,12 @@ async function run(): Promise<void> {
|
|||||||
ingestionClient = createPurchaseMessageRepository(databaseUrl)
|
ingestionClient = createPurchaseMessageRepository(databaseUrl)
|
||||||
financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household)
|
financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household)
|
||||||
householdConfigurationRepositoryClient = createDbHouseholdConfigurationRepository(databaseUrl)
|
householdConfigurationRepositoryClient = createDbHouseholdConfigurationRepository(databaseUrl)
|
||||||
const financeService = createFinanceCommandService(financeRepositoryClient.repository)
|
const financeService = createFinanceCommandService({
|
||||||
|
householdId: ids.household,
|
||||||
|
repository: financeRepositoryClient.repository,
|
||||||
|
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
||||||
|
exchangeRateProvider
|
||||||
|
})
|
||||||
const financeCommands = createFinanceCommandsService({
|
const financeCommands = createFinanceCommandsService({
|
||||||
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
||||||
financeServiceForHousehold: () => financeService
|
financeServiceForHousehold: () => financeService
|
||||||
|
|||||||
Reference in New Issue
Block a user