diff --git a/bun.lock b/bun.lock index 7f1546b..14c066b 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,9 @@ }, "packages/application": { "name": "@household/application", + "dependencies": { + "@household/domain": "workspace:*", + }, }, "packages/config": { "name": "@household/config", diff --git a/docs/specs/HOUSEBOT-011-monthly-settlement-engine.md b/docs/specs/HOUSEBOT-011-monthly-settlement-engine.md new file mode 100644 index 0000000..67c802f --- /dev/null +++ b/docs/specs/HOUSEBOT-011-monthly-settlement-engine.md @@ -0,0 +1,80 @@ +# HOUSEBOT-011: Monthly Settlement Engine + +## Summary + +Implement a deterministic monthly settlement use-case that computes per-member dues from rent, utilities, and shared purchases. + +## Goals + +- Compute per-member net due amounts deterministically. +- Support utility split modes: equal and weighted-by-days. +- Model purchase offsets where payer reimbursement reduces their due. + +## Non-goals + +- Persistence adapters. +- Telegram command handlers. + +## Scope + +- In: pure settlement use-case logic and tests. +- Out: DB writes, HTTP/bot integration. + +## Interfaces and Contracts + +- Input: `SettlementInput` from `@household/domain`. +- Output: `SettlementResult` with per-member line breakdown. +- Entry point: `calculateMonthlySettlement(input)` in application package. + +## Domain Rules + +- Use integer minor units only via `Money` value object. +- Rent is split evenly among active members. +- Utility split: + - `equal`: evenly among active members. + - `weighted_by_days`: proportional to `utilityDays` for each active member. +- Purchase offset per member: + - `purchaseSharedCost - purchasePaid` +- Net due: + - `rentShare + utilityShare + purchaseOffset` + +## Data Model Changes + +- None. + +## Security and Privacy + +- No PII required in settlement computation. +- Uses internal typed member IDs only. + +## Observability + +- Explanations list on each line provides deterministic calculation fragments. + +## Edge Cases and Failure Modes + +- No active members. +- Weighted utility split with invalid or missing day values. +- Purchase payer not in active members. +- Negative monetary inputs. +- Currency mismatch across input values. + +## Test Plan + +- Unit: + - 3-member equal split with purchase offset. + - 4-member weighted utility split. + - 5-member deterministic fixture with multiple purchases. + - Invalid weighted utility day input. + - Invalid purchase payer. + +## Acceptance Criteria + +- [ ] Equal utility split default implemented. +- [ ] Day-based utility split implemented. +- [ ] Deterministic fixtures for 3-5 roommate scenarios. +- [ ] Explicit errors for invalid settlement inputs. + +## Rollout Plan + +- Merge as dependency for bot command handlers and statement generation flow. diff --git a/packages/application/package.json b/packages/application/package.json index eeb9b79..90013ef 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -2,10 +2,16 @@ "name": "@household/application", "private": true, "type": "module", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "build": "bun build src/index.ts --outdir dist --target bun", "typecheck": "tsgo --project tsconfig.json --noEmit", "test": "bun test --pass-with-no-tests", "lint": "oxlint \"src\"" + }, + "dependencies": { + "@household/domain": "workspace:*" } } diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 771ecaa..944e7de 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -1 +1 @@ -export const applicationReady = true +export { calculateMonthlySettlement } from './settlement-engine' diff --git a/packages/application/src/settlement-engine.test.ts b/packages/application/src/settlement-engine.test.ts new file mode 100644 index 0000000..c0c672e --- /dev/null +++ b/packages/application/src/settlement-engine.test.ts @@ -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) + }) +}) diff --git a/packages/application/src/settlement-engine.ts b/packages/application/src/settlement-engine.ts new file mode 100644 index 0000000..d93d39e --- /dev/null +++ b/packages/application/src/settlement-engine.ts @@ -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( + 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 + } +} diff --git a/packages/domain/package.json b/packages/domain/package.json index b52ee49..1e0af89 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -2,6 +2,9 @@ "name": "@household/domain", "private": true, "type": "module", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "build": "bun build src/index.ts --outdir dist --target bun", "typecheck": "tsgo --project tsconfig.json --noEmit", diff --git a/packages/domain/src/errors.ts b/packages/domain/src/errors.ts index 716c202..2d771bc 100644 --- a/packages/domain/src/errors.ts +++ b/packages/domain/src/errors.ts @@ -15,5 +15,6 @@ export const DOMAIN_ERROR_CODE = { INVALID_SPLIT_PARTS: 'INVALID_SPLIT_PARTS', INVALID_SPLIT_WEIGHTS: 'INVALID_SPLIT_WEIGHTS', INVALID_BILLING_PERIOD: 'INVALID_BILLING_PERIOD', - INVALID_ENTITY_ID: 'INVALID_ENTITY_ID' + INVALID_ENTITY_ID: 'INVALID_ENTITY_ID', + INVALID_SETTLEMENT_INPUT: 'INVALID_SETTLEMENT_INPUT' } as const