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:
2026-03-13 07:41:31 +04:00
parent ba99460a34
commit 588174fa52
5 changed files with 354 additions and 40 deletions

View File

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

View File

@@ -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,

View File

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