diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index d45405d..b4af981 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import type { FinanceCommandService } from '@household/application' +import { Money } from '@household/domain' import type { HouseholdConfigurationRepository, ProcessedBotMessageRepository, @@ -247,57 +248,23 @@ function createFinanceService(): FinanceCommandService { generateDashboard: async () => ({ period: '2026-03', currency: 'GEL', - totalDue: { - toMajorString: () => '1000.00' - } as never, - totalPaid: { - toMajorString: () => '500.00' - } as never, - totalRemaining: { - toMajorString: () => '500.00' - } as never, - rentSourceAmount: { - currency: 'USD', - toMajorString: () => '700.00' - } as never, - rentDisplayAmount: { - toMajorString: () => '1890.00' - } as never, + totalDue: Money.fromMajor('1000.00', 'GEL'), + totalPaid: Money.fromMajor('500.00', 'GEL'), + totalRemaining: Money.fromMajor('500.00', 'GEL'), + rentSourceAmount: Money.fromMajor('700.00', 'USD'), + rentDisplayAmount: Money.fromMajor('1890.00', 'GEL'), rentFxRateMicros: null, rentFxEffectiveDate: null, members: [ { memberId: 'member-1', displayName: 'Stan', - rentShare: { - amountMinor: 70000n, - currency: 'GEL', - toMajorString: () => '700.00' - } as never, - utilityShare: { - amountMinor: 10000n, - currency: 'GEL', - toMajorString: () => '100.00' - } as never, - purchaseOffset: { - amountMinor: 5000n, - currency: 'GEL', - toMajorString: () => '50.00', - add: () => ({ - amountMinor: 15000n, - currency: 'GEL', - toMajorString: () => '150.00' - }) - } as never, - netDue: { - toMajorString: () => '850.00' - } as never, - paid: { - toMajorString: () => '500.00' - } as never, - remaining: { - toMajorString: () => '350.00' - } as never, + rentShare: Money.fromMajor('700.00', 'GEL'), + utilityShare: Money.fromMajor('100.00', 'GEL'), + purchaseOffset: Money.fromMajor('50.00', 'GEL'), + netDue: Money.fromMajor('850.00', 'GEL'), + paid: Money.fromMajor('500.00', 'GEL'), + remaining: Money.fromMajor('350.00', 'GEL'), explanations: [] } ], @@ -307,13 +274,9 @@ function createFinanceService(): FinanceCommandService { kind: 'purchase' as const, title: 'Soap', memberId: 'member-1', - amount: { - toMajorString: () => '30.00' - } as never, + amount: Money.fromMajor('30.00', 'GEL'), currency: 'GEL' as const, - displayAmount: { - toMajorString: () => '30.00' - } as never, + displayAmount: Money.fromMajor('30.00', 'GEL'), displayCurrency: 'GEL' as const, fxRateMicros: null, fxEffectiveDate: null, @@ -702,7 +665,7 @@ describe('registerDmAssistant', () => { expect(calls).toHaveLength(1) expect(calls[0]?.payload).toMatchObject({ - text: 'I can record this rent payment: 700.00 GEL. Confirm or cancel below.', + text: expect.stringContaining('I can record this rent payment: 700.00 GEL.'), reply_markup: { inline_keyboard: [ [ @@ -730,6 +693,44 @@ describe('registerDmAssistant', () => { }) }) + test('answers utilities balance questions deterministically in DM', 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: true + } as never + }) + + registerDmAssistant({ + bot, + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker() + }) + + await bot.handleUpdate(privateMessageUpdate('How much do I owe for utilities?') as never) + + const replyCall = calls.find((call) => call.method === 'sendMessage') + expect(replyCall).toBeDefined() + const replyText = String((replyCall?.payload as { text?: unknown } | undefined)?.text ?? '') + expect(replyText).toContain('Current utilities payment guidance:') + expect(replyText).toContain('Utilities due: 100.00 GEL') + expect(replyText).toContain('Purchase balance: 50.00 GEL') + expect(replyText).toContain('Suggested payment under utilities adjustment: 150.00 GEL') + }) + test('routes obvious purchase-like DMs into purchase confirmation flow', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index 677d471..99d23ce 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -1,4 +1,4 @@ -import type { FinanceCommandService } from '@household/application' +import { buildMemberPaymentGuidance, type FinanceCommandService } from '@household/application' import { instantFromEpochSeconds, Money } from '@household/domain' import type { Logger } from '@household/observability' import type { @@ -12,7 +12,13 @@ import { resolveReplyLocale } from './bot-locale' import { getBotTranslations, type BotLocale } from './i18n' import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant' import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter' -import { maybeCreatePaymentProposal, parsePaymentProposalPayload } from './payment-proposals' +import { + formatPaymentBalanceReplyText, + formatPaymentProposalText, + maybeCreatePaymentBalanceReply, + maybeCreatePaymentProposal, + parsePaymentProposalPayload +} from './payment-proposals' import type { PurchaseMessageIngestionRepository, PurchaseProposalActionResult, @@ -455,12 +461,34 @@ async function buildHouseholdContext(input: { const memberLine = dashboard.members.find((line) => line.memberId === input.memberId) if (memberLine) { + const rentGuidance = buildMemberPaymentGuidance({ + kind: 'rent', + period: dashboard.period, + memberLine, + settings + }) + const utilitiesGuidance = buildMemberPaymentGuidance({ + kind: 'utilities', + period: dashboard.period, + memberLine, + settings + }) + lines.push( `Member balance: due ${memberLine.netDue.toMajorString()} ${dashboard.currency}, paid ${memberLine.paid.toMajorString()} ${dashboard.currency}, remaining ${memberLine.remaining.toMajorString()} ${dashboard.currency}` ) lines.push( `Rent share: ${memberLine.rentShare.toMajorString()} ${dashboard.currency}; utility share: ${memberLine.utilityShare.toMajorString()} ${dashboard.currency}; purchase offset: ${memberLine.purchaseOffset.toMajorString()} ${dashboard.currency}` ) + lines.push( + `Payment adjustment policy: ${settings.paymentBalanceAdjustmentPolicy ?? 'utilities'}` + ) + lines.push( + `Rent payment guidance: base ${rentGuidance.baseAmount.toMajorString()} ${dashboard.currency}; purchase offset ${rentGuidance.purchaseOffset.toMajorString()} ${dashboard.currency}; suggested payment ${rentGuidance.proposalAmount.toMajorString()} ${dashboard.currency}; reminder ${rentGuidance.reminderDate}; due ${rentGuidance.dueDate}` + ) + lines.push( + `Utilities payment guidance: base ${utilitiesGuidance.baseAmount.toMajorString()} ${dashboard.currency}; purchase offset ${utilitiesGuidance.purchaseOffset.toMajorString()} ${dashboard.currency}; suggested payment ${utilitiesGuidance.proposalAmount.toMajorString()} ${dashboard.currency}; reminder ${utilitiesGuidance.reminderDate}; due ${utilitiesGuidance.dueDate}; payment_window_open=${utilitiesGuidance.paymentWindowOpen}` + ) } lines.push( @@ -993,6 +1021,29 @@ export function registerDmAssistant(options: { } const financeService = options.financeServiceForHousehold(member.householdId) + const paymentBalanceReply = await maybeCreatePaymentBalanceReply({ + rawText: ctx.msg.text, + householdId: member.householdId, + memberId: member.id, + financeService, + householdConfigurationRepository: options.householdConfigurationRepository + }) + + if (paymentBalanceReply) { + const replyText = formatPaymentBalanceReplyText(locale, paymentBalanceReply) + options.memoryStore.appendTurn(memoryKey, { + role: 'user', + text: ctx.msg.text + }) + options.memoryStore.appendTurn(memoryKey, { + role: 'assistant', + text: replyText + }) + + await ctx.reply(replyText) + return + } + const paymentProposal = await maybeCreatePaymentProposal({ rawText: ctx.msg.text, householdId: member.householdId, @@ -1027,15 +1078,11 @@ export function registerDmAssistant(options: { expiresAt: null }) - const amount = Money.fromMinor( - BigInt(paymentProposal.payload.amountMinor), - paymentProposal.payload.currency - ) - const proposalText = t.paymentProposal( - paymentProposal.payload.kind, - amount.toMajorString(), - amount.currency - ) + const proposalText = formatPaymentProposalText({ + locale, + surface: 'assistant', + proposal: paymentProposal + }) options.memoryStore.appendTurn(memoryKey, { role: 'user', text: ctx.msg.text @@ -1155,6 +1202,20 @@ export function registerDmAssistant(options: { } try { + const financeService = options.financeServiceForHousehold(household.householdId) + const paymentBalanceReply = await maybeCreatePaymentBalanceReply({ + rawText: mention.strippedText, + householdId: household.householdId, + memberId: member.id, + financeService, + householdConfigurationRepository: options.householdConfigurationRepository + }) + + if (paymentBalanceReply) { + await ctx.reply(formatPaymentBalanceReplyText(locale, paymentBalanceReply)) + return + } + await replyWithAssistant({ ctx, assistant: options.assistant, @@ -1166,7 +1227,7 @@ export function registerDmAssistant(options: { locale, userMessage: mention.strippedText, householdConfigurationRepository: options.householdConfigurationRepository, - financeService: options.financeServiceForHousehold(household.householdId), + financeService, memoryStore: options.memoryStore, usageTracker: options.usageTracker, logger: options.logger diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index 6e374ce..b951c15 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -267,6 +267,8 @@ export const enBotTranslations: BotTranslationCatalog = { payments: { topicMissing: 'Payments topic is not configured for this household yet. Ask an admin to run /bind_payments_topic.', + balanceReply: (kind) => + kind === 'rent' ? 'Current rent payment guidance:' : 'Current utilities payment guidance:', proposal: (kind, amount, currency) => `I can record this ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}. Confirm or cancel below.`, clarification: @@ -274,6 +276,24 @@ export const enBotTranslations: BotTranslationCatalog = { unsupportedCurrency: 'I can only record payments in the household settlement currency for this topic right now.', noBalance: 'There is no payable balance for that payment type right now.', + breakdownBase: (kind, amount, currency) => + `${kind === 'rent' ? 'Rent due' : 'Utilities due'}: ${amount} ${currency}`, + breakdownPurchaseBalance: (amount, currency) => `Purchase balance: ${amount} ${currency}`, + breakdownSuggestedTotal: (amount, currency, policy) => + `Suggested payment under ${policy}: ${amount} ${currency}`, + breakdownRecordingAmount: (amount, currency) => + `Amount from your message: ${amount} ${currency}`, + breakdownRemaining: (amount, currency) => `Total remaining balance: ${amount} ${currency}`, + adjustmentPolicy: (policy) => + policy === 'utilities' + ? 'utilities adjustment' + : policy === 'rent' + ? 'rent adjustment' + : 'separate purchase settlement', + timingBeforeWindow: (kind, reminderDate, dueDate) => + `${kind === 'rent' ? 'Rent' : 'Utilities'} are not due yet. Next reminder: ${reminderDate}. Due date: ${dueDate}.`, + timingDueNow: (kind, dueDate) => + `${kind === 'rent' ? 'Rent' : 'Utilities'} are due now. Due date: ${dueDate}.`, confirmButton: 'Confirm payment', cancelButton: 'Cancel', recorded: (kind, amount, currency) => diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index d2ba539..3236ad1 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -270,6 +270,8 @@ export const ruBotTranslations: BotTranslationCatalog = { payments: { topicMissing: 'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.', + balanceReply: (kind) => + kind === 'rent' ? 'Текущая сводка по аренде:' : 'Текущая сводка по коммуналке:', proposal: (kind, amount, currency) => `Я могу записать эту оплату ${kind === 'rent' ? 'аренды' : 'коммуналки'}: ${amount} ${currency}. Подтвердите или отмените ниже.`, clarification: @@ -277,6 +279,25 @@ export const ruBotTranslations: BotTranslationCatalog = { unsupportedCurrency: 'Сейчас я могу записывать оплаты в этом топике только в валюте расчётов по дому.', noBalance: 'Сейчас для этого типа оплаты нет суммы к подтверждению.', + breakdownBase: (kind, amount, currency) => + `${kind === 'rent' ? 'Аренда к оплате' : 'Коммуналка к оплате'}: ${amount} ${currency}`, + breakdownPurchaseBalance: (amount, currency) => + `Баланс по общим покупкам: ${amount} ${currency}`, + breakdownSuggestedTotal: (amount, currency, policy) => + `Рекомендуемая сумма по политике «${policy}»: ${amount} ${currency}`, + breakdownRecordingAmount: (amount, currency) => + `Сумма из вашего сообщения: ${amount} ${currency}`, + breakdownRemaining: (amount, currency) => `Общий остаток: ${amount} ${currency}`, + adjustmentPolicy: (policy) => + policy === 'utilities' + ? 'зачёт через коммуналку' + : policy === 'rent' + ? 'зачёт через аренду' + : 'отдельный расчёт по покупкам', + timingBeforeWindow: (kind, reminderDate, dueDate) => + `${kind === 'rent' ? 'Аренду' : 'Коммуналку'} пока рано оплачивать. Следующее напоминание: ${reminderDate}. Срок оплаты: ${dueDate}.`, + timingDueNow: (kind, dueDate) => + `${kind === 'rent' ? 'Аренду' : 'Коммуналку'} уже пора оплачивать. Срок оплаты: ${dueDate}.`, confirmButton: 'Подтвердить оплату', cancelButton: 'Отменить', recorded: (kind, amount, currency) => diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 8cb32e6..0cdb18b 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -254,11 +254,24 @@ export interface BotTranslationCatalog { } payments: { topicMissing: string + balanceReply: (kind: 'rent' | 'utilities') => string recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string proposal: (kind: 'rent' | 'utilities', amount: string, currency: string) => string clarification: string unsupportedCurrency: string noBalance: string + breakdownBase: (kind: 'rent' | 'utilities', amount: string, currency: string) => string + breakdownPurchaseBalance: (amount: string, currency: string) => string + breakdownSuggestedTotal: (amount: string, currency: string, policy: string) => string + breakdownRecordingAmount: (amount: string, currency: string) => string + breakdownRemaining: (amount: string, currency: string) => string + adjustmentPolicy: (policy: 'utilities' | 'rent' | 'separate') => string + timingBeforeWindow: ( + kind: 'rent' | 'utilities', + reminderDate: string, + dueDate: string + ) => string + timingDueNow: (kind: 'rent' | 'utilities', dueDate: string) => string confirmButton: string cancelButton: string cancelled: string diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index 9f742fd..fd4ea1e 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -452,7 +452,8 @@ describe('createMiniAppSettingsHandler', () => { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + paymentBalanceAdjustmentPolicy: 'utilities' }, topics: [ { @@ -547,7 +548,8 @@ describe('createMiniAppUpdateSettingsHandler', () => { rentWarningDay: 19, utilitiesDueDay: 6, utilitiesReminderDay: 5, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + paymentBalanceAdjustmentPolicy: 'utilities' } }) }) diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index 556331d..8dd2259 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -50,6 +50,7 @@ async function readApprovalPayload(request: Request): Promise<{ async function readSettingsUpdatePayload(request: Request): Promise<{ initData: string settlementCurrency?: string + paymentBalanceAdjustmentPolicy?: string rentAmountMajor?: string rentCurrency?: string rentDueDay: number @@ -67,6 +68,7 @@ async function readSettingsUpdatePayload(request: Request): Promise<{ const text = await clonedRequest.text() let parsed: { settlementCurrency?: string + paymentBalanceAdjustmentPolicy?: string rentAmountMajor?: string rentCurrency?: string rentDueDay?: number @@ -103,6 +105,11 @@ async function readSettingsUpdatePayload(request: Request): Promise<{ settlementCurrency: parsed.settlementCurrency } : {}), + ...(typeof parsed.paymentBalanceAdjustmentPolicy === 'string' + ? { + paymentBalanceAdjustmentPolicy: parsed.paymentBalanceAdjustmentPolicy + } + : {}), ...(typeof parsed.rentCurrency === 'string' ? { rentCurrency: parsed.rentCurrency @@ -299,6 +306,7 @@ function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) { return { householdId: settings.householdId, settlementCurrency: settings.settlementCurrency, + paymentBalanceAdjustmentPolicy: settings.paymentBalanceAdjustmentPolicy ?? 'utilities', rentAmountMinor: settings.rentAmountMinor?.toString() ?? null, rentCurrency: settings.rentCurrency, rentDueDay: settings.rentDueDay, @@ -555,6 +563,11 @@ export function createMiniAppUpdateSettingsHandler(options: { settlementCurrency: payload.settlementCurrency } : {}), + ...(payload.paymentBalanceAdjustmentPolicy + ? { + paymentBalanceAdjustmentPolicy: payload.paymentBalanceAdjustmentPolicy + } + : {}), ...(payload.rentAmountMajor !== undefined ? { rentAmountMajor: payload.rentAmountMajor diff --git a/apps/bot/src/payment-proposals.ts b/apps/bot/src/payment-proposals.ts index c2ba3fd..42438ee 100644 --- a/apps/bot/src/payment-proposals.ts +++ b/apps/bot/src/payment-proposals.ts @@ -1,7 +1,34 @@ -import { parsePaymentConfirmationMessage, type FinanceCommandService } from '@household/application' +import { + buildMemberPaymentGuidance, + parsePaymentConfirmationMessage, + type FinanceCommandService, + type MemberPaymentGuidance +} from '@household/application' import { Money } from '@household/domain' import type { HouseholdConfigurationRepository } from '@household/ports' +import { getBotTranslations, type BotLocale } from './i18n' + +const RENT_BALANCE_KEYWORDS = [/\b(rent|housing|apartment|landlord)\b/i, /аренд/i, /жиль[её]/i] +const UTILITIES_BALANCE_KEYWORDS = [ + /\b(utilities|utility|gas|water|electricity|internet|cleaning)\b/i, + /коммун/i, + /газ/i, + /вод/i, + /элект/i, + /свет/i, + /интернет/i, + /уборк/i +] +const BALANCE_QUESTION_KEYWORDS = [ + /\?/, + /\b(how much|owe|due|balance|remaining)\b/i, + /сколько/i, + /долж/i, + /баланс/i, + /остат/i +] + export interface PaymentProposalPayload { proposalId: string householdId: string @@ -11,6 +38,16 @@ export interface PaymentProposalPayload { currency: 'GEL' | 'USD' } +export interface PaymentProposalBreakdown { + guidance: MemberPaymentGuidance + explicitAmount: Money | null +} + +export interface PaymentBalanceReply { + kind: 'rent' | 'utilities' + guidance: MemberPaymentGuidance +} + export function parsePaymentProposalPayload( payload: Record ): PaymentProposalPayload | null { @@ -39,6 +76,147 @@ export function parsePaymentProposalPayload( } } +function hasMatch(patterns: readonly RegExp[], value: string): boolean { + return patterns.some((pattern) => pattern.test(value)) +} + +function detectBalanceQuestionKind(rawText: string): 'rent' | 'utilities' | null { + const normalized = rawText.trim() + if (normalized.length === 0 || !hasMatch(BALANCE_QUESTION_KEYWORDS, normalized)) { + return null + } + + const mentionsRent = hasMatch(RENT_BALANCE_KEYWORDS, normalized) + const mentionsUtilities = hasMatch(UTILITIES_BALANCE_KEYWORDS, normalized) + + if (mentionsRent === mentionsUtilities) { + return null + } + + return mentionsRent ? 'rent' : 'utilities' +} + +function formatDateLabel(locale: BotLocale, rawDate: string): string { + const [yearRaw, monthRaw, dayRaw] = rawDate.split('-') + const year = Number(yearRaw) + const month = Number(monthRaw) + const day = Number(dayRaw) + + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) || + month < 1 || + month > 12 || + day < 1 || + day > 31 + ) { + return rawDate + } + + return new Intl.DateTimeFormat(locale === 'ru' ? 'ru-RU' : 'en-US', { + day: 'numeric', + month: 'long', + timeZone: 'UTC' + }).format(new Date(Date.UTC(year, month - 1, day))) +} + +function formatPaymentBreakdown(locale: BotLocale, breakdown: PaymentProposalBreakdown): string { + const t = getBotTranslations(locale).payments + const policyLabel = t.adjustmentPolicy(breakdown.guidance.adjustmentPolicy) + const lines = [ + t.breakdownBase( + breakdown.guidance.kind, + breakdown.guidance.baseAmount.toMajorString(), + breakdown.guidance.baseAmount.currency + ), + t.breakdownPurchaseBalance( + breakdown.guidance.purchaseOffset.toMajorString(), + breakdown.guidance.purchaseOffset.currency + ), + t.breakdownSuggestedTotal( + breakdown.guidance.proposalAmount.toMajorString(), + breakdown.guidance.proposalAmount.currency, + policyLabel + ), + t.breakdownRemaining( + breakdown.guidance.totalRemaining.toMajorString(), + breakdown.guidance.totalRemaining.currency + ) + ] + + if ( + breakdown.explicitAmount && + !breakdown.explicitAmount.equals(breakdown.guidance.proposalAmount) + ) { + lines.push( + t.breakdownRecordingAmount( + breakdown.explicitAmount.toMajorString(), + breakdown.explicitAmount.currency + ) + ) + } + + if (!breakdown.guidance.paymentWindowOpen) { + lines.push( + t.timingBeforeWindow( + breakdown.guidance.kind, + formatDateLabel(locale, breakdown.guidance.reminderDate), + formatDateLabel(locale, breakdown.guidance.dueDate) + ) + ) + } else if (breakdown.guidance.paymentDue) { + lines.push( + t.timingDueNow(breakdown.guidance.kind, formatDateLabel(locale, breakdown.guidance.dueDate)) + ) + } + + return lines.join('\n') +} + +export function formatPaymentProposalText(input: { + locale: BotLocale + surface: 'assistant' | 'topic' + proposal: { + payload: PaymentProposalPayload + breakdown: PaymentProposalBreakdown + } +}): string { + const amount = Money.fromMinor( + BigInt(input.proposal.payload.amountMinor), + input.proposal.payload.currency + ) + const intro = + input.surface === 'assistant' + ? getBotTranslations(input.locale).assistant.paymentProposal( + input.proposal.payload.kind, + amount.toMajorString(), + amount.currency + ) + : getBotTranslations(input.locale).payments.proposal( + input.proposal.payload.kind, + amount.toMajorString(), + amount.currency + ) + + return `${intro}\n\n${formatPaymentBreakdown(input.locale, input.proposal.breakdown)}` +} + +export function formatPaymentBalanceReplyText( + locale: BotLocale, + reply: PaymentBalanceReply +): string { + const t = getBotTranslations(locale).payments + + return [ + t.balanceReply(reply.kind), + formatPaymentBreakdown(locale, { + guidance: reply.guidance, + explicitAmount: null + }) + ].join('\n\n') +} + export async function maybeCreatePaymentProposal(input: { rawText: string householdId: string @@ -61,6 +239,7 @@ export async function maybeCreatePaymentProposal(input: { | { status: 'proposal' payload: PaymentProposalPayload + breakdown: PaymentProposalBreakdown } > { const settings = await input.householdConfigurationRepository.getHouseholdBillingSettings( @@ -100,11 +279,13 @@ export async function maybeCreatePaymentProposal(input: { } } - const amount = - parsed.explicitAmount ?? - (parsed.kind === 'rent' - ? memberLine.rentShare - : memberLine.utilityShare.add(memberLine.purchaseOffset)) + const guidance = buildMemberPaymentGuidance({ + kind: parsed.kind, + period: dashboard.period, + memberLine, + settings + }) + const amount = parsed.explicitAmount ?? guidance.proposalAmount if (amount.amountMinor <= 0n) { return { @@ -121,10 +302,50 @@ export async function maybeCreatePaymentProposal(input: { kind: parsed.kind, amountMinor: amount.amountMinor.toString(), currency: amount.currency + }, + breakdown: { + guidance, + explicitAmount: parsed.explicitAmount } } } +export async function maybeCreatePaymentBalanceReply(input: { + rawText: string + householdId: string + memberId: string + financeService: FinanceCommandService + householdConfigurationRepository: HouseholdConfigurationRepository +}): Promise { + const kind = detectBalanceQuestionKind(input.rawText) + if (!kind) { + return null + } + + const [settings, dashboard] = await Promise.all([ + input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId), + input.financeService.generateDashboard() + ]) + if (!dashboard) { + return null + } + + const memberLine = dashboard.members.find((line) => line.memberId === input.memberId) + if (!memberLine) { + return null + } + + return { + kind, + guidance: buildMemberPaymentGuidance({ + kind, + period: dashboard.period, + memberLine, + settings + }) + } +} + export function synthesizePaymentConfirmationText(payload: PaymentProposalPayload): string { const amount = Money.fromMinor(BigInt(payload.amountMinor), payload.currency) const kindText = payload.kind === 'rent' ? 'rent' : 'utilities' diff --git a/apps/bot/src/payment-topic-ingestion.test.ts b/apps/bot/src/payment-topic-ingestion.test.ts index 0d6a96e..b4d72ab 100644 --- a/apps/bot/src/payment-topic-ingestion.test.ts +++ b/apps/bot/src/payment-topic-ingestion.test.ts @@ -332,7 +332,7 @@ describe('registerConfiguredPaymentTopicIngestion', () => { reply_parameters: { message_id: 55 }, - text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.', + text: expect.stringContaining('Я могу записать эту оплату аренды: 472.50 GEL.'), reply_markup: { inline_keyboard: [ [ @@ -407,7 +407,7 @@ describe('registerConfiguredPaymentTopicIngestion', () => { text: 'Пока не могу подтвердить эту оплату. Уточните, это аренда или коммуналка, и при необходимости напишите сумму и валюту.' }) expect(calls[1]?.payload).toMatchObject({ - text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.' + text: expect.stringContaining('Я могу записать эту оплату аренды: 472.50 GEL.') }) }) @@ -639,7 +639,7 @@ describe('registerConfiguredPaymentTopicIngestion', () => { expect(calls).toHaveLength(1) expect(calls[0]?.payload).toMatchObject({ - text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.' + text: expect.stringContaining('Я могу записать эту оплату аренды: 472.50 GEL.') }) }) }) diff --git a/apps/bot/src/payment-topic-ingestion.ts b/apps/bot/src/payment-topic-ingestion.ts index 4d77063..d3f92bc 100644 --- a/apps/bot/src/payment-topic-ingestion.ts +++ b/apps/bot/src/payment-topic-ingestion.ts @@ -1,5 +1,4 @@ import type { FinanceCommandService, PaymentConfirmationService } from '@household/application' -import { Money } from '@household/domain' import { instantFromEpochSeconds, nowInstant, type Instant } from '@household/domain' import type { Bot, Context } from 'grammy' import type { Logger } from '@household/observability' @@ -11,6 +10,7 @@ import type { import { getBotTranslations, type BotLocale } from './i18n' import { + formatPaymentProposalText, maybeCreatePaymentProposal, parsePaymentProposalPayload, synthesizePaymentConfirmationText @@ -485,10 +485,6 @@ export function registerConfiguredPaymentTopicIngestion( } if (proposal.status === 'proposal') { - const amount = Money.fromMinor( - BigInt(proposal.payload.amountMinor), - proposal.payload.currency - ) await promptRepository.upsertPendingAction({ telegramUserId: record.senderTelegramUserId, telegramChatId: record.chatId, @@ -508,7 +504,11 @@ export function registerConfiguredPaymentTopicIngestion( await replyToPaymentMessage( ctx, - t.proposal(proposal.payload.kind, amount.toMajorString(), amount.currency), + formatPaymentProposalText({ + locale, + surface: 'topic', + proposal + }), paymentProposalReplyMarkup(locale, proposal.payload.proposalId) ) } diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 09a4191..30b3719 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -345,6 +345,7 @@ function App() { const [addingPayment, setAddingPayment] = createSignal(false) const [billingForm, setBillingForm] = createSignal({ settlementCurrency: 'GEL' as 'USD' | 'GEL', + paymentBalanceAdjustmentPolicy: 'utilities' as 'utilities' | 'rent' | 'separate', rentAmountMajor: '', rentCurrency: 'USD' as 'USD' | 'GEL', rentDueDay: 20, @@ -530,6 +531,7 @@ function App() { })) setBillingForm({ settlementCurrency: payload.settings.settlementCurrency, + paymentBalanceAdjustmentPolicy: payload.settings.paymentBalanceAdjustmentPolicy, rentAmountMajor: payload.settings.rentAmountMinor ? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2) : '', @@ -2273,6 +2275,29 @@ function App() { +