feat(miniapp): add finance dashboard view

This commit is contained in:
2026-03-08 22:40:49 +04:00
parent f8478b717b
commit c5c356f2b2
17 changed files with 901 additions and 100 deletions

View File

@@ -18,6 +18,7 @@ import { createReminderJobsHandler } from './reminder-jobs'
import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createSchedulerRequestAuthorizer } from './scheduler-auth'
import { createBotWebhookServer } from './server' import { createBotWebhookServer } from './server'
import { createMiniAppAuthHandler } from './miniapp-auth' import { createMiniAppAuthHandler } from './miniapp-auth'
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
const runtime = getBotRuntimeConfig() const runtime = getBotRuntimeConfig()
const bot = createTelegramBot(runtime.telegramBotToken) const bot = createTelegramBot(runtime.telegramBotToken)
@@ -28,6 +29,9 @@ const financeRepositoryClient =
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!) ? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
: null : null
const financeService = financeRepositoryClient
? createFinanceCommandService(financeRepositoryClient.repository)
: null
if (financeRepositoryClient) { if (financeRepositoryClient) {
shutdownTasks.push(financeRepositoryClient.close) shutdownTasks.push(financeRepositoryClient.close)
@@ -59,8 +63,7 @@ if (runtime.purchaseTopicIngestionEnabled) {
} }
if (runtime.financeCommandsEnabled) { if (runtime.financeCommandsEnabled) {
const financeService = createFinanceCommandService(financeRepositoryClient!.repository) const financeCommands = createFinanceCommandsService(financeService!)
const financeCommands = createFinanceCommandsService(financeService)
financeCommands.register(bot) financeCommands.register(bot)
} else { } else {
@@ -98,6 +101,13 @@ const server = createBotWebhookServer({
repository: financeRepositoryClient.repository repository: financeRepositoryClient.repository
}) })
: undefined, : undefined,
miniAppDashboard: financeService
? createMiniAppDashboardHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
financeService
})
: undefined,
scheduler: scheduler:
reminderJobs && runtime.schedulerSharedSecret reminderJobs && runtime.schedulerSharedSecret
? { ? {

View File

@@ -38,6 +38,7 @@ function repository(
addUtilityBill: async () => {}, addUtilityBill: async () => {},
getRentRuleForPeriod: async () => null, getRentRuleForPeriod: async () => null,
getUtilityTotalForCycle: async () => 0n, getUtilityTotalForCycle: async () => 0n,
listUtilityBillsForCycle: async () => [],
listParsedPurchasesForRange: async () => [], listParsedPurchasesForRange: async () => [],
replaceSettlementSnapshot: async () => {} replaceSettlementSnapshot: async () => {}
} }
@@ -84,6 +85,10 @@ describe('createMiniAppAuthHandler', () => {
displayName: 'Stan', displayName: 'Stan',
isAdmin: true isAdmin: true
}, },
features: {
balances: true,
ledger: true
},
telegramUser: { telegramUser: {
id: '123456', id: '123456',
firstName: 'Stan', firstName: 'Stan',

View File

@@ -1,8 +1,8 @@
import type { FinanceRepository } from '@household/ports' import type { FinanceMemberRecord, FinanceRepository } from '@household/ports'
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' 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({ const headers = new Headers({
'content-type': 'application/json; charset=utf-8' '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') const origin = request.headers.get('origin')
if (!origin) { if (!origin) {
@@ -34,7 +37,7 @@ function allowedOrigin(request: Request, allowedOrigins: readonly string[]): str
return allowedOrigins.includes(origin) ? origin : undefined return allowedOrigins.includes(origin) ? origin : undefined
} }
async function readInitData(request: Request): Promise<string | null> { export async function readMiniAppInitData(request: Request): Promise<string | null> {
const text = await request.text() const text = await request.text()
if (text.trim().length === 0) { if (text.trim().length === 0) {
@@ -47,6 +50,53 @@ async function readInitData(request: Request): Promise<string | null> {
return initData && initData.length > 0 ? initData : null return initData && initData.length > 0 ? initData : null
} }
export interface MiniAppSessionResult {
authorized: boolean
reason?: 'not_member'
member?: {
id: string
displayName: string
isAdmin: boolean
}
telegramUser?: ReturnType<typeof verifyTelegramMiniAppInitData>
}
type MiniAppMemberLookup = (telegramUserId: string) => Promise<FinanceMemberRecord | null>
export function createMiniAppSessionService(options: {
botToken: string
getMemberByTelegramUserId: MiniAppMemberLookup
}): {
authenticate: (initData: string) => Promise<MiniAppSessionResult | null>
} {
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: { export function createMiniAppAuthHandler(options: {
allowedOrigins: readonly string[] allowedOrigins: readonly string[]
botToken: string botToken: string
@@ -54,32 +104,40 @@ export function createMiniAppAuthHandler(options: {
}): { }): {
handler: (request: Request) => Promise<Response> handler: (request: Request) => Promise<Response>
} { } {
const sessionService = createMiniAppSessionService({
botToken: options.botToken,
getMemberByTelegramUserId: options.repository.getMemberByTelegramUserId
})
return { return {
handler: async (request) => { handler: async (request) => {
const origin = allowedOrigin(request, options.allowedOrigins) const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
if (request.method === 'OPTIONS') { if (request.method === 'OPTIONS') {
return json({ ok: true }, 204, origin) return miniAppJsonResponse({ ok: true }, 204, origin)
} }
if (request.method !== 'POST') { 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 { try {
const initData = await readInitData(request) const initData = await readMiniAppInitData(request)
if (!initData) { 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) const session = await sessionService.authenticate(initData)
if (!telegramUser) { if (!session) {
return json({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) return miniAppJsonResponse(
{ ok: false, error: 'Invalid Telegram init data' },
401,
origin
)
} }
const member = await options.repository.getMemberByTelegramUserId(telegramUser.id) if (!session.authorized) {
if (!member) { return miniAppJsonResponse(
return json(
{ {
ok: true, ok: true,
authorized: false, authorized: false,
@@ -90,19 +148,15 @@ export function createMiniAppAuthHandler(options: {
) )
} }
return json( return miniAppJsonResponse(
{ {
ok: true, ok: true,
authorized: true, authorized: true,
member: { member: session.member,
id: member.id, telegramUser: session.telegramUser,
displayName: member.displayName,
isAdmin: member.isAdmin
},
telegramUser,
features: { features: {
balances: false, balances: true,
ledger: false ledger: true
} }
}, },
200, 200,
@@ -110,7 +164,7 @@ export function createMiniAppAuthHandler(options: {
) )
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown mini app auth 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)
} }
} }
} }

View File

@@ -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<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
): 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'
}
]
}
})
})
})

View File

@@ -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<Response>
} {
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)
}
}
}
}

View File

@@ -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: { scheduler: {
authorize: async (request) => authorize: async (request) =>
request.headers.get('x-household-scheduler-secret') === 'scheduler-secret', 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 () => { test('rejects scheduler request with missing secret', async () => {
const response = await server.fetch( const response = await server.fetch(
new Request('http://localhost/jobs/reminder/utilities', { new Request('http://localhost/jobs/reminder/utilities', {

View File

@@ -8,6 +8,12 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response> handler: (request: Request) => Promise<Response>
} }
| undefined | undefined
miniAppDashboard?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
scheduler?: scheduler?:
| { | {
pathPrefix?: string pathPrefix?: string
@@ -39,6 +45,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
? options.webhookPath ? options.webhookPath
: `/${options.webhookPath}` : `/${options.webhookPath}`
const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session' const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session'
const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard'
const schedulerPathPrefix = options.scheduler const schedulerPathPrefix = options.scheduler
? (options.scheduler.pathPrefix ?? '/jobs/reminder') ? (options.scheduler.pathPrefix ?? '/jobs/reminder')
: null : null
@@ -55,6 +62,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppAuth.handler(request) return await options.miniAppAuth.handler(request)
} }
if (options.miniAppDashboard && url.pathname === miniAppDashboardPath) {
return await options.miniAppDashboard.handler(request)
}
if (url.pathname !== normalizedWebhookPath) { if (url.pathname !== normalizedWebhookPath) {
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) { if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
if (request.method !== 'POST') { if (request.method !== 'POST') {

View File

@@ -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 { dictionary, type Locale } from './i18n'
import { fetchMiniAppSession } from './miniapp-api' import { fetchMiniAppDashboard, fetchMiniAppSession, type MiniAppDashboard } from './miniapp-api'
import { getTelegramWebApp } from './telegram-webapp' import { getTelegramWebApp } from './telegram-webapp'
type SessionState = type SessionState =
@@ -55,6 +55,7 @@ function App() {
status: 'loading' status: 'loading'
}) })
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home') const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
const copy = createMemo(() => dictionary[locale()]) const copy = createMemo(() => dictionary[locale()])
const blockedSession = createMemo(() => { const blockedSession = createMemo(() => {
@@ -103,9 +104,58 @@ function App() {
member: payload.member, member: payload.member,
telegramUser: payload.telegramUser telegramUser: payload.telegramUser
}) })
try {
setDashboard(await fetchMiniAppDashboard(initData))
} catch {
setDashboard(null)
}
} catch { } catch {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
setSession(demoSession) 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 return
} }
@@ -119,13 +169,74 @@ function App() {
const renderPanel = () => { const renderPanel = () => {
switch (activeNav()) { switch (activeNav()) {
case 'balances': case 'balances':
return copy().balancesEmpty return (
<div class="balance-list">
<ShowDashboard
dashboard={dashboard()}
fallback={<p>{copy().emptyDashboard}</p>}
render={(data) =>
data.members.map((member) => (
<article class="balance-item">
<header>
<strong>{member.displayName}</strong>
<span>
{member.netDueMajor} {data.currency}
</span>
</header>
<p>
{copy().shareRent}: {member.rentShareMajor} {data.currency}
</p>
<p>
{copy().shareUtilities}: {member.utilityShareMajor} {data.currency}
</p>
<p>
{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}
</p>
</article>
))
}
/>
</div>
)
case 'ledger': case 'ledger':
return copy().ledgerEmpty return (
<div class="ledger-list">
<ShowDashboard
dashboard={dashboard()}
fallback={<p>{copy().emptyDashboard}</p>}
render={(data) =>
data.ledger.map((entry) => (
<article class="ledger-item">
<header>
<strong>{entry.title}</strong>
<span>
{entry.amountMajor} {data.currency}
</span>
</header>
<p>{entry.actorDisplayName ?? 'Household'}</p>
</article>
))
}
/>
</div>
)
case 'house': case 'house':
return copy().houseEmpty return copy().houseEmpty
default: default:
return copy().summaryBody return (
<ShowDashboard
dashboard={dashboard()}
fallback={<p>{copy().summaryBody}</p>}
render={(data) => (
<>
<p>
{copy().totalDue}: {data.totalDueMajor} {data.currency}
</p>
<p>{copy().summaryBody}</p>
</>
)}
/>
)
} }
} }
@@ -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 export default App

View File

@@ -26,6 +26,12 @@ export const dictionary = {
summaryTitle: 'Current shell', summaryTitle: 'Current shell',
summaryBody: summaryBody:
'Balances, ledger, and house wiki will land in the next tickets. This shell focuses on verified access, navigation, and mobile layout.', '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', cardAccess: 'Access',
cardAccessBody: 'Telegram identity verified and matched to a household member.', cardAccessBody: 'Telegram identity verified and matched to a household member.',
cardLocale: 'Locale', cardLocale: 'Locale',
@@ -64,6 +70,12 @@ export const dictionary = {
summaryTitle: 'Текущая оболочка', summaryTitle: 'Текущая оболочка',
summaryBody: summaryBody:
'Баланс, леджер и вики дома появятся в следующих тикетах. Сейчас приоритет — проверенный доступ, навигация и мобильный layout.', 'Баланс, леджер и вики дома появятся в следующих тикетах. Сейчас приоритет — проверенный доступ, навигация и мобильный layout.',
totalDue: 'Итого к оплате',
shareRent: 'Аренда',
shareUtilities: 'Коммуналка',
shareOffset: 'Общие покупки',
ledgerTitle: 'Вошедшие операции',
emptyDashboard: 'Пока нет готового billing cycle.',
cardAccess: 'Доступ', cardAccess: 'Доступ',
cardAccessBody: 'Telegram-личность подтверждена и сопоставлена с участником household.', cardAccessBody: 'Telegram-личность подтверждена и сопоставлена с участником household.',
cardLocale: 'Локаль', cardLocale: 'Локаль',

View File

@@ -210,6 +210,39 @@ button {
padding: 18px; 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 { .panel--wide {
min-height: 170px; min-height: 170px;
} }

View File

@@ -14,6 +14,29 @@ export interface MiniAppSession {
reason?: string 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 { function apiBaseUrl(): string {
const runtimeConfigured = runtimeBotApiUrl() const runtimeConfigured = runtimeBotApiUrl()
if (runtimeConfigured) { if (runtimeConfigured) {
@@ -64,3 +87,28 @@ export async function fetchMiniAppSession(initData: string): Promise<MiniAppSess
...(payload.reason ? { reason: payload.reason } : {}) ...(payload.reason ? { reason: payload.reason } : {})
} }
} }
export async function fetchMiniAppDashboard(initData: string): Promise<MiniAppDashboard> {
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
}

View File

@@ -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.

View File

@@ -249,12 +249,34 @@ export function createDbFinanceRepository(
return BigInt(rows[0]?.totalMinor ?? '0') 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) { async listParsedPurchasesForRange(start, end) {
const rows = await db const rows = await db
.select({ .select({
id: schema.purchaseMessages.id, id: schema.purchaseMessages.id,
payerMemberId: schema.purchaseMessages.senderMemberId, payerMemberId: schema.purchaseMessages.senderMemberId,
amountMinor: schema.purchaseMessages.parsedAmountMinor amountMinor: schema.purchaseMessages.parsedAmountMinor,
description: schema.purchaseMessages.parsedItemDescription,
occurredAt: schema.purchaseMessages.messageSentAt
}) })
.from(schema.purchaseMessages) .from(schema.purchaseMessages)
.where( .where(
@@ -270,7 +292,9 @@ export function createDbFinanceRepository(
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
payerMemberId: row.payerMemberId!, payerMemberId: row.payerMemberId!,
amountMinor: row.amountMinor! amountMinor: row.amountMinor!,
description: row.description,
occurredAt: row.occurredAt
})) }))
}, },

View File

@@ -20,6 +20,14 @@ class FinanceRepositoryStub implements FinanceRepository {
rentRule: FinanceRentRuleRecord | null = null rentRule: FinanceRentRuleRecord | null = null
utilityTotal: bigint = 0n utilityTotal: bigint = 0n
purchases: readonly FinanceParsedPurchaseRecord[] = [] purchases: readonly FinanceParsedPurchaseRecord[] = []
utilityBills: readonly {
id: string
billName: string
amountMinor: bigint
currency: 'USD' | 'GEL'
createdByMemberId: string | null
createdAt: Date
}[] = []
lastSavedRentRule: { lastSavedRentRule: {
period: string period: string
@@ -93,6 +101,10 @@ class FinanceRepositoryStub implements FinanceRepository {
return this.utilityTotal return this.utilityTotal
} }
async listUtilityBillsForCycle() {
return this.utilityBills
}
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> { async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
return this.purchases return this.purchases
} }
@@ -161,17 +173,33 @@ describe('createFinanceCommandService', () => {
currency: 'USD' currency: 'USD'
} }
repository.utilityTotal = 12000n 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 = [ repository.purchases = [
{ {
id: 'purchase-1', id: 'purchase-1',
payerMemberId: 'alice', payerMemberId: 'alice',
amountMinor: 3000n amountMinor: 3000n,
description: 'Soap',
occurredAt: new Date('2026-03-12T11:00:00.000Z')
} }
] ]
const service = createFinanceCommandService(repository) const service = createFinanceCommandService(repository)
const dashboard = await service.generateDashboard()
const statement = await service.generateStatement() 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( expect(statement).toBe(
[ [
'Statement for 2026-03', 'Statement for 2026-03',

View File

@@ -47,6 +47,147 @@ async function getCycleByPeriodOrLatest(
return repository.getLatestCycle() 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<FinanceDashboard | null> {
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 { export interface FinanceCommandService {
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null> getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
getOpenCycle(): Promise<FinanceCycleRecord | null> getOpenCycle(): Promise<FinanceCycleRecord | null>
@@ -71,6 +212,7 @@ export interface FinanceCommandService {
currency: CurrencyCode currency: CurrencyCode
period: string period: string
} | null> } | null>
generateDashboard(periodArg?: string): Promise<FinanceDashboard | null>
generateStatement(periodArg?: string): Promise<string | null> generateStatement(periodArg?: string): Promise<string | null>
} }
@@ -155,79 +297,24 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
}, },
async generateStatement(periodArg) { async generateStatement(periodArg) {
const cycle = await getCycleByPeriodOrLatest(repository, periodArg) const dashboard = await buildFinanceDashboard(repository, periodArg)
if (!cycle) { if (!dashboard) {
return null return null
} }
const members = await repository.listMembers() const statementLines = dashboard.members.map((line) => {
if (members.length === 0) { return `- ${line.displayName}: ${line.netDue.toMajorString()} ${dashboard.currency}`
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}`
}) })
return [ return [
`Statement for ${cycle.period}`, `Statement for ${dashboard.period}`,
...statementLines, ...statementLines,
`Total: ${settlement.totalDue.toMajorString()} ${rentRule.currency}` `Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`
].join('\n') ].join('\n')
},
generateDashboard(periodArg) {
return buildFinanceDashboard(repository, periodArg)
} }
} }
} }

View File

@@ -22,6 +22,17 @@ export interface FinanceParsedPurchaseRecord {
id: string id: string
payerMemberId: string payerMemberId: string
amountMinor: bigint 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 { export interface SettlementSnapshotLineRecord {
@@ -60,6 +71,7 @@ export interface FinanceRepository {
}): Promise<void> }): Promise<void>
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null> getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
getUtilityTotalForCycle(cycleId: string): Promise<bigint> getUtilityTotalForCycle(cycleId: string): Promise<bigint>
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
listParsedPurchasesForRange( listParsedPurchasesForRange(
start: Date, start: Date,
end: Date end: Date

View File

@@ -11,6 +11,7 @@ export type {
FinanceParsedPurchaseRecord, FinanceParsedPurchaseRecord,
FinanceRentRuleRecord, FinanceRentRuleRecord,
FinanceRepository, FinanceRepository,
FinanceUtilityBillRecord,
SettlementSnapshotLineRecord, SettlementSnapshotLineRecord,
SettlementSnapshotRecord SettlementSnapshotRecord
} from './finance' } from './finance'