mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
feat(member): add away billing policies
This commit is contained in:
@@ -8,8 +8,11 @@ import {
|
||||
type CurrencyCode
|
||||
} from '@household/domain'
|
||||
import {
|
||||
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
|
||||
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
|
||||
HOUSEHOLD_TOPIC_ROLES,
|
||||
type HouseholdMemberAbsencePolicy,
|
||||
type HouseholdMemberAbsencePolicyRecord,
|
||||
type HouseholdBillingSettingsRecord,
|
||||
type HouseholdConfigurationRepository,
|
||||
type HouseholdJoinTokenRecord,
|
||||
@@ -44,6 +47,16 @@ function normalizeMemberLifecycleStatus(raw: string): HouseholdMemberLifecycleSt
|
||||
throw new Error(`Unsupported household member lifecycle status: ${raw}`)
|
||||
}
|
||||
|
||||
function normalizeMemberAbsencePolicy(raw: string): HouseholdMemberAbsencePolicy {
|
||||
const normalized = raw.trim().toLowerCase()
|
||||
|
||||
if ((HOUSEHOLD_MEMBER_ABSENCE_POLICIES as readonly string[]).includes(normalized)) {
|
||||
return normalized as HouseholdMemberAbsencePolicy
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported household member absence policy: ${raw}`)
|
||||
}
|
||||
|
||||
function toHouseholdTelegramChatRecord(row: {
|
||||
householdId: string
|
||||
householdName: string
|
||||
@@ -232,6 +245,20 @@ function toHouseholdUtilityCategoryRecord(row: {
|
||||
}
|
||||
}
|
||||
|
||||
function toHouseholdMemberAbsencePolicyRecord(row: {
|
||||
householdId: string
|
||||
memberId: string
|
||||
effectiveFromPeriod: string
|
||||
policy: string
|
||||
}): HouseholdMemberAbsencePolicyRecord {
|
||||
return {
|
||||
householdId: row.householdId,
|
||||
memberId: row.memberId,
|
||||
effectiveFromPeriod: row.effectiveFromPeriod,
|
||||
policy: normalizeMemberAbsencePolicy(row.policy)
|
||||
}
|
||||
}
|
||||
|
||||
function utilityCategorySlug(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
@@ -1343,6 +1370,55 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
...row,
|
||||
defaultLocale: household.defaultLocale
|
||||
})
|
||||
},
|
||||
|
||||
async listHouseholdMemberAbsencePolicies(householdId) {
|
||||
const rows = await db
|
||||
.select({
|
||||
householdId: schema.memberAbsencePolicies.householdId,
|
||||
memberId: schema.memberAbsencePolicies.memberId,
|
||||
effectiveFromPeriod: schema.memberAbsencePolicies.effectiveFromPeriod,
|
||||
policy: schema.memberAbsencePolicies.policy
|
||||
})
|
||||
.from(schema.memberAbsencePolicies)
|
||||
.where(eq(schema.memberAbsencePolicies.householdId, householdId))
|
||||
.orderBy(
|
||||
asc(schema.memberAbsencePolicies.memberId),
|
||||
asc(schema.memberAbsencePolicies.effectiveFromPeriod)
|
||||
)
|
||||
|
||||
return rows.map(toHouseholdMemberAbsencePolicyRecord)
|
||||
},
|
||||
|
||||
async upsertHouseholdMemberAbsencePolicy(input) {
|
||||
const rows = await db
|
||||
.insert(schema.memberAbsencePolicies)
|
||||
.values({
|
||||
householdId: input.householdId,
|
||||
memberId: input.memberId,
|
||||
effectiveFromPeriod: input.effectiveFromPeriod,
|
||||
policy: input.policy
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.memberAbsencePolicies.householdId,
|
||||
schema.memberAbsencePolicies.memberId,
|
||||
schema.memberAbsencePolicies.effectiveFromPeriod
|
||||
],
|
||||
set: {
|
||||
policy: input.policy,
|
||||
updatedAt: instantToDate(nowInstant())
|
||||
}
|
||||
})
|
||||
.returning({
|
||||
householdId: schema.memberAbsencePolicies.householdId,
|
||||
memberId: schema.memberAbsencePolicies.memberId,
|
||||
effectiveFromPeriod: schema.memberAbsencePolicies.effectiveFromPeriod,
|
||||
policy: schema.memberAbsencePolicies.policy
|
||||
})
|
||||
|
||||
const row = rows[0]
|
||||
return row ? toHouseholdMemberAbsencePolicyRecord(row) : null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
householdId = 'household-1'
|
||||
member: FinanceMemberRecord | null = null
|
||||
members: readonly FinanceMemberRecord[] = []
|
||||
memberStatuses = new Map<string, 'active' | 'away' | 'left'>()
|
||||
memberAbsencePolicies: readonly {
|
||||
memberId: string
|
||||
effectiveFromPeriod: string
|
||||
policy: 'resident' | 'away_rent_and_utilities' | 'away_rent_only' | 'inactive'
|
||||
}[] = []
|
||||
openCycleRecord: FinanceCycleRecord | null = null
|
||||
cycleByPeriodRecord: FinanceCycleRecord | null = null
|
||||
latestCycleRecord: FinanceCycleRecord | null = null
|
||||
@@ -204,7 +210,7 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
|
||||
const householdConfigurationRepository: Pick<
|
||||
HouseholdConfigurationRepository,
|
||||
'getHouseholdBillingSettings'
|
||||
'getHouseholdBillingSettings' | 'listHouseholdMembers' | 'listHouseholdMemberAbsencePolicies'
|
||||
> = {
|
||||
async getHouseholdBillingSettings(householdId) {
|
||||
return {
|
||||
@@ -218,9 +224,43 @@ const householdConfigurationRepository: Pick<
|
||||
utilitiesReminderDay: 3,
|
||||
timezone: 'Asia/Tbilisi'
|
||||
}
|
||||
},
|
||||
async listHouseholdMembers(householdId) {
|
||||
const repository = financeRepositoryForHousehold(householdId)
|
||||
|
||||
return repository.members.map((member) => ({
|
||||
id: member.id,
|
||||
householdId,
|
||||
telegramUserId: member.telegramUserId,
|
||||
displayName: member.displayName,
|
||||
status: repository.memberStatuses.get(member.id) ?? 'active',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'en' as const,
|
||||
rentShareWeight: member.rentShareWeight,
|
||||
isAdmin: member.isAdmin
|
||||
}))
|
||||
},
|
||||
async listHouseholdMemberAbsencePolicies(householdId) {
|
||||
return financeRepositoryForHousehold(householdId).memberAbsencePolicies.map((policy) => ({
|
||||
householdId,
|
||||
memberId: policy.memberId,
|
||||
effectiveFromPeriod: policy.effectiveFromPeriod,
|
||||
policy: policy.policy
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const financeRepositories = new Map<string, FinanceRepositoryStub>()
|
||||
|
||||
function financeRepositoryForHousehold(householdId: string): FinanceRepositoryStub {
|
||||
const repository = financeRepositories.get(householdId)
|
||||
if (!repository) {
|
||||
throw new Error(`Missing finance repository stub for ${householdId}`)
|
||||
}
|
||||
|
||||
return repository
|
||||
}
|
||||
|
||||
const exchangeRateProvider: ExchangeRateProvider = {
|
||||
async getRate(input) {
|
||||
if (input.baseCurrency === input.quoteCurrency) {
|
||||
@@ -254,6 +294,8 @@ const exchangeRateProvider: ExchangeRateProvider = {
|
||||
}
|
||||
|
||||
function createService(repository: FinanceRepositoryStub) {
|
||||
financeRepositories.set(repository.householdId, repository)
|
||||
|
||||
return createFinanceCommandService({
|
||||
householdId: repository.householdId,
|
||||
repository,
|
||||
@@ -482,4 +524,83 @@ describe('createFinanceCommandService', () => {
|
||||
|
||||
expect(dashboard?.period).toBe('2026-03')
|
||||
})
|
||||
|
||||
test('generateDashboard excludes away members from purchases and utilities based on absence policy', async () => {
|
||||
const repository = new FinanceRepositoryStub()
|
||||
repository.members = [
|
||||
{
|
||||
id: 'alice',
|
||||
telegramUserId: '1',
|
||||
displayName: 'Alice',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: true
|
||||
},
|
||||
{
|
||||
id: 'bob',
|
||||
telegramUserId: '2',
|
||||
displayName: 'Bob',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
},
|
||||
{
|
||||
id: 'carol',
|
||||
telegramUserId: '3',
|
||||
displayName: 'Carol',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}
|
||||
]
|
||||
repository.memberStatuses.set('carol', 'away')
|
||||
repository.memberAbsencePolicies = [
|
||||
{
|
||||
memberId: 'carol',
|
||||
effectiveFromPeriod: '2026-03',
|
||||
policy: 'away_rent_only'
|
||||
}
|
||||
]
|
||||
repository.openCycleRecord = {
|
||||
id: 'cycle-2026-03',
|
||||
period: '2026-03',
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.rentRule = {
|
||||
amountMinor: 90000n,
|
||||
currency: 'GEL'
|
||||
}
|
||||
repository.utilityBills = [
|
||||
{
|
||||
id: 'utility-1',
|
||||
billName: 'Gas',
|
||||
amountMinor: 12000n,
|
||||
currency: 'GEL',
|
||||
createdByMemberId: 'alice',
|
||||
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
||||
}
|
||||
]
|
||||
repository.purchases = [
|
||||
{
|
||||
id: 'purchase-1',
|
||||
payerMemberId: 'alice',
|
||||
amountMinor: 3000n,
|
||||
currency: 'GEL',
|
||||
description: 'Kitchen towels',
|
||||
occurredAt: instantFromIso('2026-03-10T12:00:00.000Z')
|
||||
}
|
||||
]
|
||||
|
||||
const service = createService(repository)
|
||||
const dashboard = await service.generateDashboard()
|
||||
|
||||
expect(
|
||||
dashboard?.members.map((line) => ({
|
||||
memberId: line.memberId,
|
||||
utility: line.utilityShare.amountMinor,
|
||||
purchaseOffset: line.purchaseOffset.amountMinor
|
||||
}))
|
||||
).toEqual([
|
||||
{ memberId: 'alice', utility: 6000n, purchaseOffset: -1500n },
|
||||
{ memberId: 'bob', utility: 6000n, purchaseOffset: 1500n },
|
||||
{ memberId: 'carol', utility: 0n, purchaseOffset: 0n }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,10 @@ import type {
|
||||
FinancePaymentKind,
|
||||
FinanceRentRuleRecord,
|
||||
FinanceRepository,
|
||||
HouseholdConfigurationRepository
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdMemberAbsencePolicy,
|
||||
HouseholdMemberAbsencePolicyRecord,
|
||||
HouseholdMemberRecord
|
||||
} from '@household/ports'
|
||||
import {
|
||||
BillingCycleId,
|
||||
@@ -100,6 +103,9 @@ function expectedOpenCyclePeriod(
|
||||
export interface FinanceDashboardMemberLine {
|
||||
memberId: string
|
||||
displayName: string
|
||||
status?: 'active' | 'away' | 'left'
|
||||
absencePolicy?: HouseholdMemberAbsencePolicy
|
||||
absencePolicyEffectiveFromPeriod?: string | null
|
||||
rentShare: Money
|
||||
utilityShare: Money
|
||||
purchaseOffset: Money
|
||||
@@ -157,11 +163,45 @@ interface FinanceCommandServiceDependencies {
|
||||
repository: FinanceRepository
|
||||
householdConfigurationRepository: Pick<
|
||||
HouseholdConfigurationRepository,
|
||||
'getHouseholdBillingSettings'
|
||||
'getHouseholdBillingSettings' | 'listHouseholdMembers' | 'listHouseholdMemberAbsencePolicies'
|
||||
>
|
||||
exchangeRateProvider: ExchangeRateProvider
|
||||
}
|
||||
|
||||
interface ResolvedMemberAbsencePolicy {
|
||||
memberId: string
|
||||
policy: HouseholdMemberAbsencePolicy
|
||||
effectiveFromPeriod: string | null
|
||||
}
|
||||
|
||||
function resolveMemberAbsencePolicies(input: {
|
||||
members: readonly HouseholdMemberRecord[]
|
||||
policies: readonly HouseholdMemberAbsencePolicyRecord[]
|
||||
period: string
|
||||
}): ReadonlyMap<string, ResolvedMemberAbsencePolicy> {
|
||||
const resolved = new Map<string, ResolvedMemberAbsencePolicy>()
|
||||
|
||||
for (const member of input.members) {
|
||||
const applicable = input.policies
|
||||
.filter(
|
||||
(policy) =>
|
||||
policy.memberId === member.id &&
|
||||
policy.effectiveFromPeriod.localeCompare(input.period) <= 0
|
||||
)
|
||||
.sort((left, right) => left.effectiveFromPeriod.localeCompare(right.effectiveFromPeriod))
|
||||
.at(-1)
|
||||
|
||||
resolved.set(member.id, {
|
||||
memberId: member.id,
|
||||
policy:
|
||||
applicable?.policy ?? (member.status === 'away' ? 'away_rent_and_utilities' : 'resident'),
|
||||
effectiveFromPeriod: applicable?.effectiveFromPeriod ?? null
|
||||
})
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
interface ConvertedCycleMoney {
|
||||
originalAmount: Money
|
||||
settlementAmount: Money
|
||||
@@ -240,8 +280,11 @@ async function buildFinanceDashboard(
|
||||
return null
|
||||
}
|
||||
|
||||
const [members, rentRule, settings] = await Promise.all([
|
||||
dependencies.repository.listMembers(),
|
||||
const [members, memberAbsencePolicies, rentRule, settings] = await Promise.all([
|
||||
dependencies.householdConfigurationRepository.listHouseholdMembers(dependencies.householdId),
|
||||
dependencies.householdConfigurationRepository.listHouseholdMemberAbsencePolicies(
|
||||
dependencies.householdId
|
||||
),
|
||||
dependencies.repository.getRentRuleForPeriod(cycle.period),
|
||||
dependencies.householdConfigurationRepository.getHouseholdBillingSettings(
|
||||
dependencies.householdId
|
||||
@@ -258,6 +301,11 @@ async function buildFinanceDashboard(
|
||||
|
||||
const period = BillingPeriod.fromString(cycle.period)
|
||||
const { start, end } = monthRange(period)
|
||||
const resolvedAbsencePolicies = resolveMemberAbsencePolicies({
|
||||
members,
|
||||
policies: memberAbsencePolicies,
|
||||
period: cycle.period
|
||||
})
|
||||
const [purchases, utilityBills] = await Promise.all([
|
||||
dependencies.repository.listParsedPurchasesForRange(start, end),
|
||||
dependencies.repository.listUtilityBillsForCycle(cycle.id)
|
||||
@@ -319,7 +367,17 @@ async function buildFinanceDashboard(
|
||||
utilitySplitMode: 'equal',
|
||||
members: members.map((member) => ({
|
||||
memberId: MemberId.from(member.id),
|
||||
active: true,
|
||||
active: member.status !== 'left',
|
||||
participatesInRent:
|
||||
member.status === 'left'
|
||||
? false
|
||||
: (resolvedAbsencePolicies.get(member.id)?.policy ?? 'resident') !== 'inactive',
|
||||
participatesInUtilities:
|
||||
member.status === 'away'
|
||||
? (resolvedAbsencePolicies.get(member.id)?.policy ?? 'resident') ===
|
||||
'away_rent_and_utilities'
|
||||
: member.status !== 'left',
|
||||
participatesInPurchases: member.status === 'active',
|
||||
rentWeight: member.rentShareWeight
|
||||
})),
|
||||
purchases: convertedPurchases.map(({ purchase, converted }) => ({
|
||||
@@ -374,6 +432,10 @@ async function buildFinanceDashboard(
|
||||
const dashboardMembers = settlement.lines.map((line) => ({
|
||||
memberId: line.memberId.toString(),
|
||||
displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(),
|
||||
status: members.find((member) => member.id === line.memberId.toString())?.status ?? 'active',
|
||||
absencePolicy: resolvedAbsencePolicies.get(line.memberId.toString())?.policy ?? 'resident',
|
||||
absencePolicyEffectiveFromPeriod:
|
||||
resolvedAbsencePolicies.get(line.memberId.toString())?.effectiveFromPeriod ?? null,
|
||||
rentShare: line.rentShare,
|
||||
utilityShare: line.utilityShare,
|
||||
purchaseOffset: line.purchaseOffset,
|
||||
|
||||
@@ -178,7 +178,9 @@ function createRepositoryStub() {
|
||||
}),
|
||||
promoteHouseholdAdmin: async () => null,
|
||||
updateHouseholdMemberRentShareWeight: async () => null,
|
||||
updateHouseholdMemberStatus: async () => null
|
||||
updateHouseholdMemberStatus: async () => null,
|
||||
listHouseholdMemberAbsencePolicies: async () => [],
|
||||
upsertHouseholdMemberAbsencePolicy: async () => null
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -203,6 +203,12 @@ function createRepositoryStub() {
|
||||
},
|
||||
async updateHouseholdMemberStatus() {
|
||||
return null
|
||||
},
|
||||
async listHouseholdMemberAbsencePolicies() {
|
||||
return []
|
||||
},
|
||||
async upsertHouseholdMemberAbsencePolicy() {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -337,6 +337,14 @@ function createRepositoryStub() {
|
||||
}
|
||||
members.set(`${householdId}:${member.telegramUserId}`, next)
|
||||
return next
|
||||
},
|
||||
|
||||
async listHouseholdMemberAbsencePolicies() {
|
||||
return []
|
||||
},
|
||||
|
||||
async upsertHouseholdMemberAbsencePolicy() {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,9 @@ function createRepository(): HouseholdConfigurationRepository {
|
||||
}),
|
||||
promoteHouseholdAdmin: async () => null,
|
||||
updateHouseholdMemberRentShareWeight: async () => null,
|
||||
updateHouseholdMemberStatus: async () => null
|
||||
updateHouseholdMemberStatus: async () => null,
|
||||
listHouseholdMemberAbsencePolicies: async () => [],
|
||||
upsertHouseholdMemberAbsencePolicy: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ import type { HouseholdConfigurationRepository } from '@household/ports'
|
||||
import { createMiniAppAdminService } from './miniapp-admin-service'
|
||||
|
||||
function repository(): HouseholdConfigurationRepository {
|
||||
let memberAbsencePolicies: {
|
||||
householdId: string
|
||||
memberId: string
|
||||
effectiveFromPeriod: string
|
||||
policy: 'resident' | 'away_rent_and_utilities' | 'away_rent_only' | 'inactive'
|
||||
}[] = []
|
||||
|
||||
return {
|
||||
registerTelegramHouseholdChat: async () => ({
|
||||
status: 'existing',
|
||||
@@ -68,7 +75,19 @@ function repository(): HouseholdConfigurationRepository {
|
||||
isAdmin: input.isAdmin === true
|
||||
}),
|
||||
getHouseholdMember: async () => null,
|
||||
listHouseholdMembers: async () => [],
|
||||
listHouseholdMembers: async () => [
|
||||
{
|
||||
id: 'member-123456',
|
||||
householdId: 'household-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
status: 'active',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}
|
||||
],
|
||||
listHouseholdMembersByTelegramUserId: async () => [],
|
||||
listPendingHouseholdMembers: async () => [
|
||||
{
|
||||
@@ -189,7 +208,28 @@ function repository(): HouseholdConfigurationRepository {
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}
|
||||
: null
|
||||
: null,
|
||||
listHouseholdMemberAbsencePolicies: async () => memberAbsencePolicies,
|
||||
upsertHouseholdMemberAbsencePolicy: async (input) => {
|
||||
const next = {
|
||||
householdId: input.householdId,
|
||||
memberId: input.memberId,
|
||||
effectiveFromPeriod: input.effectiveFromPeriod,
|
||||
policy: input.policy
|
||||
}
|
||||
memberAbsencePolicies = [
|
||||
...memberAbsencePolicies.filter(
|
||||
(entry) =>
|
||||
!(
|
||||
entry.householdId === input.householdId &&
|
||||
entry.memberId === input.memberId &&
|
||||
entry.effectiveFromPeriod === input.effectiveFromPeriod
|
||||
)
|
||||
),
|
||||
next
|
||||
]
|
||||
return next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +264,20 @@ describe('createMiniAppAdminService', () => {
|
||||
}
|
||||
],
|
||||
categories: [],
|
||||
members: []
|
||||
members: [
|
||||
{
|
||||
id: 'member-123456',
|
||||
householdId: 'household-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
status: 'active',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}
|
||||
],
|
||||
memberAbsencePolicies: []
|
||||
})
|
||||
})
|
||||
|
||||
@@ -259,6 +312,27 @@ describe('createMiniAppAdminService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('stores an away absence policy from the current local period', async () => {
|
||||
const service = createMiniAppAdminService(repository())
|
||||
|
||||
const result = await service.updateMemberAbsencePolicy({
|
||||
householdId: 'household-1',
|
||||
actorIsAdmin: true,
|
||||
memberId: 'member-123456',
|
||||
policy: 'away_rent_only'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'ok',
|
||||
policy: {
|
||||
householdId: 'household-1',
|
||||
memberId: 'member-123456',
|
||||
effectiveFromPeriod: '2026-03',
|
||||
policy: 'away_rent_only'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('upserts utility categories for admins', async () => {
|
||||
const service = createMiniAppAdminService(repository())
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type {
|
||||
HouseholdBillingSettingsRecord,
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdMemberAbsencePolicy,
|
||||
HouseholdMemberAbsencePolicyRecord,
|
||||
HouseholdMemberLifecycleStatus,
|
||||
HouseholdMemberRecord,
|
||||
HouseholdPendingMemberRecord,
|
||||
HouseholdTopicBindingRecord,
|
||||
HouseholdUtilityCategoryRecord
|
||||
} from '@household/ports'
|
||||
import { Money, type CurrencyCode } from '@household/domain'
|
||||
import { Money, Temporal, type CurrencyCode } from '@household/domain'
|
||||
|
||||
function isValidDay(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 1 && value <= 31
|
||||
@@ -29,6 +31,7 @@ export interface MiniAppAdminService {
|
||||
settings: HouseholdBillingSettingsRecord
|
||||
categories: readonly HouseholdUtilityCategoryRecord[]
|
||||
members: readonly HouseholdMemberRecord[]
|
||||
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
|
||||
topics: readonly HouseholdTopicBindingRecord[]
|
||||
}
|
||||
| {
|
||||
@@ -142,6 +145,29 @@ export interface MiniAppAdminService {
|
||||
reason: 'not_admin' | 'member_not_found'
|
||||
}
|
||||
>
|
||||
updateMemberAbsencePolicy(input: {
|
||||
householdId: string
|
||||
actorIsAdmin: boolean
|
||||
memberId: string
|
||||
policy: HouseholdMemberAbsencePolicy
|
||||
}): Promise<
|
||||
| {
|
||||
status: 'ok'
|
||||
policy: HouseholdMemberAbsencePolicyRecord
|
||||
}
|
||||
| {
|
||||
status: 'rejected'
|
||||
reason: 'not_admin' | 'member_not_found'
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
function localDateInTimezone(timezone: string) {
|
||||
return Temporal.Now.instant().toZonedDateTimeISO(timezone).toPlainDate()
|
||||
}
|
||||
|
||||
function periodFromLocalDate(localDate: Temporal.PlainDate): string {
|
||||
return `${localDate.year}-${String(localDate.month).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function createMiniAppAdminService(
|
||||
@@ -156,10 +182,11 @@ export function createMiniAppAdminService(
|
||||
}
|
||||
}
|
||||
|
||||
const [settings, categories, members, topics] = await Promise.all([
|
||||
const [settings, categories, members, memberAbsencePolicies, topics] = await Promise.all([
|
||||
repository.getHouseholdBillingSettings(input.householdId),
|
||||
repository.listHouseholdUtilityCategories(input.householdId),
|
||||
repository.listHouseholdMembers(input.householdId),
|
||||
repository.listHouseholdMemberAbsencePolicies(input.householdId),
|
||||
repository.listHouseholdTopicBindings(input.householdId)
|
||||
])
|
||||
|
||||
@@ -168,6 +195,7 @@ export function createMiniAppAdminService(
|
||||
settings,
|
||||
categories,
|
||||
members,
|
||||
memberAbsencePolicies,
|
||||
topics
|
||||
}
|
||||
},
|
||||
@@ -395,6 +423,47 @@ export function createMiniAppAdminService(
|
||||
status: 'ok',
|
||||
member
|
||||
}
|
||||
},
|
||||
|
||||
async updateMemberAbsencePolicy(input) {
|
||||
if (!input.actorIsAdmin) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'not_admin'
|
||||
}
|
||||
}
|
||||
|
||||
const [member, settings] = await Promise.all([
|
||||
repository.listHouseholdMembers(input.householdId),
|
||||
repository.getHouseholdBillingSettings(input.householdId)
|
||||
])
|
||||
const target = member.find((candidate) => candidate.id === input.memberId)
|
||||
if (!target) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'member_not_found'
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveFromPeriod = periodFromLocalDate(localDateInTimezone(settings.timezone))
|
||||
const policy = await repository.upsertHouseholdMemberAbsencePolicy({
|
||||
householdId: input.householdId,
|
||||
memberId: input.memberId,
|
||||
effectiveFromPeriod,
|
||||
policy: input.policy
|
||||
})
|
||||
|
||||
if (!policy) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'member_not_found'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
policy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,4 +183,58 @@ describe('calculateMonthlySettlement', () => {
|
||||
|
||||
expect(() => calculateMonthlySettlement(input)).toThrow(DomainError)
|
||||
})
|
||||
|
||||
test('excludes away members from utilities and purchases when policy requires it', () => {
|
||||
const input = {
|
||||
...fixtureBase(),
|
||||
utilitySplitMode: 'equal' as const,
|
||||
members: [
|
||||
{ memberId: MemberId.from('resident-a'), active: true },
|
||||
{ memberId: MemberId.from('resident-b'), active: true },
|
||||
{
|
||||
memberId: MemberId.from('away-member'),
|
||||
active: true,
|
||||
participatesInUtilities: false,
|
||||
participatesInPurchases: false
|
||||
}
|
||||
],
|
||||
purchases: [
|
||||
{
|
||||
purchaseId: PurchaseEntryId.from('p1'),
|
||||
payerId: MemberId.from('resident-a'),
|
||||
amount: Money.fromMajor('30.00', 'USD')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const result = calculateMonthlySettlement(input)
|
||||
|
||||
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([6000n, 6000n, 0n])
|
||||
expect(result.lines.map((line) => line.purchaseOffset.amountMinor)).toEqual([-1500n, 1500n, 0n])
|
||||
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([27834n, 30833n, 23333n])
|
||||
})
|
||||
|
||||
test('excludes inactive members from all future charges', () => {
|
||||
const input = {
|
||||
...fixtureBase(),
|
||||
utilitySplitMode: 'equal' as const,
|
||||
members: [
|
||||
{ memberId: MemberId.from('resident-a'), active: true },
|
||||
{
|
||||
memberId: MemberId.from('inactive-member'),
|
||||
active: true,
|
||||
participatesInRent: false,
|
||||
participatesInUtilities: false,
|
||||
participatesInPurchases: false
|
||||
}
|
||||
],
|
||||
purchases: []
|
||||
}
|
||||
|
||||
const result = calculateMonthlySettlement(input)
|
||||
|
||||
expect(result.lines.map((line) => line.rentShare.amountMinor)).toEqual([70000n, 0n])
|
||||
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([12000n, 0n])
|
||||
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([82000n, 0n])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,53 @@ function ensureActiveMembers(
|
||||
return active
|
||||
}
|
||||
|
||||
function rentParticipants(
|
||||
members: readonly SettlementMemberInput[]
|
||||
): readonly SettlementMemberInput[] {
|
||||
const participants = members.filter((member) => member.participatesInRent !== false)
|
||||
|
||||
if (participants.length === 0) {
|
||||
throw new DomainError(
|
||||
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||
'Settlement must include at least one rent participant'
|
||||
)
|
||||
}
|
||||
|
||||
return participants
|
||||
}
|
||||
|
||||
function utilityParticipants(
|
||||
members: readonly SettlementMemberInput[],
|
||||
utilities: Money
|
||||
): readonly SettlementMemberInput[] {
|
||||
const participants = members.filter((member) => member.participatesInUtilities !== false)
|
||||
|
||||
if (participants.length === 0 && utilities.amountMinor > 0n) {
|
||||
throw new DomainError(
|
||||
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||
'Settlement must include at least one utilities participant when utilities are present'
|
||||
)
|
||||
}
|
||||
|
||||
return participants
|
||||
}
|
||||
|
||||
function purchaseParticipants(
|
||||
members: readonly SettlementMemberInput[],
|
||||
amount: Money
|
||||
): readonly SettlementMemberInput[] {
|
||||
const participants = members.filter((member) => member.participatesInPurchases !== false)
|
||||
|
||||
if (participants.length === 0 && amount.amountMinor > 0n) {
|
||||
throw new DomainError(
|
||||
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||
'Settlement must include at least one purchase participant when purchases are present'
|
||||
)
|
||||
}
|
||||
|
||||
return participants
|
||||
}
|
||||
|
||||
function ensureNonNegativeMoney(label: string, value: Money): void {
|
||||
if (value.isNegative()) {
|
||||
throw new DomainError(
|
||||
@@ -134,13 +181,15 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
|
||||
|
||||
const currency = input.rent.currency
|
||||
const activeMembers = ensureActiveMembers(input.members)
|
||||
const rentMembers = rentParticipants(activeMembers)
|
||||
const utilityMembers = utilityParticipants(activeMembers, input.utilities)
|
||||
|
||||
const membersById = new Map<string, ComputationMember>(
|
||||
activeMembers.map((member) => [member.memberId.toString(), createMemberState(member, currency)])
|
||||
)
|
||||
|
||||
const rentShares = input.rent.splitByWeights(validateRentWeights(activeMembers))
|
||||
for (const [index, member] of activeMembers.entries()) {
|
||||
const rentShares = input.rent.splitByWeights(validateRentWeights(rentMembers))
|
||||
for (const [index, member] of rentMembers.entries()) {
|
||||
const state = membersById.get(member.memberId.toString())
|
||||
if (!state) {
|
||||
continue
|
||||
@@ -149,18 +198,20 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
|
||||
state.rentShare = rentShares[index] ?? Money.zero(currency)
|
||||
}
|
||||
|
||||
const utilityShares =
|
||||
input.utilitySplitMode === 'equal'
|
||||
? input.utilities.splitEvenly(activeMembers.length)
|
||||
: input.utilities.splitByWeights(validateWeightedUtilityDays(activeMembers))
|
||||
if (utilityMembers.length > 0) {
|
||||
const utilityShares =
|
||||
input.utilitySplitMode === 'equal'
|
||||
? input.utilities.splitEvenly(utilityMembers.length)
|
||||
: input.utilities.splitByWeights(validateWeightedUtilityDays(utilityMembers))
|
||||
|
||||
for (const [index, member] of activeMembers.entries()) {
|
||||
const state = membersById.get(member.memberId.toString())
|
||||
if (!state) {
|
||||
continue
|
||||
for (const [index, member] of utilityMembers.entries()) {
|
||||
const state = membersById.get(member.memberId.toString())
|
||||
if (!state) {
|
||||
continue
|
||||
}
|
||||
|
||||
state.utilityShare = utilityShares[index] ?? Money.zero(currency)
|
||||
}
|
||||
|
||||
state.utilityShare = utilityShares[index] ?? Money.zero(currency)
|
||||
}
|
||||
|
||||
for (const purchase of input.purchases) {
|
||||
@@ -176,8 +227,9 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
|
||||
|
||||
payer.purchasePaid = payer.purchasePaid.add(purchase.amount)
|
||||
|
||||
const purchaseShares = purchase.amount.splitEvenly(activeMembers.length)
|
||||
for (const [index, member] of activeMembers.entries()) {
|
||||
const participants = purchaseParticipants(activeMembers, purchase.amount)
|
||||
const purchaseShares = purchase.amount.splitEvenly(participants.length)
|
||||
for (const [index, member] of participants.entries()) {
|
||||
const state = membersById.get(member.memberId.toString())
|
||||
if (!state) {
|
||||
continue
|
||||
|
||||
14
packages/db/drizzle/0015_white_owl.sql
Normal file
14
packages/db/drizzle/0015_white_owl.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "member_absence_policies" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"member_id" uuid NOT NULL,
|
||||
"effective_from_period" text NOT NULL,
|
||||
"policy" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "member_absence_policies" ADD CONSTRAINT "member_absence_policies_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "member_absence_policies" ADD CONSTRAINT "member_absence_policies_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "member_absence_policies_household_member_period_unique" ON "member_absence_policies" USING btree ("household_id","member_id","effective_from_period");--> statement-breakpoint
|
||||
CREATE INDEX "member_absence_policies_household_member_idx" ON "member_absence_policies" USING btree ("household_id","member_id");
|
||||
3078
packages/db/drizzle/meta/0015_snapshot.json
Normal file
3078
packages/db/drizzle/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
||||
"when": 1773222186943,
|
||||
"tag": "0014_empty_risque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1773223414625,
|
||||
"tag": "0015_white_owl",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -209,6 +209,32 @@ export const members = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
export const memberAbsencePolicies = pgTable(
|
||||
'member_absence_policies',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
householdId: uuid('household_id')
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: 'cascade' }),
|
||||
memberId: uuid('member_id')
|
||||
.notNull()
|
||||
.references(() => members.id, { onDelete: 'cascade' }),
|
||||
effectiveFromPeriod: text('effective_from_period').notNull(),
|
||||
policy: text('policy').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
householdMemberPeriodUnique: uniqueIndex(
|
||||
'member_absence_policies_household_member_period_unique'
|
||||
).on(table.householdId, table.memberId, table.effectiveFromPeriod),
|
||||
householdMemberIdx: index('member_absence_policies_household_member_idx').on(
|
||||
table.householdId,
|
||||
table.memberId
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
export const billingCycles = pgTable(
|
||||
'billing_cycles',
|
||||
{
|
||||
|
||||
@@ -7,6 +7,9 @@ export type UtilitySplitMode = 'equal' | 'weighted_by_days'
|
||||
export interface SettlementMemberInput {
|
||||
memberId: MemberId
|
||||
active: boolean
|
||||
participatesInRent?: boolean
|
||||
participatesInUtilities?: boolean
|
||||
participatesInPurchases?: boolean
|
||||
rentWeight?: number
|
||||
utilityDays?: number
|
||||
}
|
||||
|
||||
@@ -3,9 +3,16 @@ import type { ReminderTarget } from './reminders'
|
||||
|
||||
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders', 'payments'] as const
|
||||
export const HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES = ['active', 'away', 'left'] as const
|
||||
export const HOUSEHOLD_MEMBER_ABSENCE_POLICIES = [
|
||||
'resident',
|
||||
'away_rent_and_utilities',
|
||||
'away_rent_only',
|
||||
'inactive'
|
||||
] as const
|
||||
|
||||
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
|
||||
export type HouseholdMemberLifecycleStatus = (typeof HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES)[number]
|
||||
export type HouseholdMemberAbsencePolicy = (typeof HOUSEHOLD_MEMBER_ABSENCE_POLICIES)[number]
|
||||
|
||||
export interface HouseholdTelegramChatRecord {
|
||||
householdId: string
|
||||
@@ -52,6 +59,13 @@ export interface HouseholdMemberRecord {
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export interface HouseholdMemberAbsencePolicyRecord {
|
||||
householdId: string
|
||||
memberId: string
|
||||
effectiveFromPeriod: string
|
||||
policy: HouseholdMemberAbsencePolicy
|
||||
}
|
||||
|
||||
export interface HouseholdBillingSettingsRecord {
|
||||
householdId: string
|
||||
settlementCurrency: CurrencyCode
|
||||
@@ -197,4 +211,13 @@ export interface HouseholdConfigurationRepository {
|
||||
memberId: string,
|
||||
status: HouseholdMemberLifecycleStatus
|
||||
): Promise<HouseholdMemberRecord | null>
|
||||
listHouseholdMemberAbsencePolicies(
|
||||
householdId: string
|
||||
): Promise<readonly HouseholdMemberAbsencePolicyRecord[]>
|
||||
upsertHouseholdMemberAbsencePolicy(input: {
|
||||
householdId: string
|
||||
memberId: string
|
||||
effectiveFromPeriod: string
|
||||
policy: HouseholdMemberAbsencePolicy
|
||||
}): Promise<HouseholdMemberAbsencePolicyRecord | null>
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ export type {
|
||||
ReleaseProcessedBotMessageInput
|
||||
} from './processed-bot-messages'
|
||||
export {
|
||||
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
|
||||
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
|
||||
HOUSEHOLD_TOPIC_ROLES,
|
||||
type HouseholdMemberAbsencePolicy,
|
||||
type HouseholdMemberAbsencePolicyRecord,
|
||||
type HouseholdConfigurationRepository,
|
||||
type HouseholdBillingSettingsRecord,
|
||||
type HouseholdJoinTokenRecord,
|
||||
|
||||
Reference in New Issue
Block a user