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

@@ -20,18 +20,22 @@ export type UtilityBillDraft = {
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
} }
export type ParticipantShare = {
memberId: string
included: boolean
shareAmountMajor: string
sharePercentage: string
lastUpdatedAt?: number
isAutoCalculated?: boolean
}
export type PurchaseDraft = { export type PurchaseDraft = {
description: string description: string
amountMajor: string amountMajor: string
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
splitMode: 'equal' | 'custom_amounts' splitMode: 'equal' | 'custom_amounts'
splitInputMode: 'equal' | 'exact' | 'percentage' splitInputMode: 'equal' | 'exact' | 'percentage'
participants: { participants: ParticipantShare[]
memberId: string
included: boolean
shareAmountMajor: string
sharePercentage: string
}[]
} }
export type PaymentDraft = { export type PaymentDraft = {
@@ -164,6 +168,181 @@ export function paymentDraftForEntry(entry: MiniAppDashboard['ledger'][number]):
} }
} }
/**
* Rebalance purchase split with "least updated" logic.
* When a participant's share changes, the difference is absorbed by:
* 1. Auto-calculated participants first (not manually entered)
* 2. Then the manually entered participant with oldest lastUpdatedAt
* If adjusted participant would go negative, cascade to next eligible.
* Participants at 0 are automatically excluded.
*/
export function rebalancePurchaseSplit(
draft: PurchaseDraft,
changedMemberId: string | null,
newAmountMajor: string | null
): PurchaseDraft {
const totalMinor = majorStringToMinor(draft.amountMajor)
if (totalMinor <= 0n) return draft
let participants = draft.participants.map((p) => ({ ...p }))
if (changedMemberId !== null && newAmountMajor !== null) {
const changedIdx = participants.findIndex((p) => p.memberId === changedMemberId)
if (changedIdx === -1) return draft
const newAmountMinor = majorStringToMinor(newAmountMajor)
if (newAmountMinor > totalMinor) {
return draft
}
const oldAmountMinor = majorStringToMinor(participants[changedIdx]!.shareAmountMajor || '0')
const delta = oldAmountMinor - newAmountMinor
participants[changedIdx] = {
...participants[changedIdx]!,
shareAmountMajor: newAmountMajor,
lastUpdatedAt: Date.now(),
isAutoCalculated: false
}
if (delta === 0n) {
return recalculatePercentages({ ...draft, participants })
}
const included = participants
.map((p, idx) => ({ ...p, idx }))
.filter((p) => p.included && p.memberId !== changedMemberId)
if (included.length === 0) {
return recalculatePercentages({ ...draft, participants })
}
let remainingDelta = delta
const sorted = [...included].sort((a, b) => {
if (a.isAutoCalculated !== b.isAutoCalculated) {
return a.isAutoCalculated ? -1 : 1
}
const aTime = a.lastUpdatedAt ?? 0
const bTime = b.lastUpdatedAt ?? 0
return aTime - bTime
})
for (const p of sorted) {
if (remainingDelta === 0n) break
const currentMinor = majorStringToMinor(participants[p.idx]!.shareAmountMajor || '0')
let newValue = currentMinor - remainingDelta
if (newValue < 0n) {
remainingDelta = -newValue
newValue = 0n
} else {
remainingDelta = 0n
}
participants[p.idx] = {
...participants[p.idx]!,
shareAmountMajor: minorToMajorString(newValue),
isAutoCalculated: true
}
if (newValue === 0n) {
participants[p.idx]!.included = false
}
}
} else {
const included = participants.map((p, idx) => ({ ...p, idx })).filter((p) => p.included)
if (included.length === 0) {
return { ...draft, participants }
}
const count = BigInt(included.length)
const baseShare = totalMinor / count
const remainder = totalMinor % count
included.forEach((p, i) => {
const share = baseShare + (BigInt(i) < remainder ? 1n : 0n)
const existing = participants[p.idx]!
participants[p.idx] = {
...existing,
shareAmountMajor: minorToMajorString(share),
...(existing.lastUpdatedAt !== undefined ? { lastUpdatedAt: existing.lastUpdatedAt } : {}),
isAutoCalculated: true
}
})
}
return recalculatePercentages({ ...draft, participants })
}
function recalculatePercentages(draft: PurchaseDraft): PurchaseDraft {
const totalMinor = majorStringToMinor(draft.amountMajor)
if (totalMinor <= 0n) return draft
const participants = draft.participants.map((p) => {
if (!p.included) {
return { ...p, sharePercentage: '' }
}
const shareMinor = majorStringToMinor(p.shareAmountMajor || '0')
const percentage = Number((shareMinor * 10000n) / totalMinor) / 100
return {
...p,
sharePercentage: percentage > 0 ? percentage.toFixed(2) : ''
}
})
return { ...draft, participants }
}
export function calculateRemainingToAllocate(draft: PurchaseDraft): bigint {
const totalMinor = majorStringToMinor(draft.amountMajor)
const allocated = draft.participants
.filter((p) => p.included)
.reduce((sum, p) => sum + majorStringToMinor(p.shareAmountMajor || '0'), 0n)
return totalMinor - allocated
}
export type PurchaseDraftValidation = {
valid: boolean
error?: string
remainingMinor: bigint
}
export function validatePurchaseDraft(draft: PurchaseDraft): PurchaseDraftValidation {
if (draft.splitInputMode === 'equal') {
return { valid: true, remainingMinor: 0n }
}
const totalMinor = majorStringToMinor(draft.amountMajor)
const remaining = calculateRemainingToAllocate(draft)
const hasInvalidShare = draft.participants.some((p) => {
if (!p.included) return false
const shareMinor = majorStringToMinor(p.shareAmountMajor || '0')
return shareMinor > totalMinor
})
if (hasInvalidShare) {
return {
valid: false,
error: 'Share cannot exceed total amount',
remainingMinor: remaining
}
}
if (remaining !== 0n) {
return {
valid: false,
error: remaining > 0n ? 'Total shares must equal total amount' : 'Total shares exceed amount',
remainingMinor: remaining
}
}
return { valid: true, remainingMinor: remaining }
}
export function defaultCyclePeriod(): string { export function defaultCyclePeriod(): string {
return new Date().toISOString().slice(0, 7) return new Date().toISOString().slice(0, 7)
} }

