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

@@ -159,6 +159,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null, preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }),
getHouseholdMember: async () => null, getHouseholdMember: async () => null,
@@ -171,6 +172,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false isAdmin: false
} }
], ],
@@ -191,6 +193,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
displayName: 'Stan', displayName: 'Stan',
preferredLocale: locale, preferredLocale: locale,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false isAdmin: false
}), }),
getHouseholdBillingSettings: async (householdId) => ({ getHouseholdBillingSettings: async (householdId) => ({
@@ -222,7 +225,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
sortOrder: input.sortOrder, sortOrder: input.sortOrder,
isActive: input.isActive isActive: input.isActive
}), }),
promoteHouseholdAdmin: async () => null promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null
} }
} }

View File

@@ -28,6 +28,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
displayName: string displayName: string
preferredLocale: 'en' | 'ru' | null preferredLocale: 'en' | 'ru' | null
householdDefaultLocale: 'en' | 'ru' householdDefaultLocale: 'en' | 'ru'
rentShareWeight: number
isAdmin: boolean isAdmin: boolean
} }
>() >()
@@ -97,6 +98,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null, preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
members.set(input.telegramUserId, member) members.set(input.telegramUserId, member)
@@ -122,6 +124,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
displayName: pending.displayName, displayName: pending.displayName,
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
members.set(pending.telegramUserId, member) members.set(pending.telegramUserId, member)
@@ -141,6 +144,15 @@ function onboardingRepository(): HouseholdConfigurationRepository {
} }
: null : null
}, },
updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) => {
const member = [...members.values()].find((entry) => entry.id === memberId)
return member
? {
...member,
rentShareWeight
}
: null
},
getHouseholdBillingSettings: async (householdId) => ({ getHouseholdBillingSettings: async (householdId) => ({
householdId, householdId,
rentAmountMinor: null, rentAmountMinor: null,

View File

@@ -70,6 +70,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null, preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }),
getHouseholdMember: async () => null, getHouseholdMember: async () => null,
@@ -111,6 +112,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true isAdmin: true
} }
], ],
@@ -121,7 +123,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
defaultLocale: locale defaultLocale: locale
}), }),
updateMemberPreferredLocale: async () => null, updateMemberPreferredLocale: async () => null,
promoteHouseholdAdmin: async () => null promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null
} }
} }

View File

@@ -24,6 +24,7 @@ function repository(
id: 'member-1', id: 'member-1',
telegramUserId: '123456', telegramUserId: '123456',
displayName: 'Stan', displayName: 'Stan',
rentShareWeight: 1,
isAdmin: true isAdmin: true
} }
], ],
@@ -124,6 +125,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null, preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }),
getHouseholdMember: async () => null, getHouseholdMember: async () => null,
@@ -165,7 +167,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
sortOrder: input.sortOrder, sortOrder: input.sortOrder,
isActive: input.isActive isActive: input.isActive
}), }),
promoteHouseholdAdmin: async () => null promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null
} }
} }
@@ -177,6 +180,7 @@ describe('createMiniAppDashboardHandler', () => {
id: 'member-1', id: 'member-1',
telegramUserId: '123456', telegramUserId: '123456',
displayName: 'Stan', displayName: 'Stan',
rentShareWeight: 1,
isAdmin: true isAdmin: true
}) })
) )
@@ -189,6 +193,7 @@ describe('createMiniAppDashboardHandler', () => {
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true isAdmin: true
} }
] ]
@@ -255,6 +260,7 @@ describe('createMiniAppDashboardHandler', () => {
id: 'member-1', id: 'member-1',
telegramUserId: '123456', telegramUserId: '123456',
displayName: 'Stan', displayName: 'Stan',
rentShareWeight: 1,
isAdmin: true isAdmin: true
}) })
) )
@@ -267,6 +273,7 @@ describe('createMiniAppDashboardHandler', () => {
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true isAdmin: true
} }
] ]

View File

@@ -33,6 +33,7 @@ function repository(): HouseholdConfigurationRepository {
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: true isAdmin: true
} }
], ],
@@ -45,6 +46,7 @@ function repository(): HouseholdConfigurationRepository {
displayName: 'Mia', displayName: 'Mia',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: false isAdmin: false
} }
] ]
@@ -92,6 +94,7 @@ function repository(): HouseholdConfigurationRepository {
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null, preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}, },
getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null, getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null,
@@ -154,7 +157,8 @@ function repository(): HouseholdConfigurationRepository {
sortOrder: input.sortOrder, sortOrder: input.sortOrder,
isActive: input.isActive isActive: input.isActive
}), }),
promoteHouseholdAdmin: async () => null promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null
} }
} }

