feat(miniapp): carry overdue billing and admin role flows

This commit is contained in:
2026-03-23 15:44:55 +04:00
parent ee8c53d89b
commit 5af14e101e
44 changed files with 2965 additions and 329 deletions

View File

@@ -256,6 +256,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -40,6 +40,7 @@ import {
createMiniAppApproveMemberHandler,
createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler,
createMiniAppDemoteMemberHandler,
createMiniAppRejectMemberHandler,
createMiniAppSettingsHandler,
createMiniAppUpdateMemberAbsencePolicyHandler,
@@ -634,6 +635,15 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppDemoteMember: householdOnboardingService
? createMiniAppDemoteMemberHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateOwnDisplayName: householdOnboardingService
? createMiniAppUpdateOwnDisplayNameHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,

View File

@@ -135,6 +135,7 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -267,6 +267,7 @@ function createHouseholdRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
@@ -366,6 +367,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('850.00', 'GEL'),
paid: Money.fromMajor('500.00', 'GEL'),
remaining: Money.fromMajor('350.00', 'GEL'),
overduePayments: [],
explanations: []
},
{
@@ -377,6 +379,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('815.00', 'GEL'),
paid: Money.fromMajor('200.00', 'GEL'),
remaining: Money.fromMajor('615.00', 'GEL'),
overduePayments: [],
explanations: []
},
{
@@ -388,6 +391,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('680.00', 'GEL'),
paid: Money.fromMajor('100.00', 'GEL'),
remaining: Money.fromMajor('580.00', 'GEL'),
overduePayments: [],
explanations: []
}
],

View File

@@ -113,6 +113,7 @@ function createRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
@@ -150,6 +151,7 @@ function createDashboard(): NonNullable<
netDue: Money.fromMajor('210', 'GEL'),
paid: Money.fromMajor('100', 'GEL'),
remaining: Money.fromMajor('110', 'GEL'),
overduePayments: [],
explanations: []
},
{
@@ -161,6 +163,7 @@ function createDashboard(): NonNullable<
netDue: Money.fromMajor('190', 'GEL'),
paid: Money.zero('GEL'),
remaining: Money.fromMajor('190', 'GEL'),
overduePayments: [],
explanations: []
}
],

View File

@@ -484,6 +484,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
async promoteHouseholdAdmin() {
return null
},
async demoteHouseholdAdmin() {
return null
},
async updateHouseholdMemberRentShareWeight() {
return null
},

View File

