From 3ff4bbc246d3dea445b7d23960071bc175acb52d Mon Sep 17 00:00:00 2001 From: whekin Date: Wed, 11 Mar 2026 11:23:49 +0400 Subject: [PATCH] feat(bot): add structured payment topic confirmations --- apps/bot/src/dm-assistant.ts | 126 +---- apps/bot/src/index.ts | 9 +- apps/bot/src/payment-proposals.ts | 133 ++++++ apps/bot/src/payment-topic-ingestion.test.ts | 435 ++++++++++++++++-- apps/bot/src/payment-topic-ingestion.ts | 400 ++++++++++++++-- .../src/telegram-pending-action-repository.ts | 8 + .../ports/src/telegram-pending-actions.ts | 2 + 7 files changed, 928 insertions(+), 185 deletions(-) create mode 100644 apps/bot/src/payment-proposals.ts diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index 43d7496..7fe222d 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -1,4 +1,4 @@ -import { parsePaymentConfirmationMessage, type FinanceCommandService } from '@household/application' +import type { FinanceCommandService } from '@household/application' import { instantFromEpochSeconds, Money } from '@household/domain' import type { Logger } from '@household/observability' import type { @@ -12,6 +12,7 @@ import { resolveReplyLocale } from './bot-locale' import { getBotTranslations, type BotLocale } from './i18n' import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant' import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter' +import { maybeCreatePaymentProposal, parsePaymentProposalPayload } from './payment-proposals' import type { PurchaseMessageIngestionRepository, PurchaseProposalActionResult, @@ -76,15 +77,6 @@ export interface AssistantUsageTracker { listHouseholdUsage(householdId: string): readonly AssistantUsageSnapshot[] } -interface PaymentProposalPayload { - proposalId: string - householdId: string - memberId: string - kind: 'rent' | 'utilities' - amountMinor: string - currency: 'GEL' | 'USD' -} - type PurchaseActionResult = Extract< PurchaseProposalActionResult, { status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled' } @@ -401,34 +393,6 @@ function looksLikePurchaseIntent(rawText: string): boolean { return PURCHASE_MONEY_PATTERN.test(normalized) && /\p{L}/u.test(normalized) } -function parsePaymentProposalPayload( - payload: Record -): PaymentProposalPayload | null { - if ( - typeof payload.proposalId !== 'string' || - typeof payload.householdId !== 'string' || - typeof payload.memberId !== 'string' || - (payload.kind !== 'rent' && payload.kind !== 'utilities') || - typeof payload.amountMinor !== 'string' || - (payload.currency !== 'USD' && payload.currency !== 'GEL') - ) { - return null - } - - if (!/^[0-9]+$/.test(payload.amountMinor)) { - return null - } - - return { - proposalId: payload.proposalId, - householdId: payload.householdId, - memberId: payload.memberId, - kind: payload.kind, - amountMinor: payload.amountMinor, - currency: payload.currency - } -} - function formatAssistantLedger( dashboard: NonNullable>> ) { @@ -491,92 +455,6 @@ async function buildHouseholdContext(input: { return lines.join('\n') } -async function maybeCreatePaymentProposal(input: { - rawText: string - householdId: string - memberId: string - financeService: FinanceCommandService - householdConfigurationRepository: HouseholdConfigurationRepository -}): Promise< - | { - status: 'no_intent' - } - | { - status: 'clarification' - } - | { - status: 'unsupported_currency' - } - | { - status: 'no_balance' - } - | { - status: 'proposal' - payload: PaymentProposalPayload - } -> { - const settings = await input.householdConfigurationRepository.getHouseholdBillingSettings( - input.householdId - ) - const parsed = parsePaymentConfirmationMessage(input.rawText, settings.settlementCurrency) - - if (!parsed.kind && parsed.reviewReason === 'intent_missing') { - return { - status: 'no_intent' - } - } - - if (!parsed.kind || parsed.reviewReason) { - return { - status: 'clarification' - } - } - - const dashboard = await input.financeService.generateDashboard() - if (!dashboard) { - return { - status: 'clarification' - } - } - - const memberLine = dashboard.members.find((line) => line.memberId === input.memberId) - if (!memberLine) { - return { - status: 'clarification' - } - } - - if (parsed.explicitAmount && parsed.explicitAmount.currency !== dashboard.currency) { - return { - status: 'unsupported_currency' - } - } - - const amount = - parsed.explicitAmount ?? - (parsed.kind === 'rent' - ? memberLine.rentShare - : memberLine.utilityShare.add(memberLine.purchaseOffset)) - - if (amount.amountMinor <= 0n) { - return { - status: 'no_balance' - } - } - - return { - status: 'proposal', - payload: { - proposalId: crypto.randomUUID(), - householdId: input.householdId, - memberId: input.memberId, - kind: parsed.kind, - amountMinor: amount.amountMinor.toString(), - currency: amount.currency - } - } -} - export function registerDmAssistant(options: { bot: Bot assistant?: ConversationalAssistant diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index c55ae0a..92737c0 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -109,10 +109,9 @@ const miniAppAdminService = householdConfigurationRepositoryClient const localePreferenceService = householdConfigurationRepositoryClient ? createLocalePreferenceService(householdConfigurationRepositoryClient.repository) : null -const telegramPendingActionRepositoryClient = - runtime.databaseUrl && (runtime.anonymousFeedbackEnabled || runtime.assistantEnabled) - ? createDbTelegramPendingActionRepository(runtime.databaseUrl!) - : null +const telegramPendingActionRepositoryClient = runtime.databaseUrl + ? createDbTelegramPendingActionRepository(runtime.databaseUrl!) + : null const processedBotMessageRepositoryClient = runtime.databaseUrl && runtime.assistantEnabled ? createDbProcessedBotMessageRepository(runtime.databaseUrl!) @@ -243,6 +242,8 @@ if (purchaseRepositoryClient && householdConfigurationRepositoryClient) { registerConfiguredPaymentTopicIngestion( bot, householdConfigurationRepositoryClient.repository, + telegramPendingActionRepositoryClient!.repository, + financeServiceForHousehold, paymentConfirmationServiceForHousehold, { logger: getLogger('payment-ingestion') diff --git a/apps/bot/src/payment-proposals.ts b/apps/bot/src/payment-proposals.ts new file mode 100644 index 0000000..c2ba3fd --- /dev/null +++ b/apps/bot/src/payment-proposals.ts @@ -0,0 +1,133 @@ +import { parsePaymentConfirmationMessage, type FinanceCommandService } from '@household/application' +import { Money } from '@household/domain' +import type { HouseholdConfigurationRepository } from '@household/ports' + +export interface PaymentProposalPayload { + proposalId: string + householdId: string + memberId: string + kind: 'rent' | 'utilities' + amountMinor: string + currency: 'GEL' | 'USD' +} + +export function parsePaymentProposalPayload( + payload: Record +): PaymentProposalPayload | null { + if ( + typeof payload.proposalId !== 'string' || + typeof payload.householdId !== 'string' || + typeof payload.memberId !== 'string' || + (payload.kind !== 'rent' && payload.kind !== 'utilities') || + typeof payload.amountMinor !== 'string' || + (payload.currency !== 'USD' && payload.currency !== 'GEL') + ) { + return null + } + + if (!/^[0-9]+$/.test(payload.amountMinor)) { + return null + } + + return { + proposalId: payload.proposalId, + householdId: payload.householdId, + memberId: payload.memberId, + kind: payload.kind, + amountMinor: payload.amountMinor, + currency: payload.currency + } +} + +export async function maybeCreatePaymentProposal(input: { + rawText: string + householdId: string + memberId: string + financeService: FinanceCommandService + householdConfigurationRepository: HouseholdConfigurationRepository +}): Promise< + | { + status: 'no_intent' + } + | { + status: 'clarification' + } + | { + status: 'unsupported_currency' + } + | { + status: 'no_balance' + } + | { + status: 'proposal' + payload: PaymentProposalPayload + } +> { + const settings = await input.householdConfigurationRepository.getHouseholdBillingSettings( + input.householdId + ) + const parsed = parsePaymentConfirmationMessage(input.rawText, settings.settlementCurrency) + + if (!parsed.kind && parsed.reviewReason === 'intent_missing') { + return { + status: 'no_intent' + } + } + + if (!parsed.kind || parsed.reviewReason) { + return { + status: 'clarification' + } + } + + const dashboard = await input.financeService.generateDashboard() + if (!dashboard) { + return { + status: 'clarification' + } + } + + const memberLine = dashboard.members.find((line) => line.memberId === input.memberId) + if (!memberLine) { + return { + status: 'clarification' + } + } + + if (parsed.explicitAmount && parsed.explicitAmount.currency !== dashboard.currency) { + return { + status: 'unsupported_currency' + } + } + + const amount = + parsed.explicitAmount ?? + (parsed.kind === 'rent' + ? memberLine.rentShare + : memberLine.utilityShare.add(memberLine.purchaseOffset)) + + if (amount.amountMinor <= 0n) { + return { + status: 'no_balance' + } + } + + return { + status: 'proposal', + payload: { + proposalId: crypto.randomUUID(), + householdId: input.householdId, + memberId: input.memberId, + kind: parsed.kind, + amountMinor: amount.amountMinor.toString(), + currency: amount.currency + } + } +} + +export function synthesizePaymentConfirmationText(payload: PaymentProposalPayload): string { + const amount = Money.fromMinor(BigInt(payload.amountMinor), payload.currency) + const kindText = payload.kind === 'rent' ? 'rent' : 'utilities' + + return `paid ${kindText} ${amount.toMajorString()} ${amount.currency}` +} diff --git a/apps/bot/src/payment-topic-ingestion.test.ts b/apps/bot/src/payment-topic-ingestion.test.ts index 0742fee..d0e60c8 100644 --- a/apps/bot/src/payment-topic-ingestion.test.ts +++ b/apps/bot/src/payment-topic-ingestion.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'bun:test' +import type { FinanceCommandService, PaymentConfirmationService } from '@household/application' import { instantFromIso, Money } from '@household/domain' +import type { TelegramPendingActionRecord, TelegramPendingActionRepository } from '@household/ports' import { createTelegramBot } from './bot' import { buildPaymentAcknowledgement, @@ -45,6 +47,178 @@ function paymentUpdate(text: string) { } } +function createHouseholdRepository() { + return { + getHouseholdChatByHouseholdId: async () => ({ + householdId: 'household-1', + householdName: 'Test bot', + telegramChatId: '-10012345', + telegramChatType: 'supergroup', + title: 'Test bot', + defaultLocale: 'ru' as const + }), + findHouseholdTopicByTelegramContext: async () => ({ + householdId: 'household-1', + role: 'payments' as const, + telegramThreadId: '888', + topicName: 'Быт' + }), + getHouseholdBillingSettings: async () => ({ + householdId: 'household-1', + settlementCurrency: 'GEL' as const, + rentAmountMinor: 70000n, + rentCurrency: 'USD' as const, + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }) + } +} + +function paymentCallbackUpdate(data: string, fromId = 10002) { + return { + update_id: 1002, + callback_query: { + id: 'callback-1', + from: { + id: fromId, + is_bot: false, + first_name: 'Mia' + }, + chat_instance: 'instance-1', + data, + message: { + message_id: 77, + date: Math.floor(Date.now() / 1000), + chat: { + id: -10012345, + type: 'supergroup' + }, + text: 'placeholder' + } + } + } +} + +function createPromptRepository(): TelegramPendingActionRepository { + let pending: TelegramPendingActionRecord | null = null + + return { + async upsertPendingAction(input) { + pending = input + return input + }, + async getPendingAction() { + return pending + }, + async clearPendingAction() { + pending = null + }, + async clearPendingActionsForChat(telegramChatId, action) { + if (!pending || pending.telegramChatId !== telegramChatId) { + return + } + + if (action && pending.action !== action) { + return + } + + pending = null + } + } +} + +function createFinanceService(): FinanceCommandService { + return { + getMemberByTelegramUserId: async () => ({ + id: 'member-1', + telegramUserId: '10002', + displayName: 'Mia', + rentShareWeight: 1, + isAdmin: false + }), + getOpenCycle: async () => null, + ensureExpectedCycle: async () => ({ + id: 'cycle-1', + period: '2026-03', + currency: 'GEL' + }), + getAdminCycleState: async () => ({ + cycle: null, + rentRule: null, + utilityBills: [] + }), + openCycle: async () => ({ + id: 'cycle-1', + period: '2026-03', + currency: 'GEL' + }), + closeCycle: async () => null, + setRent: async () => null, + addUtilityBill: async () => null, + updateUtilityBill: async () => null, + deleteUtilityBill: async () => false, + updatePurchase: async () => null, + deletePurchase: async () => false, + addPayment: async () => null, + updatePayment: async () => null, + deletePayment: async () => false, + generateDashboard: async () => ({ + period: '2026-03', + currency: 'GEL', + totalDue: Money.fromMajor('1000', 'GEL'), + totalPaid: Money.zero('GEL'), + totalRemaining: Money.fromMajor('1000', 'GEL'), + rentSourceAmount: Money.fromMajor('700', 'USD'), + rentDisplayAmount: Money.fromMajor('1890', 'GEL'), + rentFxRateMicros: null, + rentFxEffectiveDate: null, + members: [ + { + memberId: 'member-1', + displayName: 'Mia', + rentShare: Money.fromMajor('472.50', 'GEL'), + utilityShare: Money.fromMajor('40', 'GEL'), + purchaseOffset: Money.fromMajor('-12', 'GEL'), + netDue: Money.fromMajor('500.50', 'GEL'), + paid: Money.zero('GEL'), + remaining: Money.fromMajor('500.50', 'GEL'), + explanations: [] + } + ], + ledger: [] + }), + generateStatement: async () => null + } +} + +function createPaymentConfirmationService(): PaymentConfirmationService & { + submitted: Array<{ + rawText: string + telegramMessageId: string + telegramThreadId: string + }> +} { + return { + submitted: [], + async submit(input) { + this.submitted.push({ + rawText: input.rawText, + telegramMessageId: input.telegramMessageId, + telegramThreadId: input.telegramThreadId + }) + + return { + status: 'recorded', + kind: 'rent', + amount: Money.fromMajor('472.50', 'GEL') + } + } + } +} + describe('resolveConfiguredPaymentTopicRecord', () => { test('returns record when the topic role is payments', () => { const record = resolveConfiguredPaymentTopicRecord(candidate(), { @@ -68,6 +242,17 @@ describe('resolveConfiguredPaymentTopicRecord', () => { expect(record).toBeNull() }) + + test('skips slash commands in payment topics', () => { + const record = resolveConfiguredPaymentTopicRecord(candidate({ rawText: '/unsetup' }), { + householdId: 'household-1', + role: 'payments', + telegramThreadId: '888', + topicName: 'Быт' + }) + + expect(record).toBeNull() + }) }) describe('buildPaymentAcknowledgement', () => { @@ -87,14 +272,15 @@ describe('buildPaymentAcknowledgement', () => { buildPaymentAcknowledgement('en', { status: 'needs_review' }) - ).toBe('Saved this payment confirmation for review.') + ).toBeNull() }) }) describe('registerConfiguredPaymentTopicIngestion', () => { - test('replies in-topic after a payment confirmation is recorded', async () => { + test('replies in-topic with a payment proposal and buttons for a likely payment', async () => { const bot = createTelegramBot('000000:test-token') const calls: Array<{ method: string; payload: unknown }> = [] + const promptRepository = createPromptRepository() bot.botInfo = { id: 999000, @@ -127,31 +313,14 @@ describe('registerConfiguredPaymentTopicIngestion', () => { } as never }) + const paymentConfirmationService = createPaymentConfirmationService() + 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') - }) - }) + createHouseholdRepository() as never, + promptRepository, + () => createFinanceService(), + () => paymentConfirmationService ) await bot.handleUpdate(paymentUpdate('за жилье закинул') as never) @@ -163,7 +332,221 @@ describe('registerConfiguredPaymentTopicIngestion', () => { reply_parameters: { message_id: 55 }, - text: 'Оплата аренды сохранена: 472.50 GEL' + text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Подтвердить оплату', + callback_data: expect.stringContaining('payment_topic:confirm:10002:') + }, + { + text: 'Отменить', + callback_data: expect.stringContaining('payment_topic:cancel:10002:') + } + ] + ] + } + }) + + expect(await promptRepository.getPendingAction('-10012345', '10002')).toMatchObject({ + action: 'payment_topic_confirmation' }) }) + + test('asks for clarification and resolves follow-up answers in the same payments topic', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const promptRepository = createPromptRepository() + + 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: true + } as never + }) + + const paymentConfirmationService = createPaymentConfirmationService() + + registerConfiguredPaymentTopicIngestion( + bot, + createHouseholdRepository() as never, + promptRepository, + () => createFinanceService(), + () => paymentConfirmationService + ) + + await bot.handleUpdate(paymentUpdate('готово') as never) + await bot.handleUpdate(paymentUpdate('за жилье') as never) + + expect(calls).toHaveLength(2) + expect(calls[0]?.payload).toMatchObject({ + text: 'Пока не могу подтвердить эту оплату. Уточните, это аренда или коммуналка, и при необходимости напишите сумму и валюту.' + }) + expect(calls[1]?.payload).toMatchObject({ + text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.' + }) + }) + + test('confirms a pending payment proposal from a topic callback', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const promptRepository = createPromptRepository() + + 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: true + } as never + }) + + const paymentConfirmationService = createPaymentConfirmationService() + + registerConfiguredPaymentTopicIngestion( + bot, + createHouseholdRepository() as never, + promptRepository, + () => createFinanceService(), + () => paymentConfirmationService + ) + + await bot.handleUpdate(paymentUpdate('за жилье закинул') as never) + const pending = await promptRepository.getPendingAction('-10012345', '10002') + const proposalId = (pending?.payload as { proposalId?: string } | null)?.proposalId + calls.length = 0 + + await bot.handleUpdate( + paymentCallbackUpdate(`payment_topic:confirm:10002:${proposalId ?? 'missing'}`) as never + ) + + expect(paymentConfirmationService.submitted).toEqual([ + { + rawText: 'paid rent 472.50 GEL', + telegramMessageId: '55', + telegramThreadId: '888' + } + ]) + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1', + text: 'Recorded rent payment: 472.50 GEL' + } + }) + expect(calls[1]).toMatchObject({ + method: 'editMessageText', + payload: { + chat_id: -10012345, + message_id: 77, + text: 'Recorded rent payment: 472.50 GEL' + } + }) + }) + + test('does not reply for non-payment chatter in the payments topic', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const promptRepository = createPromptRepository() + + 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: true + } as never + }) + + const paymentConfirmationService = createPaymentConfirmationService() + + registerConfiguredPaymentTopicIngestion( + bot, + createHouseholdRepository() as never, + promptRepository, + () => createFinanceService(), + () => paymentConfirmationService + ) + + await bot.handleUpdate(paymentUpdate('Так так)') as never) + + expect(calls).toHaveLength(0) + }) + + test('does not ingest slash commands sent in the payments topic', async () => { + const bot = createTelegramBot('000000:test-token') + const promptRepository = createPromptRepository() + const paymentConfirmationService = createPaymentConfirmationService() + + 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 + } + + registerConfiguredPaymentTopicIngestion( + bot, + createHouseholdRepository() as never, + promptRepository, + () => createFinanceService(), + () => paymentConfirmationService + ) + + await bot.handleUpdate(paymentUpdate('/unsetup') as never) + + expect(paymentConfirmationService.submitted).toHaveLength(0) + }) }) diff --git a/apps/bot/src/payment-topic-ingestion.ts b/apps/bot/src/payment-topic-ingestion.ts index 8b3a626..04e00d8 100644 --- a/apps/bot/src/payment-topic-ingestion.ts +++ b/apps/bot/src/payment-topic-ingestion.ts @@ -1,13 +1,25 @@ -import type { PaymentConfirmationService } from '@household/application' +import type { FinanceCommandService, PaymentConfirmationService } from '@household/application' +import { Money } from '@household/domain' import { instantFromEpochSeconds, type Instant } from '@household/domain' import type { Bot, Context } from 'grammy' import type { Logger } from '@household/observability' import type { HouseholdConfigurationRepository, - HouseholdTopicBindingRecord + HouseholdTopicBindingRecord, + TelegramPendingActionRepository } from '@household/ports' import { getBotTranslations, type BotLocale } from './i18n' +import { + maybeCreatePaymentProposal, + parsePaymentProposalPayload, + synthesizePaymentConfirmationText +} from './payment-proposals' + +const PAYMENT_TOPIC_CONFIRM_CALLBACK_PREFIX = 'payment_topic:confirm:' +const PAYMENT_TOPIC_CANCEL_CALLBACK_PREFIX = 'payment_topic:cancel:' +const PAYMENT_TOPIC_CLARIFICATION_ACTION = 'payment_topic_clarification' as const +const PAYMENT_TOPIC_CONFIRMATION_ACTION = 'payment_topic_confirmation' as const export interface PaymentTopicCandidate { updateId: number @@ -24,6 +36,28 @@ export interface PaymentTopicRecord extends PaymentTopicCandidate { householdId: string } +interface PaymentTopicClarificationPayload { + threadId: string + rawText: string +} + +interface PaymentTopicConfirmationPayload { + proposalId: string + householdId: string + memberId: string + kind: 'rent' | 'utilities' + amountMinor: string + currency: 'GEL' | 'USD' + rawText: string + senderTelegramUserId: string + telegramChatId: string + telegramMessageId: string + telegramThreadId: string + telegramUpdateId: string + attachmentCount: number + messageSentAt: Instant | null +} + function readMessageText(ctx: Context): string | null { const message = ctx.message if (!message) { @@ -99,6 +133,10 @@ export function resolveConfiguredPaymentTopicRecord( return null } + if (normalizedText.startsWith('/')) { + return null + } + if (binding.role !== 'payments') { return null } @@ -130,11 +168,81 @@ export function buildPaymentAcknowledgement( case 'recorded': return t.recorded(result.kind, result.amountMajor, result.currency) case 'needs_review': - return t.savedForReview + return null } } -async function replyToPaymentMessage(ctx: Context, text: string): Promise { +function parsePaymentClarificationPayload( + payload: Record +): PaymentTopicClarificationPayload | null { + if (typeof payload.threadId !== 'string' || typeof payload.rawText !== 'string') { + return null + } + + return { + threadId: payload.threadId, + rawText: payload.rawText + } +} + +function parsePaymentTopicConfirmationPayload( + payload: Record +): PaymentTopicConfirmationPayload | null { + const proposal = parsePaymentProposalPayload(payload) + if ( + !proposal || + typeof payload.rawText !== 'string' || + typeof payload.senderTelegramUserId !== 'string' || + typeof payload.telegramChatId !== 'string' || + typeof payload.telegramMessageId !== 'string' || + typeof payload.telegramThreadId !== 'string' || + typeof payload.telegramUpdateId !== 'string' || + typeof payload.attachmentCount !== 'number' + ) { + return null + } + + return { + ...proposal, + rawText: payload.rawText, + senderTelegramUserId: payload.senderTelegramUserId, + telegramChatId: payload.telegramChatId, + telegramMessageId: payload.telegramMessageId, + telegramThreadId: payload.telegramThreadId, + telegramUpdateId: payload.telegramUpdateId, + attachmentCount: payload.attachmentCount, + messageSentAt: null + } +} + +function paymentProposalReplyMarkup( + locale: BotLocale, + senderTelegramUserId: string, + proposalId: string +) { + const t = getBotTranslations(locale).payments + + return { + inline_keyboard: [ + [ + { + text: t.confirmButton, + callback_data: `${PAYMENT_TOPIC_CONFIRM_CALLBACK_PREFIX}${senderTelegramUserId}:${proposalId}` + }, + { + text: t.cancelButton, + callback_data: `${PAYMENT_TOPIC_CANCEL_CALLBACK_PREFIX}${senderTelegramUserId}:${proposalId}` + } + ] + ] + } +} + +async function replyToPaymentMessage( + ctx: Context, + text: string, + replyMarkup?: { inline_keyboard: Array> } +): Promise { const message = ctx.msg if (!message) { return @@ -143,18 +251,159 @@ async function replyToPaymentMessage(ctx: Context, text: string): Promise await ctx.reply(text, { reply_parameters: { message_id: message.message_id - } + }, + ...(replyMarkup + ? { + reply_markup: replyMarkup + } + : {}) }) } export function registerConfiguredPaymentTopicIngestion( bot: Bot, householdConfigurationRepository: HouseholdConfigurationRepository, + promptRepository: TelegramPendingActionRepository, + financeServiceForHousehold: (householdId: string) => FinanceCommandService, paymentServiceForHousehold: (householdId: string) => PaymentConfirmationService, options: { logger?: Logger } = {} ): void { + bot.callbackQuery( + new RegExp(`^${PAYMENT_TOPIC_CONFIRM_CALLBACK_PREFIX}(\\d+):([^:]+)$`), + async (ctx) => { + if (ctx.chat?.type !== 'group' && ctx.chat?.type !== 'supergroup') { + return + } + + const actorTelegramUserId = ctx.from?.id?.toString() + const ownerTelegramUserId = ctx.match[1] + const proposalId = ctx.match[2] + if (!actorTelegramUserId || !ownerTelegramUserId || !proposalId) { + return + } + + const locale = await resolveTopicLocale(ctx, householdConfigurationRepository) + const t = getBotTranslations(locale).payments + + if (actorTelegramUserId !== ownerTelegramUserId) { + await ctx.answerCallbackQuery({ + text: t.notYourProposal, + show_alert: true + }) + return + } + + const pending = await promptRepository.getPendingAction( + ctx.chat.id.toString(), + actorTelegramUserId + ) + const payload = + pending?.action === PAYMENT_TOPIC_CONFIRMATION_ACTION + ? parsePaymentTopicConfirmationPayload(pending.payload) + : null + + if (!payload || payload.proposalId !== proposalId) { + await ctx.answerCallbackQuery({ + text: t.proposalUnavailable, + show_alert: true + }) + return + } + + const paymentService = paymentServiceForHousehold(payload.householdId) + const result = await paymentService.submit({ + ...payload, + rawText: synthesizePaymentConfirmationText(payload) + }) + + await promptRepository.clearPendingAction(ctx.chat.id.toString(), actorTelegramUserId) + + if (result.status !== 'recorded') { + await ctx.answerCallbackQuery({ + text: t.proposalUnavailable, + show_alert: true + }) + return + } + + const recordedText = t.recorded( + result.kind, + result.amount.toMajorString(), + result.amount.currency + ) + await ctx.answerCallbackQuery({ + text: recordedText + }) + + if (ctx.msg) { + await ctx.editMessageText(recordedText, { + reply_markup: { + inline_keyboard: [] + } + }) + } + } + ) + + bot.callbackQuery( + new RegExp(`^${PAYMENT_TOPIC_CANCEL_CALLBACK_PREFIX}(\\d+):([^:]+)$`), + async (ctx) => { + if (ctx.chat?.type !== 'group' && ctx.chat?.type !== 'supergroup') { + return + } + + const actorTelegramUserId = ctx.from?.id?.toString() + const ownerTelegramUserId = ctx.match[1] + const proposalId = ctx.match[2] + if (!actorTelegramUserId || !ownerTelegramUserId || !proposalId) { + return + } + + const locale = await resolveTopicLocale(ctx, householdConfigurationRepository) + const t = getBotTranslations(locale).payments + + if (actorTelegramUserId !== ownerTelegramUserId) { + await ctx.answerCallbackQuery({ + text: t.notYourProposal, + show_alert: true + }) + return + } + + const pending = await promptRepository.getPendingAction( + ctx.chat.id.toString(), + actorTelegramUserId + ) + const payload = + pending?.action === PAYMENT_TOPIC_CONFIRMATION_ACTION + ? parsePaymentTopicConfirmationPayload(pending.payload) + : null + + if (!payload || payload.proposalId !== proposalId) { + await ctx.answerCallbackQuery({ + text: t.proposalUnavailable, + show_alert: true + }) + return + } + + await promptRepository.clearPendingAction(ctx.chat.id.toString(), actorTelegramUserId) + await ctx.answerCallbackQuery({ + text: t.cancelled + }) + + if (ctx.msg) { + await ctx.editMessageText(t.cancelled, { + reply_markup: { + inline_keyboard: [] + } + }) + } + } + ) + bot.on('message', async (ctx, next) => { const candidate = toCandidateFromContext(ctx) if (!candidate) { @@ -179,34 +428,100 @@ export function registerConfiguredPaymentTopicIngestion( } 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 + const locale = await resolveTopicLocale(ctx, householdConfigurationRepository) + const t = getBotTranslations(locale).payments + const financeService = financeServiceForHousehold(record.householdId) + const member = await financeService.getMemberByTelegramUserId(record.senderTelegramUserId) + const pending = await promptRepository.getPendingAction( + record.chatId, + record.senderTelegramUserId ) + const clarificationPayload = + pending?.action === PAYMENT_TOPIC_CLARIFICATION_ACTION + ? parsePaymentClarificationPayload(pending.payload) + : null + const combinedText = + clarificationPayload && clarificationPayload.threadId === record.threadId + ? `${clarificationPayload.rawText}\n${record.rawText}` + : record.rawText - if (acknowledgement) { - await replyToPaymentMessage(ctx, acknowledgement) + if (!member) { + await next() + return + } + + const proposal = await maybeCreatePaymentProposal({ + rawText: combinedText, + householdId: record.householdId, + memberId: member.id, + financeService, + householdConfigurationRepository + }) + + if (proposal.status === 'no_intent') { + await next() + return + } + + if (proposal.status === 'clarification') { + await promptRepository.upsertPendingAction({ + telegramUserId: record.senderTelegramUserId, + telegramChatId: record.chatId, + action: PAYMENT_TOPIC_CLARIFICATION_ACTION, + payload: { + threadId: record.threadId, + rawText: combinedText + }, + expiresAt: null + }) + + await replyToPaymentMessage(ctx, t.clarification) + return + } + + await promptRepository.clearPendingAction(record.chatId, record.senderTelegramUserId) + + if (proposal.status === 'unsupported_currency') { + await replyToPaymentMessage(ctx, t.unsupportedCurrency) + return + } + + if (proposal.status === 'no_balance') { + await replyToPaymentMessage(ctx, t.noBalance) + return + } + + if (proposal.status === 'proposal') { + const amount = Money.fromMinor( + BigInt(proposal.payload.amountMinor), + proposal.payload.currency + ) + await promptRepository.upsertPendingAction({ + telegramUserId: record.senderTelegramUserId, + telegramChatId: record.chatId, + action: PAYMENT_TOPIC_CONFIRMATION_ACTION, + payload: { + ...proposal.payload, + senderTelegramUserId: record.senderTelegramUserId, + rawText: combinedText, + telegramChatId: record.chatId, + telegramMessageId: record.messageId, + telegramThreadId: record.threadId, + telegramUpdateId: String(record.updateId), + attachmentCount: record.attachmentCount + }, + expiresAt: null + }) + + await replyToPaymentMessage( + ctx, + t.proposal(proposal.payload.kind, amount.toMajorString(), amount.currency), + paymentProposalReplyMarkup( + locale, + record.senderTelegramUserId, + proposal.payload.proposalId + ) + ) } } catch (error) { options.logger?.error( @@ -223,3 +538,26 @@ export function registerConfiguredPaymentTopicIngestion( } }) } + +async function resolveTopicLocale( + ctx: Context, + householdConfigurationRepository: HouseholdConfigurationRepository +): Promise { + const binding = + ctx.chat && ctx.msg && 'message_thread_id' in ctx.msg && ctx.msg.message_thread_id !== undefined + ? await householdConfigurationRepository.findHouseholdTopicByTelegramContext({ + telegramChatId: ctx.chat.id.toString(), + telegramThreadId: ctx.msg.message_thread_id.toString() + }) + : null + + if (!binding) { + return 'en' + } + + const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId( + binding.householdId + ) + + return householdChat?.defaultLocale ?? 'en' +} diff --git a/packages/adapters-db/src/telegram-pending-action-repository.ts b/packages/adapters-db/src/telegram-pending-action-repository.ts index 494045f..2e02681 100644 --- a/packages/adapters-db/src/telegram-pending-action-repository.ts +++ b/packages/adapters-db/src/telegram-pending-action-repository.ts @@ -17,6 +17,14 @@ function parsePendingActionType(raw: string): TelegramPendingActionType { return raw } + if (raw === 'payment_topic_clarification') { + return raw + } + + if (raw === 'payment_topic_confirmation') { + return raw + } + if (raw === 'setup_topic_binding') { return raw } diff --git a/packages/ports/src/telegram-pending-actions.ts b/packages/ports/src/telegram-pending-actions.ts index 1a77054..e1c247f 100644 --- a/packages/ports/src/telegram-pending-actions.ts +++ b/packages/ports/src/telegram-pending-actions.ts @@ -3,6 +3,8 @@ import type { Instant } from '@household/domain' export const TELEGRAM_PENDING_ACTION_TYPES = [ 'anonymous_feedback', 'assistant_payment_confirmation', + 'payment_topic_clarification', + 'payment_topic_confirmation', 'setup_topic_binding' ] as const