mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
Support rebalance and validate purchase splits
Add ParticipantShare type and client-side rebalance/validation helpers for purchase splits (rebalancePurchaseSplit, recalculatePercentages, calculateRemainingToAllocate, validatePurchaseDraft). Integrate rebalancing and validation into the miniapp ledger UI (auto-adjust shares, percentage<>amount syncing, remaining/error display, prefill participants on new purchase, disable save when invalid). On the backend, tighten input validation in finance-command-service for custom_amounts (require explicit shares and sum match) and make settlement-engine more lenient when reading legacy/malformed custom splits (ignore missing shares, accept explicit subset). Also add a test ensuring dashboard generation doesn't 500 on legacy malformed purchases.
This commit is contained in:
@@ -752,4 +752,72 @@ describe('createFinanceCommandService', () => {
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('generateDashboard should not 500 on legacy malformed custom split purchases (mixed null/explicit shares)', async () => {
|
||||
const repository = new FinanceRepositoryStub()
|
||||
repository.members = [
|
||||
{
|
||||
id: 'alice',
|
||||
telegramUserId: '1',
|
||||
displayName: 'Alice',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
},
|
||||
{
|
||||
id: 'bob',
|
||||
telegramUserId: '2',
|
||||
displayName: 'Bob',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}
|
||||
]
|
||||
repository.openCycleRecord = {
|
||||
id: 'cycle-2026-03',
|
||||
period: '2026-03',
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.rentRule = {
|
||||
amountMinor: 70000n,
|
||||
currency: 'USD'
|
||||
}
|
||||
repository.purchases = [
|
||||
{
|
||||
id: 'malformed-purchase-1',
|
||||
payerMemberId: 'alice',
|
||||
amountMinor: 1000n, // Total is 10.00 GEL
|
||||
currency: 'GEL',
|
||||
description: 'Legacy purchase',
|
||||
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
|
||||
splitMode: 'custom_amounts',
|
||||
participants: [
|
||||
{
|
||||
memberId: 'alice',
|
||||
included: true,
|
||||
shareAmountMinor: 1000n // Explicitly Alice takes full 10.00 GEL
|
||||
},
|
||||
{
|
||||
memberId: 'bob',
|
||||
// Missing included: false, and shareAmountMinor is null
|
||||
// This is the malformed data that used to cause 500
|
||||
shareAmountMinor: null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const service = createService(repository)
|
||||
const dashboard = await service.generateDashboard()
|
||||
|
||||
expect(dashboard).not.toBeNull()
|
||||
const purchase = dashboard?.ledger.find((e) => e.id === 'malformed-purchase-1')
|
||||
expect(purchase?.purchaseSplitMode).toBe('custom_amounts')
|
||||
|
||||
// Bob should be treated as excluded from the settlement calculation
|
||||
const bobLine = dashboard?.members.find((m) => m.memberId === 'bob')
|
||||
expect(bobLine?.purchaseOffset.amountMinor).toBe(0n)
|
||||
|
||||
const aliceLine = dashboard?.members.find((m) => m.memberId === 'alice')
|
||||
// Alice paid 1000n and her share is 1000n -> offset 0n
|
||||
expect(aliceLine?.purchaseOffset.amountMinor).toBe(0n)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
import {
|
||||
BillingCycleId,
|
||||
BillingPeriod,
|
||||
DomainError,
|
||||
DOMAIN_ERROR_CODE,
|
||||
MemberId,
|
||||
Money,
|
||||
PurchaseEntryId,
|
||||
@@ -867,6 +869,27 @@ export function createFinanceCommandService(
|
||||
)
|
||||
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
|
||||
const amount = Money.fromMajor(amountArg, currency)
|
||||
|
||||
if (split?.mode === 'custom_amounts') {
|
||||
if (split.participants.some((p) => p.shareAmountMajor === undefined)) {
|
||||
throw new DomainError(
|
||||
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||
'Purchase custom split must include explicit share amounts for every participant'
|
||||
)
|
||||
}
|
||||
|
||||
const totalMinor = split.participants.reduce(
|
||||
(sum, p) => sum + Money.fromMajor(p.shareAmountMajor!, currency).amountMinor,
|
||||
0n
|
||||
)
|
||||
if (totalMinor !== amount.amountMinor) {
|
||||
throw new DomainError(
|
||||
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||
'Purchase custom split must add up to the full amount'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await repository.updateParsedPurchase({
|
||||
purchaseId,
|
||||
amountMinor: amount.amountMinor,
|
||||
|
||||
@@ -259,19 +259,18 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
|
||||
payer.purchasePaid = payer.purchasePaid.add(purchase.amount)
|
||||
|
||||
const participants = purchaseParticipantMembers(activeMembers, purchase)
|
||||
const explicitShareAmounts = purchase.participants?.map(
|
||||
(participant) => participant.shareAmount
|
||||
)
|
||||
|
||||
if (explicitShareAmounts && explicitShareAmounts.some((amount) => amount !== undefined)) {
|
||||
if (explicitShareAmounts.some((amount) => amount === undefined)) {
|
||||
throw new DomainError(
|
||||
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||
`Purchase custom split must include explicit share amounts for every participant: ${purchase.purchaseId.toString()}`
|
||||
)
|
||||
}
|
||||
// Identify participants with explicit share amounts (lenient read path for legacy data)
|
||||
const explicitShares =
|
||||
purchase.participants
|
||||
?.filter((p) => p.shareAmount !== undefined)
|
||||
.map((p) => ({
|
||||
memberId: p.memberId,
|
||||
shareAmount: p.shareAmount!
|
||||
})) ?? []
|
||||
|
||||
const shares = explicitShareAmounts as readonly Money[]
|
||||
if (explicitShares.length > 0) {
|
||||
const shares = explicitShares.map((p) => p.shareAmount)
|
||||
const shareTotal = sumMoney(shares, currency)
|
||||
if (!shareTotal.equals(purchase.amount)) {
|
||||
throw new DomainError(
|
||||
@@ -280,15 +279,13 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
|
||||
)
|
||||
}
|
||||
|
||||
for (const [index, member] of participants.entries()) {
|
||||
const state = membersById.get(member.memberId.toString())
|
||||
for (const participant of explicitShares) {
|
||||
const state = membersById.get(participant.memberId.toString())
|
||||
if (!state) {
|
||||
continue
|
||||
}
|
||||
|
||||
state.purchaseSharedCost = state.purchaseSharedCost.add(
|
||||
shares[index] ?? Money.zero(currency)
|
||||
)
|
||||
state.purchaseSharedCost = state.purchaseSharedCost.add(participant.shareAmount)
|
||||
}
|
||||
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user