From 6a04b9d7f5099f06f7097471ceb1b550c5e8fb25 Mon Sep 17 00:00:00 2001 From: whekin Date: Tue, 10 Mar 2026 02:47:58 +0400 Subject: [PATCH] feat(finance): support weighted rent split --- apps/bot/src/anonymous-feedback.test.ts | 6 +- apps/bot/src/miniapp-auth.test.ts | 12 + apps/bot/src/miniapp-billing.test.ts | 5 +- apps/bot/src/miniapp-dashboard.test.ts | 9 +- apps/bot/src/miniapp-locale.test.ts | 6 +- .../HOUSEBOT-077-custom-rent-split-weights.md | 48 + .../adapters-db/src/finance-repository.ts | 2 + .../src/household-config-repository.ts | 45 + .../src/finance-command-service.test.ts | 2 + .../src/finance-command-service.ts | 3 +- .../src/household-admin-service.test.ts | 7 +- .../src/household-onboarding-service.test.ts | 10 +- .../src/household-onboarding-service.ts | 6 +- .../src/household-setup-service.test.ts | 20 + .../src/locale-preference-service.test.ts | 4 +- .../application/src/settlement-engine.test.ts | 33 + packages/application/src/settlement-engine.ts | 27 +- packages/db/drizzle-checksums.json | 4 +- .../drizzle/0011_previous_ezekiel_stane.sql | 1 + packages/db/drizzle/meta/0011_snapshot.json | 2383 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 1 + packages/domain/src/settlement-primitives.ts | 1 + packages/ports/src/finance.ts | 1 + packages/ports/src/household-config.ts | 7 + 25 files changed, 2639 insertions(+), 11 deletions(-) create mode 100644 docs/specs/HOUSEBOT-077-custom-rent-split-weights.md create mode 100644 packages/db/drizzle/0011_previous_ezekiel_stane.sql create mode 100644 packages/db/drizzle/meta/0011_snapshot.json diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index fcd6669..d93ed27 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -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 } } diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index d766dec..b4d7713 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -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, diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index 8386a03..241e32d 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -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 } } diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index a2fe368..91e342b 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -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 } ] diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts index d93b76f..90b677e 100644 --- a/apps/bot/src/miniapp-locale.test.ts +++ b/apps/bot/src/miniapp-locale.test.ts @@ -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 } } diff --git a/docs/specs/HOUSEBOT-077-custom-rent-split-weights.md b/docs/specs/HOUSEBOT-077-custom-rent-split-weights.md new file mode 100644 index 0000000..047d050 --- /dev/null +++ b/docs/specs/HOUSEBOT-077-custom-rent-split-weights.md @@ -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. diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index b21f8a8..d1822d8 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -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) diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index fea2955..282f869 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -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 diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index 40d3782..79a6b5a 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -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 } ] diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index 2f44a88..542520f 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -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), diff --git a/packages/application/src/household-admin-service.test.ts b/packages/application/src/household-admin-service.test.ts index 4b286f1..7c31335 100644 --- a/packages/application/src/household-admin-service.test.ts +++ b/packages/application/src/household-admin-service.test.ts @@ -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 } }) diff --git a/packages/application/src/household-onboarding-service.test.ts b/packages/application/src/household-onboarding-service.test.ts index a15256c..a6c92d2 100644 --- a/packages/application/src/household-onboarding-service.test.ts +++ b/packages/application/src/household-onboarding-service.test.ts @@ -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 + ) => Promise } duplicateRepository.listHouseholdMembersByTelegramUserId = async () => [ member, @@ -350,6 +357,7 @@ describe('createHouseholdOnboardingService', () => { displayName: 'Stan elsewhere', preferredLocale: null, householdDefaultLocale: 'ru', + rentShareWeight: 1, isAdmin: false } ] diff --git a/packages/application/src/household-onboarding-service.ts b/packages/application/src/household-onboarding-service.ts index 5957a19..6ae0f7e 100644 --- a/packages/application/src/household-onboarding-service.ts +++ b/packages/application/src/household-onboarding-service.ts @@ -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 } } diff --git a/packages/application/src/household-setup-service.test.ts b/packages/application/src/household-setup-service.test.ts index 44f7f4c..b006de7 100644 --- a/packages/application/src/household-setup-service.test.ts +++ b/packages/application/src/household-setup-service.test.ts @@ -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 }) }) diff --git a/packages/application/src/locale-preference-service.test.ts b/packages/application/src/locale-preference-service.test.ts index be97767..940de31 100644 --- a/packages/application/src/locale-preference-service.test.ts +++ b/packages/application/src/locale-preference-service.test.ts @@ -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 } } diff --git a/packages/application/src/settlement-engine.test.ts b/packages/application/src/settlement-engine.test.ts index c0c672e..8cd3280 100644 --- a/packages/application/src/settlement-engine.test.ts +++ b/packages/application/src/settlement-engine.test.ts @@ -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) + }) }) diff --git a/packages/application/src/settlement-engine.ts b/packages/application/src/settlement-engine.ts index d93d39e..9d2627a 100644 --- a/packages/application/src/settlement-engine.ts +++ b/packages/application/src/settlement-engine.ts @@ -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) { diff --git a/packages/db/drizzle-checksums.json b/packages/db/drizzle-checksums.json index ab4b969..e742464 100644 --- a/packages/db/drizzle-checksums.json +++ b/packages/db/drizzle-checksums.json @@ -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" } } diff --git a/packages/db/drizzle/0011_previous_ezekiel_stane.sql b/packages/db/drizzle/0011_previous_ezekiel_stane.sql new file mode 100644 index 0000000..d68afff --- /dev/null +++ b/packages/db/drizzle/0011_previous_ezekiel_stane.sql @@ -0,0 +1 @@ +ALTER TABLE "members" ADD COLUMN "rent_share_weight" integer DEFAULT 1 NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0011_snapshot.json b/packages/db/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..170c9e6 --- /dev/null +++ b/packages/db/drizzle/meta/0011_snapshot.json @@ -0,0 +1,2383 @@ +{ + "id": "909c6709-929a-418c-882a-9ea6a63e477a", + "prevId": "221ce07f-4404-4c10-8086-0017a4744327", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.anonymous_messages": { + "name": "anonymous_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submitted_by_member_id": { + "name": "submitted_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sanitized_text": { + "name": "sanitized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderation_reason": { + "name": "moderation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "posted_chat_id": { + "name": "posted_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_thread_id": { + "name": "posted_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_message_id": { + "name": "posted_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "anonymous_messages_household_tg_update_unique": { + "name": "anonymous_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_member_created_idx": { + "name": "anonymous_messages_member_created_idx", + "columns": [ + { + "expression": "submitted_by_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_status_created_idx": { + "name": "anonymous_messages_status_created_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anonymous_messages_household_id_households_id_fk": { + "name": "anonymous_messages_household_id_households_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "anonymous_messages_submitted_by_member_id_members_id_fk": { + "name": "anonymous_messages_submitted_by_member_id_members_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "members", + "columnsFrom": ["submitted_by_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_cycles": { + "name": "billing_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycles_household_period_unique": { + "name": "billing_cycles_household_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycles_household_period_idx": { + "name": "billing_cycles_household_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycles_household_id_households_id_fk": { + "name": "billing_cycles_household_id_households_id_fk", + "tableFrom": "billing_cycles", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_billing_settings": { + "name": "household_billing_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_amount_minor": { + "name": "rent_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "rent_currency": { + "name": "rent_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "rent_due_day": { + "name": "rent_due_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "rent_warning_day": { + "name": "rent_warning_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 17 + }, + "utilities_due_day": { + "name": "utilities_due_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "utilities_reminder_day": { + "name": "utilities_reminder_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Asia/Tbilisi'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_billing_settings_household_unique": { + "name": "household_billing_settings_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_billing_settings_household_id_households_id_fk": { + "name": "household_billing_settings_household_id_households_id_fk", + "tableFrom": "household_billing_settings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_join_tokens": { + "name": "household_join_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_telegram_user_id": { + "name": "created_by_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_join_tokens_household_unique": { + "name": "household_join_tokens_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_join_tokens_token_unique": { + "name": "household_join_tokens_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_join_tokens_household_id_households_id_fk": { + "name": "household_join_tokens_household_id_households_id_fk", + "tableFrom": "household_join_tokens", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_pending_members": { + "name": "household_pending_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_pending_members_household_user_unique": { + "name": "household_pending_members_household_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_pending_members_telegram_user_idx": { + "name": "household_pending_members_telegram_user_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_pending_members_household_id_households_id_fk": { + "name": "household_pending_members_household_id_households_id_fk", + "tableFrom": "household_pending_members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_telegram_chats": { + "name": "household_telegram_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_type": { + "name": "telegram_chat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_telegram_chats_household_unique": { + "name": "household_telegram_chats_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_telegram_chats_chat_unique": { + "name": "household_telegram_chats_chat_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_telegram_chats_household_id_households_id_fk": { + "name": "household_telegram_chats_household_id_households_id_fk", + "tableFrom": "household_telegram_chats", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_topic_bindings": { + "name": "household_topic_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_name": { + "name": "topic_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_topic_bindings_household_role_unique": { + "name": "household_topic_bindings_household_role_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_thread_unique": { + "name": "household_topic_bindings_household_thread_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_role_idx": { + "name": "household_topic_bindings_household_role_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_topic_bindings_household_id_households_id_fk": { + "name": "household_topic_bindings_household_id_households_id_fk", + "tableFrom": "household_topic_bindings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_utility_categories": { + "name": "household_utility_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_utility_categories_household_slug_unique": { + "name": "household_utility_categories_household_slug_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_utility_categories_household_sort_idx": { + "name": "household_utility_categories_household_sort_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_utility_categories_household_id_households_id_fk": { + "name": "household_utility_categories_household_id_households_id_fk", + "tableFrom": "household_utility_categories", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rent_share_weight": { + "name": "rent_share_weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_household_idx": { + "name": "members_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_household_tg_user_unique": { + "name": "members_household_tg_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_household_id_households_id_fk": { + "name": "members_household_id_households_id_fk", + "tableFrom": "members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_overrides": { + "name": "presence_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "utility_days": { + "name": "utility_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presence_overrides_cycle_member_unique": { + "name": "presence_overrides_cycle_member_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presence_overrides_cycle_idx": { + "name": "presence_overrides_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presence_overrides_cycle_id_billing_cycles_id_fk": { + "name": "presence_overrides_cycle_id_billing_cycles_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presence_overrides_member_id_members_id_fk": { + "name": "presence_overrides_member_id_members_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_bot_messages": { + "name": "processed_bot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_key": { + "name": "source_message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "processed_bot_messages_source_message_unique": { + "name": "processed_bot_messages_source_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processed_bot_messages_household_id_households_id_fk": { + "name": "processed_bot_messages_household_id_households_id_fk", + "tableFrom": "processed_bot_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_entries": { + "name": "purchase_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_entries_household_cycle_idx": { + "name": "purchase_entries_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_payer_idx": { + "name": "purchase_entries_payer_idx", + "columns": [ + { + "expression": "payer_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_household_tg_message_unique": { + "name": "purchase_entries_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_entries_household_id_households_id_fk": { + "name": "purchase_entries_household_id_households_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_entries_cycle_id_billing_cycles_id_fk": { + "name": "purchase_entries_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "purchase_entries_payer_member_id_members_id_fk": { + "name": "purchase_entries_payer_member_id_members_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "members", + "columnsFrom": ["payer_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_messages": { + "name": "purchase_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parsed_amount_minor": { + "name": "parsed_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "parsed_currency": { + "name": "parsed_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_item_description": { + "name": "parsed_item_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parser_error": { + "name": "parser_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_messages_household_thread_idx": { + "name": "purchase_messages_household_thread_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_sender_idx": { + "name": "purchase_messages_sender_idx", + "columns": [ + { + "expression": "sender_telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_message_unique": { + "name": "purchase_messages_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_update_unique": { + "name": "purchase_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_messages_household_id_households_id_fk": { + "name": "purchase_messages_household_id_households_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_messages_sender_member_id_members_id_fk": { + "name": "purchase_messages_sender_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["sender_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_rules": { + "name": "rent_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_to_period": { + "name": "effective_to_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rent_rules_household_from_period_unique": { + "name": "rent_rules_household_from_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rent_rules_household_from_period_idx": { + "name": "rent_rules_household_from_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rent_rules_household_id_households_id_fk": { + "name": "rent_rules_household_id_households_id_fk", + "tableFrom": "rent_rules", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlement_lines": { + "name": "settlement_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_share_minor": { + "name": "rent_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "utility_share_minor": { + "name": "utility_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "purchase_offset_minor": { + "name": "purchase_offset_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "net_due_minor": { + "name": "net_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "explanations": { + "name": "explanations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlement_lines_settlement_member_unique": { + "name": "settlement_lines_settlement_member_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlement_lines_settlement_idx": { + "name": "settlement_lines_settlement_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlement_lines_settlement_id_settlements_id_fk": { + "name": "settlement_lines_settlement_id_settlements_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "settlements", + "columnsFrom": ["settlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlement_lines_member_id_members_id_fk": { + "name": "settlement_lines_member_id_members_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_due_minor": { + "name": "total_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "settlements_cycle_unique": { + "name": "settlements_cycle_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_household_computed_idx": { + "name": "settlements_household_computed_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_household_id_households_id_fk": { + "name": "settlements_household_id_households_id_fk", + "tableFrom": "settlements", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlements_cycle_id_billing_cycles_id_fk": { + "name": "settlements_cycle_id_billing_cycles_id_fk", + "tableFrom": "settlements", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_pending_actions": { + "name": "telegram_pending_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_pending_actions_chat_user_unique": { + "name": "telegram_pending_actions_chat_user_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "telegram_pending_actions_user_action_idx": { + "name": "telegram_pending_actions_user_action_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.utility_bills": { + "name": "utility_bills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bill_name": { + "name": "bill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "utility_bills_cycle_idx": { + "name": "utility_bills_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "utility_bills_household_cycle_idx": { + "name": "utility_bills_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "utility_bills_household_id_households_id_fk": { + "name": "utility_bills_household_id_households_id_fk", + "tableFrom": "utility_bills", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_cycle_id_billing_cycles_id_fk": { + "name": "utility_bills_cycle_id_billing_cycles_id_fk", + "tableFrom": "utility_bills", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_created_by_member_id_members_id_fk": { + "name": "utility_bills_created_by_member_id_members_id_fk", + "tableFrom": "utility_bills", + "tableTo": "members", + "columnsFrom": ["created_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 37d99fd..3d0eaa3 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -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 } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 8a53774..dbf720a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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() }, diff --git a/packages/domain/src/settlement-primitives.ts b/packages/domain/src/settlement-primitives.ts index c09d56f..0fb4ac0 100644 --- a/packages/domain/src/settlement-primitives.ts +++ b/packages/domain/src/settlement-primitives.ts @@ -7,6 +7,7 @@ export type UtilitySplitMode = 'equal' | 'weighted_by_days' export interface SettlementMemberInput { memberId: MemberId active: boolean + rentWeight?: number utilityDays?: number } diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts index cae6d09..4f4e9a9 100644 --- a/packages/ports/src/finance.ts +++ b/packages/ports/src/finance.ts @@ -4,6 +4,7 @@ export interface FinanceMemberRecord { id: string telegramUserId: string displayName: string + rentShareWeight: number isAdmin: boolean } diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts index 7244d20..2d92b3a 100644 --- a/packages/ports/src/household-config.ts +++ b/packages/ports/src/household-config.ts @@ -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 getHouseholdMember( @@ -178,4 +180,9 @@ export interface HouseholdConfigurationRepository { householdId: string, memberId: string ): Promise + updateHouseholdMemberRentShareWeight( + householdId: string, + memberId: string, + rentShareWeight: number + ): Promise }