feat(bot): add resident payment confirmation command

This commit is contained in:
2026-03-10 22:04:07 +04:00
parent 753286a1f6
commit 7f8c238a23
6 changed files with 114 additions and 3 deletions

View File

@@ -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
})
)

View File

@@ -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,

View File

@@ -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 <rent|utilities> [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}`,

View File

@@ -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 <rent|utilities> [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}`,

View File

@@ -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

View File

@@ -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<TelegramCommandName>(DEFAULT_COMMAND_NAMES)
const groupMemberCommands = new Set<TelegramCommandName>(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,