fix(review): harden miniapp auth and finance flows

This commit is contained in:
2026-03-09 00:30:31 +04:00
parent 91a040f2ee
commit c8b17136be
22 changed files with 327 additions and 157 deletions

View File

@@ -1,27 +1,9 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { createHmac } from 'node:crypto'
import type { FinanceRepository } from '@household/ports' import type { FinanceRepository } from '@household/ports'
import { createMiniAppAuthHandler } from './miniapp-auth' import { createMiniAppAuthHandler } from './miniapp-auth'
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
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( function repository(
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>> member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
@@ -66,7 +48,7 @@ describe('createMiniAppAuthHandler', () => {
'content-type': 'application/json' 'content-type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
initData: buildInitData('test-bot-token', authDate, { initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456, id: 123456,
first_name: 'Stan', first_name: 'Stan',
username: 'stanislav', username: 'stanislav',
@@ -114,7 +96,7 @@ describe('createMiniAppAuthHandler', () => {
'content-type': 'application/json' 'content-type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
initData: buildInitData('test-bot-token', authDate, { initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456, id: 123456,
first_name: 'Stan' first_name: 'Stan'
}) })
@@ -129,4 +111,73 @@ describe('createMiniAppAuthHandler', () => {
reason: 'not_member' reason: 'not_member'
}) })
}) })
test('returns 400 for malformed JSON bodies', async () => {
const auth = createMiniAppAuthHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
repository: repository(null)
})
const response = await auth.handler(
new Request('http://localhost/api/miniapp/session', {
method: 'POST',
headers: {
origin: 'http://localhost:5173',
'content-type': 'application/json'
},
body: '{"initData":'
})
)
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
ok: false,
error: 'Invalid JSON body'
})
})
test('does not reflect arbitrary origins in production without an allow-list', async () => {
const previousNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
try {
const authDate = Math.floor(Date.now() / 1000)
const auth = createMiniAppAuthHandler({
allowedOrigins: [],
botToken: 'test-bot-token',
repository: repository({
id: 'member-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: true
})
})
const response = await auth.handler(
new Request('http://localhost/api/miniapp/session', {
method: 'POST',
headers: {
origin: 'https://unknown.example',
'content-type': 'application/json'
},
body: JSON.stringify({
initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456,
first_name: 'Stan'
})
})
})
)
expect(response.status).toBe(200)
expect(response.headers.get('access-control-allow-origin')).toBeNull()
} finally {
if (previousNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previousNodeEnv
}
}
})
}) })

View File

@@ -22,7 +22,10 @@ export function miniAppJsonResponse(body: object, status = 200, origin?: string)
export function allowedMiniAppOrigin( export function allowedMiniAppOrigin(
request: Request, request: Request,
allowedOrigins: readonly string[] allowedOrigins: readonly string[],
options: {
allowDynamicOrigin?: boolean
} = {}
): string | undefined { ): string | undefined {
const origin = request.headers.get('origin') const origin = request.headers.get('origin')
@@ -31,7 +34,8 @@ export function allowedMiniAppOrigin(
} }
if (allowedOrigins.length === 0) { if (allowedOrigins.length === 0) {
return origin const allowDynamicOrigin = options.allowDynamicOrigin ?? process.env.NODE_ENV !== 'production'
return allowDynamicOrigin ? origin : undefined
} }
return allowedOrigins.includes(origin) ? origin : undefined return allowedOrigins.includes(origin) ? origin : undefined
@@ -44,12 +48,35 @@ export async function readMiniAppInitData(request: Request): Promise<string | nu
return null return null
} }
const parsed = JSON.parse(text) as { initData?: string } let parsed: { initData?: string }
try {
parsed = JSON.parse(text) as { initData?: string }
} catch {
throw new Error('Invalid JSON body')
}
const initData = parsed.initData?.trim() const initData = parsed.initData?.trim()
return initData && initData.length > 0 ? initData : null return initData && initData.length > 0 ? initData : null
} }
export function miniAppErrorResponse(error: unknown, origin?: string): Response {
const message = error instanceof Error ? error.message : 'Unknown mini app error'
if (message === 'Invalid JSON body') {
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
}
console.error(
JSON.stringify({
event: 'miniapp.request_failed',
error: message
})
)
return miniAppJsonResponse({ ok: false, error: 'Internal Server Error' }, 500, origin)
}
export interface MiniAppSessionResult { export interface MiniAppSessionResult {
authorized: boolean authorized: boolean
reason?: 'not_member' reason?: 'not_member'
@@ -163,8 +190,7 @@ export function createMiniAppAuthHandler(options: {
origin origin
) )
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown mini app auth error' return miniAppErrorResponse(error, origin)
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
} }
} }
} }