View File

@@ -0,0 +1,48 @@
# HOUSEBOT-077 Custom Rent Split Weights
## Summary
Support unequal room rents by storing a deterministic rent-share weight per active household member and using those weights in monthly settlement calculations.
## Goals
- Preserve equal split as the default when all member weights are `1`.
- Allow household admins to edit per-member rent weights from the mini app.
- Keep settlement math deterministic and money-safe.
- Reflect weighted rent shares consistently in statements and dashboard views.
## Non-goals
- Per-cycle rent weights.
- Free-form percentage editing.
- Automatic square-meter or room-type calculations.
## Scope
- In: member-level `rentShareWeight`, settlement-engine support, admin API/UI, tests.
- Out: historical backfill UI, move-in/move-out proration logic, rent history analytics.
## Data Model Changes
- Add `members.rent_share_weight integer not null default 1`.
- Existing members migrate to `1`.
## Domain Rules
- Rent weights must be positive integers.
- Active members participate in rent splitting according to their weight.
- Utility splitting remains independent from rent splitting.
- The same input must always produce the same minor-unit allocation.
## Interfaces
- Household admin mini app payload includes member `rentShareWeight`.
- Admin write endpoint updates one member rent weight at a time.
## Acceptance Criteria
- [ ] Settlement engine uses weighted rent shares.
- [ ] Equal split still holds when all weights are `1`.
- [ ] Admins can edit member rent weights in the mini app.
- [ ] Dashboard and statements reflect the new rent shares.
- [ ] Validation rejects zero or negative weights.

View File

@@ -38,6 +38,7 @@ export function createDbFinanceRepository(
id: schema.members.id, id: schema.members.id,
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin isAdmin: schema.members.isAdmin
}) })
.from(schema.members) .from(schema.members)
@@ -66,6 +67,7 @@ export function createDbFinanceRepository(
id: schema.members.id, id: schema.members.id,
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin isAdmin: schema.members.isAdmin
}) })
.from(schema.members) .from(schema.members)

View File

