From 29563c24eb623cc30a64c353367fd23c8efa278f Mon Sep 17 00:00:00 2001 From: whekin Date: Tue, 10 Mar 2026 01:53:11 +0400 Subject: [PATCH] feat(miniapp): add cycle-level billing controls --- apps/bot/src/index.ts | 52 ++ apps/bot/src/miniapp-billing.test.ts | 355 ++++++++++++ apps/bot/src/miniapp-billing.ts | 525 ++++++++++++++++++ apps/bot/src/server.test.ts | 125 +++++ apps/bot/src/server.ts | 59 ++ apps/miniapp/src/App.tsx | 329 +++++++++++ apps/miniapp/src/i18n.ts | 32 ++ apps/miniapp/src/index.css | 11 + apps/miniapp/src/miniapp-api.ts | 176 ++++++ .../src/finance-command-service.test.ts | 56 ++ .../src/finance-command-service.ts | 53 +- 11 files changed, 1772 insertions(+), 1 deletion(-) create mode 100644 apps/bot/src/miniapp-billing.test.ts create mode 100644 apps/bot/src/miniapp-billing.ts diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index bfaf259..48f5ffa 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -42,6 +42,13 @@ import { createMiniAppUpdateSettingsHandler, createMiniAppUpsertUtilityCategoryHandler } from './miniapp-admin' +import { + createMiniAppAddUtilityBillHandler, + createMiniAppBillingCycleHandler, + createMiniAppCloseCycleHandler, + createMiniAppOpenCycleHandler, + createMiniAppRentUpdateHandler +} from './miniapp-billing' import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' const runtime = getBotRuntimeConfig() @@ -351,6 +358,51 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-admin') }) : undefined, + miniAppBillingCycle: householdOnboardingService + ? createMiniAppBillingCycleHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, + miniAppOpenCycle: householdOnboardingService + ? createMiniAppOpenCycleHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, + miniAppCloseCycle: householdOnboardingService + ? createMiniAppCloseCycleHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, + miniAppRentUpdate: householdOnboardingService + ? createMiniAppRentUpdateHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, + miniAppAddUtilityBill: householdOnboardingService + ? createMiniAppAddUtilityBillHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, miniAppLocalePreference: householdOnboardingService ? createMiniAppLocalePreferenceHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts new file mode 100644 index 0000000..8386a03 --- /dev/null +++ b/apps/bot/src/miniapp-billing.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, test } from 'bun:test' + +import type { FinanceCommandService } from '@household/application' +import { createHouseholdOnboardingService } from '@household/application' +import { instantFromIso, Money } from '@household/domain' +import type { + HouseholdConfigurationRepository, + HouseholdTopicBindingRecord +} from '@household/ports' + +import { + createMiniAppAddUtilityBillHandler, + createMiniAppBillingCycleHandler, + createMiniAppOpenCycleHandler, + createMiniAppRentUpdateHandler +} from './miniapp-billing' +import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' + +function onboardingRepository(): HouseholdConfigurationRepository { + const household = { + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-100123', + telegramChatType: 'supergroup', + title: 'Kojori House', + defaultLocale: 'ru' as const + } + + return { + registerTelegramHouseholdChat: async () => ({ + status: 'existing', + household + }), + getTelegramHouseholdChat: async () => household, + getHouseholdChatByHouseholdId: async () => household, + bindHouseholdTopic: async (input) => + ({ + householdId: input.householdId, + role: input.role, + telegramThreadId: input.telegramThreadId, + topicName: input.topicName?.trim() || null + }) satisfies HouseholdTopicBindingRecord, + getHouseholdTopicBinding: async () => null, + findHouseholdTopicByTelegramContext: async () => null, + listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], + upsertHouseholdJoinToken: async (input) => ({ + householdId: household.householdId, + householdName: household.householdName, + token: input.token, + createdByTelegramUserId: input.createdByTelegramUserId ?? null + }), + getHouseholdJoinToken: async () => null, + getHouseholdByJoinToken: async () => null, + upsertPendingHouseholdMember: async (input) => ({ + householdId: household.householdId, + householdName: household.householdName, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + username: input.username?.trim() || null, + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: household.defaultLocale + }), + getPendingHouseholdMember: async () => null, + findPendingHouseholdMemberByTelegramUserId: async () => null, + ensureHouseholdMember: async (input) => ({ + id: `member-${input.telegramUserId}`, + householdId: household.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + preferredLocale: input.preferredLocale ?? null, + householdDefaultLocale: household.defaultLocale, + isAdmin: input.isAdmin === true + }), + getHouseholdMember: async () => null, + listHouseholdMembers: async () => [], + getHouseholdBillingSettings: async (householdId) => ({ + householdId, + rentAmountMinor: 70000n, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async (input) => ({ + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? 70000n, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + }), + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async (input) => ({ + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + }), + listHouseholdMembersByTelegramUserId: async () => [ + { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: true + } + ], + listPendingHouseholdMembers: async () => [], + approvePendingHouseholdMember: async () => null, + updateHouseholdDefaultLocale: async (_householdId, locale) => ({ + ...household, + defaultLocale: locale + }), + updateMemberPreferredLocale: async () => null, + promoteHouseholdAdmin: async () => null + } +} + +function createFinanceServiceStub(): FinanceCommandService { + return { + getMemberByTelegramUserId: async () => null, + getOpenCycle: async () => ({ + id: 'cycle-2026-03', + period: '2026-03', + currency: 'USD' + }), + getAdminCycleState: async () => ({ + cycle: { + id: 'cycle-2026-03', + period: '2026-03', + currency: 'USD' + }, + rentRule: { + amountMinor: 70000n, + currency: 'USD' + }, + utilityBills: [ + { + id: 'utility-1', + billName: 'Electricity', + amount: Money.fromMinor(12000n, 'USD'), + currency: 'USD', + createdByMemberId: 'member-123456', + createdAt: instantFromIso('2026-03-12T12:00:00.000Z') + } + ] + }), + openCycle: async () => ({ + id: 'cycle-2026-03', + period: '2026-03', + currency: 'USD' + }), + closeCycle: async () => ({ + id: 'cycle-2026-03', + period: '2026-03', + currency: 'USD' + }), + setRent: async () => ({ + amount: Money.fromMinor(75000n, 'USD'), + currency: 'USD', + period: '2026-03' + }), + addUtilityBill: async () => ({ + amount: Money.fromMinor(4500n, 'USD'), + currency: 'USD', + period: '2026-03' + }), + generateDashboard: async () => null, + generateStatement: async () => null + } +} + +const authDate = Math.floor(Date.now() / 1000) + +function initData() { + return buildMiniAppInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan', + username: 'stanislav', + language_code: 'ru' + }) +} + +describe('createMiniAppBillingCycleHandler', () => { + test('returns the current cycle state for an authenticated admin', async () => { + const repository = onboardingRepository() + const handler = createMiniAppBillingCycleHandler({ + 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/billing-cycle', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: initData() + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + cycleState: { + cycle: { + id: 'cycle-2026-03', + period: '2026-03', + currency: 'USD' + }, + rentRule: { + amountMinor: '70000', + currency: 'USD' + }, + utilityBills: [ + { + id: 'utility-1', + billName: 'Electricity', + amountMinor: '12000', + currency: 'USD', + createdByMemberId: 'member-123456', + createdAt: '2026-03-12T12:00:00Z' + } + ] + } + }) + }) +}) + +describe('createMiniAppOpenCycleHandler', () => { + test('opens a billing cycle for an authenticated admin', async () => { + const repository = onboardingRepository() + const handler = createMiniAppOpenCycleHandler({ + 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/billing-cycle/open', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: initData(), + period: '2026-03', + currency: 'USD' + }) + }) + ) + + expect(response.status).toBe(200) + const payload = (await response.json()) as { cycleState: { cycle: unknown } } + + expect(payload.cycleState.cycle).toEqual({ + id: 'cycle-2026-03', + period: '2026-03', + currency: 'USD' + }) + }) +}) + +describe('createMiniAppRentUpdateHandler', () => { + test('updates rent for the current billing cycle', async () => { + const repository = onboardingRepository() + const handler = createMiniAppRentUpdateHandler({ + 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/rent/update', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: initData(), + amountMajor: '750', + currency: 'USD' + }) + }) + ) + + expect(response.status).toBe(200) + const payload = (await response.json()) as { cycleState: { rentRule: unknown } } + + expect(payload.cycleState.rentRule).toEqual({ + amountMinor: '70000', + currency: 'USD' + }) + }) +}) + +describe('createMiniAppAddUtilityBillHandler', () => { + test('adds a utility bill for the current billing cycle', async () => { + const repository = onboardingRepository() + const handler = createMiniAppAddUtilityBillHandler({ + 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/add', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: initData(), + billName: 'Internet', + amountMajor: '45', + currency: 'USD' + }) + }) + ) + + expect(response.status).toBe(200) + const payload = (await response.json()) as { cycleState: { utilityBills: unknown[] } } + + expect(payload.cycleState.utilityBills).toHaveLength(1) + }) +}) diff --git a/apps/bot/src/miniapp-billing.ts b/apps/bot/src/miniapp-billing.ts new file mode 100644 index 0000000..0b74942 --- /dev/null +++ b/apps/bot/src/miniapp-billing.ts @@ -0,0 +1,525 @@ +import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application' +import { BillingPeriod } from '@household/domain' +import type { Logger } from '@household/observability' +import type { MiniAppSessionResult } from './miniapp-auth' + +import { + allowedMiniAppOrigin, + createMiniAppSessionService, + miniAppErrorResponse, + miniAppJsonResponse, + readMiniAppRequestPayload +} from './miniapp-auth' + +function serializeCycleState( + state: Awaited> +) { + return { + cycle: state.cycle, + rentRule: state.rentRule + ? { + amountMinor: state.rentRule.amountMinor.toString(), + currency: state.rentRule.currency + } + : null, + utilityBills: state.utilityBills.map((bill) => ({ + id: bill.id, + billName: bill.billName, + amountMinor: bill.amount.amountMinor.toString(), + currency: bill.currency, + createdByMemberId: bill.createdByMemberId, + createdAt: bill.createdAt.toString() + })) + } +} + +async function authenticateAdminSession( + request: Request, + sessionService: ReturnType, + origin: string | undefined +): Promise< + | Response + | { + member: NonNullable + } +> { + const payload = await readMiniAppRequestPayload(request) + if (!payload.initData) { + return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) + } + + const session = await sessionService.authenticate(payload) + if (!session) { + return miniAppJsonResponse({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) + } + + if (!session.authorized || !session.member) { + return miniAppJsonResponse( + { ok: false, error: 'Access limited to active household members' }, + 403, + origin + ) + } + + if (!session.member.isAdmin) { + return miniAppJsonResponse({ ok: false, error: 'Admin access required' }, 403, origin) + } + + return { + member: session.member + } +} + +async function parseJsonBody(request: Request): Promise { + const text = await request.clone().text() + if (text.trim().length === 0) { + return {} as T + } + + try { + return JSON.parse(text) as T + } catch { + throw new Error('Invalid JSON body') + } +} + +async function readCycleQueryPayload(request: Request): Promise<{ + initData: string + period?: string +}> { + const parsed = await parseJsonBody<{ + initData?: string + period?: string + }>(request) + const initData = parsed.initData?.trim() + if (!initData) { + throw new Error('Missing initData') + } + const period = parsed.period?.trim() + + return { + initData, + ...(period + ? { + period: BillingPeriod.fromString(period).toString() + } + : {}) + } +} + +async function readOpenCyclePayload(request: Request): Promise<{ + initData: string + period: string + currency?: string +}> { + const parsed = await parseJsonBody<{ initData?: string; period?: string; currency?: string }>( + request + ) + const initData = parsed.initData?.trim() + if (!initData) { + throw new Error('Missing initData') + } + if (typeof parsed.period !== 'string' || parsed.period.trim().length === 0) { + throw new Error('Missing billing cycle period') + } + + const currency = parsed.currency?.trim() + + return { + initData, + period: BillingPeriod.fromString(parsed.period.trim()).toString(), + ...(currency + ? { + currency + } + : {}) + } +} + +async function readRentUpdatePayload(request: Request): Promise<{ + initData: string + amountMajor: string + currency?: string + period?: string +}> { + const parsed = await parseJsonBody<{ + initData?: string + amountMajor?: string + currency?: string + period?: string + }>(request) + const initData = parsed.initData?.trim() + if (!initData) { + throw new Error('Missing initData') + } + const amountMajor = parsed.amountMajor?.trim() + if (!amountMajor) { + throw new Error('Missing rent amount') + } + + const currency = parsed.currency?.trim() + const period = parsed.period?.trim() + + return { + initData, + amountMajor, + ...(currency + ? { + currency + } + : {}), + ...(period + ? { + period: BillingPeriod.fromString(period).toString() + } + : {}) + } +} + +async function readUtilityBillPayload(request: Request): Promise<{ + initData: string + billName: string + amountMajor: string + currency?: string +}> { + const parsed = await parseJsonBody<{ + initData?: string + billName?: string + amountMajor?: string + currency?: string + }>(request) + const initData = parsed.initData?.trim() + if (!initData) { + throw new Error('Missing initData') + } + const billName = parsed.billName?.trim() + const amountMajor = parsed.amountMajor?.trim() + + 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, + billName, + amountMajor, + ...(currency + ? { + currency + } + : {}) + } +} + +export function createMiniAppBillingCycleHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + 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 readCycleQueryPayload(request) + const cycleState = await options + .financeServiceForHousehold(auth.member.householdId) + .getAdminCycleState(payload.period) + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + cycleState: serializeCycleState(cycleState) + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppOpenCycleHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + 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 readOpenCyclePayload(request) + const service = options.financeServiceForHousehold(auth.member.householdId) + await service.openCycle(payload.period, payload.currency) + const cycleState = await service.getAdminCycleState(payload.period) + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + cycleState: serializeCycleState(cycleState) + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppCloseCycleHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + 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 readCycleQueryPayload(request) + const service = options.financeServiceForHousehold(auth.member.householdId) + await service.closeCycle(payload.period) + 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 createMiniAppRentUpdateHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + 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 readRentUpdatePayload(request) + const service = options.financeServiceForHousehold(auth.member.householdId) + const result = await service.setRent(payload.amountMajor, payload.currency, payload.period) + if (!result) { + return miniAppJsonResponse( + { ok: false, error: 'No billing cycle available' }, + 404, + origin + ) + } + + const cycleState = await service.getAdminCycleState(result.period) + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + cycleState: serializeCycleState(cycleState) + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppAddUtilityBillHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + 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 readUtilityBillPayload(request) + const service = options.financeServiceForHousehold(auth.member.householdId) + const result = await service.addUtilityBill( + payload.billName, + payload.amountMajor, + auth.member.id, + payload.currency + ) + + if (!result) { + return miniAppJsonResponse( + { ok: false, error: 'No billing cycle available' }, + 404, + origin + ) + } + + const cycleState = await service.getAdminCycleState(result.period) + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + cycleState: serializeCycleState(cycleState) + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index 45b8968..a112177 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -73,6 +73,51 @@ describe('createBotWebhookServer', () => { } }) }, + miniAppBillingCycle: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, + miniAppOpenCycle: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, + miniAppCloseCycle: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, + miniAppRentUpdate: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, + miniAppAddUtilityBill: { + 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: {} }), { @@ -259,6 +304,86 @@ describe('createBotWebhookServer', () => { }) }) + test('accepts mini app billing cycle request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/billing-cycle', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + cycleState: {} + }) + }) + + test('accepts mini app open cycle request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/billing-cycle/open', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + cycleState: {} + }) + }) + + test('accepts mini app close cycle request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/billing-cycle/close', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + cycleState: {} + }) + }) + + test('accepts mini app rent update request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/rent/update', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + cycleState: {} + }) + }) + + test('accepts mini app utility bill add request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/utility-bills/add', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + 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', { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 0567134..0e6ee25 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -56,6 +56,36 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppBillingCycle?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppOpenCycle?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppCloseCycle?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppRentUpdate?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppAddUtilityBill?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppLocalePreference?: | { path?: string @@ -106,6 +136,15 @@ 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 miniAppBillingCyclePath = + options.miniAppBillingCycle?.path ?? '/api/miniapp/admin/billing-cycle' + const miniAppOpenCyclePath = + options.miniAppOpenCycle?.path ?? '/api/miniapp/admin/billing-cycle/open' + const miniAppCloseCyclePath = + options.miniAppCloseCycle?.path ?? '/api/miniapp/admin/billing-cycle/close' + const miniAppRentUpdatePath = options.miniAppRentUpdate?.path ?? '/api/miniapp/admin/rent/update' + const miniAppAddUtilityBillPath = + options.miniAppAddUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/add' const miniAppLocalePreferencePath = options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale' const schedulerPathPrefix = options.scheduler @@ -159,6 +198,26 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppPromoteMember.handler(request) } + if (options.miniAppBillingCycle && url.pathname === miniAppBillingCyclePath) { + return await options.miniAppBillingCycle.handler(request) + } + + if (options.miniAppOpenCycle && url.pathname === miniAppOpenCyclePath) { + return await options.miniAppOpenCycle.handler(request) + } + + if (options.miniAppCloseCycle && url.pathname === miniAppCloseCyclePath) { + return await options.miniAppCloseCycle.handler(request) + } + + if (options.miniAppRentUpdate && url.pathname === miniAppRentUpdatePath) { + return await options.miniAppRentUpdate.handler(request) + } + + if (options.miniAppAddUtilityBill && url.pathname === miniAppAddUtilityBillPath) { + return await options.miniAppAddUtilityBill.handler(request) + } + if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) { return await options.miniAppLocalePreference.handler(request) } diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 599ce04..2dc3a59 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -2,16 +2,22 @@ import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'soli import { dictionary, type Locale } from './i18n' import { + addMiniAppUtilityBill, approveMiniAppPendingMember, + closeMiniAppBillingCycle, fetchMiniAppAdminSettings, + fetchMiniAppBillingCycle, fetchMiniAppDashboard, fetchMiniAppPendingMembers, fetchMiniAppSession, joinMiniAppHousehold, + openMiniAppBillingCycle, promoteMiniAppMember, + type MiniAppAdminCycleState, type MiniAppAdminSettingsPayload, updateMiniAppLocalePreference, updateMiniAppBillingSettings, + updateMiniAppCycleRent, upsertMiniAppUtilityCategory, type MiniAppDashboard, type MiniAppPendingMember @@ -120,6 +126,10 @@ function dashboardLedgerCount(dashboard: MiniAppDashboard | null): string { return dashboard ? String(dashboard.ledger.length) : '—' } +function defaultCyclePeriod(): string { + return new Date().toISOString().slice(0, 7) +} + function App() { const [locale, setLocale] = createSignal('en') const [session, setSession] = createSignal({ @@ -129,6 +139,7 @@ function App() { const [dashboard, setDashboard] = createSignal(null) const [pendingMembers, setPendingMembers] = createSignal([]) const [adminSettings, setAdminSettings] = createSignal(null) + const [cycleState, setCycleState] = createSignal(null) const [joining, setJoining] = createSignal(false) const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal(null) const [promotingMemberId, setPromotingMemberId] = createSignal(null) @@ -136,6 +147,10 @@ function App() { const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false) const [savingBillingSettings, setSavingBillingSettings] = createSignal(false) const [savingCategorySlug, setSavingCategorySlug] = createSignal(null) + const [openingCycle, setOpeningCycle] = createSignal(false) + const [closingCycle, setClosingCycle] = createSignal(false) + const [savingCycleRent, setSavingCycleRent] = createSignal(false) + const [savingUtilityBill, setSavingUtilityBill] = createSignal(false) const [billingForm, setBillingForm] = createSignal({ rentAmountMajor: '', rentCurrency: 'USD' as 'USD' | 'GEL', @@ -146,6 +161,13 @@ function App() { timezone: 'Asia/Tbilisi' }) const [newCategoryName, setNewCategoryName] = createSignal('') + const [cycleForm, setCycleForm] = createSignal({ + period: defaultCyclePeriod(), + currency: 'USD' as 'USD' | 'GEL', + rentAmountMajor: '', + utilityCategorySlug: '', + utilityAmountMajor: '' + }) const copy = createMemo(() => dictionary[locale()]) const onboardingSession = createMemo(() => { @@ -190,6 +212,13 @@ function App() { try { const payload = await fetchMiniAppAdminSettings(initData) setAdminSettings(payload) + setCycleForm((current) => ({ + ...current, + utilityCategorySlug: + current.utilityCategorySlug || + payload.categories.find((category) => category.isActive)?.slug || + '' + })) setBillingForm({ rentAmountMajor: payload.settings.rentAmountMinor ? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2) @@ -210,6 +239,32 @@ function App() { } } + async function loadCycleState(initData: string) { + try { + const payload = await fetchMiniAppBillingCycle(initData) + setCycleState(payload) + setCycleForm((current) => ({ + ...current, + period: payload.cycle?.period ?? current.period, + currency: payload.cycle?.currency ?? payload.rentRule?.currency ?? current.currency, + rentAmountMajor: payload.rentRule + ? (Number(payload.rentRule.amountMinor) / 100).toFixed(2) + : '', + utilityCategorySlug: + current.utilityCategorySlug || + adminSettings()?.categories.find((category) => category.isActive)?.slug || + '', + utilityAmountMajor: current.utilityAmountMajor + })) + } catch (error) { + if (import.meta.env.DEV) { + console.warn('Failed to load mini app billing cycle', error) + } + + setCycleState(null) + } + } + async function bootstrap() { const fallbackLocale = detectLocale() setLocale(fallbackLocale) @@ -267,6 +322,10 @@ function App() { if (payload.member.isAdmin) { await loadPendingMembers(initData) await loadAdminSettings(initData) + await loadCycleState(initData) + } else { + setAdminSettings(null) + setCycleState(null) } } catch { if (import.meta.env.DEV) { @@ -360,6 +419,10 @@ function App() { if (payload.member.isAdmin) { await loadPendingMembers(initData) await loadAdminSettings(initData) + await loadCycleState(initData) + } else { + setAdminSettings(null) + setCycleState(null) } return } @@ -499,6 +562,107 @@ function App() { } } + async function handleOpenCycle() { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { + return + } + + setOpeningCycle(true) + + try { + const state = await openMiniAppBillingCycle(initData, { + period: cycleForm().period, + currency: cycleForm().currency + }) + setCycleState(state) + setCycleForm((current) => ({ + ...current, + period: state.cycle?.period ?? current.period, + currency: state.cycle?.currency ?? current.currency + })) + } finally { + setOpeningCycle(false) + } + } + + async function handleCloseCycle() { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { + return + } + + setClosingCycle(true) + + try { + const state = await closeMiniAppBillingCycle(initData, cycleState()?.cycle?.period) + setCycleState(state) + } finally { + setClosingCycle(false) + } + } + + async function handleSaveCycleRent() { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { + return + } + + setSavingCycleRent(true) + + try { + const state = await updateMiniAppCycleRent(initData, { + amountMajor: cycleForm().rentAmountMajor, + currency: cycleForm().currency, + ...(cycleState()?.cycle?.period + ? { + period: cycleState()!.cycle!.period + } + : {}) + }) + setCycleState(state) + } finally { + setSavingCycleRent(false) + } + } + + async function handleAddUtilityBill() { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { + return + } + + const selectedCategory = + adminSettings()?.categories.find( + (category) => category.slug === cycleForm().utilityCategorySlug + ) ?? adminSettings()?.categories.find((category) => category.isActive) + + if (!selectedCategory || cycleForm().utilityAmountMajor.trim().length === 0) { + return + } + + setSavingUtilityBill(true) + + try { + const state = await addMiniAppUtilityBill(initData, { + billName: selectedCategory.name, + amountMajor: cycleForm().utilityAmountMajor, + currency: cycleForm().currency + }) + setCycleState(state) + setCycleForm((current) => ({ + ...current, + utilityAmountMajor: '' + })) + } finally { + setSavingUtilityBill(false) + } + } + async function handleSaveUtilityCategory(input: { slug?: string name: string @@ -625,6 +789,171 @@ function App() {

{copy().householdSettingsBody}

+
+
+ {copy().billingCycleTitle} + {cycleState()?.cycle?.period ?? copy().billingCycleEmpty} +
+ {cycleState()?.cycle ? ( + <> +

+ {copy().billingCycleStatus.replace( + '{currency}', + cycleState()?.cycle?.currency ?? cycleForm().currency + )} +

+
+ + +
+
+ + +
+
+ + +
+ +
+ {cycleState()?.utilityBills.length ? ( + cycleState()?.utilityBills.map((bill) => ( +
+
+ {bill.billName} + + {(Number(bill.amountMinor) / 100).toFixed(2)} {bill.currency} + +
+

{bill.createdAt.slice(0, 10)}

+
+ )) + ) : ( +

{copy().utilityBillsEmpty}

+ )} +
+ + ) : ( + <> +

{copy().billingCycleOpenHint}

+
+ + +
+ + + )} +
{copy().billingSettingsTitle} diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 0adc039..e50f764 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -55,6 +55,22 @@ export const dictionary = { householdSettingsTitle: 'Household settings', householdSettingsBody: 'Control household defaults and approve roommates who requested access.', billingSettingsTitle: 'Billing settings', + billingCycleTitle: 'Current billing cycle', + billingCycleEmpty: 'No open cycle', + billingCycleStatus: 'Current cycle currency: {currency}', + billingCycleOpenHint: 'Open a cycle before entering rent and utility bills.', + billingCyclePeriod: 'Cycle period', + openCycleAction: 'Open cycle', + openingCycle: 'Opening cycle…', + closeCycleAction: 'Close cycle', + closingCycle: 'Closing cycle…', + saveCycleRentAction: 'Save current cycle rent', + savingCycleRent: 'Saving rent…', + utilityCategoryLabel: 'Utility category', + utilityAmount: 'Utility amount', + addUtilityBillAction: 'Add utility bill', + savingUtilityBill: 'Saving utility bill…', + utilityBillsEmpty: 'No utility bills recorded for this cycle yet.', rentAmount: 'Rent amount', rentDueDay: 'Rent due day', rentWarningDay: 'Rent warning day', @@ -141,6 +157,22 @@ export const dictionary = { householdSettingsTitle: 'Настройки household', householdSettingsBody: 'Здесь можно менять язык household и подтверждать новых соседей.', billingSettingsTitle: 'Настройки биллинга', + billingCycleTitle: 'Текущий billing cycle', + billingCycleEmpty: 'Нет открытого цикла', + billingCycleStatus: 'Валюта текущего цикла: {currency}', + billingCycleOpenHint: 'Открой цикл перед тем, как вносить аренду и коммунальные счета.', + billingCyclePeriod: 'Период цикла', + openCycleAction: 'Открыть цикл', + openingCycle: 'Открываем цикл…', + closeCycleAction: 'Закрыть цикл', + closingCycle: 'Закрываем цикл…', + saveCycleRentAction: 'Сохранить аренду для цикла', + savingCycleRent: 'Сохраняем аренду…', + utilityCategoryLabel: 'Категория коммуналки', + utilityAmount: 'Сумма коммуналки', + addUtilityBillAction: 'Добавить коммунальный счёт', + savingUtilityBill: 'Сохраняем счёт…', + utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.', rentAmount: 'Сумма аренды', rentDueDay: 'День оплаты аренды', rentWarningDay: 'День напоминания по аренде', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 8577f52..0759bdb 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -314,6 +314,17 @@ button { grid-column: 1 / -1; } +.inline-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +} + +.inline-actions .ghost-button { + margin-top: 0; +} + .panel--wide { min-height: 170px; } diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 574fbdc..a87da94 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -89,6 +89,26 @@ export interface MiniAppAdminSettingsPayload { members: readonly MiniAppMember[] } +export interface MiniAppAdminCycleState { + cycle: { + id: string + period: string + currency: 'USD' | 'GEL' + } | null + rentRule: { + amountMinor: string + currency: 'USD' | 'GEL' + } | null + utilityBills: readonly { + id: string + billName: string + amountMinor: string + currency: 'USD' | 'GEL' + createdByMemberId: string | null + createdAt: string + }[] +} + function apiBaseUrl(): string { const runtimeConfigured = runtimeBotApiUrl() if (runtimeConfigured) { @@ -431,3 +451,159 @@ export async function promoteMiniAppMember( return payload.member } + +export async function fetchMiniAppBillingCycle(initData: string): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/billing-cycle`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + cycleState?: MiniAppAdminCycleState + error?: string + } + + if (!response.ok || !payload.authorized || !payload.cycleState) { + throw new Error(payload.error ?? 'Failed to load billing cycle') + } + + return payload.cycleState +} + +export async function openMiniAppBillingCycle( + initData: string, + input: { + period: string + currency: 'USD' | 'GEL' + } +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/billing-cycle/open`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + ...input + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + cycleState?: MiniAppAdminCycleState + error?: string + } + + if (!response.ok || !payload.authorized || !payload.cycleState) { + throw new Error(payload.error ?? 'Failed to open billing cycle') + } + + return payload.cycleState +} + +export async function closeMiniAppBillingCycle( + initData: string, + period?: string +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/billing-cycle/close`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + ...(period + ? { + period + } + : {}) + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + cycleState?: MiniAppAdminCycleState + error?: string + } + + if (!response.ok || !payload.authorized || !payload.cycleState) { + throw new Error(payload.error ?? 'Failed to close billing cycle') + } + + return payload.cycleState +} + +export async function updateMiniAppCycleRent( + initData: string, + input: { + amountMajor: string + currency: 'USD' | 'GEL' + period?: string + } +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/rent/update`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + ...input + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + cycleState?: MiniAppAdminCycleState + error?: string + } + + if (!response.ok || !payload.authorized || !payload.cycleState) { + throw new Error(payload.error ?? 'Failed to update rent') + } + + return payload.cycleState +} + +export async function addMiniAppUtilityBill( + initData: string, + input: { + billName: string + amountMajor: string + currency: 'USD' | 'GEL' + } +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/utility-bills/add`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + ...input + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + cycleState?: MiniAppAdminCycleState + error?: string + } + + if (!response.ok || !payload.authorized || !payload.cycleState) { + throw new Error(payload.error ?? 'Failed to add utility bill') + } + + return payload.cycleState +} diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index b77505e..40d3782 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -138,6 +138,62 @@ describe('createFinanceCommandService', () => { }) }) + test('getAdminCycleState prefers the open cycle and returns rent plus utility bills', async () => { + const repository = new FinanceRepositoryStub() + repository.openCycleRecord = { + id: 'cycle-1', + period: '2026-03', + currency: 'USD' + } + repository.latestCycleRecord = { + id: 'cycle-0', + period: '2026-02', + currency: 'USD' + } + repository.rentRule = { + amountMinor: 70000n, + currency: 'USD' + } + repository.utilityBills = [ + { + id: 'utility-1', + billName: 'Electricity', + amountMinor: 12000n, + currency: 'USD', + createdByMemberId: 'alice', + createdAt: instantFromIso('2026-03-12T12:00:00.000Z') + } + ] + + const service = createFinanceCommandService(repository) + const result = await service.getAdminCycleState() + + expect(result).toEqual({ + cycle: { + id: 'cycle-1', + period: '2026-03', + currency: 'USD' + }, + rentRule: { + amountMinor: 70000n, + currency: 'USD' + }, + utilityBills: [ + { + id: 'utility-1', + billName: 'Electricity', + amount: expect.objectContaining({ + amountMinor: 12000n, + currency: 'USD' + }), + currency: 'USD', + createdByMemberId: 'alice', + createdAt: instantFromIso('2026-03-12T12:00:00.000Z') + } + ] + }) + }) + test('addUtilityBill returns null when no open cycle exists', async () => { const repository = new FinanceRepositoryStub() const service = createFinanceCommandService(repository) diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index cab1654..2f44a88 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -1,6 +1,11 @@ import { createHash } from 'node:crypto' -import type { FinanceCycleRecord, FinanceMemberRecord, FinanceRepository } from '@household/ports' +import type { + FinanceCycleRecord, + FinanceMemberRecord, + FinanceRentRuleRecord, + FinanceRepository +} from '@household/ports' import { BillingCycleId, BillingPeriod, @@ -79,6 +84,19 @@ export interface FinanceDashboard { ledger: readonly FinanceDashboardLedgerEntry[] } +export interface FinanceAdminCycleState { + cycle: FinanceCycleRecord | null + rentRule: FinanceRentRuleRecord | null + utilityBills: readonly { + id: string + billName: string + amount: Money + currency: CurrencyCode + createdByMemberId: string | null + createdAt: Temporal.Instant + }[] +} + async function buildFinanceDashboard( repository: FinanceRepository, periodArg?: string @@ -196,6 +214,7 @@ async function buildFinanceDashboard( export interface FinanceCommandService { getMemberByTelegramUserId(telegramUserId: string): Promise getOpenCycle(): Promise + getAdminCycleState(periodArg?: string): Promise openCycle(periodArg: string, currencyArg?: string): Promise closeCycle(periodArg?: string): Promise setRent( @@ -231,6 +250,38 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina return repository.getOpenCycle() }, + async getAdminCycleState(periodArg) { + const cycle = periodArg + ? await repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString()) + : ((await repository.getOpenCycle()) ?? (await repository.getLatestCycle())) + + if (!cycle) { + return { + cycle: null, + rentRule: null, + utilityBills: [] + } + } + + const [rentRule, utilityBills] = await Promise.all([ + repository.getRentRuleForPeriod(cycle.period), + repository.listUtilityBillsForCycle(cycle.id) + ]) + + return { + cycle, + rentRule, + utilityBills: utilityBills.map((bill) => ({ + id: bill.id, + billName: bill.billName, + amount: Money.fromMinor(bill.amountMinor, bill.currency), + currency: bill.currency, + createdByMemberId: bill.createdByMemberId, + createdAt: bill.createdAt + })) + } + }, + async openCycle(periodArg, currencyArg) { const period = BillingPeriod.fromString(periodArg).toString() const currency = parseCurrency(currencyArg, 'USD')