feat(purchase): add per-purchase participant splits

This commit is contained in:
2026-03-11 14:34:27 +04:00
parent 98988159eb
commit 8401688032
26 changed files with 5050 additions and 114 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { instantFromIso, type Instant } from '@household/domain'
import { instantFromIso, Money, type Instant } from '@household/domain'
import type {
ExchangeRateProvider,
FinanceCycleExchangeRateRecord,
@@ -60,6 +60,7 @@ class FinanceRepositoryStub implements FinanceRepository {
} | null = null
replacedSnapshot: SettlementSnapshotRecord | null = null
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
return this.member
@@ -138,8 +139,23 @@ class FinanceRepositoryStub implements FinanceRepository {
return false
}
async updateParsedPurchase() {
return null
async updateParsedPurchase(input) {
this.lastUpdatedPurchaseInput = input
return {
id: input.purchaseId,
payerMemberId: 'alice',
amountMinor: input.amountMinor,
currency: input.currency,
description: input.description,
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
splitMode: input.splitMode ?? 'equal',
participants: input.participants?.map((participant, index) => ({
id: `participant-${index + 1}`,
memberId: participant.memberId,
included: participant.included !== false,
shareAmountMinor: participant.shareAmountMinor
}))
}
}
async deleteParsedPurchase() {
@@ -603,4 +619,133 @@ describe('createFinanceCommandService', () => {
{ memberId: 'carol', utility: 0n, purchaseOffset: 0n }
])
})
test('updatePurchase persists explicit participant splits', async () => {
const repository = new FinanceRepositoryStub()
const service = createService(repository)
const result = await service.updatePurchase('purchase-1', 'Kitchen towels', '30.00', 'GEL', {
mode: 'custom_amounts',
participants: [
{
memberId: 'alice',
shareAmountMajor: '20.00'
},
{
memberId: 'bob',
shareAmountMajor: '10.00'
}
]
})
expect(result).toMatchObject({
purchaseId: 'purchase-1',
currency: 'GEL'
})
expect(repository.lastUpdatedPurchaseInput).toEqual({
purchaseId: 'purchase-1',
amountMinor: 3000n,
currency: 'GEL',
description: 'Kitchen towels',
splitMode: 'custom_amounts',
participants: [
{
memberId: 'alice',
shareAmountMinor: 2000n
},
{
memberId: 'bob',
shareAmountMinor: 1000n
}
]
})
})
test('generateDashboard exposes purchase participant splits in the ledger', 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
},
{
id: 'carol',
telegramUserId: '3',
displayName: 'Carol',
rentShareWeight: 1,
isAdmin: false
}
]
repository.openCycleRecord = {
id: 'cycle-2026-03',
period: '2026-03',
currency: 'GEL'
}
repository.rentRule = {
amountMinor: 90000n,
currency: 'GEL'
}
repository.purchases = [
{
id: 'purchase-1',
payerMemberId: 'alice',
amountMinor: 3000n,
currency: 'GEL',
description: 'Kettle',
occurredAt: instantFromIso('2026-03-10T12:00:00.000Z'),
splitMode: 'custom_amounts',
participants: [
{
memberId: 'alice',
included: true,
shareAmountMinor: 2000n
},
{
memberId: 'bob',
included: true,
shareAmountMinor: 1000n
},
{
memberId: 'carol',
included: false,
shareAmountMinor: null
}
]
}
]
const service = createService(repository)
const dashboard = await service.generateDashboard()
const purchaseEntry = dashboard?.ledger.find((entry) => entry.id === 'purchase-1')
expect(purchaseEntry?.kind).toBe('purchase')
expect(purchaseEntry?.purchaseSplitMode).toBe('custom_amounts')
expect(purchaseEntry?.purchaseParticipants).toEqual([
{
memberId: 'alice',
included: true,
shareAmount: Money.fromMinor(2000n, 'GEL')
},
{
memberId: 'bob',
included: true,
shareAmount: Money.fromMinor(1000n, 'GEL')
},
{
memberId: 'carol',
included: false,
shareAmount: null
}
])
})
})

