feat(finance): support weighted rent split

This commit is contained in:
2026-03-10 02:47:58 +04:00
parent 9c4fe5cb52
commit 6a04b9d7f5
25 changed files with 2639 additions and 11 deletions

View File

@@ -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
}
]

View File

@@ -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),

View File

@@ -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
}
})

View File

@@ -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
}
]

View File

@@ -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
}
}

View File

@@ -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
})
})

View File

@@ -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
}
}

View File

@@ -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)
})
})

View File

@@ -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) {