mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
fix(bot): improve calculated purchase confirmation flow
This commit is contained in:
@@ -1078,6 +1078,7 @@ export function registerDmAssistant(options: {
|
||||
purchaseResult.status === 'pending_confirmation'
|
||||
? getBotTranslations(locale).purchase.proposal(
|
||||
formatPurchaseSummary(locale, purchaseResult),
|
||||
null,
|
||||
null
|
||||
)
|
||||
: purchaseResult.status === 'clarification_needed'
|
||||
@@ -1358,6 +1359,7 @@ export function registerDmAssistant(options: {
|
||||
if (purchaseResult.status === 'pending_confirmation') {
|
||||
const purchaseText = getBotTranslations(locale).purchase.proposal(
|
||||
formatPurchaseSummary(locale, purchaseResult),
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
|
||||
@@ -288,8 +288,12 @@ export const enBotTranslations: BotTranslationCatalog = {
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 'shared purchase',
|
||||
processing: 'Checking that purchase...',
|
||||
proposal: (summary, participants) =>
|
||||
`I think this shared purchase was: ${summary}.${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`,
|
||||
proposal: (summary: string, calculationNote: string | null, participants: string | null) =>
|
||||
`I think this shared purchase was: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`,
|
||||
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,
|
||||
clarificationMissingAmountAndCurrency:
|
||||
'What amount and currency should I record for this shared purchase?',
|
||||
@@ -304,7 +308,13 @@ export const enBotTranslations: BotTranslationCatalog = {
|
||||
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
||||
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
||||
confirmButton: 'Confirm',
|
||||
calculatedConfirmButton: 'Looks right',
|
||||
calculatedFixAmountButton: 'Fix amount',
|
||||
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}`,
|
||||
cancelled: (summary) => `Purchase proposal cancelled: ${summary}`,
|
||||
confirmedToast: 'Purchase confirmed.',
|
||||
|
||||
@@ -292,8 +292,12 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 'общая покупка',
|
||||
processing: 'Проверяю покупку...',
|
||||
proposal: (summary, participants) =>
|
||||
`Похоже, это общая покупка: ${summary}.${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`,
|
||||
proposal: (summary: string, calculationNote: string | null, participants: string | null) =>
|
||||
`Похоже, это общая покупка: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`,
|
||||
calculatedAmountNote: (explanation: string | null) =>
|
||||
explanation
|
||||
? `Я посчитал итог как ${explanation}. Всё верно?`
|
||||
: 'Я посчитал итоговую сумму для этой покупки. Всё верно?',
|
||||
clarification: (question) => question,
|
||||
clarificationMissingAmountAndCurrency:
|
||||
'Какую сумму и валюту нужно записать для этой общей покупки?',
|
||||
@@ -308,7 +312,13 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
||||
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
||||
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
||||
confirmButton: 'Подтвердить',
|
||||
calculatedConfirmButton: 'Верно',
|
||||
calculatedFixAmountButton: 'Исправить сумму',
|
||||
cancelButton: 'Отменить',
|
||||
calculatedFixAmountPrompt:
|
||||
'Ответьте в этот топик исправленной итоговой суммой и валютой, и я заново проверю покупку.',
|
||||
calculatedFixAmountRequestedToast: 'Ответьте исправленной суммой.',
|
||||
calculatedFixAmountAlreadyRequested: 'Жду исправленную сумму.',
|
||||
confirmed: (summary) => `Покупка подтверждена: ${summary}`,
|
||||
cancelled: (summary) => `Предложение покупки отменено: ${summary}`,
|
||||
confirmedToast: 'Покупка подтверждена.',
|
||||
|
||||
@@ -268,7 +268,12 @@ export interface BotTranslationCatalog {
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 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
|
||||
clarificationMissingAmountAndCurrency: string
|
||||
clarificationMissingAmount: string
|
||||
@@ -281,7 +286,12 @@ export interface BotTranslationCatalog {
|
||||
participantToggleIncluded: (displayName: string) => string
|
||||
participantToggleExcluded: (displayName: string) => string
|
||||
confirmButton: string
|
||||
calculatedConfirmButton: string
|
||||
calculatedFixAmountButton: string
|
||||
cancelButton: string
|
||||
calculatedFixAmountPrompt: string
|
||||
calculatedFixAmountRequestedToast: string
|
||||
calculatedFixAmountAlreadyRequested: string
|
||||
confirmed: (summary: string) => string
|
||||
cancelled: (summary: string) => string
|
||||
confirmedToast: string
|
||||
|
||||
@@ -67,6 +67,8 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
||||
amountMinor: 100000n,
|
||||
currency: 'GEL',
|
||||
itemDescription: 'армянская золотая швабра',
|
||||
amountSource: 'explicit',
|
||||
calculationExplanation: null,
|
||||
confidence: 93,
|
||||
parserMode: 'llm',
|
||||
clarificationQuestion: null
|
||||
@@ -104,6 +106,8 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
||||
amountMinor: 1000n,
|
||||
currency: 'GEL',
|
||||
itemDescription: 'сухари',
|
||||
amountSource: 'explicit',
|
||||
calculationExplanation: null,
|
||||
confidence: 88,
|
||||
parserMode: 'llm',
|
||||
clarificationQuestion: null
|
||||
@@ -148,6 +152,8 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
||||
amountMinor: 5000n,
|
||||
currency: 'GEL',
|
||||
itemDescription: 'шампунь',
|
||||
amountSource: 'explicit',
|
||||
calculationExplanation: null,
|
||||
confidence: 92,
|
||||
parserMode: 'llm',
|
||||
clarificationQuestion: null
|
||||
@@ -192,6 +198,8 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
||||
amountMinor: 4500n,
|
||||
currency: 'GEL',
|
||||
itemDescription: 'сосисоны',
|
||||
amountSource: 'explicit',
|
||||
calculationExplanation: null,
|
||||
confidence: 85,
|
||||
parserMode: 'llm',
|
||||
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')
|
||||
expect(interpreter).toBeDefined()
|
||||
|
||||
@@ -236,9 +244,11 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
||||
|
||||
expect(result).toEqual<PurchaseInterpretation>({
|
||||
decision: 'purchase',
|
||||
amountMinor: 35000n,
|
||||
amountMinor: 350n,
|
||||
currency: 'GEL',
|
||||
itemDescription: 'обои, 100 рулонов',
|
||||
amountSource: 'explicit',
|
||||
calculationExplanation: null,
|
||||
confidence: 86,
|
||||
parserMode: 'llm',
|
||||
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')
|
||||
expect(interpreter).toBeDefined()
|
||||
|
||||
@@ -283,9 +293,11 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
||||
|
||||
expect(result).toEqual<PurchaseInterpretation>({
|
||||
decision: 'purchase',
|
||||
amountMinor: 35000n,
|
||||
amountMinor: 350n,
|
||||
currency: 'GEL',
|
||||
itemDescription: 'Рулоны обоев',
|
||||
amountSource: 'explicit',
|
||||
calculationExplanation: null,
|
||||
confidence: 89,
|
||||
parserMode: 'llm',
|
||||
clarificationQuestion: null
|
||||
@@ -294,4 +306,103 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses'
|
||||
|
||||
export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase'
|
||||
export type PurchaseInterpretationAmountSource = 'explicit' | 'calculated'
|
||||
|
||||
export interface PurchaseInterpretation {
|
||||
decision: PurchaseInterpretationDecision
|
||||
amountMinor: bigint | null
|
||||
currency: 'GEL' | 'USD' | null
|
||||
itemDescription: string | null
|
||||
amountSource?: PurchaseInterpretationAmountSource | null
|
||||
calculationExplanation?: string | null
|
||||
confidence: number
|
||||
parserMode: 'llm'
|
||||
clarificationQuestion: string | null
|
||||
@@ -31,6 +34,8 @@ interface OpenAiStructuredResult {
|
||||
amountMinor: string | null
|
||||
currency: 'GEL' | 'USD' | null
|
||||
itemDescription: string | null
|
||||
amountSource: PurchaseInterpretationAmountSource | null
|
||||
calculationExplanation: string | null
|
||||
confidence: number
|
||||
clarificationQuestion: string | null
|
||||
}
|
||||
@@ -53,61 +58,15 @@ function normalizeCurrency(value: string | null): 'GEL' | 'USD' | null {
|
||||
return value === 'GEL' || value === 'USD' ? value : null
|
||||
}
|
||||
|
||||
function toMinorUnits(rawAmount: string): bigint {
|
||||
const normalized = rawAmount.replace(',', '.')
|
||||
const [wholePart, fractionalPart = ''] = normalized.split('.')
|
||||
const cents = fractionalPart.padEnd(2, '0').slice(0, 2)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAmountSource(
|
||||
value: PurchaseInterpretationAmountSource | null,
|
||||
amountMinor: bigint | null
|
||||
): PurchaseInterpretationAmountSource | null {
|
||||
if (amountMinor === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveAmountMinor(input: { rawText: string; amountMinor: bigint | null }): bigint | null {
|
||||
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
|
||||
return value === 'calculated' ? 'calculated' : 'explicit'
|
||||
}
|
||||
|
||||
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.',
|
||||
`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.',
|
||||
'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.',
|
||||
'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 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.',
|
||||
@@ -233,6 +196,18 @@ export function createOpenAiPurchaseInterpreter(
|
||||
itemDescription: {
|
||||
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||
},
|
||||
amountSource: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
enum: ['explicit', 'calculated']
|
||||
},
|
||||
{ type: 'null' }
|
||||
]
|
||||
},
|
||||
calculationExplanation: {
|
||||
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||
},
|
||||
confidence: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
@@ -247,6 +222,8 @@ export function createOpenAiPurchaseInterpreter(
|
||||
'amountMinor',
|
||||
'currency',
|
||||
'itemDescription',
|
||||
'amountSource',
|
||||
'calculationExplanation',
|
||||
'confidence',
|
||||
'clarificationQuestion'
|
||||
]
|
||||
@@ -286,11 +263,10 @@ export function createOpenAiPurchaseInterpreter(
|
||||
return null
|
||||
}
|
||||
|
||||
const amountMinor = resolveAmountMinor({
|
||||
rawText,
|
||||
amountMinor: asOptionalBigInt(parsedJson.amountMinor)
|
||||
})
|
||||
const amountMinor = asOptionalBigInt(parsedJson.amountMinor)
|
||||
const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
|
||||
const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor)
|
||||
const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation)
|
||||
const currency = resolveMissingCurrency({
|
||||
decision: parsedJson.decision,
|
||||
amountMinor,
|
||||
@@ -315,6 +291,8 @@ export function createOpenAiPurchaseInterpreter(
|
||||
amountMinor,
|
||||
currency,
|
||||
itemDescription,
|
||||
amountSource,
|
||||
calculationExplanation: amountSource === 'calculated' ? calculationExplanation : null,
|
||||
confidence: normalizeConfidence(parsedJson.confidence),
|
||||
parserMode: 'llm',
|
||||
clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null
|
||||
|
||||
@@ -220,6 +220,8 @@ describe('buildPurchaseAcknowledgement', () => {
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'toilet paper',
|
||||
amountSource: 'explicit',
|
||||
calculationExplanation: null,
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm',
|
||||
participants: participants()
|
||||
@@ -227,6 +229,29 @@ describe('buildPurchaseAcknowledgement', () => {
|
||||
|
||||
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:
|
||||
- Mia
|
||||
- Dima (excluded)
|
||||
@@ -241,6 +266,8 @@ Confirm or cancel below.`)
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: null,
|
||||
parsedItemDescription: 'toilet paper',
|
||||
amountSource: 'explicit',
|
||||
calculationExplanation: null,
|
||||
parserConfidence: 61,
|
||||
parserMode: 'llm'
|
||||
})
|
||||
@@ -256,6 +283,8 @@ Confirm or cancel below.`)
|
||||
parsedAmountMinor: null,
|
||||
parsedCurrency: null,
|
||||
parsedItemDescription: 'toilet paper',
|
||||
amountSource: null,
|
||||
calculationExplanation: null,
|
||||
parserConfidence: 42,
|
||||
parserMode: 'llm'
|
||||
})
|
||||
@@ -297,6 +326,8 @@ Confirm or cancel below.`)
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'туалетная бумага',
|
||||
amountSource: 'explicit',
|
||||
calculationExplanation: null,
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm',
|
||||
participants: participants()
|
||||
@@ -734,6 +765,212 @@ Confirm or cancel below.`,
|
||||
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 () => {
|
||||
const bot = createTestBot()
|
||||
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 () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { createDbClient, schema } from '@household/db'
|
||||
import { getBotTranslations, type BotLocale } from './i18n'
|
||||
import type {
|
||||
PurchaseInterpretationAmountSource,
|
||||
PurchaseInterpretation,
|
||||
PurchaseMessageInterpreter
|
||||
} from './openai-purchase-interpreter'
|
||||
@@ -19,13 +20,14 @@ import { stripExplicitBotMention } from './telegram-mentions'
|
||||
const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:'
|
||||
const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:'
|
||||
const PURCHASE_PARTICIPANT_CALLBACK_PREFIX = 'purchase:participant:'
|
||||
const PURCHASE_FIX_AMOUNT_CALLBACK_PREFIX = 'purchase:fix_amount:'
|
||||
const MIN_PROPOSAL_CONFIDENCE = 70
|
||||
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 =
|
||||
/\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 =
|
||||
/\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
|
||||
|
||||
type PurchaseTopicEngagement =
|
||||
@@ -68,6 +70,8 @@ interface PurchaseProposalFields {
|
||||
parsedAmountMinor: bigint | null
|
||||
parsedCurrency: 'GEL' | 'USD' | null
|
||||
parsedItemDescription: string | null
|
||||
amountSource?: PurchaseInterpretationAmountSource | null
|
||||
calculationExplanation?: string | null
|
||||
parserConfidence: number | null
|
||||
parserMode: 'llm' | null
|
||||
}
|
||||
@@ -173,6 +177,29 @@ export type PurchaseProposalParticipantToggleResult =
|
||||
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 {
|
||||
hasClarificationContext(record: PurchaseTopicRecord): Promise<boolean>
|
||||
save(
|
||||
@@ -196,6 +223,10 @@ export interface PurchaseMessageIngestionRepository {
|
||||
participantId: string,
|
||||
actorTelegramUserId: string
|
||||
): Promise<PurchaseProposalParticipantToggleResult>
|
||||
requestAmountCorrection?(
|
||||
purchaseMessageId: string,
|
||||
actorTelegramUserId: string
|
||||
): Promise<PurchaseProposalAmountCorrectionResult>
|
||||
}
|
||||
|
||||
interface PurchasePersistenceDecision {
|
||||
@@ -203,6 +234,8 @@ interface PurchasePersistenceDecision {
|
||||
parsedAmountMinor: bigint | null
|
||||
parsedCurrency: 'GEL' | 'USD' | null
|
||||
parsedItemDescription: string | null
|
||||
amountSource: PurchaseInterpretationAmountSource | null
|
||||
calculationExplanation: string | null
|
||||
parserConfidence: number | null
|
||||
parserMode: 'llm' | null
|
||||
clarificationQuestion: string | null
|
||||
@@ -292,6 +325,8 @@ function normalizeInterpretation(
|
||||
parsedAmountMinor: null,
|
||||
parsedCurrency: null,
|
||||
parsedItemDescription: null,
|
||||
amountSource: null,
|
||||
calculationExplanation: null,
|
||||
parserConfidence: null,
|
||||
parserMode: null,
|
||||
clarificationQuestion: null,
|
||||
@@ -306,6 +341,8 @@ function normalizeInterpretation(
|
||||
parsedAmountMinor: interpretation.amountMinor,
|
||||
parsedCurrency: interpretation.currency,
|
||||
parsedItemDescription: interpretation.itemDescription,
|
||||
amountSource: interpretation.amountSource ?? null,
|
||||
calculationExplanation: interpretation.calculationExplanation ?? null,
|
||||
parserConfidence: interpretation.confidence,
|
||||
parserMode: interpretation.parserMode,
|
||||
clarificationQuestion: null,
|
||||
@@ -329,6 +366,8 @@ function normalizeInterpretation(
|
||||
parsedAmountMinor: interpretation.amountMinor,
|
||||
parsedCurrency: interpretation.currency,
|
||||
parsedItemDescription: interpretation.itemDescription,
|
||||
amountSource: interpretation.amountSource ?? null,
|
||||
calculationExplanation: interpretation.calculationExplanation ?? null,
|
||||
parserConfidence: interpretation.confidence,
|
||||
parserMode: interpretation.parserMode,
|
||||
clarificationQuestion: interpretation.clarificationQuestion,
|
||||
@@ -342,6 +381,8 @@ function normalizeInterpretation(
|
||||
parsedAmountMinor: interpretation.amountMinor,
|
||||
parsedCurrency: interpretation.currency,
|
||||
parsedItemDescription: interpretation.itemDescription,
|
||||
amountSource: interpretation.amountSource ?? null,
|
||||
calculationExplanation: interpretation.calculationExplanation ?? null,
|
||||
parserConfidence: interpretation.confidence,
|
||||
parserMode: interpretation.parserMode,
|
||||
clarificationQuestion: null,
|
||||
@@ -398,6 +439,8 @@ function toProposalFields(row: StoredPurchaseMessageRow): PurchaseProposalFields
|
||||
parsedAmountMinor: row.parsedAmountMinor,
|
||||
parsedCurrency: row.parsedCurrency,
|
||||
parsedItemDescription: row.parsedItemDescription,
|
||||
amountSource: null,
|
||||
calculationExplanation: null,
|
||||
parserConfidence: row.parserConfidence,
|
||||
parserMode: row.parserMode
|
||||
}
|
||||
@@ -991,6 +1034,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
parsedAmountMinor: decision.parsedAmountMinor,
|
||||
parsedCurrency: decision.parsedCurrency,
|
||||
parsedItemDescription: decision.parsedItemDescription,
|
||||
amountSource: decision.amountSource,
|
||||
calculationExplanation: decision.calculationExplanation,
|
||||
parserConfidence: decision.parserConfidence,
|
||||
parserMode: decision.parserMode
|
||||
}
|
||||
@@ -1018,6 +1063,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
parsedAmountMinor: decision.parsedAmountMinor!,
|
||||
parsedCurrency: decision.parsedCurrency!,
|
||||
parsedItemDescription: decision.parsedItemDescription!,
|
||||
amountSource: decision.amountSource,
|
||||
calculationExplanation: decision.calculationExplanation,
|
||||
parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE,
|
||||
parserMode: decision.parserMode ?? 'llm',
|
||||
participants: toProposalParticipants(await getStoredParticipants(insertedRow.id))
|
||||
@@ -1135,6 +1182,84 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
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')}`
|
||||
}
|
||||
|
||||
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(
|
||||
result: PurchaseMessageIngestionResult,
|
||||
locale: BotLocale = 'en'
|
||||
@@ -1219,6 +1359,7 @@ export function buildPurchaseAcknowledgement(
|
||||
case 'pending_confirmation':
|
||||
return t.proposal(
|
||||
formatPurchaseSummary(locale, result),
|
||||
formatPurchaseCalculationNote(locale, result),
|
||||
formatPurchaseParticipants(locale, result.participants)
|
||||
)
|
||||
case 'clarification_needed':
|
||||
@@ -1230,6 +1371,9 @@ export function buildPurchaseAcknowledgement(
|
||||
|
||||
function purchaseProposalReplyMarkup(
|
||||
locale: BotLocale,
|
||||
options: {
|
||||
amountSource?: PurchaseInterpretationAmountSource | null
|
||||
},
|
||||
purchaseMessageId: string,
|
||||
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}`
|
||||
},
|
||||
...(options.amountSource === 'calculated'
|
||||
? [
|
||||
{
|
||||
text: t.calculatedFixAmountButton,
|
||||
callback_data: `${PURCHASE_FIX_AMOUNT_CALLBACK_PREFIX}${purchaseMessageId}`
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
text: t.cancelButton,
|
||||
callback_data: `${PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}`
|
||||
@@ -1319,7 +1471,14 @@ async function handlePurchaseMessageResult(
|
||||
pendingReply,
|
||||
acknowledgement,
|
||||
result.status === 'pending_confirmation'
|
||||
? purchaseProposalReplyMarkup(locale, result.purchaseMessageId, result.participants)
|
||||
? purchaseProposalReplyMarkup(
|
||||
locale,
|
||||
{
|
||||
amountSource: result.amountSource ?? null
|
||||
},
|
||||
result.purchaseMessageId,
|
||||
result.participants
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
@@ -1353,6 +1512,7 @@ function buildPurchaseToggleMessage(
|
||||
): string {
|
||||
return getBotTranslations(locale).purchase.proposal(
|
||||
formatPurchaseSummary(locale, result),
|
||||
null,
|
||||
formatPurchaseParticipants(locale, result.participants)
|
||||
)
|
||||
}
|
||||
@@ -1409,6 +1569,9 @@ function registerPurchaseProposalCallbacks(
|
||||
await ctx.editMessageText(buildPurchaseToggleMessage(locale, result), {
|
||||
reply_markup: purchaseProposalReplyMarkup(
|
||||
locale,
|
||||
{
|
||||
amountSource: result.amountSource ?? null
|
||||
},
|
||||
result.purchaseMessageId,
|
||||
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) => {
|
||||
const purchaseMessageId = ctx.match[1]
|
||||
const actorTelegramUserId = ctx.from?.id?.toString()
|
||||
|
||||
Reference in New Issue
Block a user