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:
@@ -159,6 +159,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
}),
|
||||
getHouseholdMember: async () => null,
|
||||
@@ -171,6 +172,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}
|
||||
],
|
||||
@@ -191,6 +193,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
||||
displayName: 'Stan',
|
||||
preferredLocale: locale,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}),
|
||||
getHouseholdBillingSettings: async (householdId) => ({
|
||||
@@ -222,7 +225,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: input.isActive
|
||||
}),
|
||||
promoteHouseholdAdmin: async () => null
|
||||
promoteHouseholdAdmin: async () => null,
|
||||
updateHouseholdMemberRentShareWeight: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
displayName: string
|
||||
preferredLocale: 'en' | 'ru' | null
|
||||
householdDefaultLocale: 'en' | 'ru'
|
||||
rentShareWeight: number
|
||||
isAdmin: boolean
|
||||
}
|
||||
>()
|
||||
@@ -97,6 +98,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
}
|
||||
members.set(input.telegramUserId, member)
|
||||
@@ -122,6 +124,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
displayName: pending.displayName,
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
}
|
||||
members.set(pending.telegramUserId, member)
|
||||
@@ -141,6 +144,15 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
}
|
||||
: null
|
||||
},
|
||||
updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) => {
|
||||
const member = [...members.values()].find((entry) => entry.id === memberId)
|
||||
return member
|
||||
? {
|
||||
...member,
|
||||
rentShareWeight
|
||||
}
|
||||
: null
|
||||
},
|
||||
getHouseholdBillingSettings: async (householdId) => ({
|
||||
householdId,
|
||||
rentAmountMinor: null,
|
||||
|
||||
@@ -70,6 +70,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
}),
|
||||
getHouseholdMember: async () => null,
|
||||
@@ -111,6 +112,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
],
|
||||
@@ -121,7 +123,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
defaultLocale: locale
|
||||
}),
|
||||
updateMemberPreferredLocale: async () => null,
|
||||
promoteHouseholdAdmin: async () => null
|
||||
promoteHouseholdAdmin: async () => null,
|
||||
updateHouseholdMemberRentShareWeight: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ function repository(
|
||||
id: 'member-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
],
|
||||
@@ -124,6 +125,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
}),
|
||||
getHouseholdMember: async () => null,
|
||||
@@ -165,7 +167,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: input.isActive
|
||||
}),
|
||||
promoteHouseholdAdmin: async () => null
|
||||
promoteHouseholdAdmin: async () => null,
|
||||
updateHouseholdMemberRentShareWeight: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +180,7 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
id: 'member-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
})
|
||||
)
|
||||
@@ -189,6 +193,7 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
@@ -255,6 +260,7 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
id: 'member-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
})
|
||||
)
|
||||
@@ -267,6 +273,7 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -33,6 +33,7 @@ function repository(): HouseholdConfigurationRepository {
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
}
|
||||
],
|
||||
@@ -45,6 +46,7 @@ function repository(): HouseholdConfigurationRepository {
|
||||
displayName: 'Mia',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}
|
||||
]
|
||||
@@ -92,6 +94,7 @@ function repository(): HouseholdConfigurationRepository {
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin === true
|
||||
},
|
||||
getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null,
|
||||
@@ -154,7 +157,8 @@ function repository(): HouseholdConfigurationRepository {
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: input.isActive
|
||||
}),
|
||||
promoteHouseholdAdmin: async () => null
|
||||
promoteHouseholdAdmin: async () => null,
|
||||
updateHouseholdMemberRentShareWeight: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
docs/specs/HOUSEBOT-077-custom-rent-split-weights.md
Normal file
48
docs/specs/HOUSEBOT-077-custom-rent-split-weights.md
Normal 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.
|
||||
@@ -38,6 +38,7 @@ export function createDbFinanceRepository(
|
||||
id: schema.members.id,
|
||||
telegramUserId: schema.members.telegramUserId,
|
||||
displayName: schema.members.displayName,
|
||||
rentShareWeight: schema.members.rentShareWeight,
|
||||
isAdmin: schema.members.isAdmin
|
||||
})
|
||||
.from(schema.members)
|
||||
@@ -66,6 +67,7 @@ export function createDbFinanceRepository(
|
||||
id: schema.members.id,
|
||||
telegramUserId: schema.members.telegramUserId,
|
||||
displayName: schema.members.displayName,
|
||||
rentShareWeight: schema.members.rentShareWeight,
|
||||
isAdmin: schema.members.isAdmin
|
||||
})
|
||||
.from(schema.members)
|
||||
|
||||
@@ -115,6 +115,7 @@ function toHouseholdMemberRecord(row: {
|
||||
displayName: string
|
||||
preferredLocale: string | null
|
||||
defaultLocale: string
|
||||
rentShareWeight: number
|
||||
isAdmin: number
|
||||
}): HouseholdMemberRecord {
|
||||
const householdDefaultLocale = normalizeSupportedLocale(row.defaultLocale)
|
||||
@@ -129,6 +130,7 @@ function toHouseholdMemberRecord(row: {
|
||||
displayName: row.displayName,
|
||||
preferredLocale: normalizeSupportedLocale(row.preferredLocale),
|
||||
householdDefaultLocale,
|
||||
rentShareWeight: row.rentShareWeight,
|
||||
isAdmin: row.isAdmin === 1
|
||||
}
|
||||
}
|
||||
@@ -766,6 +768,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
rentShareWeight: input.rentShareWeight ?? 1,
|
||||
isAdmin: input.isAdmin ? 1 : 0
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
@@ -773,6 +776,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
set: {
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? schema.members.preferredLocale,
|
||||
rentShareWeight: input.rentShareWeight ?? schema.members.rentShareWeight,
|
||||
...(input.isAdmin
|
||||
? {
|
||||
isAdmin: 1
|
||||
@@ -786,6 +790,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
telegramUserId: schema.members.telegramUserId,
|
||||
displayName: schema.members.displayName,
|
||||
preferredLocale: schema.members.preferredLocale,
|
||||
rentShareWeight: schema.members.rentShareWeight,
|
||||
isAdmin: schema.members.isAdmin
|
||||
})
|
||||
|
||||
@@ -813,6 +818,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
telegramUserId: schema.members.telegramUserId,
|
||||
displayName: schema.members.displayName,
|
||||
preferredLocale: schema.members.preferredLocale,
|
||||
rentShareWeight: schema.members.rentShareWeight,
|
||||
defaultLocale: schema.households.defaultLocale,
|
||||
isAdmin: schema.members.isAdmin
|
||||
})
|
||||
@@ -838,6 +844,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
telegramUserId: schema.members.telegramUserId,
|
||||
displayName: schema.members.displayName,
|
||||
preferredLocale: schema.members.preferredLocale,
|
||||
rentShareWeight: schema.members.rentShareWeight,
|
||||
defaultLocale: schema.households.defaultLocale,
|
||||
isAdmin: schema.members.isAdmin
|
||||
})
|
||||
@@ -1012,6 +1019,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
telegramUserId: schema.members.telegramUserId,
|
||||
displayName: schema.members.displayName,
|
||||
preferredLocale: schema.members.preferredLocale,
|
||||
rentShareWeight: schema.members.rentShareWeight,
|
||||
defaultLocale: schema.households.defaultLocale,
|
||||
isAdmin: schema.members.isAdmin
|
||||
})
|
||||
@@ -1082,6 +1090,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
telegramUserId: pending.telegramUserId,
|
||||
displayName: pending.displayName,
|
||||
preferredLocale: normalizeSupportedLocale(pending.languageCode),
|
||||
rentShareWeight: 1,
|
||||
isAdmin: input.isAdmin ? 1 : 0
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
@@ -1103,6 +1112,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
telegramUserId: schema.members.telegramUserId,
|
||||
displayName: schema.members.displayName,
|
||||
preferredLocale: schema.members.preferredLocale,
|
||||
rentShareWeight: schema.members.rentShareWeight,
|
||||
isAdmin: schema.members.isAdmin
|
||||
})
|
||||
|
||||
@@ -1174,6 +1184,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
telegramUserId: schema.members.telegramUserId,
|
||||
displayName: schema.members.displayName,
|
||||
preferredLocale: schema.members.preferredLocale,
|
||||
rentShareWeight: schema.members.rentShareWeight,
|
||||
isAdmin: schema.members.isAdmin
|
||||
})
|
||||
|
||||
@@ -1206,6 +1217,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
telegramUserId: schema.members.telegramUserId,
|
||||
displayName: schema.members.displayName,
|
||||
preferredLocale: schema.members.preferredLocale,
|
||||
rentShareWeight: schema.members.rentShareWeight,
|
||||
isAdmin: schema.members.isAdmin
|
||||
})
|
||||
|
||||
@@ -1219,6 +1231,39 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
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({
|
||||
...row,
|
||||
defaultLocale: household.defaultLocale
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"0006_marvelous_nehzno.sql": "369a862f68dede5568116e29865aa3c8a7a0ff494487af1b202b62932ffe83f4",
|
||||
"0007_sudden_murmur.sql": "eedf265c46705c20be4ddb3e2bd7d9670108756915b911e580e19a00f8118104",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/db/drizzle/0011_previous_ezekiel_stane.sql
Normal file
1
packages/db/drizzle/0011_previous_ezekiel_stane.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "members" ADD COLUMN "rent_share_weight" integer DEFAULT 1 NOT NULL;
|
||||
2383
packages/db/drizzle/meta/0011_snapshot.json
Normal file
2383
packages/db/drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
||||
"when": 1773092080214,
|
||||
"tag": "0010_wild_molecule_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1773096404367,
|
||||
"tag": "0011_previous_ezekiel_stane",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ export const members = pgTable(
|
||||
telegramUserId: text('telegram_user_id').notNull(),
|
||||
displayName: text('display_name').notNull(),
|
||||
preferredLocale: text('preferred_locale'),
|
||||
rentShareWeight: integer('rent_share_weight').default(1).notNull(),
|
||||
isAdmin: integer('is_admin').default(0).notNull(),
|
||||
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ export type UtilitySplitMode = 'equal' | 'weighted_by_days'
|
||||
export interface SettlementMemberInput {
|
||||
memberId: MemberId
|
||||
active: boolean
|
||||
rentWeight?: number
|
||||
utilityDays?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface FinanceMemberRecord {
|
||||
id: string
|
||||
telegramUserId: string
|
||||
displayName: string
|
||||
rentShareWeight: number
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface HouseholdMemberRecord {
|
||||
displayName: string
|
||||
preferredLocale: SupportedLocale | null
|
||||
householdDefaultLocale: SupportedLocale
|
||||
rentShareWeight: number
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
@@ -128,6 +129,7 @@ export interface HouseholdConfigurationRepository {
|
||||
telegramUserId: string
|
||||
displayName: string
|
||||
preferredLocale?: SupportedLocale | null
|
||||
rentShareWeight?: number
|
||||
isAdmin?: boolean
|
||||
}): Promise<HouseholdMemberRecord>
|
||||
getHouseholdMember(
|
||||
@@ -178,4 +180,9 @@ export interface HouseholdConfigurationRepository {
|
||||
householdId: string,
|
||||
memberId: string
|
||||
): Promise<HouseholdMemberRecord | null>
|
||||
updateHouseholdMemberRentShareWeight(
|
||||
householdId: string,
|
||||
memberId: string,
|
||||
rentShareWeight: number
|
||||
): Promise<HouseholdMemberRecord | null>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user