From 814cccb5de657ef58b50143f7daed53bd635f872 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 18:33:28 +0000 Subject: [PATCH 1/8] feat: send receipt scan summary to chat when confirmed via Mini App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user scans and confirms a receipt through the Mini App, a brief summary is now sent to the group chat showing categories, amounts, and total — matching the behavior of the bot photo handler flow. https://claude.ai/code/session_014kP9qmxnhAa8HJ8P93Krik --- src/web/miniapp-api.ts | 55 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/web/miniapp-api.ts b/src/web/miniapp-api.ts index ee887f5f..a171f81a 100644 --- a/src/web/miniapp-api.ts +++ b/src/web/miniapp-api.ts @@ -1,15 +1,17 @@ // Mini App API handler: HMAC initData validation and routing for /api/* endpoints import { createHmac } from 'node:crypto'; import sharp from 'sharp'; +import { getCategoryEmoji } from '../config/category-emojis.ts'; import { type CurrencyCode, isValidCurrencyCode } from '../config/constants.ts'; import { env } from '../config/env.ts'; import { database } from '../database/index.ts'; -import { sendDocumentDirect } from '../services/bank/telegram-sender.ts'; -import { convertCurrency } from '../services/currency/converter.ts'; +import { sendDocumentDirect, sendMessage, withChatContext } from '../services/bank/telegram-sender.ts'; +import { convertCurrency, formatAmount } from '../services/currency/converter.ts'; import { getExpenseRecorder } from '../services/expense-recorder.ts'; import { extractExpensesFromReceipt } from '../services/receipt/ai-extractor.ts'; import { extractTextFromImageBuffer } from '../services/receipt/ocr-extractor.ts'; import { fetchReceiptData } from '../services/receipt/receipt-fetcher.ts'; +import { escapeHtml } from '../utils/html.ts'; import { createLogger } from '../utils/logger.ts'; import { emitForGroup, subscribeGroup } from './sse-emitter.ts'; @@ -513,6 +515,11 @@ export async function handleMiniAppRequest( logger.warn({ err: emitError }, 'SSE emit failed, continuing'); } + // Send brief summary to Telegram chat + notifyReceiptConfirmed(ctx.internalGroupId, expenseInputs).catch( + (e) => logger.warn({ err: e }, 'Receipt chat notification failed'), + ); + logger.info({ userId: ctx.userId, created }, 'Receipt confirm completed'); return new Response(JSON.stringify({ created }), { @@ -840,6 +847,50 @@ export async function handleMiniAppRequest( return errorResponse(404, 'Not Found', 'NOT_FOUND', corsHeaders); } +/** Send brief receipt summary to the group chat after mini app confirmation */ +async function notifyReceiptConfirmed( + internalGroupId: number, + items: { name?: unknown; total?: unknown; category?: unknown; currency?: unknown }[], +): Promise { + const group = database.groups.findById(internalGroupId); + if (!group) return; + + // Aggregate by category + const byCategory = new Map(); + let grandTotal = 0; + let currency: CurrencyCode = 'EUR'; + + for (const item of items) { + const cat = item.category as string; + const total = item.total as number; + currency = item.currency as CurrencyCode; + grandTotal += total; + + const existing = byCategory.get(cat); + if (existing) { + existing.total += total; + existing.count++; + } else { + byCategory.set(cat, { total, count: 1 }); + } + } + + // Build message + const lines: string[] = [`🧾 Чек из Mini App (${items.length} поз.):`]; + + for (const [cat, { total }] of byCategory) { + const emoji = getCategoryEmoji(cat); + lines.push(`${emoji} ${escapeHtml(cat)}: ${formatAmount(total, currency)}`); + } + + lines.push(`\n💰 Итого: ${formatAmount(grandTotal, currency)}`); + + const threadId = group.active_topic_id ?? null; + await withChatContext(group.telegram_group_id, threadId, async () => { + await sendMessage(lines.join('\n')); + }); +} + /** Send scan failure report to admin with detailed log file */ async function notifyScanFailure(source: string, input: string, error: unknown): Promise { const adminChatId = env.BOT_ADMIN_CHAT_ID; From a0c30c0f6cc57e31af0c48c757581341b517a6f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 19:14:20 +0000 Subject: [PATCH 2/8] refactor(receipt): unify bot and Mini App summary with expandable item list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared buildReceiptSummaryMessage helper used by both receipt paths (bot photo handler and Mini App confirm endpoint) — eliminates divergent notification code between the two flows - Add expandable
with full item list (name, qty×price=total) to the chat message - Replace fire-and-forget notification with awaited call wrapped in try/catch so errors surface synchronously without failing confirm - Add pluralize for "позиция/позиции/позиций" - Remove duplicate getCategoryEmoji from receipt-summarizer.ts; the universal function in config/category-emojis.ts is now used everywhere - Expand universal CATEGORY_EMOJIS map with ~100 additional emojis covering food, transport, health, finance, work, and more - Add 16 unit tests covering pluralize, aggregation, truncation, HTML escaping, and emoji fallback https://claude.ai/code/session_014kP9qmxnhAa8HJ8P93Krik --- src/bot/services/expense-saver.ts | 24 ++- src/config/category-emojis.ts | 151 ++++++++++++++++++ .../receipt/receipt-summarizer.test.ts | 3 +- src/services/receipt/receipt-summarizer.ts | 36 +---- src/services/receipt/summary-message.test.ts | 150 +++++++++++++++++ src/services/receipt/summary-message.ts | 64 ++++++++ src/web/miniapp-api.ts | 77 +++++---- 7 files changed, 425 insertions(+), 80 deletions(-) create mode 100644 src/services/receipt/summary-message.test.ts create mode 100644 src/services/receipt/summary-message.ts diff --git a/src/bot/services/expense-saver.ts b/src/bot/services/expense-saver.ts index 806c586f..9ae8a450 100644 --- a/src/bot/services/expense-saver.ts +++ b/src/bot/services/expense-saver.ts @@ -6,6 +6,10 @@ import { database } from '../../database'; import { sendMessage } from '../../services/bank/telegram-sender'; import { convertCurrency, formatAmount, getExchangeRate } from '../../services/currency/converter'; import { googleConn } from '../../services/google/sheets'; +import { + buildReceiptSummaryMessage, + type ReceiptSummaryItem, +} from '../../services/receipt/summary-message'; import { createLogger } from '../../utils/logger.ts'; import { buildMiniAppUrl } from '../../utils/miniapp-url'; import { silentSyncBudgets } from './budget-sync'; @@ -286,9 +290,17 @@ export async function saveReceiptExpenses( // Delete all processed receipt items (confirmed + skipped) database.receiptItems.deleteProcessedByPhotoQueueId(photoQueueId); - // Notify user - const totalItems = confirmedItems.length; - const totalCategories = itemsByCategory.size; + // Notify user using the shared summary builder + const summaryItems: ReceiptSummaryItem[] = confirmedItems + .filter((item) => item.confirmed_category !== null) + .map((item) => ({ + name: item.name_ru, + qty: item.quantity, + price: item.price, + total: item.total, + category: item.confirmed_category as string, + currency: item.currency, + })); const miniAppUrl = buildMiniAppUrl('scanner', group.telegram_group_id); const scanButton = miniAppUrl @@ -296,9 +308,11 @@ export async function saveReceiptExpenses( : undefined; await sendMessage( - `✅ Чек обработан!\n📦 Товаров: ${totalItems}\n📂 Категорий: ${totalCategories}`, + buildReceiptSummaryMessage(summaryItems), scanButton ? { reply_markup: scanButton } : undefined, ); - logger.info(`[RECEIPT] Saved ${totalItems} items from receipt (${totalCategories} categories)`); + logger.info( + `[RECEIPT] Saved ${confirmedItems.length} items from receipt (${itemsByCategory.size} categories)`, + ); } diff --git a/src/config/category-emojis.ts b/src/config/category-emojis.ts index 02b41cc2..9f780397 100644 --- a/src/config/category-emojis.ts +++ b/src/config/category-emojis.ts @@ -8,105 +8,256 @@ export const CATEGORY_EMOJIS: Record = { Продукты: '🛒', Ресторан: '🍽️', Кафе: '☕', + Бар: '🍻', + Кофе: '☕', + Завтрак: '🥐', + Обед: '🍲', + Ужин: '🍽️', + Доставка: '🛵', + Фастфуд: '🍟', + Сладости: '🍬', + Снеки: '🍿', + Фрукты: '🍎', + Овощи: '🥦', + Мясо: '🥩', + Рыба: '🐟', + Напитки: '🥤', + Вода: '💧', + Алкоголь: '🍷', Food: '🍔', Groceries: '🛒', Restaurant: '🍽️', Cafe: '☕', + Bar: '🍻', + Coffee: '☕', + Breakfast: '🥐', + Lunch: '🍲', + Dinner: '🍽️', + Delivery: '🛵', + FastFood: '🍟', + Sweets: '🍬', + Snacks: '🍿', + Fruits: '🍎', + Vegetables: '🥦', + Meat: '🥩', + Fish: '🐟', + Drinks: '🥤', + Water: '💧', + Alcohol: '🍷', // Transportation Транспорт: '🚗', Такси: '🚕', Бензин: '⛽', Парковка: '🅿️', + Авто: '🚗', + Машина: '🚗', + Автосервис: '🔧', + Метро: '🚇', + Автобус: '🚌', + Поезд: '🚆', + Каршеринг: '🚗', + Самокат: '🛴', + Велосипед: '🚲', Transport: '🚗', Taxi: '🚕', Gas: '⛽', Parking: '🅿️', + Car: '🚗', + CarService: '🔧', + Metro: '🚇', + Bus: '🚌', + Train: '🚆', + Carsharing: '🚗', + Scooter: '🛴', + Bike: '🚲', // Entertainment Развлечения: '🎮', Кино: '🎬', Игры: '🎯', + Театр: '🎭', + Концерт: '🎤', + Музей: '🏛️', + Выставка: '🖼️', + Клуб: '🪩', + Стриминг: '📺', + Музыка: '🎵', + Хобби: '🎨', Entertainment: '🎮', Movies: '🎬', Games: '🎯', + Theatre: '🎭', + Concert: '🎤', + Museum: '🏛️', + Exhibition: '🖼️', + Club: '🪩', + Streaming: '📺', + Music: '🎵', + Hobby: '🎨', // Health Здоровье: '💊', Аптека: '💊', Врач: '⚕️', Спорт: '⚽', + Фитнес: '💪', + Йога: '🧘', + Стоматолог: '🦷', + Анализы: '🧪', Health: '💊', Pharmacy: '💊', Doctor: '⚕️', Sport: '⚽', Gym: '💪', + Fitness: '💪', + Yoga: '🧘', + Dentist: '🦷', + Lab: '🧪', // Shopping Одежда: '👕', Обувь: '👟', Покупки: '🛍️', + Аксессуары: '👜', + Украшения: '💍', + Магазин: '🏬', Clothes: '👕', Shoes: '👟', Shopping: '🛍️', + Accessories: '👜', + Jewelry: '💍', + Store: '🏬', // Housing Жилье: '🏠', + Дом: '🏠', Аренда: '🏡', Коммуналка: '🔌', Ремонт: '🔧', + Мебель: '🛋️', + 'Бытовая техника': '📺', + Хозтовары: '🧹', + 'Бытовая химия': '🧴', Housing: '🏠', + Home: '🏠', Rent: '🏡', Utilities: '🔌', Repair: '🔧', + Furniture: '🛋️', + Appliances: '📺', + Household: '🧹', // Personal Личное: '👤', Подарки: '🎁', Красота: '💄', + Парикмахер: '💇', + Салон: '💅', + СПА: '🧖', Personal: '👤', Gifts: '🎁', Beauty: '💄', + Hairdresser: '💇', + Salon: '💅', + Spa: '🧖', // Education Образование: '📚', Книги: '📖', Курсы: '🎓', + Школа: '🏫', + Университет: '🎓', + 'Детский сад': '🧸', + Канцелярия: '📝', Education: '📚', Books: '📖', Courses: '🎓', + School: '🏫', + University: '🎓', + Kindergarten: '🧸', + Stationery: '📝', // Technology Техника: '💻', Гаджеты: '📱', Софт: '💿', + Электроника: '🔌', + Подписки: '🔄', + Интернет: '🌐', + Связь: '📞', + Телефон: '📱', + Мобильная: '📱', Tech: '💻', Gadgets: '📱', Software: '💿', + Electronics: '🔌', + Subscriptions: '🔄', + Internet: '🌐', + Phone: '📱', + Mobile: '📱', // Travel Путешествия: '✈️', Отель: '🏨', Билеты: '🎫', + Отпуск: '🏖️', + Экскурсия: '🗺️', + Сувениры: '🎁', Travel: '✈️', Hotel: '🏨', Tickets: '🎫', + Vacation: '🏖️', + Tour: '🗺️', + Souvenirs: '🎁', // Family & Kids Дети: '👶', Семья: '👨‍👩‍👧', + Игрушки: '🧸', Kids: '👶', Family: '👨‍👩‍👧', + Toys: '🧸', // Pets Питомцы: '🐾', + Ветеринар: '🐾', + 'Корм для питомцев': '🐾', Pets: '🐾', + Vet: '🐾', + PetFood: '🐾', + + // Finance + Налоги: '🧾', + Страховка: '🛡️', + Инвестиции: '📈', + Банк: '🏦', + Комиссия: '🏦', + Кредит: '💳', + Штрафы: '🚨', + Благотворительность: '❤️', + Taxes: '🧾', + Insurance: '🛡️', + Investments: '📈', + Bank: '🏦', + Fee: '🏦', + Credit: '💳', + Fines: '🚨', + Charity: '❤️', + + // Work + Работа: '💼', + Офис: '💼', + Work: '💼', + Office: '💼', // Other Другое: '📦', Разное: '📦', + 'Без категории': '💰', Other: '📦', Misc: '📦', + Uncategorized: '💰', }; /** diff --git a/src/services/receipt/receipt-summarizer.test.ts b/src/services/receipt/receipt-summarizer.test.ts index a81521b2..b69ddb3e 100644 --- a/src/services/receipt/receipt-summarizer.test.ts +++ b/src/services/receipt/receipt-summarizer.test.ts @@ -276,7 +276,8 @@ describe('formatSummaryMessage', () => { currency: 'EUR', }; const msg = formatSummaryMessage(summary, 1); - expect(msg).toContain('📦'); + // Universal getCategoryEmoji default for unknown categories + expect(msg).toContain('💰'); }); }); }); diff --git a/src/services/receipt/receipt-summarizer.ts b/src/services/receipt/receipt-summarizer.ts index 44fe6503..502db8c5 100644 --- a/src/services/receipt/receipt-summarizer.ts +++ b/src/services/receipt/receipt-summarizer.ts @@ -1,5 +1,6 @@ /** Receipt summarizer — uses Hugging Face to generate a human-readable summary of receipt items */ import { InferenceClient } from '@huggingface/inference'; +import { getCategoryEmoji } from '../../config/category-emojis'; import { BASE_CURRENCY, type CurrencyCode } from '../../config/constants'; import { env } from '../../config/env'; import type { ReceiptItem } from '../../database/types'; @@ -44,41 +45,6 @@ export interface CorrectionEntry { result: string; } -/** - * Category emoji map - */ -const CATEGORY_EMOJIS: Record = { - Еда: '🍔', - Продукты: '🍔', - Дом: '🏠', - Хозтовары: '🧹', - Транспорт: '🚗', - Здоровье: '💊', - Развлечения: '🎬', - Одежда: '👕', - Техника: '📱', - Разное: '🛒', -}; - -/** - * Get emoji for category - */ -function getCategoryEmoji(category: string): string { - // Try exact match - if (CATEGORY_EMOJIS[category]) { - return CATEGORY_EMOJIS[category]; - } - - // Try partial match - for (const [key, emoji] of Object.entries(CATEGORY_EMOJIS)) { - if (category.toLowerCase().includes(key.toLowerCase())) { - return emoji; - } - } - - return '📦'; -} - /** * Build summary from receipt items (simple algorithm, no AI) */ diff --git a/src/services/receipt/summary-message.test.ts b/src/services/receipt/summary-message.test.ts new file mode 100644 index 00000000..e4ad91c8 --- /dev/null +++ b/src/services/receipt/summary-message.test.ts @@ -0,0 +1,150 @@ +// Tests for buildReceiptSummaryMessage — the shared summary builder used by +// both the bot photo handler flow and the Mini App confirm endpoint. +import { describe, expect, it } from 'bun:test'; +import type { CurrencyCode } from '../../config/constants'; +import { buildReceiptSummaryMessage, type ReceiptSummaryItem } from './summary-message'; + +function item(overrides: Partial = {}): ReceiptSummaryItem { + return { + name: overrides.name ?? 'Товар', + qty: overrides.qty ?? 1, + price: overrides.price ?? 100, + total: overrides.total ?? 100, + category: overrides.category ?? 'Продукты', + currency: overrides.currency ?? ('RSD' as CurrencyCode), + }; +} + +describe('buildReceiptSummaryMessage', () => { + it('returns empty string for no items', () => { + expect(buildReceiptSummaryMessage([])).toBe(''); + }); + + it('uses singular "позиция" for count=1', () => { + const msg = buildReceiptSummaryMessage([item()]); + expect(msg).toContain('1 позиция'); + }); + + it('uses few form "позиции" for count=2-4', () => { + const msg = buildReceiptSummaryMessage([item(), item(), item()]); + expect(msg).toContain('3 позиции'); + }); + + it('uses many form "позиций" for count=5+', () => { + const items = Array.from({ length: 7 }, () => item()); + const msg = buildReceiptSummaryMessage(items); + expect(msg).toContain('7 позиций'); + }); + + it('uses many form "позиций" for count=11 (teen special case)', () => { + const items = Array.from({ length: 11 }, () => item()); + const msg = buildReceiptSummaryMessage(items); + expect(msg).toContain('11 позиций'); + }); + + it('aggregates totals by category', () => { + const items = [ + item({ category: 'Продукты', total: 100 }), + item({ category: 'Продукты', total: 250 }), + item({ category: 'Здоровье', total: 400 }), + ]; + const msg = buildReceiptSummaryMessage(items); + + // Продукты: 100 + 250 = 350 + expect(msg).toMatch(/Продукты.*350/); + // Здоровье: 400 + expect(msg).toMatch(/Здоровье.*400/); + }); + + it('shows grand total', () => { + const items = [item({ total: 100 }), item({ total: 200 }), item({ total: 300 })]; + const msg = buildReceiptSummaryMessage(items); + expect(msg).toMatch(/Итого.*600/); + }); + + it('wraps full item list in expandable blockquote', () => { + const msg = buildReceiptSummaryMessage([item({ name: 'Молоко' }), item({ name: 'Хлеб' })]); + expect(msg).toContain('
'); + expect(msg).toContain('
'); + expect(msg).toContain('Молоко'); + expect(msg).toContain('Хлеб'); + }); + + it('includes qty × price = total format for each item', () => { + const msg = buildReceiptSummaryMessage([ + item({ name: 'Молоко', qty: 2, price: 150, total: 300 }), + ]); + // Should contain "2×150 RSD = 300 RSD" (approximately) + expect(msg).toMatch(/Молоко.*2×.*150.*300/); + }); + + it('emits per-category emoji in the header', () => { + const msg = buildReceiptSummaryMessage([item({ category: 'Продукты' })]); + // Продукты → 🛒 + expect(msg).toContain('🛒'); + }); + + it('falls back to default emoji for unknown category', () => { + const msg = buildReceiptSummaryMessage([item({ category: 'ВымышленнаяКатегория' })]); + // Default emoji for unknown category is 💰 (from getCategoryEmoji) + expect(msg).toContain('💰'); + }); + + it('escapes HTML in category and item names', () => { + const msg = buildReceiptSummaryMessage([ + item({ name: '', category: 'A & B' }), + ]); + expect(msg).not.toContain('', category: 'A & B' }), ]); expect(msg).not.toContain('', category: 'A & B' }), - ]); + const msg = await buildReceiptSummaryMessage( + [item({ name: '', category: 'A & B' })], + 1, + ); expect(msg).not.toContain('