mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(finance): support weighted rent split
This commit is contained in:
@@ -216,12 +216,14 @@ describe('createFinanceCommandService', () => {
|
||||
id: 'alice',
|
||||
telegramUserId: '100',
|
||||
displayName: 'Alice',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
},
|
||||
{
|
||||
id: 'bob',
|
||||
telegramUserId: '200',
|
||||
displayName: 'Bob',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -130,7 +130,8 @@ async function buildFinanceDashboard(
|
||||
utilitySplitMode: 'equal',
|
||||
members: members.map((member) => ({
|
||||
memberId: MemberId.from(member.id),
|
||||
active: true
|
||||
active: true,
|
||||
rentWeight: member.rentShareWeight
|
||||
})),
|
||||
purchases: purchases.map((purchase) => ({
|
||||
purchaseId: PurchaseEntryId.from(purchase.id),
|
||||
|
||||
@@ -30,6 +30,7 @@ function createRepositoryStub() {
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
})
|
||||
pendingMembers.set('2', {
|
||||
@@ -94,6 +95,7 @@ function createRepositoryStub() {
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
}
|
||||
members.set(input.telegramUserId, record)
|
||||
@@ -120,6 +122,7 @@ function createRepositoryStub() {
|
||||
displayName: pending.displayName,
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
}
|
||||
members.set(member.telegramUserId, member)
|
||||
@@ -167,7 +170,8 @@ function createRepositoryStub() {
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: input.isActive
|
||||
}),
|
||||
promoteHouseholdAdmin: async () => null
|
||||
promoteHouseholdAdmin: async () => null,
|
||||
updateHouseholdMemberRentShareWeight: async () => null
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -238,6 +242,7 @@ describe('createHouseholdAdminService', () => {
|
||||
displayName: 'Alice',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -100,6 +100,7 @@ function createRepositoryStub() {
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
}
|
||||
members.set(input.telegramUserId, member)
|
||||
@@ -133,6 +134,7 @@ function createRepositoryStub() {
|
||||
displayName: pending.displayName,
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
}
|
||||
},
|
||||
@@ -190,6 +192,9 @@ function createRepositoryStub() {
|
||||
},
|
||||
async promoteHouseholdAdmin() {
|
||||
return null
|
||||
},
|
||||
async updateHouseholdMemberRentShareWeight() {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +324,7 @@ describe('createHouseholdOnboardingService', () => {
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
})
|
||||
@@ -333,13 +339,14 @@ describe('createHouseholdOnboardingService', () => {
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
const service = createHouseholdOnboardingService({ repository })
|
||||
const duplicateRepository = repository as HouseholdConfigurationRepository & {
|
||||
listHouseholdMembersByTelegramUserId: (
|
||||
telegramUserId: string
|
||||
) => Promise<readonly HouseholdMemberRecord[]>
|
||||
) => Promise<readonly (HouseholdMemberRecord & { rentShareWeight: number })[]>
|
||||
}
|
||||
duplicateRepository.listHouseholdMembersByTelegramUserId = async () => [
|
||||
member,
|
||||
@@ -350,6 +357,7 @@ describe('createHouseholdOnboardingService', () => {
|
||||
displayName: 'Stan elsewhere',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ export type HouseholdMiniAppAccess =
|
||||
isAdmin: boolean
|
||||
preferredLocale: SupportedLocale | null
|
||||
householdDefaultLocale: SupportedLocale
|
||||
rentShareWeight: number
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -70,6 +71,7 @@ export interface HouseholdOnboardingService {
|
||||
isAdmin: boolean
|
||||
preferredLocale: SupportedLocale | null
|
||||
householdDefaultLocale: SupportedLocale
|
||||
rentShareWeight: number
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -85,6 +87,7 @@ function toMember(member: HouseholdMemberRecord): {
|
||||
isAdmin: boolean
|
||||
preferredLocale: SupportedLocale | null
|
||||
householdDefaultLocale: SupportedLocale
|
||||
rentShareWeight: number
|
||||
} {
|
||||
return {
|
||||
id: member.id,
|
||||
@@ -92,7 +95,8 @@ function toMember(member: HouseholdMemberRecord): {
|
||||
displayName: member.displayName,
|
||||
isAdmin: member.isAdmin,
|
||||
preferredLocale: member.preferredLocale,
|
||||
householdDefaultLocale: member.householdDefaultLocale
|
||||
householdDefaultLocale: member.householdDefaultLocale,
|
||||
rentShareWeight: member.rentShareWeight
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ function createRepositoryStub() {
|
||||
householdDefaultLocale:
|
||||
[...households.values()].find((household) => household.householdId === input.householdId)
|
||||
?.defaultLocale ?? 'ru',
|
||||
rentShareWeight: input.rentShareWeight ?? existing?.rentShareWeight ?? 1,
|
||||
isAdmin: input.isAdmin === true || existing?.isAdmin === true
|
||||
}
|
||||
members.set(key, next)
|
||||
@@ -216,6 +217,7 @@ function createRepositoryStub() {
|
||||
[...households.values()].find(
|
||||
(household) => household.householdId === pending.householdId
|
||||
)?.defaultLocale ?? 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
}
|
||||
members.set(key, member)
|
||||
@@ -296,6 +298,22 @@ function createRepositoryStub() {
|
||||
}
|
||||
members.set(`${householdId}:${member.telegramUserId}`, next)
|
||||
return next
|
||||
},
|
||||
|
||||
async updateHouseholdMemberRentShareWeight(householdId, memberId, rentShareWeight) {
|
||||
const member = [...members.values()].find(
|
||||
(entry) => entry.householdId === householdId && entry.id === memberId
|
||||
)
|
||||
if (!member) {
|
||||
return null
|
||||
}
|
||||
|
||||
const next = {
|
||||
...member,
|
||||
rentShareWeight
|
||||
}
|
||||
members.set(`${householdId}:${member.telegramUserId}`, next)
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +350,7 @@ describe('createHouseholdSetupService', () => {
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
})
|
||||
})
|
||||
@@ -369,6 +388,7 @@ describe('createHouseholdSetupService', () => {
|
||||
displayName: 'Mia',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
|
||||
@@ -104,7 +105,8 @@ function createRepository(): HouseholdConfigurationRepository {
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: input.isActive
|
||||
}),
|
||||
promoteHouseholdAdmin: async () => null
|
||||
promoteHouseholdAdmin: async () => null,
|
||||
updateHouseholdMemberRentShareWeight: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,25 @@ describe('calculateMonthlySettlement', () => {
|
||||
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(),
|
||||
@@ -150,4 +169,18 @@ describe('calculateMonthlySettlement', () => {
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,6 +82,31 @@ function validateWeightedUtilityDays(members: readonly SettlementMemberInput[]):
|
||||
return weights
|
||||
}
|
||||
|
||||
function validateRentWeights(members: readonly SettlementMemberInput[]): readonly bigint[] {
|
||||
const weights = members.map((member) => {
|
||||
const raw = member.rentWeight ?? 1
|
||||
|
||||
if (!Number.isInteger(raw) || raw <= 0) {
|
||||
throw new DomainError(
|
||||
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||
`rentWeight must be a positive integer for member ${member.memberId.toString()}`
|
||||
)
|
||||
}
|
||||
|
||||
return BigInt(raw)
|
||||
})
|
||||
|
||||
const total = weights.reduce((sum, current) => sum + current, 0n)
|
||||
if (total <= 0n) {
|
||||
throw new DomainError(
|
||||
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||
'Total rent weights must be positive'
|
||||
)
|
||||
}
|
||||
|
||||
return weights
|
||||
}
|
||||
|
||||
function validateCurrencyConsistency(input: SettlementInput): void {
|
||||
const currency = input.rent.currency
|
||||
|
||||
@@ -114,7 +139,7 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
|
||||
activeMembers.map((member) => [member.memberId.toString(), createMemberState(member, currency)])
|
||||
)
|
||||
|
||||
const rentShares = input.rent.splitEvenly(activeMembers.length)
|
||||
const rentShares = input.rent.splitByWeights(validateRentWeights(activeMembers))
|
||||
for (const [index, member] of activeMembers.entries()) {
|
||||
const state = membersById.get(member.memberId.toString())
|
||||
if (!state) {
|
||||
|
||||
Reference in New Issue
Block a user