mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:44:03 +00:00
feat(payments): track household payment confirmations
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 'Это подтверждение оплаты уже было обработано.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 () => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
169
apps/bot/src/payment-topic-ingestion.test.ts
Normal file
169
apps/bot/src/payment-topic-ingestion.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
225
apps/bot/src/payment-topic-ingestion.ts
Normal file
225
apps/bot/src/payment-topic-ingestion.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user