From c5c356f2b2ea63f492d8bbfc854c475ad5a36606 Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 22:40:49 +0400 Subject: [PATCH] feat(miniapp): add finance dashboard view --- apps/bot/src/index.ts | 14 +- apps/bot/src/miniapp-auth.test.ts | 5 + apps/bot/src/miniapp-auth.ts | 104 +++++++-- apps/bot/src/miniapp-dashboard.test.ts | 147 ++++++++++++ apps/bot/src/miniapp-dashboard.ts | 106 +++++++++ apps/bot/src/server.test.ts | 25 ++ apps/bot/src/server.ts | 11 + apps/miniapp/src/App.tsx | 129 ++++++++++- apps/miniapp/src/i18n.ts | 12 + apps/miniapp/src/index.css | 33 +++ apps/miniapp/src/miniapp-api.ts | 48 ++++ .../HOUSEBOT-041-miniapp-finance-dashboard.md | 79 +++++++ .../adapters-db/src/finance-repository.ts | 28 ++- .../src/finance-command-service.test.ts | 30 ++- .../src/finance-command-service.ts | 217 ++++++++++++------ packages/ports/src/finance.ts | 12 + packages/ports/src/index.ts | 1 + 17 files changed, 901 insertions(+), 100 deletions(-) create mode 100644 apps/bot/src/miniapp-dashboard.test.ts create mode 100644 apps/bot/src/miniapp-dashboard.ts create mode 100644 docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index a05fd67..42702a1 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -18,6 +18,7 @@ import { createReminderJobsHandler } from './reminder-jobs' import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createBotWebhookServer } from './server' import { createMiniAppAuthHandler } from './miniapp-auth' +import { createMiniAppDashboardHandler } from './miniapp-dashboard' const runtime = getBotRuntimeConfig() const bot = createTelegramBot(runtime.telegramBotToken) @@ -28,6 +29,9 @@ const financeRepositoryClient = runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled ? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!) : null +const financeService = financeRepositoryClient + ? createFinanceCommandService(financeRepositoryClient.repository) + : null if (financeRepositoryClient) { shutdownTasks.push(financeRepositoryClient.close) @@ -59,8 +63,7 @@ if (runtime.purchaseTopicIngestionEnabled) { } if (runtime.financeCommandsEnabled) { - const financeService = createFinanceCommandService(financeRepositoryClient!.repository) - const financeCommands = createFinanceCommandsService(financeService) + const financeCommands = createFinanceCommandsService(financeService!) financeCommands.register(bot) } else { @@ -98,6 +101,13 @@ const server = createBotWebhookServer({ repository: financeRepositoryClient.repository }) : undefined, + miniAppDashboard: financeService + ? createMiniAppDashboardHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + financeService + }) + : undefined, scheduler: reminderJobs && runtime.schedulerSharedSecret ? { diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index adc669a..e812255 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -38,6 +38,7 @@ function repository( addUtilityBill: async () => {}, getRentRuleForPeriod: async () => null, getUtilityTotalForCycle: async () => 0n, + listUtilityBillsForCycle: async () => [], listParsedPurchasesForRange: async () => [], replaceSettlementSnapshot: async () => {} } @@ -84,6 +85,10 @@ describe('createMiniAppAuthHandler', () => { displayName: 'Stan', isAdmin: true }, + features: { + balances: true, + ledger: true + }, telegramUser: { id: '123456', firstName: 'Stan', diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts index 035d701..3316a16 100644 --- a/apps/bot/src/miniapp-auth.ts +++ b/apps/bot/src/miniapp-auth.ts @@ -1,8 +1,8 @@ -import type { FinanceRepository } from '@household/ports' +import type { FinanceMemberRecord, FinanceRepository } from '@household/ports' import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' -function json(body: object, status = 200, origin?: string): Response { +export function miniAppJsonResponse(body: object, status = 200, origin?: string): Response { const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' }) @@ -20,7 +20,10 @@ function json(body: object, status = 200, origin?: string): Response { }) } -function allowedOrigin(request: Request, allowedOrigins: readonly string[]): string | undefined { +export function allowedMiniAppOrigin( + request: Request, + allowedOrigins: readonly string[] +): string | undefined { const origin = request.headers.get('origin') if (!origin) { @@ -34,7 +37,7 @@ function allowedOrigin(request: Request, allowedOrigins: readonly string[]): str return allowedOrigins.includes(origin) ? origin : undefined } -async function readInitData(request: Request): Promise { +export async function readMiniAppInitData(request: Request): Promise { const text = await request.text() if (text.trim().length === 0) { @@ -47,6 +50,53 @@ async function readInitData(request: Request): Promise { return initData && initData.length > 0 ? initData : null } +export interface MiniAppSessionResult { + authorized: boolean + reason?: 'not_member' + member?: { + id: string + displayName: string + isAdmin: boolean + } + telegramUser?: ReturnType +} + +type MiniAppMemberLookup = (telegramUserId: string) => Promise + +export function createMiniAppSessionService(options: { + botToken: string + getMemberByTelegramUserId: MiniAppMemberLookup +}): { + authenticate: (initData: string) => Promise +} { + return { + authenticate: async (initData) => { + const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken) + if (!telegramUser) { + return null + } + + const member = await options.getMemberByTelegramUserId(telegramUser.id) + if (!member) { + return { + authorized: false, + reason: 'not_member' + } + } + + return { + authorized: true, + member: { + id: member.id, + displayName: member.displayName, + isAdmin: member.isAdmin + }, + telegramUser + } + } + } +} + export function createMiniAppAuthHandler(options: { allowedOrigins: readonly string[] botToken: string @@ -54,32 +104,40 @@ export function createMiniAppAuthHandler(options: { }): { handler: (request: Request) => Promise } { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + getMemberByTelegramUserId: options.repository.getMemberByTelegramUserId + }) + return { handler: async (request) => { - const origin = allowedOrigin(request, options.allowedOrigins) + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) if (request.method === 'OPTIONS') { - return json({ ok: true }, 204, origin) + return miniAppJsonResponse({ ok: true }, 204, origin) } if (request.method !== 'POST') { - return json({ ok: false, error: 'Method Not Allowed' }, 405, origin) + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) } try { - const initData = await readInitData(request) + const initData = await readMiniAppInitData(request) if (!initData) { - return json({ ok: false, error: 'Missing initData' }, 400, origin) + return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) } - const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken) - if (!telegramUser) { - return json({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) + const session = await sessionService.authenticate(initData) + if (!session) { + return miniAppJsonResponse( + { ok: false, error: 'Invalid Telegram init data' }, + 401, + origin + ) } - const member = await options.repository.getMemberByTelegramUserId(telegramUser.id) - if (!member) { - return json( + if (!session.authorized) { + return miniAppJsonResponse( { ok: true, authorized: false, @@ -90,19 +148,15 @@ export function createMiniAppAuthHandler(options: { ) } - return json( + return miniAppJsonResponse( { ok: true, authorized: true, - member: { - id: member.id, - displayName: member.displayName, - isAdmin: member.isAdmin - }, - telegramUser, + member: session.member, + telegramUser: session.telegramUser, features: { - balances: false, - ledger: false + balances: true, + ledger: true } }, 200, @@ -110,7 +164,7 @@ export function createMiniAppAuthHandler(options: { ) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown mini app auth error' - return json({ ok: false, error: message }, 400, origin) + return miniAppJsonResponse({ ok: false, error: message }, 400, origin) } } } diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts new file mode 100644 index 0000000..180c9be --- /dev/null +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from 'bun:test' +import { createHmac } from 'node:crypto' + +import { createFinanceCommandService } from '@household/application' +import type { FinanceRepository } from '@household/ports' + +import { createMiniAppDashboardHandler } from './miniapp-dashboard' + +function buildInitData(botToken: string, authDate: number, user: object): string { + const params = new URLSearchParams() + params.set('auth_date', authDate.toString()) + params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc') + params.set('user', JSON.stringify(user)) + + const dataCheckString = [...params.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + + const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() + const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') + params.set('hash', hash) + + return params.toString() +} + +function repository( + member: Awaited> +): FinanceRepository { + return { + getMemberByTelegramUserId: async () => member, + listMembers: async () => [ + member ?? { + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + } + ], + getOpenCycle: async () => ({ + id: 'cycle-1', + period: '2026-03', + currency: 'USD' + }), + getCycleByPeriod: async () => null, + getLatestCycle: async () => ({ + id: 'cycle-1', + period: '2026-03', + currency: 'USD' + }), + openCycle: async () => {}, + closeCycle: async () => {}, + saveRentRule: async () => {}, + addUtilityBill: async () => {}, + getRentRuleForPeriod: async () => ({ + amountMinor: 70000n, + currency: 'USD' + }), + getUtilityTotalForCycle: async () => 12000n, + listUtilityBillsForCycle: async () => [ + { + id: 'utility-1', + billName: 'Electricity', + amountMinor: 12000n, + currency: 'USD', + createdByMemberId: member?.id ?? 'member-1', + createdAt: new Date('2026-03-12T12:00:00.000Z') + } + ], + listParsedPurchasesForRange: async () => [ + { + id: 'purchase-1', + payerMemberId: member?.id ?? 'member-1', + amountMinor: 3000n, + description: 'Soap', + occurredAt: new Date('2026-03-12T11:00:00.000Z') + } + ], + replaceSettlementSnapshot: async () => {} + } +} + +describe('createMiniAppDashboardHandler', () => { + test('returns a dashboard for an authenticated household member', async () => { + const authDate = Math.floor(Date.now() / 1000) + const financeService = createFinanceCommandService( + repository({ + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + }) + ) + + const dashboard = createMiniAppDashboardHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + financeService + }) + + const response = await dashboard.handler( + new Request('http://localhost/api/miniapp/dashboard', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan', + username: 'stanislav', + language_code: 'ru' + }) + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + ok: true, + authorized: true, + dashboard: { + period: '2026-03', + currency: 'USD', + totalDueMajor: '820.00', + members: [ + { + displayName: 'Stan', + netDueMajor: '820.00', + rentShareMajor: '700.00', + utilityShareMajor: '120.00', + purchaseOffsetMajor: '0.00' + } + ], + ledger: [ + { + title: 'Soap' + }, + { + title: 'Electricity' + } + ] + } + }) + }) +}) diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts new file mode 100644 index 0000000..f8f8dc4 --- /dev/null +++ b/apps/bot/src/miniapp-dashboard.ts @@ -0,0 +1,106 @@ +import type { FinanceCommandService } from '@household/application' + +import { + allowedMiniAppOrigin, + createMiniAppSessionService, + miniAppJsonResponse, + readMiniAppInitData +} from './miniapp-auth' + +export function createMiniAppDashboardHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeService: FinanceCommandService +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + getMemberByTelegramUserId: options.financeService.getMemberByTelegramUserId + }) + + 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 initData = await readMiniAppInitData(request) + if (!initData) { + return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) + } + + const session = await sessionService.authenticate(initData) + if (!session) { + return miniAppJsonResponse( + { ok: false, error: 'Invalid Telegram init data' }, + 401, + origin + ) + } + + if (!session.authorized) { + return miniAppJsonResponse( + { + ok: true, + authorized: false, + reason: 'not_member' + }, + 403, + origin + ) + } + + const dashboard = await options.financeService.generateDashboard() + if (!dashboard) { + return miniAppJsonResponse( + { ok: false, error: 'No billing cycle available' }, + 404, + origin + ) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + dashboard: { + period: dashboard.period, + currency: dashboard.currency, + totalDueMajor: dashboard.totalDue.toMajorString(), + members: dashboard.members.map((line) => ({ + memberId: line.memberId, + displayName: line.displayName, + rentShareMajor: line.rentShare.toMajorString(), + utilityShareMajor: line.utilityShare.toMajorString(), + purchaseOffsetMajor: line.purchaseOffset.toMajorString(), + netDueMajor: line.netDue.toMajorString(), + explanations: line.explanations + })), + ledger: dashboard.ledger.map((entry) => ({ + id: entry.id, + kind: entry.kind, + title: entry.title, + amountMajor: entry.amount.toMajorString(), + actorDisplayName: entry.actorDisplayName, + occurredAt: entry.occurredAt + })) + } + }, + 200, + origin + ) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown mini app dashboard error' + return miniAppJsonResponse({ ok: false, error: message }, 400, origin) + } + } + } +} diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index 83a2ab2..33c4258 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -16,6 +16,15 @@ describe('createBotWebhookServer', () => { } }) }, + miniAppDashboard: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, dashboard: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, scheduler: { authorize: async (request) => request.headers.get('x-household-scheduler-secret') === 'scheduler-secret', @@ -95,6 +104,22 @@ describe('createBotWebhookServer', () => { }) }) + test('accepts mini app dashboard request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/dashboard', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + dashboard: {} + }) + }) + test('rejects scheduler request with missing secret', async () => { const response = await server.fetch( new Request('http://localhost/jobs/reminder/utilities', { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 171feaa..adfb4ec 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -8,6 +8,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppDashboard?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined scheduler?: | { pathPrefix?: string @@ -39,6 +45,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { ? options.webhookPath : `/${options.webhookPath}` const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session' + const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard' const schedulerPathPrefix = options.scheduler ? (options.scheduler.pathPrefix ?? '/jobs/reminder') : null @@ -55,6 +62,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppAuth.handler(request) } + if (options.miniAppDashboard && url.pathname === miniAppDashboardPath) { + return await options.miniAppDashboard.handler(request) + } + if (url.pathname !== normalizedWebhookPath) { if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) { if (request.method !== 'POST') { diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 3eeb8e1..1f6eb22 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -1,7 +1,7 @@ -import { Match, Switch, createMemo, createSignal, onMount } from 'solid-js' +import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js' import { dictionary, type Locale } from './i18n' -import { fetchMiniAppSession } from './miniapp-api' +import { fetchMiniAppDashboard, fetchMiniAppSession, type MiniAppDashboard } from './miniapp-api' import { getTelegramWebApp } from './telegram-webapp' type SessionState = @@ -55,6 +55,7 @@ function App() { status: 'loading' }) const [activeNav, setActiveNav] = createSignal('home') + const [dashboard, setDashboard] = createSignal(null) const copy = createMemo(() => dictionary[locale()]) const blockedSession = createMemo(() => { @@ -103,9 +104,58 @@ function App() { member: payload.member, telegramUser: payload.telegramUser }) + + try { + setDashboard(await fetchMiniAppDashboard(initData)) + } catch { + setDashboard(null) + } } catch { if (import.meta.env.DEV) { setSession(demoSession) + setDashboard({ + period: '2026-03', + currency: 'USD', + totalDueMajor: '820.00', + members: [ + { + memberId: 'alice', + displayName: 'Alice', + rentShareMajor: '350.00', + utilityShareMajor: '60.00', + purchaseOffsetMajor: '-15.00', + netDueMajor: '395.00', + explanations: ['Equal utility split', 'Shared purchase offset'] + }, + { + memberId: 'bob', + displayName: 'Bob', + rentShareMajor: '350.00', + utilityShareMajor: '60.00', + purchaseOffsetMajor: '15.00', + netDueMajor: '425.00', + explanations: ['Equal utility split'] + } + ], + ledger: [ + { + id: 'purchase-1', + kind: 'purchase', + title: 'Soap', + amountMajor: '30.00', + actorDisplayName: 'Alice', + occurredAt: '2026-03-12T11:00:00.000Z' + }, + { + id: 'utility-1', + kind: 'utility', + title: 'Electricity', + amountMajor: '120.00', + actorDisplayName: 'Alice', + occurredAt: '2026-03-12T12:00:00.000Z' + } + ] + }) return } @@ -119,13 +169,74 @@ function App() { const renderPanel = () => { switch (activeNav()) { case 'balances': - return copy().balancesEmpty + return ( +
+ {copy().emptyDashboard}

} + render={(data) => + data.members.map((member) => ( +
+
+ {member.displayName} + + {member.netDueMajor} {data.currency} + +
+

+ {copy().shareRent}: {member.rentShareMajor} {data.currency} +

+

+ {copy().shareUtilities}: {member.utilityShareMajor} {data.currency} +

+

+ {copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency} +

+
+ )) + } + /> +
+ ) case 'ledger': - return copy().ledgerEmpty + return ( +
+ {copy().emptyDashboard}

} + render={(data) => + data.ledger.map((entry) => ( +
+
+ {entry.title} + + {entry.amountMajor} {data.currency} + +
+

{entry.actorDisplayName ?? 'Household'}

+
+ )) + } + /> +
+ ) case 'house': return copy().houseEmpty default: - return copy().summaryBody + return ( + {copy().summaryBody}

} + render={(data) => ( + <> +

+ {copy().totalDue}: {data.totalDueMajor} {data.currency} +

+

{copy().summaryBody}

+ + )} + /> + ) } } @@ -254,4 +365,12 @@ function App() { ) } +function ShowDashboard(props: { + dashboard: MiniAppDashboard | null + fallback: JSX.Element + render: (dashboard: MiniAppDashboard) => JSX.Element +}) { + return <>{props.dashboard ? props.render(props.dashboard) : props.fallback} +} + export default App diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index dc7a680..2cd2bb4 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -26,6 +26,12 @@ export const dictionary = { summaryTitle: 'Current shell', summaryBody: 'Balances, ledger, and house wiki will land in the next tickets. This shell focuses on verified access, navigation, and mobile layout.', + totalDue: 'Total due', + shareRent: 'Rent', + shareUtilities: 'Utilities', + shareOffset: 'Shared buys', + ledgerTitle: 'Included ledger', + emptyDashboard: 'No billing cycle is ready yet.', cardAccess: 'Access', cardAccessBody: 'Telegram identity verified and matched to a household member.', cardLocale: 'Locale', @@ -64,6 +70,12 @@ export const dictionary = { summaryTitle: 'Текущая оболочка', summaryBody: 'Баланс, леджер и вики дома появятся в следующих тикетах. Сейчас приоритет — проверенный доступ, навигация и мобильный layout.', + totalDue: 'Итого к оплате', + shareRent: 'Аренда', + shareUtilities: 'Коммуналка', + shareOffset: 'Общие покупки', + ledgerTitle: 'Вошедшие операции', + emptyDashboard: 'Пока нет готового billing cycle.', cardAccess: 'Доступ', cardAccessBody: 'Telegram-личность подтверждена и сопоставлена с участником household.', cardLocale: 'Локаль', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index e5ebdb1..f65eb87 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -210,6 +210,39 @@ button { padding: 18px; } +.balance-list, +.ledger-list { + display: grid; + gap: 12px; +} + +.balance-item, +.ledger-item { + border: 1px solid rgb(255 255 255 / 0.08); + border-radius: 18px; + padding: 14px; + background: rgb(255 255 255 / 0.03); +} + +.balance-item header, +.ledger-item header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.balance-item strong, +.ledger-item strong { + font-size: 1rem; +} + +.balance-item p, +.ledger-item p { + margin-top: 6px; +} + .panel--wide { min-height: 170px; } diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 2773cab..c040b2f 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -14,6 +14,29 @@ export interface MiniAppSession { reason?: string } +export interface MiniAppDashboard { + period: string + currency: 'USD' | 'GEL' + totalDueMajor: string + members: { + memberId: string + displayName: string + rentShareMajor: string + utilityShareMajor: string + purchaseOffsetMajor: string + netDueMajor: string + explanations: readonly string[] + }[] + ledger: { + id: string + kind: 'purchase' | 'utility' + title: string + amountMajor: string + actorDisplayName: string | null + occurredAt: string | null + }[] +} + function apiBaseUrl(): string { const runtimeConfigured = runtimeBotApiUrl() if (runtimeConfigured) { @@ -64,3 +87,28 @@ export async function fetchMiniAppSession(initData: string): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/dashboard`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + dashboard?: MiniAppDashboard + error?: string + } + + if (!response.ok || !payload.authorized || !payload.dashboard) { + throw new Error(payload.error ?? 'Failed to load dashboard') + } + + return payload.dashboard +} diff --git a/docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md b/docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md new file mode 100644 index 0000000..5d7995c --- /dev/null +++ b/docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md @@ -0,0 +1,79 @@ +# HOUSEBOT-041: Mini App Finance Dashboard + +## Summary + +Expose the current settlement snapshot to the Telegram mini app so household members can inspect balances and included ledger items without leaving Telegram. + +## Goals + +- Reuse the same finance service and settlement calculation path as bot statements. +- Show per-member balances for the active or latest billing cycle. +- Show the ledger items that contributed to the cycle total. +- Keep the layout usable inside the Telegram mobile webview. + +## Non-goals + +- Editing balances or bills from the mini app. +- Historical multi-period browsing. +- Advanced charts or analytics. + +## Scope + +- In: backend dashboard endpoint, authenticated mini app access, structured balance payload, ledger rendering in the Solid shell. +- Out: write actions, filters, pagination, admin-only controls. + +## Interfaces and Contracts + +- Backend endpoint: `POST /api/miniapp/dashboard` +- Request body: + - `initData: string` +- Success response: + - `authorized: true` + - `dashboard.period` + - `dashboard.currency` + - `dashboard.totalDueMajor` + - `dashboard.members[]` + - `dashboard.ledger[]` +- Membership failure: + - `authorized: false` + - `reason: "not_member"` +- Missing cycle response: + - `404` + - `error: "No billing cycle available"` + +## Domain Rules + +- Dashboard totals must match the same settlement calculation used by `/finance statement`. +- Money remains in minor units internally and is formatted to major strings only at the API boundary. +- Ledger items are ordered by event time, then title for deterministic display. + +## Security and Privacy + +- Dashboard access requires valid Telegram initData and a mapped household member. +- CORS follows the same allow-list behavior as the mini app session endpoint. +- Only household-scoped finance data is returned. + +## Observability + +- Reuse existing HTTP request logs from the bot server. +- Handler errors return explicit 4xx responses for invalid auth or missing cycle state. + +## Edge Cases and Failure Modes + +- Invalid or expired initData returns `401`. +- Non-members receive `403`. +- Empty household billing state returns `404`. +- Missing purchase descriptions fall back to `Shared purchase`. + +## Test Plan + +- Unit: finance command service dashboard output and ledger ordering. +- Unit: mini app dashboard handler auth and payload contract. +- Integration: full repo typecheck, tests, build. + +## Acceptance Criteria + +- [ ] Mini app members can view current balances and total due. +- [ ] Ledger entries match the purchase and utility inputs used by the settlement. +- [ ] Dashboard totals stay consistent with the bot statement output. +- [ ] Mobile shell renders balances and ledger states without placeholder-only content. diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index fe04338..5d18644 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -249,12 +249,34 @@ export function createDbFinanceRepository( return BigInt(rows[0]?.totalMinor ?? '0') }, + async listUtilityBillsForCycle(cycleId) { + const rows = await db + .select({ + id: schema.utilityBills.id, + billName: schema.utilityBills.billName, + amountMinor: schema.utilityBills.amountMinor, + currency: schema.utilityBills.currency, + createdByMemberId: schema.utilityBills.createdByMemberId, + createdAt: schema.utilityBills.createdAt + }) + .from(schema.utilityBills) + .where(eq(schema.utilityBills.cycleId, cycleId)) + .orderBy(schema.utilityBills.createdAt) + + return rows.map((row) => ({ + ...row, + currency: toCurrencyCode(row.currency) + })) + }, + async listParsedPurchasesForRange(start, end) { const rows = await db .select({ id: schema.purchaseMessages.id, payerMemberId: schema.purchaseMessages.senderMemberId, - amountMinor: schema.purchaseMessages.parsedAmountMinor + amountMinor: schema.purchaseMessages.parsedAmountMinor, + description: schema.purchaseMessages.parsedItemDescription, + occurredAt: schema.purchaseMessages.messageSentAt }) .from(schema.purchaseMessages) .where( @@ -270,7 +292,9 @@ export function createDbFinanceRepository( return rows.map((row) => ({ id: row.id, payerMemberId: row.payerMemberId!, - amountMinor: row.amountMinor! + amountMinor: row.amountMinor!, + description: row.description, + occurredAt: row.occurredAt })) }, diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index 2aec740..2a71e2b 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -20,6 +20,14 @@ class FinanceRepositoryStub implements FinanceRepository { rentRule: FinanceRentRuleRecord | null = null utilityTotal: bigint = 0n purchases: readonly FinanceParsedPurchaseRecord[] = [] + utilityBills: readonly { + id: string + billName: string + amountMinor: bigint + currency: 'USD' | 'GEL' + createdByMemberId: string | null + createdAt: Date + }[] = [] lastSavedRentRule: { period: string @@ -93,6 +101,10 @@ class FinanceRepositoryStub implements FinanceRepository { return this.utilityTotal } + async listUtilityBillsForCycle() { + return this.utilityBills + } + async listParsedPurchasesForRange(): Promise { return this.purchases } @@ -161,17 +173,33 @@ describe('createFinanceCommandService', () => { currency: 'USD' } repository.utilityTotal = 12000n + repository.utilityBills = [ + { + id: 'utility-1', + billName: 'Electricity', + amountMinor: 12000n, + currency: 'USD', + createdByMemberId: 'alice', + createdAt: new Date('2026-03-12T12:00:00.000Z') + } + ] repository.purchases = [ { id: 'purchase-1', payerMemberId: 'alice', - amountMinor: 3000n + amountMinor: 3000n, + description: 'Soap', + occurredAt: new Date('2026-03-12T11:00:00.000Z') } ] const service = createFinanceCommandService(repository) + const dashboard = await service.generateDashboard() const statement = await service.generateStatement() + expect(dashboard).not.toBeNull() + expect(dashboard?.members.map((line) => line.netDue.amountMinor)).toEqual([39500n, 42500n]) + expect(dashboard?.ledger.map((entry) => entry.title)).toEqual(['Soap', 'Electricity']) expect(statement).toBe( [ 'Statement for 2026-03', diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index 3562196..8edc41b 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -47,6 +47,147 @@ async function getCycleByPeriodOrLatest( return repository.getLatestCycle() } +export interface FinanceDashboardMemberLine { + memberId: string + displayName: string + rentShare: Money + utilityShare: Money + purchaseOffset: Money + netDue: Money + explanations: readonly string[] +} + +export interface FinanceDashboardLedgerEntry { + id: string + kind: 'purchase' | 'utility' + title: string + amount: Money + actorDisplayName: string | null + occurredAt: string | null +} + +export interface FinanceDashboard { + period: string + currency: CurrencyCode + totalDue: Money + members: readonly FinanceDashboardMemberLine[] + ledger: readonly FinanceDashboardLedgerEntry[] +} + +async function buildFinanceDashboard( + repository: FinanceRepository, + periodArg?: string +): Promise { + const cycle = await getCycleByPeriodOrLatest(repository, periodArg) + if (!cycle) { + return null + } + + const members = await repository.listMembers() + if (members.length === 0) { + throw new Error('No household members configured') + } + + const rentRule = await repository.getRentRuleForPeriod(cycle.period) + if (!rentRule) { + throw new Error('No rent rule configured for this cycle period') + } + + const period = BillingPeriod.fromString(cycle.period) + const { start, end } = monthRange(period) + const purchases = await repository.listParsedPurchasesForRange(start, end) + const utilityBills = await repository.listUtilityBillsForCycle(cycle.id) + const utilitiesMinor = await repository.getUtilityTotalForCycle(cycle.id) + + const settlement = calculateMonthlySettlement({ + cycleId: BillingCycleId.from(cycle.id), + period, + rent: Money.fromMinor(rentRule.amountMinor, rentRule.currency), + utilities: Money.fromMinor(utilitiesMinor, rentRule.currency), + utilitySplitMode: 'equal', + members: members.map((member) => ({ + memberId: MemberId.from(member.id), + active: true + })), + purchases: purchases.map((purchase) => ({ + purchaseId: PurchaseEntryId.from(purchase.id), + payerId: MemberId.from(purchase.payerMemberId), + amount: Money.fromMinor(purchase.amountMinor, rentRule.currency) + })) + }) + + await repository.replaceSettlementSnapshot({ + cycleId: cycle.id, + inputHash: computeInputHash({ + cycleId: cycle.id, + rentMinor: rentRule.amountMinor.toString(), + utilitiesMinor: utilitiesMinor.toString(), + purchaseCount: purchases.length, + memberCount: members.length + }), + totalDueMinor: settlement.totalDue.amountMinor, + currency: rentRule.currency, + metadata: { + generatedBy: 'bot-command', + source: 'finance-service' + }, + lines: settlement.lines.map((line) => ({ + memberId: line.memberId.toString(), + rentShareMinor: line.rentShare.amountMinor, + utilityShareMinor: line.utilityShare.amountMinor, + purchaseOffsetMinor: line.purchaseOffset.amountMinor, + netDueMinor: line.netDue.amountMinor, + explanations: line.explanations + })) + }) + + const memberNameById = new Map(members.map((member) => [member.id, member.displayName])) + const dashboardMembers = settlement.lines.map((line) => ({ + memberId: line.memberId.toString(), + displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(), + rentShare: line.rentShare, + utilityShare: line.utilityShare, + purchaseOffset: line.purchaseOffset, + netDue: line.netDue, + explanations: line.explanations + })) + + const ledger: FinanceDashboardLedgerEntry[] = [ + ...utilityBills.map((bill) => ({ + id: bill.id, + kind: 'utility' as const, + title: bill.billName, + amount: Money.fromMinor(bill.amountMinor, bill.currency), + actorDisplayName: bill.createdByMemberId + ? (memberNameById.get(bill.createdByMemberId) ?? null) + : null, + occurredAt: bill.createdAt.toISOString() + })), + ...purchases.map((purchase) => ({ + id: purchase.id, + kind: 'purchase' as const, + title: purchase.description ?? 'Shared purchase', + amount: Money.fromMinor(purchase.amountMinor, rentRule.currency), + actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null, + occurredAt: purchase.occurredAt?.toISOString() ?? null + })) + ].sort((left, right) => { + if (left.occurredAt === right.occurredAt) { + return left.title.localeCompare(right.title) + } + + return (left.occurredAt ?? '').localeCompare(right.occurredAt ?? '') + }) + + return { + period: cycle.period, + currency: rentRule.currency, + totalDue: settlement.totalDue, + members: dashboardMembers, + ledger + } +} + export interface FinanceCommandService { getMemberByTelegramUserId(telegramUserId: string): Promise getOpenCycle(): Promise @@ -71,6 +212,7 @@ export interface FinanceCommandService { currency: CurrencyCode period: string } | null> + generateDashboard(periodArg?: string): Promise generateStatement(periodArg?: string): Promise } @@ -155,79 +297,24 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina }, async generateStatement(periodArg) { - const cycle = await getCycleByPeriodOrLatest(repository, periodArg) - if (!cycle) { + const dashboard = await buildFinanceDashboard(repository, periodArg) + if (!dashboard) { return null } - const members = await repository.listMembers() - if (members.length === 0) { - throw new Error('No household members configured') - } - - const rentRule = await repository.getRentRuleForPeriod(cycle.period) - if (!rentRule) { - throw new Error('No rent rule configured for this cycle period') - } - - const period = BillingPeriod.fromString(cycle.period) - const { start, end } = monthRange(period) - const purchases = await repository.listParsedPurchasesForRange(start, end) - const utilitiesMinor = await repository.getUtilityTotalForCycle(cycle.id) - - const settlement = calculateMonthlySettlement({ - cycleId: BillingCycleId.from(cycle.id), - period, - rent: Money.fromMinor(rentRule.amountMinor, rentRule.currency), - utilities: Money.fromMinor(utilitiesMinor, rentRule.currency), - utilitySplitMode: 'equal', - members: members.map((member) => ({ - memberId: MemberId.from(member.id), - active: true - })), - purchases: purchases.map((purchase) => ({ - purchaseId: PurchaseEntryId.from(purchase.id), - payerId: MemberId.from(purchase.payerMemberId), - amount: Money.fromMinor(purchase.amountMinor, rentRule.currency) - })) - }) - - await repository.replaceSettlementSnapshot({ - cycleId: cycle.id, - inputHash: computeInputHash({ - cycleId: cycle.id, - rentMinor: rentRule.amountMinor.toString(), - utilitiesMinor: utilitiesMinor.toString(), - purchaseCount: purchases.length, - memberCount: members.length - }), - totalDueMinor: settlement.totalDue.amountMinor, - currency: rentRule.currency, - metadata: { - generatedBy: 'bot-command', - source: 'statement' - }, - lines: settlement.lines.map((line) => ({ - memberId: line.memberId.toString(), - rentShareMinor: line.rentShare.amountMinor, - utilityShareMinor: line.utilityShare.amountMinor, - purchaseOffsetMinor: line.purchaseOffset.amountMinor, - netDueMinor: line.netDue.amountMinor, - explanations: line.explanations - })) - }) - - const memberNameById = new Map(members.map((member) => [member.id, member.displayName])) - const statementLines = settlement.lines.map((line) => { - const name = memberNameById.get(line.memberId.toString()) ?? line.memberId.toString() - return `- ${name}: ${line.netDue.toMajorString()} ${rentRule.currency}` + const statementLines = dashboard.members.map((line) => { + return `- ${line.displayName}: ${line.netDue.toMajorString()} ${dashboard.currency}` }) return [ - `Statement for ${cycle.period}`, + `Statement for ${dashboard.period}`, ...statementLines, - `Total: ${settlement.totalDue.toMajorString()} ${rentRule.currency}` + `Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}` ].join('\n') + }, + + generateDashboard(periodArg) { + return buildFinanceDashboard(repository, periodArg) } } } diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts index c71c80e..2abb898 100644 --- a/packages/ports/src/finance.ts +++ b/packages/ports/src/finance.ts @@ -22,6 +22,17 @@ export interface FinanceParsedPurchaseRecord { id: string payerMemberId: string amountMinor: bigint + description: string | null + occurredAt: Date | null +} + +export interface FinanceUtilityBillRecord { + id: string + billName: string + amountMinor: bigint + currency: CurrencyCode + createdByMemberId: string | null + createdAt: Date } export interface SettlementSnapshotLineRecord { @@ -60,6 +71,7 @@ export interface FinanceRepository { }): Promise getRentRuleForPeriod(period: string): Promise getUtilityTotalForCycle(cycleId: string): Promise + listUtilityBillsForCycle(cycleId: string): Promise listParsedPurchasesForRange( start: Date, end: Date diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 6a8bff2..6ec77f4 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -11,6 +11,7 @@ export type { FinanceParsedPurchaseRecord, FinanceRentRuleRecord, FinanceRepository, + FinanceUtilityBillRecord, SettlementSnapshotLineRecord, SettlementSnapshotRecord } from './finance'