Merge pull request #16 from whekin/codex/whe-66-payment-topic-flow

Improve topic setup refresh and payment topic confirmations
This commit is contained in:
Stas
2026-03-11 10:26:50 +03:00
committed by GitHub
12 changed files with 1015 additions and 200 deletions

View File

@@ -1,4 +1,4 @@
import { parsePaymentConfirmationMessage, type FinanceCommandService } from '@household/application' import type { FinanceCommandService } from '@household/application'
import { instantFromEpochSeconds, Money } from '@household/domain' import { instantFromEpochSeconds, Money } from '@household/domain'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import type { import type {
@@ -12,6 +12,7 @@ import { resolveReplyLocale } from './bot-locale'
import { getBotTranslations, type BotLocale } from './i18n' import { getBotTranslations, type BotLocale } from './i18n'
import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant' import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant'
import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter' import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter'
import { maybeCreatePaymentProposal, parsePaymentProposalPayload } from './payment-proposals'
import type { import type {
PurchaseMessageIngestionRepository, PurchaseMessageIngestionRepository,
PurchaseProposalActionResult, PurchaseProposalActionResult,
@@ -76,15 +77,6 @@ export interface AssistantUsageTracker {
listHouseholdUsage(householdId: string): readonly AssistantUsageSnapshot[] listHouseholdUsage(householdId: string): readonly AssistantUsageSnapshot[]
} }
interface PaymentProposalPayload {
proposalId: string
householdId: string
memberId: string
kind: 'rent' | 'utilities'
amountMinor: string
currency: 'GEL' | 'USD'
}
type PurchaseActionResult = Extract< type PurchaseActionResult = Extract<
PurchaseProposalActionResult, PurchaseProposalActionResult,
{ status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled' } { 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) return PURCHASE_MONEY_PATTERN.test(normalized) && /\p{L}/u.test(normalized)
} }
function parsePaymentProposalPayload(
payload: Record<string, unknown>
): 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( function formatAssistantLedger(
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>> dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
) { ) {
@@ -491,92 +455,6 @@ async function buildHouseholdContext(input: {
return lines.join('\n') 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: { export function registerDmAssistant(options: {
bot: Bot bot: Bot
assistant?: ConversationalAssistant assistant?: ConversationalAssistant

View File

@@ -810,11 +810,11 @@ describe('registerHouseholdSetupCommands', () => {
], ],
[ [
{ {
text: 'Create purchases', text: 'Create purchases topic',
callback_data: 'setup_topic:create:purchase' callback_data: 'setup_topic:create:purchase'
}, },
{ {
text: 'Bind purchases', text: 'Bind purchases topic',
callback_data: 'setup_topic:bind:purchase' callback_data: 'setup_topic:bind:purchase'
} }
] ]
@@ -1060,15 +1060,24 @@ describe('registerHouseholdSetupCommands', () => {
expect(await promptRepository.getPendingAction('-100123456', '123456')).toMatchObject({ expect(await promptRepository.getPendingAction('-100123456', '123456')).toMatchObject({
action: 'setup_topic_binding', action: 'setup_topic_binding',
payload: { payload: {
role: 'payments' role: 'payments',
setupMessageId: 91
} }
}) })
calls.length = 0 calls.length = 0
await bot.handleUpdate(topicMessageUpdate('hello from payments', 444) as never) await bot.handleUpdate(topicMessageUpdate('hello from payments', 444) as never)
expect(calls).toHaveLength(2) expect(calls).toHaveLength(3)
expect(calls[1]).toMatchObject({ expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
chat_id: -100123456,
message_id: 91,
text: expect.stringContaining('- payments: bound to thread 444')
}
})
expect(calls[2]).toMatchObject({
method: 'sendMessage', method: 'sendMessage',
payload: { payload: {
chat_id: -100123456, chat_id: -100123456,

View File

@@ -321,12 +321,20 @@ function isHouseholdTopicRole(value: string): value is HouseholdTopicRole {
function parseSetupBindPayload(payload: Record<string, unknown>): { function parseSetupBindPayload(payload: Record<string, unknown>): {
role: HouseholdTopicRole role: HouseholdTopicRole
setupMessageId?: number
} | null { } | null {
return typeof payload.role === 'string' && isHouseholdTopicRole(payload.role) if (typeof payload.role !== 'string' || !isHouseholdTopicRole(payload.role)) {
? { return null
role: payload.role }
return {
role: payload.role,
...(typeof payload.setupMessageId === 'number' && Number.isInteger(payload.setupMessageId)
? {
setupMessageId: payload.setupMessageId
}
: {})
} }
: null
} }
export function buildJoinMiniAppUrl( export function buildJoinMiniAppUrl(
@@ -527,6 +535,22 @@ export function registerHouseholdSetupCommands(options: {
return return
} }
if (payload.setupMessageId && options.householdConfigurationRepository) {
const reply = await buildSetupReplyForHousehold({
ctx,
locale,
household: result.household,
created: false
})
await ctx.api.editMessageText(
Number(telegramChatId),
payload.setupMessageId,
reply.text,
'reply_markup' in reply ? { reply_markup: reply.reply_markup } : {}
)
}
await ctx.reply( await ctx.reply(
bindTopicSuccessMessage( bindTopicSuccessMessage(
locale, locale,
@@ -1027,7 +1051,12 @@ export function registerHouseholdSetupCommands(options: {
telegramChatId, telegramChatId,
action: SETUP_BIND_TOPIC_ACTION, action: SETUP_BIND_TOPIC_ACTION,
payload: { payload: {
role role,
...(ctx.msg
? {
setupMessageId: ctx.msg.message_id
}
: {})
}, },
expiresAt: nowInstant().add({ milliseconds: SETUP_BIND_TOPIC_TTL_MS }) expiresAt: nowInstant().add({ milliseconds: SETUP_BIND_TOPIC_TTL_MS })
}) })

View File

@@ -60,8 +60,8 @@ export const enBotTranslations: BotTranslationCatalog = {
setupTopicsHeading: 'Topic setup:', setupTopicsHeading: 'Topic setup:',
setupTopicBound: (role, topic) => `- ${role}: bound to ${topic}`, setupTopicBound: (role, topic) => `- ${role}: bound to ${topic}`,
setupTopicMissing: (role) => `- ${role}: not configured`, setupTopicMissing: (role) => `- ${role}: not configured`,
setupTopicCreateButton: (role) => `Create ${role}`, setupTopicCreateButton: (role) => `Create ${role} topic`,
setupTopicBindButton: (role) => `Bind ${role}`, setupTopicBindButton: (role) => `Bind ${role} topic`,
setupTopicCreateFailed: setupTopicCreateFailed:
'I could not create that topic. Check bot admin permissions and forum settings.', 'I could not create that topic. Check bot admin permissions and forum settings.',
setupTopicCreateForbidden: setupTopicCreateForbidden:
@@ -260,8 +260,20 @@ export const enBotTranslations: BotTranslationCatalog = {
payments: { payments: {
topicMissing: topicMissing:
'Payments topic is not configured for this household yet. Ask an admin to run /bind_payments_topic.', 'Payments topic is not configured for this household yet. Ask an admin to run /bind_payments_topic.',
proposal: (kind, amount, currency) =>
`I can record this ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}. Confirm or cancel below.`,
clarification:
'I could not confirm this payment yet. Please clarify whether this was rent or utilities and include the amount/currency if needed.',
unsupportedCurrency:
'I can only record payments in the household settlement currency for this topic right now.',
noBalance: 'There is no payable balance for that payment type right now.',
confirmButton: 'Confirm payment',
cancelButton: 'Cancel',
recorded: (kind, amount, currency) => recorded: (kind, amount, currency) =>
`Recorded ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}`, `Recorded ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}`,
cancelled: 'Payment proposal cancelled.',
proposalUnavailable: 'This payment proposal is no longer available.',
notYourProposal: 'Only the original sender can confirm or cancel this payment.',
savedForReview: 'Saved this payment confirmation for review.', savedForReview: 'Saved this payment confirmation for review.',
duplicate: 'This payment confirmation was already processed.' duplicate: 'This payment confirmation was already processed.'
} }

View File

@@ -62,8 +62,8 @@ export const ruBotTranslations: BotTranslationCatalog = {
setupTopicsHeading: 'Настройка топиков:', setupTopicsHeading: 'Настройка топиков:',
setupTopicBound: (role, topic) => `- ${role}: привязан к ${topic}`, setupTopicBound: (role, topic) => `- ${role}: привязан к ${topic}`,
setupTopicMissing: (role) => `- ${role}: не настроен`, setupTopicMissing: (role) => `- ${role}: не настроен`,
setupTopicCreateButton: (role) => `Создать ${role}`, setupTopicCreateButton: (role) => `Создать топик для ${role}`,
setupTopicBindButton: (role) => `Привязать ${role}`, setupTopicBindButton: (role) => `Привязать топик для ${role}`,
setupTopicCreateFailed: setupTopicCreateFailed:
'Не удалось создать этот топик. Проверьте права бота и включённые форум-топики в группе.', 'Не удалось создать этот топик. Проверьте права бота и включённые форум-топики в группе.',
setupTopicCreateForbidden: setupTopicCreateForbidden:
@@ -263,8 +263,20 @@ export const ruBotTranslations: BotTranslationCatalog = {
payments: { payments: {
topicMissing: topicMissing:
'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.', 'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.',
proposal: (kind, amount, currency) =>
`Я могу записать эту оплату ${kind === 'rent' ? 'аренды' : 'коммуналки'}: ${amount} ${currency}. Подтвердите или отмените ниже.`,
clarification:
'Пока не могу подтвердить эту оплату. Уточните, это аренда или коммуналка, и при необходимости напишите сумму и валюту.',
unsupportedCurrency:
'Сейчас я могу записывать оплаты в этом топике только в валюте расчётов по дому.',
noBalance: 'Сейчас для этого типа оплаты нет суммы к подтверждению.',
confirmButton: 'Подтвердить оплату',
cancelButton: 'Отменить',
recorded: (kind, amount, currency) => recorded: (kind, amount, currency) =>
`Оплата ${kind === 'rent' ? 'аренды' : 'коммуналки'} сохранена: ${amount} ${currency}`, `Оплата ${kind === 'rent' ? 'аренды' : 'коммуналки'} сохранена: ${amount} ${currency}`,
cancelled: 'Предложение оплаты отменено.',
proposalUnavailable: 'Это предложение оплаты уже недоступно.',
notYourProposal: 'Подтвердить или отменить эту оплату может только отправитель сообщения.',
savedForReview: 'Это подтверждение оплаты сохранено на проверку.', savedForReview: 'Это подтверждение оплаты сохранено на проверку.',
duplicate: 'Это подтверждение оплаты уже было обработано.' duplicate: 'Это подтверждение оплаты уже было обработано.'
} }

View File

@@ -249,6 +249,15 @@ export interface BotTranslationCatalog {
payments: { payments: {
topicMissing: string topicMissing: string
recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
proposal: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
clarification: string
unsupportedCurrency: string
noBalance: string
confirmButton: string
cancelButton: string
cancelled: string
proposalUnavailable: string
notYourProposal: string
savedForReview: string savedForReview: string
duplicate: string duplicate: string
} }

View File

@@ -109,8 +109,7 @@ const miniAppAdminService = householdConfigurationRepositoryClient
const localePreferenceService = householdConfigurationRepositoryClient const localePreferenceService = householdConfigurationRepositoryClient
? createLocalePreferenceService(householdConfigurationRepositoryClient.repository) ? createLocalePreferenceService(householdConfigurationRepositoryClient.repository)
: null : null
const telegramPendingActionRepositoryClient = const telegramPendingActionRepositoryClient = runtime.databaseUrl
runtime.databaseUrl && (runtime.anonymousFeedbackEnabled || runtime.assistantEnabled)
? createDbTelegramPendingActionRepository(runtime.databaseUrl!) ? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
: null : null
const processedBotMessageRepositoryClient = const processedBotMessageRepositoryClient =
@@ -243,6 +242,8 @@ if (purchaseRepositoryClient && householdConfigurationRepositoryClient) {
registerConfiguredPaymentTopicIngestion( registerConfiguredPaymentTopicIngestion(
bot, bot,
householdConfigurationRepositoryClient.repository, householdConfigurationRepositoryClient.repository,
telegramPendingActionRepositoryClient!.repository,
financeServiceForHousehold,
paymentConfirmationServiceForHousehold, paymentConfirmationServiceForHousehold,
{ {
logger: getLogger('payment-ingestion') logger: getLogger('payment-ingestion')

View File

@@ -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<string, unknown>
): 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}`
}

View File

@@ -1,6 +1,8 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { FinanceCommandService, PaymentConfirmationService } from '@household/application'
import { instantFromIso, Money } from '@household/domain' import { instantFromIso, Money } from '@household/domain'
import type { TelegramPendingActionRecord, TelegramPendingActionRepository } from '@household/ports'
import { createTelegramBot } from './bot' import { createTelegramBot } from './bot'
import { import {
buildPaymentAcknowledgement, 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', () => { describe('resolveConfiguredPaymentTopicRecord', () => {
test('returns record when the topic role is payments', () => { test('returns record when the topic role is payments', () => {
const record = resolveConfiguredPaymentTopicRecord(candidate(), { const record = resolveConfiguredPaymentTopicRecord(candidate(), {
@@ -68,6 +242,17 @@ describe('resolveConfiguredPaymentTopicRecord', () => {
expect(record).toBeNull() 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', () => { describe('buildPaymentAcknowledgement', () => {
@@ -87,14 +272,15 @@ describe('buildPaymentAcknowledgement', () => {
buildPaymentAcknowledgement('en', { buildPaymentAcknowledgement('en', {
status: 'needs_review' status: 'needs_review'
}) })
).toBe('Saved this payment confirmation for review.') ).toBeNull()
}) })
}) })
describe('registerConfiguredPaymentTopicIngestion', () => { 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 bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []
const promptRepository = createPromptRepository()
bot.botInfo = { bot.botInfo = {
id: 999000, id: 999000,
@@ -127,31 +313,14 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
} as never } as never
}) })
const paymentConfirmationService = createPaymentConfirmationService()
registerConfiguredPaymentTopicIngestion( registerConfiguredPaymentTopicIngestion(
bot, bot,
{ createHouseholdRepository() as never,
getHouseholdChatByHouseholdId: async () => ({ promptRepository,
householdId: 'household-1', () => createFinanceService(),
householdName: 'Test bot', () => paymentConfirmationService
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) await bot.handleUpdate(paymentUpdate('за жилье закинул') as never)
@@ -163,7 +332,221 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
reply_parameters: { reply_parameters: {
message_id: 55 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)
})
}) })

View File

@@ -1,13 +1,26 @@
import type { PaymentConfirmationService } from '@household/application' import type { FinanceCommandService, PaymentConfirmationService } from '@household/application'
import { instantFromEpochSeconds, type Instant } from '@household/domain' import { Money } from '@household/domain'
import { instantFromEpochSeconds, nowInstant, type Instant } from '@household/domain'
import type { Bot, Context } from 'grammy' import type { Bot, Context } from 'grammy'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import type { import type {
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
HouseholdTopicBindingRecord HouseholdTopicBindingRecord,
TelegramPendingActionRepository
} from '@household/ports' } from '@household/ports'
import { getBotTranslations, type BotLocale } from './i18n' 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
const PAYMENT_TOPIC_ACTION_TTL_MS = 30 * 60_000
export interface PaymentTopicCandidate { export interface PaymentTopicCandidate {
updateId: number updateId: number
@@ -24,6 +37,28 @@ export interface PaymentTopicRecord extends PaymentTopicCandidate {
householdId: string 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 { function readMessageText(ctx: Context): string | null {
const message = ctx.message const message = ctx.message
if (!message) { if (!message) {
@@ -99,6 +134,10 @@ export function resolveConfiguredPaymentTopicRecord(
return null return null
} }
if (normalizedText.startsWith('/')) {
return null
}
if (binding.role !== 'payments') { if (binding.role !== 'payments') {
return null return null
} }
@@ -130,11 +169,81 @@ export function buildPaymentAcknowledgement(
case 'recorded': case 'recorded':
return t.recorded(result.kind, result.amountMajor, result.currency) return t.recorded(result.kind, result.amountMajor, result.currency)
case 'needs_review': case 'needs_review':
return t.savedForReview return null
} }
} }
async function replyToPaymentMessage(ctx: Context, text: string): Promise<void> { function parsePaymentClarificationPayload(
payload: Record<string, unknown>
): PaymentTopicClarificationPayload | null {
if (typeof payload.threadId !== 'string' || typeof payload.rawText !== 'string') {
return null
}
return {
threadId: payload.threadId,
rawText: payload.rawText
}
}
function parsePaymentTopicConfirmationPayload(
payload: Record<string, unknown>
): 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<Array<{ text: string; callback_data: string }>> }
): Promise<void> {
const message = ctx.msg const message = ctx.msg
if (!message) { if (!message) {
return return
@@ -143,18 +252,159 @@ async function replyToPaymentMessage(ctx: Context, text: string): Promise<void>
await ctx.reply(text, { await ctx.reply(text, {
reply_parameters: { reply_parameters: {
message_id: message.message_id message_id: message.message_id
},
...(replyMarkup
? {
reply_markup: replyMarkup
} }
: {})
}) })
} }
export function registerConfiguredPaymentTopicIngestion( export function registerConfiguredPaymentTopicIngestion(
bot: Bot, bot: Bot,
householdConfigurationRepository: HouseholdConfigurationRepository, householdConfigurationRepository: HouseholdConfigurationRepository,
promptRepository: TelegramPendingActionRepository,
financeServiceForHousehold: (householdId: string) => FinanceCommandService,
paymentServiceForHousehold: (householdId: string) => PaymentConfirmationService, paymentServiceForHousehold: (householdId: string) => PaymentConfirmationService,
options: { options: {
logger?: Logger logger?: Logger
} = {} } = {}
): void { ): 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) => { bot.on('message', async (ctx, next) => {
const candidate = toCandidateFromContext(ctx) const candidate = toCandidateFromContext(ctx)
if (!candidate) { if (!candidate) {
@@ -179,34 +429,100 @@ export function registerConfiguredPaymentTopicIngestion(
} }
try { try {
const result = await paymentServiceForHousehold(record.householdId).submit({ 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 (!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: nowInstant().add({ milliseconds: PAYMENT_TOPIC_ACTION_TTL_MS })
})
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, senderTelegramUserId: record.senderTelegramUserId,
rawText: record.rawText, rawText: combinedText,
telegramChatId: record.chatId, telegramChatId: record.chatId,
telegramMessageId: record.messageId, telegramMessageId: record.messageId,
telegramThreadId: record.threadId, telegramThreadId: record.threadId,
telegramUpdateId: String(record.updateId), telegramUpdateId: String(record.updateId),
attachmentCount: record.attachmentCount, attachmentCount: record.attachmentCount
messageSentAt: record.messageSentAt },
expiresAt: nowInstant().add({ milliseconds: PAYMENT_TOPIC_ACTION_TTL_MS })
}) })
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(
await replyToPaymentMessage(ctx, acknowledgement) ctx,
t.proposal(proposal.payload.kind, amount.toMajorString(), amount.currency),
paymentProposalReplyMarkup(
locale,
record.senderTelegramUserId,
proposal.payload.proposalId
)
)
} }
} catch (error) { } catch (error) {
options.logger?.error( options.logger?.error(
@@ -223,3 +539,26 @@ export function registerConfiguredPaymentTopicIngestion(
} }
}) })
} }
async function resolveTopicLocale(
ctx: Context,
householdConfigurationRepository: HouseholdConfigurationRepository
): Promise<BotLocale> {
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'
}

View File

@@ -17,6 +17,14 @@ function parsePendingActionType(raw: string): TelegramPendingActionType {
return raw return raw
} }
if (raw === 'payment_topic_clarification') {
return raw
}
if (raw === 'payment_topic_confirmation') {
return raw
}
if (raw === 'setup_topic_binding') { if (raw === 'setup_topic_binding') {
return raw return raw
} }

View File

@@ -3,6 +3,8 @@ import type { Instant } from '@household/domain'
export const TELEGRAM_PENDING_ACTION_TYPES = [ export const TELEGRAM_PENDING_ACTION_TYPES = [
'anonymous_feedback', 'anonymous_feedback',
'assistant_payment_confirmation', 'assistant_payment_confirmation',
'payment_topic_clarification',
'payment_topic_confirmation',
'setup_topic_binding' 'setup_topic_binding'
] as const ] as const