diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index 0654328..d45405d 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -397,7 +397,15 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository { parsedCurrency: 'GEL' as const, parsedItemDescription: 'door handle', parserConfidence: 92, - parserMode: 'llm' as const + parserMode: 'llm' as const, + participants: [ + { + id: 'participant-1', + memberId: 'member-1', + displayName: 'Mia', + included: true + } + ] } } @@ -433,7 +441,15 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository { parsedCurrency: 'GEL' as const, parsedItemDescription: 'sausages', parserConfidence: 88, - parserMode: 'llm' as const + parserMode: 'llm' as const, + participants: [ + { + id: 'participant-1', + memberId: 'member-1', + displayName: 'Mia', + included: true + } + ] } } @@ -535,6 +551,9 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository { parserConfidence: 92, parserMode: 'llm' as const } + }, + async toggleParticipant() { + throw new Error('not used') } } } @@ -768,7 +787,8 @@ describe('registerDmAssistant', () => { method: 'sendMessage', payload: { chat_id: 123456, - text: 'I think this shared purchase was: door handle - 30.00 GEL. Confirm or cancel below.', + text: `I think this shared purchase was: door handle - 30.00 GEL. +Confirm or cancel below.`, reply_markup: { inline_keyboard: [ [ @@ -830,7 +850,8 @@ describe('registerDmAssistant', () => { method: 'sendMessage', payload: { chat_id: 123456, - text: 'I think this shared purchase was: sausages - 45.00 GEL. Confirm or cancel below.', + text: `I think this shared purchase was: sausages - 45.00 GEL. +Confirm or cancel below.`, reply_markup: { inline_keyboard: [ [ diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index 688dfee..677d471 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -958,7 +958,8 @@ export function registerDmAssistant(options: { const purchaseText = purchaseResult.status === 'pending_confirmation' ? getBotTranslations(locale).purchase.proposal( - formatPurchaseSummary(locale, purchaseResult) + formatPurchaseSummary(locale, purchaseResult), + null ) : purchaseResult.status === 'clarification_needed' ? buildPurchaseClarificationText(locale, purchaseResult) diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index 79fcff4..6e374ce 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -235,7 +235,8 @@ export const enBotTranslations: BotTranslationCatalog = { purchase: { sharedPurchaseFallback: 'shared purchase', processing: 'Checking that purchase...', - proposal: (summary) => `I think this shared purchase was: ${summary}. Confirm or cancel below.`, + proposal: (summary, participants) => + `I think this shared purchase was: ${summary}.${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`, clarification: (question) => question, clarificationMissingAmountAndCurrency: 'What amount and currency should I record for this shared purchase?', @@ -244,6 +245,11 @@ export const enBotTranslations: BotTranslationCatalog = { clarificationMissingItem: 'What exactly was purchased?', clarificationLowConfidence: 'I am not confident I understood this. Please restate the shared purchase with item, amount, and currency.', + participantsHeading: 'Participants:', + participantIncluded: (displayName) => `- ${displayName}`, + participantExcluded: (displayName) => `- ${displayName} (excluded)`, + participantToggleIncluded: (displayName) => `✅ ${displayName}`, + participantToggleExcluded: (displayName) => `⬜ ${displayName}`, confirmButton: 'Confirm', cancelButton: 'Cancel', confirmed: (summary) => `Purchase confirmed: ${summary}`, @@ -252,6 +258,7 @@ export const enBotTranslations: BotTranslationCatalog = { cancelledToast: 'Purchase cancelled.', alreadyConfirmed: 'This purchase was already confirmed.', alreadyCancelled: 'This purchase was already cancelled.', + atLeastOneParticipant: 'Keep at least one participant in the purchase split.', notYourProposal: 'Only the original sender can confirm or cancel this purchase.', proposalUnavailable: 'This purchase proposal is no longer available.', parseFailed: diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index f97deb4..d2ba539 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -238,7 +238,8 @@ export const ruBotTranslations: BotTranslationCatalog = { purchase: { sharedPurchaseFallback: 'общая покупка', processing: 'Проверяю покупку...', - proposal: (summary) => `Похоже, это общая покупка: ${summary}. Подтвердите или отмените ниже.`, + proposal: (summary, participants) => + `Похоже, это общая покупка: ${summary}.${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`, clarification: (question) => question, clarificationMissingAmountAndCurrency: 'Какую сумму и валюту нужно записать для этой общей покупки?', @@ -247,6 +248,11 @@ export const ruBotTranslations: BotTranslationCatalog = { clarificationMissingItem: 'Что именно было куплено?', clarificationLowConfidence: 'Я не уверен, что правильно понял сообщение. Переформулируйте покупку с предметом, суммой и валютой.', + participantsHeading: 'Участники:', + participantIncluded: (displayName) => `- ${displayName}`, + participantExcluded: (displayName) => `- ${displayName} (не участвует)`, + participantToggleIncluded: (displayName) => `✅ ${displayName}`, + participantToggleExcluded: (displayName) => `⬜ ${displayName}`, confirmButton: 'Подтвердить', cancelButton: 'Отменить', confirmed: (summary) => `Покупка подтверждена: ${summary}`, @@ -255,6 +261,7 @@ export const ruBotTranslations: BotTranslationCatalog = { cancelledToast: 'Покупка отменена.', alreadyConfirmed: 'Эта покупка уже подтверждена.', alreadyCancelled: 'Это предложение покупки уже отменено.', + atLeastOneParticipant: 'В распределении покупки должен остаться хотя бы один участник.', notYourProposal: 'Подтвердить или отменить эту покупку может только отправитель сообщения.', proposalUnavailable: 'Это предложение покупки уже недоступно.', parseFailed: diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 6a062c2..8cb32e6 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -227,13 +227,18 @@ export interface BotTranslationCatalog { purchase: { sharedPurchaseFallback: string processing: string - proposal: (summary: string) => string + proposal: (summary: string, participants: string | null) => string clarification: (question: string) => string clarificationMissingAmountAndCurrency: string clarificationMissingAmount: string clarificationMissingCurrency: string clarificationMissingItem: string clarificationLowConfidence: string + participantsHeading: string + participantIncluded: (displayName: string) => string + participantExcluded: (displayName: string) => string + participantToggleIncluded: (displayName: string) => string + participantToggleExcluded: (displayName: string) => string confirmButton: string cancelButton: string confirmed: (summary: string) => string @@ -242,6 +247,7 @@ export interface BotTranslationCatalog { cancelledToast: string alreadyConfirmed: string alreadyCancelled: string + atLeastOneParticipant: string notYourProposal: string proposalUnavailable: string parseFailed: string diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index 00cdf66..46179f0 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -14,6 +14,7 @@ import { createMiniAppDeleteUtilityBillHandler, createMiniAppOpenCycleHandler, createMiniAppRentUpdateHandler, + createMiniAppUpdatePurchaseHandler, createMiniAppUpdateUtilityBillHandler } from './miniapp-billing' import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' @@ -476,3 +477,83 @@ describe('createMiniAppAddUtilityBillHandler', () => { expect(payload.cycleState.utilityBills).toHaveLength(1) }) }) + +describe('createMiniAppUpdatePurchaseHandler', () => { + test('forwards purchase split edits to the finance service', async () => { + const repository = onboardingRepository() + let capturedSplit: Parameters[4] | undefined + + const handler = createMiniAppUpdatePurchaseHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + onboardingService: createHouseholdOnboardingService({ + repository + }), + financeServiceForHousehold: () => ({ + ...createFinanceServiceStub(), + updatePurchase: async (_purchaseId, _description, _amountArg, _currencyArg, split) => { + capturedSplit = split + return { + purchaseId: 'purchase-1', + amount: Money.fromMinor(3000n, 'GEL'), + currency: 'GEL' + } + } + }) + }) + + const response = await handler.handler( + new Request('http://localhost/api/miniapp/admin/purchases/update', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: initData(), + purchaseId: 'purchase-1', + description: 'Kettle', + amountMajor: '30', + currency: 'GEL', + split: { + mode: 'custom_amounts', + participants: [ + { + memberId: 'member-123456', + included: true, + shareAmountMajor: '20' + }, + { + memberId: 'member-999', + included: false + }, + { + memberId: 'member-888', + included: true, + shareAmountMajor: '10' + } + ] + } + }) + }) + ) + + expect(response.status).toBe(200) + expect(capturedSplit).toEqual({ + mode: 'custom_amounts', + participants: [ + { + memberId: 'member-123456', + shareAmountMajor: '20' + }, + { + memberId: 'member-999' + }, + { + memberId: 'member-888', + shareAmountMajor: '10' + } + ] + }) + }) +}) diff --git a/apps/bot/src/miniapp-billing.ts b/apps/bot/src/miniapp-billing.ts index 3cd25d7..e77ff46 100644 --- a/apps/bot/src/miniapp-billing.ts +++ b/apps/bot/src/miniapp-billing.ts @@ -289,6 +289,13 @@ async function readPurchaseMutationPayload(request: Request): Promise<{ description?: string amountMajor?: string currency?: string + split?: { + mode: 'equal' | 'custom_amounts' + participants: { + memberId: string + shareAmountMajor?: string + }[] + } }> { const parsed = await parseJsonBody<{ initData?: string @@ -296,6 +303,13 @@ async function readPurchaseMutationPayload(request: Request): Promise<{ description?: string amountMajor?: string currency?: string + split?: { + mode?: string + participants?: { + memberId?: string + shareAmountMajor?: string + }[] + } }>(request) const initData = parsed.initData?.trim() if (!initData) { @@ -323,6 +337,32 @@ async function readPurchaseMutationPayload(request: Request): Promise<{ ? { currency: parsed.currency.trim() } + : {}), + ...(parsed.split && + (parsed.split.mode === 'equal' || parsed.split.mode === 'custom_amounts') && + Array.isArray(parsed.split.participants) + ? { + split: { + mode: parsed.split.mode, + participants: parsed.split.participants + .map((participant) => { + const memberId = participant.memberId?.trim() + if (!memberId) { + return null + } + + return { + memberId, + ...(participant.shareAmountMajor?.trim() + ? { + shareAmountMajor: participant.shareAmountMajor.trim() + } + : {}) + } + }) + .filter((participant) => participant !== null) + } + } : {}) } } @@ -858,7 +898,8 @@ export function createMiniAppUpdatePurchaseHandler(options: { payload.purchaseId, payload.description, payload.amountMajor, - payload.currency + payload.currency, + payload.split ) if (!updated) { diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index e521f2c..ea16e0c 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -275,7 +275,6 @@ describe('createMiniAppDashboardHandler', () => { isAdmin: true } ] - const dashboard = createMiniAppDashboardHandler({ allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', @@ -350,6 +349,190 @@ describe('createMiniAppDashboardHandler', () => { }) }) + test('serializes purchase split details into the mini app dashboard', async () => { + const authDate = Math.floor(Date.now() / 1000) + const householdRepository = onboardingRepository() + const financeRepository = repository({ + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + rentShareWeight: 1, + isAdmin: true + }) + + financeRepository.listParsedPurchasesForRange = async () => [ + { + id: 'purchase-1', + payerMemberId: 'member-1', + amountMinor: 3000n, + currency: 'GEL', + description: 'Kettle', + occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'), + splitMode: 'custom_amounts', + participants: [ + { + memberId: 'member-1', + included: true, + shareAmountMinor: 2000n + }, + { + memberId: 'member-2', + included: false, + shareAmountMinor: null + }, + { + memberId: 'member-3', + included: true, + shareAmountMinor: 1000n + } + ] + } + ] + financeRepository.listMembers = async () => [ + { + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + rentShareWeight: 1, + isAdmin: true + }, + { + id: 'member-2', + telegramUserId: '456789', + displayName: 'Dima', + rentShareWeight: 1, + isAdmin: false + }, + { + id: 'member-3', + telegramUserId: '789123', + displayName: 'Chorbanaut', + rentShareWeight: 1, + isAdmin: false + } + ] + + const financeService = createFinanceCommandService({ + householdId: 'household-1', + repository: financeRepository, + householdConfigurationRepository: householdRepository, + exchangeRateProvider + }) + + householdRepository.listHouseholdMembersByTelegramUserId = async () => [ + { + id: 'member-1', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: true + } + ] + householdRepository.listHouseholdMembers = async () => [ + { + id: 'member-1', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: true + }, + { + id: 'member-2', + householdId: 'household-1', + telegramUserId: '456789', + displayName: 'Dima', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: false + }, + { + id: 'member-3', + householdId: 'household-1', + telegramUserId: '789123', + displayName: 'Chorbanaut', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: false + } + ] + + const dashboard = createMiniAppDashboardHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + financeServiceForHousehold: () => financeService, + onboardingService: createHouseholdOnboardingService({ + repository: householdRepository + }) + }) + + const response = await dashboard.handler( + new Request('http://localhost/api/miniapp/dashboard', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildMiniAppInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan', + username: 'stanislav', + language_code: 'ru' + }) + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + ok: true, + authorized: true, + dashboard: { + ledger: [ + { + id: 'purchase-1', + purchaseSplitMode: 'custom_amounts', + purchaseParticipants: [ + { + memberId: 'member-1', + included: true, + shareAmountMajor: '20.00' + }, + { + memberId: 'member-2', + included: false, + shareAmountMajor: null + }, + { + memberId: 'member-3', + included: true, + shareAmountMajor: '10.00' + } + ] + }, + { + title: 'Electricity' + }, + { + kind: 'payment' + } + ] + } + }) + }) + test('returns 400 for malformed JSON bodies', async () => { const householdRepository = onboardingRepository() const financeService = createFinanceCommandService({ diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index 3a2d970..1bd2638 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -120,7 +120,18 @@ export function createMiniAppDashboardHandler(options: { fxRateMicros: entry.fxRateMicros?.toString() ?? null, fxEffectiveDate: entry.fxEffectiveDate, actorDisplayName: entry.actorDisplayName, - occurredAt: entry.occurredAt + occurredAt: entry.occurredAt, + ...(entry.kind === 'purchase' + ? { + purchaseSplitMode: entry.purchaseSplitMode ?? 'equal', + purchaseParticipants: + entry.purchaseParticipants?.map((participant) => ({ + memberId: participant.memberId, + included: participant.included, + shareAmountMajor: participant.shareAmount?.toMajorString() ?? null + })) ?? [] + } + : {}) })) } }, diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index 667e073..dc2ba93 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -31,6 +31,23 @@ function candidate(overrides: Partial = {}): PurchaseTop } } +function participants() { + return [ + { + id: 'participant-1', + memberId: 'member-1', + displayName: 'Mia', + included: true + }, + { + id: 'participant-2', + memberId: 'member-2', + displayName: 'Dima', + included: false + } + ] as const +} + function purchaseUpdate(text: string) { const commandToken = text.split(' ')[0] ?? text @@ -180,12 +197,16 @@ describe('buildPurchaseAcknowledgement', () => { parsedCurrency: 'GEL', parsedItemDescription: 'toilet paper', parserConfidence: 92, - parserMode: 'llm' + parserMode: 'llm', + participants: participants() }) - expect(result).toBe( - 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.' - ) + expect(result).toBe(`I think this shared purchase was: toilet paper - 30.00 GEL. + +Participants: +- Mia +- Dima (excluded) +Confirm or cancel below.`) }) test('returns explicit clarification text from the interpreter', () => { @@ -253,14 +274,18 @@ describe('buildPurchaseAcknowledgement', () => { parsedCurrency: 'GEL', parsedItemDescription: 'туалетная бумага', parserConfidence: 92, - parserMode: 'llm' + parserMode: 'llm', + participants: participants() }, 'ru' ) - expect(result).toBe( - 'Похоже, это общая покупка: туалетная бумага - 30.00 GEL. Подтвердите или отмените ниже.' - ) + expect(result).toBe(`Похоже, это общая покупка: туалетная бумага - 30.00 GEL. + +Участники: +- Mia +- Dima (не участвует) +Подтвердите или отмените ниже.`) }) }) @@ -298,7 +323,8 @@ describe('registerPurchaseTopicIngestion', () => { parsedCurrency: 'GEL', parsedItemDescription: 'toilet paper', parserConfidence: 92, - parserMode: 'llm' + parserMode: 'llm', + participants: participants() } }, async confirm() { @@ -306,6 +332,9 @@ describe('registerPurchaseTopicIngestion', () => { }, async cancel() { throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') } } @@ -319,9 +348,26 @@ describe('registerPurchaseTopicIngestion', () => { reply_parameters: { message_id: 55 }, - text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.', + text: `I think this shared purchase was: toilet paper - 30.00 GEL. + +Participants: +- Mia +- Dima (excluded) +Confirm or cancel below.`, reply_markup: { inline_keyboard: [ + [ + { + text: '✅ Mia', + callback_data: 'purchase:participant:participant-1' + } + ], + [ + { + text: '⬜ Dima', + callback_data: 'purchase:participant:participant-2' + } + ], [ { text: 'Confirm', @@ -379,6 +425,9 @@ describe('registerPurchaseTopicIngestion', () => { }, async cancel() { throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') } } @@ -431,7 +480,8 @@ describe('registerPurchaseTopicIngestion', () => { parsedCurrency: 'GEL', parsedItemDescription: 'toilet paper', parserConfidence: 92, - parserMode: 'llm' + parserMode: 'llm', + participants: participants() } }, async confirm() { @@ -439,6 +489,9 @@ describe('registerPurchaseTopicIngestion', () => { }, async cancel() { throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') } } @@ -480,9 +533,26 @@ describe('registerPurchaseTopicIngestion', () => { payload: { chat_id: Number(config.householdChatId), message_id: 2, - text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.', + text: `I think this shared purchase was: toilet paper - 30.00 GEL. + +Participants: +- Mia +- Dima (excluded) +Confirm or cancel below.`, reply_markup: { inline_keyboard: [ + [ + { + text: '✅ Mia', + callback_data: 'purchase:participant:participant-1' + } + ], + [ + { + text: '⬜ Dima', + callback_data: 'purchase:participant:participant-2' + } + ], [ { text: 'Confirm', @@ -532,6 +602,9 @@ describe('registerPurchaseTopicIngestion', () => { }, async cancel() { throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') } } @@ -571,6 +644,9 @@ describe('registerPurchaseTopicIngestion', () => { }, async cancel() { throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') } } @@ -606,7 +682,8 @@ describe('registerPurchaseTopicIngestion', () => { parsedCurrency: 'GEL', parsedItemDescription: 'toilet paper', parserConfidence: 92, - parserMode: 'llm' + parserMode: 'llm', + participants: participants() } }, async confirm() { @@ -614,6 +691,9 @@ describe('registerPurchaseTopicIngestion', () => { }, async cancel() { throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') } } @@ -626,7 +706,162 @@ describe('registerPurchaseTopicIngestion', () => { expect(calls[0]).toMatchObject({ method: 'sendMessage', payload: { - text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.' + text: `I think this shared purchase was: toilet paper - 30.00 GEL. + +Participants: +- Mia +- Dima (excluded) +Confirm or cancel below.` + } + }) + }) + + test('toggles purchase participants before confirmation', 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() { + return { + status: 'updated' as const, + purchaseMessageId: 'proposal-1', + householdId: config.householdId, + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL' as const, + parsedItemDescription: 'toilet paper', + parserConfidence: 92, + parserMode: 'llm' as const, + participants: [ + { + id: 'participant-1', + memberId: 'member-1', + displayName: 'Mia', + included: true + }, + { + id: 'participant-2', + memberId: 'member-2', + displayName: 'Dima', + included: true + } + ] + } + } + } + + registerPurchaseTopicIngestion(bot, config, repository) + await bot.handleUpdate(callbackUpdate('purchase:participant:participant-2') as never) + + expect(calls).toHaveLength(2) + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1' + } + }) + expect(calls[1]).toMatchObject({ + method: 'editMessageText', + payload: { + text: `I think this shared purchase was: toilet paper - 30.00 GEL. + +Participants: +- Mia +- Dima +Confirm or cancel below.`, + reply_markup: { + inline_keyboard: [ + [ + { + text: '✅ Mia', + callback_data: 'purchase:participant:participant-1' + } + ], + [ + { + text: '✅ Dima', + callback_data: 'purchase:participant:participant-2' + } + ], + [ + { + text: 'Confirm', + callback_data: 'purchase:confirm:proposal-1' + }, + { + text: 'Cancel', + callback_data: 'purchase:cancel:proposal-1' + } + ] + ] + } + } + }) + }) + + test('blocks removing the last included participant', 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() { + return { + status: 'at_least_one_required' as const, + householdId: config.householdId + } + } + } + + registerPurchaseTopicIngestion(bot, config, repository) + await bot.handleUpdate(callbackUpdate('purchase:participant:participant-1') as never) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1', + text: 'Keep at least one participant in the purchase split.', + show_alert: true } }) }) @@ -656,7 +891,8 @@ describe('registerPurchaseTopicIngestion', () => { parsedCurrency: 'GEL', parsedItemDescription: 'toilet paper', parserConfidence: 92, - parserMode: 'llm' + parserMode: 'llm', + participants: participants() } }, async confirm() { @@ -673,6 +909,9 @@ describe('registerPurchaseTopicIngestion', () => { }, async cancel() { throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') } } @@ -734,6 +973,9 @@ describe('registerPurchaseTopicIngestion', () => { }, async cancel() { throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') } } @@ -789,6 +1031,9 @@ describe('registerPurchaseTopicIngestion', () => { parserConfidence: 92, parserMode: 'llm' as const } + }, + async toggleParticipant() { + throw new Error('not used') } } diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 7f54816..38e6b9c 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -18,6 +18,7 @@ import { stripExplicitBotMention } from './telegram-mentions' const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:' const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:' +const PURCHASE_PARTICIPANT_CALLBACK_PREFIX = 'purchase:participant:' const MIN_PROPOSAL_CONFIDENCE = 70 type StoredPurchaseProcessingStatus = @@ -64,6 +65,14 @@ interface PurchasePendingConfirmationResult extends PurchaseProposalFields { parsedItemDescription: string parserConfidence: number parserMode: 'llm' + participants: readonly PurchaseProposalParticipant[] +} + +interface PurchaseProposalParticipant { + id: string + memberId: string + displayName: string + included: boolean } export interface PurchaseTopicIngestionConfig { @@ -120,6 +129,29 @@ export type PurchaseProposalActionResult = status: 'not_found' } +export type PurchaseProposalParticipantToggleResult = + | ({ + status: 'updated' + purchaseMessageId: string + householdId: string + participants: readonly PurchaseProposalParticipant[] + } & PurchaseProposalFields) + | { + status: 'at_least_one_required' + householdId: string + } + | { + status: 'forbidden' + householdId: string + } + | { + status: 'not_pending' + householdId: string + } + | { + status: 'not_found' + } + export interface PurchaseMessageIngestionRepository { hasClarificationContext(record: PurchaseTopicRecord): Promise save( @@ -135,6 +167,10 @@ export interface PurchaseMessageIngestionRepository { purchaseMessageId: string, actorTelegramUserId: string ): Promise + toggleParticipant( + participantId: string, + actorTelegramUserId: string + ): Promise } interface PurchasePersistenceDecision { @@ -149,9 +185,23 @@ interface PurchasePersistenceDecision { needsReview: boolean } +interface StoredPurchaseParticipantRow { + id: string + purchaseMessageId: string + memberId: string + displayName: string + telegramUserId: string + included: boolean +} + const CLARIFICATION_CONTEXT_MAX_AGE_MS = 30 * 60_000 const MAX_CLARIFICATION_CONTEXT_MESSAGES = 3 +function periodFromInstant(instant: Instant, timezone: string): string { + const localDate = instant.toZonedDateTimeISO(timezone).toPlainDate() + return `${localDate.year}-${String(localDate.month).padStart(2, '0')}` +} + function normalizeInterpretation( interpretation: PurchaseInterpretation | null, parserError: string | null @@ -224,6 +274,10 @@ function needsReviewAsInt(value: boolean): number { return value ? 1 : 0 } +function participantIncludedAsInt(value: boolean): number { + return value ? 1 : 0 +} + function toStoredPurchaseRow(row: { id: string householdId: string @@ -269,6 +323,17 @@ function toProposalFields(row: StoredPurchaseMessageRow): PurchaseProposalFields } } +function toProposalParticipants( + rows: readonly StoredPurchaseParticipantRow[] +): readonly PurchaseProposalParticipant[] { + return rows.map((row) => ({ + id: row.id, + memberId: row.memberId, + displayName: row.displayName, + included: row.included + })) +} + async function replyToPurchaseMessage( ctx: Context, text: string, @@ -529,6 +594,128 @@ export function createPurchaseMessageRepository(databaseUrl: string): { return row ? toStoredPurchaseRow(row) : null } + async function getStoredParticipants( + purchaseMessageId: string + ): Promise { + const rows = await db + .select({ + id: schema.purchaseMessageParticipants.id, + purchaseMessageId: schema.purchaseMessageParticipants.purchaseMessageId, + memberId: schema.purchaseMessageParticipants.memberId, + displayName: schema.members.displayName, + telegramUserId: schema.members.telegramUserId, + included: schema.purchaseMessageParticipants.included + }) + .from(schema.purchaseMessageParticipants) + .innerJoin(schema.members, eq(schema.purchaseMessageParticipants.memberId, schema.members.id)) + .where(eq(schema.purchaseMessageParticipants.purchaseMessageId, purchaseMessageId)) + + return rows.map((row) => ({ + id: row.id, + purchaseMessageId: row.purchaseMessageId, + memberId: row.memberId, + displayName: row.displayName, + telegramUserId: row.telegramUserId, + included: row.included === 1 + })) + } + + async function defaultProposalParticipants(input: { + householdId: string + senderTelegramUserId: string + senderMemberId: string | null + messageSentAt: Instant + }): Promise { + const [members, settingsRows, policyRows] = await Promise.all([ + db + .select({ + id: schema.members.id, + telegramUserId: schema.members.telegramUserId, + lifecycleStatus: schema.members.lifecycleStatus + }) + .from(schema.members) + .where(eq(schema.members.householdId, input.householdId)), + db + .select({ + timezone: schema.householdBillingSettings.timezone + }) + .from(schema.householdBillingSettings) + .where(eq(schema.householdBillingSettings.householdId, input.householdId)) + .limit(1), + db + .select({ + memberId: schema.memberAbsencePolicies.memberId, + effectiveFromPeriod: schema.memberAbsencePolicies.effectiveFromPeriod, + policy: schema.memberAbsencePolicies.policy + }) + .from(schema.memberAbsencePolicies) + .where(eq(schema.memberAbsencePolicies.householdId, input.householdId)) + ]) + + const timezone = settingsRows[0]?.timezone ?? 'Asia/Tbilisi' + const period = periodFromInstant(input.messageSentAt, timezone) + const policyByMemberId = new Map< + string, + { + effectiveFromPeriod: string + policy: string + } + >() + + for (const row of policyRows) { + if (row.effectiveFromPeriod.localeCompare(period) > 0) { + continue + } + + const current = policyByMemberId.get(row.memberId) + if (!current || current.effectiveFromPeriod.localeCompare(row.effectiveFromPeriod) < 0) { + policyByMemberId.set(row.memberId, { + effectiveFromPeriod: row.effectiveFromPeriod, + policy: row.policy + }) + } + } + + const participants = members + .filter((member) => member.lifecycleStatus !== 'left') + .map((member) => { + const policy = policyByMemberId.get(member.id)?.policy ?? 'resident' + const included = + member.lifecycleStatus === 'away' + ? policy === 'resident' + : member.lifecycleStatus === 'active' + + return { + memberId: member.id, + telegramUserId: member.telegramUserId, + included + } + }) + + if (participants.length === 0) { + return [] + } + + if (participants.some((participant) => participant.included)) { + return participants.map(({ memberId, included }) => ({ + memberId, + included + })) + } + + const fallbackParticipant = + participants.find((participant) => participant.memberId === input.senderMemberId) ?? + participants.find( + (participant) => participant.telegramUserId === input.senderTelegramUserId + ) ?? + participants[0] + + return participants.map(({ memberId }) => ({ + memberId, + included: memberId === fallbackParticipant?.memberId + })) + } + async function mutateProposalStatus( purchaseMessageId: string, actorTelegramUserId: string, @@ -725,7 +912,24 @@ export function createPurchaseMessageRepository(databaseUrl: string): { parserConfidence: decision.parserConfidence, parserMode: decision.parserMode } - case 'pending_confirmation': + case 'pending_confirmation': { + const participants = await defaultProposalParticipants({ + householdId: record.householdId, + senderTelegramUserId: record.senderTelegramUserId, + senderMemberId, + messageSentAt: record.messageSentAt + }) + + if (participants.length > 0) { + await db.insert(schema.purchaseMessageParticipants).values( + participants.map((participant) => ({ + purchaseMessageId: insertedRow.id, + memberId: participant.memberId, + included: participantIncludedAsInt(participant.included) + })) + ) + } + return { status: 'pending_confirmation', purchaseMessageId: insertedRow.id, @@ -733,8 +937,10 @@ export function createPurchaseMessageRepository(databaseUrl: string): { parsedCurrency: decision.parsedCurrency!, parsedItemDescription: decision.parsedItemDescription!, parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE, - parserMode: decision.parserMode ?? 'llm' + parserMode: decision.parserMode ?? 'llm', + participants: toProposalParticipants(await getStoredParticipants(insertedRow.id)) } + } case 'parse_failed': return { status: 'parse_failed', @@ -749,6 +955,104 @@ export function createPurchaseMessageRepository(databaseUrl: string): { async cancel(purchaseMessageId, actorTelegramUserId) { return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'cancelled') + }, + + async toggleParticipant(participantId, actorTelegramUserId) { + const rows = await db + .select({ + participantId: schema.purchaseMessageParticipants.id, + purchaseMessageId: schema.purchaseMessageParticipants.purchaseMessageId, + memberId: schema.purchaseMessageParticipants.memberId, + included: schema.purchaseMessageParticipants.included, + householdId: schema.purchaseMessages.householdId, + senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId, + parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor, + parsedCurrency: schema.purchaseMessages.parsedCurrency, + parsedItemDescription: schema.purchaseMessages.parsedItemDescription, + parserConfidence: schema.purchaseMessages.parserConfidence, + parserMode: schema.purchaseMessages.parserMode, + processingStatus: schema.purchaseMessages.processingStatus + }) + .from(schema.purchaseMessageParticipants) + .innerJoin( + schema.purchaseMessages, + eq(schema.purchaseMessageParticipants.purchaseMessageId, schema.purchaseMessages.id) + ) + .where(eq(schema.purchaseMessageParticipants.id, participantId)) + .limit(1) + + const existing = rows[0] + if (!existing) { + return { + status: 'not_found' + } + } + + if (existing.processingStatus !== 'pending_confirmation') { + return { + status: 'not_pending', + householdId: existing.householdId + } + } + + const actorRows = await db + .select({ + memberId: schema.members.id, + isAdmin: schema.members.isAdmin + }) + .from(schema.members) + .where( + and( + eq(schema.members.householdId, existing.householdId), + eq(schema.members.telegramUserId, actorTelegramUserId) + ) + ) + .limit(1) + + const actor = actorRows[0] + if (existing.senderTelegramUserId !== actorTelegramUserId && actor?.isAdmin !== 1) { + return { + status: 'forbidden', + householdId: existing.householdId + } + } + + const currentParticipants = await getStoredParticipants(existing.purchaseMessageId) + const currentlyIncludedCount = currentParticipants.filter( + (participant) => participant.included + ).length + + if (existing.included === 1 && currentlyIncludedCount <= 1) { + return { + status: 'at_least_one_required', + householdId: existing.householdId + } + } + + await db + .update(schema.purchaseMessageParticipants) + .set({ + included: existing.included === 1 ? 0 : 1, + updatedAt: new Date() + }) + .where(eq(schema.purchaseMessageParticipants.id, participantId)) + + return { + status: 'updated', + purchaseMessageId: existing.purchaseMessageId, + householdId: existing.householdId, + parsedAmountMinor: existing.parsedAmountMinor, + parsedCurrency: + existing.parsedCurrency === 'GEL' || existing.parsedCurrency === 'USD' + ? existing.parsedCurrency + : null, + parsedItemDescription: existing.parsedItemDescription, + parserConfidence: existing.parserConfidence, + parserMode: existing.parserMode === 'llm' ? 'llm' : null, + participants: toProposalParticipants( + await getStoredParticipants(existing.purchaseMessageId) + ) + } } } @@ -802,6 +1106,24 @@ function clarificationFallback(locale: BotLocale, result: PurchaseClarificationR return t.clarificationLowConfidence } +function formatPurchaseParticipants( + locale: BotLocale, + participants: readonly PurchaseProposalParticipant[] +): string | null { + if (participants.length === 0) { + return null + } + + const t = getBotTranslations(locale).purchase + const lines = participants.map((participant) => + participant.included + ? t.participantIncluded(participant.displayName) + : t.participantExcluded(participant.displayName) + ) + + return `${t.participantsHeading}\n${lines.join('\n')}` +} + export function buildPurchaseAcknowledgement( result: PurchaseMessageIngestionResult, locale: BotLocale = 'en' @@ -813,7 +1135,10 @@ export function buildPurchaseAcknowledgement( case 'ignored_not_purchase': return null case 'pending_confirmation': - return t.proposal(formatPurchaseSummary(locale, result)) + return t.proposal( + formatPurchaseSummary(locale, result), + formatPurchaseParticipants(locale, result.participants) + ) case 'clarification_needed': return t.clarification(result.clarificationQuestion ?? clarificationFallback(locale, result)) case 'parse_failed': @@ -821,11 +1146,23 @@ export function buildPurchaseAcknowledgement( } } -function purchaseProposalReplyMarkup(locale: BotLocale, purchaseMessageId: string) { +function purchaseProposalReplyMarkup( + locale: BotLocale, + purchaseMessageId: string, + participants: readonly PurchaseProposalParticipant[] +) { const t = getBotTranslations(locale).purchase return { inline_keyboard: [ + ...participants.map((participant) => [ + { + text: participant.included + ? t.participantToggleIncluded(participant.displayName) + : t.participantToggleExcluded(participant.displayName), + callback_data: `${PURCHASE_PARTICIPANT_CALLBACK_PREFIX}${participant.id}` + } + ]), [ { text: t.confirmButton, @@ -883,7 +1220,7 @@ async function handlePurchaseMessageResult( pendingReply, acknowledgement, result.status === 'pending_confirmation' - ? purchaseProposalReplyMarkup(locale, result.purchaseMessageId) + ? purchaseProposalReplyMarkup(locale, result.purchaseMessageId, result.participants) : undefined ) } @@ -911,12 +1248,85 @@ function buildPurchaseActionMessage( return t.cancelled(summary) } +function buildPurchaseToggleMessage( + locale: BotLocale, + result: Extract +): string { + return getBotTranslations(locale).purchase.proposal( + formatPurchaseSummary(locale, result), + formatPurchaseParticipants(locale, result.participants) + ) +} + function registerPurchaseProposalCallbacks( bot: Bot, repository: PurchaseMessageIngestionRepository, resolveLocale: (householdId: string) => Promise, logger?: Logger ): void { + bot.callbackQuery(new RegExp(`^${PURCHASE_PARTICIPANT_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => { + const participantId = ctx.match[1] + const actorTelegramUserId = ctx.from?.id?.toString() + + if (!actorTelegramUserId || !participantId) { + await ctx.answerCallbackQuery({ + text: getBotTranslations('en').purchase.proposalUnavailable, + show_alert: true + }) + return + } + + const result = await repository.toggleParticipant(participantId, 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 + } + + if (result.status === 'at_least_one_required') { + await ctx.answerCallbackQuery({ + text: t.atLeastOneParticipant, + show_alert: true + }) + return + } + + await ctx.answerCallbackQuery() + + if (ctx.msg) { + await ctx.editMessageText(buildPurchaseToggleMessage(locale, result), { + reply_markup: purchaseProposalReplyMarkup( + locale, + result.purchaseMessageId, + result.participants + ) + }) + } + + logger?.info( + { + event: 'purchase.participant_toggled', + participantId, + purchaseMessageId: result.purchaseMessageId, + actorTelegramUserId + }, + 'Purchase proposal participant toggled' + ) + }) + bot.callbackQuery(new RegExp(`^${PURCHASE_CONFIRM_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => { const purchaseMessageId = ctx.match[1] const actorTelegramUserId = ctx.from?.id?.toString() diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index ceb8c25..09a4191 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -84,6 +84,11 @@ type PurchaseDraft = { description: string amountMajor: string currency: 'USD' | 'GEL' + splitMode: 'equal' | 'custom_amounts' + participants: { + memberId: string + shareAmountMajor: string + }[] } type PaymentDraft = { @@ -244,12 +249,36 @@ function purchaseDrafts( { description: entry.title, amountMajor: entry.amountMajor, - currency: entry.currency + currency: entry.currency, + splitMode: entry.purchaseSplitMode ?? 'equal', + participants: + entry.purchaseParticipants + ?.filter((participant) => participant.included) + .map((participant) => ({ + memberId: participant.memberId, + shareAmountMajor: participant.shareAmountMajor ?? '' + })) ?? [] } ]) ) } +function purchaseDraftForEntry(entry: MiniAppDashboard['ledger'][number]): PurchaseDraft { + return { + description: entry.title, + amountMajor: entry.amountMajor, + currency: entry.currency, + splitMode: entry.purchaseSplitMode ?? 'equal', + participants: + entry.purchaseParticipants + ?.filter((participant) => participant.included) + .map((participant) => ({ + memberId: participant.memberId, + shareAmountMajor: participant.shareAmountMajor ?? '' + })) ?? [] + } +} + function paymentDrafts( entries: readonly MiniAppDashboard['ledger'][number][] ): Record { @@ -1086,7 +1115,10 @@ function App() { !currentReady.member.isAdmin || !draft || draft.description.trim().length === 0 || - draft.amountMajor.trim().length === 0 + draft.amountMajor.trim().length === 0 || + draft.participants.length === 0 || + (draft.splitMode === 'custom_amounts' && + draft.participants.some((participant) => participant.shareAmountMajor.trim().length === 0)) ) { return } @@ -1098,7 +1130,25 @@ function App() { purchaseId, description: draft.description, amountMajor: draft.amountMajor, - currency: draft.currency + currency: draft.currency, + split: { + mode: draft.splitMode, + participants: (adminSettings()?.members ?? []).map((member) => { + const participant = draft.participants.find( + (currentParticipant) => currentParticipant.memberId === member.id + ) + + return { + memberId: member.id, + included: Boolean(participant), + ...(draft.splitMode === 'custom_amounts' && participant + ? { + shareAmountMajor: participant.shareAmountMajor + } + : {}) + } + }) + } }) await refreshHouseholdData(initData, true) } finally { @@ -1385,6 +1435,34 @@ function App() { } } + function purchaseSplitPreview(purchaseId: string): { memberId: string; amountMajor: string }[] { + const draft = purchaseDraftMap()[purchaseId] + if (!draft || draft.participants.length === 0) { + return [] + } + + if (draft.splitMode === 'custom_amounts') { + return draft.participants.map((participant) => ({ + memberId: participant.memberId, + amountMajor: participant.shareAmountMajor + })) + } + + const totalMinor = majorStringToMinor(draft.amountMajor) + const count = BigInt(draft.participants.length) + if (count <= 0n) { + return [] + } + + const base = totalMinor / count + const remainder = totalMinor % count + + return draft.participants.map((participant, index) => ({ + memberId: participant.memberId, + amountMajor: minorToMajorString(base + (BigInt(index) < remainder ? 1n : 0n)) + })) + } + const renderPanel = () => { switch (activeNav()) { case 'balances': @@ -1521,11 +1599,7 @@ function App() { setPurchaseDraftMap((current) => ({ ...current, [entry.id]: { - ...(current[entry.id] ?? { - description: entry.title, - amountMajor: entry.amountMajor, - currency: entry.currency - }), + ...(current[entry.id] ?? purchaseDraftForEntry(entry)), description: event.currentTarget.value } })) @@ -1543,11 +1617,7 @@ function App() { setPurchaseDraftMap((current) => ({ ...current, [entry.id]: { - ...(current[entry.id] ?? { - description: entry.title, - amountMajor: entry.amountMajor, - currency: entry.currency - }), + ...(current[entry.id] ?? purchaseDraftForEntry(entry)), amountMajor: event.currentTarget.value } })) @@ -1564,11 +1634,7 @@ function App() { setPurchaseDraftMap((current) => ({ ...current, [entry.id]: { - ...(current[entry.id] ?? { - description: entry.title, - amountMajor: entry.amountMajor, - currency: entry.currency - }), + ...(current[entry.id] ?? purchaseDraftForEntry(entry)), currency: event.currentTarget.value as 'USD' | 'GEL' } })) @@ -1579,6 +1645,147 @@ function App() { +
+
+ {copy().purchaseSplitTitle} + + {purchaseDraftMap()[entry.id]?.splitMode === 'custom_amounts' + ? copy().purchaseSplitCustom + : copy().purchaseSplitEqual} + +
+
+ +
+
+ {(adminSettings()?.members ?? []).map((member) => { + const draft = + purchaseDraftMap()[entry.id] ?? purchaseDraftForEntry(entry) + const included = draft.participants.some( + (participant) => participant.memberId === member.id + ) + + return ( +
+
+ {member.displayName} + + {purchaseSplitPreview(entry.id).find( + (participant) => participant.memberId === member.id + )?.amountMajor ?? '0.00'}{' '} + {draft.currency} + +
+
+ + + + +
+
+ ) + })} +
+