feat(miniapp): improve mobile billing and utility controls

This commit is contained in:
2026-03-10 18:50:19 +04:00
parent 3168356431
commit b7658164a8
15 changed files with 878 additions and 52 deletions

View File

@@ -49,8 +49,10 @@ import {
createMiniAppAddUtilityBillHandler,
createMiniAppBillingCycleHandler,
createMiniAppCloseCycleHandler,
createMiniAppDeleteUtilityBillHandler,
createMiniAppOpenCycleHandler,
createMiniAppRentUpdateHandler
createMiniAppRentUpdateHandler,
createMiniAppUpdateUtilityBillHandler
} from './miniapp-billing'
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
import { createNbgExchangeRateProvider } from './nbg-exchange-rates'
@@ -463,6 +465,24 @@ const server = createBotWebhookServer({
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppUpdateUtilityBill: householdOnboardingService
? createMiniAppUpdateUtilityBillHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppDeleteUtilityBill: householdOnboardingService
? createMiniAppDeleteUtilityBillHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppLocalePreference: householdOnboardingService
? createMiniAppLocalePreferenceHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,

View File

@@ -449,6 +449,11 @@ export function createMiniAppUpdateSettingsHandler(options: {
const result = await options.miniAppAdminService.updateSettings({
householdId: session.member.householdId,
actorIsAdmin: session.member.isAdmin,
...(payload.settlementCurrency
? {
settlementCurrency: payload.settlementCurrency
}
: {}),
...(payload.rentAmountMajor !== undefined
? {
rentAmountMajor: payload.rentAmountMajor

View File

@@ -11,8 +11,10 @@ import type {
import {
createMiniAppAddUtilityBillHandler,
createMiniAppBillingCycleHandler,
createMiniAppDeleteUtilityBillHandler,
createMiniAppOpenCycleHandler,
createMiniAppRentUpdateHandler
createMiniAppRentUpdateHandler,
createMiniAppUpdateUtilityBillHandler
} from './miniapp-billing'
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
@@ -179,6 +181,12 @@ function createFinanceServiceStub(): FinanceCommandService {
currency: 'USD',
period: '2026-03'
}),
updateUtilityBill: async () => ({
billId: 'utility-1',
amount: Money.fromMinor(4500n, 'USD'),
currency: 'USD'
}),
deleteUtilityBill: async () => true,
generateDashboard: async () => null,
generateStatement: async () => null
}
@@ -249,6 +257,87 @@ describe('createMiniAppBillingCycleHandler', () => {
})
})
test('createMiniAppUpdateUtilityBillHandler updates a utility bill for the current cycle', async () => {
const repository = onboardingRepository()
const handler = createMiniAppUpdateUtilityBillHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
onboardingService: createHouseholdOnboardingService({
repository
}),
financeServiceForHousehold: () => createFinanceServiceStub()
})
const response = await handler.handler(
new Request('http://localhost/api/miniapp/admin/utility-bills/update', {
method: 'POST',
headers: {
origin: 'http://localhost:5173',
'content-type': 'application/json'
},
body: JSON.stringify({
initData: initData(),
billId: 'utility-1',
billName: 'Electricity',
amountMajor: '45.00',
currency: 'GEL'
})
})
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
ok: true,
authorized: true,
cycleState: {
utilityBills: [
{
id: 'utility-1'
}
]
}
})
})
test('createMiniAppDeleteUtilityBillHandler deletes a utility bill for the current cycle', async () => {
const repository = onboardingRepository()
const handler = createMiniAppDeleteUtilityBillHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
onboardingService: createHouseholdOnboardingService({
repository
}),
financeServiceForHousehold: () => createFinanceServiceStub()
})
const response = await handler.handler(
new Request('http://localhost/api/miniapp/admin/utility-bills/delete', {
method: 'POST',
headers: {
origin: 'http://localhost:5173',
'content-type': 'application/json'
},
body: JSON.stringify({
initData: initData(),
billId: 'utility-1'
})
})
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
ok: true,
authorized: true,
cycleState: {
utilityBills: [
{
id: 'utility-1'
}
]
}
})
})
describe('createMiniAppOpenCycleHandler', () => {
test('opens a billing cycle for an authenticated admin', async () => {
const repository = onboardingRepository()

View File

@@ -217,6 +217,72 @@ async function readUtilityBillPayload(request: Request): Promise<{
}
}
async function readUtilityBillUpdatePayload(request: Request): Promise<{
initData: string
billId: string
billName: string
amountMajor: string
currency?: string
}> {
const parsed = await parseJsonBody<{
initData?: string
billId?: string
billName?: string
amountMajor?: string
currency?: string
}>(request)
const initData = parsed.initData?.trim()
if (!initData) {
throw new Error('Missing initData')
}
const billId = parsed.billId?.trim()
const billName = parsed.billName?.trim()
const amountMajor = parsed.amountMajor?.trim()
if (!billId) {
throw new Error('Missing utility bill id')
}
if (!billName) {
throw new Error('Missing utility bill name')
}
if (!amountMajor) {
throw new Error('Missing utility bill amount')
}
const currency = parsed.currency?.trim()
return {
initData,
billId,
billName,
amountMajor,
...(currency ? { currency } : {})
}
}
async function readUtilityBillDeletePayload(request: Request): Promise<{
initData: string
billId: string
}> {
const parsed = await parseJsonBody<{
initData?: string
billId?: string
}>(request)
const initData = parsed.initData?.trim()
if (!initData) {
throw new Error('Missing initData')
}
const billId = parsed.billId?.trim()
if (!billId) {
throw new Error('Missing utility bill id')
}
return {
initData,
billId
}
}
export function createMiniAppBillingCycleHandler(options: {
allowedOrigins: readonly string[]
botToken: string
@@ -523,3 +589,132 @@ export function createMiniAppAddUtilityBillHandler(options: {
}
}
}
export function createMiniAppUpdateUtilityBillHandler(options: {
allowedOrigins: readonly string[]
botToken: string
financeServiceForHousehold: (householdId: string) => FinanceCommandService
onboardingService: HouseholdOnboardingService
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 auth = await authenticateAdminSession(
request.clone() as Request,
sessionService,
origin
)
if (auth instanceof Response) {
return auth
}
const payload = await readUtilityBillUpdatePayload(request)
const service = options.financeServiceForHousehold(auth.member.householdId)
const result = await service.updateUtilityBill(
payload.billId,
payload.billName,
payload.amountMajor,
payload.currency
)
if (!result) {
return miniAppJsonResponse({ ok: false, error: 'Utility bill not found' }, 404, origin)
}
const cycleState = await service.getAdminCycleState()
return miniAppJsonResponse(
{
ok: true,
authorized: true,
cycleState: serializeCycleState(cycleState)
},
200,
origin
)
} catch (error) {
return miniAppErrorResponse(error, origin, options.logger)
}
}
}
}
export function createMiniAppDeleteUtilityBillHandler(options: {
allowedOrigins: readonly string[]
botToken: string
financeServiceForHousehold: (householdId: string) => FinanceCommandService
onboardingService: HouseholdOnboardingService
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 auth = await authenticateAdminSession(
request.clone() as Request,
sessionService,
origin
)
if (auth instanceof Response) {
return auth
}
const payload = await readUtilityBillDeletePayload(request)
const service = options.financeServiceForHousehold(auth.member.householdId)
const deleted = await service.deleteUtilityBill(payload.billId)
if (!deleted) {
return miniAppJsonResponse({ ok: false, error: 'Utility bill not found' }, 404, origin)
}
const cycleState = await service.getAdminCycleState()
return miniAppJsonResponse(
{
ok: true,
authorized: true,
cycleState: serializeCycleState(cycleState)
},
200,
origin
)
} catch (error) {
return miniAppErrorResponse(error, origin, options.logger)
}
}
}
}

