diff --git a/apps/bot/package.json b/apps/bot/package.json index dbf7d47..6ef3f2d 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -10,9 +10,9 @@ "lint": "oxlint \"src\"" }, "dependencies": { + "@household/adapters-db": "workspace:*", "@household/application": "workspace:*", "@household/db": "workspace:*", - "@household/domain": "workspace:*", "drizzle-orm": "^0.44.7", "grammy": "1.41.1" } diff --git a/apps/bot/src/finance-commands.ts b/apps/bot/src/finance-commands.ts index 7c7b4ae..7731bd7 100644 --- a/apps/bot/src/finance-commands.ts +++ b/apps/bot/src/finance-commands.ts @@ -1,53 +1,6 @@ -import { calculateMonthlySettlement } from '@household/application' -import { createDbClient, schema } from '@household/db' -import { BillingCycleId, BillingPeriod, MemberId, Money, PurchaseEntryId } from '@household/domain' -import { and, desc, eq, gte, isNotNull, isNull, lte, or, sql } from 'drizzle-orm' +import type { FinanceCommandService } from '@household/application' import type { Bot, Context } from 'grammy' -import { createHash } from 'node:crypto' - -type SupportedCurrency = 'USD' | 'GEL' - -interface FinanceCommandsConfig { - householdId: string -} - -interface SettlementCycleData { - id: string - period: string - currency: string -} - -interface HouseholdMemberData { - id: string - telegramUserId: string - displayName: string - isAdmin: number -} - -function parseCurrency(raw: string | undefined, fallback: SupportedCurrency): SupportedCurrency { - 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 } { - const start = new Date(Date.UTC(period.year, period.month - 1, 1, 0, 0, 0)) - const end = new Date(Date.UTC(period.year, period.month, 0, 23, 59, 59)) - - return { - start, - end - } -} - function commandArgs(ctx: Context): string[] { const raw = typeof ctx.match === 'string' ? ctx.match.trim() : '' if (raw.length === 0) { @@ -57,52 +10,17 @@ function commandArgs(ctx: Context): string[] { return raw.split(/\s+/).filter(Boolean) } -function computeInputHash(payload: object): string { - return createHash('sha256').update(JSON.stringify(payload)).digest('hex') -} - -export function createFinanceCommandsService( - databaseUrl: string, - config: FinanceCommandsConfig -): { +export function createFinanceCommandsService(financeService: FinanceCommandService): { register: (bot: Bot) => void - close: () => Promise } { - const { db, queryClient } = createDbClient(databaseUrl, { - max: 5, - prepare: false - }) - - async function getMemberByTelegramUserId( - telegramUserId: string - ): Promise { - const row = 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, config.householdId), - eq(schema.members.telegramUserId, telegramUserId) - ) - ) - .limit(1) - - return row[0] ?? null - } - - async function requireMember(ctx: Context): Promise { + async function requireMember(ctx: Context) { const telegramUserId = ctx.from?.id?.toString() if (!telegramUserId) { await ctx.reply('Unable to identify sender for this command.') return null } - const member = await getMemberByTelegramUserId(telegramUserId) + const member = await financeService.getMemberByTelegramUserId(telegramUserId) if (!member) { await ctx.reply('You are not a member of this household.') return null @@ -111,13 +29,13 @@ export function createFinanceCommandsService( return member } - async function requireAdmin(ctx: Context): Promise { + async function requireAdmin(ctx: Context) { const member = await requireMember(ctx) if (!member) { return null } - if (member.isAdmin !== 1) { + if (!member.isAdmin) { await ctx.reply('Only household admins can use this command.') return null } @@ -125,217 +43,6 @@ export function createFinanceCommandsService( return member } - async function getOpenCycle(): Promise { - const cycle = await db - .select({ - id: schema.billingCycles.id, - period: schema.billingCycles.period, - currency: schema.billingCycles.currency - }) - .from(schema.billingCycles) - .where( - and( - eq(schema.billingCycles.householdId, config.householdId), - isNull(schema.billingCycles.closedAt) - ) - ) - .orderBy(desc(schema.billingCycles.startedAt)) - .limit(1) - - return cycle[0] ?? null - } - - async function getCycleByPeriodOrLatest(periodArg?: string): Promise { - if (periodArg) { - const period = BillingPeriod.fromString(periodArg).toString() - const cycle = await db - .select({ - id: schema.billingCycles.id, - period: schema.billingCycles.period, - currency: schema.billingCycles.currency - }) - .from(schema.billingCycles) - .where( - and( - eq(schema.billingCycles.householdId, config.householdId), - eq(schema.billingCycles.period, period) - ) - ) - .limit(1) - - return cycle[0] ?? null - } - - const latestCycle = await db - .select({ - id: schema.billingCycles.id, - period: schema.billingCycles.period, - currency: schema.billingCycles.currency - }) - .from(schema.billingCycles) - .where(eq(schema.billingCycles.householdId, config.householdId)) - .orderBy(desc(schema.billingCycles.period)) - .limit(1) - - return latestCycle[0] ?? null - } - - async function upsertSettlementSnapshot(cycle: SettlementCycleData): Promise { - const members = await db - .select({ - id: schema.members.id, - displayName: schema.members.displayName - }) - .from(schema.members) - .where(eq(schema.members.householdId, config.householdId)) - .orderBy(schema.members.displayName) - - if (members.length === 0) { - throw new Error('No household members configured') - } - - const rentRule = await db - .select({ - amountMinor: schema.rentRules.amountMinor, - currency: schema.rentRules.currency - }) - .from(schema.rentRules) - .where( - and( - eq(schema.rentRules.householdId, config.householdId), - lte(schema.rentRules.effectiveFromPeriod, cycle.period), - or( - isNull(schema.rentRules.effectiveToPeriod), - gte(schema.rentRules.effectiveToPeriod, cycle.period) - ) - ) - ) - .orderBy(desc(schema.rentRules.effectiveFromPeriod)) - .limit(1) - - if (!rentRule[0]) { - throw new Error('No rent rule configured for this cycle period') - } - - const utilityTotalRow = await db - .select({ - totalMinor: sql`coalesce(sum(${schema.utilityBills.amountMinor}), 0)` - }) - .from(schema.utilityBills) - .where(eq(schema.utilityBills.cycleId, cycle.id)) - - const period = BillingPeriod.fromString(cycle.period) - const range = monthRange(period) - - const purchases = await db - .select({ - id: schema.purchaseMessages.id, - senderMemberId: schema.purchaseMessages.senderMemberId, - parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor - }) - .from(schema.purchaseMessages) - .where( - and( - eq(schema.purchaseMessages.householdId, config.householdId), - isNotNull(schema.purchaseMessages.senderMemberId), - isNotNull(schema.purchaseMessages.parsedAmountMinor), - gte(schema.purchaseMessages.messageSentAt, range.start), - lte(schema.purchaseMessages.messageSentAt, range.end) - ) - ) - - const currency = parseCurrency(rentRule[0].currency, 'USD') - const utilitiesMinor = BigInt(utilityTotalRow[0]?.totalMinor ?? '0') - - const settlementInput = { - cycleId: BillingCycleId.from(cycle.id), - period, - rent: Money.fromMinor(rentRule[0].amountMinor, currency), - utilities: Money.fromMinor(utilitiesMinor, currency), - utilitySplitMode: 'equal' as const, - members: members.map((member) => ({ - memberId: MemberId.from(member.id), - active: true - })), - purchases: purchases.map((purchase) => ({ - purchaseId: PurchaseEntryId.from(purchase.id), - payerId: MemberId.from(purchase.senderMemberId!), - amount: Money.fromMinor(purchase.parsedAmountMinor!, currency) - })) - } - - const settlement = calculateMonthlySettlement(settlementInput) - const inputHash = computeInputHash({ - cycleId: cycle.id, - rentMinor: rentRule[0].amountMinor.toString(), - utilitiesMinor: utilitiesMinor.toString(), - purchaseCount: purchases.length, - memberCount: members.length - }) - - const upserted = await db - .insert(schema.settlements) - .values({ - householdId: config.householdId, - cycleId: cycle.id, - inputHash, - totalDueMinor: settlement.totalDue.amountMinor, - currency, - metadata: { - generatedBy: 'bot-command', - source: 'statement' - } - }) - .onConflictDoUpdate({ - target: [schema.settlements.cycleId], - set: { - inputHash, - totalDueMinor: settlement.totalDue.amountMinor, - currency, - computedAt: new Date(), - metadata: { - generatedBy: 'bot-command', - source: 'statement' - } - } - }) - .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)) - - const memberNameById = new Map(members.map((member) => [member.id, member.displayName])) - - await db.insert(schema.settlementLines).values( - settlement.lines.map((line) => ({ - settlementId, - memberId: line.memberId.toString(), - rentShareMinor: line.rentShare.amountMinor, - utilityShareMinor: line.utilityShare.amountMinor, - purchaseOffsetMinor: line.purchaseOffset.amountMinor, - netDueMinor: line.netDue.amountMinor, - explanations: line.explanations - })) - ) - - const statementLines = settlement.lines.map((line) => { - const name = memberNameById.get(line.memberId.toString()) ?? line.memberId.toString() - return `- ${name}: ${line.netDue.toMajorString()} ${currency}` - }) - - return [ - `Statement for ${cycle.period}`, - ...statementLines, - `Total: ${settlement.totalDue.toMajorString()} ${currency}` - ].join('\n') - } - function register(bot: Bot): void { bot.command('cycle_open', async (ctx) => { const admin = await requireAdmin(ctx) @@ -350,21 +57,8 @@ export function createFinanceCommandsService( } try { - const period = BillingPeriod.fromString(args[0]!).toString() - const currency = parseCurrency(args[1], 'USD') - - await db - .insert(schema.billingCycles) - .values({ - householdId: config.householdId, - period, - currency - }) - .onConflictDoNothing({ - target: [schema.billingCycles.householdId, schema.billingCycles.period] - }) - - await ctx.reply(`Cycle opened: ${period} (${currency})`) + const cycle = await financeService.openCycle(args[0]!, args[1]) + await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`) } catch (error) { await ctx.reply(`Failed to open cycle: ${(error as Error).message}`) } @@ -376,21 +70,13 @@ export function createFinanceCommandsService( return } - const args = commandArgs(ctx) try { - const cycle = await getCycleByPeriodOrLatest(args[0]) + const cycle = await financeService.closeCycle(commandArgs(ctx)[0]) if (!cycle) { await ctx.reply('No cycle found to close.') return } - await db - .update(schema.billingCycles) - .set({ - closedAt: new Date() - }) - .where(eq(schema.billingCycles.id, cycle.id)) - await ctx.reply(`Cycle closed: ${cycle.period}`) } catch (error) { await ctx.reply(`Failed to close cycle: ${(error as Error).message}`) @@ -410,34 +96,14 @@ export function createFinanceCommandsService( } try { - const openCycle = await getOpenCycle() - const period = args[2] ?? openCycle?.period - if (!period) { + const result = await financeService.setRent(args[0]!, args[1], args[2]) + if (!result) { await ctx.reply('No period provided and no open cycle found.') return } - const currency = parseCurrency(args[1], (openCycle?.currency as SupportedCurrency) ?? 'USD') - const amount = Money.fromMajor(args[0]!, currency) - - await db - .insert(schema.rentRules) - .values({ - householdId: config.householdId, - amountMinor: amount.amountMinor, - currency, - effectiveFromPeriod: BillingPeriod.fromString(period).toString() - }) - .onConflictDoUpdate({ - target: [schema.rentRules.householdId, schema.rentRules.effectiveFromPeriod], - set: { - amountMinor: amount.amountMinor, - currency - } - }) - await ctx.reply( - `Rent rule saved: ${amount.toMajorString()} ${currency} starting ${BillingPeriod.fromString(period).toString()}` + `Rent rule saved: ${result.amount.toMajorString()} ${result.currency} starting ${result.period}` ) } catch (error) { await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`) @@ -457,29 +123,14 @@ export function createFinanceCommandsService( } try { - const openCycle = await getOpenCycle() - if (!openCycle) { + const result = await financeService.addUtilityBill(args[0]!, args[1]!, admin.id, args[2]) + if (!result) { await ctx.reply('No open cycle found. Use /cycle_open first.') return } - const name = args[0]! - const amountRaw = args[1]! - const currency = parseCurrency(args[2], parseCurrency(openCycle.currency, 'USD')) - const amount = Money.fromMajor(amountRaw, currency) - - await db.insert(schema.utilityBills).values({ - householdId: config.householdId, - cycleId: openCycle.id, - billName: name, - amountMinor: amount.amountMinor, - currency, - source: 'manual', - createdByMemberId: admin.id - }) - await ctx.reply( - `Utility bill added: ${name} ${amount.toMajorString()} ${currency} for ${openCycle.period}` + `Utility bill added: ${args[0]} ${result.amount.toMajorString()} ${result.currency} for ${result.period}` ) } catch (error) { await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`) @@ -492,16 +143,14 @@ export function createFinanceCommandsService( return } - const args = commandArgs(ctx) try { - const cycle = await getCycleByPeriodOrLatest(args[0]) - if (!cycle) { + const statement = await financeService.generateStatement(commandArgs(ctx)[0]) + if (!statement) { await ctx.reply('No cycle found for statement.') return } - const message = await upsertSettlementSnapshot(cycle) - await ctx.reply(message) + await ctx.reply(statement) } catch (error) { await ctx.reply(`Failed to generate statement: ${(error as Error).message}`) } @@ -509,9 +158,6 @@ export function createFinanceCommandsService( } return { - register, - close: async () => { - await queryClient.end({ timeout: 5 }) - } + register } } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 9d8d642..044510f 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,8 +1,11 @@ import { webhookCallback } from 'grammy' +import { createFinanceCommandService } from '@household/application' +import { createDbFinanceRepository } from '@household/adapters-db' + +import { createFinanceCommandsService } from './finance-commands' import { createTelegramBot } from './bot' import { getBotRuntimeConfig } from './config' -import { createFinanceCommandsService } from './finance-commands' import { createOpenAiParserFallback } from './openai-parser-fallback' import { createPurchaseMessageRepository, @@ -42,12 +45,15 @@ if (runtime.purchaseTopicIngestionEnabled) { } if (runtime.financeCommandsEnabled) { - const financeCommands = createFinanceCommandsService(runtime.databaseUrl!, { - householdId: runtime.householdId! - }) + const financeRepositoryClient = createDbFinanceRepository( + runtime.databaseUrl!, + runtime.householdId! + ) + const financeService = createFinanceCommandService(financeRepositoryClient.repository) + const financeCommands = createFinanceCommandsService(financeService) financeCommands.register(bot) - shutdownTasks.push(financeCommands.close) + shutdownTasks.push(financeRepositoryClient.close) } else { console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.') } diff --git a/bun.lock b/bun.lock index d39779d..34fe20f 100644 --- a/bun.lock +++ b/bun.lock @@ -15,9 +15,10 @@ "apps/bot": { "name": "@household/bot", "dependencies": { + "@household/adapters-db": "workspace:*", "@household/application": "workspace:*", "@household/db": "workspace:*", - "@household/domain": "workspace:*", + "@household/ports": "workspace:*", "drizzle-orm": "^0.44.7", "grammy": "1.41.1", }, @@ -36,10 +37,20 @@ "vite-plugin-solid": "^2.11.8", }, }, + "packages/adapters-db": { + "name": "@household/adapters-db", + "dependencies": { + "@household/db": "workspace:*", + "@household/domain": "workspace:*", + "@household/ports": "workspace:*", + "drizzle-orm": "^0.44.7", + }, + }, "packages/application": { "name": "@household/application", "dependencies": { "@household/domain": "workspace:*", + "@household/ports": "workspace:*", }, }, "packages/config": { @@ -67,6 +78,9 @@ }, "packages/ports": { "name": "@household/ports", + "dependencies": { + "@household/domain": "workspace:*", + }, }, }, "packages": { @@ -168,6 +182,8 @@ "@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="], + "@household/adapters-db": ["@household/adapters-db@workspace:packages/adapters-db"], + "@household/application": ["@household/application@workspace:packages/application"], "@household/bot": ["@household/bot@workspace:apps/bot"], diff --git a/docs/specs/HOUSEBOT-024-repository-adapters.md b/docs/specs/HOUSEBOT-024-repository-adapters.md new file mode 100644 index 0000000..e9d7b24 --- /dev/null +++ b/docs/specs/HOUSEBOT-024-repository-adapters.md @@ -0,0 +1,76 @@ +# HOUSEBOT-024: Repository Adapters for Application Ports + +## Summary + +Move persistence concerns behind explicit ports so application use-cases remain framework-free and bot delivery code stops querying Drizzle directly. + +## Goals + +- Define repository contracts in `packages/ports` for finance command workflows. +- Move concrete Drizzle persistence into an adapter package. +- Rewire bot finance commands to depend on application services instead of direct DB access. + +## Non-goals + +- Full persistence migration for every feature in one shot. +- Replacing Drizzle or Supabase. +- Changing finance behavior or settlement rules. + +## Scope + +- In: finance command repository ports, application service orchestration, Drizzle adapter, bot composition updates. +- Out: reminder scheduler adapters and mini app query adapters. + +## Interfaces and Contracts + +- Port: `FinanceRepository` +- Application service: + - member lookup + - open/close cycle + - rent rule save + - utility bill add + - statement generation with persisted settlement snapshot +- Adapter: Drizzle-backed repository implementation bound to a household. + +## Domain Rules + +- Domain money and settlement logic stay in `packages/domain` and `packages/application`. +- Application may orchestrate repository calls but cannot import DB/schema modules. +- Bot command handlers may translate Telegram context to use-case inputs, but may not query DB directly. + +## Data Model Changes + +- None. + +## Security and Privacy + +- Authorization remains in bot delivery layer using household membership/admin data from the application service. +- No new secrets or data exposure paths. + +## Observability + +- Existing command-level success/error logging behavior remains unchanged. +- Statement persistence remains deterministic and idempotent per cycle snapshot replacement. + +## Edge Cases and Failure Modes + +- Missing cycle, rent rule, or members should still return deterministic user-facing failures. +- Adapter wiring mistakes should fail in typecheck/build, not at runtime. +- Middleware or bot delivery bugs must not bypass application-level repository boundaries. + +## Test Plan + +- Unit: application service tests with repository stubs. +- Integration: Drizzle adapter exercised through bot/e2e flows. +- E2E: billing smoke test continues to pass after the refactor. + +## Acceptance Criteria + +- [ ] `packages/application` imports ports, not concrete DB code. +- [ ] `apps/bot/src/finance-commands.ts` contains no Drizzle/schema access. +- [ ] Finance command behavior remains green in repo tests and smoke flow. + +## Rollout Plan + +- Introduce finance repository ports first. +- Keep purchase ingestion adapter migration as a follow-up if needed. diff --git a/packages/adapters-db/package.json b/packages/adapters-db/package.json new file mode 100644 index 0000000..1f87dc2 --- /dev/null +++ b/packages/adapters-db/package.json @@ -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" + } +} diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts new file mode 100644 index 0000000..fe04338 --- /dev/null +++ b/packages/adapters-db/src/finance-repository.ts @@ -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 +} { + 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`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 }) + } + } +} diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts new file mode 100644 index 0000000..aa2d2ef --- /dev/null +++ b/packages/adapters-db/src/index.ts @@ -0,0 +1 @@ +export { createDbFinanceRepository } from './finance-repository' diff --git a/packages/adapters-db/tsconfig.json b/packages/adapters-db/tsconfig.json new file mode 100644 index 0000000..d0f1a85 --- /dev/null +++ b/packages/adapters-db/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/application/package.json b/packages/application/package.json index 90013ef..9c0dc24 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -12,6 +12,7 @@ "lint": "oxlint \"src\"" }, "dependencies": { - "@household/domain": "workspace:*" + "@household/domain": "workspace:*", + "@household/ports": "workspace:*" } } diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts new file mode 100644 index 0000000..2aec740 --- /dev/null +++ b/packages/application/src/finance-command-service.test.ts @@ -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 { + return this.member + } + + async listMembers(): Promise { + return this.members + } + + async getOpenCycle(): Promise { + return this.openCycleRecord + } + + async getCycleByPeriod(): Promise { + return this.cycleByPeriodRecord + } + + async getLatestCycle(): Promise { + return this.latestCycleRecord + } + + async openCycle(period: string, currency: 'USD' | 'GEL'): Promise { + this.openCycleRecord = { + id: 'opened-cycle', + period, + currency + } + } + + async closeCycle(): Promise {} + + async saveRentRule(period: string, amountMinor: bigint, currency: 'USD' | 'GEL'): Promise { + this.lastSavedRentRule = { + period, + amountMinor, + currency + } + } + + async addUtilityBill(input: { + cycleId: string + billName: string + amountMinor: bigint + currency: 'USD' | 'GEL' + createdByMemberId: string + }): Promise { + this.lastUtilityBill = input + } + + async getRentRuleForPeriod(): Promise { + return this.rentRule + } + + async getUtilityTotalForCycle(): Promise { + return this.utilityTotal + } + + async listParsedPurchasesForRange(): Promise { + return this.purchases + } + + async replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise { + 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 + ]) + }) +}) diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts new file mode 100644 index 0000000..3562196 --- /dev/null +++ b/packages/application/src/finance-command-service.ts @@ -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 { + if (periodArg) { + return repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString()) + } + + return repository.getLatestCycle() +} + +export interface FinanceCommandService { + getMemberByTelegramUserId(telegramUserId: string): Promise + getOpenCycle(): Promise + openCycle(periodArg: string, currencyArg?: string): Promise + closeCycle(periodArg?: string): Promise + 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 +} + +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') + } + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 8973460..ce8c1fc 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -1,4 +1,5 @@ export { calculateMonthlySettlement } from './settlement-engine' +export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service' export { parsePurchaseMessage, type ParsedPurchaseResult, diff --git a/packages/ports/package.json b/packages/ports/package.json index 171ab24..31405b5 100644 --- a/packages/ports/package.json +++ b/packages/ports/package.json @@ -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:*" } } diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts new file mode 100644 index 0000000..c71c80e --- /dev/null +++ b/packages/ports/src/finance.ts @@ -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 + lines: readonly SettlementSnapshotLineRecord[] +} + +export interface FinanceRepository { + getMemberByTelegramUserId(telegramUserId: string): Promise + listMembers(): Promise + getOpenCycle(): Promise + getCycleByPeriod(period: string): Promise + getLatestCycle(): Promise + openCycle(period: string, currency: CurrencyCode): Promise + closeCycle(cycleId: string, closedAt: Date): Promise + saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise + addUtilityBill(input: { + cycleId: string + billName: string + amountMinor: bigint + currency: CurrencyCode + createdByMemberId: string + }): Promise + getRentRuleForPeriod(period: string): Promise + getUtilityTotalForCycle(cycleId: string): Promise + listParsedPurchasesForRange( + start: Date, + end: Date + ): Promise + replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise +} diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index c1fa326..a9ba74a 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -1 +1,9 @@ -export const portsReady = true +export type { + FinanceCycleRecord, + FinanceMemberRecord, + FinanceParsedPurchaseRecord, + FinanceRentRuleRecord, + FinanceRepository, + SettlementSnapshotLineRecord, + SettlementSnapshotRecord +} from './finance' diff --git a/tsconfig.json b/tsconfig.json index d88127c..6739956 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ { "path": "./packages/contracts" }, { "path": "./packages/observability" }, { "path": "./packages/config" }, - { "path": "./packages/db" } + { "path": "./packages/db" }, + { "path": "./packages/adapters-db" } ] }