mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:44:03 +00:00
feat(bot): add structured payment topic confirmations
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
133
apps/bot/src/payment-proposals.ts
Normal file
133
apps/bot/src/payment-proposals.ts
Normal 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}`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { instantFromEpochSeconds, 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
|
||||||
|
|
||||||
export interface PaymentTopicCandidate {
|
export interface PaymentTopicCandidate {
|
||||||
updateId: number
|
updateId: number
|
||||||
@@ -24,6 +36,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 +133,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 +168,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 +251,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 +428,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: 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,
|
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: null
|
||||||
})
|
})
|
||||||
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 +538,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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user