mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:44:02 +00:00
feat(miniapp): redesign admin payment management
This commit is contained in:
@@ -1269,4 +1269,76 @@ describe('createFinanceCommandService', () => {
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('generateDashboard rounds rent suggestions in payment period summaries', async () => {
|
||||
const repository = new FinanceRepositoryStub()
|
||||
repository.members = [
|
||||
{
|
||||
id: 'alice',
|
||||
telegramUserId: '1',
|
||||
displayName: 'Alice',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
repository.openCycleRecord = {
|
||||
id: 'cycle-2026-03',
|
||||
period: '2026-03',
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.latestCycleRecord = repository.openCycleRecord
|
||||
repository.cycles = [repository.openCycleRecord]
|
||||
repository.rentRule = {
|
||||
amountMinor: 47256n,
|
||||
currency: 'GEL'
|
||||
}
|
||||
|
||||
const service = createService(repository)
|
||||
const dashboard = await service.generateDashboard('2026-03')
|
||||
const rentSummary = dashboard?.paymentPeriods?.[0]?.kinds.find((kind) => kind.kind === 'rent')
|
||||
|
||||
expect(rentSummary?.unresolvedMembers[0]?.suggestedAmount.toMajorString()).toBe('473.00')
|
||||
})
|
||||
|
||||
test('addPayment rejects duplicate explicit payments when the period is already effectively settled', async () => {
|
||||
const repository = new FinanceRepositoryStub()
|
||||
repository.members = [
|
||||
{
|
||||
id: 'alice',
|
||||
telegramUserId: '1',
|
||||
displayName: 'Alice',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
repository.openCycleRecord = {
|
||||
id: 'cycle-2026-03',
|
||||
period: '2026-03',
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.latestCycleRecord = repository.openCycleRecord
|
||||
repository.cycles = [repository.openCycleRecord]
|
||||
repository.rentRule = {
|
||||
amountMinor: 47256n,
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.paymentRecords = [
|
||||
{
|
||||
id: 'payment-1',
|
||||
cycleId: 'cycle-2026-03',
|
||||
cyclePeriod: '2026-03',
|
||||
memberId: 'alice',
|
||||
kind: 'rent',
|
||||
amountMinor: 47200n,
|
||||
currency: 'GEL',
|
||||
recordedAt: instantFromIso('2026-03-18T12:00:00.000Z')
|
||||
}
|
||||
]
|
||||
|
||||
const service = createService(repository)
|
||||
|
||||
await expect(service.addPayment('alice', 'rent', '10.00', 'GEL', '2026-03')).rejects.toThrow(
|
||||
'Payment period is already settled'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,6 +123,32 @@ export interface FinanceDashboardMemberLine {
|
||||
explanations: readonly string[]
|
||||
}
|
||||
|
||||
export interface FinanceDashboardPaymentMemberSummary {
|
||||
memberId: string
|
||||
displayName: string
|
||||
suggestedAmount: Money
|
||||
baseDue: Money
|
||||
paid: Money
|
||||
remaining: Money
|
||||
effectivelySettled: boolean
|
||||
}
|
||||
|
||||
export interface FinanceDashboardPaymentKindSummary {
|
||||
kind: FinancePaymentKind
|
||||
totalDue: Money
|
||||
totalPaid: Money
|
||||
totalRemaining: Money
|
||||
unresolvedMembers: readonly FinanceDashboardPaymentMemberSummary[]
|
||||
}
|
||||
|
||||
export interface FinanceDashboardPaymentPeriodSummary {
|
||||
period: string
|
||||
utilityTotal: Money
|
||||
hasOverdueBalance: boolean
|
||||
isCurrentPeriod: boolean
|
||||
kinds: readonly FinanceDashboardPaymentKindSummary[]
|
||||
}
|
||||
|
||||
export interface FinanceDashboardLedgerEntry {
|
||||
id: string
|
||||
kind: 'purchase' | 'utility' | 'payment'
|
||||
@@ -171,6 +197,7 @@ export interface FinanceDashboard {
|
||||
rentFxRateMicros: bigint | null
|
||||
rentFxEffectiveDate: string | null
|
||||
members: readonly FinanceDashboardMemberLine[]
|
||||
paymentPeriods?: readonly FinanceDashboardPaymentPeriodSummary[]
|
||||
ledger: readonly FinanceDashboardLedgerEntry[]
|
||||
}
|
||||
|
||||
@@ -259,6 +286,33 @@ interface MutableOverdueSummary {
|
||||
utilities: { amountMinor: bigint; periods: string[] }
|
||||
}
|
||||
|
||||
const PAYMENT_SETTLEMENT_TOLERANCE_MINOR = 200n
|
||||
|
||||
function effectiveRemainingMinor(expectedMinor: bigint, paidMinor: bigint): bigint {
|
||||
const shortfallMinor = expectedMinor - paidMinor
|
||||
|
||||
if (shortfallMinor <= PAYMENT_SETTLEMENT_TOLERANCE_MINOR) {
|
||||
return 0n
|
||||
}
|
||||
|
||||
return shortfallMinor
|
||||
}
|
||||
|
||||
function roundSuggestedPaymentMinor(kind: FinancePaymentKind, amountMinor: bigint): bigint {
|
||||
if (kind !== 'rent') {
|
||||
return amountMinor
|
||||
}
|
||||
|
||||
if (amountMinor <= 0n) {
|
||||
return 0n
|
||||
}
|
||||
|
||||
const wholeMinor = amountMinor / 100n
|
||||
const remainderMinor = amountMinor % 100n
|
||||
|
||||
return (remainderMinor >= 50n ? wholeMinor + 1n : wholeMinor) * 100n
|
||||
}
|
||||
|
||||
function periodFromInstant(instant: Temporal.Instant | null | undefined): string | null {
|
||||
if (!instant) {
|
||||
return null
|
||||
@@ -516,13 +570,19 @@ async function computeMemberOverduePayments(input: {
|
||||
utilities: { amountMinor: 0n, periods: [] }
|
||||
}
|
||||
|
||||
const rentRemainingMinor = line.rentShare.subtract(line.rentPaid).amountMinor
|
||||
const rentRemainingMinor = effectiveRemainingMinor(
|
||||
line.rentShare.amountMinor,
|
||||
line.rentPaid.amountMinor
|
||||
)
|
||||
if (Temporal.PlainDate.compare(localDate, rentDueDate) > 0 && rentRemainingMinor > 0n) {
|
||||
current.rent.amountMinor += rentRemainingMinor
|
||||
current.rent.periods.push(cycle.period)
|
||||
}
|
||||
|
||||
const utilityRemainingMinor = line.utilityShare.subtract(line.utilityPaid).amountMinor
|
||||
const utilityRemainingMinor = effectiveRemainingMinor(
|
||||
line.utilityShare.amountMinor,
|
||||
line.utilityPaid.amountMinor
|
||||
)
|
||||
if (
|
||||
Temporal.PlainDate.compare(localDate, utilitiesDueDate) > 0 &&
|
||||
utilityRemainingMinor > 0n
|
||||
@@ -558,6 +618,161 @@ async function computeMemberOverduePayments(input: {
|
||||
)
|
||||
}
|
||||
|
||||
async function buildPaymentPeriodSummaries(input: {
|
||||
dependencies: FinanceCommandServiceDependencies
|
||||
currentCycle: FinanceCycleRecord
|
||||
members: readonly HouseholdMemberRecord[]
|
||||
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
|
||||
settings: HouseholdBillingSettingsRecord
|
||||
}): Promise<readonly FinanceDashboardPaymentPeriodSummary[]> {
|
||||
const localDate = localDateInTimezone(input.settings.timezone)
|
||||
const memberNameById = new Map(input.members.map((member) => [member.id, member.displayName]))
|
||||
const cycles = (await input.dependencies.repository.listCycles())
|
||||
.filter((cycle) => cycle.period.localeCompare(input.currentCycle.period) <= 0)
|
||||
.sort((left, right) => right.period.localeCompare(left.period))
|
||||
|
||||
const summaries: FinanceDashboardPaymentPeriodSummary[] = []
|
||||
|
||||
for (const cycle of cycles) {
|
||||
const [baseLines, utilityBills] = await Promise.all([
|
||||
buildCycleBaseMemberLines({
|
||||
dependencies: input.dependencies,
|
||||
cycle,
|
||||
members: input.members,
|
||||
memberAbsencePolicies: input.memberAbsencePolicies,
|
||||
settings: input.settings
|
||||
}),
|
||||
input.dependencies.repository.listUtilityBillsForCycle(cycle.id)
|
||||
])
|
||||
|
||||
const utilityTotal = utilityBills.reduce(
|
||||
(sum, bill) => sum.add(Money.fromMinor(bill.amountMinor, bill.currency)),
|
||||
Money.zero(cycle.currency)
|
||||
)
|
||||
const rentDueDate = billingPeriodLockDate(
|
||||
BillingPeriod.fromString(cycle.period),
|
||||
input.settings.rentDueDay
|
||||
)
|
||||
const utilitiesDueDate = billingPeriodLockDate(
|
||||
BillingPeriod.fromString(cycle.period),
|
||||
input.settings.utilitiesDueDay
|
||||
)
|
||||
|
||||
const rentMembers = baseLines.map((line) => {
|
||||
const remainingMinor = effectiveRemainingMinor(
|
||||
line.rentShare.amountMinor,
|
||||
line.rentPaid.amountMinor
|
||||
)
|
||||
const baseDue = line.rentShare
|
||||
return {
|
||||
memberId: line.memberId,
|
||||
displayName: memberNameById.get(line.memberId) ?? line.memberId,
|
||||
suggestedAmount: Money.fromMinor(
|
||||
roundSuggestedPaymentMinor('rent', remainingMinor),
|
||||
cycle.currency
|
||||
),
|
||||
baseDue,
|
||||
paid: line.rentPaid,
|
||||
remaining: Money.fromMinor(remainingMinor, cycle.currency),
|
||||
effectivelySettled: remainingMinor === 0n
|
||||
} satisfies FinanceDashboardPaymentMemberSummary
|
||||
})
|
||||
|
||||
const utilitiesMembers = baseLines.map((line) => {
|
||||
const remainingMinor = effectiveRemainingMinor(
|
||||
line.utilityShare.amountMinor,
|
||||
line.utilityPaid.amountMinor
|
||||
)
|
||||
return {
|
||||
memberId: line.memberId,
|
||||
displayName: memberNameById.get(line.memberId) ?? line.memberId,
|
||||
suggestedAmount: Money.fromMinor(remainingMinor, cycle.currency),
|
||||
baseDue: line.utilityShare,
|
||||
paid: line.utilityPaid,
|
||||
remaining: Money.fromMinor(remainingMinor, cycle.currency),
|
||||
effectivelySettled: remainingMinor === 0n
|
||||
} satisfies FinanceDashboardPaymentMemberSummary
|
||||
})
|
||||
|
||||
const hasOverdueBalance =
|
||||
(Temporal.PlainDate.compare(localDate, rentDueDate) > 0 &&
|
||||
rentMembers.some((member) => !member.effectivelySettled)) ||
|
||||
(Temporal.PlainDate.compare(localDate, utilitiesDueDate) > 0 &&
|
||||
utilitiesMembers.some((member) => !member.effectivelySettled))
|
||||
|
||||
summaries.push({
|
||||
period: cycle.period,
|
||||
utilityTotal,
|
||||
hasOverdueBalance,
|
||||
isCurrentPeriod: cycle.period === input.currentCycle.period,
|
||||
kinds: [
|
||||
{
|
||||
kind: 'rent',
|
||||
totalDue: rentMembers.reduce(
|
||||
(sum, member) => sum.add(member.baseDue),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
totalPaid: rentMembers.reduce(
|
||||
(sum, member) => sum.add(member.paid),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
totalRemaining: rentMembers.reduce(
|
||||
(sum, member) => sum.add(member.remaining),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
unresolvedMembers: rentMembers.filter((member) => !member.effectivelySettled)
|
||||
},
|
||||
{
|
||||
kind: 'utilities',
|
||||
totalDue: utilitiesMembers.reduce(
|
||||
(sum, member) => sum.add(member.baseDue),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
totalPaid: utilitiesMembers.reduce(
|
||||
(sum, member) => sum.add(member.paid),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
totalRemaining: utilitiesMembers.reduce(
|
||||
(sum, member) => sum.add(member.remaining),
|
||||
Money.zero(cycle.currency)
|
||||
),
|
||||
unresolvedMembers: utilitiesMembers.filter((member) => !member.effectivelySettled)
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
async function getCycleKindBaseRemaining(input: {
|
||||
dependencies: FinanceCommandServiceDependencies
|
||||
cycle: FinanceCycleRecord
|
||||
members: readonly HouseholdMemberRecord[]
|
||||
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
|
||||
settings: HouseholdBillingSettingsRecord
|
||||
memberId: string
|
||||
kind: FinancePaymentKind
|
||||
}): Promise<bigint> {
|
||||
const baseLine = (
|
||||
await buildCycleBaseMemberLines({
|
||||
dependencies: input.dependencies,
|
||||
cycle: input.cycle,
|
||||
members: input.members,
|
||||
memberAbsencePolicies: input.memberAbsencePolicies,
|
||||
settings: input.settings
|
||||
})
|
||||
).find((line) => line.memberId === input.memberId)
|
||||
|
||||
if (!baseLine) {
|
||||
return 0n
|
||||
}
|
||||
|
||||
return input.kind === 'rent'
|
||||
? effectiveRemainingMinor(baseLine.rentShare.amountMinor, baseLine.rentPaid.amountMinor)
|
||||
: effectiveRemainingMinor(baseLine.utilityShare.amountMinor, baseLine.utilityPaid.amountMinor)
|
||||
}
|
||||
|
||||
async function resolveAutomaticPaymentTargets(input: {
|
||||
dependencies: FinanceCommandServiceDependencies
|
||||
currentCycle: FinanceCycleRecord
|
||||
@@ -608,8 +823,11 @@ async function resolveAutomaticPaymentTargets(input: {
|
||||
|
||||
const remainingMinor =
|
||||
input.kind === 'rent'
|
||||
? baseLine.rentShare.subtract(baseLine.rentPaid).amountMinor
|
||||
: baseLine.utilityShare.subtract(baseLine.utilityPaid).amountMinor
|
||||
? effectiveRemainingMinor(baseLine.rentShare.amountMinor, baseLine.rentPaid.amountMinor)
|
||||
: effectiveRemainingMinor(
|
||||
baseLine.utilityShare.amountMinor,
|
||||
baseLine.utilityPaid.amountMinor
|
||||
)
|
||||
|
||||
if (remainingMinor <= 0n) {
|
||||
continue
|
||||
@@ -687,13 +905,22 @@ async function buildFinanceDashboard(
|
||||
const previousSnapshotLines = previousCycle
|
||||
? await dependencies.repository.getSettlementSnapshotLines(previousCycle.id)
|
||||
: []
|
||||
const overduePaymentsByMemberId = await computeMemberOverduePayments({
|
||||
dependencies,
|
||||
currentCycle: cycle,
|
||||
members,
|
||||
memberAbsencePolicies,
|
||||
settings
|
||||
})
|
||||
const [overduePaymentsByMemberId, paymentPeriods] = await Promise.all([
|
||||
computeMemberOverduePayments({
|
||||
dependencies,
|
||||
currentCycle: cycle,
|
||||
members,
|
||||
memberAbsencePolicies,
|
||||
settings
|
||||
}),
|
||||
buildPaymentPeriodSummaries({
|
||||
dependencies,
|
||||
currentCycle: cycle,
|
||||
members,
|
||||
memberAbsencePolicies,
|
||||
settings
|
||||
})
|
||||
])
|
||||
const previousUtilityShareByMemberId = new Map(
|
||||
previousSnapshotLines.map((line) => [
|
||||
line.memberId,
|
||||
@@ -1061,6 +1288,7 @@ async function buildFinanceDashboard(
|
||||
rentFxRateMicros: convertedRent.fxRateMicros,
|
||||
rentFxEffectiveDate: convertedRent.fxEffectiveDate,
|
||||
members: dashboardMembers,
|
||||
paymentPeriods,
|
||||
ledger
|
||||
}
|
||||
}
|
||||
@@ -1095,7 +1323,8 @@ async function allocatePaymentPurchaseOverage(input: {
|
||||
}
|
||||
|
||||
const baseAmount = input.kind === 'rent' ? memberLine.rentShare : memberLine.utilityShare
|
||||
let remainingMinor = input.paymentAmount.amountMinor - baseAmount.amountMinor
|
||||
const baseThresholdMinor = roundSuggestedPaymentMinor(input.kind, baseAmount.amountMinor)
|
||||
let remainingMinor = input.paymentAmount.amountMinor - baseThresholdMinor
|
||||
if (remainingMinor <= 0n) {
|
||||
return []
|
||||
}
|
||||
@@ -1588,6 +1817,22 @@ export function createFinanceCommandService(
|
||||
|
||||
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
|
||||
const amount = Money.fromMajor(amountArg, currency)
|
||||
|
||||
if (periodArg) {
|
||||
const explicitRemainingMinor = await getCycleKindBaseRemaining({
|
||||
dependencies,
|
||||
cycle: currentCycle,
|
||||
members,
|
||||
memberAbsencePolicies,
|
||||
settings,
|
||||
memberId,
|
||||
kind
|
||||
})
|
||||
if (explicitRemainingMinor === 0n) {
|
||||
throw new Error('Payment period is already settled')
|
||||
}
|
||||
}
|
||||
|
||||
const paymentTargets = periodArg
|
||||
? [
|
||||
{
|
||||
@@ -1606,6 +1851,15 @@ export function createFinanceCommandService(
|
||||
kind
|
||||
})
|
||||
|
||||
if (
|
||||
!periodArg &&
|
||||
paymentTargets.every(
|
||||
(target) => target.baseRemainingMinor <= 0n && target.cycle.id === currentCycle.id
|
||||
)
|
||||
) {
|
||||
throw new Error('Payment period is already settled')
|
||||
}
|
||||
|
||||
let remainingMinor = amount.amountMinor
|
||||
let firstPayment: Awaited<ReturnType<FinanceRepository['addPaymentRecord']>> | null = null
|
||||
|
||||
|
||||
@@ -37,6 +37,20 @@ function adjustmentApplies(
|
||||
return (policy === 'utilities' && kind === 'utilities') || (policy === 'rent' && kind === 'rent')
|
||||
}
|
||||
|
||||
function roundSuggestedPayment(kind: 'rent' | 'utilities', amount: Money): Money {
|
||||
if (kind !== 'rent' || amount.amountMinor <= 0n) {
|
||||
return amount
|
||||
}
|
||||
|
||||
const wholeMinor = amount.amountMinor / 100n
|
||||
const remainderMinor = amount.amountMinor % 100n
|
||||
|
||||
return Money.fromMinor(
|
||||
(remainderMinor >= 50n ? wholeMinor + 1n : wholeMinor) * 100n,
|
||||
amount.currency
|
||||
)
|
||||
}
|
||||
|
||||
export function buildMemberPaymentGuidance(input: {
|
||||
kind: 'rent' | 'utilities'
|
||||
period: string
|
||||
@@ -48,9 +62,10 @@ export function buildMemberPaymentGuidance(input: {
|
||||
const baseAmount =
|
||||
input.kind === 'rent' ? input.memberLine.rentShare : input.memberLine.utilityShare
|
||||
const purchaseOffset = input.memberLine.purchaseOffset
|
||||
const proposalAmount = adjustmentApplies(policy, input.kind)
|
||||
? baseAmount.add(purchaseOffset)
|
||||
: baseAmount
|
||||
const proposalAmount = roundSuggestedPayment(
|
||||
input.kind,
|
||||
adjustmentApplies(policy, input.kind) ? baseAmount.add(purchaseOffset) : baseAmount
|
||||
)
|
||||
|
||||
const reminderDay =
|
||||
input.kind === 'rent' ? input.settings.rentWarningDay : input.settings.utilitiesReminderDay
|
||||
|
||||
Reference in New Issue
Block a user