@@ -115,6 +115,7 @@ function toHouseholdMemberRecord(row: {
displayName: string displayName: string
preferredLocale: string | null preferredLocale: string | null
defaultLocale: string defaultLocale: string
rentShareWeight: number
isAdmin: number isAdmin: number
}): HouseholdMemberRecord { }): HouseholdMemberRecord {
const householdDefaultLocale = normalizeSupportedLocale(row.defaultLocale) const householdDefaultLocale = normalizeSupportedLocale(row.defaultLocale)
@@ -129,6 +130,7 @@ function toHouseholdMemberRecord(row: {
displayName: row.displayName, displayName: row.displayName,
preferredLocale: normalizeSupportedLocale(row.preferredLocale), preferredLocale: normalizeSupportedLocale(row.preferredLocale),
householdDefaultLocale, householdDefaultLocale,
rentShareWeight: row.rentShareWeight,
isAdmin: row.isAdmin === 1 isAdmin: row.isAdmin === 1
} }
} }
@@ -766,6 +768,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null, preferredLocale: input.preferredLocale ?? null,
rentShareWeight: input.rentShareWeight ?? 1,
isAdmin: input.isAdmin ? 1 : 0 isAdmin: input.isAdmin ? 1 : 0
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
@@ -773,6 +776,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
set: { set: {
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? schema.members.preferredLocale, preferredLocale: input.preferredLocale ?? schema.members.preferredLocale,
rentShareWeight: input.rentShareWeight ?? schema.members.rentShareWeight,
...(input.isAdmin ...(input.isAdmin
? { ? {
isAdmin: 1 isAdmin: 1
@@ -786,6 +790,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
preferredLocale: schema.members.preferredLocale, preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin isAdmin: schema.members.isAdmin
}) })
@@ -813,6 +818,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
preferredLocale: schema.members.preferredLocale, preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
defaultLocale: schema.households.defaultLocale, defaultLocale: schema.households.defaultLocale,
isAdmin: schema.members.isAdmin isAdmin: schema.members.isAdmin
}) })
@@ -838,6 +844,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
preferredLocale: schema.members.preferredLocale, preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
defaultLocale: schema.households.defaultLocale, defaultLocale: schema.households.defaultLocale,
isAdmin: schema.members.isAdmin isAdmin: schema.members.isAdmin
}) })
@@ -1012,6 +1019,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
preferredLocale: schema.members.preferredLocale, preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
defaultLocale: schema.households.defaultLocale, defaultLocale: schema.households.defaultLocale,
isAdmin: schema.members.isAdmin isAdmin: schema.members.isAdmin
}) })
@@ -1082,6 +1090,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
telegramUserId: pending.telegramUserId, telegramUserId: pending.telegramUserId,
displayName: pending.displayName, displayName: pending.displayName,
preferredLocale: normalizeSupportedLocale(pending.languageCode), preferredLocale: normalizeSupportedLocale(pending.languageCode),
rentShareWeight: 1,
isAdmin: input.isAdmin ? 1 : 0 isAdmin: input.isAdmin ? 1 : 0
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
@@ -1103,6 +1112,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
preferredLocale: schema.members.preferredLocale, preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin isAdmin: schema.members.isAdmin
}) })
@@ -1174,6 +1184,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
preferredLocale: schema.members.preferredLocale, preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin isAdmin: schema.members.isAdmin
}) })
@@ -1206,6 +1217,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
preferredLocale: schema.members.preferredLocale, preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin isAdmin: schema.members.isAdmin
}) })
@@ -1219,6 +1231,39 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
throw new Error('Failed to resolve household chat after admin promotion') throw new Error('Failed to resolve household chat after admin promotion')
} }
return toHouseholdMemberRecord({
...row,
defaultLocale: household.defaultLocale
})
},
async updateHouseholdMemberRentShareWeight(householdId, memberId, rentShareWeight) {
const rows = await db
.update(schema.members)
.set({
rentShareWeight
})
.where(and(eq(schema.members.householdId, householdId), eq(schema.members.id, memberId)))
.returning({
id: schema.members.id,
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin
})
const row = rows[0]
if (!row) {
return null
}
const household = await this.getHouseholdChatByHouseholdId(householdId)
if (!household) {
throw new Error('Failed to resolve household chat after rent weight update')
}
return toHouseholdMemberRecord({ return toHouseholdMemberRecord({
...row, ...row,
defaultLocale: household.defaultLocale defaultLocale: household.defaultLocale

View File

@@ -216,12 +216,14 @@ describe('createFinanceCommandService', () => {
id: 'alice', id: 'alice',
telegramUserId: '100', telegramUserId: '100',
displayName: 'Alice', displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true isAdmin: true
}, },
{ {
id: 'bob', id: 'bob',
telegramUserId: '200', telegramUserId: '200',
displayName: 'Bob', displayName: 'Bob',
rentShareWeight: 1,
isAdmin: false isAdmin: false
} }
] ]

View File

@@ -130,7 +130,8 @@ async function buildFinanceDashboard(
utilitySplitMode: 'equal', utilitySplitMode: 'equal',
members: members.map((member) => ({ members: members.map((member) => ({
memberId: MemberId.from(member.id), memberId: MemberId.from(member.id),
active: true active: true,
rentWeight: member.rentShareWeight
})), })),
purchases: purchases.map((purchase) => ({ purchases: purchases.map((purchase) => ({
purchaseId: PurchaseEntryId.from(purchase.id), purchaseId: PurchaseEntryId.from(purchase.id),

View File

@@ -30,6 +30,7 @@ function createRepositoryStub() {
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: true isAdmin: true
}) })
pendingMembers.set('2', { pendingMembers.set('2', {
@@ -94,6 +95,7 @@ function createRepositoryStub() {
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null, preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
members.set(input.telegramUserId, record) members.set(input.telegramUserId, record)
@@ -120,6 +122,7 @@ function createRepositoryStub() {
displayName: pending.displayName, displayName: pending.displayName,
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
members.set(member.telegramUserId, member) members.set(member.telegramUserId, member)
@@ -167,7 +170,8 @@ function createRepositoryStub() {
sortOrder: input.sortOrder, sortOrder: input.sortOrder,
isActive: input.isActive isActive: input.isActive
}), }),
promoteHouseholdAdmin: async () => null promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null
} }
return { return {
@@ -238,6 +242,7 @@ describe('createHouseholdAdminService', () => {
displayName: 'Alice', displayName: 'Alice',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false isAdmin: false
} }
}) })

View File

@@ -100,6 +100,7 @@ function createRepositoryStub() {
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null, preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
members.set(input.telegramUserId, member) members.set(input.telegramUserId, member)
@@ -133,6 +134,7 @@ function createRepositoryStub() {
displayName: pending.displayName, displayName: pending.displayName,
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
}, },
@@ -190,6 +192,9 @@ function createRepositoryStub() {
}, },
async promoteHouseholdAdmin() { async promoteHouseholdAdmin() {
return null return null
},
async updateHouseholdMemberRentShareWeight() {
return null
} }
} }
@@ -319,6 +324,7 @@ describe('createHouseholdOnboardingService', () => {
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true isAdmin: true
} }
}) })
@@ -333,13 +339,14 @@ describe('createHouseholdOnboardingService', () => {
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true isAdmin: true
} }
const service = createHouseholdOnboardingService({ repository }) const service = createHouseholdOnboardingService({ repository })
const duplicateRepository = repository as HouseholdConfigurationRepository & { const duplicateRepository = repository as HouseholdConfigurationRepository & {
listHouseholdMembersByTelegramUserId: ( listHouseholdMembersByTelegramUserId: (
telegramUserId: string telegramUserId: string
) => Promise<readonly HouseholdMemberRecord[]> ) => Promise<readonly (HouseholdMemberRecord & { rentShareWeight: number })[]>
} }
duplicateRepository.listHouseholdMembersByTelegramUserId = async () => [ duplicateRepository.listHouseholdMembersByTelegramUserId = async () => [
member, member,
@@ -350,6 +357,7 @@ describe('createHouseholdOnboardingService', () => {
displayName: 'Stan elsewhere', displayName: 'Stan elsewhere',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false isAdmin: false
} }
] ]