View File

@@ -46,6 +46,8 @@ function repository(
getCycleExchangeRate: async () => null,
saveCycleExchangeRate: async (input) => input,
addUtilityBill: async () => {},
updateUtilityBill: async () => null,
deleteUtilityBill: async () => false,
getRentRuleForPeriod: async () => ({
amountMinor: 70000n,
currency: 'USD'

View File

@@ -127,6 +127,24 @@ describe('createBotWebhookServer', () => {
}
})
},
miniAppUpdateUtilityBill: {
handler: async () =>
new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), {
status: 200,
headers: {
'content-type': 'application/json; charset=utf-8'
}
})
},
miniAppDeleteUtilityBill: {
handler: async () =>
new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), {
status: 200,
headers: {
'content-type': 'application/json; charset=utf-8'
}
})
},
miniAppApproveMember: {
handler: async () =>
new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), {
@@ -409,6 +427,38 @@ describe('createBotWebhookServer', () => {
})
})
test('accepts mini app utility bill update request', async () => {
const response = await server.fetch(
new Request('http://localhost/api/miniapp/admin/utility-bills/update', {
method: 'POST',
body: JSON.stringify({ initData: 'payload', billId: 'utility-1' })
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
ok: true,
authorized: true,
cycleState: {}
})
})
test('accepts mini app utility bill delete request', async () => {
const response = await server.fetch(
new Request('http://localhost/api/miniapp/admin/utility-bills/delete', {
method: 'POST',
body: JSON.stringify({ initData: 'payload', billId: 'utility-1' })
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
ok: true,
authorized: true,
cycleState: {}
})
})
test('accepts mini app approve member request', async () => {
const response = await server.fetch(
new Request('http://localhost/api/miniapp/admin/approve-member', {

View File

@@ -92,6 +92,18 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppUpdateUtilityBill?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppDeleteUtilityBill?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppLocalePreference?:
| {
path?: string
@@ -153,6 +165,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
const miniAppRentUpdatePath = options.miniAppRentUpdate?.path ?? '/api/miniapp/admin/rent/update'
const miniAppAddUtilityBillPath =
options.miniAppAddUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/add'
const miniAppUpdateUtilityBillPath =
options.miniAppUpdateUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/update'
const miniAppDeleteUtilityBillPath =
options.miniAppDeleteUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/delete'
const miniAppLocalePreferencePath =
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
const schedulerPathPrefix = options.scheduler
@@ -233,6 +249,14 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppAddUtilityBill.handler(request)
}
if (options.miniAppUpdateUtilityBill && url.pathname === miniAppUpdateUtilityBillPath) {
return await options.miniAppUpdateUtilityBill.handler(request)
}
if (options.miniAppDeleteUtilityBill && url.pathname === miniAppDeleteUtilityBillPath) {
return await options.miniAppDeleteUtilityBill.handler(request)
}
if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) {
return await options.miniAppLocalePreference.handler(request)
}