mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 18:34:02 +00:00
275 lines
8.6 KiB
TypeScript
275 lines
8.6 KiB
TypeScript
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('supports weighted rent split by member weights', () => {
|
|
const input = {
|
|
...fixtureBase(),
|
|
utilitySplitMode: 'equal' as const,
|
|
members: [
|
|
{ memberId: MemberId.from('a'), active: true, rentWeight: 3 },
|
|
{ memberId: MemberId.from('b'), active: true, rentWeight: 2 },
|
|
{ memberId: MemberId.from('c'), active: true, rentWeight: 1 }
|
|
],
|
|
purchases: []
|
|
}
|
|
|
|
const result = calculateMonthlySettlement(input)
|
|
|
|
expect(result.lines.map((line) => line.rentShare.amountMinor)).toEqual([35000n, 23333n, 11667n])
|
|
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([4000n, 4000n, 4000n])
|
|
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)
|
|
})
|
|
|
|
test('throws if rent split is selected with invalid rent weights', () => {
|
|
const input = {
|
|
...fixtureBase(),
|
|
utilitySplitMode: 'equal' as const,
|
|
members: [
|
|
{ memberId: MemberId.from('a'), active: true, rentWeight: 1 },
|
|
{ memberId: MemberId.from('b'), active: true, rentWeight: 0 }
|
|
],
|
|
purchases: []
|
|
}
|
|
|
|
expect(() => calculateMonthlySettlement(input)).toThrow(DomainError)
|
|
})
|
|
|
|
test('excludes away members from utilities and purchases when policy requires it', () => {
|
|
const input = {
|
|
...fixtureBase(),
|
|
utilitySplitMode: 'equal' as const,
|
|
members: [
|
|
{ memberId: MemberId.from('resident-a'), active: true },
|
|
{ memberId: MemberId.from('resident-b'), active: true },
|
|
{
|
|
memberId: MemberId.from('away-member'),
|
|
active: true,
|
|
participatesInUtilities: false,
|
|
participatesInPurchases: false
|
|
}
|
|
],
|
|
purchases: [
|
|
{
|
|
purchaseId: PurchaseEntryId.from('p1'),
|
|
payerId: MemberId.from('resident-a'),
|
|
amount: Money.fromMajor('30.00', 'USD')
|
|
}
|
|
]
|
|
}
|
|
|
|
const result = calculateMonthlySettlement(input)
|
|
|
|
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([6000n, 6000n, 0n])
|
|
expect(result.lines.map((line) => line.purchaseOffset.amountMinor)).toEqual([-1500n, 1500n, 0n])
|
|
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([27834n, 30833n, 23333n])
|
|
})
|
|
|
|
test('excludes inactive members from all future charges', () => {
|
|
const input = {
|
|
...fixtureBase(),
|
|
utilitySplitMode: 'equal' as const,
|
|
members: [
|
|
{ memberId: MemberId.from('resident-a'), active: true },
|
|
{
|
|
memberId: MemberId.from('inactive-member'),
|
|
active: true,
|
|
participatesInRent: false,
|
|
participatesInUtilities: false,
|
|
participatesInPurchases: false
|
|
}
|
|
],
|
|
purchases: []
|
|
}
|
|
|
|
const result = calculateMonthlySettlement(input)
|
|
|
|
expect(result.lines.map((line) => line.rentShare.amountMinor)).toEqual([70000n, 0n])
|
|
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])
|
|
})
|
|
})
|