View File

@@ -20,6 +20,7 @@ export type HouseholdMiniAppAccess =
isAdmin: boolean isAdmin: boolean
preferredLocale: SupportedLocale | null preferredLocale: SupportedLocale | null
householdDefaultLocale: SupportedLocale householdDefaultLocale: SupportedLocale
rentShareWeight: number
} }
} }
| { | {
@@ -70,6 +71,7 @@ export interface HouseholdOnboardingService {
isAdmin: boolean isAdmin: boolean
preferredLocale: SupportedLocale | null preferredLocale: SupportedLocale | null
householdDefaultLocale: SupportedLocale householdDefaultLocale: SupportedLocale
rentShareWeight: number
} }
} }
| { | {
@@ -85,6 +87,7 @@ function toMember(member: HouseholdMemberRecord): {
isAdmin: boolean isAdmin: boolean
preferredLocale: SupportedLocale | null preferredLocale: SupportedLocale | null
householdDefaultLocale: SupportedLocale householdDefaultLocale: SupportedLocale
rentShareWeight: number
} { } {
return { return {
id: member.id, id: member.id,
@@ -92,7 +95,8 @@ function toMember(member: HouseholdMemberRecord): {
displayName: member.displayName, displayName: member.displayName,
isAdmin: member.isAdmin, isAdmin: member.isAdmin,
preferredLocale: member.preferredLocale, preferredLocale: member.preferredLocale,
householdDefaultLocale: member.householdDefaultLocale householdDefaultLocale: member.householdDefaultLocale,
rentShareWeight: member.rentShareWeight
} }
} }

View File

@@ -175,6 +175,7 @@ function createRepositoryStub() {
householdDefaultLocale: householdDefaultLocale:
[...households.values()].find((household) => household.householdId === input.householdId) [...households.values()].find((household) => household.householdId === input.householdId)
?.defaultLocale ?? 'ru', ?.defaultLocale ?? 'ru',
rentShareWeight: input.rentShareWeight ?? existing?.rentShareWeight ?? 1,
isAdmin: input.isAdmin === true || existing?.isAdmin === true isAdmin: input.isAdmin === true || existing?.isAdmin === true
} }
members.set(key, next) members.set(key, next)
@@ -216,6 +217,7 @@ function createRepositoryStub() {
[...households.values()].find( [...households.values()].find(
(household) => household.householdId === pending.householdId (household) => household.householdId === pending.householdId
)?.defaultLocale ?? 'ru', )?.defaultLocale ?? 'ru',
rentShareWeight: 1,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
members.set(key, member) members.set(key, member)
@@ -296,6 +298,22 @@ function createRepositoryStub() {
} }
members.set(`${householdId}:${member.telegramUserId}`, next) members.set(`${householdId}:${member.telegramUserId}`, next)
return 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', displayName: 'Stan',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true isAdmin: true
}) })
}) })
@@ -369,6 +388,7 @@ describe('createHouseholdSetupService', () => {
displayName: 'Mia', displayName: 'Mia',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true isAdmin: true
}) })
}) })

View File

