feat(WHE-20): implement deterministic monthly settlement engine

This commit is contained in:
2026-03-05 04:04:07 +04:00
parent 64c7d41ba9
commit 40c2313f1f
8 changed files with 453 additions and 2 deletions

View File

@@ -1 +1 @@
export const applicationReady = true
export { calculateMonthlySettlement } from './settlement-engine'

View File

@@ -0,0 +1,153 @@
import { describe, expect, test } from 'bun:test'
import {
BillingCycleId,
BillingPeriod,
DomainError,
MemberId,
Money,
PurchaseEntryId
} from '@household/domain'
import { calculateMonthlySettlement } from './settlement-engine'
function fixtureBase() {
return {
cycleId: BillingCycleId.from('cycle-2026-03'),
period: BillingPeriod.fromString('2026-03'),
rent: Money.fromMajor('700.00', 'USD'),
utilities: Money.fromMajor('120.00', 'USD')
}
}
describe('calculateMonthlySettlement', () => {
test('3-member equal split with purchase offsets', () => {
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')
}
]
}
const result = calculateMonthlySettlement(input)
expect(result.lines.map((line) => line.memberId.toString())).toEqual(['alice', 'bob', 'carol'])
expect(result.lines.map((line) => line.rentShare.amountMinor)).toEqual([23334n, 23333n, 23333n])
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([4000n, 4000n, 4000n])
expect(result.lines.map((line) => line.purchaseOffset.amountMinor)).toEqual([
-2000n,
1000n,
1000n
])
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([25334n, 28333n, 28333n])
expect(result.totalDue.amountMinor).toBe(82000n)
})
test('4-member weighted utility split by days', () => {
const input = {
...fixtureBase(),
utilitySplitMode: 'weighted_by_days' as const,
members: [
{ memberId: MemberId.from('a'), active: true, utilityDays: 31 },
{ memberId: MemberId.from('b'), active: true, utilityDays: 31 },
{ memberId: MemberId.from('c'), active: true, utilityDays: 20 },
{ memberId: MemberId.from('d'), active: true, utilityDays: 10 }
],
purchases: []
}
const result = calculateMonthlySettlement(input)
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([
4044n,
4043n,
2609n,
1304n
])
expect(result.lines.map((line) => line.rentShare.amountMinor)).toEqual([
17500n,
17500n,
17500n,
17500n
])
expect(result.totalDue.amountMinor).toBe(82000n)
})
test('5-member scenario with two purchases remains deterministic', () => {
const input = {
...fixtureBase(),
utilitySplitMode: 'equal' as const,
members: [
{ memberId: MemberId.from('m1'), active: true },
{ memberId: MemberId.from('m2'), active: true },
{ memberId: MemberId.from('m3'), active: true },
{ memberId: MemberId.from('m4'), active: true },
{ memberId: MemberId.from('m5'), active: true }
],
purchases: [
{
purchaseId: PurchaseEntryId.from('p1'),
payerId: MemberId.from('m1'),
amount: Money.fromMajor('25.00', 'USD')
},
{
purchaseId: PurchaseEntryId.from('p2'),
payerId: MemberId.from('m4'),
amount: Money.fromMajor('41.00', 'USD')
}
]
}
const result = calculateMonthlySettlement(input)
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([
15220n,
17720n,
17720n,
13620n,
17720n
])
expect(result.totalDue.amountMinor).toBe(82000n)
})
test('throws if weighted split is selected without valid utility days', () => {
const input = {
...fixtureBase(),
utilitySplitMode: 'weighted_by_days' as const,
members: [
{ memberId: MemberId.from('a'), active: true, utilityDays: 31 },
{ memberId: MemberId.from('b'), active: true }
],
purchases: []
}
expect(() => calculateMonthlySettlement(input)).toThrow(DomainError)
})
test('throws if purchase payer is not active', () => {
const input = {
...fixtureBase(),
utilitySplitMode: 'equal' as const,
members: [{ memberId: MemberId.from('a'), active: true }],
purchases: [
{
purchaseId: PurchaseEntryId.from('p1'),
payerId: MemberId.from('ghost'),
amount: Money.fromMajor('10.00', 'USD')
}
]
}
expect(() => calculateMonthlySettlement(input)).toThrow(DomainError)
})
})

View File

