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:
@@ -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) {
|
||||
await db.insert(schema.utilityBills).values({
|
||||
householdId,
|
||||
|
||||
@@ -178,6 +178,7 @@ function toCurrencyCode(raw: string): CurrencyCode {
|
||||
|
||||
function toHouseholdBillingSettingsRecord(row: {
|
||||
householdId: string
|
||||
settlementCurrency: string
|
||||
rentAmountMinor: bigint | null
|
||||
rentCurrency: string
|
||||
rentDueDay: number
|
||||
@@ -188,6 +189,7 @@ function toHouseholdBillingSettingsRecord(row: {
|
||||
}): HouseholdBillingSettingsRecord {
|
||||
return {
|
||||
householdId: row.householdId,
|
||||
settlementCurrency: toCurrencyCode(row.settlementCurrency),
|
||||
rentAmountMinor: row.rentAmountMinor,
|
||||
rentCurrency: toCurrencyCode(row.rentCurrency),
|
||||
rentDueDay: row.rentDueDay,
|
||||
@@ -862,6 +864,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
const rows = await db
|
||||
.select({
|
||||
householdId: schema.householdBillingSettings.householdId,
|
||||
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
|
||||
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
|
||||
rentCurrency: schema.householdBillingSettings.rentCurrency,
|
||||
rentDueDay: schema.householdBillingSettings.rentDueDay,
|
||||
@@ -888,6 +891,11 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
const rows = await db
|
||||
.update(schema.householdBillingSettings)
|
||||
.set({
|
||||
...(input.settlementCurrency
|
||||
? {
|
||||
settlementCurrency: input.settlementCurrency
|
||||
}
|
||||
: {}),
|
||||
...(input.rentAmountMinor !== undefined
|
||||
? {
|
||||
rentAmountMinor: input.rentAmountMinor
|
||||
@@ -928,6 +936,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
.where(eq(schema.householdBillingSettings.householdId, input.householdId))
|
||||
.returning({
|
||||
householdId: schema.householdBillingSettings.householdId,
|
||||
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
|
||||
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
|
||||
rentCurrency: schema.householdBillingSettings.rentCurrency,
|
||||
rentDueDay: schema.householdBillingSettings.rentDueDay,
|
||||
|
||||
@@ -2,24 +2,27 @@ import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { instantFromIso, type Instant } from '@household/domain'
|
||||
import type {
|
||||
ExchangeRateProvider,
|
||||
FinanceCycleExchangeRateRecord,
|
||||
FinanceCycleRecord,
|
||||
FinanceMemberRecord,
|
||||
FinanceParsedPurchaseRecord,
|
||||
FinanceRentRuleRecord,
|
||||
FinanceRepository,
|
||||
HouseholdConfigurationRepository,
|
||||
SettlementSnapshotRecord
|
||||
} from '@household/ports'
|
||||
|
||||
import { createFinanceCommandService } from './finance-command-service'
|
||||
|
||||
class FinanceRepositoryStub implements FinanceRepository {
|
||||
householdId = 'household-1'
|
||||
member: FinanceMemberRecord | null = null
|
||||
members: readonly FinanceMemberRecord[] = []
|
||||
openCycleRecord: FinanceCycleRecord | null = null
|
||||
cycleByPeriodRecord: FinanceCycleRecord | null = null
|
||||
latestCycleRecord: FinanceCycleRecord | null = null
|
||||
rentRule: FinanceRentRuleRecord | null = null
|
||||
utilityTotal: bigint = 0n
|
||||
purchases: readonly FinanceParsedPurchaseRecord[] = []
|
||||
utilityBills: readonly {
|
||||
id: string
|
||||
@@ -29,13 +32,11 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
createdByMemberId: string | null
|
||||
createdAt: Instant
|
||||
}[] = []
|
||||
|
||||
lastSavedRentRule: {
|
||||
period: string
|
||||
amountMinor: bigint
|
||||
currency: 'USD' | 'GEL'
|
||||
} | null = null
|
||||
|
||||
lastUtilityBill: {
|
||||
cycleId: string
|
||||
billName: string
|
||||
@@ -43,8 +44,8 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
currency: 'USD' | 'GEL'
|
||||
createdByMemberId: string
|
||||
} | null = null
|
||||
|
||||
replacedSnapshot: SettlementSnapshotRecord | null = null
|
||||
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
|
||||
|
||||
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
||||
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: {
|
||||
cycleId: string
|
||||
billName: string
|
||||
@@ -99,7 +118,7 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
}
|
||||
|
||||
async getUtilityTotalForCycle(): Promise<bigint> {
|
||||
return this.utilityTotal
|
||||
return this.utilityBills.reduce((sum, bill) => sum + bill.amountMinor, 0n)
|
||||
}
|
||||
|
||||
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', () => {
|
||||
test('setRent falls back to the open cycle period when one is active', async () => {
|
||||
const repository = new FinanceRepositoryStub()
|
||||
repository.openCycleRecord = {
|
||||
id: 'cycle-1',
|
||||
period: '2026-03',
|
||||
currency: 'USD'
|
||||
currency: 'GEL'
|
||||
}
|
||||
|
||||
const service = createFinanceCommandService(repository)
|
||||
const service = createService(repository)
|
||||
const result = await service.setRent('700', undefined, undefined)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
@@ -143,12 +222,12 @@ describe('createFinanceCommandService', () => {
|
||||
repository.openCycleRecord = {
|
||||
id: 'cycle-1',
|
||||
period: '2026-03',
|
||||
currency: 'USD'
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.latestCycleRecord = {
|
||||
id: 'cycle-0',
|
||||
period: '2026-02',
|
||||
currency: 'USD'
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.rentRule = {
|
||||
amountMinor: 70000n,
|
||||
@@ -159,20 +238,20 @@ describe('createFinanceCommandService', () => {
|
||||
id: 'utility-1',
|
||||
billName: 'Electricity',
|
||||
amountMinor: 12000n,
|
||||
currency: 'USD',
|
||||
currency: 'GEL',
|
||||
createdByMemberId: 'alice',
|
||||
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
||||
}
|
||||
]
|
||||
|
||||
const service = createFinanceCommandService(repository)
|
||||
const service = createService(repository)
|
||||
const result = await service.getAdminCycleState()
|
||||
|
||||
expect(result).toEqual({
|
||||
cycle: {
|
||||
id: 'cycle-1',
|
||||
period: '2026-03',
|
||||
currency: 'USD'
|
||||
currency: 'GEL'
|
||||
},
|
||||
rentRule: {
|
||||
amountMinor: 70000n,
|
||||
@@ -184,9 +263,9 @@ describe('createFinanceCommandService', () => {
|
||||
billName: 'Electricity',
|
||||
amount: expect.objectContaining({
|
||||
amountMinor: 12000n,
|
||||
currency: 'USD'
|
||||
currency: 'GEL'
|
||||
}),
|
||||
currency: 'USD',
|
||||
currency: 'GEL',
|
||||
createdByMemberId: 'alice',
|
||||
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
||||
}
|
||||
@@ -196,7 +275,7 @@ describe('createFinanceCommandService', () => {
|
||||
|
||||
test('addUtilityBill returns null when no open cycle exists', async () => {
|
||||
const repository = new FinanceRepositoryStub()
|
||||
const service = createFinanceCommandService(repository)
|
||||
const service = createService(repository)
|
||||
|
||||
const result = await service.addUtilityBill('Electricity', '55.20', 'member-1')
|
||||
|
||||
@@ -204,12 +283,12 @@ describe('createFinanceCommandService', () => {
|
||||
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()
|
||||
repository.latestCycleRecord = {
|
||||
id: 'cycle-2026-03',
|
||||
period: '2026-03',
|
||||
currency: 'USD'
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.members = [
|
||||
{
|
||||
@@ -231,13 +310,12 @@ describe('createFinanceCommandService', () => {
|
||||
amountMinor: 70000n,
|
||||
currency: 'USD'
|
||||
}
|
||||
repository.utilityTotal = 12000n
|
||||
repository.utilityBills = [
|
||||
{
|
||||
id: 'utility-1',
|
||||
billName: 'Electricity',
|
||||
amountMinor: 12000n,
|
||||
currency: 'USD',
|
||||
currency: 'GEL',
|
||||
createdByMemberId: 'alice',
|
||||
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 statement = await service.generateStatement()
|
||||
|
||||
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.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(
|
||||
[
|
||||
'Statement for 2026-03',
|
||||
'- Alice: 395.00 USD',
|
||||
'- Bob: 425.00 USD',
|
||||
'Total: 820.00 USD'
|
||||
'Rent: 700.00 USD (~1890.00 GEL)',
|
||||
'- Alice: 990.00 GEL',
|
||||
'- Bob: 1020.00 GEL',
|
||||
'Total: 2010.00 GEL'
|
||||
].join('\n')
|
||||
)
|
||||
expect(repository.replacedSnapshot).not.toBeNull()
|
||||
expect(repository.replacedSnapshot?.cycleId).toBe('cycle-2026-03')
|
||||
expect(repository.replacedSnapshot?.currency).toBe('USD')
|
||||
expect(repository.replacedSnapshot?.totalDueMinor).toBe(82000n)
|
||||
expect(repository.replacedSnapshot?.currency).toBe('GEL')
|
||||
expect(repository.replacedSnapshot?.totalDueMinor).toBe(201000n)
|
||||
expect(repository.replacedSnapshot?.lines.map((line) => line.netDueMinor)).toEqual([
|
||||
39500n,
|
||||
42500n
|
||||
99000n,
|
||||
102000n
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import type {
|
||||
ExchangeRateProvider,
|
||||
FinanceCycleRecord,
|
||||
FinanceMemberRecord,
|
||||
FinanceRentRuleRecord,
|
||||
FinanceRepository
|
||||
FinanceRepository,
|
||||
HouseholdConfigurationRepository
|
||||
} from '@household/ports'
|
||||
import {
|
||||
BillingCycleId,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
Money,
|
||||
PurchaseEntryId,
|
||||
Temporal,
|
||||
convertMoney,
|
||||
nowInstant,
|
||||
type CurrencyCode
|
||||
} from '@household/domain'
|
||||
@@ -57,6 +60,25 @@ async function getCycleByPeriodOrLatest(
|
||||
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 {
|
||||
memberId: string
|
||||
displayName: string
|
||||
@@ -73,6 +95,10 @@ export interface FinanceDashboardLedgerEntry {
|
||||
title: string
|
||||
amount: Money
|
||||
currency: CurrencyCode
|
||||
displayAmount: Money
|
||||
displayCurrency: CurrencyCode
|
||||
fxRateMicros: bigint | null
|
||||
fxEffectiveDate: string | null
|
||||
actorDisplayName: string | null
|
||||
occurredAt: string | null
|
||||
}
|
||||
@@ -81,6 +107,10 @@ export interface FinanceDashboard {
|
||||
period: string
|
||||
currency: CurrencyCode
|
||||
totalDue: Money
|
||||
rentSourceAmount: Money
|
||||
rentDisplayAmount: Money
|
||||
rentFxRateMicros: bigint | null
|
||||
rentFxEffectiveDate: string | null
|
||||
members: readonly FinanceDashboardMemberLine[]
|
||||
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(
|
||||
repository: FinanceRepository,
|
||||
dependencies: FinanceCommandServiceDependencies,
|
||||
periodArg?: string
|
||||
): Promise<FinanceDashboard | null> {
|
||||
const cycle = await getCycleByPeriodOrLatest(repository, periodArg)
|
||||
const cycle = await getCycleByPeriodOrLatest(dependencies.repository, periodArg)
|
||||
if (!cycle) {
|
||||
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) {
|
||||
throw new Error('No household members configured')
|
||||
}
|
||||
|
||||
const rentRule = await repository.getRentRuleForPeriod(cycle.period)
|
||||
if (!rentRule) {
|
||||
throw new Error('No rent rule configured for this cycle period')
|
||||
}
|
||||
|
||||
const period = BillingPeriod.fromString(cycle.period)
|
||||
const { start, end } = monthRange(period)
|
||||
const purchases = await repository.listParsedPurchasesForRange(start, end)
|
||||
const utilityBills = await repository.listUtilityBillsForCycle(cycle.id)
|
||||
const utilitiesMinor = await repository.getUtilityTotalForCycle(cycle.id)
|
||||
const [purchases, utilityBills] = await Promise.all([
|
||||
dependencies.repository.listParsedPurchasesForRange(start, end),
|
||||
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({
|
||||
cycleId: BillingCycleId.from(cycle.id),
|
||||
period,
|
||||
rent: Money.fromMinor(rentRule.amountMinor, rentRule.currency),
|
||||
utilities: Money.fromMinor(utilitiesMinor, rentRule.currency),
|
||||
rent: convertedRent.settlementAmount,
|
||||
utilities,
|
||||
utilitySplitMode: 'equal',
|
||||
members: members.map((member) => ({
|
||||
memberId: MemberId.from(member.id),
|
||||
active: true,
|
||||
rentWeight: member.rentShareWeight
|
||||
})),
|
||||
purchases: purchases.map((purchase) => ({
|
||||
purchases: convertedPurchases.map(({ purchase, converted }) => ({
|
||||
purchaseId: PurchaseEntryId.from(purchase.id),
|
||||
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,
|
||||
inputHash: computeInputHash({
|
||||
cycleId: cycle.id,
|
||||
rentMinor: rentRule.amountMinor.toString(),
|
||||
utilitiesMinor: utilitiesMinor.toString(),
|
||||
purchaseCount: purchases.length,
|
||||
rentMinor: convertedRent.settlementAmount.amountMinor.toString(),
|
||||
utilitiesMinor: utilities.amountMinor.toString(),
|
||||
purchaseMinors: convertedPurchases.map(({ purchase, converted }) => ({
|
||||
id: purchase.id,
|
||||
minor: converted.settlementAmount.amountMinor.toString(),
|
||||
currency: converted.settlementAmount.currency
|
||||
})),
|
||||
memberCount: members.length
|
||||
}),
|
||||
totalDueMinor: settlement.totalDue.amountMinor,
|
||||
currency: rentRule.currency,
|
||||
currency: cycle.currency,
|
||||
metadata: {
|
||||
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) => ({
|
||||
memberId: line.memberId.toString(),
|
||||
@@ -178,23 +349,31 @@ async function buildFinanceDashboard(
|
||||
}))
|
||||
|
||||
const ledger: FinanceDashboardLedgerEntry[] = [
|
||||
...utilityBills.map((bill) => ({
|
||||
...convertedUtilityBills.map(({ bill, converted }) => ({
|
||||
id: bill.id,
|
||||
kind: 'utility' as const,
|
||||
title: bill.billName,
|
||||
amount: Money.fromMinor(bill.amountMinor, bill.currency),
|
||||
amount: converted.originalAmount,
|
||||
currency: bill.currency,
|
||||
displayAmount: converted.settlementAmount,
|
||||
displayCurrency: cycle.currency,
|
||||
fxRateMicros: converted.fxRateMicros,
|
||||
fxEffectiveDate: converted.fxEffectiveDate,
|
||||
actorDisplayName: bill.createdByMemberId
|
||||
? (memberNameById.get(bill.createdByMemberId) ?? null)
|
||||
: null,
|
||||
occurredAt: bill.createdAt.toString()
|
||||
})),
|
||||
...purchases.map((purchase) => ({
|
||||
...convertedPurchases.map(({ purchase, converted }) => ({
|
||||
id: purchase.id,
|
||||
kind: 'purchase' as const,
|
||||
title: purchase.description ?? 'Shared purchase',
|
||||
amount: Money.fromMinor(purchase.amountMinor, purchase.currency),
|
||||
amount: converted.originalAmount,
|
||||
currency: purchase.currency,
|
||||
displayAmount: converted.settlementAmount,
|
||||
displayCurrency: cycle.currency,
|
||||
fxRateMicros: converted.fxRateMicros,
|
||||
fxEffectiveDate: converted.fxEffectiveDate,
|
||||
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
|
||||
occurredAt: purchase.occurredAt?.toString() ?? null
|
||||
}))
|
||||
@@ -208,8 +387,12 @@ async function buildFinanceDashboard(
|
||||
|
||||
return {
|
||||
period: cycle.period,
|
||||
currency: rentRule.currency,
|
||||
currency: cycle.currency,
|
||||
totalDue: settlement.totalDue,
|
||||
rentSourceAmount: convertedRent.originalAmount,
|
||||
rentDisplayAmount: convertedRent.settlementAmount,
|
||||
rentFxRateMicros: convertedRent.fxRateMicros,
|
||||
rentFxEffectiveDate: convertedRent.fxEffectiveDate,
|
||||
members: dashboardMembers,
|
||||
ledger
|
||||
}
|
||||
@@ -244,7 +427,11 @@ export interface FinanceCommandService {
|
||||
generateStatement(periodArg?: string): Promise<string | null>
|
||||
}
|
||||
|
||||
export function createFinanceCommandService(repository: FinanceRepository): FinanceCommandService {
|
||||
export function createFinanceCommandService(
|
||||
dependencies: FinanceCommandServiceDependencies
|
||||
): FinanceCommandService {
|
||||
const { repository, householdConfigurationRepository } = dependencies
|
||||
|
||||
return {
|
||||
getMemberByTelegramUserId(telegramUserId) {
|
||||
return repository.getMemberByTelegramUserId(telegramUserId)
|
||||
@@ -288,7 +475,10 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
||||
|
||||
async openCycle(periodArg, currencyArg) {
|
||||
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)
|
||||
|
||||
@@ -311,13 +501,16 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
||||
},
|
||||
|
||||
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
|
||||
if (!period) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currency = parseCurrency(currencyArg, openCycle?.currency ?? 'USD')
|
||||
const currency = parseCurrency(currencyArg, settings.rentCurrency)
|
||||
const amount = Money.fromMajor(amountArg, currency)
|
||||
|
||||
await repository.saveRentRule(
|
||||
@@ -334,12 +527,15 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
||||
},
|
||||
|
||||
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) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currency = parseCurrency(currencyArg, openCycle.currency)
|
||||
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
|
||||
const amount = Money.fromMajor(amountArg, currency)
|
||||
|
||||
await repository.addUtilityBill({
|
||||
@@ -358,7 +554,7 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
||||
},
|
||||
|
||||
async generateStatement(periodArg) {
|
||||
const dashboard = await buildFinanceDashboard(repository, periodArg)
|
||||
const dashboard = await buildFinanceDashboard(dependencies, periodArg)
|
||||
if (!dashboard) {
|
||||
return null
|
||||
}
|
||||
@@ -367,15 +563,21 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
||||
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 [
|
||||
`Statement for ${dashboard.period}`,
|
||||
rentLine,
|
||||
...statementLines,
|
||||
`Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`
|
||||
].join('\n')
|
||||
},
|
||||
|
||||
generateDashboard(periodArg) {
|
||||
return buildFinanceDashboard(repository, periodArg)
|
||||
return buildFinanceDashboard(dependencies, periodArg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ function createRepositoryStub() {
|
||||
},
|
||||
getHouseholdBillingSettings: async (householdId) => ({
|
||||
householdId,
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: null,
|
||||
rentCurrency: 'USD',
|
||||
rentDueDay: 20,
|
||||
@@ -153,6 +154,7 @@ function createRepositoryStub() {
|
||||
}),
|
||||
updateHouseholdBillingSettings: async (input) => ({
|
||||
householdId: input.householdId,
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||
rentCurrency: input.rentCurrency ?? 'USD',
|
||||
rentDueDay: input.rentDueDay ?? 20,
|
||||
|
||||
@@ -156,6 +156,7 @@ function createRepositoryStub() {
|
||||
async getHouseholdBillingSettings(householdId) {
|
||||
return {
|
||||
householdId,
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: null,
|
||||
rentCurrency: 'USD',
|
||||
rentDueDay: 20,
|
||||
@@ -168,6 +169,7 @@ function createRepositoryStub() {
|
||||
async updateHouseholdBillingSettings(input) {
|
||||
return {
|
||||
householdId: input.householdId,
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||
rentCurrency: input.rentCurrency ?? 'USD',
|
||||
rentDueDay: input.rentDueDay ?? 20,
|
||||
|
||||
@@ -250,6 +250,7 @@ function createRepositoryStub() {
|
||||
async getHouseholdBillingSettings(householdId) {
|
||||
return {
|
||||
householdId,
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: null,
|
||||
rentCurrency: 'USD',
|
||||
rentDueDay: 20,
|
||||
@@ -262,6 +263,7 @@ function createRepositoryStub() {
|
||||
async updateHouseholdBillingSettings(input) {
|
||||
return {
|
||||
householdId: input.householdId,
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||
rentCurrency: input.rentCurrency ?? 'USD',
|
||||
rentDueDay: input.rentDueDay ?? 20,
|
||||
|
||||
@@ -78,6 +78,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
||||
: null,
|
||||
getHouseholdBillingSettings: async (householdId) => ({
|
||||
householdId,
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: null,
|
||||
rentCurrency: 'USD',
|
||||
rentDueDay: 20,
|
||||
@@ -88,6 +89,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
||||
}),
|
||||
updateHouseholdBillingSettings: async (input) => ({
|
||||
householdId: input.householdId,
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||
rentCurrency: input.rentCurrency ?? 'USD',
|
||||
rentDueDay: input.rentDueDay ?? 20,
|
||||
|
||||
@@ -108,6 +108,7 @@ function repository(): HouseholdConfigurationRepository {
|
||||
: null,
|
||||
getHouseholdBillingSettings: async (householdId) => ({
|
||||
householdId,
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: null,
|
||||
rentCurrency: 'USD',
|
||||
rentDueDay: 20,
|
||||
@@ -118,6 +119,7 @@ function repository(): HouseholdConfigurationRepository {
|
||||
}),
|
||||
updateHouseholdBillingSettings: async (input) => ({
|
||||
householdId: input.householdId,
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: input.rentAmountMinor ?? null,
|
||||
rentCurrency: input.rentCurrency ?? 'USD',
|
||||
rentDueDay: input.rentDueDay ?? 20,
|
||||
@@ -177,6 +179,7 @@ describe('createMiniAppAdminService', () => {
|
||||
status: 'ok',
|
||||
settings: {
|
||||
householdId: 'household-1',
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: null,
|
||||
rentCurrency: 'USD',
|
||||
rentDueDay: 20,
|
||||
@@ -209,6 +212,7 @@ describe('createMiniAppAdminService', () => {
|
||||
status: 'ok',
|
||||
settings: {
|
||||
householdId: 'household-1',
|
||||
settlementCurrency: 'GEL',
|
||||
rentAmountMinor: 70000n,
|
||||
rentCurrency: 'USD',
|
||||
rentDueDay: 21,
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface MiniAppAdminService {
|
||||
updateSettings(input: {
|
||||
householdId: string
|
||||
actorIsAdmin: boolean
|
||||
settlementCurrency?: string
|
||||
rentAmountMajor?: string
|
||||
rentCurrency?: string
|
||||
rentDueDay: number
|
||||
@@ -176,6 +177,9 @@ export function createMiniAppAdminService(
|
||||
|
||||
let rentAmountMinor: bigint | null | undefined
|
||||
let rentCurrency: CurrencyCode | undefined
|
||||
const settlementCurrency = input.settlementCurrency
|
||||
? parseCurrency(input.settlementCurrency)
|
||||
: undefined
|
||||
|
||||
if (input.rentAmountMajor && input.rentAmountMajor.trim().length > 0) {
|
||||
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
|
||||
@@ -187,6 +191,11 @@ export function createMiniAppAdminService(
|
||||
|
||||
const settings = await repository.updateHouseholdBillingSettings({
|
||||
householdId: input.householdId,
|
||||
...(settlementCurrency
|
||||
? {
|
||||
settlementCurrency
|
||||
}
|
||||
: {}),
|
||||
...(rentAmountMinor !== undefined
|
||||
? {
|
||||
rentAmountMinor
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ParsePurchaseInput {
|
||||
|
||||
export interface ParsePurchaseOptions {
|
||||
llmFallback?: PurchaseParserLlmFallback
|
||||
defaultCurrency?: 'GEL' | 'USD'
|
||||
}
|
||||
|
||||
const CURRENCY_PATTERN = '(?:₾|gel|lari|лари|usd|\\$|доллар(?:а|ов)?)'
|
||||
@@ -60,7 +61,10 @@ function normalizeDescription(rawText: string, matchedFragment: string): string
|
||||
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))
|
||||
|
||||
if (matches.length !== 1) {
|
||||
@@ -76,7 +80,7 @@ function parseWithRules(rawText: string): ParsedPurchaseResult | null {
|
||||
const amountMinor = toMinorUnits(match.groups.amount)
|
||||
|
||||
const explicitCurrency = currency !== null
|
||||
const resolvedCurrency = currency ?? 'GEL'
|
||||
const resolvedCurrency = currency ?? defaultCurrency
|
||||
const confidence = explicitCurrency ? 92 : 78
|
||||
|
||||
return {
|
||||
@@ -118,7 +122,7 @@ export async function parsePurchaseMessage(
|
||||
return null
|
||||
}
|
||||
|
||||
const rulesResult = parseWithRules(rawText)
|
||||
const rulesResult = parseWithRules(rawText, options.defaultCurrency ?? 'GEL')
|
||||
if (rulesResult) {
|
||||
return rulesResult
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"0008_lowly_spiral.sql": "4f016332d60f7ef1fef0311210a0fa1a0bfc1d9b6da1da4380a60c14d54a9681",
|
||||
"0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087",
|
||||
"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,
|
||||
"tag": "0011_previous_ezekiel_stane",
|
||||
"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')
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: 'cascade' }),
|
||||
settlementCurrency: text('settlement_currency').default('GEL').notNull(),
|
||||
rentAmountMinor: bigint('rent_amount_minor', { mode: 'bigint' }),
|
||||
rentCurrency: text('rent_currency').default('USD').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(
|
||||
'utility_bills',
|
||||
{
|
||||
@@ -517,6 +543,7 @@ export type HouseholdTopicBinding = typeof householdTopicBindings.$inferSelect
|
||||
export type HouseholdUtilityCategory = typeof householdUtilityCategories.$inferSelect
|
||||
export type Member = typeof members.$inferSelect
|
||||
export type BillingCycle = typeof billingCycles.$inferSelect
|
||||
export type BillingCycleExchangeRate = typeof billingCycleExchangeRates.$inferSelect
|
||||
export type UtilityBill = typeof utilityBills.$inferSelect
|
||||
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
||||
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { BillingPeriod } from './billing-period'
|
||||
export { DOMAIN_ERROR_CODE, DomainError } from './errors'
|
||||
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 {
|
||||
Temporal,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DOMAIN_ERROR_CODE, DomainError } from './errors'
|
||||
|
||||
export const CURRENCIES = ['GEL', 'USD'] as const
|
||||
export const FX_RATE_SCALE_MICROS = 1_000_000n
|
||||
|
||||
export type CurrencyCode = (typeof CURRENCIES)[number]
|
||||
|
||||
@@ -73,6 +74,23 @@ function formatMajorUnits(minor: bigint): string {
|
||||
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 {
|
||||
readonly amountMinor: bigint
|
||||
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
|
||||
}
|
||||
|
||||
export interface FinanceCycleExchangeRateRecord {
|
||||
cycleId: string
|
||||
sourceCurrency: CurrencyCode
|
||||
targetCurrency: CurrencyCode
|
||||
rateMicros: bigint
|
||||
effectiveDate: string
|
||||
source: 'nbg'
|
||||
}
|
||||
|
||||
export interface FinanceRentRuleRecord {
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
@@ -64,6 +73,14 @@ export interface FinanceRepository {
|
||||
openCycle(period: string, currency: CurrencyCode): Promise<void>
|
||||
closeCycle(cycleId: string, closedAt: Instant): 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: {
|
||||
cycleId: string
|
||||
billName: string
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface HouseholdMemberRecord {
|
||||
|
||||
export interface HouseholdBillingSettingsRecord {
|
||||
householdId: string
|
||||
settlementCurrency: CurrencyCode
|
||||
rentAmountMinor: bigint | null
|
||||
rentCurrency: CurrencyCode
|
||||
rentDueDay: number
|
||||
@@ -140,6 +141,7 @@ export interface HouseholdConfigurationRepository {
|
||||
getHouseholdBillingSettings(householdId: string): Promise<HouseholdBillingSettingsRecord>
|
||||
updateHouseholdBillingSettings(input: {
|
||||
householdId: string
|
||||
settlementCurrency?: CurrencyCode
|
||||
rentAmountMinor?: bigint | null
|
||||
rentCurrency?: CurrencyCode
|
||||
rentDueDay?: number
|
||||
|
||||
@@ -30,6 +30,7 @@ export type {
|
||||
} from './anonymous-feedback'
|
||||
export type {
|
||||
FinanceCycleRecord,
|
||||
FinanceCycleExchangeRateRecord,
|
||||
FinanceMemberRecord,
|
||||
FinanceParsedPurchaseRecord,
|
||||
FinanceRentRuleRecord,
|
||||
@@ -38,6 +39,7 @@ export type {
|
||||
SettlementSnapshotLineRecord,
|
||||
SettlementSnapshotRecord
|
||||
} from './finance'
|
||||
export type { ExchangeRateProvider, ExchangeRateQuote } from './exchange-rates'
|
||||
export {
|
||||
TELEGRAM_PENDING_ACTION_TYPES,
|
||||
type TelegramPendingActionRecord,
|
||||
|
||||
Reference in New Issue
Block a user