View File

@@ -129,6 +129,12 @@ export interface FinanceDashboardLedgerEntry {
actorDisplayName: string | null
occurredAt: string | null
paymentKind: FinancePaymentKind | null
purchaseSplitMode?: 'equal' | 'custom_amounts'
purchaseParticipants?: readonly {
memberId: string
included: boolean
shareAmount: Money | null
}[]
}
export interface FinanceDashboard {
@@ -380,11 +386,41 @@ async function buildFinanceDashboard(
participatesInPurchases: member.status === 'active',
rentWeight: member.rentShareWeight
})),
purchases: convertedPurchases.map(({ purchase, converted }) => ({
purchaseId: PurchaseEntryId.from(purchase.id),
payerId: MemberId.from(purchase.payerMemberId),
amount: converted.settlementAmount
}))
purchases: convertedPurchases.map(({ purchase, converted }) => {
const nextPurchase: {
purchaseId: PurchaseEntryId
payerId: MemberId
amount: Money
splitMode: 'equal' | 'custom_amounts'
participants?: {
memberId: MemberId
shareAmount?: Money
}[]
} = {
purchaseId: PurchaseEntryId.from(purchase.id),
payerId: MemberId.from(purchase.payerMemberId),
amount: converted.settlementAmount,
splitMode: purchase.splitMode ?? 'equal'
}
if (purchase.participants) {
nextPurchase.participants = purchase.participants
.filter((participant) => participant.included !== false)
.map((participant) => ({
memberId: MemberId.from(participant.memberId),
...(participant.shareAmountMinor !== null
? {
shareAmount: Money.fromMinor(
participant.shareAmountMinor,
converted.settlementAmount.currency
)
}
: {})
}))
}
return nextPurchase
})
})
await dependencies.repository.replaceSettlementSnapshot({
@@ -465,21 +501,37 @@ async function buildFinanceDashboard(
occurredAt: bill.createdAt.toString(),
paymentKind: null
})),
...convertedPurchases.map(({ purchase, converted }) => ({
id: purchase.id,
kind: 'purchase' as const,
title: purchase.description ?? 'Shared purchase',
memberId: purchase.payerMemberId,
amount: converted.originalAmount,
currency: purchase.currency,
displayAmount: converted.settlementAmount,
displayCurrency: cycle.currency,
fxRateMicros: converted.fxRateMicros,
fxEffectiveDate: converted.fxEffectiveDate,
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
occurredAt: purchase.occurredAt?.toString() ?? null,
paymentKind: null
})),
...convertedPurchases.map(({ purchase, converted }) => {
const entry: FinanceDashboardLedgerEntry = {
id: purchase.id,
kind: 'purchase',
title: purchase.description ?? 'Shared purchase',
memberId: purchase.payerMemberId,
amount: converted.originalAmount,
currency: purchase.currency,
displayAmount: converted.settlementAmount,
displayCurrency: cycle.currency,
fxRateMicros: converted.fxRateMicros,
fxEffectiveDate: converted.fxEffectiveDate,
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
occurredAt: purchase.occurredAt?.toString() ?? null,
paymentKind: null,
purchaseSplitMode: purchase.splitMode ?? 'equal'
}
if (purchase.participants) {
entry.purchaseParticipants = purchase.participants.map((participant) => ({
memberId: participant.memberId,
included: participant.included !== false,
shareAmount:
participant.shareAmountMinor !== null
? Money.fromMinor(participant.shareAmountMinor, converted.settlementAmount.currency)
: null
}))
}
return entry
}),
...paymentRecords.map((payment) => ({
id: payment.id,
kind: 'payment' as const,
@@ -565,7 +617,14 @@ export interface FinanceCommandService {
purchaseId: string,
description: string,
amountArg: string,
currencyArg?: string
currencyArg?: string,
split?: {
mode: 'equal' | 'custom_amounts'
participants: readonly {
memberId: string
shareAmountMajor?: string
}[]
}
): Promise<{
purchaseId: string
amount: Money
@@ -782,7 +841,7 @@ export function createFinanceCommandService(
return repository.deleteUtilityBill(billId)
},
async updatePurchase(purchaseId, description, amountArg, currencyArg) {
async updatePurchase(purchaseId, description, amountArg, currencyArg, split) {
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
dependencies.householdId
)
@@ -792,7 +851,19 @@ export function createFinanceCommandService(
purchaseId,
amountMinor: amount.amountMinor,
currency,
description: description.trim().length > 0 ? description.trim() : null
description: description.trim().length > 0 ? description.trim() : null,
...(split
? {
splitMode: split.mode,
participants: split.participants.map((participant) => ({
memberId: participant.memberId,
shareAmountMinor:
participant.shareAmountMajor !== undefined
? Money.fromMajor(participant.shareAmountMajor, currency).amountMinor
: null
}))
}
: {})
})
if (!updated) {

View File

@@ -237,4 +237,38 @@ describe('calculateMonthlySettlement', () => {
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([12000n, 0n])
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([82000n, 0n])
})
test('supports custom purchase splits across selected participants', () => {
const input = {
...fixtureBase(),
utilitySplitMode: 'equal' as const,
members: [
{ memberId: MemberId.from('alice'), active: true },
{ memberId: MemberId.from('bob'), active: true },
{ memberId: MemberId.from('carol'), active: true }
],
purchases: [
{
purchaseId: PurchaseEntryId.from('p1'),
payerId: MemberId.from('alice'),
amount: Money.fromMajor('30.00', 'USD'),
participants: [
{
memberId: MemberId.from('alice'),
shareAmount: Money.fromMajor('20.00', 'USD')
},
{
memberId: MemberId.from('bob'),
shareAmount: Money.fromMajor('10.00', 'USD')
}
]
}
]
}
const result = calculateMonthlySettlement(input)
expect(result.lines.map((line) => line.purchaseOffset.amountMinor)).toEqual([-1000n, 1000n, 0n])
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([26334n, 28333n, 27333n])
})
})

View File

@@ -91,6 +91,37 @@ function purchaseParticipants(
return participants
}
function purchaseParticipantMembers(
activeMembers: readonly SettlementMemberInput[],
purchase: SettlementInput['purchases'][number]
): readonly SettlementMemberInput[] {
if (!purchase.participants || purchase.participants.length === 0) {
return purchaseParticipants(activeMembers, purchase.amount)
}
const membersById = new Map(activeMembers.map((member) => [member.memberId.toString(), member]))
const participants = purchase.participants.map((participant) => {
const matched = membersById.get(participant.memberId.toString())
if (!matched) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
`Purchase participant is not an active member: ${participant.memberId.toString()}`
)
}
return matched
})
if (participants.length === 0 && purchase.amount.amountMinor > 0n) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
'Settlement must include at least one purchase participant when purchases are present'
)
}
return participants
}
function ensureNonNegativeMoney(label: string, value: Money): void {
if (value.isNegative()) {
throw new DomainError(
@@ -227,7 +258,42 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
payer.purchasePaid = payer.purchasePaid.add(purchase.amount)
const participants = purchaseParticipants(activeMembers, 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()}`
)
}
const shares = explicitShareAmounts as readonly Money[]
const shareTotal = sumMoney(shares, currency)
if (!shareTotal.equals(purchase.amount)) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
`Purchase custom split must add up to the full amount: ${purchase.purchaseId.toString()}`
)
}
for (const [index, member] of participants.entries()) {
const state = membersById.get(member.memberId.toString())
if (!state) {
continue
}
state.purchaseSharedCost = state.purchaseSharedCost.add(
shares[index] ?? Money.zero(currency)
)
}
continue
}
const purchaseShares = purchase.amount.splitEvenly(participants.length)
for (const [index, member] of participants.entries()) {
const state = membersById.get(member.memberId.toString())