feat(member): add away billing policies

This commit is contained in:
2026-03-11 14:05:52 +04:00
parent 773abf2531
commit 98988159eb
34 changed files with 4218 additions and 39 deletions

View File

@@ -253,7 +253,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
}), }),
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null
} }
} }

View File

@@ -133,7 +133,9 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null, updateMemberPreferredLocale: async () => null,
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null
} }
} }

View File

@@ -194,7 +194,9 @@ function createHouseholdRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null, updateMemberPreferredLocale: async () => null,
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null
} }
} }

View File

@@ -111,7 +111,9 @@ function createRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null, updateMemberPreferredLocale: async () => null,
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null
} }
} }

View File

@@ -497,6 +497,12 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
}, },
async updateHouseholdMemberStatus() { async updateHouseholdMemberStatus() {
return null return null
},
async listHouseholdMemberAbsencePolicies() {
return []
},
async upsertHouseholdMemberAbsencePolicy() {
return null
} }
} }
} }

View File

@@ -49,6 +49,7 @@ import {
createMiniAppPendingMembersHandler, createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler, createMiniAppPromoteMemberHandler,
createMiniAppSettingsHandler, createMiniAppSettingsHandler,
createMiniAppUpdateMemberAbsencePolicyHandler,
createMiniAppUpdateMemberStatusHandler, createMiniAppUpdateMemberStatusHandler,
createMiniAppUpdateMemberRentWeightHandler, createMiniAppUpdateMemberRentWeightHandler,
createMiniAppUpdateSettingsHandler, createMiniAppUpdateSettingsHandler,
@@ -546,6 +547,15 @@ const server = createBotWebhookServer({
logger: getLogger('miniapp-admin') logger: getLogger('miniapp-admin')
}) })
: undefined, : undefined,
miniAppUpdateMemberAbsencePolicy: householdOnboardingService
? createMiniAppUpdateMemberAbsencePolicyHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppBillingCycle: householdOnboardingService miniAppBillingCycle: householdOnboardingService
? createMiniAppBillingCycleHandler({ ? createMiniAppBillingCycleHandler({
allowedOrigins: runtime.miniAppAllowedOrigins, allowedOrigins: runtime.miniAppAllowedOrigins,

View File

@@ -11,6 +11,7 @@ import {
createMiniAppPendingMembersHandler, createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler, createMiniAppPromoteMemberHandler,
createMiniAppSettingsHandler, createMiniAppSettingsHandler,
createMiniAppUpdateMemberAbsencePolicyHandler,
createMiniAppUpdateMemberStatusHandler, createMiniAppUpdateMemberStatusHandler,
createMiniAppUpdateSettingsHandler createMiniAppUpdateSettingsHandler
} from './miniapp-admin' } from './miniapp-admin'
@@ -25,6 +26,12 @@ function onboardingRepository(): HouseholdConfigurationRepository {
title: 'Kojori House', title: 'Kojori House',
defaultLocale: 'ru' as const defaultLocale: 'ru' as const
} }
let memberAbsencePolicies: {
householdId: string
memberId: string
effectiveFromPeriod: string
policy: 'resident' | 'away_rent_and_utilities' | 'away_rent_only' | 'inactive'
}[] = []
return { return {
registerTelegramHouseholdChat: async () => ({ registerTelegramHouseholdChat: async () => ({
@@ -83,7 +90,19 @@ function onboardingRepository(): HouseholdConfigurationRepository {
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }),
getHouseholdMember: async () => null, getHouseholdMember: async () => null,
listHouseholdMembers: async () => [], listHouseholdMembers: async () => [
{
id: 'member-123456',
householdId: household.householdId,
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: true
}
],
listHouseholdMembersByTelegramUserId: async () => [], listHouseholdMembersByTelegramUserId: async () => [],
listPendingHouseholdMembers: async () => [ listPendingHouseholdMembers: async () => [
{ {
@@ -208,7 +227,28 @@ function onboardingRepository(): HouseholdConfigurationRepository {
rentShareWeight: 1, rentShareWeight: 1,
isAdmin: false 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
}
} }
} }
@@ -423,6 +463,7 @@ describe('createMiniAppSettingsHandler', () => {
} }
], ],
categories: [], categories: [],
memberAbsencePolicies: [],
assistantUsage: [], assistantUsage: [],
members: [ members: [
{ {
@@ -641,4 +682,63 @@ describe('createMiniAppUpdateMemberStatusHandler', () => {
} }
}) })
}) })
test('updates a household member absence policy for an authenticated admin', async () => {
const authDate = Math.floor(Date.now() / 1000)
const repository = onboardingRepository()
repository.listHouseholdMembersByTelegramUserId = async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
const handler = createMiniAppUpdateMemberAbsencePolicyHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
onboardingService: createHouseholdOnboardingService({
repository
}),
miniAppAdminService: createMiniAppAdminService(repository)
})
const response = await handler.handler(
new Request('http://localhost/api/miniapp/admin/members/absence-policy', {
method: 'POST',
headers: {
origin: 'http://localhost:5173',
'content-type': 'application/json'
},
body: JSON.stringify({
initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456,
first_name: 'Stan',
username: 'stanislav',
language_code: 'ru'
}),
memberId: 'member-123456',
policy: 'away_rent_only'
})
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
ok: true,
authorized: true,
policy: {
householdId: 'household-1',
memberId: 'member-123456',
effectiveFromPeriod: '2026-03',
policy: 'away_rent_only'
}
})
})
}) })

View File

