diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index ad9950d..80b2e78 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -2,7 +2,6 @@ import { Bot, type Context } from 'grammy' import type { Logger } from '@household/observability' import type { HouseholdConfigurationRepository } from '@household/ports' -import { getBotTranslations } from './i18n' import { resolveReplyLocale } from './bot-locale' import { formatTelegramHelpText } from './telegram-commands' @@ -66,15 +65,6 @@ export function createTelegramBot( }) ) }) - - bot.command('household_status', async (ctx) => { - const locale = await resolveReplyLocale({ - ctx, - repository: householdConfigurationRepository - }) - await ctx.reply(getBotTranslations(locale).bot.householdStatusPending) - }) - bot.catch((error) => { logger?.error( { diff --git a/apps/bot/src/finance-commands.test.ts b/apps/bot/src/finance-commands.test.ts new file mode 100644 index 0000000..948aa3c --- /dev/null +++ b/apps/bot/src/finance-commands.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, test } from 'bun:test' +import type { FinanceCommandService } from '@household/application' +import { Money, instantFromIso } from '@household/domain' +import type { HouseholdConfigurationRepository } from '@household/ports' + +import { createTelegramBot } from './bot' +import { createFinanceCommandsService } from './finance-commands' + +function householdStatusUpdate(languageCode: string) { + return { + update_id: 9100, + message: { + message_id: 10, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup', + title: 'Kojori' + }, + from: { + id: 123456, + is_bot: false, + first_name: 'Stan', + language_code: languageCode + }, + text: '/household_status', + entities: [ + { + offset: 0, + length: 17, + type: 'bot_command' + } + ] + } + } +} + +function createRepository(): HouseholdConfigurationRepository { + return { + registerTelegramHouseholdChat: async () => { + throw new Error('not implemented') + }, + getTelegramHouseholdChat: async () => ({ + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-100123456', + telegramChatType: 'supergroup', + title: 'Kojori', + defaultLocale: 'ru' + }), + getHouseholdChatByHouseholdId: async () => null, + bindHouseholdTopic: async () => { + throw new Error('not implemented') + }, + getHouseholdTopicBinding: async () => null, + findHouseholdTopicByTelegramContext: async () => null, + listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], + upsertHouseholdJoinToken: async () => { + throw new Error('not implemented') + }, + getHouseholdJoinToken: async () => null, + getHouseholdByJoinToken: async () => null, + upsertPendingHouseholdMember: async () => { + throw new Error('not implemented') + }, + getPendingHouseholdMember: async () => null, + findPendingHouseholdMemberByTelegramUserId: async () => null, + ensureHouseholdMember: async () => { + throw new Error('not implemented') + }, + getHouseholdMember: async () => null, + listHouseholdMembers: async () => [], + listHouseholdMembersByTelegramUserId: async () => [ + { + id: 'member-1', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: 'ru', + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: true + } + ], + getHouseholdBillingSettings: async () => ({ + householdId: 'household-1', + settlementCurrency: 'GEL', + rentAmountMinor: 70000n, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async () => { + throw new Error('not implemented') + }, + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async () => { + throw new Error('not implemented') + }, + listPendingHouseholdMembers: async () => [], + approvePendingHouseholdMember: async () => null, + updateHouseholdDefaultLocale: async () => { + throw new Error('not implemented') + }, + updateMemberPreferredLocale: async () => null, + promoteHouseholdAdmin: async () => null, + updateHouseholdMemberRentShareWeight: async () => null + } +} + +function createDashboard(): NonNullable< + Awaited> +> { + return { + period: '2026-03', + currency: 'GEL', + totalDue: Money.fromMajor('400', 'GEL'), + totalPaid: Money.fromMajor('150', 'GEL'), + totalRemaining: Money.fromMajor('250', 'GEL'), + rentSourceAmount: Money.fromMajor('700', 'USD'), + rentDisplayAmount: Money.fromMajor('1890', 'GEL'), + rentFxRateMicros: 2_700_000n, + rentFxEffectiveDate: '2026-03-17', + members: [ + { + memberId: 'member-1', + displayName: 'Стас', + rentShare: Money.fromMajor('200', 'GEL'), + utilityShare: Money.fromMajor('20', 'GEL'), + purchaseOffset: Money.fromMajor('-10', 'GEL'), + netDue: Money.fromMajor('210', 'GEL'), + paid: Money.fromMajor('100', 'GEL'), + remaining: Money.fromMajor('110', 'GEL'), + explanations: [] + }, + { + memberId: 'member-2', + displayName: 'Ион', + rentShare: Money.fromMajor('200', 'GEL'), + utilityShare: Money.fromMajor('20', 'GEL'), + purchaseOffset: Money.fromMajor('10', 'GEL'), + netDue: Money.fromMajor('190', 'GEL'), + paid: Money.fromMajor('50', 'GEL'), + remaining: Money.fromMajor('140', 'GEL'), + explanations: [] + } + ], + ledger: [ + { + id: 'utility-1', + kind: 'utility', + title: 'Electricity', + memberId: 'member-1', + amount: Money.fromMajor('82', 'GEL'), + currency: 'GEL', + displayAmount: Money.fromMajor('82', 'GEL'), + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Стас', + occurredAt: instantFromIso('2026-03-10T12:00:00.000Z').toString(), + paymentKind: null + }, + { + id: 'purchase-1', + kind: 'purchase', + title: 'Туалетная бумага', + memberId: 'member-1', + amount: Money.fromMajor('30', 'GEL'), + currency: 'GEL', + displayAmount: Money.fromMajor('30', 'GEL'), + displayCurrency: 'GEL', + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Стас', + occurredAt: instantFromIso('2026-03-09T12:00:00.000Z').toString(), + paymentKind: null + } + ] + } +} + +function createFinanceService(): FinanceCommandService { + return { + getMemberByTelegramUserId: async (telegramUserId) => + telegramUserId === '123456' + ? { + id: 'member-1', + telegramUserId, + displayName: 'Стас', + rentShareWeight: 1, + isAdmin: true + } + : null, + getOpenCycle: async () => ({ + id: 'cycle-1', + period: '2026-03', + currency: 'GEL' + }), + 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 () => createDashboard(), + generateStatement: async () => null + } +} + +describe('createFinanceCommandsService', () => { + test('replies with a compact localized household status summary', async () => { + const repository = createRepository() + const financeService = createFinanceService() + const bot = createTelegramBot('000000:test-token', undefined, repository) + createFinanceCommandsService({ + householdConfigurationRepository: repository, + financeServiceForHousehold: () => financeService + }).register(bot) + + 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 + } + + const calls: Array<{ method: string; payload: unknown }> = [] + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup' + }, + text: 'ok' + } + } as never + }) + + await bot.handleUpdate(householdStatusUpdate('ru') as never) + + const payload = calls[0]?.payload as { text?: string } | undefined + expect(payload?.text).toContain('Статус дома за 2026-03') + expect(payload?.text).toContain('Аренда: 700.00 USD (~1890.00 GEL)') + expect(payload?.text).toContain('Коммуналка: 82.00 GEL') + expect(payload?.text).toContain('Общие покупки: 30.00 GEL') + expect(payload?.text).toContain( + '- Стас: должен 210.00 GEL, оплачено 100.00 GEL, осталось 110.00 GEL' + ) + }) +}) diff --git a/apps/bot/src/finance-commands.ts b/apps/bot/src/finance-commands.ts index bf515a3..0f2491b 100644 --- a/apps/bot/src/finance-commands.ts +++ b/apps/bot/src/finance-commands.ts @@ -1,4 +1,5 @@ import type { FinanceCommandService } from '@household/application' +import { Money } from '@household/domain' import type { HouseholdConfigurationRepository } from '@household/ports' import type { Bot, Context } from 'grammy' @@ -39,6 +40,54 @@ export function createFinanceCommandsService(options: { ].join('\n') } + function formatHouseholdStatus( + locale: Parameters[0], + dashboard: NonNullable>> + ): string { + const t = getBotTranslations(locale).finance + const utilityTotal = dashboard.ledger + .filter((entry) => entry.kind === 'utility') + .reduce((sum, entry) => sum.add(entry.displayAmount), Money.zero(dashboard.currency)) + const purchaseTotal = dashboard.ledger + .filter((entry) => entry.kind === 'purchase') + .reduce((sum, entry) => sum.add(entry.displayAmount), Money.zero(dashboard.currency)) + + const rentLine = + dashboard.rentSourceAmount.currency === dashboard.rentDisplayAmount.currency + ? t.householdStatusRentDirect( + dashboard.rentDisplayAmount.toMajorString(), + dashboard.currency + ) + : t.householdStatusRentConverted( + dashboard.rentSourceAmount.toMajorString(), + dashboard.rentSourceAmount.currency, + dashboard.rentDisplayAmount.toMajorString(), + dashboard.currency + ) + + return [ + t.householdStatusTitle(dashboard.period), + rentLine, + t.householdStatusUtilities(utilityTotal.toMajorString(), dashboard.currency), + t.householdStatusPurchases(purchaseTotal.toMajorString(), dashboard.currency), + ...dashboard.members.map((member) => + t.householdStatusMember( + member.displayName, + member.netDue.toMajorString(), + member.paid.toMajorString(), + member.remaining.toMajorString(), + dashboard.currency + ) + ), + t.householdStatusTotals( + dashboard.totalDue.toMajorString(), + dashboard.totalPaid.toMajorString(), + dashboard.totalRemaining.toMajorString(), + dashboard.currency + ) + ].join('\n') + } + async function resolveGroupFinanceService(ctx: Context): Promise<{ service: FinanceCommandService householdId: string @@ -117,6 +166,30 @@ export function createFinanceCommandsService(options: { } function register(bot: Bot): void { + bot.command('household_status', async (ctx) => { + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).finance + const resolved = await requireMember(ctx) + if (!resolved) { + return + } + + try { + const dashboard = await resolved.service.generateDashboard(commandArgs(ctx)[0]) + if (!dashboard) { + await ctx.reply(t.noStatementCycle) + return + } + + await ctx.reply(formatHouseholdStatus(locale, dashboard)) + } catch (error) { + await ctx.reply(t.statementFailed((error as Error).message)) + } + }) + bot.command('cycle_open', 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 8d5e1a5..7bb38ae 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -22,9 +22,6 @@ export const enBotTranslations: BotTranslationCatalog = { groupHeading: 'Group chat:', groupAdminsHeading: 'Group admins:' }, - bot: { - householdStatusPending: 'Household status is not connected yet. Data integration is next.' - }, common: { unableToIdentifySender: 'Unable to identify sender for this command.', useHelp: 'Send /help to see available commands.' @@ -144,6 +141,16 @@ export const enBotTranslations: BotTranslationCatalog = { `Payment recorded: ${kind === 'rent' ? 'rent' : 'utilities'} ${amount} ${currency} for ${period}`, paymentAddFailed: (message) => `Failed to record payment: ${message}`, noStatementCycle: 'No cycle found for statement.', + householdStatusTitle: (period) => `Household status for ${period}`, + householdStatusRentDirect: (amount, currency) => `Rent: ${amount} ${currency}`, + householdStatusRentConverted: (sourceAmount, sourceCurrency, displayAmount, displayCurrency) => + `Rent: ${sourceAmount} ${sourceCurrency} (~${displayAmount} ${displayCurrency})`, + householdStatusUtilities: (amount, currency) => `Utilities: ${amount} ${currency}`, + householdStatusPurchases: (amount, currency) => `Shared purchases: ${amount} ${currency}`, + householdStatusMember: (displayName, due, paid, remaining, currency) => + `- ${displayName}: due ${due} ${currency}, paid ${paid} ${currency}, remaining ${remaining} ${currency}`, + householdStatusTotals: (due, paid, remaining, currency) => + `Totals: due ${due} ${currency}, paid ${paid} ${currency}, remaining ${remaining} ${currency}`, statementTitle: (period) => `Statement for ${period}`, statementLine: (displayName, amount, currency) => `- ${displayName}: ${amount} ${currency}`, statementTotal: (amount, currency) => `Total: ${amount} ${currency}`, diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index d392063..ecd19fa 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -22,9 +22,6 @@ export const ruBotTranslations: BotTranslationCatalog = { groupHeading: 'Группа дома:', groupAdminsHeading: 'Админы группы:' }, - bot: { - householdStatusPending: 'Статус дома пока не подключен. Интеграция данных будет следующей.' - }, common: { unableToIdentifySender: 'Не удалось определить отправителя для этой команды.', useHelp: 'Отправьте /help, чтобы увидеть доступные команды.' @@ -147,6 +144,16 @@ export const ruBotTranslations: BotTranslationCatalog = { `Оплата сохранена: ${kind === 'rent' ? 'аренда' : 'коммуналка'} ${amount} ${currency} за ${period}`, paymentAddFailed: (message) => `Не удалось сохранить оплату: ${message}`, noStatementCycle: 'Для выписки период не найден.', + householdStatusTitle: (period) => `Статус дома за ${period}`, + householdStatusRentDirect: (amount, currency) => `Аренда: ${amount} ${currency}`, + householdStatusRentConverted: (sourceAmount, sourceCurrency, displayAmount, displayCurrency) => + `Аренда: ${sourceAmount} ${sourceCurrency} (~${displayAmount} ${displayCurrency})`, + householdStatusUtilities: (amount, currency) => `Коммуналка: ${amount} ${currency}`, + householdStatusPurchases: (amount, currency) => `Общие покупки: ${amount} ${currency}`, + householdStatusMember: (displayName, due, paid, remaining, currency) => + `- ${displayName}: должен ${due} ${currency}, оплачено ${paid} ${currency}, осталось ${remaining} ${currency}`, + householdStatusTotals: (due, paid, remaining, currency) => + `Итого: должен ${due} ${currency}, оплачено ${paid} ${currency}, осталось ${remaining} ${currency}`, statementTitle: (period) => `Выписка за ${period}`, statementLine: (displayName, amount, currency) => `- ${displayName}: ${amount} ${currency}`, statementTotal: (amount, currency) => `Итого: ${amount} ${currency}`, diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 2a57381..58d800c 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -44,9 +44,6 @@ export interface BotTranslationCatalog { groupHeading: string groupAdminsHeading: string } - bot: { - householdStatusPending: string - } common: { unableToIdentifySender: string useHelp: string @@ -154,6 +151,29 @@ export interface BotTranslationCatalog { ) => string paymentAddFailed: (message: string) => string noStatementCycle: string + householdStatusTitle: (period: string) => string + householdStatusRentDirect: (amount: string, currency: string) => string + householdStatusRentConverted: ( + sourceAmount: string, + sourceCurrency: string, + displayAmount: string, + displayCurrency: string + ) => string + householdStatusUtilities: (amount: string, currency: string) => string + householdStatusPurchases: (amount: string, currency: string) => string + householdStatusMember: ( + displayName: string, + due: string, + paid: string, + remaining: string, + currency: string + ) => string + householdStatusTotals: ( + due: string, + paid: string, + remaining: string, + currency: string + ) => string statementTitle: (period: string) => string statementLine: (displayName: string, amount: string, currency: string) => string statementTotal: (amount: string, currency: string) => string