feat(purchase): add per-purchase participant splits

This commit is contained in:
2026-03-11 14:34:27 +04:00
parent 98988159eb
commit 8401688032
26 changed files with 5050 additions and 114 deletions

View File

@@ -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: [
[

View File

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

View File

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

View File

@@ -238,7 +238,8 @@ export const ruBotTranslations: BotTranslationCatalog = {
purchase: {
sharedPurchaseFallback: 'общая покупка',
processing: 'Проверяю покупку...',
proposal: (summary) => `Похоже, это общая покупка: ${summary}. Подтвердите или отмените ниже.`,
proposal: (summary, participants) =>
`Похоже, это общая покупка: ${summary}.${participants ? `\n\n${participants}` : ''}\одтвердите или отмените ниже.`,
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:

View File

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

View File

@@ -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<FinanceCommandService['updatePurchase']>[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'
}
]
})
})
})

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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
})) ?? []
}
: {})
}))
}
},

View File

@@ -31,6 +31,23 @@ function candidate(overrides: Partial<PurchaseTopicCandidate> = {}): 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')
}
}

View File

@@ -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<boolean>
save(
@@ -135,6 +167,10 @@ export interface PurchaseMessageIngestionRepository {
purchaseMessageId: string,
actorTelegramUserId: string
): Promise<PurchaseProposalActionResult>
toggleParticipant(
participantId: string,
actorTelegramUserId: string
): Promise<PurchaseProposalParticipantToggleResult>
}
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<readonly StoredPurchaseParticipantRow[]> {
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<readonly { memberId: string; included: boolean }[]> {
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<PurchaseProposalParticipantToggleResult, { status: 'updated' }>
): string {
return getBotTranslations(locale).purchase.proposal(
formatPurchaseSummary(locale, result),
formatPurchaseParticipants(locale, result.participants)
)
}
function registerPurchaseProposalCallbacks(
bot: Bot,
repository: PurchaseMessageIngestionRepository,
resolveLocale: (householdId: string) => Promise<BotLocale>,
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()