@@ -1,8 +1,10 @@
import type { HouseholdOnboardingService, MiniAppAdminService } from '@household/application' import type { HouseholdOnboardingService, MiniAppAdminService } from '@household/application'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import { import {
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES, HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
type HouseholdBillingSettingsRecord, type HouseholdBillingSettingsRecord,
type HouseholdMemberAbsencePolicy,
type HouseholdMemberLifecycleStatus type HouseholdMemberLifecycleStatus
} from '@household/ports' } from '@household/ports'
import type { MiniAppSessionResult } from './miniapp-auth' import type { MiniAppSessionResult } from './miniapp-auth'
@@ -257,6 +259,42 @@ async function readMemberStatusPayload(request: Request): Promise<{
} }
} }
async function readMemberAbsencePolicyPayload(request: Request): Promise<{
initData: string
memberId: string
policy: HouseholdMemberAbsencePolicy
}> {
const clonedRequest = request.clone()
const payload = await readMiniAppRequestPayload(request)
if (!payload.initData) {
throw new Error('Missing initData')
}
const text = await clonedRequest.text()
let parsed: { memberId?: string; policy?: string }
try {
parsed = JSON.parse(text)
} catch {
throw new Error('Invalid JSON body')
}
const memberId = parsed.memberId?.trim()
const policy = parsed.policy?.trim().toLowerCase()
if (!memberId || !policy) {
throw new Error('Missing member absence policy fields')
}
if (!(HOUSEHOLD_MEMBER_ABSENCE_POLICIES as readonly string[]).includes(policy)) {
throw new Error('Invalid member absence policy')
}
return {
initData: payload.initData,
memberId,
policy: policy as HouseholdMemberAbsencePolicy
}
}
function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) { function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) {
return { return {
householdId: settings.householdId, householdId: settings.householdId,
@@ -442,6 +480,7 @@ export function createMiniAppSettingsHandler(options: {
topics: result.topics, topics: result.topics,
categories: result.categories, categories: result.categories,
members: result.members, members: result.members,
memberAbsencePolicies: result.memberAbsencePolicies,
assistantUsage: assistantUsage:
options.assistantUsageTracker?.listHouseholdUsage(member.householdId) ?? [] options.assistantUsageTracker?.listHouseholdUsage(member.householdId) ?? []
}, },
@@ -923,6 +962,87 @@ export function createMiniAppUpdateMemberStatusHandler(options: {
} }
} }
export function createMiniAppUpdateMemberAbsencePolicyHandler(options: {
allowedOrigins: readonly string[]
botToken: string
onboardingService: HouseholdOnboardingService
miniAppAdminService: MiniAppAdminService
logger?: Logger
}): {
handler: (request: Request) => Promise<Response>
} {
const sessionService = createMiniAppSessionService({
botToken: options.botToken,
onboardingService: options.onboardingService
})
return {
handler: async (request) => {
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
if (request.method === 'OPTIONS') {
return miniAppJsonResponse({ ok: true }, 204, origin)
}
if (request.method !== 'POST') {
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
}
try {
const payload = await readMemberAbsencePolicyPayload(request)
const session = await sessionService.authenticate({
initData: payload.initData
})
if (
!session ||
!session.authorized ||
!session.member ||
session.member.status !== 'active' ||
!session.member.isAdmin
) {
return miniAppJsonResponse(
{ ok: false, error: 'Admin access required for active household members' },
session ? 403 : 401,
origin
)
}
const result = await options.miniAppAdminService.updateMemberAbsencePolicy({
householdId: session.member.householdId,
actorIsAdmin: session.member.isAdmin,
memberId: payload.memberId,
policy: payload.policy
})
if (result.status === 'rejected') {
return miniAppJsonResponse(
{
ok: false,
error:
result.reason === 'member_not_found' ? 'Member not found' : 'Admin access required'
},
result.reason === 'member_not_found' ? 404 : 403,
origin
)
}
return miniAppJsonResponse(
{
ok: true,
authorized: true,
policy: result.policy
},
200,
origin
)
} catch (error) {
return miniAppErrorResponse(error, origin, options.logger)
}
}
}
}
export function createMiniAppApproveMemberHandler(options: { export function createMiniAppApproveMemberHandler(options: {
allowedOrigins: readonly string[] allowedOrigins: readonly string[]
botToken: string botToken: string

View File

@@ -154,6 +154,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
} }
: null : null
}, },
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null,
getHouseholdBillingSettings: async (householdId) => ({ getHouseholdBillingSettings: async (householdId) => ({
householdId, householdId,
settlementCurrency: 'GEL', settlementCurrency: 'GEL',

View File

@@ -132,7 +132,9 @@ function onboardingRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null, updateMemberPreferredLocale: async () => null,
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null
} }
} }

View File

@@ -185,7 +185,19 @@ function onboardingRepository(): HouseholdConfigurationRepository {
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }),
getHouseholdMember: async () => null, getHouseholdMember: async () => null,
listHouseholdMembers: async () => [], listHouseholdMembers: async () => [
{
id: 'member-1',
householdId: household.householdId,
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: true
}
],
listHouseholdMembersByTelegramUserId: async () => [], listHouseholdMembersByTelegramUserId: async () => [],
listPendingHouseholdMembers: async () => [], listPendingHouseholdMembers: async () => [],
approvePendingHouseholdMember: async () => null, approvePendingHouseholdMember: async () => null,
@@ -227,7 +239,9 @@ function onboardingRepository(): HouseholdConfigurationRepository {
}), }),
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null
} }
} }

View File

@@ -165,7 +165,9 @@ function repository(): HouseholdConfigurationRepository {
}), }),
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null
} }
} }

View File

