mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
feat(member): add away billing policies
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -497,6 +497,12 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
},
|
},
|
||||||
async updateHouseholdMemberStatus() {
|
async updateHouseholdMemberStatus() {
|
||||||
return null
|
return null
|
||||||
|
},
|
||||||
|
async listHouseholdMemberAbsencePolicies() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
async upsertHouseholdMemberAbsencePolicy() {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: 'Сохранить вес аренды',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -203,6 +203,12 @@ function createRepositoryStub() {
|
|||||||
},
|
},
|
||||||
async updateHouseholdMemberStatus() {
|
async updateHouseholdMemberStatus() {
|
||||||
return null
|
return null
|
||||||
|
},
|
||||||
|
async listHouseholdMemberAbsencePolicies() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
async upsertHouseholdMemberAbsencePolicy() {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user