mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
feat(miniapp): add finance dashboard view
This commit is contained in:
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
147
apps/bot/src/miniapp-dashboard.test.ts
Normal file
147
apps/bot/src/miniapp-dashboard.test.ts
Normal 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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
106
apps/bot/src/miniapp-dashboard.ts
Normal file
106
apps/bot/src/miniapp-dashboard.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: 'Локаль',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
79
docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md
Normal file
79
docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md
Normal 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.
|
||||||
@@ -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
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type {
|
|||||||
FinanceParsedPurchaseRecord,
|
FinanceParsedPurchaseRecord,
|
||||||
FinanceRentRuleRecord,
|
FinanceRentRuleRecord,
|
||||||
FinanceRepository,
|
FinanceRepository,
|
||||||
|
FinanceUtilityBillRecord,
|
||||||
SettlementSnapshotLineRecord,
|
SettlementSnapshotLineRecord,
|
||||||
SettlementSnapshotRecord
|
SettlementSnapshotRecord
|
||||||
} from './finance'
|
} from './finance'
|
||||||
|
|||||||
Reference in New Issue
Block a user