mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 21:14: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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user