@@ -21,6 +21,7 @@ function createRepository(): HouseholdConfigurationRepository {
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null, preferredLocale: null,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true isAdmin: true
} }
@@ -104,7 +105,8 @@ function createRepository(): HouseholdConfigurationRepository {
sortOrder: input.sortOrder, sortOrder: input.sortOrder,
isActive: input.isActive 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) 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', () => { test('5-member scenario with two purchases remains deterministic', () => {
const input = { const input = {
...fixtureBase(), ...fixtureBase(),
@@ -150,4 +169,18 @@ describe('calculateMonthlySettlement', () => {
expect(() => calculateMonthlySettlement(input)).toThrow(DomainError) 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 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 { function validateCurrencyConsistency(input: SettlementInput): void {
const currency = input.rent.currency const currency = input.rent.currency
@@ -114,7 +139,7 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
activeMembers.map((member) => [member.memberId.toString(), createMemberState(member, currency)]) 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()) { for (const [index, member] of activeMembers.entries()) {
const state = membersById.get(member.memberId.toString()) const state = membersById.get(member.memberId.toString())
if (!state) { if (!state) {

View File

@@ -10,6 +10,8 @@
"0006_marvelous_nehzno.sql": "369a862f68dede5568116e29865aa3c8a7a0ff494487af1b202b62932ffe83f4", "0006_marvelous_nehzno.sql": "369a862f68dede5568116e29865aa3c8a7a0ff494487af1b202b62932ffe83f4",
"0007_sudden_murmur.sql": "eedf265c46705c20be4ddb3e2bd7d9670108756915b911e580e19a00f8118104", "0007_sudden_murmur.sql": "eedf265c46705c20be4ddb3e2bd7d9670108756915b911e580e19a00f8118104",
"0008_lowly_spiral.sql": "4f016332d60f7ef1fef0311210a0fa1a0bfc1d9b6da1da4380a60c14d54a9681", "0008_lowly_spiral.sql": "4f016332d60f7ef1fef0311210a0fa1a0bfc1d9b6da1da4380a60c14d54a9681",
"0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087" "0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087",
"0010_wild_molecule_man.sql": "46027a6ac770cdc2efd4c3eb5bb53f09d1b852c70fdc46a2434e5a7064587245",
"0011_previous_ezekiel_stane.sql": "d996e64d3854de22e36dedeaa94e46774399163d90263bbb05e0b9199af79b70"
} }
} }

View File

@@ -0,0 +1 @@
ALTER TABLE "members" ADD COLUMN "rent_share_weight" integer DEFAULT 1 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1773092080214, "when": 1773092080214,
"tag": "0010_wild_molecule_man", "tag": "0010_wild_molecule_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1773096404367,
"tag": "0011_previous_ezekiel_stane",
"breakpoints": true
} }
] ]
} }

View File

@@ -194,6 +194,7 @@ export const members = pgTable(
telegramUserId: text('telegram_user_id').notNull(), telegramUserId: text('telegram_user_id').notNull(),
displayName: text('display_name').notNull(), displayName: text('display_name').notNull(),
preferredLocale: text('preferred_locale'), preferredLocale: text('preferred_locale'),
rentShareWeight: integer('rent_share_weight').default(1).notNull(),
isAdmin: integer('is_admin').default(0).notNull(), isAdmin: integer('is_admin').default(0).notNull(),
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull() joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull()
}, },

View File

@@ -7,6 +7,7 @@ export type UtilitySplitMode = 'equal' | 'weighted_by_days'
export interface SettlementMemberInput { export interface SettlementMemberInput {
memberId: MemberId memberId: MemberId
active: boolean active: boolean
rentWeight?: number
utilityDays?: number utilityDays?: number
} }

View File

@@ -4,6 +4,7 @@ export interface FinanceMemberRecord {
id: string id: string
telegramUserId: string telegramUserId: string
displayName: string displayName: string
rentShareWeight: number
isAdmin: boolean isAdmin: boolean
} }

View File

@@ -45,6 +45,7 @@ export interface HouseholdMemberRecord {
displayName: string displayName: string
preferredLocale: SupportedLocale | null preferredLocale: SupportedLocale | null
householdDefaultLocale: SupportedLocale householdDefaultLocale: SupportedLocale
rentShareWeight: number
isAdmin: boolean isAdmin: boolean
} }
@@ -128,6 +129,7 @@ export interface HouseholdConfigurationRepository {
telegramUserId: string telegramUserId: string
displayName: string displayName: string
preferredLocale?: SupportedLocale | null preferredLocale?: SupportedLocale | null
rentShareWeight?: number
isAdmin?: boolean isAdmin?: boolean
}): Promise<HouseholdMemberRecord> }): Promise<HouseholdMemberRecord>
getHouseholdMember( getHouseholdMember(
@@ -178,4 +180,9 @@ export interface HouseholdConfigurationRepository {
householdId: string, householdId: string,
memberId: string memberId: string
): Promise<HouseholdMemberRecord | null> ): Promise<HouseholdMemberRecord | null>
updateHouseholdMemberRentShareWeight(
householdId: string,
memberId: string,
rentShareWeight: number
): Promise<HouseholdMemberRecord | null>
} }