From 7f8c238a235ac7fa19f024c9ae5104bbd923f0de Mon Sep 17 00:00:00 2001 From: whekin Date: Tue, 10 Mar 2026 22:04:07 +0400 Subject: [PATCH] feat(bot): add resident payment confirmation command --- apps/bot/src/bot.ts | 1 + apps/bot/src/finance-commands.ts | 65 +++++++++++++++++++++++++++++++ apps/bot/src/i18n/locales/en.ts | 8 ++++ apps/bot/src/i18n/locales/ru.ts | 8 ++++ apps/bot/src/i18n/types.ts | 13 +++++++ apps/bot/src/telegram-commands.ts | 22 +++++++++-- 6 files changed, 114 insertions(+), 3 deletions(-) diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index 1f9c2d9..ad9950d 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -61,6 +61,7 @@ export function createTelegramBot( await ctx.reply( formatTelegramHelpText(locale, { includePrivateCommands: ctx.chat?.type === 'private', + includeGroupCommands: ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup', includeAdminCommands }) ) diff --git a/apps/bot/src/finance-commands.ts b/apps/bot/src/finance-commands.ts index 46d4524..bf515a3 100644 --- a/apps/bot/src/finance-commands.ts +++ b/apps/bot/src/finance-commands.ts @@ -233,6 +233,71 @@ export function createFinanceCommandsService(options: { } }) + bot.command('payment_add', async (ctx) => { + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).finance + const resolved = await requireMember(ctx) + if (!resolved) { + return + } + + const args = commandArgs(ctx) + const kind = args[0] + if (kind !== 'rent' && kind !== 'utilities') { + await ctx.reply(t.paymentAddUsage) + return + } + + try { + const dashboard = await resolved.service.generateDashboard() + if (!dashboard) { + await ctx.reply(t.paymentNoCycle) + return + } + + const currentMember = dashboard.members.find( + (member) => member.memberId === resolved.member.id + ) + if (!currentMember) { + await ctx.reply(t.notMember) + return + } + + const inferredAmount = + kind === 'rent' + ? currentMember.rentShare + : currentMember.netDue.subtract(currentMember.rentShare) + + if (args[1] === undefined && inferredAmount.amountMinor <= 0n) { + await ctx.reply(t.paymentNoBalance) + return + } + + const amountArg = args[1] ?? inferredAmount.toMajorString() + const currencyArg = args[2] + const result = await resolved.service.addPayment( + resolved.member.id, + kind, + amountArg, + currencyArg + ) + + if (!result) { + await ctx.reply(t.paymentNoCycle) + return + } + + await ctx.reply( + t.paymentAdded(kind, result.amount.toMajorString(), result.currency, result.period) + ) + } catch (error) { + await ctx.reply(t.paymentAddFailed((error as Error).message)) + } + }) + bot.command('statement', async (ctx) => { const locale = await resolveReplyLocale({ ctx, diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index 50403bf..8d5e1a5 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -12,12 +12,14 @@ export const enBotTranslations: BotTranslationCatalog = { bind_feedback_topic: 'Bind the current topic as feedback', bind_reminders_topic: 'Bind the current topic as reminders', bind_payments_topic: 'Bind the current topic as payments', + payment_add: 'Record your rent or utilities payment', pending_members: 'List pending household join requests', approve_member: 'Approve a pending household member' }, help: { intro: 'Household bot is live.', privateChatHeading: 'Private chat:', + groupHeading: 'Group chat:', groupAdminsHeading: 'Group admins:' }, bot: { @@ -135,6 +137,12 @@ export const enBotTranslations: BotTranslationCatalog = { utilityAdded: (name, amount, currency, period) => `Utility bill added: ${name} ${amount} ${currency} for ${period}`, utilityAddFailed: (message) => `Failed to add utility bill: ${message}`, + paymentAddUsage: 'Usage: /payment_add [amount] [USD|GEL]', + paymentNoCycle: 'No billing cycle is ready yet.', + paymentNoBalance: 'There is no payable balance for that payment type right now.', + paymentAdded: (kind, amount, currency, period) => + `Payment recorded: ${kind === 'rent' ? 'rent' : 'utilities'} ${amount} ${currency} for ${period}`, + paymentAddFailed: (message) => `Failed to record payment: ${message}`, noStatementCycle: 'No cycle found for statement.', statementTitle: (period) => `Statement for ${period}`, statementLine: (displayName, amount, currency) => `- ${displayName}: ${amount} ${currency}`, diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index 7822a15..d392063 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -12,12 +12,14 @@ export const ruBotTranslations: BotTranslationCatalog = { bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений', bind_reminders_topic: 'Назначить текущий топик для напоминаний', bind_payments_topic: 'Назначить текущий топик для оплат', + payment_add: 'Подтвердить оплату аренды или коммуналки', pending_members: 'Показать ожидающие заявки на вступление', approve_member: 'Подтвердить участника дома' }, help: { intro: 'Бот для дома подключен.', privateChatHeading: 'Личный чат:', + groupHeading: 'Группа дома:', groupAdminsHeading: 'Админы группы:' }, bot: { @@ -138,6 +140,12 @@ export const ruBotTranslations: BotTranslationCatalog = { utilityAdded: (name, amount, currency, period) => `Коммунальный счёт добавлен: ${name} ${amount} ${currency} за ${period}`, utilityAddFailed: (message) => `Не удалось добавить коммунальный счёт: ${message}`, + paymentAddUsage: 'Использование: /payment_add [amount] [USD|GEL]', + paymentNoCycle: 'Биллинг-цикл пока не готов.', + paymentNoBalance: 'Сейчас для этого типа оплаты нет суммы к подтверждению.', + paymentAdded: (kind, amount, currency, period) => + `Оплата сохранена: ${kind === 'rent' ? 'аренда' : 'коммуналка'} ${amount} ${currency} за ${period}`, + paymentAddFailed: (message) => `Не удалось сохранить оплату: ${message}`, noStatementCycle: 'Для выписки период не найден.', statementTitle: (period) => `Выписка за ${period}`, statementLine: (displayName, amount, currency) => `- ${displayName}: ${amount} ${currency}`, diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 862a333..2a57381 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -10,6 +10,7 @@ export type TelegramCommandName = | 'bind_feedback_topic' | 'bind_reminders_topic' | 'bind_payments_topic' + | 'payment_add' | 'pending_members' | 'approve_member' @@ -23,6 +24,7 @@ export interface BotCommandDescriptions { bind_feedback_topic: string bind_reminders_topic: string bind_payments_topic: string + payment_add: string pending_members: string approve_member: string } @@ -39,6 +41,7 @@ export interface BotTranslationCatalog { help: { intro: string privateChatHeading: string + groupHeading: string groupAdminsHeading: string } bot: { @@ -140,6 +143,16 @@ export interface BotTranslationCatalog { utilityNoOpenCycle: string utilityAdded: (name: string, amount: string, currency: string, period: string) => string utilityAddFailed: (message: string) => string + paymentAddUsage: string + paymentNoCycle: string + paymentNoBalance: string + paymentAdded: ( + kind: 'rent' | 'utilities', + amount: string, + currency: string, + period: string + ) => string + paymentAddFailed: (message: string) => string noStatementCycle: string statementTitle: (period: string) => string statementLine: (displayName: string, amount: string, currency: string) => string diff --git a/apps/bot/src/telegram-commands.ts b/apps/bot/src/telegram-commands.ts index fda3feb..156b582 100644 --- a/apps/bot/src/telegram-commands.ts +++ b/apps/bot/src/telegram-commands.ts @@ -13,6 +13,7 @@ export interface ScopedTelegramCommands { export interface TelegramHelpOptions { includePrivateCommands?: boolean + includeGroupCommands?: boolean includeAdminCommands?: boolean } @@ -26,8 +27,12 @@ const PRIVATE_CHAT_COMMAND_NAMES = [ 'cancel' ] as const satisfies readonly TelegramCommandName[] const GROUP_CHAT_COMMAND_NAMES = DEFAULT_COMMAND_NAMES -const GROUP_ADMIN_COMMAND_NAMES = [ +const GROUP_MEMBER_COMMAND_NAMES = [ ...GROUP_CHAT_COMMAND_NAMES, + 'payment_add' +] as const satisfies readonly TelegramCommandName[] +const GROUP_ADMIN_COMMAND_NAMES = [ + ...GROUP_MEMBER_COMMAND_NAMES, 'setup', 'bind_purchase_topic', 'bind_feedback_topic', @@ -61,7 +66,7 @@ export function getTelegramCommandScopes(locale: BotLocale): readonly ScopedTele }, { scope: 'all_group_chats', - commands: mapCommands(locale, GROUP_CHAT_COMMAND_NAMES) + commands: mapCommands(locale, GROUP_MEMBER_COMMAND_NAMES) }, { scope: 'all_chat_administrators', @@ -76,14 +81,18 @@ export function formatTelegramHelpText( ): string { const t = getBotTranslations(locale) const defaultCommands = new Set(DEFAULT_COMMAND_NAMES) + const groupMemberCommands = new Set(GROUP_MEMBER_COMMAND_NAMES) const includePrivateCommands = options.includePrivateCommands ?? true + const includeGroupCommands = options.includeGroupCommands ?? false const includeAdminCommands = options.includeAdminCommands ?? false const privateCommands = includePrivateCommands ? mapCommands(locale, PRIVATE_CHAT_COMMAND_NAMES) : [] + const groupCommands = includeGroupCommands ? mapCommands(locale, GROUP_MEMBER_COMMAND_NAMES) : [] const adminCommands = includeAdminCommands ? mapCommands(locale, GROUP_ADMIN_COMMAND_NAMES).filter( - (command) => !defaultCommands.has(command.command) + (command) => + !defaultCommands.has(command.command) && !groupMemberCommands.has(command.command) ) : [] @@ -96,6 +105,13 @@ export function formatTelegramHelpText( ) } + if (groupCommands.length > 0) { + sections.push( + t.help.groupHeading, + ...groupCommands.map((command) => `/${command.command} - ${command.description}`) + ) + } + if (adminCommands.length > 0) { sections.push( t.help.groupAdminsHeading,