feat(bot): quiet finance topics and support purchase payers

This commit is contained in:
2026-03-22 20:27:43 +04:00
parent 7d706eba07
commit 7665af0268
22 changed files with 1044 additions and 81 deletions

View File

@@ -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 bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []
let assistantCalls = 0 let assistantCalls = 0
@@ -1493,17 +1493,8 @@ Confirm or cancel below.`,
await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never) await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never)
expect(processorCalls).toBe(1) expect(processorCalls).toBe(1)
expect(assistantCalls).toBe(1) expect(assistantCalls).toBe(0)
expect(calls).toEqual( expect(calls).toHaveLength(0)
expect.arrayContaining([
expect.objectContaining({
method: 'sendMessage',
payload: expect.objectContaining({
text: 'Still here.'
})
})
])
)
}) })
test('stays silent for regular group chatter when the bot is not addressed', async () => { test('stays silent for regular group chatter when the bot is not addressed', async () => {

View File

@@ -1132,6 +1132,7 @@ export function registerDmAssistant(options: {
? getBotTranslations(locale).purchase.proposal( ? getBotTranslations(locale).purchase.proposal(
formatPurchaseSummary(locale, purchaseResult), formatPurchaseSummary(locale, purchaseResult),
null, null,
null,
null null
) )
: purchaseResult.status === 'clarification_needed' : purchaseResult.status === 'clarification_needed'
@@ -1502,6 +1503,7 @@ export function registerDmAssistant(options: {
const fallbackText = getBotTranslations(locale).purchase.proposal( const fallbackText = getBotTranslations(locale).purchase.proposal(
formatPurchaseSummary(locale, purchaseResult), formatPurchaseSummary(locale, purchaseResult),
null, null,
null,
null null
) )
const purchaseText = await composeAssistantReplyText({ const purchaseText = await composeAssistantReplyText({

View File

@@ -875,8 +875,7 @@ describe('registerHouseholdSetupCommands', () => {
} }
}) })
expect(sendPayload.text).toContain('New household! **Kojori House** is ready.') expect(sendPayload.text).toContain('New household! **Kojori House** is ready.')
expect(sendPayload.text).toContain('Current setup progress: 0/5') expect(sendPayload.text).toContain('Current setup progress: 0/4')
expect(sendPayload.text).toContain('0/5')
expect(sendPayload.text).toContain('⚪ Purchases') expect(sendPayload.text).toContain('⚪ Purchases')
expect(sendPayload.text).toContain('⚪ Payments') expect(sendPayload.text).toContain('⚪ Payments')
// Check that join household button exists // Check that join household button exists

View File

@@ -22,7 +22,6 @@ const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:' const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:'
const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [ const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [
'chat',
'purchase', 'purchase',
'feedback', 'feedback',
'reminders', 'reminders',
@@ -1104,7 +1103,7 @@ export function registerHouseholdSetupCommands(options: {
) )
options.bot.callbackQuery( options.bot.callbackQuery(
/^bind_topic:(chat|purchase|feedback|reminders|payments):(\d+)$/, /^bind_topic:(purchase|feedback|reminders|payments):(\d+)$/,
async (ctx) => { async (ctx) => {
const locale = await resolveReplyLocale({ const locale = await resolveReplyLocale({
ctx, ctx,

View File

@@ -276,8 +276,13 @@ export const enBotTranslations: BotTranslationCatalog = {
purchase: { purchase: {
sharedPurchaseFallback: 'shared purchase', sharedPurchaseFallback: 'shared purchase',
processing: 'Checking that purchase...', processing: 'Checking that purchase...',
proposal: (summary: string, calculationNote: string | null, participants: string | null) => proposal: (
`I think this shared purchase was: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`, 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) => calculatedAmountNote: (explanation: string | null) =>
explanation explanation
? `I calculated the total as ${explanation}. Is that right?` ? `I calculated the total as ${explanation}. Is that right?`
@@ -295,6 +300,12 @@ export const enBotTranslations: BotTranslationCatalog = {
participantExcluded: (displayName) => `- ${displayName} (excluded)`, participantExcluded: (displayName) => `- ${displayName} (excluded)`,
participantToggleIncluded: (displayName) => `${displayName}`, participantToggleIncluded: (displayName) => `${displayName}`,
participantToggleExcluded: (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', confirmButton: 'Confirm',
calculatedConfirmButton: 'Looks right', calculatedConfirmButton: 'Looks right',
calculatedFixAmountButton: 'Fix amount', calculatedFixAmountButton: 'Fix amount',

View File

@@ -280,8 +280,13 @@ export const ruBotTranslations: BotTranslationCatalog = {
purchase: { purchase: {
sharedPurchaseFallback: 'общая покупка', sharedPurchaseFallback: 'общая покупка',
processing: 'Проверяю покупку...', processing: 'Проверяю покупку...',
proposal: (summary: string, calculationNote: string | null, participants: string | null) => proposal: (
`Похоже, это общая покупка: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\одтвердите или отмените ниже.`, summary: string,
payer: string | null,
calculationNote: string | null,
participants: string | null
) =>
`Похоже, это общая покупка: ${summary}.${payer ? `\n${payer}` : ''}${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\одтвердите или отмените ниже.`,
calculatedAmountNote: (explanation: string | null) => calculatedAmountNote: (explanation: string | null) =>
explanation explanation
? `Я посчитал итог как ${explanation}. Всё верно?` ? `Я посчитал итог как ${explanation}. Всё верно?`
@@ -299,6 +304,12 @@ export const ruBotTranslations: BotTranslationCatalog = {
participantExcluded: (displayName) => `- ${displayName} (не участвует)`, participantExcluded: (displayName) => `- ${displayName} (не участвует)`,
participantToggleIncluded: (displayName) => `${displayName}`, participantToggleIncluded: (displayName) => `${displayName}`,
participantToggleExcluded: (displayName) => `${displayName}`, participantToggleExcluded: (displayName) => `${displayName}`,
payerHeading: 'Кто оплатил:',
payerSelected: (displayName) => `Оплатил: ${displayName}`,
payerQuestion: 'Кто именно это купил?',
payerFallbackQuestion: 'Не понял, кто именно это купил. Выберите человека ниже.',
payerButton: (displayName) => `Оплатил ${displayName}`,
payerSelectedToast: (displayName) => `Записал покупателя: ${displayName}.`,
confirmButton: 'Подтвердить', confirmButton: 'Подтвердить',
calculatedConfirmButton: 'Верно', calculatedConfirmButton: 'Верно',
calculatedFixAmountButton: 'Исправить сумму', calculatedFixAmountButton: 'Исправить сумму',

View File

@@ -264,6 +264,7 @@ export interface BotTranslationCatalog {
processing: string processing: string
proposal: ( proposal: (
summary: string, summary: string,
payer: string | null,
calculationNote: string | null, calculationNote: string | null,
participants: string | null participants: string | null
) => string ) => string
@@ -279,6 +280,12 @@ export interface BotTranslationCatalog {
participantExcluded: (displayName: string) => string participantExcluded: (displayName: string) => string
participantToggleIncluded: (displayName: string) => string participantToggleIncluded: (displayName: string) => string
participantToggleExcluded: (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 confirmButton: string
calculatedConfirmButton: string calculatedConfirmButton: string
calculatedFixAmountButton: string calculatedFixAmountButton: string

View File

@@ -15,10 +15,29 @@ import type {
import { createMiniAppDashboardHandler } from './miniapp-dashboard' import { createMiniAppDashboardHandler } from './miniapp-dashboard'
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' 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( function repository(
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>> member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
): FinanceRepository { ): FinanceRepository {
const cycle = { let cycle: Awaited<ReturnType<FinanceRepository['getOpenCycle']>> extends infer T
? Exclude<T, null>
: never = {
id: 'cycle-1', id: 'cycle-1',
period: '2026-03', period: '2026-03',
currency: 'GEL' as const currency: 'GEL' as const
@@ -38,7 +57,13 @@ function repository(
getOpenCycle: async () => cycle, getOpenCycle: async () => cycle,
getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null), getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null),
getLatestCycle: async () => cycle, getLatestCycle: async () => cycle,
openCycle: async () => {}, openCycle: async (period, currency) => {
cycle = {
id: 'opened-cycle',
period,
currency
}
},
closeCycle: async () => {}, closeCycle: async () => {},
saveRentRule: async () => {}, saveRentRule: async () => {},
getCycleExchangeRate: async () => null, getCycleExchangeRate: async () => null,
@@ -326,7 +351,7 @@ describe('createMiniAppDashboardHandler', () => {
ok: true, ok: true,
authorized: true, authorized: true,
dashboard: { dashboard: {
period: '2026-03', period: expectedCurrentCyclePeriod('Asia/Tbilisi', 20),
currency: 'GEL', currency: 'GEL',
paymentBalanceAdjustmentPolicy: 'utilities', paymentBalanceAdjustmentPolicy: 'utilities',
totalDueMajor: '2010.00', totalDueMajor: '2010.00',

View File

@@ -14,6 +14,7 @@ export interface PurchaseInterpretation {
amountMinor: bigint | null amountMinor: bigint | null
currency: 'GEL' | 'USD' | null currency: 'GEL' | 'USD' | null
itemDescription: string | null itemDescription: string | null
payerMemberId?: string | null
amountSource?: PurchaseInterpretationAmountSource | null amountSource?: PurchaseInterpretationAmountSource | null
calculationExplanation?: string | null calculationExplanation?: string | null
participantMemberIds?: readonly string[] | null participantMemberIds?: readonly string[] | null
@@ -43,6 +44,7 @@ interface OpenAiStructuredResult {
amountMinor: string | null amountMinor: string | null
currency: 'GEL' | 'USD' | null currency: 'GEL' | 'USD' | null
itemDescription: string | null itemDescription: string | null
payerMemberId: string | null
amountSource: PurchaseInterpretationAmountSource | null amountSource: PurchaseInterpretationAmountSource | null
calculationExplanation: string | null calculationExplanation: string | null
participantMemberIds: string[] | null participantMemberIds: string[] | null
@@ -104,6 +106,26 @@ function normalizeParticipantMemberIds(
return normalized.length > 0 ? normalized : null 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: { function resolveMissingCurrency(input: {
decision: PurchaseInterpretationDecision decision: PurchaseInterpretationDecision
amountMinor: bigint | null 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 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 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 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.', '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.', '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.', 'Away members may still be included when the user explicitly names them.',
@@ -260,6 +283,9 @@ export function createOpenAiPurchaseInterpreter(
itemDescription: { itemDescription: {
anyOf: [{ type: 'string' }, { type: 'null' }] anyOf: [{ type: 'string' }, { type: 'null' }]
}, },
payerMemberId: {
anyOf: [{ type: 'string' }, { type: 'null' }]
},
amountSource: { amountSource: {
anyOf: [ anyOf: [
{ {
@@ -295,6 +321,7 @@ export function createOpenAiPurchaseInterpreter(
'amountMinor', 'amountMinor',
'currency', 'currency',
'itemDescription', 'itemDescription',
'payerMemberId',
'amountSource', 'amountSource',
'calculationExplanation', 'calculationExplanation',
'participantMemberIds', 'participantMemberIds',
@@ -339,6 +366,7 @@ export function createOpenAiPurchaseInterpreter(
const amountMinor = asOptionalBigInt(parsedJson.amountMinor) const amountMinor = asOptionalBigInt(parsedJson.amountMinor)
const itemDescription = normalizeOptionalText(parsedJson.itemDescription) const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
const payerMemberId = normalizePayerMemberId(parsedJson.payerMemberId, options.householdMembers)
const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor) const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor)
const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation) const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation)
const participantMemberIds = normalizeParticipantMemberIds( const participantMemberIds = normalizeParticipantMemberIds(
@@ -376,6 +404,10 @@ export function createOpenAiPurchaseInterpreter(
clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null
} }
if (payerMemberId) {
result.payerMemberId = payerMemberId
}
if (participantMemberIds) { if (participantMemberIds) {
result.participantMemberIds = participantMemberIds result.participantMemberIds = participantMemberIds
} }

View File

@@ -174,6 +174,28 @@ function formatPaymentBreakdown(locale: BotLocale, breakdown: PaymentProposalBre
return lines.join('\n') 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: { export function formatPaymentProposalText(input: {
locale: BotLocale locale: BotLocale
surface: 'assistant' | 'topic' surface: 'assistant' | 'topic'
@@ -199,6 +221,15 @@ export function formatPaymentProposalText(input: {
amount.currency amount.currency
) )
if (
shouldUseCompactTopicProposal({
surface: input.surface,
breakdown: input.proposal.breakdown
})
) {
return intro
}
return `${intro}\n\n${formatPaymentBreakdown(input.locale, input.proposal.breakdown)}` return `${intro}\n\n${formatPaymentBreakdown(input.locale, input.proposal.breakdown)}`
} }

View File

@@ -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({ expect(await promptRepository.getPendingAction('-10012345', '10002')).toMatchObject({
action: 'payment_topic_confirmation' action: 'payment_topic_confirmation'

View File

@@ -21,7 +21,7 @@ import {
parsePaymentProposalPayload, parsePaymentProposalPayload,
synthesizePaymentConfirmationText synthesizePaymentConfirmationText
} from './payment-proposals' } from './payment-proposals'
import type { TopicMessageRouter } from './topic-message-router' import { cacheTopicMessageRoute, type TopicMessageRouter } from './topic-message-router'
import { import {
persistTopicHistoryMessage, persistTopicHistoryMessage,
telegramMessageIdFromMessage, telegramMessageIdFromMessage,
@@ -662,6 +662,15 @@ export function registerConfiguredPaymentTopicIngestion(
// Handle different routes // Handle different routes
switch (processorResult.route) { switch (processorResult.route) {
case 'silent': { 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() await next()
return return
} }

View File

@@ -60,6 +60,7 @@ function purchaseUpdate(
options: { options: {
replyToBot?: boolean replyToBot?: boolean
threadId?: number threadId?: number
asCaption?: boolean
} = {} } = {}
) { ) {
const commandToken = text.split(' ')[0] ?? text const commandToken = text.split(' ')[0] ?? text
@@ -99,16 +100,30 @@ function purchaseUpdate(
} }
} }
: {}), : {}),
text, ...(options.asCaption
entities: text.startsWith('/') ? {
? [ caption: text,
{ photo: [
offset: 0, {
length: commandToken.length, file_id: 'photo-1',
type: 'bot_command' 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<Array<{ text: string; callback_data: string }>>
}
}
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 () => { test('keeps bare-amount purchase reports on the ingestion path', async () => {
const bot = createTestBot() const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,28 @@ export function fallbackTopicMessageRoute(
reason: 'active_purchase_workflow' 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') { if (input.topicRole === 'payments') {
@@ -123,6 +145,28 @@ export function fallbackTopicMessageRoute(
reason: 'active_payment_workflow' 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 ( if (

View File

@@ -295,11 +295,10 @@ export function createTopicProcessor(
- The message reports a completed purchase or payment (your primary purpose in these topics) - 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) - 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 - 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 - Regular chat between users (plans, greetings, discussion) → silent
=== PURCHASE TOPIC (topicRole=purchase) === === 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. 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. - Completed buy verbs: купил, bought, ordered, picked up, spent, взял, заказал, потратил, сходил взял, etc.
- Realistic household items: food, groceries, household goods, toiletries, medicine, transport, cafe, restaurant - 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 - Gifts for household members ARE shared purchases
- Plans, wishes, future intent → silent (NOT purchases) - Plans, wishes, future intent → silent (NOT purchases)
- Fantastical items (car, plane, island) or excessive amounts (>500) → chat_reply with playful response - 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": When classifying as "purchase":
- amountMinor in minor currency units (350 GEL → 35000, 3.50 → 350) - 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 - Use clarification when amount, item, or intent is unclear but purchase seems likely
=== PAYMENT TOPIC (topicRole=payments) === === 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". If the message reports a completed rent or utility payment (payment verb + rent/utilities + amount), classify as "payment".
- Payment verbs: оплатил, paid, заплатил, перевёл, кинул, отправил - Payment verbs: оплатил, paid, заплатил, перевёл, кинул, отправил
- Realistic amount for rent/utilities - 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 === === 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. 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 === === 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 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 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. For questions that need household knowledge, use topic_helper.

View File

@@ -355,6 +355,7 @@ export function createDbFinanceRepository(
id: purchaseId, id: purchaseId,
householdId, householdId,
senderMemberId: input.payerMemberId, senderMemberId: input.payerMemberId,
payerMemberId: input.payerMemberId,
senderTelegramUserId: 'miniapp', senderTelegramUserId: 'miniapp',
senderDisplayName: member?.displayName ?? 'Mini App', senderDisplayName: member?.displayName ?? 'Mini App',
telegramChatId: 'miniapp', telegramChatId: 'miniapp',
@@ -388,7 +389,7 @@ export function createDbFinanceRepository(
const rows = await db const rows = await db
.select({ .select({
id: schema.purchaseMessages.id, id: schema.purchaseMessages.id,
payerMemberId: schema.purchaseMessages.senderMemberId, payerMemberId: schema.purchaseMessages.payerMemberId,
amountMinor: schema.purchaseMessages.parsedAmountMinor, amountMinor: schema.purchaseMessages.parsedAmountMinor,
currency: schema.purchaseMessages.parsedCurrency, currency: schema.purchaseMessages.parsedCurrency,
description: schema.purchaseMessages.parsedItemDescription, description: schema.purchaseMessages.parsedItemDescription,
@@ -443,7 +444,8 @@ export function createDbFinanceRepository(
: {}), : {}),
...(input.payerMemberId ...(input.payerMemberId
? { ? {
senderMemberId: input.payerMemberId senderMemberId: input.payerMemberId,
payerMemberId: input.payerMemberId
} }
: {}), : {}),
needsReview: 0, needsReview: 0,
@@ -458,7 +460,7 @@ export function createDbFinanceRepository(
) )
.returning({ .returning({
id: schema.purchaseMessages.id, id: schema.purchaseMessages.id,
payerMemberId: schema.purchaseMessages.senderMemberId, payerMemberId: schema.purchaseMessages.payerMemberId,
amountMinor: schema.purchaseMessages.parsedAmountMinor, amountMinor: schema.purchaseMessages.parsedAmountMinor,
currency: schema.purchaseMessages.parsedCurrency, currency: schema.purchaseMessages.parsedCurrency,
description: schema.purchaseMessages.parsedItemDescription, description: schema.purchaseMessages.parsedItemDescription,
@@ -763,7 +765,7 @@ export function createDbFinanceRepository(
const rows = await db const rows = await db
.select({ .select({
id: schema.purchaseMessages.id, id: schema.purchaseMessages.id,
payerMemberId: schema.purchaseMessages.senderMemberId, payerMemberId: schema.purchaseMessages.payerMemberId,
amountMinor: schema.purchaseMessages.parsedAmountMinor, amountMinor: schema.purchaseMessages.parsedAmountMinor,
currency: schema.purchaseMessages.parsedCurrency, currency: schema.purchaseMessages.parsedCurrency,
description: schema.purchaseMessages.parsedItemDescription, description: schema.purchaseMessages.parsedItemDescription,
@@ -774,7 +776,7 @@ export function createDbFinanceRepository(
.where( .where(
and( and(
eq(schema.purchaseMessages.householdId, householdId), eq(schema.purchaseMessages.householdId, householdId),
isNotNull(schema.purchaseMessages.senderMemberId), isNotNull(schema.purchaseMessages.payerMemberId),
isNotNull(schema.purchaseMessages.parsedAmountMinor), isNotNull(schema.purchaseMessages.parsedAmountMinor),
isNotNull(schema.purchaseMessages.parsedCurrency), isNotNull(schema.purchaseMessages.parsedCurrency),
or( or(

View File

@@ -15,6 +15,23 @@ import type {
import { createFinanceCommandService } from './finance-command-service' 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 { class FinanceRepositoryStub implements FinanceRepository {
householdId = 'household-1' householdId = 'household-1'
member: FinanceMemberRecord | null = null member: FinanceMemberRecord | null = null
@@ -428,9 +445,10 @@ describe('createFinanceCommandService', () => {
const service = createService(repository) const service = createService(repository)
const result = await service.addUtilityBill('Electricity', '55.20', 'member-1') const result = await service.addUtilityBill('Electricity', '55.20', 'member-1')
const expectedPeriod = expectedCurrentCyclePeriod('Asia/Tbilisi', 20)
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(result?.period).toBe('2026-03') expect(result?.period).toBe(expectedPeriod)
expect(repository.lastUtilityBill).toEqual({ expect(repository.lastUtilityBill).toEqual({
cycleId: 'opened-cycle', cycleId: 'opened-cycle',
billName: 'Electricity', billName: 'Electricity',

View File

@@ -22,6 +22,7 @@
"0018_nimble_kojori.sql": "818738e729119c6de8049dcfca562926a5dc6e321ecbbf9cf38e02bc70b5a0dc", "0018_nimble_kojori.sql": "818738e729119c6de8049dcfca562926a5dc6e321ecbbf9cf38e02bc70b5a0dc",
"0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641", "0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641",
"0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1", "0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1",
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad" "0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad",
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1"
} }
} }

View File

@@ -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;

View File

@@ -148,6 +148,13 @@
"when": 1773590603863, "when": 1773590603863,
"tag": "0020_natural_mauler", "tag": "0020_natural_mauler",
"breakpoints": true "breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1774200000000,
"tag": "0021_sharp_payer",
"breakpoints": true
} }
] ]
} }

View File

@@ -417,6 +417,9 @@ export const purchaseMessages = pgTable(
senderMemberId: uuid('sender_member_id').references(() => members.id, { senderMemberId: uuid('sender_member_id').references(() => members.id, {
onDelete: 'set null' onDelete: 'set null'
}), }),
payerMemberId: uuid('payer_member_id').references(() => members.id, {
onDelete: 'set null'
}),
senderTelegramUserId: text('sender_telegram_user_id').notNull(), senderTelegramUserId: text('sender_telegram_user_id').notNull(),
senderDisplayName: text('sender_display_name'), senderDisplayName: text('sender_display_name'),
rawText: text('raw_text').notNull(), rawText: text('raw_text').notNull(),