diff --git a/apps/miniapp/src/lib/ledger-helpers.ts b/apps/miniapp/src/lib/ledger-helpers.ts
index 892193e..ace415d 100644
--- a/apps/miniapp/src/lib/ledger-helpers.ts
+++ b/apps/miniapp/src/lib/ledger-helpers.ts
@@ -20,18 +20,22 @@ export type UtilityBillDraft = {
currency: 'USD' | 'GEL'
}
+export type ParticipantShare = {
+ memberId: string
+ included: boolean
+ shareAmountMajor: string
+ sharePercentage: string
+ lastUpdatedAt?: number
+ isAutoCalculated?: boolean
+}
+
export type PurchaseDraft = {
description: string
amountMajor: string
currency: 'USD' | 'GEL'
splitMode: 'equal' | 'custom_amounts'
splitInputMode: 'equal' | 'exact' | 'percentage'
- participants: {
- memberId: string
- included: boolean
- shareAmountMajor: string
- sharePercentage: string
- }[]
+ participants: ParticipantShare[]
}
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 {
return new Date().toISOString().slice(0, 7)
}
diff --git a/apps/miniapp/src/routes/ledger.tsx b/apps/miniapp/src/routes/ledger.tsx
index f848e6a..5b65fed 100644
--- a/apps/miniapp/src/routes/ledger.tsx
+++ b/apps/miniapp/src/routes/ledger.tsx
@@ -18,9 +18,13 @@ import {
purchaseDraftForEntry,
paymentDraftForEntry,
computePaymentPrefill,
+ rebalancePurchaseSplit,
+ validatePurchaseDraft,
+ calculateRemainingToAllocate,
type PurchaseDraft,
type PaymentDraft
} from '../lib/ledger-helpers'
+import { minorToMajorString } from '../lib/money'
import {
addMiniAppPurchase,
updateMiniAppPurchase,
@@ -361,6 +365,9 @@ export default function LedgerRoute() {
draft: PurchaseDraft,
updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void
) {
+ const remaining = () => calculateRemainingToAllocate(draft)
+ const validation = () => validatePurchaseDraft(draft)
+
return (
{
const newParticipants = [...d.participants]
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"
value={participant.shareAmountMajor}
onInput={(e) => {
+ const value = e.currentTarget.value
updateDraft((d) => {
const newParticipants = [...d.participants]
newParticipants[idx()] = {
...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="%"
value={participant.sharePercentage}
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) => {
const newParticipants = [...d.participants]
newParticipants[idx()] = {
...participant,
- sharePercentage: e.currentTarget.value
+ sharePercentage: value,
+ shareAmountMajor: newAmountMajor
}
-
- // Calculate exact amount based on percentage
- 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 }
+ const updated = { ...d, participants: newParticipants }
+ return rebalancePurchaseSplit(updated, participant.memberId, newAmountMajor)
})
}}
/>
@@ -436,6 +443,19 @@ export default function LedgerRoute() {
)
}}
+
p.included)}>
+
+ {validation().error
+ ? validation().error
+ : `Remaining: ${minorToMajorString(remaining() > 0n ? remaining() : -remaining())} ${draft.currency}`}
+
+
)
}
@@ -456,7 +476,28 @@ export default function LedgerRoute() {
-