View File

@@ -1,28 +1,10 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { createHmac } from 'node:crypto'
import { createFinanceCommandService } from '@household/application' import { createFinanceCommandService } from '@household/application'
import type { FinanceRepository } from '@household/ports' import type { FinanceRepository } from '@household/ports'
import { createMiniAppDashboardHandler } from './miniapp-dashboard' import { createMiniAppDashboardHandler } from './miniapp-dashboard'
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
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( function repository(
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>> member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
@@ -106,7 +88,7 @@ describe('createMiniAppDashboardHandler', () => {
'content-type': 'application/json' 'content-type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
initData: buildInitData('test-bot-token', authDate, { initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456, id: 123456,
first_name: 'Stan', first_name: 'Stan',
username: 'stanislav', username: 'stanislav',
@@ -144,4 +126,38 @@ describe('createMiniAppDashboardHandler', () => {
} }
}) })
}) })
test('returns 400 for malformed JSON bodies', async () => {
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: '{"initData":'
})
)
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
ok: false,
error: 'Invalid JSON body'
})
})
}) })

View File

@@ -3,6 +3,7 @@ import type { FinanceCommandService } from '@household/application'
import { import {
allowedMiniAppOrigin, allowedMiniAppOrigin,
createMiniAppSessionService, createMiniAppSessionService,
miniAppErrorResponse,
miniAppJsonResponse, miniAppJsonResponse,
readMiniAppInitData readMiniAppInitData
} from './miniapp-auth' } from './miniapp-auth'
@@ -98,8 +99,7 @@ export function createMiniAppDashboardHandler(options: {
origin origin
) )
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown mini app dashboard error' return miniAppErrorResponse(error, origin)
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
} }
} }
} }

View File