@@ -68,6 +68,12 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response> handler: (request: Request) => Promise<Response>
} }
| undefined | undefined
miniAppUpdateMemberAbsencePolicy?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppBillingCycle?: miniAppBillingCycle?:
| { | {
path?: string path?: string
@@ -194,6 +200,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
options.miniAppUpdateMemberRentWeight?.path ?? '/api/miniapp/admin/members/rent-weight' options.miniAppUpdateMemberRentWeight?.path ?? '/api/miniapp/admin/members/rent-weight'
const miniAppUpdateMemberStatusPath = const miniAppUpdateMemberStatusPath =
options.miniAppUpdateMemberStatus?.path ?? '/api/miniapp/admin/members/status' options.miniAppUpdateMemberStatus?.path ?? '/api/miniapp/admin/members/status'
const miniAppUpdateMemberAbsencePolicyPath =
options.miniAppUpdateMemberAbsencePolicy?.path ?? '/api/miniapp/admin/members/absence-policy'
const miniAppBillingCyclePath = const miniAppBillingCyclePath =
options.miniAppBillingCycle?.path ?? '/api/miniapp/admin/billing-cycle' options.miniAppBillingCycle?.path ?? '/api/miniapp/admin/billing-cycle'
const miniAppOpenCyclePath = const miniAppOpenCyclePath =
@@ -280,6 +288,13 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppUpdateMemberStatus.handler(request) return await options.miniAppUpdateMemberStatus.handler(request)
} }
if (
options.miniAppUpdateMemberAbsencePolicy &&
url.pathname === miniAppUpdateMemberAbsencePolicyPath
) {
return await options.miniAppUpdateMemberAbsencePolicy.handler(request)
}
if (options.miniAppBillingCycle && url.pathname === miniAppBillingCyclePath) { if (options.miniAppBillingCycle && url.pathname === miniAppBillingCyclePath) {
return await options.miniAppBillingCycle.handler(request) return await options.miniAppBillingCycle.handler(request)
} }

View File