@@ -8,6 +8,7 @@ import type {
import {
createMiniAppApproveMemberHandler,
createMiniAppDemoteMemberHandler,
createMiniAppRejectMemberHandler,
createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler,
@@ -230,6 +231,28 @@ function onboardingRepository(): HouseholdConfigurationRepository {
}
: null
},
demoteHouseholdAdmin: async (householdId, memberId) => {
const member = [
{
id: 'member-123456',
householdId,
telegramUserId: '123456',
displayName: 'Stan',
status: 'active' as const,
preferredLocale: null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: true
}
].find((entry) => entry.id === memberId)
return member
? {
...member,
isAdmin: false
}
: null
},
updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) =>
memberId === 'member-123456'
? {
@@ -776,6 +799,95 @@ describe('createMiniAppPromoteMemberHandler', () => {
})
})
describe('createMiniAppDemoteMemberHandler', () => {
test('removes admin access from a household member 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
}
]
repository.listHouseholdMembers = async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'member-555777',
householdId: 'household-1',
telegramUserId: '555777',
displayName: 'Mia',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
const handler = createMiniAppDemoteMemberHandler({
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/demote', {
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'
})
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
ok: true,
authorized: true,
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
})
describe('createMiniAppUpdateOwnDisplayNameHandler', () => {
test('updates the acting member display name for an authenticated member', async () => {
const authDate = Math.floor(Date.now() / 1000)

View File

@@ -898,6 +898,94 @@ export function createMiniAppPromoteMemberHandler(options: {
}
}
export function createMiniAppDemoteMemberHandler(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 readPromoteMemberPayload(request)
const session = await sessionService.authenticate({
initData: payload.initData
})
if (!session) {
return miniAppJsonResponse(
{ ok: false, error: 'Invalid Telegram init data' },
401,
origin
)
}
if (
!session.authorized ||
!session.member ||
session.member.status !== 'active' ||
!session.member.isAdmin
) {
return miniAppJsonResponse(
{ ok: false, error: 'Admin access required for active household members' },
403,
origin
)
}
const result = await options.miniAppAdminService.demoteMemberFromAdmin({
householdId: session.member.householdId,
actorIsAdmin: session.member.isAdmin,
memberId: payload.memberId
})
if (result.status === 'rejected') {
const status =
result.reason === 'member_not_found' ? 404 : result.reason === 'last_admin' ? 409 : 403
const error =
result.reason === 'member_not_found'
? 'Member not found'
: result.reason === 'last_admin'
? 'Cannot remove the last household admin'
: 'Admin access required'
return miniAppJsonResponse({ ok: false, error }, status, origin)
}
return miniAppJsonResponse(
{
ok: true,
authorized: true,
member: result.member
},
200,
origin
)
} catch (error) {
return miniAppErrorResponse(error, origin, options.logger)
}
}
}
}
export function createMiniAppUpdateOwnDisplayNameHandler(options: {
allowedOrigins: readonly string[]
botToken: string

View File

@@ -191,7 +191,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
sortOrder: input.sortOrder,
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null
}
}

View File

@@ -137,6 +137,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -486,6 +486,7 @@ async function readPaymentMutationPayload(request: Request): Promise<{
kind?: 'rent' | 'utilities'
amountMajor?: string
currency?: string
period?: string
}> {
const parsed = await parseJsonBody<{
initData?: string
@@ -494,6 +495,7 @@ async function readPaymentMutationPayload(request: Request): Promise<{
kind?: 'rent' | 'utilities'
amountMajor?: string
currency?: string
period?: string
}>(request)
const initData = parsed.initData?.trim()
if (!initData) {
@@ -526,6 +528,11 @@ async function readPaymentMutationPayload(request: Request): Promise<{
? {
currency: parsed.currency.trim()
}
: {}),
...(parsed.period?.trim()
? {
period: BillingPeriod.fromString(parsed.period.trim()).toString()
}
: {})
}
}
@@ -1001,7 +1008,8 @@ export function createMiniAppSubmitPaymentHandler(options: {
auth.member.id,
payload.kind,
payload.amountMajor,
payload.currency
payload.currency,
payload.period
)
if (!payment) {
@@ -1374,7 +1382,8 @@ export function createMiniAppAddPaymentHandler(options: {
payload.memberId,
payload.kind,
payload.amountMajor,
payload.currency
payload.currency,
payload.period
)
if (!payment) {

View File

@@ -54,6 +54,7 @@ function repository(
isAdmin: true
}
],
listCycles: async () => [cycle],
getOpenCycle: async () => cycle,
getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null),
getLatestCycle: async () => cycle,
@@ -72,6 +73,8 @@ function repository(
updateParsedPurchase: async () => null,
addParsedPurchase: async (input) => ({
id: 'purchase-new',
cycleId: input.cycleId,
cyclePeriod: null,
payerMemberId: input.payerMemberId,
amountMinor: input.amountMinor,
currency: input.currency,
@@ -90,6 +93,8 @@ function repository(
deleteUtilityBill: async () => false,
addPaymentRecord: async (input) => ({
id: 'payment-new',
cycleId: input.cycleId,
cyclePeriod: null,
memberId: input.memberId,
kind: input.kind,
amountMinor: input.amountMinor,
@@ -97,6 +102,8 @@ function repository(
recordedAt: input.recordedAt
}),
updatePaymentRecord: async () => null,
getPaymentRecord: async () => null,
replacePaymentPurchaseAllocations: async () => {},
deletePaymentRecord: async () => false,
getRentRuleForPeriod: async () => ({
amountMinor: 70000n,
@@ -116,6 +123,8 @@ function repository(
listPaymentRecordsForCycle: async () => [
{
id: 'payment-1',
cycleId: cycle.id,
cyclePeriod: cycle.period,
memberId: member?.id ?? 'member-1',
kind: 'rent',
amountMinor: 50000n,
@@ -126,6 +135,8 @@ function repository(
listParsedPurchasesForRange: async () => [
{
id: 'purchase-1',
cycleId: cycle.id,
cyclePeriod: cycle.period,
payerMemberId: member?.id ?? 'member-1',
amountMinor: 3000n,
currency: 'GEL',
@@ -133,6 +144,19 @@ function repository(
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
}
],
listParsedPurchases: async () => [
{
id: 'purchase-1',
cycleId: cycle.id,
cyclePeriod: cycle.period,
payerMemberId: member?.id ?? 'member-1',
amountMinor: 3000n,
currency: 'GEL',
description: 'Soap',
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
}
],
listPaymentPurchaseAllocations: async () => [],
getSettlementSnapshotLines: async () => [],
savePaymentConfirmation: async () =>
({
@@ -282,6 +306,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
@@ -364,6 +389,7 @@ describe('createMiniAppDashboardHandler', () => {
{
displayName: 'Stan',
netDueMajor: '2010.00',
overduePayments: [],
paidMajor: '500.00',
remainingMajor: '1510.00',
rentShareMajor: '1890.00',
@@ -408,6 +434,8 @@ describe('createMiniAppDashboardHandler', () => {
financeRepository.listParsedPurchasesForRange = async () => [
{
id: 'purchase-1',
cycleId: 'cycle-1',
cyclePeriod: '2026-03',
payerMemberId: 'member-1',
amountMinor: 3000n,
currency: 'GEL',
@@ -433,6 +461,11 @@ describe('createMiniAppDashboardHandler', () => {
]
}
]
financeRepository.listParsedPurchases = async () =>
financeRepository.listParsedPurchasesForRange(
instantFromIso('2026-03-01T00:00:00.000Z'),
instantFromIso('2026-04-01T00:00:00.000Z')
)
financeRepository.listMembers = async () => [
{
id: 'member-1',

View File

@@ -1,4 +1,5 @@
import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application'
import { Money } from '@household/domain'
import type { Logger } from '@household/observability'
import {
@@ -113,6 +114,14 @@ export function createMiniAppDashboardHandler(options: {
netDueMajor: line.netDue.toMajorString(),
paidMajor: line.paid.toMajorString(),
remainingMajor: line.remaining.toMajorString(),
overduePayments: line.overduePayments.map((overdue) => ({
kind: overdue.kind,
amountMajor: Money.fromMinor(
overdue.amountMinor,
dashboard.currency
).toMajorString(),
periods: overdue.periods
})),
explanations: line.explanations
})),
ledger: dashboard.ledger.map((entry) => ({
@@ -132,6 +141,14 @@ export function createMiniAppDashboardHandler(options: {
...(entry.kind === 'purchase'
? {
purchaseSplitMode: entry.purchaseSplitMode ?? 'equal',
originPeriod: entry.originPeriod ?? null,
resolutionStatus: entry.resolutionStatus ?? 'unresolved',
resolvedAt: entry.resolvedAt ?? null,
outstandingByMember:
entry.outstandingByMember?.map((outstanding) => ({
memberId: outstanding.memberId,
amountMajor: outstanding.amount.toMajorString()
})) ?? [],
purchaseParticipants:
entry.purchaseParticipants?.map((participant) => ({
memberId: participant.memberId,

View File

@@ -168,6 +168,7 @@ function repository(): HouseholdConfigurationRepository {
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -198,6 +198,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'),
overduePayments: [],
explanations: []
}
],

View File

@@ -62,6 +62,12 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppDemoteMember?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppUpdateOwnDisplayName?:
| {
path?: string
@@ -234,6 +240,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
options.miniAppUpsertUtilityCategory?.path ?? '/api/miniapp/admin/utility-categories/upsert'
const miniAppPromoteMemberPath =
options.miniAppPromoteMember?.path ?? '/api/miniapp/admin/members/promote'
const miniAppDemoteMemberPath =
options.miniAppDemoteMember?.path ?? '/api/miniapp/admin/members/demote'
const miniAppUpdateOwnDisplayNamePath =
options.miniAppUpdateOwnDisplayName?.path ?? '/api/miniapp/member/display-name'
const miniAppUpdateMemberDisplayNamePath =
@@ -328,6 +336,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppPromoteMember.handler(request)
}
if (options.miniAppDemoteMember && url.pathname === miniAppDemoteMemberPath) {
return await options.miniAppDemoteMember.handler(request)
}
if (options.miniAppUpdateOwnDisplayName && url.pathname === miniAppUpdateOwnDisplayNamePath) {
return await options.miniAppUpdateOwnDisplayName.handler(request)
}