feat(payments): track household payment confirmations

This commit is contained in:
2026-03-10 17:00:45 +04:00
parent fb85219409
commit 1988521931
31 changed files with 4795 additions and 19 deletions

View File

@@ -67,7 +67,7 @@ function bindRejectionMessage(
function bindTopicUsageMessage(
locale: BotLocale,
role: 'purchase' | 'feedback' | 'reminders'
role: 'purchase' | 'feedback' | 'reminders' | 'payments'
): string {
const t = getBotTranslations(locale).setup
@@ -78,12 +78,14 @@ function bindTopicUsageMessage(
return t.useBindFeedbackTopicInGroup
case 'reminders':
return t.useBindRemindersTopicInGroup
case 'payments':
return t.useBindPaymentsTopicInGroup
}
}
function bindTopicSuccessMessage(
locale: BotLocale,
role: 'purchase' | 'feedback' | 'reminders',
role: 'purchase' | 'feedback' | 'reminders' | 'payments',
householdName: string,
threadId: string
): string {
@@ -96,6 +98,8 @@ function bindTopicSuccessMessage(
return t.feedbackTopicSaved(householdName, threadId)
case 'reminders':
return t.remindersTopicSaved(householdName, threadId)
case 'payments':
return t.paymentsTopicSaved(householdName, threadId)
}
}
@@ -218,7 +222,7 @@ export function registerHouseholdSetupCommands(options: {
}): void {
async function handleBindTopicCommand(
ctx: Context,
role: 'purchase' | 'feedback' | 'reminders'
role: 'purchase' | 'feedback' | 'reminders' | 'payments'
): Promise<void> {
const locale = await resolveReplyLocale({
ctx,
@@ -455,6 +459,10 @@ export function registerHouseholdSetupCommands(options: {
await handleBindTopicCommand(ctx, 'reminders')
})
options.bot.command('bind_payments_topic', async (ctx) => {
await handleBindTopicCommand(ctx, 'payments')
})
options.bot.command('pending_members', async (ctx) => {
const locale = await resolveReplyLocale({
ctx,

View File

@@ -11,6 +11,7 @@ export const enBotTranslations: BotTranslationCatalog = {
bind_purchase_topic: 'Bind the current topic as purchases',
bind_feedback_topic: 'Bind the current topic as feedback',
bind_reminders_topic: 'Bind the current topic as reminders',
bind_payments_topic: 'Bind the current topic as payments',
pending_members: 'List pending household join requests',
approve_member: 'Approve a pending household member'
},
@@ -53,7 +54,7 @@ export const enBotTranslations: BotTranslationCatalog = {
[
`Household ${created ? 'created' : 'already registered'}: ${householdName}`,
`Chat ID: ${telegramChatId}`,
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic. If you want a dedicated reminders topic, open it and run /bind_reminders_topic.',
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic. If you want dedicated reminders or payments topics, open them and run /bind_reminders_topic or /bind_payments_topic.',
'Members should open the bot chat from the button below and confirm the join request there.'
].join('\n'),
useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.',
@@ -65,6 +66,9 @@ export const enBotTranslations: BotTranslationCatalog = {
useBindRemindersTopicInGroup: 'Use /bind_reminders_topic inside the household group topic.',
remindersTopicSaved: (householdName, threadId) =>
`Reminders topic saved for ${householdName} (thread ${threadId}).`,
useBindPaymentsTopicInGroup: 'Use /bind_payments_topic inside the household group topic.',
paymentsTopicSaved: (householdName, threadId) =>
`Payments topic saved for ${householdName} (thread ${threadId}).`,
usePendingMembersInGroup: 'Use /pending_members inside the household group.',
useApproveMemberInGroup: 'Use /approve_member inside the household group.',
approveMemberUsage: 'Usage: /approve_member <telegram_user_id>',
@@ -147,5 +151,13 @@ export const enBotTranslations: BotTranslationCatalog = {
recorded: (summary) => `Recorded purchase: ${summary}`,
savedForReview: (summary) => `Saved for review: ${summary}`,
parseFailed: "Saved for review: I couldn't parse this purchase yet."
},
payments: {
topicMissing:
'Payments topic is not configured for this household yet. Ask an admin to run /bind_payments_topic.',
recorded: (kind, amount, currency) =>
`Recorded ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}`,
savedForReview: 'Saved this payment confirmation for review.',
duplicate: 'This payment confirmation was already processed.'
}
}

View File

@@ -11,6 +11,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
bind_purchase_topic: 'Назначить текущий топик для покупок',
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
bind_reminders_topic: 'Назначить текущий топик для напоминаний',
bind_payments_topic: 'Назначить текущий топик для оплат',
pending_members: 'Показать ожидающие заявки на вступление',
approve_member: 'Подтвердить участника дома'
},
@@ -55,7 +56,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
[
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
`ID чата: ${telegramChatId}`,
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельный топик для напоминаний, откройте его и выполните /bind_reminders_topic.',
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельные топики для напоминаний или оплат, откройте их и выполните /bind_reminders_topic или /bind_payments_topic.',
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
].join('\n'),
useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.',
@@ -67,6 +68,9 @@ export const ruBotTranslations: BotTranslationCatalog = {
useBindRemindersTopicInGroup: 'Используйте /bind_reminders_topic внутри топика группы дома.',
remindersTopicSaved: (householdName, threadId) =>
`Топик напоминаний сохранён для ${householdName} (тред ${threadId}).`,
useBindPaymentsTopicInGroup: 'Используйте /bind_payments_topic внутри топика группы дома.',
paymentsTopicSaved: (householdName, threadId) =>
`Топик оплат сохранён для ${householdName} (тред ${threadId}).`,
usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.',
useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.',
approveMemberUsage: 'Использование: /approve_member <telegram_user_id>',
@@ -150,5 +154,13 @@ export const ruBotTranslations: BotTranslationCatalog = {
recorded: (summary) => `Покупка сохранена: ${summary}`,
savedForReview: (summary) => `Сохранено на проверку: ${summary}`,
parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.'
},
payments: {
topicMissing:
'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.',
recorded: (kind, amount, currency) =>
`Оплата ${kind === 'rent' ? 'аренды' : 'коммуналки'} сохранена: ${amount} ${currency}`,
savedForReview: 'Это подтверждение оплаты сохранено на проверку.',
duplicate: 'Это подтверждение оплаты уже было обработано.'
}
}

View File

@@ -9,6 +9,7 @@ export type TelegramCommandName =
| 'bind_purchase_topic'
| 'bind_feedback_topic'
| 'bind_reminders_topic'
| 'bind_payments_topic'
| 'pending_members'
| 'approve_member'
@@ -21,6 +22,7 @@ export interface BotCommandDescriptions {
bind_purchase_topic: string
bind_feedback_topic: string
bind_reminders_topic: string
bind_payments_topic: string
pending_members: string
approve_member: string
}
@@ -77,6 +79,8 @@ export interface BotTranslationCatalog {
feedbackTopicSaved: (householdName: string, threadId: string) => string
useBindRemindersTopicInGroup: string
remindersTopicSaved: (householdName: string, threadId: string) => string
useBindPaymentsTopicInGroup: string
paymentsTopicSaved: (householdName: string, threadId: string) => string
usePendingMembersInGroup: string
useApproveMemberInGroup: string
approveMemberUsage: string
@@ -153,4 +157,10 @@ export interface BotTranslationCatalog {
savedForReview: (summary: string) => string
parseFailed: string
}
payments: {
topicMissing: string
recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
savedForReview: string
duplicate: string
}
}

View File

@@ -8,7 +8,8 @@ import {
createLocalePreferenceService,
createMiniAppAdminService,
createHouseholdSetupService,
createReminderJobService
createReminderJobService,
createPaymentConfirmationService
} from '@household/application'
import {
createDbAnonymousFeedbackRepository,
@@ -29,6 +30,7 @@ import {
createPurchaseMessageRepository,
registerConfiguredPurchaseTopicIngestion
} from './purchase-topic-ingestion'
import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion'
import { createReminderJobsHandler } from './reminder-jobs'
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
import { createBotWebhookServer } from './server'
@@ -72,6 +74,10 @@ const bot = createTelegramBot(
const webhookHandler = webhookCallback(bot, 'std/http')
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
const paymentConfirmationServices = new Map<
string,
ReturnType<typeof createPaymentConfirmationService>
>()
const exchangeRateProvider = createNbgExchangeRateProvider({
logger: getLogger('fx')
})
@@ -105,10 +111,7 @@ function financeServiceForHousehold(householdId: string) {
return existing
}
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId)
financeRepositoryClients.set(householdId, repositoryClient)
shutdownTasks.push(repositoryClient.close)
const repositoryClient = financeRepositoryForHousehold(householdId)
const service = createFinanceCommandService({
householdId,
repository: repositoryClient.repository,
@@ -119,6 +122,35 @@ function financeServiceForHousehold(householdId: string) {
return service
}
function financeRepositoryForHousehold(householdId: string) {
const existing = financeRepositoryClients.get(householdId)
if (existing) {
return existing
}
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId)
financeRepositoryClients.set(householdId, repositoryClient)
shutdownTasks.push(repositoryClient.close)
return repositoryClient
}
function paymentConfirmationServiceForHousehold(householdId: string) {
const existing = paymentConfirmationServices.get(householdId)
if (existing) {
return existing
}
const service = createPaymentConfirmationService({
householdId,
financeService: financeServiceForHousehold(householdId),
repository: financeRepositoryForHousehold(householdId).repository,
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
exchangeRateProvider
})
paymentConfirmationServices.set(householdId, service)
return service
}
function anonymousFeedbackServiceForHousehold(householdId: string) {
const existing = anonymousFeedbackServices.get(householdId)
if (existing) {
@@ -160,6 +192,15 @@ if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
logger: getLogger('purchase-ingestion')
}
)
registerConfiguredPaymentTopicIngestion(
bot,
householdConfigurationRepositoryClient.repository,
paymentConfirmationServiceForHousehold,
{
logger: getLogger('payment-ingestion')
}
)
} else {
logger.warn(
{

View File

@@ -61,6 +61,7 @@ function repository(
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
}
],
listPaymentRecordsForCycle: async () => [],
listParsedPurchasesForRange: async () => [
{
id: 'purchase-1',
@@ -71,6 +72,12 @@ function repository(
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
}
],
getSettlementSnapshotLines: async () => [],
savePaymentConfirmation: async () =>
({
status: 'needs_review',
reviewReason: 'settlement_not_ready'
}) as const,
replaceSettlementSnapshot: async () => {}
}
}

View File

@@ -89,6 +89,8 @@ export function createMiniAppDashboardHandler(options: {
period: dashboard.period,
currency: dashboard.currency,
totalDueMajor: dashboard.totalDue.toMajorString(),
totalPaidMajor: dashboard.totalPaid.toMajorString(),
totalRemainingMajor: dashboard.totalRemaining.toMajorString(),
rentSourceAmountMajor: dashboard.rentSourceAmount.toMajorString(),
rentSourceCurrency: dashboard.rentSourceAmount.currency,
rentDisplayAmountMajor: dashboard.rentDisplayAmount.toMajorString(),
@@ -101,6 +103,8 @@ export function createMiniAppDashboardHandler(options: {
utilityShareMajor: line.utilityShare.toMajorString(),
purchaseOffsetMajor: line.purchaseOffset.toMajorString(),
netDueMajor: line.netDue.toMajorString(),
paidMajor: line.paid.toMajorString(),
remainingMajor: line.remaining.toMajorString(),
explanations: line.explanations
})),
ledger: dashboard.ledger.map((entry) => ({

View File

@@ -0,0 +1,169 @@
import { describe, expect, test } from 'bun:test'
import { instantFromIso, Money } from '@household/domain'
import { createTelegramBot } from './bot'
import {
buildPaymentAcknowledgement,
registerConfiguredPaymentTopicIngestion,
resolveConfiguredPaymentTopicRecord,
type PaymentTopicCandidate
} from './payment-topic-ingestion'
function candidate(overrides: Partial<PaymentTopicCandidate> = {}): PaymentTopicCandidate {
return {
updateId: 1,
chatId: '-10012345',
messageId: '10',
threadId: '888',
senderTelegramUserId: '10002',
rawText: 'за жилье закинул',
attachmentCount: 0,
messageSentAt: instantFromIso('2026-03-20T00:00:00.000Z'),
...overrides
}
}
function paymentUpdate(text: string) {
return {
update_id: 1001,
message: {
message_id: 55,
date: Math.floor(Date.now() / 1000),
message_thread_id: 888,
is_topic_message: true,
chat: {
id: -10012345,
type: 'supergroup'
},
from: {
id: 10002,
is_bot: false,
first_name: 'Mia'
},
text
}
}
}
describe('resolveConfiguredPaymentTopicRecord', () => {
test('returns record when the topic role is payments', () => {
const record = resolveConfiguredPaymentTopicRecord(candidate(), {
householdId: 'household-1',
role: 'payments',
telegramThreadId: '888',
topicName: 'Быт'
})
expect(record).not.toBeNull()
expect(record?.householdId).toBe('household-1')
})
test('skips non-payments topic bindings', () => {
const record = resolveConfiguredPaymentTopicRecord(candidate(), {
householdId: 'household-1',
role: 'feedback',
telegramThreadId: '888',
topicName: 'Анонимно'
})
expect(record).toBeNull()
})
})
describe('buildPaymentAcknowledgement', () => {
test('returns localized recorded acknowledgement', () => {
expect(
buildPaymentAcknowledgement('ru', {
status: 'recorded',
kind: 'rent',
amountMajor: '472.50',
currency: 'GEL'
})
).toBe('Оплата аренды сохранена: 472.50 GEL')
})
test('returns review acknowledgement', () => {
expect(
buildPaymentAcknowledgement('en', {
status: 'needs_review'
})
).toBe('Saved this payment confirmation for review.')
})
})
describe('registerConfiguredPaymentTopicIngestion', () => {
test('replies in-topic after a payment confirmation is recorded', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: -10012345,
type: 'supergroup'
},
text: 'ok'
}
} as never
})
registerConfiguredPaymentTopicIngestion(
bot,
{
getHouseholdChatByHouseholdId: async () => ({
householdId: 'household-1',
householdName: 'Test bot',
telegramChatId: '-10012345',
telegramChatType: 'supergroup',
title: 'Test bot',
defaultLocale: 'ru'
}),
findHouseholdTopicByTelegramContext: async () => ({
householdId: 'household-1',
role: 'payments',
telegramThreadId: '888',
topicName: 'Быт'
})
} as never,
() => ({
submit: async () => ({
status: 'recorded',
kind: 'rent',
amount: Money.fromMajor('472.50', 'GEL')
})
})
)
await bot.handleUpdate(paymentUpdate('за жилье закинул') as never)
expect(calls).toHaveLength(1)
expect(calls[0]?.method).toBe('sendMessage')
expect(calls[0]?.payload).toMatchObject({
chat_id: -10012345,
reply_parameters: {
message_id: 55
},
text: 'Оплата аренды сохранена: 472.50 GEL'
})
})
})

View File

@@ -0,0 +1,225 @@
import type { PaymentConfirmationService } from '@household/application'
import { instantFromEpochSeconds, type Instant } from '@household/domain'
import type { Bot, Context } from 'grammy'
import type { Logger } from '@household/observability'
import type {
HouseholdConfigurationRepository,
HouseholdTopicBindingRecord
} from '@household/ports'
import { getBotTranslations, type BotLocale } from './i18n'
export interface PaymentTopicCandidate {
updateId: number
chatId: string
messageId: string
threadId: string
senderTelegramUserId: string
rawText: string
attachmentCount: number
messageSentAt: Instant
}
export interface PaymentTopicRecord extends PaymentTopicCandidate {
householdId: string
}
function readMessageText(ctx: Context): string | null {
const message = ctx.message
if (!message) {
return null
}
if ('text' in message && typeof message.text === 'string') {
return message.text
}
if ('caption' in message && typeof message.caption === 'string') {
return message.caption
}
return null
}
function attachmentCount(ctx: Context): number {
const message = ctx.message
if (!message) {
return 0
}
if ('photo' in message && Array.isArray(message.photo)) {
return message.photo.length
}
if ('document' in message && message.document) {
return 1
}
return 0
}
function toCandidateFromContext(ctx: Context): PaymentTopicCandidate | null {
const message = ctx.message
const rawText = readMessageText(ctx)
if (!message || !rawText) {
return null
}
if (!('is_topic_message' in message) || message.is_topic_message !== true) {
return null
}
if (!('message_thread_id' in message) || message.message_thread_id === undefined) {
return null
}
const senderTelegramUserId = ctx.from?.id?.toString()
if (!senderTelegramUserId) {
return null
}
return {
updateId: ctx.update.update_id,
chatId: message.chat.id.toString(),
messageId: message.message_id.toString(),
threadId: message.message_thread_id.toString(),
senderTelegramUserId,
rawText,
attachmentCount: attachmentCount(ctx),
messageSentAt: instantFromEpochSeconds(message.date)
}
}
export function resolveConfiguredPaymentTopicRecord(
value: PaymentTopicCandidate,
binding: HouseholdTopicBindingRecord
): PaymentTopicRecord | null {
const normalizedText = value.rawText.trim()
if (normalizedText.length === 0) {
return null
}
if (binding.role !== 'payments') {
return null
}
return {
...value,
rawText: normalizedText,
householdId: binding.householdId
}
}
export function buildPaymentAcknowledgement(
locale: BotLocale,
result:
| { status: 'duplicate' }
| {
status: 'recorded'
kind: 'rent' | 'utilities'
amountMajor: string
currency: 'USD' | 'GEL'
}
| { status: 'needs_review' }
): string | null {
const t = getBotTranslations(locale).payments
switch (result.status) {
case 'duplicate':
return null
case 'recorded':
return t.recorded(result.kind, result.amountMajor, result.currency)
case 'needs_review':
return t.savedForReview
}
}
async function replyToPaymentMessage(ctx: Context, text: string): Promise<void> {
const message = ctx.msg
if (!message) {
return
}
await ctx.reply(text, {
reply_parameters: {
message_id: message.message_id
}
})
}
export function registerConfiguredPaymentTopicIngestion(
bot: Bot,
householdConfigurationRepository: HouseholdConfigurationRepository,
paymentServiceForHousehold: (householdId: string) => PaymentConfirmationService,
options: {
logger?: Logger
} = {}
): void {
bot.on('message', async (ctx, next) => {
const candidate = toCandidateFromContext(ctx)
if (!candidate) {
await next()
return
}
const binding = await householdConfigurationRepository.findHouseholdTopicByTelegramContext({
telegramChatId: candidate.chatId,
telegramThreadId: candidate.threadId
})
if (!binding) {
await next()
return
}
const record = resolveConfiguredPaymentTopicRecord(candidate, binding)
if (!record) {
await next()
return
}
try {
const result = await paymentServiceForHousehold(record.householdId).submit({
senderTelegramUserId: record.senderTelegramUserId,
rawText: record.rawText,
telegramChatId: record.chatId,
telegramMessageId: record.messageId,
telegramThreadId: record.threadId,
telegramUpdateId: String(record.updateId),
attachmentCount: record.attachmentCount,
messageSentAt: record.messageSentAt
})
const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId(
record.householdId
)
const locale = householdChat?.defaultLocale ?? 'en'
const acknowledgement = buildPaymentAcknowledgement(
locale,
result.status === 'recorded'
? {
status: 'recorded',
kind: result.kind,
amountMajor: result.amount.toMajorString(),
currency: result.amount.currency
}
: result
)
if (acknowledgement) {
await replyToPaymentMessage(ctx, acknowledgement)
}
} catch (error) {
options.logger?.error(
{
event: 'payment.ingest_failed',
chatId: record.chatId,
threadId: record.threadId,
messageId: record.messageId,
updateId: record.updateId,
error
},
'Failed to ingest payment confirmation'
)
}
})
}

View File

@@ -27,6 +27,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [
'bind_purchase_topic',
'bind_feedback_topic',
'bind_reminders_topic',
'bind_payments_topic',
'pending_members',
'approve_member'
] as const satisfies readonly TelegramCommandName[]