@@ -0,0 +1,205 @@
import {
DOMAIN_ERROR_CODE,
DomainError,
Money,
type SettlementInput,
type SettlementMemberInput,
type SettlementMemberLine,
type SettlementResult
} from '@household/domain'
interface ComputationMember {
input: SettlementMemberInput
rentShare: Money
utilityShare: Money
purchaseSharedCost: Money
purchasePaid: Money
}
function createMemberState(
input: SettlementMemberInput,
currency: 'GEL' | 'USD'
): ComputationMember {
return {
input,
rentShare: Money.zero(currency),
utilityShare: Money.zero(currency),
purchaseSharedCost: Money.zero(currency),
purchasePaid: Money.zero(currency)
}
}
function ensureActiveMembers(
members: readonly SettlementMemberInput[]
): readonly SettlementMemberInput[] {
const active = members.filter((member) => member.active)
if (active.length === 0) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
'Settlement must include at least one active member'
)
}
return active
}
function ensureNonNegativeMoney(label: string, value: Money): void {
if (value.isNegative()) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
`${label} must be non-negative`
)
}
}
function sumMoney(values: readonly Money[], currency: 'GEL' | 'USD'): Money {
return values.reduce((sum, current) => sum.add(current), Money.zero(currency))
}
function validateWeightedUtilityDays(members: readonly SettlementMemberInput[]): readonly bigint[] {
const weights = members.map((member) => {
const days = member.utilityDays
if (days === undefined || !Number.isInteger(days) || days <= 0) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
`utilityDays must be a positive integer for member ${member.memberId.toString()}`
)
}
return BigInt(days)
})
const total = weights.reduce((sum, current) => sum + current, 0n)
if (total <= 0n) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
'Total utility day weights must be positive'
)
}
return weights
}
function validateCurrencyConsistency(input: SettlementInput): void {
const currency = input.rent.currency
if (input.utilities.currency !== currency) {
throw new DomainError(
DOMAIN_ERROR_CODE.CURRENCY_MISMATCH,
`Money operation currency mismatch: ${currency} vs ${input.utilities.currency}`
)
}
for (const purchase of input.purchases) {
if (purchase.amount.currency !== currency) {
throw new DomainError(
DOMAIN_ERROR_CODE.CURRENCY_MISMATCH,
`Money operation currency mismatch: ${currency} vs ${purchase.amount.currency}`
)
}
}
}
export function calculateMonthlySettlement(input: SettlementInput): SettlementResult {
validateCurrencyConsistency(input)
ensureNonNegativeMoney('Rent', input.rent)
ensureNonNegativeMoney('Utilities', input.utilities)
const currency = input.rent.currency
const activeMembers = ensureActiveMembers(input.members)
const membersById = new Map<string, ComputationMember>(
activeMembers.map((member) => [member.memberId.toString(), createMemberState(member, currency)])
)
const rentShares = input.rent.splitEvenly(activeMembers.length)
for (const [index, member] of activeMembers.entries()) {
const state = membersById.get(member.memberId.toString())
if (!state) {
continue
}
state.rentShare = rentShares[index] ?? Money.zero(currency)
}
const utilityShares =
input.utilitySplitMode === 'equal'
? input.utilities.splitEvenly(activeMembers.length)
: input.utilities.splitByWeights(validateWeightedUtilityDays(activeMembers))
for (const [index, member] of activeMembers.entries()) {
const state = membersById.get(member.memberId.toString())
if (!state) {
continue
}
state.utilityShare = utilityShares[index] ?? Money.zero(currency)
}
for (const purchase of input.purchases) {
ensureNonNegativeMoney('Purchase amount', purchase.amount)
const payer = membersById.get(purchase.payerId.toString())
if (!payer) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
`Purchase payer is not an active member: ${purchase.payerId.toString()}`
)
}
payer.purchasePaid = payer.purchasePaid.add(purchase.amount)
const purchaseShares = purchase.amount.splitEvenly(activeMembers.length)
for (const [index, member] of activeMembers.entries()) {
const state = membersById.get(member.memberId.toString())
if (!state) {
continue
}
state.purchaseSharedCost = state.purchaseSharedCost.add(
purchaseShares[index] ?? Money.zero(currency)
)
}
}
const lines: SettlementMemberLine[] = activeMembers.map((member) => {
const state = membersById.get(member.memberId.toString())
if (!state) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
`Missing member state: ${member.memberId.toString()}`
)
}
const purchaseOffset = state.purchaseSharedCost.subtract(state.purchasePaid)
const netDue = state.rentShare.add(state.utilityShare).add(purchaseOffset)
return {
memberId: member.memberId,
rentShare: state.rentShare,
utilityShare: state.utilityShare,
purchaseOffset,
netDue,
explanations: [
`rent_share_minor=${state.rentShare.amountMinor.toString()}`,
`utility_share_minor=${state.utilityShare.amountMinor.toString()}`,
`purchase_paid_minor=${state.purchasePaid.amountMinor.toString()}`,
`purchase_shared_minor=${state.purchaseSharedCost.amountMinor.toString()}`
]
}
})
const totalDue = sumMoney(
lines.map((line) => line.netDue),
currency
)
return {
cycleId: input.cycleId,
period: input.period,
lines,
totalDue
}
}