mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(bot): quiet finance topics and support purchase payers
This commit is contained in:
@@ -1417,7 +1417,7 @@ Confirm or cancel below.`,
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('uses topic processor for classification and assistant for response', async () => {
|
test('does not hand finance-topic helper routing over to the generic assistant', async () => {
|
||||||
const bot = createTestBot()
|
const bot = createTestBot()
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
let assistantCalls = 0
|
let assistantCalls = 0
|
||||||
@@ -1493,17 +1493,8 @@ Confirm or cancel below.`,
|
|||||||
await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never)
|
await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never)
|
||||||
|
|
||||||
expect(processorCalls).toBe(1)
|
expect(processorCalls).toBe(1)
|
||||||
expect(assistantCalls).toBe(1)
|
expect(assistantCalls).toBe(0)
|
||||||
expect(calls).toEqual(
|
expect(calls).toHaveLength(0)
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'sendMessage',
|
|
||||||
payload: expect.objectContaining({
|
|
||||||
text: 'Still here.'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
])
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('stays silent for regular group chatter when the bot is not addressed', async () => {
|
test('stays silent for regular group chatter when the bot is not addressed', async () => {
|
||||||
|
|||||||
@@ -1132,6 +1132,7 @@ export function registerDmAssistant(options: {
|
|||||||
? getBotTranslations(locale).purchase.proposal(
|
? getBotTranslations(locale).purchase.proposal(
|
||||||
formatPurchaseSummary(locale, purchaseResult),
|
formatPurchaseSummary(locale, purchaseResult),
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
: purchaseResult.status === 'clarification_needed'
|
: purchaseResult.status === 'clarification_needed'
|
||||||
@@ -1502,6 +1503,7 @@ export function registerDmAssistant(options: {
|
|||||||
const fallbackText = getBotTranslations(locale).purchase.proposal(
|
const fallbackText = getBotTranslations(locale).purchase.proposal(
|
||||||
formatPurchaseSummary(locale, purchaseResult),
|
formatPurchaseSummary(locale, purchaseResult),
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
const purchaseText = await composeAssistantReplyText({
|
const purchaseText = await composeAssistantReplyText({
|
||||||
|
|||||||
@@ -875,8 +875,7 @@ describe('registerHouseholdSetupCommands', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
expect(sendPayload.text).toContain('New household! **Kojori House** is ready.')
|
expect(sendPayload.text).toContain('New household! **Kojori House** is ready.')
|
||||||
expect(sendPayload.text).toContain('Current setup progress: 0/5')
|
expect(sendPayload.text).toContain('Current setup progress: 0/4')
|
||||||
expect(sendPayload.text).toContain('0/5')
|
|
||||||
expect(sendPayload.text).toContain('⚪ Purchases')
|
expect(sendPayload.text).toContain('⚪ Purchases')
|
||||||
expect(sendPayload.text).toContain('⚪ Payments')
|
expect(sendPayload.text).toContain('⚪ Payments')
|
||||||
// Check that join household button exists
|
// Check that join household button exists
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
|
|||||||
const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:'
|
const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:'
|
||||||
|
|
||||||
const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [
|
const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [
|
||||||
'chat',
|
|
||||||
'purchase',
|
'purchase',
|
||||||
'feedback',
|
'feedback',
|
||||||
'reminders',
|
'reminders',
|
||||||
@@ -1104,7 +1103,7 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
options.bot.callbackQuery(
|
options.bot.callbackQuery(
|
||||||
/^bind_topic:(chat|purchase|feedback|reminders|payments):(\d+)$/,
|
/^bind_topic:(purchase|feedback|reminders|payments):(\d+)$/,
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const locale = await resolveReplyLocale({
|
const locale = await resolveReplyLocale({
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -276,8 +276,13 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: 'shared purchase',
|
sharedPurchaseFallback: 'shared purchase',
|
||||||
processing: 'Checking that purchase...',
|
processing: 'Checking that purchase...',
|
||||||
proposal: (summary: string, calculationNote: string | null, participants: string | null) =>
|
proposal: (
|
||||||
`I think this shared purchase was: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`,
|
summary: string,
|
||||||
|
payer: string | null,
|
||||||
|
calculationNote: string | null,
|
||||||
|
participants: string | null
|
||||||
|
) =>
|
||||||
|
`I think this shared purchase was: ${summary}.${payer ? `\n${payer}` : ''}${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`,
|
||||||
calculatedAmountNote: (explanation: string | null) =>
|
calculatedAmountNote: (explanation: string | null) =>
|
||||||
explanation
|
explanation
|
||||||
? `I calculated the total as ${explanation}. Is that right?`
|
? `I calculated the total as ${explanation}. Is that right?`
|
||||||
@@ -295,6 +300,12 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
participantExcluded: (displayName) => `- ${displayName} (excluded)`,
|
participantExcluded: (displayName) => `- ${displayName} (excluded)`,
|
||||||
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
||||||
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
||||||
|
payerHeading: 'Paid by:',
|
||||||
|
payerSelected: (displayName) => `Paid by: ${displayName}`,
|
||||||
|
payerQuestion: 'Who actually bought this?',
|
||||||
|
payerFallbackQuestion: 'I could not tell who bought this. Pick the payer below.',
|
||||||
|
payerButton: (displayName) => `${displayName} paid`,
|
||||||
|
payerSelectedToast: (displayName) => `Set payer to ${displayName}.`,
|
||||||
confirmButton: 'Confirm',
|
confirmButton: 'Confirm',
|
||||||
calculatedConfirmButton: 'Looks right',
|
calculatedConfirmButton: 'Looks right',
|
||||||
calculatedFixAmountButton: 'Fix amount',
|
calculatedFixAmountButton: 'Fix amount',
|
||||||
|
|||||||
@@ -280,8 +280,13 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: 'общая покупка',
|
sharedPurchaseFallback: 'общая покупка',
|
||||||
processing: 'Проверяю покупку...',
|
processing: 'Проверяю покупку...',
|
||||||
proposal: (summary: string, calculationNote: string | null, participants: string | null) =>
|
proposal: (
|
||||||
`Похоже, это общая покупка: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`,
|
summary: string,
|
||||||
|
payer: string | null,
|
||||||
|
calculationNote: string | null,
|
||||||
|
participants: string | null
|
||||||
|
) =>
|
||||||
|
`Похоже, это общая покупка: ${summary}.${payer ? `\n${payer}` : ''}${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`,
|
||||||
calculatedAmountNote: (explanation: string | null) =>
|
calculatedAmountNote: (explanation: string | null) =>
|
||||||
explanation
|
explanation
|
||||||
? `Я посчитал итог как ${explanation}. Всё верно?`
|
? `Я посчитал итог как ${explanation}. Всё верно?`
|
||||||
@@ -299,6 +304,12 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
participantExcluded: (displayName) => `- ${displayName} (не участвует)`,
|
participantExcluded: (displayName) => `- ${displayName} (не участвует)`,
|
||||||
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
||||||
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
||||||
|
payerHeading: 'Кто оплатил:',
|
||||||
|
payerSelected: (displayName) => `Оплатил: ${displayName}`,
|
||||||
|
payerQuestion: 'Кто именно это купил?',
|
||||||
|
payerFallbackQuestion: 'Не понял, кто именно это купил. Выберите человека ниже.',
|
||||||
|
payerButton: (displayName) => `Оплатил ${displayName}`,
|
||||||
|
payerSelectedToast: (displayName) => `Записал покупателя: ${displayName}.`,
|
||||||
confirmButton: 'Подтвердить',
|
confirmButton: 'Подтвердить',
|
||||||
calculatedConfirmButton: 'Верно',
|
calculatedConfirmButton: 'Верно',
|
||||||
calculatedFixAmountButton: 'Исправить сумму',
|
calculatedFixAmountButton: 'Исправить сумму',
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ export interface BotTranslationCatalog {
|
|||||||
processing: string
|
processing: string
|
||||||
proposal: (
|
proposal: (
|
||||||
summary: string,
|
summary: string,
|
||||||
|
payer: string | null,
|
||||||
calculationNote: string | null,
|
calculationNote: string | null,
|
||||||
participants: string | null
|
participants: string | null
|
||||||
) => string
|
) => string
|
||||||
@@ -279,6 +280,12 @@ export interface BotTranslationCatalog {
|
|||||||
participantExcluded: (displayName: string) => string
|
participantExcluded: (displayName: string) => string
|
||||||
participantToggleIncluded: (displayName: string) => string
|
participantToggleIncluded: (displayName: string) => string
|
||||||
participantToggleExcluded: (displayName: string) => string
|
participantToggleExcluded: (displayName: string) => string
|
||||||
|
payerHeading: string
|
||||||
|
payerSelected: (displayName: string) => string
|
||||||
|
payerQuestion: string
|
||||||
|
payerFallbackQuestion: string
|
||||||
|
payerButton: (displayName: string) => string
|
||||||
|
payerSelectedToast: (displayName: string) => string
|
||||||
confirmButton: string
|
confirmButton: string
|
||||||
calculatedConfirmButton: string
|
calculatedConfirmButton: string
|
||||||
calculatedFixAmountButton: string
|
calculatedFixAmountButton: string
|
||||||
|
|||||||
@@ -15,10 +15,29 @@ import type {
|
|||||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||||
|
|
||||||
|
function expectedCurrentCyclePeriod(timezone: string, rentDueDay: number): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
}).formatToParts(new Date())
|
||||||
|
const year = Number(parts.find((part) => part.type === 'year')?.value ?? '0')
|
||||||
|
const month = Number(parts.find((part) => part.type === 'month')?.value ?? '1')
|
||||||
|
const day = Number(parts.find((part) => part.type === 'day')?.value ?? '1')
|
||||||
|
const carryMonth = day > rentDueDay ? month + 1 : month
|
||||||
|
const normalizedYear = carryMonth > 12 ? year + 1 : year
|
||||||
|
const normalizedMonth = carryMonth > 12 ? 1 : carryMonth
|
||||||
|
|
||||||
|
return `${normalizedYear}-${String(normalizedMonth).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
function repository(
|
function repository(
|
||||||
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
||||||
): FinanceRepository {
|
): FinanceRepository {
|
||||||
const cycle = {
|
let cycle: Awaited<ReturnType<FinanceRepository['getOpenCycle']>> extends infer T
|
||||||
|
? Exclude<T, null>
|
||||||
|
: never = {
|
||||||
id: 'cycle-1',
|
id: 'cycle-1',
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'GEL' as const
|
currency: 'GEL' as const
|
||||||
@@ -38,7 +57,13 @@ function repository(
|
|||||||
getOpenCycle: async () => cycle,
|
getOpenCycle: async () => cycle,
|
||||||
getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null),
|
getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null),
|
||||||
getLatestCycle: async () => cycle,
|
getLatestCycle: async () => cycle,
|
||||||
openCycle: async () => {},
|
openCycle: async (period, currency) => {
|
||||||
|
cycle = {
|
||||||
|
id: 'opened-cycle',
|
||||||
|
period,
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
},
|
||||||
closeCycle: async () => {},
|
closeCycle: async () => {},
|
||||||
saveRentRule: async () => {},
|
saveRentRule: async () => {},
|
||||||
getCycleExchangeRate: async () => null,
|
getCycleExchangeRate: async () => null,
|
||||||
@@ -326,7 +351,7 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
authorized: true,
|
authorized: true,
|
||||||
dashboard: {
|
dashboard: {
|
||||||
period: '2026-03',
|
period: expectedCurrentCyclePeriod('Asia/Tbilisi', 20),
|
||||||
currency: 'GEL',
|
currency: 'GEL',
|
||||||
paymentBalanceAdjustmentPolicy: 'utilities',
|
paymentBalanceAdjustmentPolicy: 'utilities',
|
||||||
totalDueMajor: '2010.00',
|
totalDueMajor: '2010.00',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface PurchaseInterpretation {
|
|||||||
amountMinor: bigint | null
|
amountMinor: bigint | null
|
||||||
currency: 'GEL' | 'USD' | null
|
currency: 'GEL' | 'USD' | null
|
||||||
itemDescription: string | null
|
itemDescription: string | null
|
||||||
|
payerMemberId?: string | null
|
||||||
amountSource?: PurchaseInterpretationAmountSource | null
|
amountSource?: PurchaseInterpretationAmountSource | null
|
||||||
calculationExplanation?: string | null
|
calculationExplanation?: string | null
|
||||||
participantMemberIds?: readonly string[] | null
|
participantMemberIds?: readonly string[] | null
|
||||||
@@ -43,6 +44,7 @@ interface OpenAiStructuredResult {
|
|||||||
amountMinor: string | null
|
amountMinor: string | null
|
||||||
currency: 'GEL' | 'USD' | null
|
currency: 'GEL' | 'USD' | null
|
||||||
itemDescription: string | null
|
itemDescription: string | null
|
||||||
|
payerMemberId: string | null
|
||||||
amountSource: PurchaseInterpretationAmountSource | null
|
amountSource: PurchaseInterpretationAmountSource | null
|
||||||
calculationExplanation: string | null
|
calculationExplanation: string | null
|
||||||
participantMemberIds: string[] | null
|
participantMemberIds: string[] | null
|
||||||
@@ -104,6 +106,26 @@ function normalizeParticipantMemberIds(
|
|||||||
return normalized.length > 0 ? normalized : null
|
return normalized.length > 0 ? normalized : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePayerMemberId(
|
||||||
|
value: string | null | undefined,
|
||||||
|
householdMembers: readonly PurchaseInterpreterHouseholdMember[] | undefined
|
||||||
|
): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim()
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!householdMembers) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return householdMembers.some((member) => member.memberId === normalized) ? normalized : null
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMissingCurrency(input: {
|
function resolveMissingCurrency(input: {
|
||||||
decision: PurchaseInterpretationDecision
|
decision: PurchaseInterpretationDecision
|
||||||
amountMinor: bigint | null
|
amountMinor: bigint | null
|
||||||
@@ -198,6 +220,7 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
'If the latest message is a complete standalone purchase on its own, ignore the earlier clarification context.',
|
'If the latest message is a complete standalone purchase on its own, ignore the earlier clarification context.',
|
||||||
'If the latest message answers a previous clarification, combine it with the earlier messages to resolve the purchase.',
|
'If the latest message answers a previous clarification, combine it with the earlier messages to resolve the purchase.',
|
||||||
'If a household member roster is provided and the user explicitly says who shares the purchase, return participantMemberIds as the included member IDs.',
|
'If a household member roster is provided and the user explicitly says who shares the purchase, return participantMemberIds as the included member IDs.',
|
||||||
|
'If a household member roster is provided and the user explicitly says who paid for the purchase, return payerMemberId.',
|
||||||
'For phrases like "split with Dima", "for me and Alice", or similar, include the sender and the explicitly mentioned household members in participantMemberIds.',
|
'For phrases like "split with Dima", "for me and Alice", or similar, include the sender and the explicitly mentioned household members in participantMemberIds.',
|
||||||
'If the message does not clearly specify a participant subset, return participantMemberIds as null.',
|
'If the message does not clearly specify a participant subset, return participantMemberIds as null.',
|
||||||
'Away members may still be included when the user explicitly names them.',
|
'Away members may still be included when the user explicitly names them.',
|
||||||
@@ -260,6 +283,9 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
itemDescription: {
|
itemDescription: {
|
||||||
anyOf: [{ type: 'string' }, { type: 'null' }]
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
},
|
},
|
||||||
|
payerMemberId: {
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
|
},
|
||||||
amountSource: {
|
amountSource: {
|
||||||
anyOf: [
|
anyOf: [
|
||||||
{
|
{
|
||||||
@@ -295,6 +321,7 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
'amountMinor',
|
'amountMinor',
|
||||||
'currency',
|
'currency',
|
||||||
'itemDescription',
|
'itemDescription',
|
||||||
|
'payerMemberId',
|
||||||
'amountSource',
|
'amountSource',
|
||||||
'calculationExplanation',
|
'calculationExplanation',
|
||||||
'participantMemberIds',
|
'participantMemberIds',
|
||||||
@@ -339,6 +366,7 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
|
|
||||||
const amountMinor = asOptionalBigInt(parsedJson.amountMinor)
|
const amountMinor = asOptionalBigInt(parsedJson.amountMinor)
|
||||||
const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
|
const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
|
||||||
|
const payerMemberId = normalizePayerMemberId(parsedJson.payerMemberId, options.householdMembers)
|
||||||
const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor)
|
const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor)
|
||||||
const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation)
|
const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation)
|
||||||
const participantMemberIds = normalizeParticipantMemberIds(
|
const participantMemberIds = normalizeParticipantMemberIds(
|
||||||
@@ -376,6 +404,10 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null
|
clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payerMemberId) {
|
||||||
|
result.payerMemberId = payerMemberId
|
||||||
|
}
|
||||||
|
|
||||||
if (participantMemberIds) {
|
if (participantMemberIds) {
|
||||||
result.participantMemberIds = participantMemberIds
|
result.participantMemberIds = participantMemberIds
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,28 @@ function formatPaymentBreakdown(locale: BotLocale, breakdown: PaymentProposalBre
|
|||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseCompactTopicProposal(input: {
|
||||||
|
surface: 'assistant' | 'topic'
|
||||||
|
breakdown: PaymentProposalBreakdown
|
||||||
|
}): boolean {
|
||||||
|
if (input.surface !== 'topic') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.breakdown.guidance.kind !== 'rent') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.breakdown.guidance.adjustmentPolicy !== 'utilities') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
input.breakdown.explicitAmount === null ||
|
||||||
|
input.breakdown.explicitAmount.equals(input.breakdown.guidance.proposalAmount)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function formatPaymentProposalText(input: {
|
export function formatPaymentProposalText(input: {
|
||||||
locale: BotLocale
|
locale: BotLocale
|
||||||
surface: 'assistant' | 'topic'
|
surface: 'assistant' | 'topic'
|
||||||
@@ -199,6 +221,15 @@ export function formatPaymentProposalText(input: {
|
|||||||
amount.currency
|
amount.currency
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldUseCompactTopicProposal({
|
||||||
|
surface: input.surface,
|
||||||
|
breakdown: input.proposal.breakdown
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return intro
|
||||||
|
}
|
||||||
|
|
||||||
return `${intro}\n\n${formatPaymentBreakdown(input.locale, input.proposal.breakdown)}`
|
return `${intro}\n\n${formatPaymentBreakdown(input.locale, input.proposal.breakdown)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,9 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const payload = calls[0]?.payload as { text?: string } | undefined
|
||||||
|
expect(String(payload?.text)).not.toContain('Аренда к оплате')
|
||||||
|
expect(String(payload?.text)).not.toContain('Баланс по общим покупкам')
|
||||||
|
|
||||||
expect(await promptRepository.getPendingAction('-10012345', '10002')).toMatchObject({
|
expect(await promptRepository.getPendingAction('-10012345', '10002')).toMatchObject({
|
||||||
action: 'payment_topic_confirmation'
|
action: 'payment_topic_confirmation'
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
parsePaymentProposalPayload,
|
parsePaymentProposalPayload,
|
||||||
synthesizePaymentConfirmationText
|
synthesizePaymentConfirmationText
|
||||||
} from './payment-proposals'
|
} from './payment-proposals'
|
||||||
import type { TopicMessageRouter } from './topic-message-router'
|
import { cacheTopicMessageRoute, type TopicMessageRouter } from './topic-message-router'
|
||||||
import {
|
import {
|
||||||
persistTopicHistoryMessage,
|
persistTopicHistoryMessage,
|
||||||
telegramMessageIdFromMessage,
|
telegramMessageIdFromMessage,
|
||||||
@@ -662,6 +662,15 @@ export function registerConfiguredPaymentTopicIngestion(
|
|||||||
// Handle different routes
|
// Handle different routes
|
||||||
switch (processorResult.route) {
|
switch (processorResult.route) {
|
||||||
case 'silent': {
|
case 'silent': {
|
||||||
|
cacheTopicMessageRoute(ctx, 'payments', {
|
||||||
|
route: 'silent',
|
||||||
|
replyText: null,
|
||||||
|
helperKind: null,
|
||||||
|
shouldStartTyping: false,
|
||||||
|
shouldClearWorkflow: false,
|
||||||
|
confidence: processorResult.reason === 'test' ? 0 : 80,
|
||||||
|
reason: processorResult.reason
|
||||||
|
})
|
||||||
await next()
|
await next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ function purchaseUpdate(
|
|||||||
options: {
|
options: {
|
||||||
replyToBot?: boolean
|
replyToBot?: boolean
|
||||||
threadId?: number
|
threadId?: number
|
||||||
|
asCaption?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const commandToken = text.split(' ')[0] ?? text
|
const commandToken = text.split(' ')[0] ?? text
|
||||||
@@ -99,6 +100,19 @@ function purchaseUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(options.asCaption
|
||||||
|
? {
|
||||||
|
caption: text,
|
||||||
|
photo: [
|
||||||
|
{
|
||||||
|
file_id: 'photo-1',
|
||||||
|
file_unique_id: 'photo-1',
|
||||||
|
width: 100,
|
||||||
|
height: 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: {
|
||||||
text,
|
text,
|
||||||
entities: text.startsWith('/')
|
entities: text.startsWith('/')
|
||||||
? [
|
? [
|
||||||
@@ -109,6 +123,7 @@ function purchaseUpdate(
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,6 +643,160 @@ Confirm or cancel below.`,
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('reads purchase captions from photo messages', async () => {
|
||||||
|
const bot = createTestBot()
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
message_id: calls.length,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: Number(config.householdChatId),
|
||||||
|
type: 'supergroup'
|
||||||
|
},
|
||||||
|
text: 'ok'
|
||||||
|
}
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
|
async hasClarificationContext() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async save(record) {
|
||||||
|
expect(record.rawText).toBe('Bought toilet paper 30 gel')
|
||||||
|
return {
|
||||||
|
status: 'pending_confirmation',
|
||||||
|
purchaseMessageId: 'proposal-caption',
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL',
|
||||||
|
parsedItemDescription: 'toilet paper',
|
||||||
|
payerMemberId: 'member-1',
|
||||||
|
payerDisplayName: 'Mia',
|
||||||
|
parserConfidence: 90,
|
||||||
|
parserMode: 'llm',
|
||||||
|
participants: participants()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(
|
||||||
|
purchaseUpdate('Bought toilet paper 30 gel', { asCaption: true }) as never
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
|
text: expect.stringContaining('toilet paper - 30.00 GEL')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows payer selection buttons when the purchase payer is ambiguous', async () => {
|
||||||
|
const bot = createTestBot()
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
message_id: calls.length,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: Number(config.householdChatId),
|
||||||
|
type: 'supergroup'
|
||||||
|
},
|
||||||
|
text: 'ok'
|
||||||
|
}
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
|
async hasClarificationContext() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
return {
|
||||||
|
status: 'clarification_needed',
|
||||||
|
purchaseMessageId: 'proposal-1',
|
||||||
|
clarificationQuestion: null,
|
||||||
|
parsedAmountMinor: 1000n,
|
||||||
|
parsedCurrency: 'GEL',
|
||||||
|
parsedItemDescription: 'chicken',
|
||||||
|
payerMemberId: null,
|
||||||
|
payerDisplayName: null,
|
||||||
|
parserConfidence: 78,
|
||||||
|
parserMode: 'llm',
|
||||||
|
payerCandidates: [
|
||||||
|
{ memberId: 'member-1', displayName: 'Mia' },
|
||||||
|
{ memberId: 'member-2', displayName: 'Dima' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(purchaseUpdate('Dima bought chicken for 10 gel') as never)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
const payload = calls[0]?.payload as {
|
||||||
|
text: string
|
||||||
|
reply_markup?: {
|
||||||
|
inline_keyboard?: Array<Array<{ text: string; callback_data: string }>>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(payload.text).toBe('I could not tell who bought this. Pick the payer below.')
|
||||||
|
expect(payload.reply_markup?.inline_keyboard?.[0]).toEqual([
|
||||||
|
{
|
||||||
|
text: 'Mia paid',
|
||||||
|
callback_data: 'purchase:payer:proposal-1:member-1'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
expect(payload.reply_markup?.inline_keyboard?.[1]).toEqual([
|
||||||
|
{
|
||||||
|
text: 'Dima paid',
|
||||||
|
callback_data: 'purchase:payer:proposal-1:member-2'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
expect(payload.reply_markup?.inline_keyboard?.[2]).toEqual([
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
callback_data: 'purchase:cancel:proposal-1'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test('keeps bare-amount purchase reports on the ingestion path', async () => {
|
test('keeps bare-amount purchase reports on the ingestion path', async () => {
|
||||||
const bot = createTestBot()
|
const bot = createTestBot()
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,28 @@ export function fallbackTopicMessageRoute(
|
|||||||
reason: 'active_purchase_workflow'
|
reason: 'active_purchase_workflow'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.isExplicitMention || input.isReplyToBot) {
|
||||||
|
return {
|
||||||
|
route: 'topic_helper',
|
||||||
|
replyText: null,
|
||||||
|
helperKind: 'assistant',
|
||||||
|
shouldStartTyping: true,
|
||||||
|
shouldClearWorkflow: false,
|
||||||
|
confidence: 56,
|
||||||
|
reason: 'addressed_finance_topic'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
route: 'silent',
|
||||||
|
replyText: null,
|
||||||
|
helperKind: null,
|
||||||
|
shouldStartTyping: false,
|
||||||
|
shouldClearWorkflow: false,
|
||||||
|
confidence: 78,
|
||||||
|
reason: 'quiet_purchase_topic'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.topicRole === 'payments') {
|
if (input.topicRole === 'payments') {
|
||||||
@@ -123,6 +145,28 @@ export function fallbackTopicMessageRoute(
|
|||||||
reason: 'active_payment_workflow'
|
reason: 'active_payment_workflow'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.isExplicitMention || input.isReplyToBot) {
|
||||||
|
return {
|
||||||
|
route: 'topic_helper',
|
||||||
|
replyText: null,
|
||||||
|
helperKind: 'assistant',
|
||||||
|
shouldStartTyping: false,
|
||||||
|
shouldClearWorkflow: false,
|
||||||
|
confidence: 56,
|
||||||
|
reason: 'addressed_finance_topic'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
route: 'silent',
|
||||||
|
replyText: null,
|
||||||
|
helperKind: null,
|
||||||
|
shouldStartTyping: false,
|
||||||
|
shouldClearWorkflow: false,
|
||||||
|
confidence: 78,
|
||||||
|
reason: 'quiet_payments_topic'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -295,11 +295,10 @@ export function createTopicProcessor(
|
|||||||
- The message reports a completed purchase or payment (your primary purpose in these topics)
|
- The message reports a completed purchase or payment (your primary purpose in these topics)
|
||||||
- The user addresses the bot (by @mention, reply to bot, or text reference in ANY language — бот, bot, kojori, кожори, or any recognizable variant)
|
- The user addresses the bot (by @mention, reply to bot, or text reference in ANY language — бот, bot, kojori, кожори, or any recognizable variant)
|
||||||
- There is an active clarification/confirmation workflow for this user
|
- There is an active clarification/confirmation workflow for this user
|
||||||
- The user is clearly engaged with the bot (recent bot interaction, strong context reference)
|
|
||||||
- Regular chat between users (plans, greetings, discussion) → silent
|
- Regular chat between users (plans, greetings, discussion) → silent
|
||||||
|
|
||||||
=== PURCHASE TOPIC (topicRole=purchase) ===
|
=== PURCHASE TOPIC (topicRole=purchase) ===
|
||||||
Purchase detection is CONTENT-BASED — engagement signals are irrelevant for this decision.
|
Purchase detection is CONTENT-BASED. This topic is a workflow topic, not a casual assistant thread.
|
||||||
If the message reports a completed purchase (past-tense buy verb + realistic item + amount), classify as "purchase" REGARDLESS of mention/engagement.
|
If the message reports a completed purchase (past-tense buy verb + realistic item + amount), classify as "purchase" REGARDLESS of mention/engagement.
|
||||||
- Completed buy verbs: купил, bought, ordered, picked up, spent, взял, заказал, потратил, сходил взял, etc.
|
- Completed buy verbs: купил, bought, ordered, picked up, spent, взял, заказал, потратил, сходил взял, etc.
|
||||||
- Realistic household items: food, groceries, household goods, toiletries, medicine, transport, cafe, restaurant
|
- Realistic household items: food, groceries, household goods, toiletries, medicine, transport, cafe, restaurant
|
||||||
@@ -307,6 +306,8 @@ If the message reports a completed purchase (past-tense buy verb + realistic ite
|
|||||||
- Gifts for household members ARE shared purchases
|
- Gifts for household members ARE shared purchases
|
||||||
- Plans, wishes, future intent → silent (NOT purchases)
|
- Plans, wishes, future intent → silent (NOT purchases)
|
||||||
- Fantastical items (car, plane, island) or excessive amounts (>500) → chat_reply with playful response
|
- Fantastical items (car, plane, island) or excessive amounts (>500) → chat_reply with playful response
|
||||||
|
- If the user explicitly addresses the bot with non-purchase banter, use chat_reply with one short sentence.
|
||||||
|
- Do not use topic_helper for casual banter in the purchase topic.
|
||||||
|
|
||||||
When classifying as "purchase":
|
When classifying as "purchase":
|
||||||
- amountMinor in minor currency units (350 GEL → 35000, 3.50 → 350)
|
- amountMinor in minor currency units (350 GEL → 35000, 3.50 → 350)
|
||||||
@@ -315,15 +316,19 @@ When classifying as "purchase":
|
|||||||
- Use clarification when amount, item, or intent is unclear but purchase seems likely
|
- Use clarification when amount, item, or intent is unclear but purchase seems likely
|
||||||
|
|
||||||
=== PAYMENT TOPIC (topicRole=payments) ===
|
=== PAYMENT TOPIC (topicRole=payments) ===
|
||||||
|
This topic is also a workflow topic, not a casual assistant thread.
|
||||||
If the message reports a completed rent or utility payment (payment verb + rent/utilities + amount), classify as "payment".
|
If the message reports a completed rent or utility payment (payment verb + rent/utilities + amount), classify as "payment".
|
||||||
- Payment verbs: оплатил, paid, заплатил, перевёл, кинул, отправил
|
- Payment verbs: оплатил, paid, заплатил, перевёл, кинул, отправил
|
||||||
- Realistic amount for rent/utilities
|
- Realistic amount for rent/utilities
|
||||||
|
- If the message is a payment-related balance/status question, use topic_helper.
|
||||||
|
- If the user explicitly addresses the bot with non-payment banter, use chat_reply with one short sentence.
|
||||||
|
- Otherwise ordinary discussion in this topic stays silent.
|
||||||
|
|
||||||
=== CHAT REPLIES ===
|
=== CHAT REPLIES ===
|
||||||
CRITICAL: chat_reply replyText must NEVER claim a purchase or payment was saved, recorded, confirmed, or logged. The chat_reply route does NOT save anything. Only "purchase" and "payment" routes process real data.
|
CRITICAL: chat_reply replyText must NEVER claim a purchase or payment was saved, recorded, confirmed, or logged. The chat_reply route does NOT save anything. Only "purchase" and "payment" routes process real data.
|
||||||
|
|
||||||
=== BOT ADDRESSING ===
|
=== BOT ADDRESSING ===
|
||||||
When the user addresses the bot (by any means), you MUST respond — never silent.
|
When the user addresses the bot (by any means), you should respond briefly, but finance topics still stay workflow-focused.
|
||||||
For bare summons ("бот?", "bot", "@kojori_bot"), use topic_helper to let the assistant greet.
|
For bare summons ("бот?", "bot", "@kojori_bot"), use topic_helper to let the assistant greet.
|
||||||
For small talk or jokes directed at the bot, use chat_reply with a short playful response.
|
For small talk or jokes directed at the bot, use chat_reply with a short playful response.
|
||||||
For questions that need household knowledge, use topic_helper.
|
For questions that need household knowledge, use topic_helper.
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ export function createDbFinanceRepository(
|
|||||||
id: purchaseId,
|
id: purchaseId,
|
||||||
householdId,
|
householdId,
|
||||||
senderMemberId: input.payerMemberId,
|
senderMemberId: input.payerMemberId,
|
||||||
|
payerMemberId: input.payerMemberId,
|
||||||
senderTelegramUserId: 'miniapp',
|
senderTelegramUserId: 'miniapp',
|
||||||
senderDisplayName: member?.displayName ?? 'Mini App',
|
senderDisplayName: member?.displayName ?? 'Mini App',
|
||||||
telegramChatId: 'miniapp',
|
telegramChatId: 'miniapp',
|
||||||
@@ -388,7 +389,7 @@ export function createDbFinanceRepository(
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: schema.purchaseMessages.id,
|
id: schema.purchaseMessages.id,
|
||||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
payerMemberId: schema.purchaseMessages.payerMemberId,
|
||||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||||
currency: schema.purchaseMessages.parsedCurrency,
|
currency: schema.purchaseMessages.parsedCurrency,
|
||||||
description: schema.purchaseMessages.parsedItemDescription,
|
description: schema.purchaseMessages.parsedItemDescription,
|
||||||
@@ -443,7 +444,8 @@ export function createDbFinanceRepository(
|
|||||||
: {}),
|
: {}),
|
||||||
...(input.payerMemberId
|
...(input.payerMemberId
|
||||||
? {
|
? {
|
||||||
senderMemberId: input.payerMemberId
|
senderMemberId: input.payerMemberId,
|
||||||
|
payerMemberId: input.payerMemberId
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
needsReview: 0,
|
needsReview: 0,
|
||||||
@@ -458,7 +460,7 @@ export function createDbFinanceRepository(
|
|||||||
)
|
)
|
||||||
.returning({
|
.returning({
|
||||||
id: schema.purchaseMessages.id,
|
id: schema.purchaseMessages.id,
|
||||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
payerMemberId: schema.purchaseMessages.payerMemberId,
|
||||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||||
currency: schema.purchaseMessages.parsedCurrency,
|
currency: schema.purchaseMessages.parsedCurrency,
|
||||||
description: schema.purchaseMessages.parsedItemDescription,
|
description: schema.purchaseMessages.parsedItemDescription,
|
||||||
@@ -763,7 +765,7 @@ export function createDbFinanceRepository(
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: schema.purchaseMessages.id,
|
id: schema.purchaseMessages.id,
|
||||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
payerMemberId: schema.purchaseMessages.payerMemberId,
|
||||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||||
currency: schema.purchaseMessages.parsedCurrency,
|
currency: schema.purchaseMessages.parsedCurrency,
|
||||||
description: schema.purchaseMessages.parsedItemDescription,
|
description: schema.purchaseMessages.parsedItemDescription,
|
||||||
@@ -774,7 +776,7 @@ export function createDbFinanceRepository(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.purchaseMessages.householdId, householdId),
|
eq(schema.purchaseMessages.householdId, householdId),
|
||||||
isNotNull(schema.purchaseMessages.senderMemberId),
|
isNotNull(schema.purchaseMessages.payerMemberId),
|
||||||
isNotNull(schema.purchaseMessages.parsedAmountMinor),
|
isNotNull(schema.purchaseMessages.parsedAmountMinor),
|
||||||
isNotNull(schema.purchaseMessages.parsedCurrency),
|
isNotNull(schema.purchaseMessages.parsedCurrency),
|
||||||
or(
|
or(
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ import type {
|
|||||||
|
|
||||||
import { createFinanceCommandService } from './finance-command-service'
|
import { createFinanceCommandService } from './finance-command-service'
|
||||||
|
|
||||||
|
function expectedCurrentCyclePeriod(timezone: string, rentDueDay: number): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
}).formatToParts(new Date())
|
||||||
|
const year = Number(parts.find((part) => part.type === 'year')?.value ?? '0')
|
||||||
|
const month = Number(parts.find((part) => part.type === 'month')?.value ?? '1')
|
||||||
|
const day = Number(parts.find((part) => part.type === 'day')?.value ?? '1')
|
||||||
|
const carryMonth = day > rentDueDay ? month + 1 : month
|
||||||
|
const normalizedYear = carryMonth > 12 ? year + 1 : year
|
||||||
|
const normalizedMonth = carryMonth > 12 ? 1 : carryMonth
|
||||||
|
|
||||||
|
return `${normalizedYear}-${String(normalizedMonth).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
class FinanceRepositoryStub implements FinanceRepository {
|
class FinanceRepositoryStub implements FinanceRepository {
|
||||||
householdId = 'household-1'
|
householdId = 'household-1'
|
||||||
member: FinanceMemberRecord | null = null
|
member: FinanceMemberRecord | null = null
|
||||||
@@ -428,9 +445,10 @@ describe('createFinanceCommandService', () => {
|
|||||||
const service = createService(repository)
|
const service = createService(repository)
|
||||||
|
|
||||||
const result = await service.addUtilityBill('Electricity', '55.20', 'member-1')
|
const result = await service.addUtilityBill('Electricity', '55.20', 'member-1')
|
||||||
|
const expectedPeriod = expectedCurrentCyclePeriod('Asia/Tbilisi', 20)
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(result?.period).toBe('2026-03')
|
expect(result?.period).toBe(expectedPeriod)
|
||||||
expect(repository.lastUtilityBill).toEqual({
|
expect(repository.lastUtilityBill).toEqual({
|
||||||
cycleId: 'opened-cycle',
|
cycleId: 'opened-cycle',
|
||||||
billName: 'Electricity',
|
billName: 'Electricity',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"0018_nimble_kojori.sql": "818738e729119c6de8049dcfca562926a5dc6e321ecbbf9cf38e02bc70b5a0dc",
|
"0018_nimble_kojori.sql": "818738e729119c6de8049dcfca562926a5dc6e321ecbbf9cf38e02bc70b5a0dc",
|
||||||
"0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641",
|
"0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641",
|
||||||
"0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1",
|
"0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1",
|
||||||
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad"
|
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad",
|
||||||
|
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
packages/db/drizzle/0021_sharp_payer.sql
Normal file
7
packages/db/drizzle/0021_sharp_payer.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE "purchase_messages"
|
||||||
|
ADD COLUMN "payer_member_id" uuid REFERENCES "members"("id") ON DELETE SET NULL;
|
||||||
|
|
||||||
|
UPDATE "purchase_messages"
|
||||||
|
SET "payer_member_id" = "sender_member_id"
|
||||||
|
WHERE "payer_member_id" IS NULL
|
||||||
|
AND "sender_member_id" IS NOT NULL;
|
||||||
@@ -148,6 +148,13 @@
|
|||||||
"when": 1773590603863,
|
"when": 1773590603863,
|
||||||
"tag": "0020_natural_mauler",
|
"tag": "0020_natural_mauler",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774200000000,
|
||||||
|
"tag": "0021_sharp_payer",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,6 +417,9 @@ export const purchaseMessages = pgTable(
|
|||||||
senderMemberId: uuid('sender_member_id').references(() => members.id, {
|
senderMemberId: uuid('sender_member_id').references(() => members.id, {
|
||||||
onDelete: 'set null'
|
onDelete: 'set null'
|
||||||
}),
|
}),
|
||||||
|
payerMemberId: uuid('payer_member_id').references(() => members.id, {
|
||||||
|
onDelete: 'set null'
|
||||||
|
}),
|
||||||
senderTelegramUserId: text('sender_telegram_user_id').notNull(),
|
senderTelegramUserId: text('sender_telegram_user_id').notNull(),
|
||||||
senderDisplayName: text('sender_display_name'),
|
senderDisplayName: text('sender_display_name'),
|
||||||
rawText: text('raw_text').notNull(),
|
rawText: text('raw_text').notNull(),
|
||||||
|
|||||||
Reference in New Issue
Block a user