@@ -17,10 +17,12 @@ import {
joinMiniAppHousehold, joinMiniAppHousehold,
openMiniAppBillingCycle, openMiniAppBillingCycle,
promoteMiniAppMember, promoteMiniAppMember,
updateMiniAppMemberAbsencePolicy,
updateMiniAppMemberStatus, updateMiniAppMemberStatus,
updateMiniAppMemberRentWeight, updateMiniAppMemberRentWeight,
type MiniAppAdminCycleState, type MiniAppAdminCycleState,
type MiniAppAdminSettingsPayload, type MiniAppAdminSettingsPayload,
type MiniAppMemberAbsencePolicy,
updateMiniAppLocalePreference, updateMiniAppLocalePreference,
updateMiniAppBillingSettings, updateMiniAppBillingSettings,
updateMiniAppCycleRent, updateMiniAppCycleRent,
@@ -282,10 +284,16 @@ function App() {
const [promotingMemberId, setPromotingMemberId] = createSignal<string | null>(null) const [promotingMemberId, setPromotingMemberId] = createSignal<string | null>(null)
const [savingRentWeightMemberId, setSavingRentWeightMemberId] = createSignal<string | null>(null) const [savingRentWeightMemberId, setSavingRentWeightMemberId] = createSignal<string | null>(null)
const [savingMemberStatusId, setSavingMemberStatusId] = createSignal<string | null>(null) const [savingMemberStatusId, setSavingMemberStatusId] = createSignal<string | null>(null)
const [savingMemberAbsencePolicyId, setSavingMemberAbsencePolicyId] = createSignal<string | null>(
null
)
const [rentWeightDrafts, setRentWeightDrafts] = createSignal<Record<string, string>>({}) const [rentWeightDrafts, setRentWeightDrafts] = createSignal<Record<string, string>>({})
const [memberStatusDrafts, setMemberStatusDrafts] = createSignal< const [memberStatusDrafts, setMemberStatusDrafts] = createSignal<
Record<string, 'active' | 'away' | 'left'> Record<string, 'active' | 'away' | 'left'>
>({}) >({})
const [memberAbsencePolicyDrafts, setMemberAbsencePolicyDrafts] = createSignal<
Record<string, MiniAppMemberAbsencePolicy>
>({})
const [savingMemberLocale, setSavingMemberLocale] = createSignal(false) const [savingMemberLocale, setSavingMemberLocale] = createSignal(false)
const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false) const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false)
const [savingBillingSettings, setSavingBillingSettings] = createSignal(false) const [savingBillingSettings, setSavingBillingSettings] = createSignal(false)
@@ -400,6 +408,39 @@ function App() {
} }
} }
function defaultAbsencePolicyForStatus(
status: 'active' | 'away' | 'left'
): MiniAppMemberAbsencePolicy {
if (status === 'away') {
return 'away_rent_and_utilities'
}
if (status === 'left') {
return 'inactive'
}
return 'resident'
}
function resolvedMemberAbsencePolicy(
memberId: string,
status: 'active' | 'away' | 'left',
settings = adminSettings()
) {
const current = settings?.memberAbsencePolicies
.filter((policy) => policy.memberId === memberId)
.sort((left, right) => left.effectiveFromPeriod.localeCompare(right.effectiveFromPeriod))
.at(-1)
return (
current ?? {
memberId,
effectiveFromPeriod: '',
policy: defaultAbsencePolicyForStatus(status)
}
)
}
async function loadDashboard(initData: string) { async function loadDashboard(initData: string) {
try { try {
const nextDashboard = await fetchMiniAppDashboard(initData) const nextDashboard = await fetchMiniAppDashboard(initData)
@@ -441,6 +482,14 @@ function App() {
setMemberStatusDrafts( setMemberStatusDrafts(
Object.fromEntries(payload.members.map((member) => [member.id, member.status])) Object.fromEntries(payload.members.map((member) => [member.id, member.status]))
) )
setMemberAbsencePolicyDrafts(
Object.fromEntries(
payload.members.map((member) => [
member.id,
resolvedMemberAbsencePolicy(member.id, member.status, payload).policy
])
)
)
setCycleForm((current) => ({ setCycleForm((current) => ({
...current, ...current,
rentCurrency: payload.settings.rentCurrency, rentCurrency: payload.settings.rentCurrency,
@@ -1276,11 +1325,66 @@ function App() {
...current, ...current,
[member.id]: member.status [member.id]: member.status
})) }))
setMemberAbsencePolicyDrafts((current) => ({
...current,
[member.id]:
current[member.id] ??
resolvedMemberAbsencePolicy(member.id, member.status).policy ??
defaultAbsencePolicyForStatus(member.status)
}))
} finally { } finally {
setSavingMemberStatusId(null) setSavingMemberStatusId(null)
} }
} }
async function handleSaveMemberAbsencePolicy(memberId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const member = adminSettings()?.members.find((entry) => entry.id === memberId)
const nextPolicy = memberAbsencePolicyDrafts()[memberId]
const effectiveStatus = memberStatusDrafts()[memberId] ?? member?.status
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!member ||
!nextPolicy ||
effectiveStatus !== 'away'
) {
return
}
setSavingMemberAbsencePolicyId(memberId)
try {
const savedPolicy = await updateMiniAppMemberAbsencePolicy(initData, memberId, nextPolicy)
setAdminSettings((current) =>
current
? {
...current,
memberAbsencePolicies: [
...current.memberAbsencePolicies.filter(
(policy) =>
!(
policy.memberId === savedPolicy.memberId &&
policy.effectiveFromPeriod === savedPolicy.effectiveFromPeriod
)
),
savedPolicy
]
}
: current
)
setMemberAbsencePolicyDrafts((current) => ({
...current,
[memberId]: savedPolicy.policy
}))
} finally {
setSavingMemberAbsencePolicyId(null)
}
}
const renderPanel = () => { const renderPanel = () => {
switch (activeNav()) { switch (activeNav()) {
case 'balances': case 'balances':
@@ -2447,6 +2551,44 @@ function App() {
<option value="left">{copy().memberStatusLeft}</option> <option value="left">{copy().memberStatusLeft}</option>
</select> </select>
</label> </label>
<label class="settings-field settings-field--wide">
<span>{copy().absencePolicyLabel}</span>
<select
value={
memberAbsencePolicyDrafts()[member.id] ??
resolvedMemberAbsencePolicy(member.id, member.status).policy
}
disabled={
(memberStatusDrafts()[member.id] ?? member.status) !== 'away'
}
onChange={(event) =>
setMemberAbsencePolicyDrafts((current) => ({
...current,
[member.id]: event.currentTarget
.value as MiniAppMemberAbsencePolicy
}))
}
>
<option value="away_rent_and_utilities">
{copy().absencePolicyAwayRentAndUtilities}
</option>
<option value="away_rent_only">
{copy().absencePolicyAwayRentOnly}
</option>
<option value="inactive">{copy().absencePolicyInactive}</option>
<option value="resident">{copy().absencePolicyResident}</option>
</select>
<small>
{resolvedMemberAbsencePolicy(member.id, member.status)
.effectiveFromPeriod
? copy().absencePolicyEffectiveFrom.replace(
'{period}',
resolvedMemberAbsencePolicy(member.id, member.status)
.effectiveFromPeriod
)
: copy().absencePolicyHint}
</small>
</label>
<label class="settings-field settings-field--wide"> <label class="settings-field settings-field--wide">
<span>{copy().rentWeightLabel}</span> <span>{copy().rentWeightLabel}</span>
<input <input
@@ -2474,6 +2616,19 @@ function App() {
? copy().savingMemberStatus ? copy().savingMemberStatus
: copy().saveMemberStatusAction} : copy().saveMemberStatusAction}
</button> </button>
<button
class="ghost-button"
type="button"
disabled={
savingMemberAbsencePolicyId() === member.id ||
(memberStatusDrafts()[member.id] ?? member.status) !== 'away'
}
onClick={() => void handleSaveMemberAbsencePolicy(member.id)}
>
{savingMemberAbsencePolicyId() === member.id
? copy().savingAbsencePolicy
: copy().saveAbsencePolicyAction}
</button>
<button <button
class="ghost-button" class="ghost-button"
type="button" type="button"

View File

@@ -144,6 +144,15 @@ export const dictionary = {
memberStatusActive: 'Active', memberStatusActive: 'Active',
memberStatusAway: 'Away', memberStatusAway: 'Away',
memberStatusLeft: 'Left', memberStatusLeft: 'Left',
absencePolicyLabel: 'Away billing policy',
absencePolicyResident: 'Resident billing',
absencePolicyAwayRentAndUtilities: 'Away: rent and utilities',
absencePolicyAwayRentOnly: 'Away: rent only',
absencePolicyInactive: 'Inactive / moved out',
absencePolicyHint: 'Applies to future cycle calculations for away members.',
absencePolicyEffectiveFrom: 'Effective from {period}',
saveAbsencePolicyAction: 'Save away policy',
savingAbsencePolicy: 'Saving policy…',
memberStatusSummary: 'Your household status: {status}.', memberStatusSummary: 'Your household status: {status}.',
rentWeightLabel: 'Rent weight', rentWeightLabel: 'Rent weight',
saveRentWeightAction: 'Save rent weight', saveRentWeightAction: 'Save rent weight',
@@ -309,6 +318,15 @@ export const dictionary = {
memberStatusActive: 'Активный', memberStatusActive: 'Активный',
memberStatusAway: 'В отъезде', memberStatusAway: 'В отъезде',
memberStatusLeft: 'Выехал', memberStatusLeft: 'Выехал',
absencePolicyLabel: 'Политика начислений в отъезде',
absencePolicyResident: 'Как у проживающего',
absencePolicyAwayRentAndUtilities: 'В отъезде: аренда и коммуналка',
absencePolicyAwayRentOnly: 'В отъезде: только аренда',
absencePolicyInactive: 'Неактивен / выехал',
absencePolicyHint: 'Применяется к будущим расчётам для участников со статусом «В отъезде».',
absencePolicyEffectiveFrom: 'Действует с {period}',
saveAbsencePolicyAction: 'Сохранить политику',
savingAbsencePolicy: 'Сохраняем политику…',
memberStatusSummary: 'Твой статус в household: {status}.', memberStatusSummary: 'Твой статус в household: {status}.',
rentWeightLabel: 'Вес аренды', rentWeightLabel: 'Вес аренды',
saveRentWeightAction: 'Сохранить вес аренды', saveRentWeightAction: 'Сохранить вес аренды',

View File

@@ -37,6 +37,18 @@ export interface MiniAppPendingMember {
languageCode: string | null languageCode: string | null
} }
export type MiniAppMemberAbsencePolicy =
| 'resident'
| 'away_rent_and_utilities'
| 'away_rent_only'
| 'inactive'
export interface MiniAppMemberAbsencePolicyRecord {
memberId: string
effectiveFromPeriod: string
policy: MiniAppMemberAbsencePolicy
}
export interface MiniAppMember { export interface MiniAppMember {
id: string id: string
displayName: string displayName: string
@@ -116,6 +128,7 @@ export interface MiniAppAdminSettingsPayload {
topics: readonly MiniAppTopicBinding[] topics: readonly MiniAppTopicBinding[]
categories: readonly MiniAppUtilityCategory[] categories: readonly MiniAppUtilityCategory[]
members: readonly MiniAppMember[] members: readonly MiniAppMember[]
memberAbsencePolicies: readonly MiniAppMemberAbsencePolicyRecord[]
} }
export interface MiniAppAdminCycleState { export interface MiniAppAdminCycleState {
@@ -362,6 +375,7 @@ export async function fetchMiniAppAdminSettings(
topics?: MiniAppTopicBinding[] topics?: MiniAppTopicBinding[]
categories?: MiniAppUtilityCategory[] categories?: MiniAppUtilityCategory[]
members?: MiniAppMember[] members?: MiniAppMember[]
memberAbsencePolicies?: MiniAppMemberAbsencePolicyRecord[]
error?: string error?: string
} }
@@ -371,7 +385,8 @@ export async function fetchMiniAppAdminSettings(
!payload.settings || !payload.settings ||
!payload.topics || !payload.topics ||
!payload.categories || !payload.categories ||
!payload.members !payload.members ||
!payload.memberAbsencePolicies
) { ) {
throw new Error(payload.error ?? 'Failed to load admin settings') throw new Error(payload.error ?? 'Failed to load admin settings')
} }
@@ -380,7 +395,8 @@ export async function fetchMiniAppAdminSettings(
settings: payload.settings, settings: payload.settings,
topics: payload.topics, topics: payload.topics,
categories: payload.categories, categories: payload.categories,
members: payload.members members: payload.members,
memberAbsencePolicies: payload.memberAbsencePolicies
} }
} }
@@ -547,6 +563,37 @@ export async function updateMiniAppMemberStatus(
return payload.member return payload.member
} }
export async function updateMiniAppMemberAbsencePolicy(
initData: string,
memberId: string,
policy: MiniAppMemberAbsencePolicy
): Promise<MiniAppMemberAbsencePolicyRecord> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/members/absence-policy`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
memberId,
policy
})
})
const payload = (await response.json()) as {
ok: boolean
authorized?: boolean
policy?: MiniAppMemberAbsencePolicyRecord
error?: string
}
if (!response.ok || !payload.policy) {
throw new Error(payload.error ?? 'Failed to update member absence policy')
}
return payload.policy
}
export async function fetchMiniAppBillingCycle(initData: string): Promise<MiniAppAdminCycleState> { export async function fetchMiniAppBillingCycle(initData: string): Promise<MiniAppAdminCycleState> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/billing-cycle`, { const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/billing-cycle`, {
method: 'POST', method: 'POST',

View File

@@ -8,8 +8,11 @@ import {
type CurrencyCode type CurrencyCode
} from '@household/domain' } from '@household/domain'
import { import {
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES, HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
HOUSEHOLD_TOPIC_ROLES, HOUSEHOLD_TOPIC_ROLES,
type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord,
type HouseholdBillingSettingsRecord, type HouseholdBillingSettingsRecord,
type HouseholdConfigurationRepository, type HouseholdConfigurationRepository,
type HouseholdJoinTokenRecord, type HouseholdJoinTokenRecord,
@@ -44,6 +47,16 @@ function normalizeMemberLifecycleStatus(raw: string): HouseholdMemberLifecycleSt
throw new Error(`Unsupported household member lifecycle status: ${raw}`) 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: { function toHouseholdTelegramChatRecord(row: {
householdId: string householdId: string
householdName: 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 { function utilityCategorySlug(name: string): string {
return name return name
.trim() .trim()
@@ -1343,6 +1370,55 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
...row, ...row,
defaultLocale: household.defaultLocale 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
} }
} }

View File

@@ -19,6 +19,12 @@ class FinanceRepositoryStub implements FinanceRepository {
householdId = 'household-1' householdId = 'household-1'
member: FinanceMemberRecord | null = null member: FinanceMemberRecord | null = null
members: readonly FinanceMemberRecord[] = [] 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 openCycleRecord: FinanceCycleRecord | null = null
cycleByPeriodRecord: FinanceCycleRecord | null = null cycleByPeriodRecord: FinanceCycleRecord | null = null
latestCycleRecord: FinanceCycleRecord | null = null latestCycleRecord: FinanceCycleRecord | null = null
@@ -204,7 +210,7 @@ class FinanceRepositoryStub implements FinanceRepository {
const householdConfigurationRepository: Pick< const householdConfigurationRepository: Pick<
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
'getHouseholdBillingSettings' 'getHouseholdBillingSettings' | 'listHouseholdMembers' | 'listHouseholdMemberAbsencePolicies'
> = { > = {
async getHouseholdBillingSettings(householdId) { async getHouseholdBillingSettings(householdId) {
return { return {
@@ -218,9 +224,43 @@ const householdConfigurationRepository: Pick<
utilitiesReminderDay: 3, utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi' 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 = { const exchangeRateProvider: ExchangeRateProvider = {
async getRate(input) { async getRate(input) {
if (input.baseCurrency === input.quoteCurrency) { if (input.baseCurrency === input.quoteCurrency) {
@@ -254,6 +294,8 @@ const exchangeRateProvider: ExchangeRateProvider = {
} }
function createService(repository: FinanceRepositoryStub) { function createService(repository: FinanceRepositoryStub) {
financeRepositories.set(repository.householdId, repository)
return createFinanceCommandService({ return createFinanceCommandService({
householdId: repository.householdId, householdId: repository.householdId,
repository, repository,
@@ -482,4 +524,83 @@ describe('createFinanceCommandService', () => {
expect(dashboard?.period).toBe('2026-03') 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 }
])
})
}) })

View File

@@ -7,7 +7,10 @@ import type {
FinancePaymentKind, FinancePaymentKind,
FinanceRentRuleRecord, FinanceRentRuleRecord,
FinanceRepository, FinanceRepository,
HouseholdConfigurationRepository HouseholdConfigurationRepository,
HouseholdMemberAbsencePolicy,
HouseholdMemberAbsencePolicyRecord,
HouseholdMemberRecord
} from '@household/ports' } from '@household/ports'
import { import {
BillingCycleId, BillingCycleId,
@@ -100,6 +103,9 @@ function expectedOpenCyclePeriod(
export interface FinanceDashboardMemberLine { export interface FinanceDashboardMemberLine {
memberId: string memberId: string
displayName: string displayName: string
status?: 'active' | 'away' | 'left'
absencePolicy?: HouseholdMemberAbsencePolicy
absencePolicyEffectiveFromPeriod?: string | null
rentShare: Money rentShare: Money
utilityShare: Money utilityShare: Money
purchaseOffset: Money purchaseOffset: Money
@@ -157,11 +163,45 @@ interface FinanceCommandServiceDependencies {
repository: FinanceRepository repository: FinanceRepository
householdConfigurationRepository: Pick< householdConfigurationRepository: Pick<
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
'getHouseholdBillingSettings' 'getHouseholdBillingSettings' | 'listHouseholdMembers' | 'listHouseholdMemberAbsencePolicies'
> >
exchangeRateProvider: ExchangeRateProvider 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 { interface ConvertedCycleMoney {
originalAmount: Money originalAmount: Money
settlementAmount: Money settlementAmount: Money
@@ -240,8 +280,11 @@ async function buildFinanceDashboard(
return null return null
} }
const [members, rentRule, settings] = await Promise.all([ const [members, memberAbsencePolicies, rentRule, settings] = await Promise.all([
dependencies.repository.listMembers(), dependencies.householdConfigurationRepository.listHouseholdMembers(dependencies.householdId),
dependencies.householdConfigurationRepository.listHouseholdMemberAbsencePolicies(
dependencies.householdId
),
dependencies.repository.getRentRuleForPeriod(cycle.period), dependencies.repository.getRentRuleForPeriod(cycle.period),
dependencies.householdConfigurationRepository.getHouseholdBillingSettings( dependencies.householdConfigurationRepository.getHouseholdBillingSettings(
dependencies.householdId dependencies.householdId
@@ -258,6 +301,11 @@ async function buildFinanceDashboard(
const period = BillingPeriod.fromString(cycle.period) const period = BillingPeriod.fromString(cycle.period)
const { start, end } = monthRange(period) const { start, end } = monthRange(period)
const resolvedAbsencePolicies = resolveMemberAbsencePolicies({
members,
policies: memberAbsencePolicies,
period: cycle.period
})
const [purchases, utilityBills] = await Promise.all([ const [purchases, utilityBills] = await Promise.all([
dependencies.repository.listParsedPurchasesForRange(start, end), dependencies.repository.listParsedPurchasesForRange(start, end),
dependencies.repository.listUtilityBillsForCycle(cycle.id) dependencies.repository.listUtilityBillsForCycle(cycle.id)
@@ -319,7 +367,17 @@ async function buildFinanceDashboard(
utilitySplitMode: 'equal', utilitySplitMode: 'equal',
members: members.map((member) => ({ members: members.map((member) => ({
memberId: MemberId.from(member.id), memberId: MemberId.from(member.id),
active: true, active: 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 rentWeight: member.rentShareWeight
})), })),
purchases: convertedPurchases.map(({ purchase, converted }) => ({ purchases: convertedPurchases.map(({ purchase, converted }) => ({
@@ -374,6 +432,10 @@ async function buildFinanceDashboard(
const dashboardMembers = settlement.lines.map((line) => ({ const dashboardMembers = settlement.lines.map((line) => ({
memberId: line.memberId.toString(), memberId: line.memberId.toString(),
displayName: memberNameById.get(line.memberId.toString()) ?? 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, rentShare: line.rentShare,
utilityShare: line.utilityShare, utilityShare: line.utilityShare,
purchaseOffset: line.purchaseOffset, purchaseOffset: line.purchaseOffset,

View File

@@ -178,7 +178,9 @@ function createRepositoryStub() {
}), }),
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null
} }
return { return {

View File

@@ -203,6 +203,12 @@ function createRepositoryStub() {
}, },
async updateHouseholdMemberStatus() { async updateHouseholdMemberStatus() {
return null return null
},
async listHouseholdMemberAbsencePolicies() {
return []
},
async upsertHouseholdMemberAbsencePolicy() {
return null
} }
} }

View File

@@ -337,6 +337,14 @@ function createRepositoryStub() {
} }
members.set(`${householdId}:${member.telegramUserId}`, next) members.set(`${householdId}:${member.telegramUserId}`, next)
return next return next
},
async listHouseholdMemberAbsencePolicies() {
return []
},
async upsertHouseholdMemberAbsencePolicy() {
return null
} }
} }

View File

@@ -111,7 +111,9 @@ function createRepository(): HouseholdConfigurationRepository {
}), }),
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null
} }
} }

View File

@@ -5,6 +5,13 @@ import type { HouseholdConfigurationRepository } from '@household/ports'
import { createMiniAppAdminService } from './miniapp-admin-service' import { createMiniAppAdminService } from './miniapp-admin-service'
function repository(): HouseholdConfigurationRepository { function repository(): HouseholdConfigurationRepository {
let memberAbsencePolicies: {
householdId: string
memberId: string
effectiveFromPeriod: string
policy: 'resident' | 'away_rent_and_utilities' | 'away_rent_only' | 'inactive'
}[] = []
return { return {
registerTelegramHouseholdChat: async () => ({ registerTelegramHouseholdChat: async () => ({
status: 'existing', status: 'existing',
@@ -68,7 +75,19 @@ function repository(): HouseholdConfigurationRepository {
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }),
getHouseholdMember: async () => null, 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 () => [], listHouseholdMembersByTelegramUserId: async () => [],
listPendingHouseholdMembers: async () => [ listPendingHouseholdMembers: async () => [
{ {
@@ -189,7 +208,28 @@ function repository(): HouseholdConfigurationRepository {
rentShareWeight: 1, rentShareWeight: 1,
isAdmin: false 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: [], 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 () => { test('upserts utility categories for admins', async () => {
const service = createMiniAppAdminService(repository()) const service = createMiniAppAdminService(repository())

View File

@@ -1,13 +1,15 @@
import type { import type {
HouseholdBillingSettingsRecord, HouseholdBillingSettingsRecord,
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
HouseholdMemberAbsencePolicy,
HouseholdMemberAbsencePolicyRecord,
HouseholdMemberLifecycleStatus, HouseholdMemberLifecycleStatus,
HouseholdMemberRecord, HouseholdMemberRecord,
HouseholdPendingMemberRecord, HouseholdPendingMemberRecord,
HouseholdTopicBindingRecord, HouseholdTopicBindingRecord,
HouseholdUtilityCategoryRecord HouseholdUtilityCategoryRecord
} from '@household/ports' } from '@household/ports'
import { Money, type CurrencyCode } from '@household/domain' import { Money, Temporal, type CurrencyCode } from '@household/domain'
function isValidDay(value: number): boolean { function isValidDay(value: number): boolean {
return Number.isInteger(value) && value >= 1 && value <= 31 return Number.isInteger(value) && value >= 1 && value <= 31
@@ -29,6 +31,7 @@ export interface MiniAppAdminService {
settings: HouseholdBillingSettingsRecord settings: HouseholdBillingSettingsRecord
categories: readonly HouseholdUtilityCategoryRecord[] categories: readonly HouseholdUtilityCategoryRecord[]
members: readonly HouseholdMemberRecord[] members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
topics: readonly HouseholdTopicBindingRecord[] topics: readonly HouseholdTopicBindingRecord[]
} }
| { | {
@@ -142,6 +145,29 @@ export interface MiniAppAdminService {
reason: 'not_admin' | 'member_not_found' 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( 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.getHouseholdBillingSettings(input.householdId),
repository.listHouseholdUtilityCategories(input.householdId), repository.listHouseholdUtilityCategories(input.householdId),
repository.listHouseholdMembers(input.householdId), repository.listHouseholdMembers(input.householdId),
repository.listHouseholdMemberAbsencePolicies(input.householdId),
repository.listHouseholdTopicBindings(input.householdId) repository.listHouseholdTopicBindings(input.householdId)
]) ])
@@ -168,6 +195,7 @@ export function createMiniAppAdminService(
settings, settings,
categories, categories,
members, members,
memberAbsencePolicies,
topics topics
} }
}, },
@@ -395,6 +423,47 @@ export function createMiniAppAdminService(
status: 'ok', status: 'ok',
member 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
}
} }
} }
} }

View File

@@ -183,4 +183,58 @@ describe('calculateMonthlySettlement', () => {
expect(() => calculateMonthlySettlement(input)).toThrow(DomainError) 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])
})
}) })

View File

@@ -44,6 +44,53 @@ function ensureActiveMembers(
return active 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 { function ensureNonNegativeMoney(label: string, value: Money): void {
if (value.isNegative()) { if (value.isNegative()) {
throw new DomainError( throw new DomainError(
@@ -134,13 +181,15 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
const currency = input.rent.currency const currency = input.rent.currency
const activeMembers = ensureActiveMembers(input.members) const activeMembers = ensureActiveMembers(input.members)
const rentMembers = rentParticipants(activeMembers)
const utilityMembers = utilityParticipants(activeMembers, input.utilities)
const membersById = new Map<string, ComputationMember>( const membersById = new Map<string, ComputationMember>(
activeMembers.map((member) => [member.memberId.toString(), createMemberState(member, currency)]) activeMembers.map((member) => [member.memberId.toString(), createMemberState(member, currency)])
) )
const rentShares = input.rent.splitByWeights(validateRentWeights(activeMembers)) const rentShares = input.rent.splitByWeights(validateRentWeights(rentMembers))
for (const [index, member] of activeMembers.entries()) { for (const [index, member] of rentMembers.entries()) {
const state = membersById.get(member.memberId.toString()) const state = membersById.get(member.memberId.toString())
if (!state) { if (!state) {
continue continue
@@ -149,18 +198,20 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
state.rentShare = rentShares[index] ?? Money.zero(currency) state.rentShare = rentShares[index] ?? Money.zero(currency)
} }
const utilityShares = if (utilityMembers.length > 0) {
input.utilitySplitMode === 'equal' const utilityShares =
? input.utilities.splitEvenly(activeMembers.length) input.utilitySplitMode === 'equal'
: input.utilities.splitByWeights(validateWeightedUtilityDays(activeMembers)) ? input.utilities.splitEvenly(utilityMembers.length)
: input.utilities.splitByWeights(validateWeightedUtilityDays(utilityMembers))
for (const [index, member] of activeMembers.entries()) { for (const [index, member] of utilityMembers.entries()) {
const state = membersById.get(member.memberId.toString()) const state = membersById.get(member.memberId.toString())
if (!state) { if (!state) {
continue continue
}
state.utilityShare = utilityShares[index] ?? Money.zero(currency)
} }
state.utilityShare = utilityShares[index] ?? Money.zero(currency)
} }
for (const purchase of input.purchases) { for (const purchase of input.purchases) {
@@ -176,8 +227,9 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
payer.purchasePaid = payer.purchasePaid.add(purchase.amount) payer.purchasePaid = payer.purchasePaid.add(purchase.amount)
const purchaseShares = purchase.amount.splitEvenly(activeMembers.length) const participants = purchaseParticipants(activeMembers, purchase.amount)
for (const [index, member] of activeMembers.entries()) { const purchaseShares = purchase.amount.splitEvenly(participants.length)
for (const [index, member] of participants.entries()) {
const state = membersById.get(member.memberId.toString()) const state = membersById.get(member.memberId.toString())
if (!state) { if (!state) {
continue continue

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,13 @@
"when": 1773222186943, "when": 1773222186943,
"tag": "0014_empty_risque", "tag": "0014_empty_risque",
"breakpoints": true "breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1773223414625,
"tag": "0015_white_owl",
"breakpoints": true
} }
] ]
} }

View File

@@ -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( export const billingCycles = pgTable(
'billing_cycles', 'billing_cycles',
{ {

View File

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

View File

@@ -3,9 +3,16 @@ import type { ReminderTarget } from './reminders'
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders', 'payments'] as const 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_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 HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
export type HouseholdMemberLifecycleStatus = (typeof HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES)[number] export type HouseholdMemberLifecycleStatus = (typeof HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES)[number]
export type HouseholdMemberAbsencePolicy = (typeof HOUSEHOLD_MEMBER_ABSENCE_POLICIES)[number]
export interface HouseholdTelegramChatRecord { export interface HouseholdTelegramChatRecord {
householdId: string householdId: string
@@ -52,6 +59,13 @@ export interface HouseholdMemberRecord {
isAdmin: boolean isAdmin: boolean
} }
export interface HouseholdMemberAbsencePolicyRecord {
householdId: string
memberId: string
effectiveFromPeriod: string
policy: HouseholdMemberAbsencePolicy
}
export interface HouseholdBillingSettingsRecord { export interface HouseholdBillingSettingsRecord {
householdId: string householdId: string
settlementCurrency: CurrencyCode settlementCurrency: CurrencyCode
@@ -197,4 +211,13 @@ export interface HouseholdConfigurationRepository {
memberId: string, memberId: string,
status: HouseholdMemberLifecycleStatus status: HouseholdMemberLifecycleStatus
): Promise<HouseholdMemberRecord | null> ): Promise<HouseholdMemberRecord | null>
listHouseholdMemberAbsencePolicies(
householdId: string
): Promise<readonly HouseholdMemberAbsencePolicyRecord[]>
upsertHouseholdMemberAbsencePolicy(input: {
householdId: string
memberId: string
effectiveFromPeriod: string
policy: HouseholdMemberAbsencePolicy
}): Promise<HouseholdMemberAbsencePolicyRecord | null>
} }

View File

@@ -13,8 +13,11 @@ export type {
ReleaseProcessedBotMessageInput ReleaseProcessedBotMessageInput
} from './processed-bot-messages' } from './processed-bot-messages'
export { export {
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES, HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
HOUSEHOLD_TOPIC_ROLES, HOUSEHOLD_TOPIC_ROLES,
type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord,
type HouseholdConfigurationRepository, type HouseholdConfigurationRepository,
type HouseholdBillingSettingsRecord, type HouseholdBillingSettingsRecord,
type HouseholdJoinTokenRecord, type HouseholdJoinTokenRecord,