mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:44:02 +00:00
feat(purchase): add per-purchase participant splits
This commit is contained in:
@@ -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
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user