feat(architecture): add finance repository adapters

This commit is contained in:
2026-03-08 22:14:09 +04:00
parent 4ecafcfe23
commit f6d1f34acf
17 changed files with 994 additions and 383 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "@household/adapters-db",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --target bun",
"typecheck": "tsgo --project tsconfig.json --noEmit",
"test": "bun test --pass-with-no-tests",
"lint": "oxlint \"src\""
},
"dependencies": {
"@household/db": "workspace:*",
"@household/domain": "workspace:*",
"@household/ports": "workspace:*",
"drizzle-orm": "^0.44.7"
}
}

View File

@@ -0,0 +1,329 @@
import { and, desc, eq, gte, isNotNull, isNull, lte, or, sql } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import type { FinanceRepository } from '@household/ports'
import type { CurrencyCode } from '@household/domain'
function toCurrencyCode(raw: string): CurrencyCode {
const normalized = raw.trim().toUpperCase()
if (normalized !== 'USD' && normalized !== 'GEL') {
throw new Error(`Unsupported currency in finance repository: ${raw}`)
}
return normalized
}
export function createDbFinanceRepository(
databaseUrl: string,
householdId: string
): {
repository: FinanceRepository
close: () => Promise<void>
} {
const { db, queryClient } = createDbClient(databaseUrl, {
max: 5,
prepare: false
})
const repository: FinanceRepository = {
async getMemberByTelegramUserId(telegramUserId) {
const rows = await db
.select({
id: schema.members.id,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
isAdmin: schema.members.isAdmin
})
.from(schema.members)
.where(
and(
eq(schema.members.householdId, householdId),
eq(schema.members.telegramUserId, telegramUserId)
)
)
.limit(1)
const row = rows[0]
if (!row) {
return null
}
return {
...row,
isAdmin: row.isAdmin === 1
}
},
async listMembers() {
const rows = await db
.select({
id: schema.members.id,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
isAdmin: schema.members.isAdmin
})
.from(schema.members)
.where(eq(schema.members.householdId, householdId))
.orderBy(schema.members.displayName)
return rows.map((row) => ({
...row,
isAdmin: row.isAdmin === 1
}))
},
async getOpenCycle() {
const rows = await db
.select({
id: schema.billingCycles.id,
period: schema.billingCycles.period,
currency: schema.billingCycles.currency
})
.from(schema.billingCycles)
.where(
and(
eq(schema.billingCycles.householdId, householdId),
isNull(schema.billingCycles.closedAt)
)
)
.orderBy(desc(schema.billingCycles.startedAt))
.limit(1)
const row = rows[0]
if (!row) {
return null
}
return {
...row,
currency: toCurrencyCode(row.currency)
}
},
async getCycleByPeriod(period) {
const rows = await db
.select({
id: schema.billingCycles.id,
period: schema.billingCycles.period,
currency: schema.billingCycles.currency
})
.from(schema.billingCycles)
.where(
and(
eq(schema.billingCycles.householdId, householdId),
eq(schema.billingCycles.period, period)
)
)
.limit(1)
const row = rows[0]
if (!row) {
return null
}
return {
...row,
currency: toCurrencyCode(row.currency)
}
},
async getLatestCycle() {
const rows = await db
.select({
id: schema.billingCycles.id,
period: schema.billingCycles.period,
currency: schema.billingCycles.currency
})
.from(schema.billingCycles)
.where(eq(schema.billingCycles.householdId, householdId))
.orderBy(desc(schema.billingCycles.period))
.limit(1)
const row = rows[0]
if (!row) {
return null
}
return {
...row,
currency: toCurrencyCode(row.currency)
}
},
async openCycle(period, currency) {
await db
.insert(schema.billingCycles)
.values({
householdId,
period,
currency
})
.onConflictDoNothing({
target: [schema.billingCycles.householdId, schema.billingCycles.period]
})
},
async closeCycle(cycleId, closedAt) {
await db
.update(schema.billingCycles)
.set({
closedAt
})
.where(eq(schema.billingCycles.id, cycleId))
},
async saveRentRule(period, amountMinor, currency) {
await db
.insert(schema.rentRules)
.values({
householdId,
amountMinor,
currency,
effectiveFromPeriod: period
})
.onConflictDoUpdate({
target: [schema.rentRules.householdId, schema.rentRules.effectiveFromPeriod],
set: {
amountMinor,
currency
}
})
},
async addUtilityBill(input) {
await db.insert(schema.utilityBills).values({
householdId,
cycleId: input.cycleId,
billName: input.billName,
amountMinor: input.amountMinor,
currency: input.currency,
source: 'manual',
createdByMemberId: input.createdByMemberId
})
},
async getRentRuleForPeriod(period) {
const rows = await db
.select({
amountMinor: schema.rentRules.amountMinor,
currency: schema.rentRules.currency
})
.from(schema.rentRules)
.where(
and(
eq(schema.rentRules.householdId, householdId),
lte(schema.rentRules.effectiveFromPeriod, period),
or(
isNull(schema.rentRules.effectiveToPeriod),
gte(schema.rentRules.effectiveToPeriod, period)
)
)
)
.orderBy(desc(schema.rentRules.effectiveFromPeriod))
.limit(1)
const row = rows[0]
if (!row) {
return null
}
return {
...row,
currency: toCurrencyCode(row.currency)
}
},
async getUtilityTotalForCycle(cycleId) {
const rows = await db
.select({
totalMinor: sql<string>`coalesce(sum(${schema.utilityBills.amountMinor}), 0)`
})
.from(schema.utilityBills)
.where(eq(schema.utilityBills.cycleId, cycleId))
return BigInt(rows[0]?.totalMinor ?? '0')
},
async listParsedPurchasesForRange(start, end) {
const rows = await db
.select({
id: schema.purchaseMessages.id,
payerMemberId: schema.purchaseMessages.senderMemberId,
amountMinor: schema.purchaseMessages.parsedAmountMinor
})
.from(schema.purchaseMessages)
.where(
and(
eq(schema.purchaseMessages.householdId, householdId),
isNotNull(schema.purchaseMessages.senderMemberId),
isNotNull(schema.purchaseMessages.parsedAmountMinor),
gte(schema.purchaseMessages.messageSentAt, start),
lte(schema.purchaseMessages.messageSentAt, end)
)
)
return rows.map((row) => ({
id: row.id,
payerMemberId: row.payerMemberId!,
amountMinor: row.amountMinor!
}))
},
async replaceSettlementSnapshot(snapshot) {
const upserted = await db
.insert(schema.settlements)
.values({
householdId,
cycleId: snapshot.cycleId,
inputHash: snapshot.inputHash,
totalDueMinor: snapshot.totalDueMinor,
currency: snapshot.currency,
metadata: snapshot.metadata
})
.onConflictDoUpdate({
target: [schema.settlements.cycleId],
set: {
inputHash: snapshot.inputHash,
totalDueMinor: snapshot.totalDueMinor,
currency: snapshot.currency,
computedAt: new Date(),
metadata: snapshot.metadata
}
})
.returning({ id: schema.settlements.id })
const settlementId = upserted[0]?.id
if (!settlementId) {
throw new Error('Failed to persist settlement snapshot')
}
await db
.delete(schema.settlementLines)
.where(eq(schema.settlementLines.settlementId, settlementId))
await db.insert(schema.settlementLines).values(
snapshot.lines.map((line) => ({
settlementId,
memberId: line.memberId,
rentShareMinor: line.rentShareMinor,
utilityShareMinor: line.utilityShareMinor,
purchaseOffsetMinor: line.purchaseOffsetMinor,
netDueMinor: line.netDueMinor,
explanations: line.explanations
}))
)
}
}
return {
repository,
close: async () => {
await queryClient.end({ timeout: 5 })
}
}
}

