feat(miniapp): add finance dashboard view

This commit is contained in:
2026-03-08 22:40:49 +04:00
parent f8478b717b
commit c5c356f2b2
17 changed files with 901 additions and 100 deletions

View File

@@ -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)
}
}
}