mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
feat(architecture): add finance repository adapters
This commit is contained in:
@@ -10,9 +10,9 @@
|
|||||||
"lint": "oxlint \"src\""
|
"lint": "oxlint \"src\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@household/adapters-db": "workspace:*",
|
||||||
"@household/application": "workspace:*",
|
"@household/application": "workspace:*",
|
||||||
"@household/db": "workspace:*",
|
"@household/db": "workspace:*",
|
||||||
"@household/domain": "workspace:*",
|
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"grammy": "1.41.1"
|
"grammy": "1.41.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,6 @@
|
|||||||
import { calculateMonthlySettlement } from '@household/application'
|
import type { FinanceCommandService } 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 { Bot, Context } from 'grammy'
|
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[] {
|
function commandArgs(ctx: Context): string[] {
|
||||||
const raw = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
const raw = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
||||||
if (raw.length === 0) {
|
if (raw.length === 0) {
|
||||||
@@ -57,52 +10,17 @@ function commandArgs(ctx: Context): string[] {
|
|||||||
return raw.split(/\s+/).filter(Boolean)
|
return raw.split(/\s+/).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeInputHash(payload: object): string {
|
export function createFinanceCommandsService(financeService: FinanceCommandService): {
|
||||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFinanceCommandsService(
|
|
||||||
databaseUrl: string,
|
|
||||||
config: FinanceCommandsConfig
|
|
||||||
): {
|
|
||||||
register: (bot: Bot) => void
|
register: (bot: Bot) => void
|
||||||
close: () => Promise<void>
|
|
||||||
} {
|
} {
|
||||||
const { db, queryClient } = createDbClient(databaseUrl, {
|
async function requireMember(ctx: Context) {
|
||||||
max: 5,
|
|
||||||
prepare: false
|
|
||||||
})
|
|
||||||
|
|
||||||
async function getMemberByTelegramUserId(
|
|
||||||
telegramUserId: string
|
|
||||||
): Promise<HouseholdMemberData | null> {
|
|
||||||
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<HouseholdMemberData | null> {
|
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
const telegramUserId = ctx.from?.id?.toString()
|
||||||
if (!telegramUserId) {
|
if (!telegramUserId) {
|
||||||
await ctx.reply('Unable to identify sender for this command.')
|
await ctx.reply('Unable to identify sender for this command.')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await getMemberByTelegramUserId(telegramUserId)
|
const member = await financeService.getMemberByTelegramUserId(telegramUserId)
|
||||||
if (!member) {
|
if (!member) {
|
||||||
await ctx.reply('You are not a member of this household.')
|
await ctx.reply('You are not a member of this household.')
|
||||||
return null
|
return null
|
||||||
@@ -111,13 +29,13 @@ export function createFinanceCommandsService(
|
|||||||
return member
|
return member
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireAdmin(ctx: Context): Promise<HouseholdMemberData | null> {
|
async function requireAdmin(ctx: Context) {
|
||||||
const member = await requireMember(ctx)
|
const member = await requireMember(ctx)
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (member.isAdmin !== 1) {
|
if (!member.isAdmin) {
|
||||||
await ctx.reply('Only household admins can use this command.')
|
await ctx.reply('Only household admins can use this command.')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -125,217 +43,6 @@ export function createFinanceCommandsService(
|
|||||||
return member
|
return member
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOpenCycle(): Promise<SettlementCycleData | null> {
|
|
||||||
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<SettlementCycleData | null> {
|
|
||||||
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<string> {
|
|
||||||
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<string>`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 {
|
function register(bot: Bot): void {
|
||||||
bot.command('cycle_open', async (ctx) => {
|
bot.command('cycle_open', async (ctx) => {
|
||||||
const admin = await requireAdmin(ctx)
|
const admin = await requireAdmin(ctx)
|
||||||
@@ -350,21 +57,8 @@ export function createFinanceCommandsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const period = BillingPeriod.fromString(args[0]!).toString()
|
const cycle = await financeService.openCycle(args[0]!, args[1])
|
||||||
const currency = parseCurrency(args[1], 'USD')
|
await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`)
|
||||||
|
|
||||||
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})`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
|
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
|
||||||
}
|
}
|
||||||
@@ -376,21 +70,13 @@ export function createFinanceCommandsService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = commandArgs(ctx)
|
|
||||||
try {
|
try {
|
||||||
const cycle = await getCycleByPeriodOrLatest(args[0])
|
const cycle = await financeService.closeCycle(commandArgs(ctx)[0])
|
||||||
if (!cycle) {
|
if (!cycle) {
|
||||||
await ctx.reply('No cycle found to close.')
|
await ctx.reply('No cycle found to close.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
|
||||||
.update(schema.billingCycles)
|
|
||||||
.set({
|
|
||||||
closedAt: new Date()
|
|
||||||
})
|
|
||||||
.where(eq(schema.billingCycles.id, cycle.id))
|
|
||||||
|
|
||||||
await ctx.reply(`Cycle closed: ${cycle.period}`)
|
await ctx.reply(`Cycle closed: ${cycle.period}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to close cycle: ${(error as Error).message}`)
|
await ctx.reply(`Failed to close cycle: ${(error as Error).message}`)
|
||||||
@@ -410,34 +96,14 @@ export function createFinanceCommandsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const openCycle = await getOpenCycle()
|
const result = await financeService.setRent(args[0]!, args[1], args[2])
|
||||||
const period = args[2] ?? openCycle?.period
|
if (!result) {
|
||||||
if (!period) {
|
|
||||||
await ctx.reply('No period provided and no open cycle found.')
|
await ctx.reply('No period provided and no open cycle found.')
|
||||||
return
|
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(
|
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) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`)
|
await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`)
|
||||||
@@ -457,29 +123,14 @@ export function createFinanceCommandsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const openCycle = await getOpenCycle()
|
const result = await financeService.addUtilityBill(args[0]!, args[1]!, admin.id, args[2])
|
||||||
if (!openCycle) {
|
if (!result) {
|
||||||
await ctx.reply('No open cycle found. Use /cycle_open first.')
|
await ctx.reply('No open cycle found. Use /cycle_open first.')
|
||||||
return
|
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(
|
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) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`)
|
await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`)
|
||||||
@@ -492,16 +143,14 @@ export function createFinanceCommandsService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = commandArgs(ctx)
|
|
||||||
try {
|
try {
|
||||||
const cycle = await getCycleByPeriodOrLatest(args[0])
|
const statement = await financeService.generateStatement(commandArgs(ctx)[0])
|
||||||
if (!cycle) {
|
if (!statement) {
|
||||||
await ctx.reply('No cycle found for statement.')
|
await ctx.reply('No cycle found for statement.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await upsertSettlementSnapshot(cycle)
|
await ctx.reply(statement)
|
||||||
await ctx.reply(message)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to generate statement: ${(error as Error).message}`)
|
await ctx.reply(`Failed to generate statement: ${(error as Error).message}`)
|
||||||
}
|
}
|
||||||
@@ -509,9 +158,6 @@ export function createFinanceCommandsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register
|
||||||
close: async () => {
|
|
||||||
await queryClient.end({ timeout: 5 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { webhookCallback } from 'grammy'
|
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 { createTelegramBot } from './bot'
|
||||||
import { getBotRuntimeConfig } from './config'
|
import { getBotRuntimeConfig } from './config'
|
||||||
import { createFinanceCommandsService } from './finance-commands'
|
|
||||||
import { createOpenAiParserFallback } from './openai-parser-fallback'
|
import { createOpenAiParserFallback } from './openai-parser-fallback'
|
||||||
import {
|
import {
|
||||||
createPurchaseMessageRepository,
|
createPurchaseMessageRepository,
|
||||||
@@ -42,12 +45,15 @@ if (runtime.purchaseTopicIngestionEnabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (runtime.financeCommandsEnabled) {
|
if (runtime.financeCommandsEnabled) {
|
||||||
const financeCommands = createFinanceCommandsService(runtime.databaseUrl!, {
|
const financeRepositoryClient = createDbFinanceRepository(
|
||||||
householdId: runtime.householdId!
|
runtime.databaseUrl!,
|
||||||
})
|
runtime.householdId!
|
||||||
|
)
|
||||||
|
const financeService = createFinanceCommandService(financeRepositoryClient.repository)
|
||||||
|
const financeCommands = createFinanceCommandsService(financeService)
|
||||||
|
|
||||||
financeCommands.register(bot)
|
financeCommands.register(bot)
|
||||||
shutdownTasks.push(financeCommands.close)
|
shutdownTasks.push(financeRepositoryClient.close)
|
||||||
} else {
|
} else {
|
||||||
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.')
|
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.')
|
||||||
}
|
}
|
||||||
|
|||||||
18
bun.lock
18
bun.lock
@@ -15,9 +15,10 @@
|
|||||||
"apps/bot": {
|
"apps/bot": {
|
||||||
"name": "@household/bot",
|
"name": "@household/bot",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@household/adapters-db": "workspace:*",
|
||||||
"@household/application": "workspace:*",
|
"@household/application": "workspace:*",
|
||||||
"@household/db": "workspace:*",
|
"@household/db": "workspace:*",
|
||||||
"@household/domain": "workspace:*",
|
"@household/ports": "workspace:*",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"grammy": "1.41.1",
|
"grammy": "1.41.1",
|
||||||
},
|
},
|
||||||
@@ -36,10 +37,20 @@
|
|||||||
"vite-plugin-solid": "^2.11.8",
|
"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": {
|
"packages/application": {
|
||||||
"name": "@household/application",
|
"name": "@household/application",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@household/domain": "workspace:*",
|
"@household/domain": "workspace:*",
|
||||||
|
"@household/ports": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/config": {
|
"packages/config": {
|
||||||
@@ -67,6 +78,9 @@
|
|||||||
},
|
},
|
||||||
"packages/ports": {
|
"packages/ports": {
|
||||||
"name": "@household/ports",
|
"name": "@household/ports",
|
||||||
|
"dependencies": {
|
||||||
|
"@household/domain": "workspace:*",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -168,6 +182,8 @@
|
|||||||
|
|
||||||
"@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="],
|
"@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/application": ["@household/application@workspace:packages/application"],
|
||||||
|
|
||||||
"@household/bot": ["@household/bot@workspace:apps/bot"],
|
"@household/bot": ["@household/bot@workspace:apps/bot"],
|
||||||
|
|||||||
76
docs/specs/HOUSEBOT-024-repository-adapters.md
Normal file
76
docs/specs/HOUSEBOT-024-repository-adapters.md
Normal file
@@ -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.
|
||||||
20
packages/adapters-db/package.json
Normal file
20
packages/adapters-db/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
329
packages/adapters-db/src/finance-repository.ts
Normal file
329
packages/adapters-db/src/finance-repository.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/adapters-db/src/index.ts
Normal file
1
packages/adapters-db/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { createDbFinanceRepository } from './finance-repository'
|
||||||
7
packages/adapters-db/tsconfig.json
Normal file
7
packages/adapters-db/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"lint": "oxlint \"src\""
|
"lint": "oxlint \"src\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@household/domain": "workspace:*"
|
"@household/domain": "workspace:*",
|
||||||
|
"@household/ports": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { calculateMonthlySettlement } from './settlement-engine'
|
||||||
|
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
||||||
export {
|
export {
|
||||||
parsePurchaseMessage,
|
parsePurchaseMessage,
|
||||||
type ParsedPurchaseResult,
|
type ParsedPurchaseResult,
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
"name": "@household/ports",
|
"name": "@household/ports",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||||
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
||||||
"test": "bun test --pass-with-no-tests",
|
"test": "bun test --pass-with-no-tests",
|
||||||
"lint": "oxlint \"src\""
|
"lint": "oxlint \"src\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@household/domain": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
packages/ports/src/finance.ts
Normal file
68
packages/ports/src/finance.ts
Normal 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>
|
||||||
|
}
|
||||||
@@ -1 +1,9 @@
|
|||||||
export const portsReady = true
|
export type {
|
||||||
|
FinanceCycleRecord,
|
||||||
|
FinanceMemberRecord,
|
||||||
|
FinanceParsedPurchaseRecord,
|
||||||
|
FinanceRentRuleRecord,
|
||||||
|
FinanceRepository,
|
||||||
|
SettlementSnapshotLineRecord,
|
||||||
|
SettlementSnapshotRecord
|
||||||
|
} from './finance'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
{ "path": "./packages/contracts" },
|
{ "path": "./packages/contracts" },
|
||||||
{ "path": "./packages/observability" },
|
{ "path": "./packages/observability" },
|
||||||
{ "path": "./packages/config" },
|
{ "path": "./packages/config" },
|
||||||
{ "path": "./packages/db" }
|
{ "path": "./packages/db" },
|
||||||
|
{ "path": "./packages/adapters-db" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user