@@ -1,8 +1,6 @@
import { BillingPeriod } from '@household/domain'
import type { ReminderJobService } from '@household/application' import type { ReminderJobService } from '@household/application'
import { BillingPeriod } from '@household/domain'
const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const import { REMINDER_TYPES, type ReminderType } from '@household/ports'
type ReminderType = (typeof REMINDER_TYPES)[number]
interface ReminderJobRequestBody { interface ReminderJobRequestBody {
period?: string period?: string
@@ -42,8 +40,11 @@ async function readBody(request: Request): Promise<ReminderJobRequestBody> {
return {} return {}
} }
const parsed = JSON.parse(text) as ReminderJobRequestBody try {
return parsed return JSON.parse(text) as ReminderJobRequestBody
} catch {
throw new Error('Invalid JSON body')
}
} }
export function createReminderJobsHandler(options: { export function createReminderJobsHandler(options: {

View File

@@ -57,11 +57,9 @@ export function createSchedulerRequestAuthorizer(options: {
return true return true
} }
if (!oidcAudience || allowedEmails.size === 0) {
if (allowedEmails.size === 0) { if (allowedEmails.size === 0) {
return false return false
} }
}
try { try {
const audience = oidcAudience ?? new URL(request.url).origin const audience = oidcAudience ?? new URL(request.url).origin

View File

@@ -1,30 +1,12 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { createHmac } from 'node:crypto'
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
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()
}
describe('verifyTelegramMiniAppInitData', () => { describe('verifyTelegramMiniAppInitData', () => {
test('verifies valid init data and extracts user payload', () => { test('verifies valid init data and extracts user payload', () => {
const now = new Date('2026-03-08T12:00:00.000Z') const now = new Date('2026-03-08T12:00:00.000Z')
const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), { const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
id: 123456, id: 123456,
first_name: 'Stan', first_name: 'Stan',
username: 'stanislav' username: 'stanislav'
@@ -44,7 +26,7 @@ describe('verifyTelegramMiniAppInitData', () => {
test('rejects invalid hash', () => { test('rejects invalid hash', () => {
const now = new Date('2026-03-08T12:00:00.000Z') const now = new Date('2026-03-08T12:00:00.000Z')
const params = new URLSearchParams( const params = new URLSearchParams(
buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), { buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
id: 123456, id: 123456,
first_name: 'Stan' first_name: 'Stan'
}) })
@@ -58,7 +40,23 @@ describe('verifyTelegramMiniAppInitData', () => {
test('rejects expired init data', () => { test('rejects expired init data', () => {
const now = new Date('2026-03-08T12:00:00.000Z') const now = new Date('2026-03-08T12:00:00.000Z')
const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000) - 7200, { const initData = buildMiniAppInitData(
'test-bot-token',
Math.floor(now.getTime() / 1000) - 7200,
{
id: 123456,
first_name: 'Stan'
}
)
const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600)
expect(result).toBeNull()
})
test('rejects init data timestamps from the future', () => {
const now = new Date('2026-03-08T12:00:00.000Z')
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000) + 5, {
id: 123456, id: 123456,
first_name: 'Stan' first_name: 'Stan'
}) })

View File

@@ -36,7 +36,11 @@ export function verifyTelegramMiniAppInitData(
const authDateSeconds = Number(authDateRaw) const authDateSeconds = Number(authDateRaw)
const nowSeconds = Math.floor(now.getTime() / 1000) const nowSeconds = Math.floor(now.getTime() / 1000)
if (Math.abs(nowSeconds - authDateSeconds) > maxAgeSeconds) { if (authDateSeconds > nowSeconds) {
return null
}
if (nowSeconds - authDateSeconds > maxAgeSeconds) {
return null return null
} }

View File

@@ -0,0 +1,19 @@
import { createHmac } from 'node:crypto'
export function buildMiniAppInitData(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()
}

View File

@@ -107,7 +107,11 @@ function App() {
try { try {
setDashboard(await fetchMiniAppDashboard(initData)) setDashboard(await fetchMiniAppDashboard(initData))
} catch { } catch (error) {
if (import.meta.env.DEV) {
console.warn('Failed to load mini app dashboard', error)
}
setDashboard(null) setDashboard(null)
} }
} catch { } catch {

View File

@@ -131,14 +131,31 @@ button {
background: rgb(247 179 137 / 0.14); background: rgb(247 179 137 / 0.14);
} }
.locale-switch__buttons button:focus-visible,
.nav-grid button:focus-visible,
.ghost-button:focus-visible {
outline: 2px solid #f7b389;
outline-offset: 2px;
border-color: rgb(247 179 137 / 0.7);
}
.hero-card, .hero-card,
.panel { .panel {
border: 1px solid rgb(255 255 255 / 0.1); border: 1px solid rgb(255 255 255 / 0.1);
background-color: rgb(18 26 36 / 0.82);
background: linear-gradient(180deg, rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.02)); background: linear-gradient(180deg, rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.02));
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
box-shadow: 0 24px 64px rgb(0 0 0 / 0.22); box-shadow: 0 24px 64px rgb(0 0 0 / 0.22);
} }
@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
.hero-card,
.panel {
background: rgb(18 26 36 / 0.94);
}
}
.hero-card { .hero-card {
margin-top: 28px; margin-top: 28px;
border-radius: 28px; border-radius: 28px;

View File

@@ -77,7 +77,7 @@ export async function fetchMiniAppSession(initData: string): Promise<MiniAppSess
} }
if (!response.ok) { if (!response.ok) {
throw new Error(payload.error) throw new Error(payload.error ?? 'Failed to create mini app session')
} }
return { return {

View File

@@ -23,5 +23,9 @@ declare global {
} }
export function getTelegramWebApp(): TelegramWebApp | undefined { export function getTelegramWebApp(): TelegramWebApp | undefined {
if (typeof window === 'undefined') {
return undefined
}
return window.Telegram?.WebApp return window.Telegram?.WebApp
} }

View File

@@ -48,8 +48,13 @@ Keep bot runtime config that is not secret in your `*.tfvars` file:
- `bot_household_id` - `bot_household_id`
- `bot_household_chat_id` - `bot_household_chat_id`
- `bot_purchase_topic_id` - `bot_purchase_topic_id`
- optional `bot_feedback_topic_id`
- `bot_mini_app_allowed_origins`
- optional `bot_parser_model` - optional `bot_parser_model`
Set `bot_mini_app_allowed_origins` to the exact mini app origins you expect in each environment.
Do not rely on permissive origin reflection in production.
## Reminder jobs ## Reminder jobs
Terraform provisions three separate Cloud Scheduler jobs: Terraform provisions three separate Cloud Scheduler jobs:
@@ -67,6 +72,7 @@ They target the bot runtime endpoints:
Recommended rollout: Recommended rollout:
- keep `scheduler_paused = true` and `scheduler_dry_run = true` on first apply - keep `scheduler_paused = true` and `scheduler_dry_run = true` on first apply
- confirm `bot_mini_app_allowed_origins` is set for the environment before exposing the mini app
- validate job responses and logs - validate job responses and logs
- unpause when the delivery side is ready - unpause when the delivery side is ready
- disable dry-run only after production verification - disable dry-run only after production verification

View File

@@ -39,7 +39,7 @@ Build the first usable SolidJS mini app shell with a real Telegram initData veri
- Telegram initData is verified with the bot token before membership lookup. - Telegram initData is verified with the bot token before membership lookup.
- Mini app access depends on an actual household membership match. - Mini app access depends on an actual household membership match.
- CORS can be limited via `MINI_APP_ALLOWED_ORIGINS`; if unset, the endpoint falls back to permissive origin reflection for deployment simplicity. - CORS can be limited via `MINI_APP_ALLOWED_ORIGINS`; local development may use permissive origin reflection, but production must use an explicit allow-list.
## UX Notes ## UX Notes

View File

@@ -1,10 +1,21 @@
import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm' import { and, eq, inArray, sql } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import type { AnonymousFeedbackRepository } from '@household/ports' import type {
AnonymousFeedbackModerationStatus,
AnonymousFeedbackRepository
} from '@household/ports'
const ACCEPTED_STATUSES = ['accepted', 'posted', 'failed'] as const const ACCEPTED_STATUSES = ['accepted', 'posted', 'failed'] as const
function parseModerationStatus(raw: string): AnonymousFeedbackModerationStatus {
if (raw === 'accepted' || raw === 'posted' || raw === 'rejected' || raw === 'failed') {
return raw
}
throw new Error(`Unexpected anonymous feedback moderation status: ${raw}`)
}
export function createDbAnonymousFeedbackRepository( export function createDbAnonymousFeedbackRepository(
databaseUrl: string, databaseUrl: string,
householdId: string householdId: string
@@ -38,23 +49,10 @@ export function createDbAnonymousFeedbackRepository(
}, },
async getRateLimitSnapshot(memberId, acceptedSince) { async getRateLimitSnapshot(memberId, acceptedSince) {
const countRows = await db const rows = await db
.select({ .select({
count: sql<string>`count(*)` acceptedCountSince: sql<string>`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSince})`,
}) lastAcceptedAt: sql<Date | null>`max(${schema.anonymousMessages.createdAt})`
.from(schema.anonymousMessages)
.where(
and(
eq(schema.anonymousMessages.householdId, householdId),
eq(schema.anonymousMessages.submittedByMemberId, memberId),
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES),
gte(schema.anonymousMessages.createdAt, acceptedSince)
)
)
const lastRows = await db
.select({
createdAt: schema.anonymousMessages.createdAt
}) })
.from(schema.anonymousMessages) .from(schema.anonymousMessages)
.where( .where(
@@ -64,12 +62,10 @@ export function createDbAnonymousFeedbackRepository(
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES) inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES)
) )
) )
.orderBy(desc(schema.anonymousMessages.createdAt))
.limit(1)
return { return {
acceptedCountSince: Number(countRows[0]?.count ?? '0'), acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'),
lastAcceptedAt: lastRows[0]?.createdAt ?? null lastAcceptedAt: rows[0]?.lastAcceptedAt ?? null
} }
}, },
@@ -99,11 +95,7 @@ export function createDbAnonymousFeedbackRepository(
return { return {
submission: { submission: {
id: inserted[0].id, id: inserted[0].id,
moderationStatus: inserted[0].moderationStatus as moderationStatus: parseModerationStatus(inserted[0].moderationStatus)
| 'accepted'
| 'posted'
| 'rejected'
| 'failed'
}, },
duplicate: false duplicate: false
} }
@@ -131,7 +123,7 @@ export function createDbAnonymousFeedbackRepository(
return { return {
submission: { submission: {
id: row.id, id: row.id,
moderationStatus: row.moderationStatus as 'accepted' | 'posted' | 'rejected' | 'failed' moderationStatus: parseModerationStatus(row.moderationStatus)
}, },
duplicate: true duplicate: true
} }

View File

@@ -299,7 +299,8 @@ export function createDbFinanceRepository(
}, },
async replaceSettlementSnapshot(snapshot) { async replaceSettlementSnapshot(snapshot) {
const upserted = await db await db.transaction(async (tx) => {
const upserted = await tx
.insert(schema.settlements) .insert(schema.settlements)
.values({ .values({
householdId, householdId,
@@ -326,11 +327,15 @@ export function createDbFinanceRepository(
throw new Error('Failed to persist settlement snapshot') throw new Error('Failed to persist settlement snapshot')
} }
await db await tx
.delete(schema.settlementLines) .delete(schema.settlementLines)
.where(eq(schema.settlementLines.settlementId, settlementId)) .where(eq(schema.settlementLines.settlementId, settlementId))
await db.insert(schema.settlementLines).values( if (snapshot.lines.length === 0) {
return
}
await tx.insert(schema.settlementLines).values(
snapshot.lines.map((line) => ({ snapshot.lines.map((line) => ({
settlementId, settlementId,
memberId: line.memberId, memberId: line.memberId,
@@ -341,6 +346,7 @@ export function createDbFinanceRepository(
explanations: line.explanations explanations: line.explanations
})) }))
) )
})
} }
} }

View File

@@ -232,11 +232,12 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
await repository.openCycle(period, currency) await repository.openCycle(period, currency)
return { const cycle = await repository.getCycleByPeriod(period)
id: '', if (!cycle) {
period, throw new Error(`Failed to load billing cycle for period ${period}`)
currency
} }
return cycle
}, },
async closeCycle(periodArg) { async closeCycle(periodArg) {

View File

@@ -44,6 +44,10 @@ describe('createReminderJobService', () => {
test('claims a dispatch once and returns the dedupe key', async () => { test('claims a dispatch once and returns the dedupe key', async () => {
const repository = new ReminderDispatchRepositoryStub() const repository = new ReminderDispatchRepositoryStub()
repository.nextResult = {
dedupeKey: '2026-03:rent-due',
claimed: true
}
const service = createReminderJobService(repository) const service = createReminderJobService(repository)
const result = await service.handleJob({ const result = await service.handleJob({
@@ -53,6 +57,7 @@ describe('createReminderJobService', () => {
}) })
expect(result.status).toBe('claimed') expect(result.status).toBe('claimed')
expect(result.dedupeKey).toBe('2026-03:rent-due')
expect(repository.lastClaim).toMatchObject({ expect(repository.lastClaim).toMatchObject({
householdId: 'household-1', householdId: 'household-1',
period: '2026-03', period: '2026-03',

View File

@@ -11,6 +11,10 @@ function computePayloadHash(payload: object): string {
return createHash('sha256').update(JSON.stringify(payload)).digest('hex') return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
} }
function buildReminderDedupeKey(period: string, reminderType: ReminderType): string {
return `${period}:${reminderType}`
}
function createReminderMessage(reminderType: ReminderType, period: string): string { function createReminderMessage(reminderType: ReminderType, period: string): string {
switch (reminderType) { switch (reminderType) {
case 'utilities': case 'utilities':
@@ -56,7 +60,7 @@ export function createReminderJobService(
if (input.dryRun === true) { if (input.dryRun === true) {
return { return {
status: 'dry-run', status: 'dry-run',
dedupeKey: `${period}:${input.reminderType}`, dedupeKey: buildReminderDedupeKey(period, input.reminderType),
payloadHash, payloadHash,
reminderType: input.reminderType, reminderType: input.reminderType,
period, period,

View File

@@ -15,7 +15,15 @@ function toUrl(base: string, path: string): URL {
async function expectJson(url: URL, init: RequestInit, expectedStatus: number): Promise<any> { async function expectJson(url: URL, init: RequestInit, expectedStatus: number): Promise<any> {
const response = await fetch(url, init) const response = await fetch(url, init)
const text = await response.text() const text = await response.text()
const payload = (text.length > 0 ? JSON.parse(text) : null) as unknown let payload: unknown = null
if (text.length > 0) {
try {
payload = JSON.parse(text) as unknown
} catch {
throw new Error(`${url.toString()} returned invalid JSON: ${text}`)
}
}
if (response.status !== expectedStatus) { if (response.status !== expectedStatus) {
throw new Error( throw new Error(

View File

@@ -9,11 +9,21 @@ function requireEnv(name: string): string {
return value return value
} }
async function telegramRequest( function parseCommand(raw: string | undefined): WebhookCommand {
const command = raw?.trim() || 'info'
if (command === 'info' || command === 'set' || command === 'delete') {
return command
}
throw new Error(`Unsupported command: ${command}`)
}
async function telegramRequest<T>(
botToken: string, botToken: string,
method: string, method: string,
body?: URLSearchParams body?: URLSearchParams
): Promise<any> { ): Promise<T> {
const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, { const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
method: body ? 'POST' : 'GET', method: body ? 'POST' : 'GET',
body body
@@ -27,11 +37,11 @@ async function telegramRequest(
throw new Error(`Telegram ${method} failed: ${JSON.stringify(payload)}`) throw new Error(`Telegram ${method} failed: ${JSON.stringify(payload)}`)
} }
return payload.result return payload.result as T
} }
async function run(): Promise<void> { async function run(): Promise<void> {
const command = (process.argv[2] ?? 'info') as WebhookCommand const command = parseCommand(process.argv[2])
const botToken = requireEnv('TELEGRAM_BOT_TOKEN') const botToken = requireEnv('TELEGRAM_BOT_TOKEN')
switch (command) { switch (command) {