Files
household-bot/apps/bot/src/finance-commands.test.ts
whekin 488a488137 feat: add quick payment action and improve copy button UX
Mini App Home Screen:
- Add 'Record Payment' button to utilities and rent period cards
- Pre-fill payment amount with member's share (rentShare/utilityShare)
- Modal dialog with amount input and currency display
- Toast notifications for copy and payment success/failure feedback

Copy Button Improvements:
- Increase spacing between icon and text (4px → 8px)
- Add hover background and padding for better touch target
- Green background highlight when copied (in addition to icon color change)
- Toast notification appears when copying any value

Backend:
- Add /api/miniapp/payments/add endpoint for quick payments
- Payment notifications sent to 'reminders' topic in Telegram
- Include member name, payment type, amount, and period in notification

Files:
- New: apps/miniapp/src/components/ui/toast.tsx
- Modified: apps/miniapp/src/routes/home.tsx, apps/miniapp/src/index.css,
  apps/miniapp/src/theme.css, apps/miniapp/src/i18n.ts,
  apps/bot/src/miniapp-billing.ts, apps/bot/src/server.ts

Quality Gates:  format, lint, typecheck, build, test

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-14 08:51:53 +04:00

314 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 () => [],
clearHouseholdTopicBindings: 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',
status: 'active',
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',
rentPaymentDestinations: null
}),
updateHouseholdBillingSettings: async () => {
throw new Error('not implemented')
},
listHouseholdUtilityCategories: async () => [],
upsertHouseholdUtilityCategory: async () => {
throw new Error('not implemented')
},
listPendingHouseholdMembers: async () => [],
approvePendingHouseholdMember: async () => null,
rejectPendingHouseholdMember: async () => false,
updateHouseholdDefaultLocale: async () => {
throw new Error('not implemented')
},
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
upsertHouseholdMemberAbsencePolicy: async () => null
}
}
function createDashboard(): NonNullable<
Awaited<ReturnType<FinanceCommandService['generateDashboard']>>
> {
return {
period: '2026-03',
currency: 'GEL',
timezone: 'Asia/Tbilisi',
rentWarningDay: 17,
rentDueDay: 20,
utilitiesReminderDay: 3,
utilitiesDueDay: 4,
paymentBalanceAdjustmentPolicy: 'utilities',
rentPaymentDestinations: null,
totalDue: Money.fromMajor('400', 'GEL'),
totalPaid: Money.fromMajor('100', 'GEL'),
totalRemaining: Money.fromMajor('300', '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.zero('GEL'),
remaining: Money.fromMajor('190', '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,
addPurchase: async () => ({
purchaseId: 'test-purchase',
amount: Money.fromMinor(0n, 'GEL'),
currency: 'GEL'
}),
updatePayment: async () => null,
deletePayment: async () => false,
generateDashboard: async () => createDashboard(),
generateStatement: async () => null
}
}
describe('createFinanceCommandsService', () => {
test('replies with a clearer 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')
expect(payload?.text).toContain('\n\nНачисления\n')
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('Срок оплаты аренды: до 20 марта')
expect(payload?.text).toContain('Расчёты')
expect(payload?.text).toContain('Общий баланс: 400.00 GEL')
expect(payload?.text).toContain('Уже оплачено: 100.00 GEL')
expect(payload?.text).toContain('Осталось оплатить: 300.00 GEL')
expect(payload?.text).toContain('Участники')
expect(payload?.text).toContain('- Ион: остаток 190.00 GEL')
expect(payload?.text).toContain('- Стас: остаток 110.00 GEL (210.00 баланс, 100.00 оплачено)')
expect(payload?.text).not.toContain('- Ион: остаток 190.00 GEL (')
})
})