View File

@@ -18,9 +18,13 @@ import {
purchaseDraftForEntry, purchaseDraftForEntry,
paymentDraftForEntry, paymentDraftForEntry,
computePaymentPrefill, computePaymentPrefill,
rebalancePurchaseSplit,
validatePurchaseDraft,
calculateRemainingToAllocate,
type PurchaseDraft, type PurchaseDraft,
type PaymentDraft type PaymentDraft
} from '../lib/ledger-helpers' } from '../lib/ledger-helpers'
import { minorToMajorString } from '../lib/money'
import { import {
addMiniAppPurchase, addMiniAppPurchase,
updateMiniAppPurchase, updateMiniAppPurchase,
@@ -361,6 +365,9 @@ export default function LedgerRoute() {
draft: PurchaseDraft, draft: PurchaseDraft,
updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void
) { ) {
const remaining = () => calculateRemainingToAllocate(draft)
const validation = () => validatePurchaseDraft(draft)
return ( return (
<div <div
class="split-configuration" class="split-configuration"
@@ -380,7 +387,8 @@ export default function LedgerRoute() {
updateDraft((d) => { updateDraft((d) => {
const newParticipants = [...d.participants] const newParticipants = [...d.participants]
newParticipants[idx()] = { ...participant, included: checked } newParticipants[idx()] = { ...participant, included: checked }
return { ...d, participants: newParticipants } const updated = { ...d, participants: newParticipants }
return rebalancePurchaseSplit(updated, null, null)
}) })
}} }}
/> />
@@ -392,13 +400,15 @@ export default function LedgerRoute() {
placeholder="0.00" placeholder="0.00"
value={participant.shareAmountMajor} value={participant.shareAmountMajor}
onInput={(e) => { onInput={(e) => {
const value = e.currentTarget.value
updateDraft((d) => { updateDraft((d) => {
const newParticipants = [...d.participants] const newParticipants = [...d.participants]
newParticipants[idx()] = { newParticipants[idx()] = {
...participant, ...participant,
shareAmountMajor: e.currentTarget.value shareAmountMajor: value
} }
return { ...d, participants: newParticipants } const updated = { ...d, participants: newParticipants }
return rebalancePurchaseSplit(updated, participant.memberId, value)
}) })
}} }}
/> />
@@ -410,24 +420,21 @@ export default function LedgerRoute() {
placeholder="%" placeholder="%"
value={participant.sharePercentage} value={participant.sharePercentage}
onInput={(e) => { onInput={(e) => {
const value = e.currentTarget.value
const percentage = parseFloat(value) || 0
const totalMajor = parseFloat(draft.amountMajor) || 0
const exactAmount = (totalMajor * percentage) / 100
const newAmountMajor = exactAmount > 0 ? exactAmount.toFixed(2) : ''
updateDraft((d) => { updateDraft((d) => {
const newParticipants = [...d.participants] const newParticipants = [...d.participants]
newParticipants[idx()] = { newParticipants[idx()] = {
...participant, ...participant,
sharePercentage: e.currentTarget.value sharePercentage: value,
shareAmountMajor: newAmountMajor
} }
const updated = { ...d, participants: newParticipants }
// Calculate exact amount based on percentage return rebalancePurchaseSplit(updated, participant.memberId, newAmountMajor)
const percentage = parseFloat(e.currentTarget.value) || 0
const totalMajor = parseFloat(d.amountMajor) || 0
const exactAmount = (totalMajor * percentage) / 100
const nextParticipant = newParticipants[idx()]
if (nextParticipant) {
nextParticipant.shareAmountMajor =
exactAmount > 0 ? exactAmount.toFixed(2) : ''
}
return { ...d, participants: newParticipants }
}) })
}} }}
/> />
@@ -436,6 +443,19 @@ export default function LedgerRoute() {
) )
}} }}
</For> </For>
<Show when={draft.splitInputMode !== 'equal' && draft.participants.some((p) => p.included)}>
<div
style={{
'font-size': '12px',
'margin-top': '4px',
color: validation().valid ? '#22c55e' : '#ef4444'
}}
>
{validation().error
? validation().error
: `Remaining: ${minorToMajorString(remaining() > 0n ? remaining() : -remaining())} ${draft.currency}`}
</div>
</Show>
</div> </div>
) )
} }
@@ -456,7 +476,28 @@ export default function LedgerRoute() {
<Collapsible title={copy().purchasesTitle} body={copy().purchaseReviewBody} defaultOpen> <Collapsible title={copy().purchasesTitle} body={copy().purchaseReviewBody} defaultOpen>
<Show when={effectiveIsAdmin()}> <Show when={effectiveIsAdmin()}>
<div class="ledger-actions"> <div class="ledger-actions">
<Button variant="primary" size="sm" onClick={() => setAddPurchaseOpen(true)}> <Button
variant="primary"
size="sm"
onClick={() => {
const members = dashboard()?.members ?? []
const currency = (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
setNewPurchase({
description: '',
amountMajor: '',
currency,
splitMode: 'equal',
splitInputMode: 'equal',
participants: members.map((m) => ({
memberId: m.memberId,
included: true,
shareAmountMajor: '',
sharePercentage: ''
}))
})
setAddPurchaseOpen(true)
}}
>
<Plus size={14} /> <Plus size={14} />
{copy().purchaseSaveAction} {copy().purchaseSaveAction}
</Button> </Button>
@@ -592,7 +633,12 @@ export default function LedgerRoute() {
<Button <Button
variant="primary" variant="primary"
loading={addingPurchase()} loading={addingPurchase()}
disabled={!newPurchase().description.trim() || !newPurchase().amountMajor.trim()} disabled={
!newPurchase().description.trim() ||
!newPurchase().amountMajor.trim() ||
(newPurchase().splitInputMode !== 'equal' &&
!validatePurchaseDraft(newPurchase()).valid)
}
onClick={() => void handleAddPurchase()} onClick={() => void handleAddPurchase()}
> >
{copy().purchaseSaveAction} {copy().purchaseSaveAction}
@@ -665,6 +711,7 @@ export default function LedgerRoute() {
<Button <Button
variant="primary" variant="primary"
loading={savingPurchase()} loading={savingPurchase()}
disabled={purchaseDraft() ? !validatePurchaseDraft(purchaseDraft()!).valid : false}
onClick={() => void handleSavePurchase()} onClick={() => void handleSavePurchase()}
> >
{savingPurchase() ? copy().savingPurchase : copy().purchaseSaveAction} {savingPurchase() ? copy().savingPurchase : copy().purchaseSaveAction}

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 { import {
BillingCycleId, BillingCycleId,
BillingPeriod, BillingPeriod,
DomainError,
DOMAIN_ERROR_CODE,
MemberId, MemberId,
Money, Money,
PurchaseEntryId, PurchaseEntryId,
@@ -867,6 +869,27 @@ export function createFinanceCommandService(
) )
const currency = parseCurrency(currencyArg, settings.settlementCurrency) const currency = parseCurrency(currencyArg, settings.settlementCurrency)
const amount = Money.fromMajor(amountArg, currency) 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({ const updated = await repository.updateParsedPurchase({
purchaseId, purchaseId,
amountMinor: amount.amountMinor, amountMinor: amount.amountMinor,

View File

@@ -259,19 +259,18 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
payer.purchasePaid = payer.purchasePaid.add(purchase.amount) payer.purchasePaid = payer.purchasePaid.add(purchase.amount)
const participants = purchaseParticipantMembers(activeMembers, purchase) const participants = purchaseParticipantMembers(activeMembers, purchase)
const explicitShareAmounts = purchase.participants?.map(
(participant) => participant.shareAmount
)
if (explicitShareAmounts && explicitShareAmounts.some((amount) => amount !== undefined)) { // Identify participants with explicit share amounts (lenient read path for legacy data)
if (explicitShareAmounts.some((amount) => amount === undefined)) { const explicitShares =
throw new DomainError( purchase.participants
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT, ?.filter((p) => p.shareAmount !== undefined)
`Purchase custom split must include explicit share amounts for every participant: ${purchase.purchaseId.toString()}` .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) const shareTotal = sumMoney(shares, currency)
if (!shareTotal.equals(purchase.amount)) { if (!shareTotal.equals(purchase.amount)) {
throw new DomainError( throw new DomainError(
@@ -280,15 +279,13 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
) )
} }
for (const [index, member] of participants.entries()) { for (const participant of explicitShares) {
const state = membersById.get(member.memberId.toString()) const state = membersById.get(participant.memberId.toString())
if (!state) { if (!state) {
continue continue
} }
state.purchaseSharedCost = state.purchaseSharedCost.add( state.purchaseSharedCost = state.purchaseSharedCost.add(participant.shareAmount)
shares[index] ?? Money.zero(currency)
)
} }
continue continue