View File

@@ -0,0 +1 @@
export { createDbFinanceRepository } from './finance-repository'

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -12,6 +12,7 @@
"lint": "oxlint \"src\""
},
"dependencies": {
"@household/domain": "workspace:*"
"@household/domain": "workspace:*",
"@household/ports": "workspace:*"
}
}

View 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
])
})
})

View 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')
}
}
}

View File

@@ -1,4 +1,5 @@
export { calculateMonthlySettlement } from './settlement-engine'
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
export {
parsePurchaseMessage,
type ParsedPurchaseResult,

View File

@@ -2,10 +2,16 @@
"name": "@household/ports",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --target bun",
"typecheck": "tsgo --project tsconfig.json --noEmit",
"test": "bun test --pass-with-no-tests",
"lint": "oxlint \"src\""
},
"dependencies": {
"@household/domain": "workspace:*"
}
}

View File

@@ -0,0 +1,68 @@
import type { CurrencyCode } from '@household/domain'
export interface FinanceMemberRecord {
id: string
telegramUserId: string
displayName: string
isAdmin: boolean
}
export interface FinanceCycleRecord {
id: string
period: string
currency: CurrencyCode
}
export interface FinanceRentRuleRecord {
amountMinor: bigint
currency: CurrencyCode
}
export interface FinanceParsedPurchaseRecord {
id: string
payerMemberId: string
amountMinor: bigint
}
export interface SettlementSnapshotLineRecord {
memberId: string
rentShareMinor: bigint
utilityShareMinor: bigint
purchaseOffsetMinor: bigint
netDueMinor: bigint
explanations: readonly string[]
}
export interface SettlementSnapshotRecord {
cycleId: string
inputHash: string
totalDueMinor: bigint
currency: CurrencyCode
metadata: Record<string, unknown>
lines: readonly SettlementSnapshotLineRecord[]
}
export interface FinanceRepository {
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
listMembers(): Promise<readonly FinanceMemberRecord[]>
getOpenCycle(): Promise<FinanceCycleRecord | null>
getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null>
getLatestCycle(): Promise<FinanceCycleRecord | null>
openCycle(period: string, currency: CurrencyCode): Promise<void>
closeCycle(cycleId: string, closedAt: Date): Promise<void>
saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise<void>
addUtilityBill(input: {
cycleId: string
billName: string
amountMinor: bigint
currency: CurrencyCode
createdByMemberId: string
}): Promise<void>
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
listParsedPurchasesForRange(
start: Date,
end: Date
): Promise<readonly FinanceParsedPurchaseRecord[]>
replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void>
}

View File

@@ -1 +1,9 @@
export const portsReady = true
export type {
FinanceCycleRecord,
FinanceMemberRecord,
FinanceParsedPurchaseRecord,
FinanceRentRuleRecord,
FinanceRepository,
SettlementSnapshotLineRecord,
SettlementSnapshotRecord
} from './finance'