diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index 8b69f9e..a93a71e 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -1417,7 +1417,7 @@ Confirm or cancel below.`, }) }) - test('uses topic processor for classification and assistant for response', async () => { + test('does not hand finance-topic helper routing over to the generic assistant', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] let assistantCalls = 0 @@ -1493,17 +1493,8 @@ Confirm or cancel below.`, await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never) expect(processorCalls).toBe(1) - expect(assistantCalls).toBe(1) - expect(calls).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - method: 'sendMessage', - payload: expect.objectContaining({ - text: 'Still here.' - }) - }) - ]) - ) + expect(assistantCalls).toBe(0) + expect(calls).toHaveLength(0) }) test('stays silent for regular group chatter when the bot is not addressed', async () => { diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index 4fb0f9a..d621314 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -1132,6 +1132,7 @@ export function registerDmAssistant(options: { ? getBotTranslations(locale).purchase.proposal( formatPurchaseSummary(locale, purchaseResult), null, + null, null ) : purchaseResult.status === 'clarification_needed' @@ -1502,6 +1503,7 @@ export function registerDmAssistant(options: { const fallbackText = getBotTranslations(locale).purchase.proposal( formatPurchaseSummary(locale, purchaseResult), null, + null, null ) const purchaseText = await composeAssistantReplyText({ diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index 81bbdf7..134493d 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -875,8 +875,7 @@ describe('registerHouseholdSetupCommands', () => { } }) expect(sendPayload.text).toContain('New household! **Kojori House** is ready.') - expect(sendPayload.text).toContain('Current setup progress: 0/5') - expect(sendPayload.text).toContain('0/5') + expect(sendPayload.text).toContain('Current setup progress: 0/4') expect(sendPayload.text).toContain('⚪ Purchases') expect(sendPayload.text).toContain('⚪ Payments') // Check that join household button exists diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index d05e982..1414ffd 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -22,7 +22,6 @@ const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:' const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:' const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [ - 'chat', 'purchase', 'feedback', 'reminders', @@ -1104,7 +1103,7 @@ export function registerHouseholdSetupCommands(options: { ) options.bot.callbackQuery( - /^bind_topic:(chat|purchase|feedback|reminders|payments):(\d+)$/, + /^bind_topic:(purchase|feedback|reminders|payments):(\d+)$/, 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 c1af013..5e93fb9 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -276,8 +276,13 @@ export const enBotTranslations: BotTranslationCatalog = { purchase: { sharedPurchaseFallback: 'shared purchase', processing: 'Checking that purchase...', - proposal: (summary: string, calculationNote: string | null, participants: string | null) => - `I think this shared purchase was: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`, + proposal: ( + summary: string, + payer: string | null, + calculationNote: string | null, + participants: string | null + ) => + `I think this shared purchase was: ${summary}.${payer ? `\n${payer}` : ''}${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`, calculatedAmountNote: (explanation: string | null) => explanation ? `I calculated the total as ${explanation}. Is that right?` @@ -295,6 +300,12 @@ export const enBotTranslations: BotTranslationCatalog = { participantExcluded: (displayName) => `- ${displayName} (excluded)`, participantToggleIncluded: (displayName) => `✅ ${displayName}`, participantToggleExcluded: (displayName) => `⬜ ${displayName}`, + payerHeading: 'Paid by:', + payerSelected: (displayName) => `Paid by: ${displayName}`, + payerQuestion: 'Who actually bought this?', + payerFallbackQuestion: 'I could not tell who bought this. Pick the payer below.', + payerButton: (displayName) => `${displayName} paid`, + payerSelectedToast: (displayName) => `Set payer to ${displayName}.`, confirmButton: 'Confirm', calculatedConfirmButton: 'Looks right', calculatedFixAmountButton: 'Fix amount', diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index b626f3f..8cfbd0f 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -280,8 +280,13 @@ export const ruBotTranslations: BotTranslationCatalog = { purchase: { sharedPurchaseFallback: 'общая покупка', processing: 'Проверяю покупку...', - proposal: (summary: string, calculationNote: string | null, participants: string | null) => - `Похоже, это общая покупка: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`, + proposal: ( + summary: string, + payer: string | null, + calculationNote: string | null, + participants: string | null + ) => + `Похоже, это общая покупка: ${summary}.${payer ? `\n${payer}` : ''}${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`, calculatedAmountNote: (explanation: string | null) => explanation ? `Я посчитал итог как ${explanation}. Всё верно?` @@ -299,6 +304,12 @@ export const ruBotTranslations: BotTranslationCatalog = { participantExcluded: (displayName) => `- ${displayName} (не участвует)`, participantToggleIncluded: (displayName) => `✅ ${displayName}`, participantToggleExcluded: (displayName) => `⬜ ${displayName}`, + payerHeading: 'Кто оплатил:', + payerSelected: (displayName) => `Оплатил: ${displayName}`, + payerQuestion: 'Кто именно это купил?', + payerFallbackQuestion: 'Не понял, кто именно это купил. Выберите человека ниже.', + payerButton: (displayName) => `Оплатил ${displayName}`, + payerSelectedToast: (displayName) => `Записал покупателя: ${displayName}.`, confirmButton: 'Подтвердить', calculatedConfirmButton: 'Верно', calculatedFixAmountButton: 'Исправить сумму', diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index febf58d..9e00d84 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -264,6 +264,7 @@ export interface BotTranslationCatalog { processing: string proposal: ( summary: string, + payer: string | null, calculationNote: string | null, participants: string | null ) => string @@ -279,6 +280,12 @@ export interface BotTranslationCatalog { participantExcluded: (displayName: string) => string participantToggleIncluded: (displayName: string) => string participantToggleExcluded: (displayName: string) => string + payerHeading: string + payerSelected: (displayName: string) => string + payerQuestion: string + payerFallbackQuestion: string + payerButton: (displayName: string) => string + payerSelectedToast: (displayName: string) => string confirmButton: string calculatedConfirmButton: string calculatedFixAmountButton: string diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index ccc8fc5..e7e521c 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -15,10 +15,29 @@ import type { import { createMiniAppDashboardHandler } from './miniapp-dashboard' import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' +function expectedCurrentCyclePeriod(timezone: string, rentDueDay: number): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).formatToParts(new Date()) + const year = Number(parts.find((part) => part.type === 'year')?.value ?? '0') + const month = Number(parts.find((part) => part.type === 'month')?.value ?? '1') + const day = Number(parts.find((part) => part.type === 'day')?.value ?? '1') + const carryMonth = day > rentDueDay ? month + 1 : month + const normalizedYear = carryMonth > 12 ? year + 1 : year + const normalizedMonth = carryMonth > 12 ? 1 : carryMonth + + return `${normalizedYear}-${String(normalizedMonth).padStart(2, '0')}` +} + function repository( member: Awaited> ): FinanceRepository { - const cycle = { + let cycle: Awaited> extends infer T + ? Exclude + : never = { id: 'cycle-1', period: '2026-03', currency: 'GEL' as const @@ -38,7 +57,13 @@ function repository( getOpenCycle: async () => cycle, getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null), getLatestCycle: async () => cycle, - openCycle: async () => {}, + openCycle: async (period, currency) => { + cycle = { + id: 'opened-cycle', + period, + currency + } + }, closeCycle: async () => {}, saveRentRule: async () => {}, getCycleExchangeRate: async () => null, @@ -326,7 +351,7 @@ describe('createMiniAppDashboardHandler', () => { ok: true, authorized: true, dashboard: { - period: '2026-03', + period: expectedCurrentCyclePeriod('Asia/Tbilisi', 20), currency: 'GEL', paymentBalanceAdjustmentPolicy: 'utilities', totalDueMajor: '2010.00', diff --git a/apps/bot/src/openai-purchase-interpreter.ts b/apps/bot/src/openai-purchase-interpreter.ts index abe5a1c..090a197 100644 --- a/apps/bot/src/openai-purchase-interpreter.ts +++ b/apps/bot/src/openai-purchase-interpreter.ts @@ -14,6 +14,7 @@ export interface PurchaseInterpretation { amountMinor: bigint | null currency: 'GEL' | 'USD' | null itemDescription: string | null + payerMemberId?: string | null amountSource?: PurchaseInterpretationAmountSource | null calculationExplanation?: string | null participantMemberIds?: readonly string[] | null @@ -43,6 +44,7 @@ interface OpenAiStructuredResult { amountMinor: string | null currency: 'GEL' | 'USD' | null itemDescription: string | null + payerMemberId: string | null amountSource: PurchaseInterpretationAmountSource | null calculationExplanation: string | null participantMemberIds: string[] | null @@ -104,6 +106,26 @@ function normalizeParticipantMemberIds( return normalized.length > 0 ? normalized : null } +function normalizePayerMemberId( + value: string | null | undefined, + householdMembers: readonly PurchaseInterpreterHouseholdMember[] | undefined +): string | null { + if (!value) { + return null + } + + const normalized = value.trim() + if (normalized.length === 0) { + return null + } + + if (!householdMembers) { + return normalized + } + + return householdMembers.some((member) => member.memberId === normalized) ? normalized : null +} + function resolveMissingCurrency(input: { decision: PurchaseInterpretationDecision amountMinor: bigint | null @@ -198,6 +220,7 @@ export function createOpenAiPurchaseInterpreter( 'If the latest message is a complete standalone purchase on its own, ignore the earlier clarification context.', 'If the latest message answers a previous clarification, combine it with the earlier messages to resolve the purchase.', 'If a household member roster is provided and the user explicitly says who shares the purchase, return participantMemberIds as the included member IDs.', + 'If a household member roster is provided and the user explicitly says who paid for the purchase, return payerMemberId.', 'For phrases like "split with Dima", "for me and Alice", or similar, include the sender and the explicitly mentioned household members in participantMemberIds.', 'If the message does not clearly specify a participant subset, return participantMemberIds as null.', 'Away members may still be included when the user explicitly names them.', @@ -260,6 +283,9 @@ export function createOpenAiPurchaseInterpreter( itemDescription: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + payerMemberId: { + anyOf: [{ type: 'string' }, { type: 'null' }] + }, amountSource: { anyOf: [ { @@ -295,6 +321,7 @@ export function createOpenAiPurchaseInterpreter( 'amountMinor', 'currency', 'itemDescription', + 'payerMemberId', 'amountSource', 'calculationExplanation', 'participantMemberIds', @@ -339,6 +366,7 @@ export function createOpenAiPurchaseInterpreter( const amountMinor = asOptionalBigInt(parsedJson.amountMinor) const itemDescription = normalizeOptionalText(parsedJson.itemDescription) + const payerMemberId = normalizePayerMemberId(parsedJson.payerMemberId, options.householdMembers) const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor) const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation) const participantMemberIds = normalizeParticipantMemberIds( @@ -376,6 +404,10 @@ export function createOpenAiPurchaseInterpreter( clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null } + if (payerMemberId) { + result.payerMemberId = payerMemberId + } + if (participantMemberIds) { result.participantMemberIds = participantMemberIds } diff --git a/apps/bot/src/payment-proposals.ts b/apps/bot/src/payment-proposals.ts index 42438ee..718a992 100644 --- a/apps/bot/src/payment-proposals.ts +++ b/apps/bot/src/payment-proposals.ts @@ -174,6 +174,28 @@ function formatPaymentBreakdown(locale: BotLocale, breakdown: PaymentProposalBre return lines.join('\n') } +function shouldUseCompactTopicProposal(input: { + surface: 'assistant' | 'topic' + breakdown: PaymentProposalBreakdown +}): boolean { + if (input.surface !== 'topic') { + return false + } + + if (input.breakdown.guidance.kind !== 'rent') { + return false + } + + if (input.breakdown.guidance.adjustmentPolicy !== 'utilities') { + return false + } + + return ( + input.breakdown.explicitAmount === null || + input.breakdown.explicitAmount.equals(input.breakdown.guidance.proposalAmount) + ) +} + export function formatPaymentProposalText(input: { locale: BotLocale surface: 'assistant' | 'topic' @@ -199,6 +221,15 @@ export function formatPaymentProposalText(input: { amount.currency ) + if ( + shouldUseCompactTopicProposal({ + surface: input.surface, + breakdown: input.proposal.breakdown + }) + ) { + return intro + } + return `${intro}\n\n${formatPaymentBreakdown(input.locale, input.proposal.breakdown)}` } diff --git a/apps/bot/src/payment-topic-ingestion.test.ts b/apps/bot/src/payment-topic-ingestion.test.ts index 712fade..730bb7d 100644 --- a/apps/bot/src/payment-topic-ingestion.test.ts +++ b/apps/bot/src/payment-topic-ingestion.test.ts @@ -395,6 +395,9 @@ describe('registerConfiguredPaymentTopicIngestion', () => { ] } }) + const payload = calls[0]?.payload as { text?: string } | undefined + expect(String(payload?.text)).not.toContain('Аренда к оплате') + expect(String(payload?.text)).not.toContain('Баланс по общим покупкам') expect(await promptRepository.getPendingAction('-10012345', '10002')).toMatchObject({ action: 'payment_topic_confirmation' diff --git a/apps/bot/src/payment-topic-ingestion.ts b/apps/bot/src/payment-topic-ingestion.ts index 6f518a4..72b77ee 100644 --- a/apps/bot/src/payment-topic-ingestion.ts +++ b/apps/bot/src/payment-topic-ingestion.ts @@ -21,7 +21,7 @@ import { parsePaymentProposalPayload, synthesizePaymentConfirmationText } from './payment-proposals' -import type { TopicMessageRouter } from './topic-message-router' +import { cacheTopicMessageRoute, type TopicMessageRouter } from './topic-message-router' import { persistTopicHistoryMessage, telegramMessageIdFromMessage, @@ -662,6 +662,15 @@ export function registerConfiguredPaymentTopicIngestion( // Handle different routes switch (processorResult.route) { case 'silent': { + cacheTopicMessageRoute(ctx, 'payments', { + route: 'silent', + replyText: null, + helperKind: null, + shouldStartTyping: false, + shouldClearWorkflow: false, + confidence: processorResult.reason === 'test' ? 0 : 80, + reason: processorResult.reason + }) await next() return } diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index 4bdd21f..247fd0b 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -60,6 +60,7 @@ function purchaseUpdate( options: { replyToBot?: boolean threadId?: number + asCaption?: boolean } = {} ) { const commandToken = text.split(' ')[0] ?? text @@ -99,16 +100,30 @@ function purchaseUpdate( } } : {}), - text, - entities: text.startsWith('/') - ? [ - { - offset: 0, - length: commandToken.length, - type: 'bot_command' - } - ] - : [] + ...(options.asCaption + ? { + caption: text, + photo: [ + { + file_id: 'photo-1', + file_unique_id: 'photo-1', + width: 100, + height: 100 + } + ] + } + : { + text, + entities: text.startsWith('/') + ? [ + { + offset: 0, + length: commandToken.length, + type: 'bot_command' + } + ] + : [] + }) } } } @@ -628,6 +643,160 @@ Confirm or cancel below.`, }) }) + test('reads purchase captions from photo messages', async () => { + const bot = createTestBot() + 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: Number(config.householdChatId), + type: 'supergroup' + }, + text: 'ok' + } + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, + async save(record) { + expect(record.rawText).toBe('Bought toilet paper 30 gel') + return { + status: 'pending_confirmation', + purchaseMessageId: 'proposal-caption', + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL', + parsedItemDescription: 'toilet paper', + payerMemberId: 'member-1', + payerDisplayName: 'Mia', + parserConfidence: 90, + parserMode: 'llm', + participants: participants() + } + }, + async confirm() { + throw new Error('not used') + }, + async saveWithInterpretation() { + throw new Error('not implemented') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository) + await bot.handleUpdate( + purchaseUpdate('Bought toilet paper 30 gel', { asCaption: true }) as never + ) + + expect(calls).toHaveLength(1) + expect(calls[0]?.payload).toMatchObject({ + text: expect.stringContaining('toilet paper - 30.00 GEL') + }) + }) + + test('shows payer selection buttons when the purchase payer is ambiguous', async () => { + const bot = createTestBot() + 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: Number(config.householdChatId), + type: 'supergroup' + }, + text: 'ok' + } + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, + async save() { + return { + status: 'clarification_needed', + purchaseMessageId: 'proposal-1', + clarificationQuestion: null, + parsedAmountMinor: 1000n, + parsedCurrency: 'GEL', + parsedItemDescription: 'chicken', + payerMemberId: null, + payerDisplayName: null, + parserConfidence: 78, + parserMode: 'llm', + payerCandidates: [ + { memberId: 'member-1', displayName: 'Mia' }, + { memberId: 'member-2', displayName: 'Dima' } + ] + } + }, + async confirm() { + throw new Error('not used') + }, + async saveWithInterpretation() { + throw new Error('not implemented') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository) + await bot.handleUpdate(purchaseUpdate('Dima bought chicken for 10 gel') as never) + + expect(calls).toHaveLength(1) + const payload = calls[0]?.payload as { + text: string + reply_markup?: { + inline_keyboard?: Array> + } + } + + expect(payload.text).toBe('I could not tell who bought this. Pick the payer below.') + expect(payload.reply_markup?.inline_keyboard?.[0]).toEqual([ + { + text: 'Mia paid', + callback_data: 'purchase:payer:proposal-1:member-1' + } + ]) + expect(payload.reply_markup?.inline_keyboard?.[1]).toEqual([ + { + text: 'Dima paid', + callback_data: 'purchase:payer:proposal-1:member-2' + } + ]) + expect(payload.reply_markup?.inline_keyboard?.[2]).toEqual([ + { + text: 'Cancel', + callback_data: 'purchase:cancel:proposal-1' + } + ]) + }) + test('keeps bare-amount purchase reports on the ingestion path', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index b68d24e..697b4f8 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -41,6 +41,7 @@ import { stripExplicitBotMention } from './telegram-mentions' const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:' const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:' const PURCHASE_PARTICIPANT_CALLBACK_PREFIX = 'purchase:participant:' +const PURCHASE_PAYER_CALLBACK_PREFIX = 'purchase:payer:' const PURCHASE_FIX_AMOUNT_CALLBACK_PREFIX = 'purchase:fix_amount:' const MIN_PROPOSAL_CONFIDENCE = 70 const LIKELY_PURCHASE_VERB_PATTERN = @@ -64,6 +65,8 @@ type StoredPurchaseProcessingStatus = interface StoredPurchaseMessageRow { id: string householdId: string + senderMemberId: string | null + payerMemberId: string | null senderTelegramUserId: string parsedAmountMinor: bigint | null parsedCurrency: 'GEL' | 'USD' | null @@ -77,16 +80,24 @@ interface PurchaseProposalFields { parsedAmountMinor: bigint | null parsedCurrency: 'GEL' | 'USD' | null parsedItemDescription: string | null + payerMemberId?: string | null + payerDisplayName?: string | null amountSource?: PurchaseInterpretationAmountSource | null calculationExplanation?: string | null parserConfidence: number | null parserMode: 'llm' | null } +interface PurchaseProposalPayerCandidate { + memberId: string + displayName: string +} + interface PurchaseClarificationResult extends PurchaseProposalFields { status: 'clarification_needed' purchaseMessageId: string clarificationQuestion: string | null + payerCandidates?: readonly PurchaseProposalPayerCandidate[] } interface PurchasePendingConfirmationResult extends PurchaseProposalFields { @@ -107,6 +118,25 @@ interface PurchaseProposalParticipant { included: boolean } +export type PurchaseProposalPayerSelectionResult = + | ({ + status: 'selected' + purchaseMessageId: string + householdId: string + participants: readonly PurchaseProposalParticipant[] + } & PurchaseProposalFields) + | { + status: 'forbidden' + householdId: string + } + | { + status: 'not_pending' + householdId: string + } + | { + status: 'not_found' + } + export interface PurchaseTopicIngestionConfig { householdId: string householdChatId: string @@ -239,6 +269,11 @@ export interface PurchaseMessageIngestionRepository { participantId: string, actorTelegramUserId: string ): Promise + selectPayer?( + purchaseMessageId: string, + memberId: string, + actorTelegramUserId: string + ): Promise requestAmountCorrection?( purchaseMessageId: string, actorTelegramUserId: string @@ -250,6 +285,8 @@ interface PurchasePersistenceDecision { parsedAmountMinor: bigint | null parsedCurrency: 'GEL' | 'USD' | null parsedItemDescription: string | null + payerMemberId: string | null + payerCandidateMemberIds: readonly string[] | null amountSource: PurchaseInterpretationAmountSource | null calculationExplanation: string | null participantMemberIds: readonly string[] | null @@ -312,6 +349,8 @@ function normalizeInterpretation( parsedAmountMinor: null, parsedCurrency: null, parsedItemDescription: null, + payerMemberId: null, + payerCandidateMemberIds: null, amountSource: null, calculationExplanation: null, participantMemberIds: null, @@ -329,6 +368,8 @@ function normalizeInterpretation( parsedAmountMinor: interpretation.amountMinor, parsedCurrency: interpretation.currency, parsedItemDescription: interpretation.itemDescription, + payerMemberId: interpretation.payerMemberId ?? null, + payerCandidateMemberIds: null, amountSource: interpretation.amountSource ?? null, calculationExplanation: interpretation.calculationExplanation ?? null, participantMemberIds: interpretation.participantMemberIds ?? null, @@ -355,6 +396,8 @@ function normalizeInterpretation( parsedAmountMinor: interpretation.amountMinor, parsedCurrency: interpretation.currency, parsedItemDescription: interpretation.itemDescription, + payerMemberId: interpretation.payerMemberId ?? null, + payerCandidateMemberIds: null, amountSource: interpretation.amountSource ?? null, calculationExplanation: interpretation.calculationExplanation ?? null, participantMemberIds: interpretation.participantMemberIds ?? null, @@ -371,6 +414,8 @@ function normalizeInterpretation( parsedAmountMinor: interpretation.amountMinor, parsedCurrency: interpretation.currency, parsedItemDescription: interpretation.itemDescription, + payerMemberId: interpretation.payerMemberId ?? null, + payerCandidateMemberIds: null, amountSource: interpretation.amountSource ?? null, calculationExplanation: interpretation.calculationExplanation ?? null, participantMemberIds: interpretation.participantMemberIds ?? null, @@ -393,6 +438,7 @@ export function toPurchaseInterpretation( amountSource: result.amountSource, calculationExplanation: result.calculationExplanation, participantMemberIds: result.participantMemberIds, + payerMemberId: null, confidence: result.confidence, parserMode: 'llm', clarificationQuestion: null @@ -407,6 +453,7 @@ export function toPurchaseClarificationInterpretation( amountMinor: null, currency: null, itemDescription: null, + payerMemberId: null, confidence: 0, parserMode: 'llm', clarificationQuestion: result.clarificationQuestion @@ -429,6 +476,7 @@ export function resolveProposalParticipantSelection(input: { members: readonly { memberId: string telegramUserId: string | null + displayName?: string lifecycleStatus: 'active' | 'away' | 'left' }[] policyByMemberId: ReadonlyMap< @@ -440,6 +488,7 @@ export function resolveProposalParticipantSelection(input: { > senderTelegramUserId: string senderMemberId: string | null + payerMemberId?: string | null explicitParticipantMemberIds: readonly string[] | null }): readonly { memberId: string; included: boolean }[] { const eligibleMembers = input.members.filter((member) => member.lifecycleStatus !== 'left') @@ -455,6 +504,7 @@ export function resolveProposalParticipantSelection(input: { } const fallbackParticipant = + eligibleMembers.find((member) => member.memberId === input.payerMemberId) ?? eligibleMembers.find((member) => member.memberId === input.senderMemberId) ?? eligibleMembers.find((member) => member.telegramUserId === input.senderTelegramUserId) ?? eligibleMembers[0] @@ -491,6 +541,7 @@ export function resolveProposalParticipantSelection(input: { } const fallbackParticipant = + participants.find((participant) => participant.memberId === input.payerMemberId) ?? participants.find((participant) => participant.memberId === input.senderMemberId) ?? participants.find((participant) => participant.telegramUserId === input.senderTelegramUserId) ?? participants[0] @@ -501,9 +552,122 @@ export function resolveProposalParticipantSelection(input: { })) } +function normalizeMemberText(value: string): string { + return value + .toLowerCase() + .replace(/['’]s\b/g, '') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function aliasVariants(token: string): string[] { + const aliases = new Set([token]) + + if (token.endsWith('а') && token.length > 2) { + aliases.add(`${token.slice(0, -1)}ы`) + aliases.add(`${token.slice(0, -1)}е`) + aliases.add(`${token.slice(0, -1)}у`) + } + + if (token.endsWith('я') && token.length > 2) { + aliases.add(`${token.slice(0, -1)}и`) + aliases.add(`${token.slice(0, -1)}ю`) + } + + return [...aliases] +} + +function memberAliases(displayName: string): string[] { + const normalized = normalizeMemberText(displayName) + const tokens = normalized.split(' ').filter((token) => token.length >= 2) + const aliases = new Set([normalized, ...tokens]) + + for (const token of tokens) { + for (const alias of aliasVariants(token)) { + aliases.add(alias) + } + } + + return [...aliases] +} + +function resolvePurchasePayer(input: { + rawText: string + members: readonly { + memberId: string + displayName: string + status: 'active' | 'away' | 'left' + }[] + senderMemberId: string | null +}): + | { + status: 'resolved' + payerMemberId: string | null + payerCandidateMemberIds: null + } + | { + status: 'ambiguous' + payerMemberId: null + payerCandidateMemberIds: readonly string[] + } { + const eligibleMembers = input.members.filter((member) => member.status !== 'left') + const normalizedText = normalizeMemberText(input.rawText) + + if (normalizedText.length === 0 || eligibleMembers.length === 0) { + return { + status: 'resolved', + payerMemberId: input.senderMemberId, + payerCandidateMemberIds: null + } + } + + const mentionedMembers = eligibleMembers.filter((member) => + memberAliases(member.displayName).some((alias) => { + const pattern = new RegExp( + `(^|\\s)${alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=\\s|$)`, + 'u' + ) + return pattern.test(normalizedText) + }) + ) + + if (mentionedMembers.length === 0) { + if (input.senderMemberId) { + return { + status: 'resolved', + payerMemberId: input.senderMemberId, + payerCandidateMemberIds: null + } + } + + return { + status: 'ambiguous', + payerMemberId: null, + payerCandidateMemberIds: eligibleMembers.map((member) => member.memberId) + } + } + + if (mentionedMembers.length === 1) { + return { + status: 'resolved', + payerMemberId: mentionedMembers[0]!.memberId, + payerCandidateMemberIds: null + } + } + + return { + status: 'ambiguous', + payerMemberId: null, + payerCandidateMemberIds: mentionedMembers.map((member) => member.memberId) + } +} + function toStoredPurchaseRow(row: { id: string householdId: string + senderMemberId: string | null + payerMemberId: string | null senderTelegramUserId: string parsedAmountMinor: bigint | null parsedCurrency: string | null @@ -515,6 +679,8 @@ function toStoredPurchaseRow(row: { return { id: row.id, householdId: row.householdId, + senderMemberId: row.senderMemberId, + payerMemberId: row.payerMemberId, senderTelegramUserId: row.senderTelegramUserId, parsedAmountMinor: row.parsedAmountMinor, parsedCurrency: @@ -541,6 +707,8 @@ function toProposalFields(row: StoredPurchaseMessageRow): PurchaseProposalFields parsedAmountMinor: row.parsedAmountMinor, parsedCurrency: row.parsedCurrency, parsedItemDescription: row.parsedItemDescription, + payerMemberId: row.payerMemberId, + payerDisplayName: null, amountSource: null, calculationExplanation: null, parserConfidence: row.parserConfidence, @@ -652,6 +820,28 @@ function shouldShowProcessingReply( return true } +function readPurchaseMessageText(ctx: Pick): string | null { + const strippedMention = stripExplicitBotMention(ctx) + if (strippedMention) { + return strippedMention.strippedText + } + + const message = ctx.message + if (!message) { + return null + } + + if ('text' in message && typeof message.text === 'string') { + return message.text + } + + if ('caption' in message && typeof message.caption === 'string') { + return message.caption + } + + return null +} + async function finalizePurchaseReply( ctx: Context, pendingReply: PendingPurchaseReply | null, @@ -712,7 +902,8 @@ async function finalizePurchaseReply( function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { const message = ctx.message - if (!message || !('text' in message)) { + const rawText = readPurchaseMessageText(ctx) + if (!message || !rawText) { return null } @@ -735,7 +926,7 @@ function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { messageId: message.message_id.toString(), threadId: message.message_thread_id.toString(), senderTelegramUserId, - rawText: stripExplicitBotMention(ctx)?.strippedText ?? message.text, + rawText, messageSentAt: instantFromEpochSeconds(message.date) } @@ -855,6 +1046,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): { .select({ id: schema.purchaseMessages.id, householdId: schema.purchaseMessages.householdId, + senderMemberId: schema.purchaseMessages.senderMemberId, + payerMemberId: schema.purchaseMessages.payerMemberId, senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId, parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor, parsedCurrency: schema.purchaseMessages.parsedCurrency, @@ -897,10 +1090,63 @@ export function createPurchaseMessageRepository(databaseUrl: string): { })) } + async function loadHouseholdMembers(householdId: string) { + return ( + await db + .select({ + memberId: schema.members.id, + displayName: schema.members.displayName, + telegramUserId: schema.members.telegramUserId, + status: schema.members.lifecycleStatus + }) + .from(schema.members) + .where(eq(schema.members.householdId, householdId)) + ).map((member) => ({ + memberId: member.memberId, + displayName: member.displayName, + telegramUserId: member.telegramUserId, + status: normalizeLifecycleStatus(member.status) + })) + } + + function findMemberDisplayName( + members: readonly { memberId: string; displayName: string }[], + memberId: string | null + ): string | null { + if (!memberId) { + return null + } + + return members.find((member) => member.memberId === memberId)?.displayName ?? null + } + + function payerCandidatesFromIds( + members: readonly { + memberId: string + displayName: string + status: 'active' | 'away' | 'left' + }[], + candidateIds: readonly string[] | null + ): readonly PurchaseProposalPayerCandidate[] { + if (!candidateIds || candidateIds.length === 0) { + return [] + } + + const wanted = new Set(candidateIds) + return members + .filter((member) => member.status !== 'left') + .filter((member) => wanted.has(member.memberId)) + .map((member) => ({ + memberId: member.memberId, + displayName: member.displayName + })) + } + async function defaultProposalParticipants(input: { householdId: string senderTelegramUserId: string senderMemberId: string | null + payerMemberId: string | null messageSentAt: Instant explicitParticipantMemberIds: readonly string[] | null }): Promise { @@ -963,10 +1209,62 @@ export function createPurchaseMessageRepository(databaseUrl: string): { policyByMemberId, senderTelegramUserId: input.senderTelegramUserId, senderMemberId: input.senderMemberId, + payerMemberId: input.payerMemberId, explicitParticipantMemberIds: input.explicitParticipantMemberIds }) } + function finalizePayerDecision(input: { + decision: PurchasePersistenceDecision + rawText: string + householdMembers: readonly { + memberId: string + displayName: string + status: 'active' | 'away' | 'left' + }[] + senderMemberId: string | null + }): PurchasePersistenceDecision { + if ( + input.decision.status === 'ignored_not_purchase' || + input.decision.status === 'parse_failed' || + input.decision.payerMemberId + ) { + return input.decision + } + + const payerResolution = resolvePurchasePayer({ + rawText: input.rawText, + members: input.householdMembers, + senderMemberId: input.senderMemberId + }) + + if (payerResolution.status === 'resolved' && payerResolution.payerMemberId) { + return { + ...input.decision, + payerMemberId: payerResolution.payerMemberId, + payerCandidateMemberIds: null + } + } + + const canAskWithButtons = + input.decision.parsedAmountMinor !== null && + input.decision.parsedCurrency !== null && + input.decision.parsedItemDescription !== null + + return { + ...input.decision, + status: canAskWithButtons ? 'clarification_needed' : input.decision.status, + payerMemberId: null, + payerCandidateMemberIds: + payerResolution.status === 'ambiguous' ? payerResolution.payerCandidateMemberIds : null, + clarificationQuestion: + canAskWithButtons && input.decision.clarificationQuestion === null + ? null + : input.decision.clarificationQuestion, + needsReview: true + } + } + async function mutateProposalStatus( purchaseMessageId: string, actorTelegramUserId: string, @@ -1023,6 +1321,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): { .returning({ id: schema.purchaseMessages.id, householdId: schema.purchaseMessages.householdId, + senderMemberId: schema.purchaseMessages.senderMemberId, + payerMemberId: schema.purchaseMessages.payerMemberId, senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId, parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor, parsedCurrency: schema.purchaseMessages.parsedCurrency, @@ -1104,22 +1404,9 @@ export function createPurchaseMessageRepository(databaseUrl: string): { .limit(1) const senderMemberId = matchedMember[0]?.id ?? null - const householdMembers = ( - await db - .select({ - memberId: schema.members.id, - displayName: schema.members.displayName, - status: schema.members.lifecycleStatus - }) - .from(schema.members) - .where(eq(schema.members.householdId, record.householdId)) + const householdMembers = (await loadHouseholdMembers(record.householdId)).filter( + (member) => member.status !== 'left' ) - .map((member) => ({ - memberId: member.memberId, - displayName: member.displayName, - status: normalizeLifecycleStatus(member.status) - })) - .filter((member) => member.status !== 'left') let parserError: string | null = null const clarificationContext = interpreter ? await getClarificationContext(record) : undefined @@ -1143,16 +1430,22 @@ export function createPurchaseMessageRepository(databaseUrl: string): { }) : null - const decision = normalizeInterpretation( - interpretation, - parserError ?? (interpreter ? null : 'Purchase interpreter is unavailable') - ) + const decision = finalizePayerDecision({ + decision: normalizeInterpretation( + interpretation, + parserError ?? (interpreter ? null : 'Purchase interpreter is unavailable') + ), + rawText: record.rawText, + householdMembers, + senderMemberId + }) const inserted = await db .insert(schema.purchaseMessages) .values({ householdId: record.householdId, senderMemberId, + payerMemberId: decision.payerMemberId, senderTelegramUserId: record.senderTelegramUserId, senderDisplayName: record.senderDisplayName, rawText: record.rawText, @@ -1200,16 +1493,27 @@ export function createPurchaseMessageRepository(databaseUrl: string): { parsedAmountMinor: decision.parsedAmountMinor, parsedCurrency: decision.parsedCurrency, parsedItemDescription: decision.parsedItemDescription, + payerMemberId: decision.payerMemberId, + payerDisplayName: findMemberDisplayName(householdMembers, decision.payerMemberId), amountSource: decision.amountSource, calculationExplanation: decision.calculationExplanation, parserConfidence: decision.parserConfidence, - parserMode: decision.parserMode + parserMode: decision.parserMode, + ...(decision.payerCandidateMemberIds + ? { + payerCandidates: payerCandidatesFromIds( + householdMembers, + decision.payerCandidateMemberIds + ) + } + : {}) } case 'pending_confirmation': { const participants = await defaultProposalParticipants({ householdId: record.householdId, senderTelegramUserId: record.senderTelegramUserId, senderMemberId, + payerMemberId: decision.payerMemberId, messageSentAt: record.messageSentAt, explicitParticipantMemberIds: decision.participantMemberIds }) @@ -1230,6 +1534,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): { parsedAmountMinor: decision.parsedAmountMinor!, parsedCurrency: decision.parsedCurrency!, parsedItemDescription: decision.parsedItemDescription!, + payerMemberId: decision.payerMemberId, + payerDisplayName: findMemberDisplayName(householdMembers, decision.payerMemberId), amountSource: decision.amountSource, calculationExplanation: decision.calculationExplanation, parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE, @@ -1258,14 +1564,22 @@ export function createPurchaseMessageRepository(databaseUrl: string): { .limit(1) const senderMemberId = matchedMember[0]?.id ?? null - - const decision = normalizeInterpretation(interpretation, null) + const householdMembers = (await loadHouseholdMembers(record.householdId)).filter( + (member) => member.status !== 'left' + ) + const decision = finalizePayerDecision({ + decision: normalizeInterpretation(interpretation, null), + rawText: record.rawText, + householdMembers, + senderMemberId + }) const inserted = await db .insert(schema.purchaseMessages) .values({ householdId: record.householdId, senderMemberId, + payerMemberId: decision.payerMemberId, senderTelegramUserId: record.senderTelegramUserId, senderDisplayName: record.senderDisplayName, rawText: record.rawText, @@ -1313,16 +1627,27 @@ export function createPurchaseMessageRepository(databaseUrl: string): { parsedAmountMinor: decision.parsedAmountMinor, parsedCurrency: decision.parsedCurrency, parsedItemDescription: decision.parsedItemDescription, + payerMemberId: decision.payerMemberId, + payerDisplayName: findMemberDisplayName(householdMembers, decision.payerMemberId), amountSource: decision.amountSource, calculationExplanation: decision.calculationExplanation, parserConfidence: decision.parserConfidence, - parserMode: decision.parserMode + parserMode: decision.parserMode, + ...(decision.payerCandidateMemberIds + ? { + payerCandidates: payerCandidatesFromIds( + householdMembers, + decision.payerCandidateMemberIds + ) + } + : {}) } case 'pending_confirmation': { const participants = await defaultProposalParticipants({ householdId: record.householdId, senderTelegramUserId: record.senderTelegramUserId, senderMemberId, + payerMemberId: decision.payerMemberId, messageSentAt: record.messageSentAt, explicitParticipantMemberIds: decision.participantMemberIds }) @@ -1343,6 +1668,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): { parsedAmountMinor: decision.parsedAmountMinor!, parsedCurrency: decision.parsedCurrency!, parsedItemDescription: decision.parsedItemDescription!, + payerMemberId: decision.payerMemberId, + payerDisplayName: findMemberDisplayName(householdMembers, decision.payerMemberId), amountSource: decision.amountSource, calculationExplanation: decision.calculationExplanation, parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE, @@ -1374,6 +1701,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): { memberId: schema.purchaseMessageParticipants.memberId, included: schema.purchaseMessageParticipants.included, householdId: schema.purchaseMessages.householdId, + payerMemberId: schema.purchaseMessages.payerMemberId, senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId, parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor, parsedCurrency: schema.purchaseMessages.parsedCurrency, @@ -1446,6 +1774,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): { }) .where(eq(schema.purchaseMessageParticipants.id, participantId)) + const householdMembers = await loadHouseholdMembers(existing.householdId) + return { status: 'updated', purchaseMessageId: existing.purchaseMessageId, @@ -1456,6 +1786,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): { ? existing.parsedCurrency : null, parsedItemDescription: existing.parsedItemDescription, + payerMemberId: existing.payerMemberId, + payerDisplayName: findMemberDisplayName(householdMembers, existing.payerMemberId), parserConfidence: existing.parserConfidence, parserMode: existing.parserMode === 'llm' ? 'llm' : null, participants: toProposalParticipants( @@ -1464,6 +1796,104 @@ export function createPurchaseMessageRepository(databaseUrl: string): { } }, + async selectPayer(purchaseMessageId, memberId, actorTelegramUserId) { + const existing = await getStoredMessage(purchaseMessageId) + if (!existing) { + return { + status: 'not_found' + } + } + + if (existing.senderTelegramUserId !== actorTelegramUserId) { + return { + status: 'forbidden', + householdId: existing.householdId + } + } + + if (existing.processingStatus !== 'clarification_needed') { + return { + status: 'not_pending', + householdId: existing.householdId + } + } + + if ( + existing.parsedAmountMinor === null || + existing.parsedCurrency === null || + existing.parsedItemDescription === null + ) { + return { + status: 'not_pending', + householdId: existing.householdId + } + } + + const householdMembers = await loadHouseholdMembers(existing.householdId) + const payer = householdMembers.find( + (candidate) => candidate.memberId === memberId && candidate.status !== 'left' + ) + + if (!payer) { + return { + status: 'not_pending', + householdId: existing.householdId + } + } + + await db + .update(schema.purchaseMessages) + .set({ + payerMemberId: payer.memberId, + processingStatus: 'pending_confirmation', + needsReview: 1 + }) + .where( + and( + eq(schema.purchaseMessages.id, purchaseMessageId), + eq(schema.purchaseMessages.senderTelegramUserId, actorTelegramUserId), + eq(schema.purchaseMessages.processingStatus, 'clarification_needed') + ) + ) + + await db + .delete(schema.purchaseMessageParticipants) + .where(eq(schema.purchaseMessageParticipants.purchaseMessageId, purchaseMessageId)) + + const participants = await defaultProposalParticipants({ + householdId: existing.householdId, + senderTelegramUserId: existing.senderTelegramUserId, + senderMemberId: existing.senderMemberId, + payerMemberId: payer.memberId, + messageSentAt: nowInstant(), + explicitParticipantMemberIds: null + }) + + if (participants.length > 0) { + await db.insert(schema.purchaseMessageParticipants).values( + participants.map((participant) => ({ + purchaseMessageId, + memberId: participant.memberId, + included: participantIncludedAsInt(participant.included) + })) + ) + } + + return { + status: 'selected', + purchaseMessageId, + householdId: existing.householdId, + parsedAmountMinor: existing.parsedAmountMinor, + parsedCurrency: existing.parsedCurrency, + parsedItemDescription: existing.parsedItemDescription, + payerMemberId: payer.memberId, + payerDisplayName: payer.displayName, + parserConfidence: existing.parserConfidence, + parserMode: existing.parserMode, + participants: toProposalParticipants(await getStoredParticipants(purchaseMessageId)) + } + }, + async requestAmountCorrection(purchaseMessageId, actorTelegramUserId) { const existing = await getStoredMessage(purchaseMessageId) if (!existing) { @@ -1626,6 +2056,19 @@ function formatPurchaseCalculationNote( return t.calculatedAmountNote(result.calculationExplanation ?? null) } +function formatPurchasePayer( + locale: BotLocale, + result: { + payerDisplayName?: string | null + } +): string | null { + if (!result.payerDisplayName) { + return null + } + + return getBotTranslations(locale).purchase.payerSelected(result.payerDisplayName) +} + export function buildPurchaseAcknowledgement( result: PurchaseMessageIngestionResult, locale: BotLocale = 'en' @@ -1639,11 +2082,17 @@ export function buildPurchaseAcknowledgement( case 'pending_confirmation': return t.proposal( formatPurchaseSummary(locale, result), + formatPurchasePayer(locale, result), formatPurchaseCalculationNote(locale, result), formatPurchaseParticipants(locale, result.participants) ) case 'clarification_needed': - return t.clarification(result.clarificationQuestion ?? clarificationFallback(locale, result)) + return t.clarification( + result.clarificationQuestion ?? + (result.payerCandidates && result.payerCandidates.length > 0 + ? t.payerFallbackQuestion + : clarificationFallback(locale, result)) + ) case 'parse_failed': return t.parseFailed } @@ -1930,7 +2379,11 @@ async function handlePurchaseMessageResult( result.purchaseMessageId, result.participants ) - : undefined, + : result.status === 'clarification_needed' && + result.payerCandidates && + result.payerCandidates.length > 0 + ? purchaseClarificationReplyMarkup(locale, result.purchaseMessageId, result.payerCandidates) + : undefined, historyRepository ? { repository: historyRepository, @@ -1971,17 +2424,119 @@ function buildPurchaseToggleMessage( ): string { return getBotTranslations(locale).purchase.proposal( formatPurchaseSummary(locale, result), + formatPurchasePayer(locale, result), null, formatPurchaseParticipants(locale, result.participants) ) } +function buildPurchasePayerSelectionMessage( + locale: BotLocale, + result: Extract +): string { + return getBotTranslations(locale).purchase.proposal( + formatPurchaseSummary(locale, result), + formatPurchasePayer(locale, result), + null, + formatPurchaseParticipants(locale, result.participants) + ) +} + +function purchaseClarificationReplyMarkup( + locale: BotLocale, + purchaseMessageId: string, + payerCandidates: readonly PurchaseProposalPayerCandidate[] +) { + const t = getBotTranslations(locale).purchase + + return { + inline_keyboard: [ + ...payerCandidates.map((candidate) => [ + { + text: t.payerButton(candidate.displayName), + callback_data: `${PURCHASE_PAYER_CALLBACK_PREFIX}${purchaseMessageId}:${candidate.memberId}` + } + ]), + [ + { + text: t.cancelButton, + callback_data: `${PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}` + } + ] + ] + } +} + function registerPurchaseProposalCallbacks( bot: Bot, repository: PurchaseMessageIngestionRepository, resolveLocale: (householdId: string) => Promise, logger?: Logger ): void { + bot.callbackQuery( + new RegExp(`^${PURCHASE_PAYER_CALLBACK_PREFIX}([^:]+):([^:]+)$`), + async (ctx) => { + const purchaseMessageId = ctx.match[1] + const memberId = ctx.match[2] + const actorTelegramUserId = ctx.from?.id?.toString() + + if (!repository.selectPayer || !actorTelegramUserId || !purchaseMessageId || !memberId) { + await ctx.answerCallbackQuery({ + text: getBotTranslations('en').purchase.proposalUnavailable, + show_alert: true + }) + return + } + + const result = await repository.selectPayer(purchaseMessageId, memberId, actorTelegramUserId) + const locale = 'householdId' in result ? await resolveLocale(result.householdId) : 'en' + const t = getBotTranslations(locale).purchase + + if (result.status === 'not_found' || result.status === 'not_pending') { + await ctx.answerCallbackQuery({ + text: t.proposalUnavailable, + show_alert: true + }) + return + } + + if (result.status === 'forbidden') { + await ctx.answerCallbackQuery({ + text: t.notYourProposal, + show_alert: true + }) + return + } + + await ctx.answerCallbackQuery({ + text: t.payerSelectedToast(result.payerDisplayName ?? memberId) + }) + + if (ctx.msg) { + await ctx.editMessageText(buildPurchasePayerSelectionMessage(locale, result), { + reply_markup: purchaseProposalReplyMarkup( + locale, + { + amountSource: result.amountSource ?? null + }, + result.purchaseMessageId, + result.participants + ) + }) + } + + logger?.info( + { + event: 'purchase.payer_selected', + purchaseMessageId, + memberId, + actorTelegramUserId + }, + 'Purchase proposal payer selected' + ) + } + ) + bot.callbackQuery(new RegExp(`^${PURCHASE_PARTICIPANT_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => { const participantId = ctx.match[1] const actorTelegramUserId = ctx.from?.id?.toString() @@ -2233,7 +2788,7 @@ export function registerPurchaseTopicIngestion( ): void { void registerPurchaseProposalCallbacks(bot, repository, async () => 'en', options.logger) - bot.on('message:text', async (ctx, next) => { + bot.on('message', async (ctx, next) => { const candidate = toCandidateFromContext(ctx) if (!candidate) { await next() @@ -2361,7 +2916,7 @@ export function registerConfiguredPurchaseTopicIngestion( options.logger ) - bot.on('message:text', async (ctx, next) => { + bot.on('message', async (ctx, next) => { const candidate = toCandidateFromContext(ctx) if (!candidate) { await next() @@ -2441,13 +2996,27 @@ export function registerConfiguredPurchaseTopicIngestion( } }) - // Get household members for the processor - const householdMembers = await (async () => { - if (!options.topicProcessor) return [] - // This will be loaded from DB in the actual implementation - // For now, we return empty array - the processor will work without it - return [] - })() + const householdMembers = + options.topicProcessor && + 'listHouseholdMembers' in householdConfigurationRepository && + typeof householdConfigurationRepository.listHouseholdMembers === 'function' + ? (await householdConfigurationRepository.listHouseholdMembers(record.householdId)).map( + (member) => ({ + memberId: member.id, + displayName: member.displayName, + status: member.status + }) + ) + : [] + const senderMemberId = + ('getHouseholdMember' in householdConfigurationRepository && + typeof householdConfigurationRepository.getHouseholdMember === 'function' + ? await householdConfigurationRepository.getHouseholdMember( + record.householdId, + record.senderTelegramUserId + ) + : null + )?.id ?? null // Use topic processor if available, fall back to legacy router if (options.topicProcessor) { @@ -2462,7 +3031,7 @@ export function registerConfiguredPurchaseTopicIngestion( householdContext: householdContext.householdContext, assistantTone: householdContext.assistantTone, householdMembers, - senderMemberId: null, // Will be resolved in saveWithInterpretation + senderMemberId, recentThreadMessages: conversationContext.recentThreadMessages.map((m) => ({ role: m.role, speaker: m.speaker, @@ -2506,6 +3075,15 @@ export function registerConfiguredPurchaseTopicIngestion( // Handle different routes switch (processorResult.route) { case 'silent': { + cacheTopicMessageRoute(ctx, 'purchase', { + route: 'silent', + replyText: null, + helperKind: null, + shouldStartTyping: false, + shouldClearWorkflow: false, + confidence: 80, + reason: processorResult.reason + }) await next() return } @@ -2520,6 +3098,15 @@ export function registerConfiguredPurchaseTopicIngestion( } case 'topic_helper': { + cacheTopicMessageRoute(ctx, 'purchase', { + route: 'silent', + replyText: null, + helperKind: null, + shouldStartTyping: false, + shouldClearWorkflow: false, + confidence: 80, + reason: processorResult.reason + }) await next() return } diff --git a/apps/bot/src/topic-message-router.ts b/apps/bot/src/topic-message-router.ts index 69a0713..ddca4cd 100644 --- a/apps/bot/src/topic-message-router.ts +++ b/apps/bot/src/topic-message-router.ts @@ -106,6 +106,28 @@ export function fallbackTopicMessageRoute( reason: 'active_purchase_workflow' } } + + if (input.isExplicitMention || input.isReplyToBot) { + return { + route: 'topic_helper', + replyText: null, + helperKind: 'assistant', + shouldStartTyping: true, + shouldClearWorkflow: false, + confidence: 56, + reason: 'addressed_finance_topic' + } + } + + return { + route: 'silent', + replyText: null, + helperKind: null, + shouldStartTyping: false, + shouldClearWorkflow: false, + confidence: 78, + reason: 'quiet_purchase_topic' + } } if (input.topicRole === 'payments') { @@ -123,6 +145,28 @@ export function fallbackTopicMessageRoute( reason: 'active_payment_workflow' } } + + if (input.isExplicitMention || input.isReplyToBot) { + return { + route: 'topic_helper', + replyText: null, + helperKind: 'assistant', + shouldStartTyping: false, + shouldClearWorkflow: false, + confidence: 56, + reason: 'addressed_finance_topic' + } + } + + return { + route: 'silent', + replyText: null, + helperKind: null, + shouldStartTyping: false, + shouldClearWorkflow: false, + confidence: 78, + reason: 'quiet_payments_topic' + } } if ( diff --git a/apps/bot/src/topic-processor.ts b/apps/bot/src/topic-processor.ts index 3e61dfb..536e480 100644 --- a/apps/bot/src/topic-processor.ts +++ b/apps/bot/src/topic-processor.ts @@ -295,11 +295,10 @@ export function createTopicProcessor( - The message reports a completed purchase or payment (your primary purpose in these topics) - The user addresses the bot (by @mention, reply to bot, or text reference in ANY language — бот, bot, kojori, кожори, or any recognizable variant) - There is an active clarification/confirmation workflow for this user - - The user is clearly engaged with the bot (recent bot interaction, strong context reference) - Regular chat between users (plans, greetings, discussion) → silent === PURCHASE TOPIC (topicRole=purchase) === -Purchase detection is CONTENT-BASED — engagement signals are irrelevant for this decision. +Purchase detection is CONTENT-BASED. This topic is a workflow topic, not a casual assistant thread. If the message reports a completed purchase (past-tense buy verb + realistic item + amount), classify as "purchase" REGARDLESS of mention/engagement. - Completed buy verbs: купил, bought, ordered, picked up, spent, взял, заказал, потратил, сходил взял, etc. - Realistic household items: food, groceries, household goods, toiletries, medicine, transport, cafe, restaurant @@ -307,6 +306,8 @@ If the message reports a completed purchase (past-tense buy verb + realistic ite - Gifts for household members ARE shared purchases - Plans, wishes, future intent → silent (NOT purchases) - Fantastical items (car, plane, island) or excessive amounts (>500) → chat_reply with playful response +- If the user explicitly addresses the bot with non-purchase banter, use chat_reply with one short sentence. +- Do not use topic_helper for casual banter in the purchase topic. When classifying as "purchase": - amountMinor in minor currency units (350 GEL → 35000, 3.50 → 350) @@ -315,15 +316,19 @@ When classifying as "purchase": - Use clarification when amount, item, or intent is unclear but purchase seems likely === PAYMENT TOPIC (topicRole=payments) === +This topic is also a workflow topic, not a casual assistant thread. If the message reports a completed rent or utility payment (payment verb + rent/utilities + amount), classify as "payment". - Payment verbs: оплатил, paid, заплатил, перевёл, кинул, отправил - Realistic amount for rent/utilities +- If the message is a payment-related balance/status question, use topic_helper. +- If the user explicitly addresses the bot with non-payment banter, use chat_reply with one short sentence. +- Otherwise ordinary discussion in this topic stays silent. === CHAT REPLIES === CRITICAL: chat_reply replyText must NEVER claim a purchase or payment was saved, recorded, confirmed, or logged. The chat_reply route does NOT save anything. Only "purchase" and "payment" routes process real data. === BOT ADDRESSING === -When the user addresses the bot (by any means), you MUST respond — never silent. +When the user addresses the bot (by any means), you should respond briefly, but finance topics still stay workflow-focused. For bare summons ("бот?", "bot", "@kojori_bot"), use topic_helper to let the assistant greet. For small talk or jokes directed at the bot, use chat_reply with a short playful response. For questions that need household knowledge, use topic_helper. diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index 61b2a2d..ac87750 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -355,6 +355,7 @@ export function createDbFinanceRepository( id: purchaseId, householdId, senderMemberId: input.payerMemberId, + payerMemberId: input.payerMemberId, senderTelegramUserId: 'miniapp', senderDisplayName: member?.displayName ?? 'Mini App', telegramChatId: 'miniapp', @@ -388,7 +389,7 @@ export function createDbFinanceRepository( const rows = await db .select({ id: schema.purchaseMessages.id, - payerMemberId: schema.purchaseMessages.senderMemberId, + payerMemberId: schema.purchaseMessages.payerMemberId, amountMinor: schema.purchaseMessages.parsedAmountMinor, currency: schema.purchaseMessages.parsedCurrency, description: schema.purchaseMessages.parsedItemDescription, @@ -443,7 +444,8 @@ export function createDbFinanceRepository( : {}), ...(input.payerMemberId ? { - senderMemberId: input.payerMemberId + senderMemberId: input.payerMemberId, + payerMemberId: input.payerMemberId } : {}), needsReview: 0, @@ -458,7 +460,7 @@ export function createDbFinanceRepository( ) .returning({ id: schema.purchaseMessages.id, - payerMemberId: schema.purchaseMessages.senderMemberId, + payerMemberId: schema.purchaseMessages.payerMemberId, amountMinor: schema.purchaseMessages.parsedAmountMinor, currency: schema.purchaseMessages.parsedCurrency, description: schema.purchaseMessages.parsedItemDescription, @@ -763,7 +765,7 @@ export function createDbFinanceRepository( const rows = await db .select({ id: schema.purchaseMessages.id, - payerMemberId: schema.purchaseMessages.senderMemberId, + payerMemberId: schema.purchaseMessages.payerMemberId, amountMinor: schema.purchaseMessages.parsedAmountMinor, currency: schema.purchaseMessages.parsedCurrency, description: schema.purchaseMessages.parsedItemDescription, @@ -774,7 +776,7 @@ export function createDbFinanceRepository( .where( and( eq(schema.purchaseMessages.householdId, householdId), - isNotNull(schema.purchaseMessages.senderMemberId), + isNotNull(schema.purchaseMessages.payerMemberId), isNotNull(schema.purchaseMessages.parsedAmountMinor), isNotNull(schema.purchaseMessages.parsedCurrency), or( diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index 7d42bf4..9deaed7 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -15,6 +15,23 @@ import type { import { createFinanceCommandService } from './finance-command-service' +function expectedCurrentCyclePeriod(timezone: string, rentDueDay: number): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).formatToParts(new Date()) + const year = Number(parts.find((part) => part.type === 'year')?.value ?? '0') + const month = Number(parts.find((part) => part.type === 'month')?.value ?? '1') + const day = Number(parts.find((part) => part.type === 'day')?.value ?? '1') + const carryMonth = day > rentDueDay ? month + 1 : month + const normalizedYear = carryMonth > 12 ? year + 1 : year + const normalizedMonth = carryMonth > 12 ? 1 : carryMonth + + return `${normalizedYear}-${String(normalizedMonth).padStart(2, '0')}` +} + class FinanceRepositoryStub implements FinanceRepository { householdId = 'household-1' member: FinanceMemberRecord | null = null @@ -428,9 +445,10 @@ describe('createFinanceCommandService', () => { const service = createService(repository) const result = await service.addUtilityBill('Electricity', '55.20', 'member-1') + const expectedPeriod = expectedCurrentCyclePeriod('Asia/Tbilisi', 20) expect(result).not.toBeNull() - expect(result?.period).toBe('2026-03') + expect(result?.period).toBe(expectedPeriod) expect(repository.lastUtilityBill).toEqual({ cycleId: 'opened-cycle', billName: 'Electricity', diff --git a/packages/db/drizzle-checksums.json b/packages/db/drizzle-checksums.json index 37ded4d..8139b59 100644 --- a/packages/db/drizzle-checksums.json +++ b/packages/db/drizzle-checksums.json @@ -22,6 +22,7 @@ "0018_nimble_kojori.sql": "818738e729119c6de8049dcfca562926a5dc6e321ecbbf9cf38e02bc70b5a0dc", "0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641", "0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1", - "0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad" + "0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad", + "0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1" } } diff --git a/packages/db/drizzle/0021_sharp_payer.sql b/packages/db/drizzle/0021_sharp_payer.sql new file mode 100644 index 0000000..649a8a8 --- /dev/null +++ b/packages/db/drizzle/0021_sharp_payer.sql @@ -0,0 +1,7 @@ +ALTER TABLE "purchase_messages" +ADD COLUMN "payer_member_id" uuid REFERENCES "members"("id") ON DELETE SET NULL; + +UPDATE "purchase_messages" +SET "payer_member_id" = "sender_member_id" +WHERE "payer_member_id" IS NULL + AND "sender_member_id" IS NOT NULL; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index bc88796..5bf2f3d 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1773590603863, "tag": "0020_natural_mauler", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1774200000000, + "tag": "0021_sharp_payer", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 33df0cc..6a7b81f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -417,6 +417,9 @@ export const purchaseMessages = pgTable( senderMemberId: uuid('sender_member_id').references(() => members.id, { onDelete: 'set null' }), + payerMemberId: uuid('payer_member_id').references(() => members.id, { + onDelete: 'set null' + }), senderTelegramUserId: text('sender_telegram_user_id').notNull(), senderDisplayName: text('sender_display_name'), rawText: text('raw_text').notNull(),