fix(bot): improve calculated purchase confirmation flow

This commit is contained in:
2026-03-12 15:35:02 +04:00
parent 995725f121
commit 014d791bdc
8 changed files with 708 additions and 70 deletions

View File

@@ -1078,6 +1078,7 @@ export function registerDmAssistant(options: {
purchaseResult.status === 'pending_confirmation' purchaseResult.status === 'pending_confirmation'
? getBotTranslations(locale).purchase.proposal( ? getBotTranslations(locale).purchase.proposal(
formatPurchaseSummary(locale, purchaseResult), formatPurchaseSummary(locale, purchaseResult),
null,
null null
) )
: purchaseResult.status === 'clarification_needed' : purchaseResult.status === 'clarification_needed'
@@ -1358,6 +1359,7 @@ export function registerDmAssistant(options: {
if (purchaseResult.status === 'pending_confirmation') { if (purchaseResult.status === 'pending_confirmation') {
const purchaseText = getBotTranslations(locale).purchase.proposal( const purchaseText = getBotTranslations(locale).purchase.proposal(
formatPurchaseSummary(locale, purchaseResult), formatPurchaseSummary(locale, purchaseResult),
null,
null null
) )

View File

@@ -288,8 +288,12 @@ export const enBotTranslations: BotTranslationCatalog = {
purchase: { purchase: {
sharedPurchaseFallback: 'shared purchase', sharedPurchaseFallback: 'shared purchase',
processing: 'Checking that purchase...', processing: 'Checking that purchase...',
proposal: (summary, participants) => proposal: (summary: string, calculationNote: string | null, participants: string | null) =>
`I think this shared purchase was: ${summary}.${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`, `I think this shared purchase was: ${summary}.${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?`
: 'I calculated the total for this purchase. Is that right?',
clarification: (question) => question, clarification: (question) => question,
clarificationMissingAmountAndCurrency: clarificationMissingAmountAndCurrency:
'What amount and currency should I record for this shared purchase?', 'What amount and currency should I record for this shared purchase?',
@@ -304,7 +308,13 @@ export const enBotTranslations: BotTranslationCatalog = {
participantToggleIncluded: (displayName) => `${displayName}`, participantToggleIncluded: (displayName) => `${displayName}`,
participantToggleExcluded: (displayName) => `${displayName}`, participantToggleExcluded: (displayName) => `${displayName}`,
confirmButton: 'Confirm', confirmButton: 'Confirm',
calculatedConfirmButton: 'Looks right',
calculatedFixAmountButton: 'Fix amount',
cancelButton: 'Cancel', cancelButton: 'Cancel',
calculatedFixAmountPrompt:
'Reply with the corrected total and currency in this topic, and I will re-check the purchase.',
calculatedFixAmountRequestedToast: 'Reply with the corrected total.',
calculatedFixAmountAlreadyRequested: 'Waiting for the corrected total.',
confirmed: (summary) => `Purchase confirmed: ${summary}`, confirmed: (summary) => `Purchase confirmed: ${summary}`,
cancelled: (summary) => `Purchase proposal cancelled: ${summary}`, cancelled: (summary) => `Purchase proposal cancelled: ${summary}`,
confirmedToast: 'Purchase confirmed.', confirmedToast: 'Purchase confirmed.',

View File

@@ -292,8 +292,12 @@ export const ruBotTranslations: BotTranslationCatalog = {
purchase: { purchase: {
sharedPurchaseFallback: 'общая покупка', sharedPurchaseFallback: 'общая покупка',
processing: 'Проверяю покупку...', processing: 'Проверяю покупку...',
proposal: (summary, participants) => proposal: (summary: string, calculationNote: string | null, participants: string | null) =>
`Похоже, это общая покупка: ${summary}.${participants ? `\n\n${participants}` : ''}\одтвердите или отмените ниже.`, `Похоже, это общая покупка: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\одтвердите или отмените ниже.`,
calculatedAmountNote: (explanation: string | null) =>
explanation
? `Я посчитал итог как ${explanation}. Всё верно?`
: 'Я посчитал итоговую сумму для этой покупки. Всё верно?',
clarification: (question) => question, clarification: (question) => question,
clarificationMissingAmountAndCurrency: clarificationMissingAmountAndCurrency:
'Какую сумму и валюту нужно записать для этой общей покупки?', 'Какую сумму и валюту нужно записать для этой общей покупки?',
@@ -308,7 +312,13 @@ export const ruBotTranslations: BotTranslationCatalog = {
participantToggleIncluded: (displayName) => `${displayName}`, participantToggleIncluded: (displayName) => `${displayName}`,
participantToggleExcluded: (displayName) => `${displayName}`, participantToggleExcluded: (displayName) => `${displayName}`,
confirmButton: 'Подтвердить', confirmButton: 'Подтвердить',
calculatedConfirmButton: 'Верно',
calculatedFixAmountButton: 'Исправить сумму',
cancelButton: 'Отменить', cancelButton: 'Отменить',
calculatedFixAmountPrompt:
'Ответьте в этот топик исправленной итоговой суммой и валютой, и я заново проверю покупку.',
calculatedFixAmountRequestedToast: 'Ответьте исправленной суммой.',
calculatedFixAmountAlreadyRequested: 'Жду исправленную сумму.',
confirmed: (summary) => `Покупка подтверждена: ${summary}`, confirmed: (summary) => `Покупка подтверждена: ${summary}`,
cancelled: (summary) => `Предложение покупки отменено: ${summary}`, cancelled: (summary) => `Предложение покупки отменено: ${summary}`,
confirmedToast: 'Покупка подтверждена.', confirmedToast: 'Покупка подтверждена.',

View File

@@ -268,7 +268,12 @@ export interface BotTranslationCatalog {
purchase: { purchase: {
sharedPurchaseFallback: string sharedPurchaseFallback: string
processing: string processing: string
proposal: (summary: string, participants: string | null) => string proposal: (
summary: string,
calculationNote: string | null,
participants: string | null
) => string
calculatedAmountNote: (explanation: string | null) => string
clarification: (question: string) => string clarification: (question: string) => string
clarificationMissingAmountAndCurrency: string clarificationMissingAmountAndCurrency: string
clarificationMissingAmount: string clarificationMissingAmount: string
@@ -281,7 +286,12 @@ export interface BotTranslationCatalog {
participantToggleIncluded: (displayName: string) => string participantToggleIncluded: (displayName: string) => string
participantToggleExcluded: (displayName: string) => string participantToggleExcluded: (displayName: string) => string
confirmButton: string confirmButton: string
calculatedConfirmButton: string
calculatedFixAmountButton: string
cancelButton: string cancelButton: string
calculatedFixAmountPrompt: string
calculatedFixAmountRequestedToast: string
calculatedFixAmountAlreadyRequested: string
confirmed: (summary: string) => string confirmed: (summary: string) => string
cancelled: (summary: string) => string cancelled: (summary: string) => string
confirmedToast: string confirmedToast: string

View File

@@ -67,6 +67,8 @@ describe('createOpenAiPurchaseInterpreter', () => {
amountMinor: 100000n, amountMinor: 100000n,
currency: 'GEL', currency: 'GEL',
itemDescription: 'армянская золотая швабра', itemDescription: 'армянская золотая швабра',
amountSource: 'explicit',
calculationExplanation: null,
confidence: 93, confidence: 93,
parserMode: 'llm', parserMode: 'llm',
clarificationQuestion: null clarificationQuestion: null
@@ -104,6 +106,8 @@ describe('createOpenAiPurchaseInterpreter', () => {
amountMinor: 1000n, amountMinor: 1000n,
currency: 'GEL', currency: 'GEL',
itemDescription: 'сухари', itemDescription: 'сухари',
amountSource: 'explicit',
calculationExplanation: null,
confidence: 88, confidence: 88,
parserMode: 'llm', parserMode: 'llm',
clarificationQuestion: null clarificationQuestion: null
@@ -148,6 +152,8 @@ describe('createOpenAiPurchaseInterpreter', () => {
amountMinor: 5000n, amountMinor: 5000n,
currency: 'GEL', currency: 'GEL',
itemDescription: 'шампунь', itemDescription: 'шампунь',
amountSource: 'explicit',
calculationExplanation: null,
confidence: 92, confidence: 92,
parserMode: 'llm', parserMode: 'llm',
clarificationQuestion: null clarificationQuestion: null
@@ -192,6 +198,8 @@ describe('createOpenAiPurchaseInterpreter', () => {
amountMinor: 4500n, amountMinor: 4500n,
currency: 'GEL', currency: 'GEL',
itemDescription: 'сосисоны', itemDescription: 'сосисоны',
amountSource: 'explicit',
calculationExplanation: null,
confidence: 85, confidence: 85,
parserMode: 'llm', parserMode: 'llm',
clarificationQuestion: null clarificationQuestion: null
@@ -201,7 +209,7 @@ describe('createOpenAiPurchaseInterpreter', () => {
} }
}) })
test('corrects mis-scaled amountMinor when the source text contains a clear money amount', async () => { test('keeps the llm provided amountMinor without local correction', async () => {
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-4o-mini') const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-4o-mini')
expect(interpreter).toBeDefined() expect(interpreter).toBeDefined()
@@ -236,9 +244,11 @@ describe('createOpenAiPurchaseInterpreter', () => {
expect(result).toEqual<PurchaseInterpretation>({ expect(result).toEqual<PurchaseInterpretation>({
decision: 'purchase', decision: 'purchase',
amountMinor: 35000n, amountMinor: 350n,
currency: 'GEL', currency: 'GEL',
itemDescription: 'обои, 100 рулонов', itemDescription: 'обои, 100 рулонов',
amountSource: 'explicit',
calculationExplanation: null,
confidence: 86, confidence: 86,
parserMode: 'llm', parserMode: 'llm',
clarificationQuestion: null clarificationQuestion: null
@@ -248,7 +258,7 @@ describe('createOpenAiPurchaseInterpreter', () => {
} }
}) })
test('corrects mis-scaled amountMinor for simple clarification replies', async () => { test('keeps llm provided amountMinor for clarification follow-ups without local correction', async () => {
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-4o-mini') const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-4o-mini')
expect(interpreter).toBeDefined() expect(interpreter).toBeDefined()
@@ -283,9 +293,11 @@ describe('createOpenAiPurchaseInterpreter', () => {
expect(result).toEqual<PurchaseInterpretation>({ expect(result).toEqual<PurchaseInterpretation>({
decision: 'purchase', decision: 'purchase',
amountMinor: 35000n, amountMinor: 350n,
currency: 'GEL', currency: 'GEL',
itemDescription: 'Рулоны обоев', itemDescription: 'Рулоны обоев',
amountSource: 'explicit',
calculationExplanation: null,
confidence: 89, confidence: 89,
parserMode: 'llm', parserMode: 'llm',
clarificationQuestion: null clarificationQuestion: null
@@ -294,4 +306,103 @@ describe('createOpenAiPurchaseInterpreter', () => {
globalThis.fetch = originalFetch globalThis.fetch = originalFetch
} }
}) })
test('preserves llm computed totals for quantity times unit-price purchases', async () => {
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini')
expect(interpreter).toBeDefined()
const originalFetch = globalThis.fetch
globalThis.fetch = (async () =>
successfulResponse({
output: [
{
content: [
{
text: JSON.stringify({
decision: 'purchase',
amountMinor: '3000',
currency: 'GEL',
itemDescription: 'бутылки воды',
amountSource: 'calculated',
calculationExplanation: '5 × 6 лари = 30 лари',
confidence: 94,
clarificationQuestion: null
})
}
]
}
]
})) as unknown as typeof fetch
try {
const result = await interpreter!('Купил 5 бутылок воды, 6 лари за бутылку', {
defaultCurrency: 'GEL'
})
expect(result).toEqual<PurchaseInterpretation>({
decision: 'purchase',
amountMinor: 3000n,
currency: 'GEL',
itemDescription: 'бутылки воды',
amountSource: 'calculated',
calculationExplanation: '5 × 6 лари = 30 лари',
confidence: 94,
parserMode: 'llm',
clarificationQuestion: null
})
} finally {
globalThis.fetch = originalFetch
}
})
test('tells the llm to total per-item pricing and accept colloquial completed purchase phrasing', async () => {
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini')
expect(interpreter).toBeDefined()
const originalFetch = globalThis.fetch
let requestBody: unknown = null
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
requestBody = init?.body ? JSON.parse(String(init.body)) : null
return successfulResponse({
output: [
{
content: [
{
text: JSON.stringify({
decision: 'purchase',
amountMinor: '3000',
currency: 'GEL',
itemDescription: 'бутылки воды',
confidence: 94,
clarificationQuestion: null
})
}
]
}
]
})
}) as unknown as typeof fetch
try {
await interpreter!('Купил 5 бутылок воды, 6 лари за бутылку', {
defaultCurrency: 'GEL'
})
const systemMessage =
(
(requestBody as { input?: Array<{ role?: string; content?: string }> | null })?.input ??
[]
).find((entry) => entry.role === 'system')?.content ?? ''
expect(systemMessage).toContain(
'If the user gives quantity and per-item price, compute the total spend and return that total in amountMinor.'
)
expect(systemMessage).toContain(
'Treat colloquial completed-buy phrasing like "взял", "сходил и взял", or "сторговался до X" as a completed purchase when the message reports a real buy fact.'
)
} finally {
globalThis.fetch = originalFetch
}
})
}) })

View File

@@ -1,12 +1,15 @@
import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses' import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses'
export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase' export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase'
export type PurchaseInterpretationAmountSource = 'explicit' | 'calculated'
export interface PurchaseInterpretation { export interface PurchaseInterpretation {
decision: PurchaseInterpretationDecision decision: PurchaseInterpretationDecision
amountMinor: bigint | null amountMinor: bigint | null
currency: 'GEL' | 'USD' | null currency: 'GEL' | 'USD' | null
itemDescription: string | null itemDescription: string | null
amountSource?: PurchaseInterpretationAmountSource | null
calculationExplanation?: string | null
confidence: number confidence: number
parserMode: 'llm' parserMode: 'llm'
clarificationQuestion: string | null clarificationQuestion: string | null
@@ -31,6 +34,8 @@ interface OpenAiStructuredResult {
amountMinor: string | null amountMinor: string | null
currency: 'GEL' | 'USD' | null currency: 'GEL' | 'USD' | null
itemDescription: string | null itemDescription: string | null
amountSource: PurchaseInterpretationAmountSource | null
calculationExplanation: string | null
confidence: number confidence: number
clarificationQuestion: string | null clarificationQuestion: string | null
} }
@@ -53,61 +58,15 @@ function normalizeCurrency(value: string | null): 'GEL' | 'USD' | null {
return value === 'GEL' || value === 'USD' ? value : null return value === 'GEL' || value === 'USD' ? value : null
} }
function toMinorUnits(rawAmount: string): bigint { function normalizeAmountSource(
const normalized = rawAmount.replace(',', '.') value: PurchaseInterpretationAmountSource | null,
const [wholePart, fractionalPart = ''] = normalized.split('.') amountMinor: bigint | null
const cents = fractionalPart.padEnd(2, '0').slice(0, 2) ): PurchaseInterpretationAmountSource | null {
if (amountMinor === null) {
return BigInt(`${wholePart}${cents}`)
}
function extractLikelyMoneyAmountMinor(rawText: string): bigint | null {
const moneyCueMatches = Array.from(
rawText.matchAll(
/(?:за|выложил(?:а)?|отдал(?:а)?|заплатил(?:а)?|потратил(?:а)?|стоит|стоило)\s*(\d+(?:[.,]\d{1,2})?)/giu
)
)
if (moneyCueMatches.length === 1) {
const rawAmount = moneyCueMatches[0]?.[1]
if (rawAmount) {
return toMinorUnits(rawAmount)
}
}
const explicitMoneyMatches = Array.from(
rawText.matchAll(
/(\d+(?:[.,]\d{1,2})?)\s*(?:|gel|lari|лари|usd|\$|доллар(?:а|ов)?|кровн\p{L}*)/giu
)
)
if (explicitMoneyMatches.length === 1) {
const rawAmount = explicitMoneyMatches[0]?.[1]
if (rawAmount) {
return toMinorUnits(rawAmount)
}
}
const standaloneMatches = Array.from(rawText.matchAll(/\b(\d+(?:[.,]\d{1,2})?)\b/gu))
if (standaloneMatches.length === 1) {
const rawAmount = standaloneMatches[0]?.[1]
if (rawAmount) {
return toMinorUnits(rawAmount)
}
}
return null return null
} }
function resolveAmountMinor(input: { rawText: string; amountMinor: bigint | null }): bigint | null { return value === 'calculated' ? 'calculated' : 'explicit'
if (input.amountMinor === null) {
return null
}
const explicitAmountMinor = extractLikelyMoneyAmountMinor(input.rawText)
if (explicitAmountMinor === null) {
return input.amountMinor
}
return explicitAmountMinor === input.amountMinor * 100n ? explicitAmountMinor : input.amountMinor
} }
function normalizeConfidence(value: number): number { function normalizeConfidence(value: number): number {
@@ -183,7 +142,11 @@ export function createOpenAiPurchaseInterpreter(
'Decide whether the latest message is a real shared purchase, needs clarification, or is not a shared purchase at all.', 'Decide whether the latest message is a real shared purchase, needs clarification, or is not a shared purchase at all.',
`The household default currency is ${options.defaultCurrency}. If a real purchase clearly omits currency, use ${options.defaultCurrency}.`, `The household default currency is ${options.defaultCurrency}. If a real purchase clearly omits currency, use ${options.defaultCurrency}.`,
'amountMinor must be expressed in minor currency units. Example: 350 GEL -> 35000, 3.50 GEL -> 350, 45 lari -> 4500.', 'amountMinor must be expressed in minor currency units. Example: 350 GEL -> 35000, 3.50 GEL -> 350, 45 lari -> 4500.',
'If the user gives quantity and per-item price, compute the total spend and return that total in amountMinor.',
'Set amountSource to "explicit" when the user directly states the total amount, or "calculated" when you compute it from quantity x price or similar arithmetic.',
'When amountSource is "calculated", also return a short calculationExplanation in the user message language, such as "5 × 6 lari = 30 lari".',
'Ignore item quantities like rolls, kilograms, or layers unless they are clearly the money amount.', 'Ignore item quantities like rolls, kilograms, or layers unless they are clearly the money amount.',
'Treat colloquial completed-buy phrasing like "взял", "сходил и взял", or "сторговался до X" as a completed purchase when the message reports a real buy fact.',
'If recent messages from the same sender are provided, treat them as clarification context for the latest message.', 'If recent messages from the same sender are provided, treat them as clarification context for the latest message.',
'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.',
@@ -233,6 +196,18 @@ export function createOpenAiPurchaseInterpreter(
itemDescription: { itemDescription: {
anyOf: [{ type: 'string' }, { type: 'null' }] anyOf: [{ type: 'string' }, { type: 'null' }]
}, },
amountSource: {
anyOf: [
{
type: 'string',
enum: ['explicit', 'calculated']
},
{ type: 'null' }
]
},
calculationExplanation: {
anyOf: [{ type: 'string' }, { type: 'null' }]
},
confidence: { confidence: {
type: 'number', type: 'number',
minimum: 0, minimum: 0,
@@ -247,6 +222,8 @@ export function createOpenAiPurchaseInterpreter(
'amountMinor', 'amountMinor',
'currency', 'currency',
'itemDescription', 'itemDescription',
'amountSource',
'calculationExplanation',
'confidence', 'confidence',
'clarificationQuestion' 'clarificationQuestion'
] ]
@@ -286,11 +263,10 @@ export function createOpenAiPurchaseInterpreter(
return null return null
} }
const amountMinor = resolveAmountMinor({ const amountMinor = asOptionalBigInt(parsedJson.amountMinor)
rawText,
amountMinor: asOptionalBigInt(parsedJson.amountMinor)
})
const itemDescription = normalizeOptionalText(parsedJson.itemDescription) const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor)
const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation)
const currency = resolveMissingCurrency({ const currency = resolveMissingCurrency({
decision: parsedJson.decision, decision: parsedJson.decision,
amountMinor, amountMinor,
@@ -315,6 +291,8 @@ export function createOpenAiPurchaseInterpreter(
amountMinor, amountMinor,
currency, currency,
itemDescription, itemDescription,
amountSource,
calculationExplanation: amountSource === 'calculated' ? calculationExplanation : null,
confidence: normalizeConfidence(parsedJson.confidence), confidence: normalizeConfidence(parsedJson.confidence),
parserMode: 'llm', parserMode: 'llm',
clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null

View File

@@ -220,6 +220,8 @@ describe('buildPurchaseAcknowledgement', () => {
parsedAmountMinor: 3000n, parsedAmountMinor: 3000n,
parsedCurrency: 'GEL', parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper', parsedItemDescription: 'toilet paper',
amountSource: 'explicit',
calculationExplanation: null,
parserConfidence: 92, parserConfidence: 92,
parserMode: 'llm', parserMode: 'llm',
participants: participants() participants: participants()
@@ -227,6 +229,29 @@ describe('buildPurchaseAcknowledgement', () => {
expect(result).toBe(`I think this shared purchase was: toilet paper - 30.00 GEL. expect(result).toBe(`I think this shared purchase was: toilet paper - 30.00 GEL.
Participants:
- Mia
- Dima (excluded)
Confirm or cancel below.`)
})
test('shows a calculation note when the llm computed the total', () => {
const result = buildPurchaseAcknowledgement({
status: 'pending_confirmation',
purchaseMessageId: 'proposal-1b',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'water bottles',
amountSource: 'calculated',
calculationExplanation: '5 x 6 lari = 30 lari',
parserConfidence: 94,
parserMode: 'llm',
participants: participants()
})
expect(result).toBe(`I think this shared purchase was: water bottles - 30.00 GEL.
I calculated the total as 5 x 6 lari = 30 lari. Is that right?
Participants: Participants:
- Mia - Mia
- Dima (excluded) - Dima (excluded)
@@ -241,6 +266,8 @@ Confirm or cancel below.`)
parsedAmountMinor: 3000n, parsedAmountMinor: 3000n,
parsedCurrency: null, parsedCurrency: null,
parsedItemDescription: 'toilet paper', parsedItemDescription: 'toilet paper',
amountSource: 'explicit',
calculationExplanation: null,
parserConfidence: 61, parserConfidence: 61,
parserMode: 'llm' parserMode: 'llm'
}) })
@@ -256,6 +283,8 @@ Confirm or cancel below.`)
parsedAmountMinor: null, parsedAmountMinor: null,
parsedCurrency: null, parsedCurrency: null,
parsedItemDescription: 'toilet paper', parsedItemDescription: 'toilet paper',
amountSource: null,
calculationExplanation: null,
parserConfidence: 42, parserConfidence: 42,
parserMode: 'llm' parserMode: 'llm'
}) })
@@ -297,6 +326,8 @@ Confirm or cancel below.`)
parsedAmountMinor: 3000n, parsedAmountMinor: 3000n,
parsedCurrency: 'GEL', parsedCurrency: 'GEL',
parsedItemDescription: 'туалетная бумага', parsedItemDescription: 'туалетная бумага',
amountSource: 'explicit',
calculationExplanation: null,
parserConfidence: 92, parserConfidence: 92,
parserMode: 'llm', parserMode: 'llm',
participants: participants() participants: participants()
@@ -734,6 +765,212 @@ Confirm or cancel below.`,
expect(calls).toHaveLength(0) expect(calls).toHaveLength(0)
}) })
test('treats colloquial completed purchase reports as likely purchases', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save(record) {
expect(record.rawText).toBe(
'Короч, сходил на рынок и взял этот долбаный ковер. Сторговался до 150 лари'
)
return {
status: 'pending_confirmation',
purchaseMessageId: 'proposal-carpet',
parsedAmountMinor: 15000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'ковер',
parserConfidence: 91,
parserMode: 'llm',
participants: participants()
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'purchase',
amountMinor: 15000n,
currency: 'GEL',
itemDescription: 'ковер',
confidence: 91,
parserMode: 'llm',
clarificationQuestion: null
})
})
await bot.handleUpdate(
purchaseUpdate(
'Короч, сходил на рынок и взял этот долбаный ковер. Сторговался до 150 лари'
) as never
)
expect(calls).toHaveLength(3)
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'Checking that purchase...'
}
})
expect(calls[2]).toMatchObject({
method: 'editMessageText',
payload: {
text: `I think this shared purchase was: ковер - 150.00 GEL.
Participants:
- Mia
- Dima (excluded)
Confirm or cancel below.`
}
})
})
test('uses dedicated buttons for calculated totals', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
return {
status: 'pending_confirmation',
purchaseMessageId: 'proposal-calculated',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'water bottles',
amountSource: 'calculated',
calculationExplanation: '5 x 6 lari = 30 lari',
parserConfidence: 94,
parserMode: 'llm',
participants: participants()
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'purchase',
amountMinor: 3000n,
currency: 'GEL',
itemDescription: 'water bottles',
amountSource: 'calculated',
calculationExplanation: '5 x 6 lari = 30 lari',
confidence: 94,
parserMode: 'llm',
clarificationQuestion: null
})
})
await bot.handleUpdate(purchaseUpdate('Bought 5 bottles of water, 6 lari each') as never)
expect(calls[2]).toMatchObject({
method: 'editMessageText',
payload: {
reply_markup: {
inline_keyboard: [
[
{
text: '✅ Mia',
callback_data: 'purchase:participant:participant-1'
}
],
[
{
text: '⬜ Dima',
callback_data: 'purchase:participant:participant-2'
}
],
[
{
text: 'Looks right',
callback_data: 'purchase:confirm:proposal-calculated'
},
{
text: 'Fix amount',
callback_data: 'purchase:fix_amount:proposal-calculated'
},
{
text: 'Cancel',
callback_data: 'purchase:cancel:proposal-calculated'
}
]
]
}
}
})
})
test('stays silent for stray amount chatter in the purchase topic', async () => { test('stays silent for stray amount chatter in the purchase topic', async () => {
const bot = createTestBot() const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []
@@ -1366,6 +1603,59 @@ Confirm or cancel below.`,
}) })
}) })
test('requests amount correction for calculated purchase proposals', 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
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
},
async requestAmountCorrection() {
return {
status: 'requested',
purchaseMessageId: 'proposal-1',
householdId: config.householdId
}
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(callbackUpdate('purchase:fix_amount:proposal-1') as never)
expect(calls).toHaveLength(2)
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
text: 'Reply with the corrected total and currency in this topic, and I will re-check the purchase.',
reply_markup: {
inline_keyboard: []
}
}
})
})
test('handles duplicate confirm callbacks idempotently', async () => { test('handles duplicate confirm callbacks idempotently', async () => {
const bot = createTestBot() const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []

View File

@@ -10,6 +10,7 @@ import type {
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import { getBotTranslations, type BotLocale } from './i18n' import { getBotTranslations, type BotLocale } from './i18n'
import type { import type {
PurchaseInterpretationAmountSource,
PurchaseInterpretation, PurchaseInterpretation,
PurchaseMessageInterpreter PurchaseMessageInterpreter
} from './openai-purchase-interpreter' } from './openai-purchase-interpreter'
@@ -19,13 +20,14 @@ import { stripExplicitBotMention } from './telegram-mentions'
const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:' const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:'
const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:' const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:'
const PURCHASE_PARTICIPANT_CALLBACK_PREFIX = 'purchase:participant:' const PURCHASE_PARTICIPANT_CALLBACK_PREFIX = 'purchase:participant:'
const PURCHASE_FIX_AMOUNT_CALLBACK_PREFIX = 'purchase:fix_amount:'
const MIN_PROPOSAL_CONFIDENCE = 70 const MIN_PROPOSAL_CONFIDENCE = 70
const LIKELY_PURCHASE_VERB_PATTERN = const LIKELY_PURCHASE_VERB_PATTERN =
/\b(?:bought|purchased|paid|spent|ordered|picked up|grabbed|got)\b|\b(?:купил(?:а|и)?|куплено|заказал(?:а|и)?|оплатил(?:а|и)?|потратил(?:а|и)?|взял(?:а|и)?)\b/iu /\b(?:bought|purchased|paid|spent|ordered|picked up|grabbed|got)\b|(?:^|[^\p{L}])(?:купил(?:а|и)?|куплено|заказал(?:а|и)?|оплатил(?:а|и)?|потратил(?:а|и)?|взял(?:а|и)?)(?=$|[^\p{L}])/iu
const PLANNING_PURCHASE_PATTERN = const PLANNING_PURCHASE_PATTERN =
/\b(?:should buy|should get|need to buy|need to get|want to buy|want to get|let'?s buy|let'?s get|going to buy|gonna buy|plan to buy|planning to buy|thinking about buying|thinking of buying|should we buy|should we get|can buy)\b|\b(?:надо|нужно|хочу|хотим|давай(?:те)?|будем|планирую|планируем|может|стоит)\s+(?:купить|взять|заказать|оплатить)\b|\b(?:купим|возьмем|возьмём|закажем|оплатим)\b/iu /\b(?:should buy|should get|need to buy|need to get|want to buy|want to get|let'?s buy|let'?s get|going to buy|gonna buy|plan to buy|planning to buy|thinking about buying|thinking of buying|should we buy|should we get|can buy)\b|(?:^|[^\p{L}])(?:надо|нужно|хочу|хотим|давай(?:те)?|будем|планирую|планируем|может|стоит)\s+(?:купить|взять|заказать|оплатить)(?=$|[^\p{L}])|(?:^|[^\p{L}])(?:купим|возьмем|возьмём|закажем|оплатим)(?=$|[^\p{L}])/iu
const MONEY_SIGNAL_PATTERN = const MONEY_SIGNAL_PATTERN =
/\b\d+(?:[.,]\d{1,2})?\s*(?:|gel|lari|лари|tetri|тетри|usd|\$|доллар(?:а|ов)?)\b|\b(?:for|за|на)\s+\d+(?:[.,]\d{1,2})?\b|\b(?:paid|spent|заплатил(?:а|и)?|потратил(?:а|и)?|отдал(?:а|и)?|выложил(?:а|и)?)\s+\d+(?:[.,]\d{1,2})?\b/iu /\b\d+(?:[.,]\d{1,2})?\s*(?:|gel|lari|usd|\$)\b|\d+(?:[.,]\d{1,2})?\s*(?:лари|лри|tetri|тетри|доллар(?:а|ов)?)(?=$|[^\p{L}])|\b(?:for|за|на|до)\s+\d+(?:[.,]\d{1,2})?\b|\b(?:paid|spent)\s+\d+(?:[.,]\d{1,2})?\b|(?:^|[^\p{L}])(?:заплатил(?:а|и)?|потратил(?:а|и)?|отдал(?:а|и)?|выложил(?:а|и)?|сторговался(?:\s+до)?)(?:\s+\d+(?:[.,]\d{1,2})?|\s+до\s+\d+(?:[.,]\d{1,2})?)(?=$|[^\p{L}])/iu
const STANDALONE_NUMBER_PATTERN = /\b\d+(?:[.,]\d{1,2})?\b/gu const STANDALONE_NUMBER_PATTERN = /\b\d+(?:[.,]\d{1,2})?\b/gu
type PurchaseTopicEngagement = type PurchaseTopicEngagement =
@@ -68,6 +70,8 @@ interface PurchaseProposalFields {
parsedAmountMinor: bigint | null parsedAmountMinor: bigint | null
parsedCurrency: 'GEL' | 'USD' | null parsedCurrency: 'GEL' | 'USD' | null
parsedItemDescription: string | null parsedItemDescription: string | null
amountSource?: PurchaseInterpretationAmountSource | null
calculationExplanation?: string | null
parserConfidence: number | null parserConfidence: number | null
parserMode: 'llm' | null parserMode: 'llm' | null
} }
@@ -173,6 +177,29 @@ export type PurchaseProposalParticipantToggleResult =
status: 'not_found' status: 'not_found'
} }
export type PurchaseProposalAmountCorrectionResult =
| {
status: 'requested'
purchaseMessageId: string
householdId: string
}
| {
status: 'already_requested'
purchaseMessageId: string
householdId: string
}
| {
status: 'forbidden'
householdId: string
}
| {
status: 'not_pending'
householdId: string
}
| {
status: 'not_found'
}
export interface PurchaseMessageIngestionRepository { export interface PurchaseMessageIngestionRepository {
hasClarificationContext(record: PurchaseTopicRecord): Promise<boolean> hasClarificationContext(record: PurchaseTopicRecord): Promise<boolean>
save( save(
@@ -196,6 +223,10 @@ export interface PurchaseMessageIngestionRepository {
participantId: string, participantId: string,
actorTelegramUserId: string actorTelegramUserId: string
): Promise<PurchaseProposalParticipantToggleResult> ): Promise<PurchaseProposalParticipantToggleResult>
requestAmountCorrection?(
purchaseMessageId: string,
actorTelegramUserId: string
): Promise<PurchaseProposalAmountCorrectionResult>
} }
interface PurchasePersistenceDecision { interface PurchasePersistenceDecision {
@@ -203,6 +234,8 @@ interface PurchasePersistenceDecision {
parsedAmountMinor: bigint | null parsedAmountMinor: bigint | null
parsedCurrency: 'GEL' | 'USD' | null parsedCurrency: 'GEL' | 'USD' | null
parsedItemDescription: string | null parsedItemDescription: string | null
amountSource: PurchaseInterpretationAmountSource | null
calculationExplanation: string | null
parserConfidence: number | null parserConfidence: number | null
parserMode: 'llm' | null parserMode: 'llm' | null
clarificationQuestion: string | null clarificationQuestion: string | null
@@ -292,6 +325,8 @@ function normalizeInterpretation(
parsedAmountMinor: null, parsedAmountMinor: null,
parsedCurrency: null, parsedCurrency: null,
parsedItemDescription: null, parsedItemDescription: null,
amountSource: null,
calculationExplanation: null,
parserConfidence: null, parserConfidence: null,
parserMode: null, parserMode: null,
clarificationQuestion: null, clarificationQuestion: null,
@@ -306,6 +341,8 @@ function normalizeInterpretation(
parsedAmountMinor: interpretation.amountMinor, parsedAmountMinor: interpretation.amountMinor,
parsedCurrency: interpretation.currency, parsedCurrency: interpretation.currency,
parsedItemDescription: interpretation.itemDescription, parsedItemDescription: interpretation.itemDescription,
amountSource: interpretation.amountSource ?? null,
calculationExplanation: interpretation.calculationExplanation ?? null,
parserConfidence: interpretation.confidence, parserConfidence: interpretation.confidence,
parserMode: interpretation.parserMode, parserMode: interpretation.parserMode,
clarificationQuestion: null, clarificationQuestion: null,
@@ -329,6 +366,8 @@ function normalizeInterpretation(
parsedAmountMinor: interpretation.amountMinor, parsedAmountMinor: interpretation.amountMinor,
parsedCurrency: interpretation.currency, parsedCurrency: interpretation.currency,
parsedItemDescription: interpretation.itemDescription, parsedItemDescription: interpretation.itemDescription,
amountSource: interpretation.amountSource ?? null,
calculationExplanation: interpretation.calculationExplanation ?? null,
parserConfidence: interpretation.confidence, parserConfidence: interpretation.confidence,
parserMode: interpretation.parserMode, parserMode: interpretation.parserMode,
clarificationQuestion: interpretation.clarificationQuestion, clarificationQuestion: interpretation.clarificationQuestion,
@@ -342,6 +381,8 @@ function normalizeInterpretation(
parsedAmountMinor: interpretation.amountMinor, parsedAmountMinor: interpretation.amountMinor,
parsedCurrency: interpretation.currency, parsedCurrency: interpretation.currency,
parsedItemDescription: interpretation.itemDescription, parsedItemDescription: interpretation.itemDescription,
amountSource: interpretation.amountSource ?? null,
calculationExplanation: interpretation.calculationExplanation ?? null,
parserConfidence: interpretation.confidence, parserConfidence: interpretation.confidence,
parserMode: interpretation.parserMode, parserMode: interpretation.parserMode,
clarificationQuestion: null, clarificationQuestion: null,
@@ -398,6 +439,8 @@ function toProposalFields(row: StoredPurchaseMessageRow): PurchaseProposalFields
parsedAmountMinor: row.parsedAmountMinor, parsedAmountMinor: row.parsedAmountMinor,
parsedCurrency: row.parsedCurrency, parsedCurrency: row.parsedCurrency,
parsedItemDescription: row.parsedItemDescription, parsedItemDescription: row.parsedItemDescription,
amountSource: null,
calculationExplanation: null,
parserConfidence: row.parserConfidence, parserConfidence: row.parserConfidence,
parserMode: row.parserMode parserMode: row.parserMode
} }
@@ -991,6 +1034,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
parsedAmountMinor: decision.parsedAmountMinor, parsedAmountMinor: decision.parsedAmountMinor,
parsedCurrency: decision.parsedCurrency, parsedCurrency: decision.parsedCurrency,
parsedItemDescription: decision.parsedItemDescription, parsedItemDescription: decision.parsedItemDescription,
amountSource: decision.amountSource,
calculationExplanation: decision.calculationExplanation,
parserConfidence: decision.parserConfidence, parserConfidence: decision.parserConfidence,
parserMode: decision.parserMode parserMode: decision.parserMode
} }
@@ -1018,6 +1063,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
parsedAmountMinor: decision.parsedAmountMinor!, parsedAmountMinor: decision.parsedAmountMinor!,
parsedCurrency: decision.parsedCurrency!, parsedCurrency: decision.parsedCurrency!,
parsedItemDescription: decision.parsedItemDescription!, parsedItemDescription: decision.parsedItemDescription!,
amountSource: decision.amountSource,
calculationExplanation: decision.calculationExplanation,
parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE, parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE,
parserMode: decision.parserMode ?? 'llm', parserMode: decision.parserMode ?? 'llm',
participants: toProposalParticipants(await getStoredParticipants(insertedRow.id)) participants: toProposalParticipants(await getStoredParticipants(insertedRow.id))
@@ -1135,6 +1182,84 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
await getStoredParticipants(existing.purchaseMessageId) await getStoredParticipants(existing.purchaseMessageId)
) )
} }
},
async requestAmountCorrection(purchaseMessageId, 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: 'already_requested',
purchaseMessageId: existing.id,
householdId: existing.householdId
}
}
if (existing.processingStatus !== 'pending_confirmation') {
return {
status: 'not_pending',
householdId: existing.householdId
}
}
const rows = await db
.update(schema.purchaseMessages)
.set({
processingStatus: 'clarification_needed',
needsReview: 1
})
.where(
and(
eq(schema.purchaseMessages.id, purchaseMessageId),
eq(schema.purchaseMessages.senderTelegramUserId, actorTelegramUserId),
eq(schema.purchaseMessages.processingStatus, 'pending_confirmation')
)
)
.returning({
id: schema.purchaseMessages.id,
householdId: schema.purchaseMessages.householdId
})
const updated = rows[0]
if (!updated) {
const reloaded = await getStoredMessage(purchaseMessageId)
if (!reloaded) {
return {
status: 'not_found'
}
}
if (reloaded.processingStatus === 'clarification_needed') {
return {
status: 'already_requested',
purchaseMessageId: reloaded.id,
householdId: reloaded.householdId
}
}
return {
status: 'not_pending',
householdId: reloaded.householdId
}
}
return {
status: 'requested',
purchaseMessageId: updated.id,
householdId: updated.householdId
}
} }
} }
@@ -1206,6 +1331,21 @@ function formatPurchaseParticipants(
return `${t.participantsHeading}\n${lines.join('\n')}` return `${t.participantsHeading}\n${lines.join('\n')}`
} }
function formatPurchaseCalculationNote(
locale: BotLocale,
result: {
amountSource?: PurchaseInterpretationAmountSource | null
calculationExplanation?: string | null
}
): string | null {
if (result.amountSource !== 'calculated') {
return null
}
const t = getBotTranslations(locale).purchase
return t.calculatedAmountNote(result.calculationExplanation ?? null)
}
export function buildPurchaseAcknowledgement( export function buildPurchaseAcknowledgement(
result: PurchaseMessageIngestionResult, result: PurchaseMessageIngestionResult,
locale: BotLocale = 'en' locale: BotLocale = 'en'
@@ -1219,6 +1359,7 @@ export function buildPurchaseAcknowledgement(
case 'pending_confirmation': case 'pending_confirmation':
return t.proposal( return t.proposal(
formatPurchaseSummary(locale, result), formatPurchaseSummary(locale, result),
formatPurchaseCalculationNote(locale, result),
formatPurchaseParticipants(locale, result.participants) formatPurchaseParticipants(locale, result.participants)
) )
case 'clarification_needed': case 'clarification_needed':
@@ -1230,6 +1371,9 @@ export function buildPurchaseAcknowledgement(
function purchaseProposalReplyMarkup( function purchaseProposalReplyMarkup(
locale: BotLocale, locale: BotLocale,
options: {
amountSource?: PurchaseInterpretationAmountSource | null
},
purchaseMessageId: string, purchaseMessageId: string,
participants: readonly PurchaseProposalParticipant[] participants: readonly PurchaseProposalParticipant[]
) { ) {
@@ -1247,9 +1391,17 @@ function purchaseProposalReplyMarkup(
]), ]),
[ [
{ {
text: t.confirmButton, text: options.amountSource === 'calculated' ? t.calculatedConfirmButton : t.confirmButton,
callback_data: `${PURCHASE_CONFIRM_CALLBACK_PREFIX}${purchaseMessageId}` callback_data: `${PURCHASE_CONFIRM_CALLBACK_PREFIX}${purchaseMessageId}`
}, },
...(options.amountSource === 'calculated'
? [
{
text: t.calculatedFixAmountButton,
callback_data: `${PURCHASE_FIX_AMOUNT_CALLBACK_PREFIX}${purchaseMessageId}`
}
]
: []),
{ {
text: t.cancelButton, text: t.cancelButton,
callback_data: `${PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}` callback_data: `${PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}`
@@ -1319,7 +1471,14 @@ async function handlePurchaseMessageResult(
pendingReply, pendingReply,
acknowledgement, acknowledgement,
result.status === 'pending_confirmation' result.status === 'pending_confirmation'
? purchaseProposalReplyMarkup(locale, result.purchaseMessageId, result.participants) ? purchaseProposalReplyMarkup(
locale,
{
amountSource: result.amountSource ?? null
},
result.purchaseMessageId,
result.participants
)
: undefined : undefined
) )
} }
@@ -1353,6 +1512,7 @@ function buildPurchaseToggleMessage(
): string { ): string {
return getBotTranslations(locale).purchase.proposal( return getBotTranslations(locale).purchase.proposal(
formatPurchaseSummary(locale, result), formatPurchaseSummary(locale, result),
null,
formatPurchaseParticipants(locale, result.participants) formatPurchaseParticipants(locale, result.participants)
) )
} }
@@ -1409,6 +1569,9 @@ function registerPurchaseProposalCallbacks(
await ctx.editMessageText(buildPurchaseToggleMessage(locale, result), { await ctx.editMessageText(buildPurchaseToggleMessage(locale, result), {
reply_markup: purchaseProposalReplyMarkup( reply_markup: purchaseProposalReplyMarkup(
locale, locale,
{
amountSource: result.amountSource ?? null
},
result.purchaseMessageId, result.purchaseMessageId,
result.participants result.participants
) )
@@ -1479,6 +1642,70 @@ function registerPurchaseProposalCallbacks(
) )
}) })
bot.callbackQuery(new RegExp(`^${PURCHASE_FIX_AMOUNT_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => {
const purchaseMessageId = ctx.match[1]
const actorTelegramUserId = ctx.from?.id?.toString()
if (!actorTelegramUserId || !purchaseMessageId) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').purchase.proposalUnavailable,
show_alert: true
})
return
}
if (!repository.requestAmountCorrection) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').purchase.proposalUnavailable,
show_alert: true
})
return
}
const result = await repository.requestAmountCorrection(purchaseMessageId, 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:
result.status === 'requested'
? t.calculatedFixAmountRequestedToast
: t.calculatedFixAmountAlreadyRequested
})
if (ctx.msg) {
await ctx.editMessageText(t.calculatedFixAmountPrompt, {
reply_markup: emptyInlineKeyboard()
})
}
logger?.info(
{
event: 'purchase.amount_correction_requested',
purchaseMessageId,
actorTelegramUserId,
status: result.status
},
'Purchase amount correction requested'
)
})
bot.callbackQuery(new RegExp(`^${PURCHASE_CANCEL_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => { bot.callbackQuery(new RegExp(`^${PURCHASE_CANCEL_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => {
const purchaseMessageId = ctx.match[1] const purchaseMessageId = ctx.match[1]
const actorTelegramUserId = ctx.from?.id?.toString() const actorTelegramUserId = ctx.from?.id?.toString()