feat(miniapp): add cycle-level billing controls

This commit is contained in:
2026-03-10 01:53:11 +04:00
parent 565ac277c1
commit 29563c24eb
11 changed files with 1772 additions and 1 deletions

View File

@@ -138,6 +138,62 @@ describe('createFinanceCommandService', () => {
})
})
test('getAdminCycleState prefers the open cycle and returns rent plus utility bills', async () => {
const repository = new FinanceRepositoryStub()
repository.openCycleRecord = {
id: 'cycle-1',
period: '2026-03',
currency: 'USD'
}
repository.latestCycleRecord = {
id: 'cycle-0',
period: '2026-02',
currency: 'USD'
}
repository.rentRule = {
amountMinor: 70000n,
currency: 'USD'
}
repository.utilityBills = [
{
id: 'utility-1',
billName: 'Electricity',
amountMinor: 12000n,
currency: 'USD',
createdByMemberId: 'alice',
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
}
]
const service = createFinanceCommandService(repository)
const result = await service.getAdminCycleState()
expect(result).toEqual({
cycle: {
id: 'cycle-1',
period: '2026-03',
currency: 'USD'
},
rentRule: {
amountMinor: 70000n,
currency: 'USD'
},
utilityBills: [
{
id: 'utility-1',
billName: 'Electricity',
amount: expect.objectContaining({
amountMinor: 12000n,
currency: 'USD'
}),
currency: 'USD',
createdByMemberId: 'alice',
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
}
]
})
})
test('addUtilityBill returns null when no open cycle exists', async () => {
const repository = new FinanceRepositoryStub()
const service = createFinanceCommandService(repository)

View File

@@ -1,6 +1,11 @@
import { createHash } from 'node:crypto'
import type { FinanceCycleRecord, FinanceMemberRecord, FinanceRepository } from '@household/ports'
import type {
FinanceCycleRecord,
FinanceMemberRecord,
FinanceRentRuleRecord,
FinanceRepository
} from '@household/ports'
import {
BillingCycleId,
BillingPeriod,
@@ -79,6 +84,19 @@ export interface FinanceDashboard {
ledger: readonly FinanceDashboardLedgerEntry[]
}
export interface FinanceAdminCycleState {
cycle: FinanceCycleRecord | null
rentRule: FinanceRentRuleRecord | null
utilityBills: readonly {
id: string
billName: string
amount: Money
currency: CurrencyCode
createdByMemberId: string | null
createdAt: Temporal.Instant
}[]
}
async function buildFinanceDashboard(
repository: FinanceRepository,
periodArg?: string
@@ -196,6 +214,7 @@ async function buildFinanceDashboard(
export interface FinanceCommandService {
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
getOpenCycle(): Promise<FinanceCycleRecord | null>
getAdminCycleState(periodArg?: string): Promise<FinanceAdminCycleState>
openCycle(periodArg: string, currencyArg?: string): Promise<FinanceCycleRecord>
closeCycle(periodArg?: string): Promise<FinanceCycleRecord | null>
setRent(
@@ -231,6 +250,38 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
return repository.getOpenCycle()
},
async getAdminCycleState(periodArg) {
const cycle = periodArg
? await repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString())
: ((await repository.getOpenCycle()) ?? (await repository.getLatestCycle()))
if (!cycle) {
return {
cycle: null,
rentRule: null,
utilityBills: []
}
}
const [rentRule, utilityBills] = await Promise.all([
repository.getRentRuleForPeriod(cycle.period),
repository.listUtilityBillsForCycle(cycle.id)
])
return {
cycle,
rentRule,
utilityBills: utilityBills.map((bill) => ({
id: bill.id,
billName: bill.billName,
amount: Money.fromMinor(bill.amountMinor, bill.currency),
currency: bill.currency,
createdByMemberId: bill.createdByMemberId,
createdAt: bill.createdAt
}))
}
},
async openCycle(periodArg, currencyArg) {
const period = BillingPeriod.fromString(periodArg).toString()
const currency = parseCurrency(currencyArg, 'USD')