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,
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,
promoteHouseholdAdmin: 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,
promoteHouseholdAdmin: 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,
promoteHouseholdAdmin: 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() {
return null
},
async listHouseholdMemberAbsencePolicies() {
return []
},
async upsertHouseholdMemberAbsencePolicy() {
return null
}
}
}

View File

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

View File

@@ -11,6 +11,7 @@ import {
createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler,
createMiniAppSettingsHandler,
createMiniAppUpdateMemberAbsencePolicyHandler,
createMiniAppUpdateMemberStatusHandler,
createMiniAppUpdateSettingsHandler
} from './miniapp-admin'
@@ -25,6 +26,12 @@ function onboardingRepository(): HouseholdConfigurationRepository {
title: 'Kojori House',
defaultLocale: 'ru' as const
}
let memberAbsencePolicies: {
householdId: string
memberId: string
effectiveFromPeriod: string
policy: 'resident' | 'away_rent_and_utilities' | 'away_rent_only' | 'inactive'
}[] = []
return {
registerTelegramHouseholdChat: async () => ({
@@ -83,7 +90,19 @@ function onboardingRepository(): HouseholdConfigurationRepository {
isAdmin: input.isAdmin === true
}),
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 () => [],
listPendingHouseholdMembers: async () => [
{
@@ -208,7 +227,28 @@ function onboardingRepository(): 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
}
}
}
@@ -423,6 +463,7 @@ describe('createMiniAppSettingsHandler', () => {
}
],
categories: [],
memberAbsencePolicies: [],
assistantUsage: [],
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 { Logger } from '@household/observability'
import {
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
type HouseholdBillingSettingsRecord,
type HouseholdMemberAbsencePolicy,
type HouseholdMemberLifecycleStatus
} from '@household/ports'
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) {
return {
householdId: settings.householdId,
@@ -442,6 +480,7 @@ export function createMiniAppSettingsHandler(options: {
topics: result.topics,
categories: result.categories,
members: result.members,
memberAbsencePolicies: result.memberAbsencePolicies,
assistantUsage:
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: {
allowedOrigins: readonly string[]
botToken: string

View File

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

View File

@@ -132,7 +132,9 @@ function onboardingRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null,
promoteHouseholdAdmin: 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
}),
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 () => [],
listPendingHouseholdMembers: async () => [],
approvePendingHouseholdMember: async () => null,
@@ -227,7 +239,9 @@ function onboardingRepository(): HouseholdConfigurationRepository {
}),
promoteHouseholdAdmin: 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,
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>
}
| undefined
miniAppUpdateMemberAbsencePolicy?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppBillingCycle?:
| {
path?: string
@@ -194,6 +200,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
options.miniAppUpdateMemberRentWeight?.path ?? '/api/miniapp/admin/members/rent-weight'
const miniAppUpdateMemberStatusPath =
options.miniAppUpdateMemberStatus?.path ?? '/api/miniapp/admin/members/status'
const miniAppUpdateMemberAbsencePolicyPath =
options.miniAppUpdateMemberAbsencePolicy?.path ?? '/api/miniapp/admin/members/absence-policy'
const miniAppBillingCyclePath =
options.miniAppBillingCycle?.path ?? '/api/miniapp/admin/billing-cycle'
const miniAppOpenCyclePath =
@@ -280,6 +288,13 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppUpdateMemberStatus.handler(request)
}
if (
options.miniAppUpdateMemberAbsencePolicy &&
url.pathname === miniAppUpdateMemberAbsencePolicyPath
) {
return await options.miniAppUpdateMemberAbsencePolicy.handler(request)
}
if (options.miniAppBillingCycle && url.pathname === miniAppBillingCyclePath) {
return await options.miniAppBillingCycle.handler(request)
}