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 { 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
? {

View File

@@ -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',

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'
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<string | null> {
export async function readMiniAppInitData(request: Request): Promise<string | null> {
const text = await request.text()
if (text.trim().length === 0) {
@@ -47,6 +50,53 @@ async function readInitData(request: Request): Promise<string | 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: {
allowedOrigins: readonly string[]
botToken: string
@@ -54,32 +104,40 @@ export function createMiniAppAuthHandler(options: {
}): {
handler: (request: Request) => Promise<Response>
} {
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)
}
}
}

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: {
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', {

View File

@@ -8,6 +8,12 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppDashboard?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| 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') {

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 { 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<NavigationKey>('home')
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(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 (
<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':
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':
return copy().houseEmpty
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

View File

@@ -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: 'Локаль',

View File

@@ -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;
}

View File

@@ -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<MiniAppSess
...(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
}