mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 01:44:02 +00:00
feat(miniapp): add finance dashboard view
This commit is contained in:
@@ -249,12 +249,34 @@ export function createDbFinanceRepository(
|
||||
return BigInt(rows[0]?.totalMinor ?? '0')
|
||||
},
|
||||
|
||||
async listUtilityBillsForCycle(cycleId) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.utilityBills.id,
|
||||
billName: schema.utilityBills.billName,
|
||||
amountMinor: schema.utilityBills.amountMinor,
|
||||
currency: schema.utilityBills.currency,
|
||||
createdByMemberId: schema.utilityBills.createdByMemberId,
|
||||
createdAt: schema.utilityBills.createdAt
|
||||
})
|
||||
.from(schema.utilityBills)
|
||||
.where(eq(schema.utilityBills.cycleId, cycleId))
|
||||
.orderBy(schema.utilityBills.createdAt)
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
currency: toCurrencyCode(row.currency)
|
||||
}))
|
||||
},
|
||||
|
||||
async listParsedPurchasesForRange(start, end) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.purchaseMessages.id,
|
||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
||||
amountMinor: schema.purchaseMessages.parsedAmountMinor
|
||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||
description: schema.purchaseMessages.parsedItemDescription,
|
||||
occurredAt: schema.purchaseMessages.messageSentAt
|
||||
})
|
||||
.from(schema.purchaseMessages)
|
||||
.where(
|
||||
@@ -270,7 +292,9 @@ export function createDbFinanceRepository(
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
payerMemberId: row.payerMemberId!,
|
||||
amountMinor: row.amountMinor!
|
||||
amountMinor: row.amountMinor!,
|
||||
description: row.description,
|
||||
occurredAt: row.occurredAt
|
||||
}))
|
||||
},
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
rentRule: FinanceRentRuleRecord | null = null
|
||||
utilityTotal: bigint = 0n
|
||||
purchases: readonly FinanceParsedPurchaseRecord[] = []
|
||||
utilityBills: readonly {
|
||||
id: string
|
||||
billName: string
|
||||
amountMinor: bigint
|
||||
currency: 'USD' | 'GEL'
|
||||
createdByMemberId: string | null
|
||||
createdAt: Date
|
||||
}[] = []
|
||||
|
||||
lastSavedRentRule: {
|
||||
period: string
|
||||
@@ -93,6 +101,10 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
return this.utilityTotal
|
||||
}
|
||||
|
||||
async listUtilityBillsForCycle() {
|
||||
return this.utilityBills
|
||||
}
|
||||
|
||||
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
|
||||
return this.purchases
|
||||
}
|
||||
@@ -161,17 +173,33 @@ describe('createFinanceCommandService', () => {
|
||||
currency: 'USD'
|
||||
}
|
||||
repository.utilityTotal = 12000n
|
||||
repository.utilityBills = [
|
||||
{
|
||||
id: 'utility-1',
|
||||
billName: 'Electricity',
|
||||
amountMinor: 12000n,
|
||||
currency: 'USD',
|
||||
createdByMemberId: 'alice',
|
||||
createdAt: new Date('2026-03-12T12:00:00.000Z')
|
||||
}
|
||||
]
|
||||
repository.purchases = [
|
||||
{
|
||||
id: 'purchase-1',
|
||||
payerMemberId: 'alice',
|
||||
amountMinor: 3000n
|
||||
amountMinor: 3000n,
|
||||
description: 'Soap',
|
||||
occurredAt: new Date('2026-03-12T11:00:00.000Z')
|
||||
}
|
||||
]
|
||||
|
||||
const service = createFinanceCommandService(repository)
|
||||
const dashboard = await service.generateDashboard()
|
||||
const statement = await service.generateStatement()
|
||||
|
||||
expect(dashboard).not.toBeNull()
|
||||
expect(dashboard?.members.map((line) => line.netDue.amountMinor)).toEqual([39500n, 42500n])
|
||||
expect(dashboard?.ledger.map((entry) => entry.title)).toEqual(['Soap', 'Electricity'])
|
||||
expect(statement).toBe(
|
||||
[
|
||||
'Statement for 2026-03',
|
||||
|
||||
@@ -47,6 +47,147 @@ async function getCycleByPeriodOrLatest(
|
||||
return repository.getLatestCycle()
|
||||
}
|
||||
|
||||
export interface FinanceDashboardMemberLine {
|
||||
memberId: string
|
||||
displayName: string
|
||||
rentShare: Money
|
||||
utilityShare: Money
|
||||
purchaseOffset: Money
|
||||
netDue: Money
|
||||
explanations: readonly string[]
|
||||
}
|
||||
|
||||
export interface FinanceDashboardLedgerEntry {
|
||||
id: string
|
||||
kind: 'purchase' | 'utility'
|
||||
title: string
|
||||
amount: Money
|
||||
actorDisplayName: string | null
|
||||
occurredAt: string | null
|
||||
}
|
||||
|
||||
export interface FinanceDashboard {
|
||||
period: string
|
||||
currency: CurrencyCode
|
||||
totalDue: Money
|
||||
members: readonly FinanceDashboardMemberLine[]
|
||||
ledger: readonly FinanceDashboardLedgerEntry[]
|
||||
}
|
||||
|
||||
async function buildFinanceDashboard(
|
||||
repository: FinanceRepository,
|
||||
periodArg?: string
|
||||
): Promise<FinanceDashboard | null> {
|
||||
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 utilityBills = await repository.listUtilityBillsForCycle(cycle.id)
|
||||
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: 'finance-service'
|
||||
},
|
||||
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 dashboardMembers = settlement.lines.map((line) => ({
|
||||
memberId: line.memberId.toString(),
|
||||
displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(),
|
||||
rentShare: line.rentShare,
|
||||
utilityShare: line.utilityShare,
|
||||
purchaseOffset: line.purchaseOffset,
|
||||
netDue: line.netDue,
|
||||
explanations: line.explanations
|
||||
}))
|
||||
|
||||
const ledger: FinanceDashboardLedgerEntry[] = [
|
||||
...utilityBills.map((bill) => ({
|
||||
id: bill.id,
|
||||
kind: 'utility' as const,
|
||||
title: bill.billName,
|
||||
amount: Money.fromMinor(bill.amountMinor, bill.currency),
|
||||
actorDisplayName: bill.createdByMemberId
|
||||
? (memberNameById.get(bill.createdByMemberId) ?? null)
|
||||
: null,
|
||||
occurredAt: bill.createdAt.toISOString()
|
||||
})),
|
||||
...purchases.map((purchase) => ({
|
||||
id: purchase.id,
|
||||
kind: 'purchase' as const,
|
||||
title: purchase.description ?? 'Shared purchase',
|
||||
amount: Money.fromMinor(purchase.amountMinor, rentRule.currency),
|
||||
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
|
||||
occurredAt: purchase.occurredAt?.toISOString() ?? null
|
||||
}))
|
||||
].sort((left, right) => {
|
||||
if (left.occurredAt === right.occurredAt) {
|
||||
return left.title.localeCompare(right.title)
|
||||
}
|
||||
|
||||
return (left.occurredAt ?? '').localeCompare(right.occurredAt ?? '')
|
||||
})
|
||||
|
||||
return {
|
||||
period: cycle.period,
|
||||
currency: rentRule.currency,
|
||||
totalDue: settlement.totalDue,
|
||||
members: dashboardMembers,
|
||||
ledger
|
||||
}
|
||||
}
|
||||
|
||||
export interface FinanceCommandService {
|
||||
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
|
||||
getOpenCycle(): Promise<FinanceCycleRecord | null>
|
||||
@@ -71,6 +212,7 @@ export interface FinanceCommandService {
|
||||
currency: CurrencyCode
|
||||
period: string
|
||||
} | null>
|
||||
generateDashboard(periodArg?: string): Promise<FinanceDashboard | null>
|
||||
generateStatement(periodArg?: string): Promise<string | null>
|
||||
}
|
||||
|
||||
@@ -155,79 +297,24 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
||||
},
|
||||
|
||||
async generateStatement(periodArg) {
|
||||
const cycle = await getCycleByPeriodOrLatest(repository, periodArg)
|
||||
if (!cycle) {
|
||||
const dashboard = await buildFinanceDashboard(repository, periodArg)
|
||||
if (!dashboard) {
|
||||
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}`
|
||||
const statementLines = dashboard.members.map((line) => {
|
||||
return `- ${line.displayName}: ${line.netDue.toMajorString()} ${dashboard.currency}`
|
||||
})
|
||||
|
||||
return [
|
||||
`Statement for ${cycle.period}`,
|
||||
`Statement for ${dashboard.period}`,
|
||||
...statementLines,
|
||||
`Total: ${settlement.totalDue.toMajorString()} ${rentRule.currency}`
|
||||
`Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`
|
||||
].join('\n')
|
||||
},
|
||||
|
||||
generateDashboard(periodArg) {
|
||||
return buildFinanceDashboard(repository, periodArg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,17 @@ export interface FinanceParsedPurchaseRecord {
|
||||
id: string
|
||||
payerMemberId: string
|
||||
amountMinor: bigint
|
||||
description: string | null
|
||||
occurredAt: Date | null
|
||||
}
|
||||
|
||||
export interface FinanceUtilityBillRecord {
|
||||
id: string
|
||||
billName: string
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
createdByMemberId: string | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface SettlementSnapshotLineRecord {
|
||||
@@ -60,6 +71,7 @@ export interface FinanceRepository {
|
||||
}): Promise<void>
|
||||
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
|
||||
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
||||
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
||||
listParsedPurchasesForRange(
|
||||
start: Date,
|
||||
end: Date
|
||||
|
||||
@@ -11,6 +11,7 @@ export type {
|
||||
FinanceParsedPurchaseRecord,
|
||||
FinanceRentRuleRecord,
|
||||
FinanceRepository,
|
||||
FinanceUtilityBillRecord,
|
||||
SettlementSnapshotLineRecord,
|
||||
SettlementSnapshotRecord
|
||||
} from './finance'
|
||||
|
||||
Reference in New Issue
Block a user