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( await ctx.reply(
formatTelegramHelpText(locale, { formatTelegramHelpText(locale, {
includePrivateCommands: ctx.chat?.type === 'private', includePrivateCommands: ctx.chat?.type === 'private',
includeGroupCommands: ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup',
includeAdminCommands 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) => { bot.command('statement', async (ctx) => {
const locale = await resolveReplyLocale({ const locale = await resolveReplyLocale({
ctx, ctx,

View File

@@ -12,12 +12,14 @@ export const enBotTranslations: BotTranslationCatalog = {
bind_feedback_topic: 'Bind the current topic as feedback', bind_feedback_topic: 'Bind the current topic as feedback',
bind_reminders_topic: 'Bind the current topic as reminders', bind_reminders_topic: 'Bind the current topic as reminders',
bind_payments_topic: 'Bind the current topic as payments', bind_payments_topic: 'Bind the current topic as payments',
payment_add: 'Record your rent or utilities payment',
pending_members: 'List pending household join requests', pending_members: 'List pending household join requests',
approve_member: 'Approve a pending household member' approve_member: 'Approve a pending household member'
}, },
help: { help: {
intro: 'Household bot is live.', intro: 'Household bot is live.',
privateChatHeading: 'Private chat:', privateChatHeading: 'Private chat:',
groupHeading: 'Group chat:',
groupAdminsHeading: 'Group admins:' groupAdminsHeading: 'Group admins:'
}, },
bot: { bot: {
@@ -135,6 +137,12 @@ export const enBotTranslations: BotTranslationCatalog = {
utilityAdded: (name, amount, currency, period) => utilityAdded: (name, amount, currency, period) =>
`Utility bill added: ${name} ${amount} ${currency} for ${period}`, `Utility bill added: ${name} ${amount} ${currency} for ${period}`,
utilityAddFailed: (message) => `Failed to add utility bill: ${message}`, 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.', noStatementCycle: 'No cycle found for statement.',
statementTitle: (period) => `Statement for ${period}`, statementTitle: (period) => `Statement for ${period}`,
statementLine: (displayName, amount, currency) => `- ${displayName}: ${amount} ${currency}`, statementLine: (displayName, amount, currency) => `- ${displayName}: ${amount} ${currency}`,

View File

@@ -12,12 +12,14 @@ export const ruBotTranslations: BotTranslationCatalog = {
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений', bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
bind_reminders_topic: 'Назначить текущий топик для напоминаний', bind_reminders_topic: 'Назначить текущий топик для напоминаний',
bind_payments_topic: 'Назначить текущий топик для оплат', bind_payments_topic: 'Назначить текущий топик для оплат',
payment_add: 'Подтвердить оплату аренды или коммуналки',
pending_members: 'Показать ожидающие заявки на вступление', pending_members: 'Показать ожидающие заявки на вступление',
approve_member: 'Подтвердить участника дома' approve_member: 'Подтвердить участника дома'
}, },
help: { help: {
intro: 'Бот для дома подключен.', intro: 'Бот для дома подключен.',
privateChatHeading: 'Личный чат:', privateChatHeading: 'Личный чат:',
groupHeading: 'Группа дома:',
groupAdminsHeading: 'Админы группы:' groupAdminsHeading: 'Админы группы:'
}, },
bot: { bot: {
@@ -138,6 +140,12 @@ export const ruBotTranslations: BotTranslationCatalog = {
utilityAdded: (name, amount, currency, period) => utilityAdded: (name, amount, currency, period) =>
`Коммунальный счёт добавлен: ${name} ${amount} ${currency} за ${period}`, `Коммунальный счёт добавлен: ${name} ${amount} ${currency} за ${period}`,
utilityAddFailed: (message) => `Не удалось добавить коммунальный счёт: ${message}`, 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: 'Для выписки период не найден.', noStatementCycle: 'Для выписки период не найден.',
statementTitle: (period) => `Выписка за ${period}`, statementTitle: (period) => `Выписка за ${period}`,
statementLine: (displayName, amount, currency) => `- ${displayName}: ${amount} ${currency}`, statementLine: (displayName, amount, currency) => `- ${displayName}: ${amount} ${currency}`,

View File

@@ -10,6 +10,7 @@ export type TelegramCommandName =
| 'bind_feedback_topic' | 'bind_feedback_topic'
| 'bind_reminders_topic' | 'bind_reminders_topic'
| 'bind_payments_topic' | 'bind_payments_topic'
| 'payment_add'
| 'pending_members' | 'pending_members'
| 'approve_member' | 'approve_member'
@@ -23,6 +24,7 @@ export interface BotCommandDescriptions {
bind_feedback_topic: string bind_feedback_topic: string
bind_reminders_topic: string bind_reminders_topic: string
bind_payments_topic: string bind_payments_topic: string
payment_add: string
pending_members: string pending_members: string
approve_member: string approve_member: string
} }
@@ -39,6 +41,7 @@ export interface BotTranslationCatalog {
help: { help: {
intro: string intro: string
privateChatHeading: string privateChatHeading: string
groupHeading: string
groupAdminsHeading: string groupAdminsHeading: string
} }
bot: { bot: {
@@ -140,6 +143,16 @@ export interface BotTranslationCatalog {
utilityNoOpenCycle: string utilityNoOpenCycle: string
utilityAdded: (name: string, amount: string, currency: string, period: string) => string utilityAdded: (name: string, amount: string, currency: string, period: string) => string
utilityAddFailed: (message: 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 noStatementCycle: string
statementTitle: (period: string) => string statementTitle: (period: string) => string
statementLine: (displayName: string, amount: string, currency: string) => string statementLine: (displayName: string, amount: string, currency: string) => string

View File

@@ -13,6 +13,7 @@ export interface ScopedTelegramCommands {
export interface TelegramHelpOptions { export interface TelegramHelpOptions {
includePrivateCommands?: boolean includePrivateCommands?: boolean
includeGroupCommands?: boolean
includeAdminCommands?: boolean includeAdminCommands?: boolean
} }
@@ -26,8 +27,12 @@ const PRIVATE_CHAT_COMMAND_NAMES = [
'cancel' 'cancel'
] as const satisfies readonly TelegramCommandName[] ] as const satisfies readonly TelegramCommandName[]
const GROUP_CHAT_COMMAND_NAMES = DEFAULT_COMMAND_NAMES const GROUP_CHAT_COMMAND_NAMES = DEFAULT_COMMAND_NAMES
const GROUP_ADMIN_COMMAND_NAMES = [ const GROUP_MEMBER_COMMAND_NAMES = [
...GROUP_CHAT_COMMAND_NAMES, ...GROUP_CHAT_COMMAND_NAMES,
'payment_add'
] as const satisfies readonly TelegramCommandName[]
const GROUP_ADMIN_COMMAND_NAMES = [
...GROUP_MEMBER_COMMAND_NAMES,
'setup', 'setup',
'bind_purchase_topic', 'bind_purchase_topic',
'bind_feedback_topic', 'bind_feedback_topic',
@@ -61,7 +66,7 @@ export function getTelegramCommandScopes(locale: BotLocale): readonly ScopedTele
}, },
{ {
scope: 'all_group_chats', scope: 'all_group_chats',
commands: mapCommands(locale, GROUP_CHAT_COMMAND_NAMES) commands: mapCommands(locale, GROUP_MEMBER_COMMAND_NAMES)
}, },
{ {
scope: 'all_chat_administrators', scope: 'all_chat_administrators',
@@ -76,14 +81,18 @@ export function formatTelegramHelpText(
): string { ): string {
const t = getBotTranslations(locale) const t = getBotTranslations(locale)
const defaultCommands = new Set<TelegramCommandName>(DEFAULT_COMMAND_NAMES) const defaultCommands = new Set<TelegramCommandName>(DEFAULT_COMMAND_NAMES)
const groupMemberCommands = new Set<TelegramCommandName>(GROUP_MEMBER_COMMAND_NAMES)
const includePrivateCommands = options.includePrivateCommands ?? true const includePrivateCommands = options.includePrivateCommands ?? true
const includeGroupCommands = options.includeGroupCommands ?? false
const includeAdminCommands = options.includeAdminCommands ?? false const includeAdminCommands = options.includeAdminCommands ?? false
const privateCommands = includePrivateCommands const privateCommands = includePrivateCommands
? mapCommands(locale, PRIVATE_CHAT_COMMAND_NAMES) ? mapCommands(locale, PRIVATE_CHAT_COMMAND_NAMES)
: [] : []
const groupCommands = includeGroupCommands ? mapCommands(locale, GROUP_MEMBER_COMMAND_NAMES) : []
const adminCommands = includeAdminCommands const adminCommands = includeAdminCommands
? mapCommands(locale, GROUP_ADMIN_COMMAND_NAMES).filter( ? 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) { if (adminCommands.length > 0) {
sections.push( sections.push(
t.help.groupAdminsHeading, t.help.groupAdminsHeading,