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'