mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 21:04:03 +00:00
feat(architecture): add finance repository adapters
This commit is contained in:
192
packages/application/src/finance-command-service.test.ts
Normal file
192
packages/application/src/finance-command-service.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type {
|
||||
FinanceCycleRecord,
|
||||
FinanceMemberRecord,
|
||||
FinanceParsedPurchaseRecord,
|
||||
FinanceRentRuleRecord,
|
||||
FinanceRepository,
|
||||
SettlementSnapshotRecord
|
||||
} from '@household/ports'
|
||||
|
||||
import { createFinanceCommandService } from './finance-command-service'
|
||||
|
||||
class FinanceRepositoryStub implements FinanceRepository {
|
||||
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[] = []
|
||||
|
||||
lastSavedRentRule: {
|
||||
period: string
|
||||
amountMinor: bigint
|
||||
currency: 'USD' | 'GEL'
|
||||
} | null = null
|
||||
|
||||
lastUtilityBill: {
|
||||
cycleId: string
|
||||
billName: string
|
||||
amountMinor: bigint
|
||||
currency: 'USD' | 'GEL'
|
||||
createdByMemberId: string
|
||||
} | null = null
|
||||
|
||||
replacedSnapshot: SettlementSnapshotRecord | null = null
|
||||
|
||||
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
||||
return this.member
|
||||
}
|
||||
|
||||
async listMembers(): Promise<readonly FinanceMemberRecord[]> {
|
||||
return this.members
|
||||
}
|
||||
|
||||
async getOpenCycle(): Promise<FinanceCycleRecord | null> {
|
||||
return this.openCycleRecord
|
||||
}
|
||||
|
||||
async getCycleByPeriod(): Promise<FinanceCycleRecord | null> {
|
||||
return this.cycleByPeriodRecord
|
||||
}
|
||||
|
||||
async getLatestCycle(): Promise<FinanceCycleRecord | null> {
|
||||
return this.latestCycleRecord
|
||||
}
|
||||
|
||||
async openCycle(period: string, currency: 'USD' | 'GEL'): Promise<void> {
|
||||
this.openCycleRecord = {
|
||||
id: 'opened-cycle',
|
||||
period,
|
||||
currency
|
||||
}
|
||||
}
|
||||
|
||||
async closeCycle(): Promise<void> {}
|
||||
|
||||
async saveRentRule(period: string, amountMinor: bigint, currency: 'USD' | 'GEL'): Promise<void> {
|
||||
this.lastSavedRentRule = {
|
||||
period,
|
||||
amountMinor,
|
||||
currency
|
||||
}
|
||||
}
|
||||
|
||||
async addUtilityBill(input: {
|
||||
cycleId: string
|
||||
billName: string
|
||||
amountMinor: bigint
|
||||
currency: 'USD' | 'GEL'
|
||||
createdByMemberId: string
|
||||
}): Promise<void> {
|
||||
this.lastUtilityBill = input
|
||||
}
|
||||
|
||||
async getRentRuleForPeriod(): Promise<FinanceRentRuleRecord | null> {
|
||||
return this.rentRule
|
||||
}
|
||||
|
||||
async getUtilityTotalForCycle(): Promise<bigint> {
|
||||
return this.utilityTotal
|
||||
}
|
||||
|
||||
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
|
||||
return this.purchases
|
||||
}
|
||||
|
||||
async replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void> {
|
||||
this.replacedSnapshot = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
const service = createFinanceCommandService(repository)
|
||||
const result = await service.setRent('700', undefined, undefined)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.period).toBe('2026-03')
|
||||
expect(result?.currency).toBe('USD')
|
||||
expect(result?.amount.amountMinor).toBe(70000n)
|
||||
expect(repository.lastSavedRentRule).toEqual({
|
||||
period: '2026-03',
|
||||
amountMinor: 70000n,
|
||||
currency: 'USD'
|
||||
})
|
||||
})
|
||||
|
||||
test('addUtilityBill returns null when no open cycle exists', async () => {
|
||||
const repository = new FinanceRepositoryStub()
|
||||
const service = createFinanceCommandService(repository)
|
||||
|
||||
const result = await service.addUtilityBill('Electricity', '55.20', 'member-1')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(repository.lastUtilityBill).toBeNull()
|
||||
})
|
||||
|
||||
test('generateStatement persists settlement snapshot and returns member lines', async () => {
|
||||
const repository = new FinanceRepositoryStub()
|
||||
repository.latestCycleRecord = {
|
||||
id: 'cycle-2026-03',
|
||||
period: '2026-03',
|
||||
currency: 'USD'
|
||||
}
|
||||
repository.members = [
|
||||
{
|
||||
id: 'alice',
|
||||
telegramUserId: '100',
|
||||
displayName: 'Alice',
|
||||
isAdmin: true
|
||||
},
|
||||
{
|
||||
id: 'bob',
|
||||
telegramUserId: '200',
|
||||
displayName: 'Bob',
|
||||
isAdmin: false
|
||||
}
|
||||
]
|
||||
repository.rentRule = {
|
||||
amountMinor: 70000n,
|
||||
currency: 'USD'
|
||||
}
|
||||
repository.utilityTotal = 12000n
|
||||
repository.purchases = [
|
||||
{
|
||||
id: 'purchase-1',
|
||||
payerMemberId: 'alice',
|
||||
amountMinor: 3000n
|
||||
}
|
||||
]
|
||||
|
||||
const service = createFinanceCommandService(repository)
|
||||
const statement = await service.generateStatement()
|
||||
|
||||
expect(statement).toBe(
|
||||
[
|
||||
'Statement for 2026-03',
|
||||
'- Alice: 395.00 USD',
|
||||
'- Bob: 425.00 USD',
|
||||
'Total: 820.00 USD'
|
||||
].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?.lines.map((line) => line.netDueMinor)).toEqual([
|
||||
39500n,
|
||||
42500n
|
||||
])
|
||||
})
|
||||
})
|
||||
233
packages/application/src/finance-command-service.ts
Normal file
233
packages/application/src/finance-command-service.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import type { FinanceCycleRecord, FinanceMemberRecord, FinanceRepository } from '@household/ports'
|
||||
import {
|
||||
BillingCycleId,
|
||||
BillingPeriod,
|
||||
MemberId,
|
||||
Money,
|
||||
PurchaseEntryId,
|
||||
type CurrencyCode
|
||||
} from '@household/domain'
|
||||
|
||||
import { calculateMonthlySettlement } from './settlement-engine'
|
||||
|
||||
function parseCurrency(raw: string | undefined, fallback: CurrencyCode): CurrencyCode {
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const normalized = raw.trim().toUpperCase()
|
||||
if (normalized !== 'USD' && normalized !== 'GEL') {
|
||||
throw new Error(`Unsupported currency: ${raw}`)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function monthRange(period: BillingPeriod): { start: Date; end: Date } {
|
||||
return {
|
||||
start: new Date(Date.UTC(period.year, period.month - 1, 1, 0, 0, 0)),
|
||||
end: new Date(Date.UTC(period.year, period.month, 0, 23, 59, 59))
|
||||
}
|
||||
}
|
||||
|
||||
function computeInputHash(payload: object): string {
|
||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
||||
}
|
||||
|
||||
async function getCycleByPeriodOrLatest(
|
||||
repository: FinanceRepository,
|
||||
periodArg?: string
|
||||
): Promise<FinanceCycleRecord | null> {
|
||||
if (periodArg) {
|
||||
return repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString())
|
||||
}
|
||||
|
||||
return repository.getLatestCycle()
|
||||
}
|
||||
|
||||
export interface FinanceCommandService {
|
||||
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
|
||||
getOpenCycle(): Promise<FinanceCycleRecord | null>
|
||||
openCycle(periodArg: string, currencyArg?: string): Promise<FinanceCycleRecord>
|
||||
closeCycle(periodArg?: string): Promise<FinanceCycleRecord | null>
|
||||
setRent(
|
||||
amountArg: string,
|
||||
currencyArg?: string,
|
||||
periodArg?: string
|
||||
): Promise<{
|
||||
amount: Money
|
||||
currency: CurrencyCode
|
||||
period: string
|
||||
} | null>
|
||||
addUtilityBill(
|
||||
billName: string,
|
||||
amountArg: string,
|
||||
createdByMemberId: string,
|
||||
currencyArg?: string
|
||||
): Promise<{
|
||||
amount: Money
|
||||
currency: CurrencyCode
|
||||
period: string
|
||||
} | null>
|
||||
generateStatement(periodArg?: string): Promise<string | null>
|
||||
}
|
||||
|
||||
export function createFinanceCommandService(repository: FinanceRepository): FinanceCommandService {
|
||||
return {
|
||||
getMemberByTelegramUserId(telegramUserId) {
|
||||
return repository.getMemberByTelegramUserId(telegramUserId)
|
||||
},
|
||||
|
||||
getOpenCycle() {
|
||||
return repository.getOpenCycle()
|
||||
},
|
||||
|
||||
async openCycle(periodArg, currencyArg) {
|
||||
const period = BillingPeriod.fromString(periodArg).toString()
|
||||
const currency = parseCurrency(currencyArg, 'USD')
|
||||
|
||||
await repository.openCycle(period, currency)
|
||||
|
||||
return {
|
||||
id: '',
|
||||
period,
|
||||
currency
|
||||
}
|
||||
},
|
||||
|
||||
async closeCycle(periodArg) {
|
||||
const cycle = await getCycleByPeriodOrLatest(repository, periodArg)
|
||||
if (!cycle) {
|
||||
return null
|
||||
}
|
||||
|
||||
await repository.closeCycle(cycle.id, new Date())
|
||||
return cycle
|
||||
},
|
||||
|
||||
async setRent(amountArg, currencyArg, periodArg) {
|
||||
const openCycle = await repository.getOpenCycle()
|
||||
const period = periodArg ?? openCycle?.period
|
||||
if (!period) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currency = parseCurrency(currencyArg, openCycle?.currency ?? 'USD')
|
||||
const amount = Money.fromMajor(amountArg, currency)
|
||||
|
||||
await repository.saveRentRule(
|
||||
BillingPeriod.fromString(period).toString(),
|
||||
amount.amountMinor,
|
||||
currency
|
||||
)
|
||||
|
||||
return {
|
||||
amount,
|
||||
currency,
|
||||
period: BillingPeriod.fromString(period).toString()
|
||||
}
|
||||
},
|
||||
|
||||
async addUtilityBill(billName, amountArg, createdByMemberId, currencyArg) {
|
||||
const openCycle = await repository.getOpenCycle()
|
||||
if (!openCycle) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currency = parseCurrency(currencyArg, openCycle.currency)
|
||||
const amount = Money.fromMajor(amountArg, currency)
|
||||
|
||||
await repository.addUtilityBill({
|
||||
cycleId: openCycle.id,
|
||||
billName,
|
||||
amountMinor: amount.amountMinor,
|
||||
currency,
|
||||
createdByMemberId
|
||||
})
|
||||
|
||||
return {
|
||||
amount,
|
||||
currency,
|
||||
period: openCycle.period
|
||||
}
|
||||
},
|
||||
|
||||
async generateStatement(periodArg) {
|
||||
const cycle = await getCycleByPeriodOrLatest(repository, periodArg)
|
||||
if (!cycle) {
|
||||
return null
|
||||
}
|
||||
|
||||
const members = await repository.listMembers()
|
||||
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 utilitiesMinor = await repository.getUtilityTotalForCycle(cycle.id)
|
||||
|
||||
const settlement = calculateMonthlySettlement({
|
||||
cycleId: BillingCycleId.from(cycle.id),
|
||||
period,
|
||||
rent: Money.fromMinor(rentRule.amountMinor, rentRule.currency),
|
||||
utilities: Money.fromMinor(utilitiesMinor, rentRule.currency),
|
||||
utilitySplitMode: 'equal',
|
||||
members: members.map((member) => ({
|
||||
memberId: MemberId.from(member.id),
|
||||
active: true
|
||||
})),
|
||||
purchases: purchases.map((purchase) => ({
|
||||
purchaseId: PurchaseEntryId.from(purchase.id),
|
||||
payerId: MemberId.from(purchase.payerMemberId),
|
||||
amount: Money.fromMinor(purchase.amountMinor, rentRule.currency)
|
||||
}))
|
||||
})
|
||||
|
||||
await repository.replaceSettlementSnapshot({
|
||||
cycleId: cycle.id,
|
||||
inputHash: computeInputHash({
|
||||
cycleId: cycle.id,
|
||||
rentMinor: rentRule.amountMinor.toString(),
|
||||
utilitiesMinor: utilitiesMinor.toString(),
|
||||
purchaseCount: purchases.length,
|
||||
memberCount: members.length
|
||||
}),
|
||||
totalDueMinor: settlement.totalDue.amountMinor,
|
||||
currency: rentRule.currency,
|
||||
metadata: {
|
||||
generatedBy: 'bot-command',
|
||||
source: 'statement'
|
||||
},
|
||||
lines: settlement.lines.map((line) => ({
|
||||
memberId: line.memberId.toString(),
|
||||
rentShareMinor: line.rentShare.amountMinor,
|
||||
utilityShareMinor: line.utilityShare.amountMinor,
|
||||
purchaseOffsetMinor: line.purchaseOffset.amountMinor,
|
||||
netDueMinor: line.netDue.amountMinor,
|
||||
explanations: line.explanations
|
||||
}))
|
||||
})
|
||||
|
||||
const memberNameById = new Map(members.map((member) => [member.id, member.displayName]))
|
||||
const statementLines = settlement.lines.map((line) => {
|
||||
const name = memberNameById.get(line.memberId.toString()) ?? line.memberId.toString()
|
||||
return `- ${name}: ${line.netDue.toMajorString()} ${rentRule.currency}`
|
||||
})
|
||||
|
||||
return [
|
||||
`Statement for ${cycle.period}`,
|
||||
...statementLines,
|
||||
`Total: ${settlement.totalDue.toMajorString()} ${rentRule.currency}`
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { calculateMonthlySettlement } from './settlement-engine'
|
||||
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
||||
export {
|
||||
parsePurchaseMessage,
|
||||
type ParsedPurchaseResult,
|
||||
|
||||
Reference in New Issue
Block a user