feat(payments): track household payment confirmations

This commit is contained in:
2026-03-10 17:00:45 +04:00
parent fb85219409
commit 1988521931
31 changed files with 4795 additions and 19 deletions

View File

@@ -67,7 +67,7 @@ function bindRejectionMessage(
function bindTopicUsageMessage( function bindTopicUsageMessage(
locale: BotLocale, locale: BotLocale,
role: 'purchase' | 'feedback' | 'reminders' role: 'purchase' | 'feedback' | 'reminders' | 'payments'
): string { ): string {
const t = getBotTranslations(locale).setup const t = getBotTranslations(locale).setup
@@ -78,12 +78,14 @@ function bindTopicUsageMessage(
return t.useBindFeedbackTopicInGroup return t.useBindFeedbackTopicInGroup
case 'reminders': case 'reminders':
return t.useBindRemindersTopicInGroup return t.useBindRemindersTopicInGroup
case 'payments':
return t.useBindPaymentsTopicInGroup
} }
} }
function bindTopicSuccessMessage( function bindTopicSuccessMessage(
locale: BotLocale, locale: BotLocale,
role: 'purchase' | 'feedback' | 'reminders', role: 'purchase' | 'feedback' | 'reminders' | 'payments',
householdName: string, householdName: string,
threadId: string threadId: string
): string { ): string {
@@ -96,6 +98,8 @@ function bindTopicSuccessMessage(
return t.feedbackTopicSaved(householdName, threadId) return t.feedbackTopicSaved(householdName, threadId)
case 'reminders': case 'reminders':
return t.remindersTopicSaved(householdName, threadId) return t.remindersTopicSaved(householdName, threadId)
case 'payments':
return t.paymentsTopicSaved(householdName, threadId)
} }
} }
@@ -218,7 +222,7 @@ export function registerHouseholdSetupCommands(options: {
}): void { }): void {
async function handleBindTopicCommand( async function handleBindTopicCommand(
ctx: Context, ctx: Context,
role: 'purchase' | 'feedback' | 'reminders' role: 'purchase' | 'feedback' | 'reminders' | 'payments'
): Promise<void> { ): Promise<void> {
const locale = await resolveReplyLocale({ const locale = await resolveReplyLocale({
ctx, ctx,
@@ -455,6 +459,10 @@ export function registerHouseholdSetupCommands(options: {
await handleBindTopicCommand(ctx, 'reminders') await handleBindTopicCommand(ctx, 'reminders')
}) })
options.bot.command('bind_payments_topic', async (ctx) => {
await handleBindTopicCommand(ctx, 'payments')
})
options.bot.command('pending_members', async (ctx) => { options.bot.command('pending_members', async (ctx) => {
const locale = await resolveReplyLocale({ const locale = await resolveReplyLocale({
ctx, ctx,

View File

@@ -11,6 +11,7 @@ export const enBotTranslations: BotTranslationCatalog = {
bind_purchase_topic: 'Bind the current topic as purchases', bind_purchase_topic: 'Bind the current topic as purchases',
bind_feedback_topic: 'Bind the current topic as feedback', bind_feedback_topic: 'Bind the current topic as feedback',
bind_reminders_topic: 'Bind the current topic as reminders', bind_reminders_topic: 'Bind the current topic as reminders',
bind_payments_topic: 'Bind the current topic as payments',
pending_members: 'List pending household join requests', pending_members: 'List pending household join requests',
approve_member: 'Approve a pending household member' approve_member: 'Approve a pending household member'
}, },
@@ -53,7 +54,7 @@ export const enBotTranslations: BotTranslationCatalog = {
[ [
`Household ${created ? 'created' : 'already registered'}: ${householdName}`, `Household ${created ? 'created' : 'already registered'}: ${householdName}`,
`Chat ID: ${telegramChatId}`, `Chat ID: ${telegramChatId}`,
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic. If you want a dedicated reminders topic, open it and run /bind_reminders_topic.', 'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic. If you want dedicated reminders or payments topics, open them and run /bind_reminders_topic or /bind_payments_topic.',
'Members should open the bot chat from the button below and confirm the join request there.' 'Members should open the bot chat from the button below and confirm the join request there.'
].join('\n'), ].join('\n'),
useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.', useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.',
@@ -65,6 +66,9 @@ export const enBotTranslations: BotTranslationCatalog = {
useBindRemindersTopicInGroup: 'Use /bind_reminders_topic inside the household group topic.', useBindRemindersTopicInGroup: 'Use /bind_reminders_topic inside the household group topic.',
remindersTopicSaved: (householdName, threadId) => remindersTopicSaved: (householdName, threadId) =>
`Reminders topic saved for ${householdName} (thread ${threadId}).`, `Reminders topic saved for ${householdName} (thread ${threadId}).`,
useBindPaymentsTopicInGroup: 'Use /bind_payments_topic inside the household group topic.',
paymentsTopicSaved: (householdName, threadId) =>
`Payments topic saved for ${householdName} (thread ${threadId}).`,
usePendingMembersInGroup: 'Use /pending_members inside the household group.', usePendingMembersInGroup: 'Use /pending_members inside the household group.',
useApproveMemberInGroup: 'Use /approve_member inside the household group.', useApproveMemberInGroup: 'Use /approve_member inside the household group.',
approveMemberUsage: 'Usage: /approve_member <telegram_user_id>', approveMemberUsage: 'Usage: /approve_member <telegram_user_id>',
@@ -147,5 +151,13 @@ export const enBotTranslations: BotTranslationCatalog = {
recorded: (summary) => `Recorded purchase: ${summary}`, recorded: (summary) => `Recorded purchase: ${summary}`,
savedForReview: (summary) => `Saved for review: ${summary}`, savedForReview: (summary) => `Saved for review: ${summary}`,
parseFailed: "Saved for review: I couldn't parse this purchase yet." parseFailed: "Saved for review: I couldn't parse this purchase yet."
},
payments: {
topicMissing:
'Payments topic is not configured for this household yet. Ask an admin to run /bind_payments_topic.',
recorded: (kind, amount, currency) =>
`Recorded ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}`,
savedForReview: 'Saved this payment confirmation for review.',
duplicate: 'This payment confirmation was already processed.'
} }
} }

View File

@@ -11,6 +11,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
bind_purchase_topic: 'Назначить текущий топик для покупок', bind_purchase_topic: 'Назначить текущий топик для покупок',
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений', bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
bind_reminders_topic: 'Назначить текущий топик для напоминаний', bind_reminders_topic: 'Назначить текущий топик для напоминаний',
bind_payments_topic: 'Назначить текущий топик для оплат',
pending_members: 'Показать ожидающие заявки на вступление', pending_members: 'Показать ожидающие заявки на вступление',
approve_member: 'Подтвердить участника дома' approve_member: 'Подтвердить участника дома'
}, },
@@ -55,7 +56,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
[ [
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`, `${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
`ID чата: ${telegramChatId}`, `ID чата: ${telegramChatId}`,
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельный топик для напоминаний, откройте его и выполните /bind_reminders_topic.', 'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельные топики для напоминаний или оплат, откройте их и выполните /bind_reminders_topic или /bind_payments_topic.',
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.' 'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
].join('\n'), ].join('\n'),
useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.', useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.',
@@ -67,6 +68,9 @@ export const ruBotTranslations: BotTranslationCatalog = {
useBindRemindersTopicInGroup: 'Используйте /bind_reminders_topic внутри топика группы дома.', useBindRemindersTopicInGroup: 'Используйте /bind_reminders_topic внутри топика группы дома.',
remindersTopicSaved: (householdName, threadId) => remindersTopicSaved: (householdName, threadId) =>
`Топик напоминаний сохранён для ${householdName} (тред ${threadId}).`, `Топик напоминаний сохранён для ${householdName} (тред ${threadId}).`,
useBindPaymentsTopicInGroup: 'Используйте /bind_payments_topic внутри топика группы дома.',
paymentsTopicSaved: (householdName, threadId) =>
`Топик оплат сохранён для ${householdName} (тред ${threadId}).`,
usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.', usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.',
useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.', useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.',
approveMemberUsage: 'Использование: /approve_member <telegram_user_id>', approveMemberUsage: 'Использование: /approve_member <telegram_user_id>',
@@ -150,5 +154,13 @@ export const ruBotTranslations: BotTranslationCatalog = {
recorded: (summary) => `Покупка сохранена: ${summary}`, recorded: (summary) => `Покупка сохранена: ${summary}`,
savedForReview: (summary) => `Сохранено на проверку: ${summary}`, savedForReview: (summary) => `Сохранено на проверку: ${summary}`,
parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.' parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.'
},
payments: {
topicMissing:
'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.',
recorded: (kind, amount, currency) =>
`Оплата ${kind === 'rent' ? 'аренды' : 'коммуналки'} сохранена: ${amount} ${currency}`,
savedForReview: 'Это подтверждение оплаты сохранено на проверку.',
duplicate: 'Это подтверждение оплаты уже было обработано.'
} }
} }

View File

@@ -9,6 +9,7 @@ export type TelegramCommandName =
| 'bind_purchase_topic' | 'bind_purchase_topic'
| 'bind_feedback_topic' | 'bind_feedback_topic'
| 'bind_reminders_topic' | 'bind_reminders_topic'
| 'bind_payments_topic'
| 'pending_members' | 'pending_members'
| 'approve_member' | 'approve_member'
@@ -21,6 +22,7 @@ export interface BotCommandDescriptions {
bind_purchase_topic: string bind_purchase_topic: string
bind_feedback_topic: string bind_feedback_topic: string
bind_reminders_topic: string bind_reminders_topic: string
bind_payments_topic: string
pending_members: string pending_members: string
approve_member: string approve_member: string
} }
@@ -77,6 +79,8 @@ export interface BotTranslationCatalog {
feedbackTopicSaved: (householdName: string, threadId: string) => string feedbackTopicSaved: (householdName: string, threadId: string) => string
useBindRemindersTopicInGroup: string useBindRemindersTopicInGroup: string
remindersTopicSaved: (householdName: string, threadId: string) => string remindersTopicSaved: (householdName: string, threadId: string) => string
useBindPaymentsTopicInGroup: string
paymentsTopicSaved: (householdName: string, threadId: string) => string
usePendingMembersInGroup: string usePendingMembersInGroup: string
useApproveMemberInGroup: string useApproveMemberInGroup: string
approveMemberUsage: string approveMemberUsage: string
@@ -153,4 +157,10 @@ export interface BotTranslationCatalog {
savedForReview: (summary: string) => string savedForReview: (summary: string) => string
parseFailed: string parseFailed: string
} }
payments: {
topicMissing: string
recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
savedForReview: string
duplicate: string
}
} }

View File

@@ -8,7 +8,8 @@ import {
createLocalePreferenceService, createLocalePreferenceService,
createMiniAppAdminService, createMiniAppAdminService,
createHouseholdSetupService, createHouseholdSetupService,
createReminderJobService createReminderJobService,
createPaymentConfirmationService
} from '@household/application' } from '@household/application'
import { import {
createDbAnonymousFeedbackRepository, createDbAnonymousFeedbackRepository,
@@ -29,6 +30,7 @@ import {
createPurchaseMessageRepository, createPurchaseMessageRepository,
registerConfiguredPurchaseTopicIngestion registerConfiguredPurchaseTopicIngestion
} from './purchase-topic-ingestion' } from './purchase-topic-ingestion'
import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion'
import { createReminderJobsHandler } from './reminder-jobs' import { createReminderJobsHandler } from './reminder-jobs'
import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createSchedulerRequestAuthorizer } from './scheduler-auth'
import { createBotWebhookServer } from './server' import { createBotWebhookServer } from './server'
@@ -72,6 +74,10 @@ const bot = createTelegramBot(
const webhookHandler = webhookCallback(bot, 'std/http') const webhookHandler = webhookCallback(bot, 'std/http')
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>() const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>() const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
const paymentConfirmationServices = new Map<
string,
ReturnType<typeof createPaymentConfirmationService>
>()
const exchangeRateProvider = createNbgExchangeRateProvider({ const exchangeRateProvider = createNbgExchangeRateProvider({
logger: getLogger('fx') logger: getLogger('fx')
}) })
@@ -105,10 +111,7 @@ function financeServiceForHousehold(householdId: string) {
return existing return existing
} }
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId) const repositoryClient = financeRepositoryForHousehold(householdId)
financeRepositoryClients.set(householdId, repositoryClient)
shutdownTasks.push(repositoryClient.close)
const service = createFinanceCommandService({ const service = createFinanceCommandService({
householdId, householdId,
repository: repositoryClient.repository, repository: repositoryClient.repository,
@@ -119,6 +122,35 @@ function financeServiceForHousehold(householdId: string) {
return service return service
} }
function financeRepositoryForHousehold(householdId: string) {
const existing = financeRepositoryClients.get(householdId)
if (existing) {
return existing
}
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId)
financeRepositoryClients.set(householdId, repositoryClient)
shutdownTasks.push(repositoryClient.close)
return repositoryClient
}
function paymentConfirmationServiceForHousehold(householdId: string) {
const existing = paymentConfirmationServices.get(householdId)
if (existing) {
return existing
}
const service = createPaymentConfirmationService({
householdId,
financeService: financeServiceForHousehold(householdId),
repository: financeRepositoryForHousehold(householdId).repository,
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
exchangeRateProvider
})
paymentConfirmationServices.set(householdId, service)
return service
}
function anonymousFeedbackServiceForHousehold(householdId: string) { function anonymousFeedbackServiceForHousehold(householdId: string) {
const existing = anonymousFeedbackServices.get(householdId) const existing = anonymousFeedbackServices.get(householdId)
if (existing) { if (existing) {
@@ -160,6 +192,15 @@ if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
logger: getLogger('purchase-ingestion') logger: getLogger('purchase-ingestion')
} }
) )
registerConfiguredPaymentTopicIngestion(
bot,
householdConfigurationRepositoryClient.repository,
paymentConfirmationServiceForHousehold,
{
logger: getLogger('payment-ingestion')
}
)
} else { } else {
logger.warn( logger.warn(
{ {

View File

@@ -61,6 +61,7 @@ function repository(
createdAt: instantFromIso('2026-03-12T12:00:00.000Z') createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
} }
], ],
listPaymentRecordsForCycle: async () => [],
listParsedPurchasesForRange: async () => [ listParsedPurchasesForRange: async () => [
{ {
id: 'purchase-1', id: 'purchase-1',
@@ -71,6 +72,12 @@ function repository(
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z') occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
} }
], ],
getSettlementSnapshotLines: async () => [],
savePaymentConfirmation: async () =>
({
status: 'needs_review',
reviewReason: 'settlement_not_ready'
}) as const,
replaceSettlementSnapshot: async () => {} replaceSettlementSnapshot: async () => {}
} }
} }

View File

@@ -89,6 +89,8 @@ export function createMiniAppDashboardHandler(options: {
period: dashboard.period, period: dashboard.period,
currency: dashboard.currency, currency: dashboard.currency,
totalDueMajor: dashboard.totalDue.toMajorString(), totalDueMajor: dashboard.totalDue.toMajorString(),
totalPaidMajor: dashboard.totalPaid.toMajorString(),
totalRemainingMajor: dashboard.totalRemaining.toMajorString(),
rentSourceAmountMajor: dashboard.rentSourceAmount.toMajorString(), rentSourceAmountMajor: dashboard.rentSourceAmount.toMajorString(),
rentSourceCurrency: dashboard.rentSourceAmount.currency, rentSourceCurrency: dashboard.rentSourceAmount.currency,
rentDisplayAmountMajor: dashboard.rentDisplayAmount.toMajorString(), rentDisplayAmountMajor: dashboard.rentDisplayAmount.toMajorString(),
@@ -101,6 +103,8 @@ export function createMiniAppDashboardHandler(options: {
utilityShareMajor: line.utilityShare.toMajorString(), utilityShareMajor: line.utilityShare.toMajorString(),
purchaseOffsetMajor: line.purchaseOffset.toMajorString(), purchaseOffsetMajor: line.purchaseOffset.toMajorString(),
netDueMajor: line.netDue.toMajorString(), netDueMajor: line.netDue.toMajorString(),
paidMajor: line.paid.toMajorString(),
remainingMajor: line.remaining.toMajorString(),
explanations: line.explanations explanations: line.explanations
})), })),
ledger: dashboard.ledger.map((entry) => ({ ledger: dashboard.ledger.map((entry) => ({

View File

@@ -0,0 +1,169 @@
import { describe, expect, test } from 'bun:test'
import { instantFromIso, Money } from '@household/domain'
import { createTelegramBot } from './bot'
import {
buildPaymentAcknowledgement,
registerConfiguredPaymentTopicIngestion,
resolveConfiguredPaymentTopicRecord,
type PaymentTopicCandidate
} from './payment-topic-ingestion'
function candidate(overrides: Partial<PaymentTopicCandidate> = {}): PaymentTopicCandidate {
return {
updateId: 1,
chatId: '-10012345',
messageId: '10',
threadId: '888',
senderTelegramUserId: '10002',
rawText: 'за жилье закинул',
attachmentCount: 0,
messageSentAt: instantFromIso('2026-03-20T00:00:00.000Z'),
...overrides
}
}
function paymentUpdate(text: string) {
return {
update_id: 1001,
message: {
message_id: 55,
date: Math.floor(Date.now() / 1000),
message_thread_id: 888,
is_topic_message: true,
chat: {
id: -10012345,
type: 'supergroup'
},
from: {
id: 10002,
is_bot: false,
first_name: 'Mia'
},
text
}
}
}
describe('resolveConfiguredPaymentTopicRecord', () => {
test('returns record when the topic role is payments', () => {
const record = resolveConfiguredPaymentTopicRecord(candidate(), {
householdId: 'household-1',
role: 'payments',
telegramThreadId: '888',
topicName: 'Быт'
})
expect(record).not.toBeNull()
expect(record?.householdId).toBe('household-1')
})
test('skips non-payments topic bindings', () => {
const record = resolveConfiguredPaymentTopicRecord(candidate(), {
householdId: 'household-1',
role: 'feedback',
telegramThreadId: '888',
topicName: 'Анонимно'
})
expect(record).toBeNull()
})
})
describe('buildPaymentAcknowledgement', () => {
test('returns localized recorded acknowledgement', () => {
expect(
buildPaymentAcknowledgement('ru', {
status: 'recorded',
kind: 'rent',
amountMajor: '472.50',
currency: 'GEL'
})
).toBe('Оплата аренды сохранена: 472.50 GEL')
})
test('returns review acknowledgement', () => {
expect(
buildPaymentAcknowledgement('en', {
status: 'needs_review'
})
).toBe('Saved this payment confirmation for review.')
})
})
describe('registerConfiguredPaymentTopicIngestion', () => {
test('replies in-topic after a payment confirmation is recorded', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: false
}
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: -10012345,
type: 'supergroup'
},
text: 'ok'
}
} as never
})
registerConfiguredPaymentTopicIngestion(
bot,
{
getHouseholdChatByHouseholdId: async () => ({
householdId: 'household-1',
householdName: 'Test bot',
telegramChatId: '-10012345',
telegramChatType: 'supergroup',
title: 'Test bot',
defaultLocale: 'ru'
}),
findHouseholdTopicByTelegramContext: async () => ({
householdId: 'household-1',
role: 'payments',
telegramThreadId: '888',
topicName: 'Быт'
})
} as never,
() => ({
submit: async () => ({
status: 'recorded',
kind: 'rent',
amount: Money.fromMajor('472.50', 'GEL')
})
})
)
await bot.handleUpdate(paymentUpdate('за жилье закинул') as never)
expect(calls).toHaveLength(1)
expect(calls[0]?.method).toBe('sendMessage')
expect(calls[0]?.payload).toMatchObject({
chat_id: -10012345,
reply_parameters: {
message_id: 55
},
text: 'Оплата аренды сохранена: 472.50 GEL'
})
})
})

View File

@@ -0,0 +1,225 @@
import type { PaymentConfirmationService } from '@household/application'
import { instantFromEpochSeconds, type Instant } from '@household/domain'
import type { Bot, Context } from 'grammy'
import type { Logger } from '@household/observability'
import type {
HouseholdConfigurationRepository,
HouseholdTopicBindingRecord
} from '@household/ports'
import { getBotTranslations, type BotLocale } from './i18n'
export interface PaymentTopicCandidate {
updateId: number
chatId: string
messageId: string
threadId: string
senderTelegramUserId: string
rawText: string
attachmentCount: number
messageSentAt: Instant
}
export interface PaymentTopicRecord extends PaymentTopicCandidate {
householdId: string
}
function readMessageText(ctx: Context): string | null {
const message = ctx.message
if (!message) {
return null
}
if ('text' in message && typeof message.text === 'string') {
return message.text
}
if ('caption' in message && typeof message.caption === 'string') {
return message.caption
}
return null
}
function attachmentCount(ctx: Context): number {
const message = ctx.message
if (!message) {
return 0
}
if ('photo' in message && Array.isArray(message.photo)) {
return message.photo.length
}
if ('document' in message && message.document) {
return 1
}
return 0
}
function toCandidateFromContext(ctx: Context): PaymentTopicCandidate | null {
const message = ctx.message
const rawText = readMessageText(ctx)
if (!message || !rawText) {
return null
}
if (!('is_topic_message' in message) || message.is_topic_message !== true) {
return null
}
if (!('message_thread_id' in message) || message.message_thread_id === undefined) {
return null
}
const senderTelegramUserId = ctx.from?.id?.toString()
if (!senderTelegramUserId) {
return null
}
return {
updateId: ctx.update.update_id,
chatId: message.chat.id.toString(),
messageId: message.message_id.toString(),
threadId: message.message_thread_id.toString(),
senderTelegramUserId,
rawText,
attachmentCount: attachmentCount(ctx),
messageSentAt: instantFromEpochSeconds(message.date)
}
}
export function resolveConfiguredPaymentTopicRecord(
value: PaymentTopicCandidate,
binding: HouseholdTopicBindingRecord
): PaymentTopicRecord | null {
const normalizedText = value.rawText.trim()
if (normalizedText.length === 0) {
return null
}
if (binding.role !== 'payments') {
return null
}
return {
...value,
rawText: normalizedText,
householdId: binding.householdId
}
}
export function buildPaymentAcknowledgement(
locale: BotLocale,
result:
| { status: 'duplicate' }
| {
status: 'recorded'
kind: 'rent' | 'utilities'
amountMajor: string
currency: 'USD' | 'GEL'
}
| { status: 'needs_review' }
): string | null {
const t = getBotTranslations(locale).payments
switch (result.status) {
case 'duplicate':
return null
case 'recorded':
return t.recorded(result.kind, result.amountMajor, result.currency)
case 'needs_review':
return t.savedForReview
}
}
async function replyToPaymentMessage(ctx: Context, text: string): Promise<void> {
const message = ctx.msg
if (!message) {
return
}
await ctx.reply(text, {
reply_parameters: {
message_id: message.message_id
}
})
}
export function registerConfiguredPaymentTopicIngestion(
bot: Bot,
householdConfigurationRepository: HouseholdConfigurationRepository,
paymentServiceForHousehold: (householdId: string) => PaymentConfirmationService,
options: {
logger?: Logger
} = {}
): void {
bot.on('message', async (ctx, next) => {
const candidate = toCandidateFromContext(ctx)
if (!candidate) {
await next()
return
}
const binding = await householdConfigurationRepository.findHouseholdTopicByTelegramContext({
telegramChatId: candidate.chatId,
telegramThreadId: candidate.threadId
})
if (!binding) {
await next()
return
}
const record = resolveConfiguredPaymentTopicRecord(candidate, binding)
if (!record) {
await next()
return
}
try {
const result = await paymentServiceForHousehold(record.householdId).submit({
senderTelegramUserId: record.senderTelegramUserId,
rawText: record.rawText,
telegramChatId: record.chatId,
telegramMessageId: record.messageId,
telegramThreadId: record.threadId,
telegramUpdateId: String(record.updateId),
attachmentCount: record.attachmentCount,
messageSentAt: record.messageSentAt
})
const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId(
record.householdId
)
const locale = householdChat?.defaultLocale ?? 'en'
const acknowledgement = buildPaymentAcknowledgement(
locale,
result.status === 'recorded'
? {
status: 'recorded',
kind: result.kind,
amountMajor: result.amount.toMajorString(),
currency: result.amount.currency
}
: result
)
if (acknowledgement) {
await replyToPaymentMessage(ctx, acknowledgement)
}
} catch (error) {
options.logger?.error(
{
event: 'payment.ingest_failed',
chatId: record.chatId,
threadId: record.threadId,
messageId: record.messageId,
updateId: record.updateId,
error
},
'Failed to ingest payment confirmation'
)
}
})
}

View File

@@ -27,6 +27,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [
'bind_purchase_topic', 'bind_purchase_topic',
'bind_feedback_topic', 'bind_feedback_topic',
'bind_reminders_topic', 'bind_reminders_topic',
'bind_payments_topic',
'pending_members', 'pending_members',
'approve_member' 'approve_member'
] as const satisfies readonly TelegramCommandName[] ] as const satisfies readonly TelegramCommandName[]

View File

@@ -160,6 +160,20 @@ function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string
) )
} }
function memberRemainingClass(member: MiniAppDashboard['members'][number]): string {
const remainingMinor = majorStringToMinor(member.remainingMajor)
if (remainingMinor < 0n) {
return 'is-credit'
}
if (remainingMinor === 0n) {
return 'is-settled'
}
return 'is-due'
}
function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string { function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string {
return `${entry.displayAmountMajor} ${entry.displayCurrency}` return `${entry.displayAmountMajor} ${entry.displayCurrency}`
} }
@@ -405,6 +419,8 @@ function App() {
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
totalDueMajor: '1030.00', totalDueMajor: '1030.00',
totalPaidMajor: '501.00',
totalRemainingMajor: '529.00',
rentSourceAmountMajor: '700.00', rentSourceAmountMajor: '700.00',
rentSourceCurrency: 'USD', rentSourceCurrency: 'USD',
rentDisplayAmountMajor: '1932.00', rentDisplayAmountMajor: '1932.00',
@@ -418,6 +434,8 @@ function App() {
utilityShareMajor: '32.00', utilityShareMajor: '32.00',
purchaseOffsetMajor: '-14.00', purchaseOffsetMajor: '-14.00',
netDueMajor: '501.00', netDueMajor: '501.00',
paidMajor: '501.00',
remainingMajor: '0.00',
explanations: ['Equal utility split', 'Shared purchase offset'] explanations: ['Equal utility split', 'Shared purchase offset']
}, },
{ {
@@ -427,6 +445,8 @@ function App() {
utilityShareMajor: '32.00', utilityShareMajor: '32.00',
purchaseOffsetMajor: '14.00', purchaseOffsetMajor: '14.00',
netDueMajor: '529.00', netDueMajor: '529.00',
paidMajor: '0.00',
remainingMajor: '529.00',
explanations: ['Equal utility split'] explanations: ['Equal utility split']
} }
], ],
@@ -893,6 +913,18 @@ function App() {
{currentMemberLine()!.netDueMajor} {data.currency} {currentMemberLine()!.netDueMajor} {data.currency}
</strong> </strong>
</div> </div>
<div class="stat-card">
<span>{copy().paidLabel}</span>
<strong>
{currentMemberLine()!.paidMajor} {data.currency}
</strong>
</div>
<div class="stat-card">
<span>{copy().remainingLabel}</span>
<strong>
{currentMemberLine()!.remainingMajor} {data.currency}
</strong>
</div>
</div> </div>
</article> </article>
) : null} ) : null}
@@ -907,7 +939,7 @@ function App() {
<header> <header>
<strong>{member.displayName}</strong> <strong>{member.displayName}</strong>
<span> <span>
{member.netDueMajor} {data.currency} {member.remainingMajor} {data.currency}
</span> </span>
</header> </header>
<p> <p>
@@ -922,6 +954,12 @@ function App() {
<p> <p>
{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency} {copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}
</p> </p>
<p>
{copy().paidLabel}: {member.paidMajor} {data.currency}
</p>
<p class={`balance-status ${memberRemainingClass(member)}`}>
{copy().remainingLabel}: {member.remainingMajor} {data.currency}
</p>
</article> </article>
))} ))}
</> </>
@@ -1554,6 +1592,18 @@ function App() {
{dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'} {dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'}
</strong> </strong>
</article> </article>
<article class="stat-card">
<span>{copy().paidLabel}</span>
<strong>
{dashboard() ? `${dashboard()!.totalPaidMajor} ${dashboard()!.currency}` : '—'}
</strong>
</article>
<article class="stat-card">
<span>{copy().remainingLabel}</span>
<strong>
{dashboard() ? `${dashboard()!.totalRemainingMajor} ${dashboard()!.currency}` : '—'}
</strong>
</article>
<article class="stat-card"> <article class="stat-card">
<span>{copy().membersCount}</span> <span>{copy().membersCount}</span>
<strong>{dashboardMemberCount(dashboard())}</strong> <strong>{dashboardMemberCount(dashboard())}</strong>
@@ -1578,7 +1628,7 @@ function App() {
<header> <header>
<strong>{copy().yourBalanceTitle}</strong> <strong>{copy().yourBalanceTitle}</strong>
<span> <span>
{currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''} {currentMemberLine()!.remainingMajor} {dashboard()?.currency ?? ''}
</span> </span>
</header> </header>
<p>{copy().yourBalanceBody}</p> <p>{copy().yourBalanceBody}</p>
@@ -1613,6 +1663,18 @@ function App() {
{currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''} {currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''}
</strong> </strong>
</div> </div>
<div class="stat-card">
<span>{copy().paidLabel}</span>
<strong>
{currentMemberLine()!.paidMajor} {dashboard()?.currency ?? ''}
</strong>
</div>
<div class="stat-card">
<span>{copy().remainingLabel}</span>
<strong>
{currentMemberLine()!.remainingMajor} {dashboard()?.currency ?? ''}
</strong>
</div>
</div> </div>
</article> </article>
) : ( ) : (

View File

@@ -42,6 +42,8 @@ export const dictionary = {
overviewBody: overviewBody:
'Use the sections below to review balances, ledger entries, and household access.', 'Use the sections below to review balances, ledger entries, and household access.',
totalDue: 'Total due', totalDue: 'Total due',
paidLabel: 'Paid',
remainingLabel: 'Remaining',
membersCount: 'Members', membersCount: 'Members',
ledgerEntries: 'Ledger entries', ledgerEntries: 'Ledger entries',
pendingRequests: 'Pending requests', pendingRequests: 'Pending requests',
@@ -159,6 +161,8 @@ export const dictionary = {
overviewTitle: 'Текущий цикл', overviewTitle: 'Текущий цикл',
overviewBody: 'Ниже можно посмотреть балансы, записи леджера и доступ к household.', overviewBody: 'Ниже можно посмотреть балансы, записи леджера и доступ к household.',
totalDue: 'Итого к оплате', totalDue: 'Итого к оплате',
paidLabel: 'Оплачено',
remainingLabel: 'Осталось',
membersCount: 'Участники', membersCount: 'Участники',
ledgerEntries: 'Записи леджера', ledgerEntries: 'Записи леджера',
pendingRequests: 'Ожидают подтверждения', pendingRequests: 'Ожидают подтверждения',

View File

@@ -270,6 +270,18 @@ button {
margin-top: 6px; margin-top: 6px;
} }
.balance-status.is-credit {
color: #95e2b0;
}
.balance-status.is-settled {
color: #d6d0c9;
}
.balance-status.is-due {
color: #f7b389;
}
.home-grid { .home-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }

View File

@@ -68,6 +68,8 @@ export interface MiniAppDashboard {
period: string period: string
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
totalDueMajor: string totalDueMajor: string
totalPaidMajor: string
totalRemainingMajor: string
rentSourceAmountMajor: string rentSourceAmountMajor: string
rentSourceCurrency: 'USD' | 'GEL' rentSourceCurrency: 'USD' | 'GEL'
rentDisplayAmountMajor: string rentDisplayAmountMajor: string
@@ -80,6 +82,8 @@ export interface MiniAppDashboard {
utilityShareMajor: string utilityShareMajor: string
purchaseOffsetMajor: string purchaseOffsetMajor: string
netDueMajor: string netDueMajor: string
paidMajor: string
remainingMajor: string
explanations: readonly string[] explanations: readonly string[]
}[] }[]
ledger: { ledger: {

View File

@@ -0,0 +1,39 @@
# HOUSEBOT-080 Payment Confirmations From Household Topic
## Goal
Track when members confirm rent or utility payments from a dedicated household topic, without forcing them to type an exact amount every time.
## Scope
- add a `payments` household topic role and `/bind_payments_topic`
- ingest text or caption-based confirmations from the configured payments topic
- persist every confirmation message idempotently
- record deterministic payment entries when the bot can resolve the amount safely
- keep ambiguous confirmations in `needs_review` instead of guessing
- expose paid and remaining amounts in the finance dashboard
## Parsing rules
- detect `rent` intent from phrases like `за жилье`, `аренда`, `paid rent`
- detect `utilities` intent from phrases like `коммуналка`, `газ`, `электричество`, `utilities`
- treat generic confirmations like `готово` as review-required
- treat multi-person confirmations like `за двоих` or `за Кирилла и себя` as review-required
- parse explicit amounts when present
- if no amount is present:
- `rent` resolves to the member's current rent share
- `utilities` resolves to `utilityShare + purchaseOffset`
## Persistence
- `payment_confirmations`
- stores raw Telegram message context and normalized review state
- `payment_records`
- stores accepted cycle-scoped payments in settlement currency
## Acceptance
- a member can say `за жилье закинул` or `оплатил коммуналку` in the configured payments topic
- the bot records the payment against the current cycle when resolution is deterministic
- the dashboard shows `due`, `paid`, and `remaining`
- ambiguous confirmations are stored for review, not silently converted into money movements

View File

@@ -360,6 +360,30 @@ export function createDbFinanceRepository(
})) }))
}, },
async listPaymentRecordsForCycle(cycleId) {
const rows = await db
.select({
id: schema.paymentRecords.id,
memberId: schema.paymentRecords.memberId,
kind: schema.paymentRecords.kind,
amountMinor: schema.paymentRecords.amountMinor,
currency: schema.paymentRecords.currency,
recordedAt: schema.paymentRecords.recordedAt
})
.from(schema.paymentRecords)
.where(eq(schema.paymentRecords.cycleId, cycleId))
.orderBy(schema.paymentRecords.recordedAt)
return rows.map((row) => ({
id: row.id,
memberId: row.memberId,
kind: row.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: row.amountMinor,
currency: toCurrencyCode(row.currency),
recordedAt: instantFromDatabaseValue(row.recordedAt)!
}))
},
async listParsedPurchasesForRange(start, end) { async listParsedPurchasesForRange(start, end) {
const rows = await db const rows = await db
.select({ .select({
@@ -392,6 +416,121 @@ export function createDbFinanceRepository(
})) }))
}, },
async getSettlementSnapshotLines(cycleId) {
const rows = await db
.select({
memberId: schema.settlementLines.memberId,
rentShareMinor: schema.settlementLines.rentShareMinor,
utilityShareMinor: schema.settlementLines.utilityShareMinor,
purchaseOffsetMinor: schema.settlementLines.purchaseOffsetMinor,
netDueMinor: schema.settlementLines.netDueMinor
})
.from(schema.settlementLines)
.innerJoin(
schema.settlements,
eq(schema.settlementLines.settlementId, schema.settlements.id)
)
.where(eq(schema.settlements.cycleId, cycleId))
return rows.map((row) => ({
memberId: row.memberId,
rentShareMinor: row.rentShareMinor,
utilityShareMinor: row.utilityShareMinor,
purchaseOffsetMinor: row.purchaseOffsetMinor,
netDueMinor: row.netDueMinor
}))
},
async savePaymentConfirmation(input) {
return db.transaction(async (tx) => {
const insertedConfirmation = await tx
.insert(schema.paymentConfirmations)
.values({
householdId,
cycleId: input.cycleId,
memberId: input.memberId,
senderTelegramUserId: input.senderTelegramUserId,
rawText: input.rawText,
normalizedText: input.normalizedText,
detectedKind: input.kind,
explicitAmountMinor: input.explicitAmountMinor,
explicitCurrency: input.explicitCurrency,
resolvedAmountMinor: input.amountMinor,
resolvedCurrency: input.currency,
status: input.status,
reviewReason: input.status === 'needs_review' ? input.reviewReason : null,
attachmentCount: input.attachmentCount,
telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId,
telegramThreadId: input.telegramThreadId,
telegramUpdateId: input.telegramUpdateId,
messageSentAt: input.messageSentAt ? instantToDate(input.messageSentAt) : null
})
.onConflictDoNothing({
target: [
schema.paymentConfirmations.householdId,
schema.paymentConfirmations.telegramChatId,
schema.paymentConfirmations.telegramMessageId
]
})
.returning({
id: schema.paymentConfirmations.id
})
const confirmationId = insertedConfirmation[0]?.id
if (!confirmationId) {
return {
status: 'duplicate' as const
}
}
if (input.status === 'needs_review') {
return {
status: 'needs_review' as const,
reviewReason: input.reviewReason
}
}
const insertedPayment = await tx
.insert(schema.paymentRecords)
.values({
householdId,
cycleId: input.cycleId,
memberId: input.memberId,
kind: input.kind,
amountMinor: input.amountMinor,
currency: input.currency,
confirmationId,
recordedAt: instantToDate(input.recordedAt)
})
.returning({
id: schema.paymentRecords.id,
memberId: schema.paymentRecords.memberId,
kind: schema.paymentRecords.kind,
amountMinor: schema.paymentRecords.amountMinor,
currency: schema.paymentRecords.currency,
recordedAt: schema.paymentRecords.recordedAt
})
const paymentRow = insertedPayment[0]
if (!paymentRow) {
throw new Error('Failed to persist payment record')
}
return {
status: 'recorded' as const,
paymentRecord: {
id: paymentRow.id,
memberId: paymentRow.memberId,
kind: paymentRow.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: paymentRow.amountMinor,
currency: toCurrencyCode(paymentRow.currency),
recordedAt: instantFromDatabaseValue(paymentRow.recordedAt)!
}
}
})
},
async replaceSettlementSnapshot(snapshot) { async replaceSettlementSnapshot(snapshot) {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const upserted = await tx const upserted = await tx

View File

@@ -125,10 +125,25 @@ class FinanceRepositoryStub implements FinanceRepository {
return this.utilityBills return this.utilityBills
} }
async listPaymentRecordsForCycle() {
return []
}
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> { async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
return this.purchases return this.purchases
} }
async getSettlementSnapshotLines() {
return []
}
async savePaymentConfirmation() {
return {
status: 'needs_review' as const,
reviewReason: 'settlement_not_ready' as const
}
}
async replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void> { async replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void> {
this.replacedSnapshot = snapshot this.replacedSnapshot = snapshot
} }
@@ -347,9 +362,11 @@ describe('createFinanceCommandService', () => {
[ [
'Statement for 2026-03', 'Statement for 2026-03',
'Rent: 700.00 USD (~1890.00 GEL)', 'Rent: 700.00 USD (~1890.00 GEL)',
'- Alice: 990.00 GEL', '- Alice: due 990.00 GEL, paid 0.00 GEL, remaining 990.00 GEL',
'- Bob: 1020.00 GEL', '- Bob: due 1020.00 GEL, paid 0.00 GEL, remaining 1020.00 GEL',
'Total: 2010.00 GEL' 'Total due: 2010.00 GEL',
'Total paid: 0.00 GEL',
'Total remaining: 2010.00 GEL'
].join('\n') ].join('\n')
) )
expect(repository.replacedSnapshot).not.toBeNull() expect(repository.replacedSnapshot).not.toBeNull()

View File

@@ -86,6 +86,8 @@ export interface FinanceDashboardMemberLine {
utilityShare: Money utilityShare: Money
purchaseOffset: Money purchaseOffset: Money
netDue: Money netDue: Money
paid: Money
remaining: Money
explanations: readonly string[] explanations: readonly string[]
} }
@@ -107,6 +109,8 @@ export interface FinanceDashboard {
period: string period: string
currency: CurrencyCode currency: CurrencyCode
totalDue: Money totalDue: Money
totalPaid: Money
totalRemaining: Money
rentSourceAmount: Money rentSourceAmount: Money
rentDisplayAmount: Money rentDisplayAmount: Money
rentFxRateMicros: bigint | null rentFxRateMicros: bigint | null
@@ -238,6 +242,7 @@ async function buildFinanceDashboard(
dependencies.repository.listParsedPurchasesForRange(start, end), dependencies.repository.listParsedPurchasesForRange(start, end),
dependencies.repository.listUtilityBillsForCycle(cycle.id) dependencies.repository.listUtilityBillsForCycle(cycle.id)
]) ])
const paymentRecords = await dependencies.repository.listPaymentRecordsForCycle(cycle.id)
const convertedRent = await convertIntoCycleCurrency(dependencies, { const convertedRent = await convertIntoCycleCurrency(dependencies, {
cycle, cycle,
@@ -338,6 +343,14 @@ async function buildFinanceDashboard(
}) })
const memberNameById = new Map(members.map((member) => [member.id, member.displayName])) const memberNameById = new Map(members.map((member) => [member.id, member.displayName]))
const paymentsByMemberId = new Map<string, Money>()
for (const payment of paymentRecords) {
const current = paymentsByMemberId.get(payment.memberId) ?? Money.zero(cycle.currency)
paymentsByMemberId.set(
payment.memberId,
current.add(Money.fromMinor(payment.amountMinor, payment.currency))
)
}
const dashboardMembers = settlement.lines.map((line) => ({ const dashboardMembers = settlement.lines.map((line) => ({
memberId: line.memberId.toString(), memberId: line.memberId.toString(),
displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(), displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(),
@@ -345,6 +358,10 @@ async function buildFinanceDashboard(
utilityShare: line.utilityShare, utilityShare: line.utilityShare,
purchaseOffset: line.purchaseOffset, purchaseOffset: line.purchaseOffset,
netDue: line.netDue, netDue: line.netDue,
paid: paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency),
remaining: line.netDue.subtract(
paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency)
),
explanations: line.explanations explanations: line.explanations
})) }))
@@ -389,6 +406,14 @@ async function buildFinanceDashboard(
period: cycle.period, period: cycle.period,
currency: cycle.currency, currency: cycle.currency,
totalDue: settlement.totalDue, totalDue: settlement.totalDue,
totalPaid: paymentRecords.reduce(
(sum, payment) => sum.add(Money.fromMinor(payment.amountMinor, payment.currency)),
Money.zero(cycle.currency)
),
totalRemaining: dashboardMembers.reduce(
(sum, member) => sum.add(member.remaining),
Money.zero(cycle.currency)
),
rentSourceAmount: convertedRent.originalAmount, rentSourceAmount: convertedRent.originalAmount,
rentDisplayAmount: convertedRent.settlementAmount, rentDisplayAmount: convertedRent.settlementAmount,
rentFxRateMicros: convertedRent.fxRateMicros, rentFxRateMicros: convertedRent.fxRateMicros,
@@ -560,7 +585,7 @@ export function createFinanceCommandService(
} }
const statementLines = dashboard.members.map((line) => { const statementLines = dashboard.members.map((line) => {
return `- ${line.displayName}: ${line.netDue.toMajorString()} ${dashboard.currency}` return `- ${line.displayName}: due ${line.netDue.toMajorString()} ${dashboard.currency}, paid ${line.paid.toMajorString()} ${dashboard.currency}, remaining ${line.remaining.toMajorString()} ${dashboard.currency}`
}) })
const rentLine = const rentLine =
@@ -572,7 +597,9 @@ export function createFinanceCommandService(
`Statement for ${dashboard.period}`, `Statement for ${dashboard.period}`,
rentLine, rentLine,
...statementLines, ...statementLines,
`Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}` `Total due: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`,
`Total paid: ${dashboard.totalPaid.toMajorString()} ${dashboard.currency}`,
`Total remaining: ${dashboard.totalRemaining.toMajorString()} ${dashboard.currency}`
].join('\n') ].join('\n')
}, },

View File

@@ -31,3 +31,8 @@ export {
type PurchaseParserLlmFallback, type PurchaseParserLlmFallback,
type PurchaseParserMode type PurchaseParserMode
} from './purchase-parser' } from './purchase-parser'
export {
createPaymentConfirmationService,
type PaymentConfirmationService,
type PaymentConfirmationSubmitResult
} from './payment-confirmation-service'

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test'
import { parsePaymentConfirmationMessage } from './payment-confirmation-parser'
describe('parsePaymentConfirmationMessage', () => {
test('detects rent confirmation without explicit amount', () => {
const result = parsePaymentConfirmationMessage('за жилье закинул', 'GEL')
expect(result.kind).toBe('rent')
expect(result.explicitAmount).toBeNull()
expect(result.reviewReason).toBeNull()
})
test('detects utility confirmation with explicit default-currency amount', () => {
const result = parsePaymentConfirmationMessage('оплатил газ 120', 'GEL')
expect(result.kind).toBe('utilities')
expect(result.explicitAmount?.amountMinor).toBe(12000n)
expect(result.explicitAmount?.currency).toBe('GEL')
expect(result.reviewReason).toBeNull()
})
test('keeps multi-member confirmations for review', () => {
const result = parsePaymentConfirmationMessage('перевел за Кирилла и себя', 'GEL')
expect(result.kind).toBeNull()
expect(result.reviewReason).toBe('multiple_members')
})
test('keeps generic done messages for review', () => {
const result = parsePaymentConfirmationMessage('готово', 'GEL')
expect(result.kind).toBeNull()
expect(result.reviewReason).toBe('kind_ambiguous')
})
})

View File

@@ -0,0 +1,143 @@
import { Money, type CurrencyCode } from '@household/domain'
import type { FinancePaymentKind, FinancePaymentConfirmationReviewReason } from '@household/ports'
export interface ParsedPaymentConfirmation {
normalizedText: string
kind: FinancePaymentKind | null
explicitAmount: Money | null
reviewReason: FinancePaymentConfirmationReviewReason | null
}
const rentKeywords = [/\b(rent|housing|apartment|landlord)\b/i, /жиль[её]/i, /аренд/i] as const
const utilityKeywords = [
/\b(utilities|utility|gas|water|electricity|internet|cleaning)\b/i,
/коммун/i,
/газ/i,
/вод/i,
/элект/i,
/свет/i,
/интернет/i,
/уборк/i
] as const
const paymentIntentKeywords = [
/\b(paid|pay|sent|done|transfer(red)?)\b/i,
/оплат/i,
/закинул/i,
/закину/i,
/перев[её]л/i,
/перевела/i,
/скинул/i,
/скинула/i,
/отправил/i,
/отправила/i,
/готово/i
] as const
const multiMemberKeywords = [
/за\s+двоих/i,
/\bfor\s+two\b/i,
/за\s+.*\s+и\s+себя/i,
/за\s+.*\s+и\s+меня/i
] as const
function hasMatch(patterns: readonly RegExp[], value: string): boolean {
return patterns.some((pattern) => pattern.test(value))
}
function parseExplicitAmount(rawText: string, defaultCurrency: CurrencyCode): Money | null {
const symbolMatch = rawText.match(/(?:^|[^\d])(\$|₾)\s*(\d+(?:[.,]\d{1,2})?)/i)
if (symbolMatch) {
const currency = symbolMatch[1] === '$' ? 'USD' : 'GEL'
return Money.fromMajor(symbolMatch[2]!.replace(',', '.'), currency)
}
const suffixMatch = rawText.match(/(\d+(?:[.,]\d{1,2})?)\s*(usd|gel|лари|лар|ლარი|ლარ|₾|\$)\b/i)
if (suffixMatch) {
const rawCurrency = suffixMatch[2]!.toUpperCase()
const currency = rawCurrency === 'USD' || rawCurrency === '$' ? 'USD' : 'GEL'
return Money.fromMajor(suffixMatch[1]!.replace(',', '.'), currency)
}
const bareAmountMatch = rawText.match(/(?:^|[^\d])(\d+(?:[.,]\d{1,2})?)(?:\s|$)/)
if (!bareAmountMatch) {
return null
}
return Money.fromMajor(bareAmountMatch[1]!.replace(',', '.'), defaultCurrency)
}
export function parsePaymentConfirmationMessage(
rawText: string,
defaultCurrency: CurrencyCode
): ParsedPaymentConfirmation {
const normalizedText = rawText.trim().replaceAll(/\s+/g, ' ')
const lowercase = normalizedText.toLowerCase()
if (normalizedText.length === 0) {
return {
normalizedText,
kind: null,
explicitAmount: null,
reviewReason: 'intent_missing'
}
}
if (hasMatch(multiMemberKeywords, lowercase)) {
return {
normalizedText,
kind: null,
explicitAmount: parseExplicitAmount(normalizedText, defaultCurrency),
reviewReason: 'multiple_members'
}
}
if (!hasMatch(paymentIntentKeywords, lowercase)) {
return {
normalizedText,
kind: null,
explicitAmount: parseExplicitAmount(normalizedText, defaultCurrency),
reviewReason: 'intent_missing'
}
}
const matchesRent = hasMatch(rentKeywords, lowercase)
const matchesUtilities = hasMatch(utilityKeywords, lowercase)
const explicitAmount = parseExplicitAmount(normalizedText, defaultCurrency)
if (matchesRent && matchesUtilities) {
return {
normalizedText,
kind: null,
explicitAmount,
reviewReason: 'kind_ambiguous'
}
}
if (matchesRent) {
return {
normalizedText,
kind: 'rent',
explicitAmount,
reviewReason: null
}
}
if (matchesUtilities) {
return {
normalizedText,
kind: 'utilities',
explicitAmount,
reviewReason: null
}
}
return {
normalizedText,
kind: null,
explicitAmount,
reviewReason: 'kind_ambiguous'
}
}

View File

@@ -0,0 +1,258 @@
import { describe, expect, test } from 'bun:test'
import { Money, instantFromIso, type CurrencyCode } from '@household/domain'
import type {
ExchangeRateProvider,
FinancePaymentConfirmationSaveInput,
FinancePaymentConfirmationSaveResult,
FinanceRepository,
HouseholdConfigurationRepository
} from '@household/ports'
import { createPaymentConfirmationService } from './payment-confirmation-service'
const settingsRepository: Pick<HouseholdConfigurationRepository, 'getHouseholdBillingSettings'> = {
async getHouseholdBillingSettings(householdId) {
return {
householdId,
settlementCurrency: 'GEL',
rentAmountMinor: 70000n,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
}
}
}
const exchangeRateProvider: ExchangeRateProvider = {
async getRate(input) {
return {
baseCurrency: input.baseCurrency,
quoteCurrency: input.quoteCurrency,
rateMicros: input.baseCurrency === input.quoteCurrency ? 1_000_000n : 2_700_000n,
effectiveDate: input.effectiveDate,
source: 'nbg'
}
}
}
function createRepositoryStub(): Pick<
FinanceRepository,
| 'getOpenCycle'
| 'getLatestCycle'
| 'getCycleExchangeRate'
| 'saveCycleExchangeRate'
| 'savePaymentConfirmation'
> & {
saved: FinancePaymentConfirmationSaveInput[]
} {
return {
saved: [],
async getOpenCycle() {
return {
id: 'cycle-1',
period: '2026-03',
currency: 'GEL' as CurrencyCode
}
},
async getLatestCycle() {
return {
id: 'cycle-1',
period: '2026-03',
currency: 'GEL' as CurrencyCode
}
},
async getCycleExchangeRate() {
return null
},
async saveCycleExchangeRate(input) {
return input
},
async savePaymentConfirmation(input): Promise<FinancePaymentConfirmationSaveResult> {
this.saved.push(input)
if (input.status === 'needs_review') {
return {
status: 'needs_review',
reviewReason: input.reviewReason
}
}
return {
status: 'recorded',
paymentRecord: {
id: 'payment-1',
memberId: input.memberId,
kind: input.kind,
amountMinor: input.amountMinor,
currency: input.currency,
recordedAt: input.recordedAt
}
}
}
}
}
describe('createPaymentConfirmationService', () => {
test('resolves rent confirmations against the current member due', async () => {
const repository = createRepositoryStub()
const service = createPaymentConfirmationService({
householdId: 'household-1',
financeService: {
getMemberByTelegramUserId: async () => ({
id: 'member-1',
telegramUserId: '123',
displayName: 'Stas',
rentShareWeight: 1,
isAdmin: false
}),
generateDashboard: async () => ({
period: '2026-03',
currency: 'GEL',
totalDue: Money.fromMajor('1030', 'GEL'),
totalPaid: Money.zero('GEL'),
totalRemaining: Money.fromMajor('1030', 'GEL'),
rentSourceAmount: Money.fromMajor('700', 'USD'),
rentDisplayAmount: Money.fromMajor('1890', 'GEL'),
rentFxRateMicros: 2_700_000n,
rentFxEffectiveDate: '2026-03-17',
members: [
{
memberId: 'member-1',
displayName: 'Stas',
rentShare: Money.fromMajor('472.50', 'GEL'),
utilityShare: Money.fromMajor('40', 'GEL'),
purchaseOffset: Money.fromMajor('-12', 'GEL'),
netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'),
explanations: []
}
],
ledger: []
})
},
repository,
householdConfigurationRepository: settingsRepository,
exchangeRateProvider
})
const result = await service.submit({
senderTelegramUserId: '123',
rawText: 'за жилье закинул',
telegramChatId: '-1001',
telegramMessageId: '10',
telegramThreadId: '4',
telegramUpdateId: '200',
attachmentCount: 0,
messageSentAt: instantFromIso('2026-03-20T09:00:00.000Z')
})
expect(result).toEqual({
status: 'recorded',
kind: 'rent',
amount: Money.fromMajor('472.50', 'GEL')
})
expect(repository.saved[0]?.status).toBe('recorded')
})
test('converts explicit rent amounts into cycle currency', async () => {
const repository = createRepositoryStub()
const service = createPaymentConfirmationService({
householdId: 'household-1',
financeService: {
getMemberByTelegramUserId: async () => ({
id: 'member-1',
telegramUserId: '123',
displayName: 'Stas',
rentShareWeight: 1,
isAdmin: false
}),
generateDashboard: async () => ({
period: '2026-03',
currency: 'GEL',
totalDue: Money.fromMajor('1030', 'GEL'),
totalPaid: Money.zero('GEL'),
totalRemaining: Money.fromMajor('1030', 'GEL'),
rentSourceAmount: Money.fromMajor('700', 'USD'),
rentDisplayAmount: Money.fromMajor('1890', 'GEL'),
rentFxRateMicros: 2_700_000n,
rentFxEffectiveDate: '2026-03-17',
members: [
{
memberId: 'member-1',
displayName: 'Stas',
rentShare: Money.fromMajor('472.50', 'GEL'),
utilityShare: Money.fromMajor('40', 'GEL'),
purchaseOffset: Money.fromMajor('-12', 'GEL'),
netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'),
explanations: []
}
],
ledger: []
})
},
repository,
householdConfigurationRepository: settingsRepository,
exchangeRateProvider
})
const result = await service.submit({
senderTelegramUserId: '123',
rawText: 'paid rent $175',
telegramChatId: '-1001',
telegramMessageId: '11',
telegramThreadId: '4',
telegramUpdateId: '201',
attachmentCount: 0,
messageSentAt: instantFromIso('2026-03-20T09:00:00.000Z')
})
expect(result).toEqual({
status: 'recorded',
kind: 'rent',
amount: Money.fromMajor('472.50', 'GEL')
})
})
test('keeps ambiguous confirmations for review', async () => {
const repository = createRepositoryStub()
const service = createPaymentConfirmationService({
householdId: 'household-1',
financeService: {
getMemberByTelegramUserId: async () => ({
id: 'member-1',
telegramUserId: '123',
displayName: 'Stas',
rentShareWeight: 1,
isAdmin: false
}),
generateDashboard: async () => null
},
repository,
householdConfigurationRepository: settingsRepository,
exchangeRateProvider
})
const result = await service.submit({
senderTelegramUserId: '123',
rawText: 'готово',
telegramChatId: '-1001',
telegramMessageId: '12',
telegramThreadId: '4',
telegramUpdateId: '202',
attachmentCount: 1,
messageSentAt: instantFromIso('2026-03-20T09:00:00.000Z')
})
expect(result).toEqual({
status: 'needs_review',
reason: 'kind_ambiguous'
})
})
})

View File

@@ -0,0 +1,368 @@
import type {
ExchangeRateProvider,
FinancePaymentKind,
FinanceRepository,
HouseholdConfigurationRepository
} from '@household/ports'
import {
BillingPeriod,
Money,
Temporal,
convertMoney,
nowInstant,
type CurrencyCode
} from '@household/domain'
import type { FinanceCommandService } from './finance-command-service'
import { parsePaymentConfirmationMessage } from './payment-confirmation-parser'
function billingPeriodLockDate(period: BillingPeriod, day: number): Temporal.PlainDate {
const firstDay = Temporal.PlainDate.from({
year: period.year,
month: period.month,
day: 1
})
const clampedDay = Math.min(day, firstDay.daysInMonth)
return Temporal.PlainDate.from({
year: period.year,
month: period.month,
day: clampedDay
})
}
function localDateInTimezone(timezone: string): Temporal.PlainDate {
return nowInstant().toZonedDateTimeISO(timezone).toPlainDate()
}
async function convertIntoCycleCurrency(
dependencies: {
repository: Pick<FinanceRepository, 'getCycleExchangeRate' | 'saveCycleExchangeRate'>
exchangeRateProvider: ExchangeRateProvider
cycleId: string
cycleCurrency: CurrencyCode
period: BillingPeriod
timezone: string
lockDay: number
},
amount: Money
): Promise<{
amount: Money
explicitAmountMinor: bigint
explicitCurrency: CurrencyCode
}> {
if (amount.currency === dependencies.cycleCurrency) {
return {
amount,
explicitAmountMinor: amount.amountMinor,
explicitCurrency: amount.currency
}
}
const existingRate = await dependencies.repository.getCycleExchangeRate(
dependencies.cycleId,
amount.currency,
dependencies.cycleCurrency
)
if (existingRate) {
return {
amount: convertMoney(amount, dependencies.cycleCurrency, existingRate.rateMicros),
explicitAmountMinor: amount.amountMinor,
explicitCurrency: amount.currency
}
}
const lockDate = billingPeriodLockDate(dependencies.period, dependencies.lockDay)
const currentLocalDate = localDateInTimezone(dependencies.timezone)
const shouldPersist = Temporal.PlainDate.compare(currentLocalDate, lockDate) >= 0
const quote = await dependencies.exchangeRateProvider.getRate({
baseCurrency: amount.currency,
quoteCurrency: dependencies.cycleCurrency,
effectiveDate: lockDate.toString()
})
if (shouldPersist) {
await dependencies.repository.saveCycleExchangeRate({
cycleId: dependencies.cycleId,
sourceCurrency: quote.baseCurrency,
targetCurrency: quote.quoteCurrency,
rateMicros: quote.rateMicros,
effectiveDate: quote.effectiveDate,
source: quote.source
})
}
return {
amount: convertMoney(amount, dependencies.cycleCurrency, quote.rateMicros),
explicitAmountMinor: amount.amountMinor,
explicitCurrency: amount.currency
}
}
export interface PaymentConfirmationMessageInput {
senderTelegramUserId: string
rawText: string
telegramChatId: string
telegramMessageId: string
telegramThreadId: string
telegramUpdateId: string
attachmentCount: number
messageSentAt: Temporal.Instant | null
}
export type PaymentConfirmationSubmitResult =
| {
status: 'duplicate'
}
| {
status: 'recorded'
kind: FinancePaymentKind
amount: Money
}
| {
status: 'needs_review'
reason:
| 'member_not_found'
| 'cycle_not_found'
| 'settlement_not_ready'
| 'intent_missing'
| 'kind_ambiguous'
| 'multiple_members'
| 'non_positive_amount'
}
export interface PaymentConfirmationService {
submit(input: PaymentConfirmationMessageInput): Promise<PaymentConfirmationSubmitResult>
}
export function createPaymentConfirmationService(input: {
householdId: string
financeService: Pick<FinanceCommandService, 'getMemberByTelegramUserId' | 'generateDashboard'>
repository: Pick<
FinanceRepository,
| 'getOpenCycle'
| 'getLatestCycle'
| 'getCycleExchangeRate'
| 'saveCycleExchangeRate'
| 'savePaymentConfirmation'
>
householdConfigurationRepository: Pick<
HouseholdConfigurationRepository,
'getHouseholdBillingSettings'
>
exchangeRateProvider: ExchangeRateProvider
}): PaymentConfirmationService {
return {
async submit(message) {
const member = await input.financeService.getMemberByTelegramUserId(
message.senderTelegramUserId
)
if (!member) {
const saveResult = await input.repository.savePaymentConfirmation({
...message,
normalizedText: message.rawText.trim().replaceAll(/\s+/g, ' '),
status: 'needs_review',
cycleId: null,
memberId: null,
kind: null,
amountMinor: null,
currency: null,
explicitAmountMinor: null,
explicitCurrency: null,
reviewReason: 'member_not_found'
})
return saveResult.status === 'duplicate'
? saveResult
: {
status: 'needs_review',
reason: 'member_not_found'
}
}
const [cycle, settings] = await Promise.all([
input.repository
.getOpenCycle()
.then((openCycle) => openCycle ?? input.repository.getLatestCycle()),
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId)
])
if (!cycle) {
const saveResult = await input.repository.savePaymentConfirmation({
...message,
normalizedText: message.rawText.trim().replaceAll(/\s+/g, ' '),
status: 'needs_review',
cycleId: null,
memberId: member.id,
kind: null,
amountMinor: null,
currency: null,
explicitAmountMinor: null,
explicitCurrency: null,
reviewReason: 'cycle_not_found'
})
return saveResult.status === 'duplicate'
? saveResult
: {
status: 'needs_review',
reason: 'cycle_not_found'
}
}
const parsed = parsePaymentConfirmationMessage(message.rawText, settings.settlementCurrency)
if (!parsed.kind || parsed.reviewReason) {
const saveResult = await input.repository.savePaymentConfirmation({
...message,
normalizedText: parsed.normalizedText,
status: 'needs_review',
cycleId: cycle.id,
memberId: member.id,
kind: parsed.kind,
amountMinor: null,
currency: null,
explicitAmountMinor: parsed.explicitAmount?.amountMinor ?? null,
explicitCurrency: parsed.explicitAmount?.currency ?? null,
reviewReason: parsed.reviewReason ?? 'kind_ambiguous'
})
return saveResult.status === 'duplicate'
? saveResult
: {
status: 'needs_review',
reason: parsed.reviewReason ?? 'kind_ambiguous'
}
}
const dashboard = await input.financeService.generateDashboard(cycle.period)
if (!dashboard) {
const saveResult = await input.repository.savePaymentConfirmation({
...message,
normalizedText: parsed.normalizedText,
status: 'needs_review',
cycleId: cycle.id,
memberId: member.id,
kind: parsed.kind,
amountMinor: null,
currency: null,
explicitAmountMinor: parsed.explicitAmount?.amountMinor ?? null,
explicitCurrency: parsed.explicitAmount?.currency ?? null,
reviewReason: 'settlement_not_ready'
})
return saveResult.status === 'duplicate'
? saveResult
: {
status: 'needs_review',
reason: 'settlement_not_ready'
}
}
const memberLine = dashboard.members.find((line) => line.memberId === member.id)
if (!memberLine) {
const saveResult = await input.repository.savePaymentConfirmation({
...message,
normalizedText: parsed.normalizedText,
status: 'needs_review',
cycleId: cycle.id,
memberId: member.id,
kind: parsed.kind,
amountMinor: null,
currency: null,
explicitAmountMinor: parsed.explicitAmount?.amountMinor ?? null,
explicitCurrency: parsed.explicitAmount?.currency ?? null,
reviewReason: 'settlement_not_ready'
})
return saveResult.status === 'duplicate'
? saveResult
: {
status: 'needs_review',
reason: 'settlement_not_ready'
}
}
const inferredAmount =
parsed.kind === 'rent'
? memberLine.rentShare
: memberLine.utilityShare.add(memberLine.purchaseOffset)
const resolvedAmount = parsed.explicitAmount
? (
await convertIntoCycleCurrency(
{
repository: input.repository,
exchangeRateProvider: input.exchangeRateProvider,
cycleId: cycle.id,
cycleCurrency: dashboard.currency,
period: BillingPeriod.fromString(cycle.period),
timezone: settings.timezone,
lockDay:
parsed.kind === 'rent' ? settings.rentWarningDay : settings.utilitiesReminderDay
},
parsed.explicitAmount
)
).amount
: inferredAmount
if (resolvedAmount.amountMinor <= 0n) {
const saveResult = await input.repository.savePaymentConfirmation({
...message,
normalizedText: parsed.normalizedText,
status: 'needs_review',
cycleId: cycle.id,
memberId: member.id,
kind: parsed.kind,
amountMinor: null,
currency: null,
explicitAmountMinor: parsed.explicitAmount?.amountMinor ?? null,
explicitCurrency: parsed.explicitAmount?.currency ?? null,
reviewReason: 'non_positive_amount'
})
return saveResult.status === 'duplicate'
? saveResult
: {
status: 'needs_review',
reason: 'non_positive_amount'
}
}
const saveResult = await input.repository.savePaymentConfirmation({
...message,
normalizedText: parsed.normalizedText,
status: 'recorded',
cycleId: cycle.id,
memberId: member.id,
kind: parsed.kind,
amountMinor: resolvedAmount.amountMinor,
currency: resolvedAmount.currency,
explicitAmountMinor: parsed.explicitAmount?.amountMinor ?? null,
explicitCurrency: parsed.explicitAmount?.currency ?? null,
recordedAt: message.messageSentAt ?? nowInstant()
})
if (saveResult.status === 'duplicate') {
return saveResult
}
if (saveResult.status === 'needs_review') {
return {
status: 'needs_review',
reason: saveResult.reviewReason
}
}
return {
status: 'recorded',
kind: saveResult.paymentRecord.kind,
amount: Money.fromMinor(
saveResult.paymentRecord.amountMinor,
saveResult.paymentRecord.currency
)
}
}
}
}

View File

@@ -13,6 +13,7 @@
"0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087", "0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087",
"0010_wild_molecule_man.sql": "46027a6ac770cdc2efd4c3eb5bb53f09d1b852c70fdc46a2434e5a7064587245", "0010_wild_molecule_man.sql": "46027a6ac770cdc2efd4c3eb5bb53f09d1b852c70fdc46a2434e5a7064587245",
"0011_previous_ezekiel_stane.sql": "d996e64d3854de22e36dedeaa94e46774399163d90263bbb05e0b9199af79b70", "0011_previous_ezekiel_stane.sql": "d996e64d3854de22e36dedeaa94e46774399163d90263bbb05e0b9199af79b70",
"0012_clumsy_maestro.sql": "173797fb435c6acd7c268c624942d6f19a887c329bcef409a3dde1249baaeb8a" "0012_clumsy_maestro.sql": "173797fb435c6acd7c268c624942d6f19a887c329bcef409a3dde1249baaeb8a",
"0013_wild_avengers.sql": "76254db09c9d623134712aee57a5896aa4a5b416e45d0f6c69dec1fec5b32af4"
} }
} }

View File

@@ -0,0 +1,51 @@
CREATE TABLE "payment_confirmations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"cycle_id" uuid,
"member_id" uuid,
"sender_telegram_user_id" text NOT NULL,
"raw_text" text NOT NULL,
"normalized_text" text NOT NULL,
"detected_kind" text,
"explicit_amount_minor" bigint,
"explicit_currency" text,
"resolved_amount_minor" bigint,
"resolved_currency" text,
"status" text NOT NULL,
"review_reason" text,
"attachment_count" integer DEFAULT 0 NOT NULL,
"telegram_chat_id" text NOT NULL,
"telegram_message_id" text NOT NULL,
"telegram_thread_id" text NOT NULL,
"telegram_update_id" text NOT NULL,
"message_sent_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "payment_records" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"cycle_id" uuid NOT NULL,
"member_id" uuid NOT NULL,
"kind" text NOT NULL,
"amount_minor" bigint NOT NULL,
"currency" text NOT NULL,
"confirmation_id" uuid,
"recorded_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "payment_confirmations" ADD CONSTRAINT "payment_confirmations_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_confirmations" ADD CONSTRAINT "payment_confirmations_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_confirmations" ADD CONSTRAINT "payment_confirmations_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_confirmation_id_payment_confirmations_id_fk" FOREIGN KEY ("confirmation_id") REFERENCES "public"."payment_confirmations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "payment_confirmations_household_tg_message_unique" ON "payment_confirmations" USING btree ("household_id","telegram_chat_id","telegram_message_id");--> statement-breakpoint
CREATE UNIQUE INDEX "payment_confirmations_household_tg_update_unique" ON "payment_confirmations" USING btree ("household_id","telegram_update_id");--> statement-breakpoint
CREATE INDEX "payment_confirmations_household_status_idx" ON "payment_confirmations" USING btree ("household_id","status");--> statement-breakpoint
CREATE INDEX "payment_confirmations_member_created_idx" ON "payment_confirmations" USING btree ("member_id","created_at");--> statement-breakpoint
CREATE INDEX "payment_records_cycle_member_idx" ON "payment_records" USING btree ("cycle_id","member_id");--> statement-breakpoint
CREATE INDEX "payment_records_cycle_kind_idx" ON "payment_records" USING btree ("cycle_id","kind");--> statement-breakpoint
CREATE UNIQUE INDEX "payment_records_confirmation_unique" ON "payment_records" USING btree ("confirmation_id");

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,13 @@
"when": 1773146577992, "when": 1773146577992,
"tag": "0012_clumsy_maestro", "tag": "0012_clumsy_maestro",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1773147481265,
"tag": "0013_wild_avengers",
"breakpoints": true
} }
] ]
} }

View File

@@ -481,6 +481,83 @@ export const anonymousMessages = pgTable(
}) })
) )
export const paymentConfirmations = pgTable(
'payment_confirmations',
{
id: uuid('id').defaultRandom().primaryKey(),
householdId: uuid('household_id')
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
cycleId: uuid('cycle_id').references(() => billingCycles.id, { onDelete: 'set null' }),
memberId: uuid('member_id').references(() => members.id, { onDelete: 'set null' }),
senderTelegramUserId: text('sender_telegram_user_id').notNull(),
rawText: text('raw_text').notNull(),
normalizedText: text('normalized_text').notNull(),
detectedKind: text('detected_kind'),
explicitAmountMinor: bigint('explicit_amount_minor', { mode: 'bigint' }),
explicitCurrency: text('explicit_currency'),
resolvedAmountMinor: bigint('resolved_amount_minor', { mode: 'bigint' }),
resolvedCurrency: text('resolved_currency'),
status: text('status').notNull(),
reviewReason: text('review_reason'),
attachmentCount: integer('attachment_count').default(0).notNull(),
telegramChatId: text('telegram_chat_id').notNull(),
telegramMessageId: text('telegram_message_id').notNull(),
telegramThreadId: text('telegram_thread_id').notNull(),
telegramUpdateId: text('telegram_update_id').notNull(),
messageSentAt: timestamp('message_sent_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
householdMessageUnique: uniqueIndex('payment_confirmations_household_tg_message_unique').on(
table.householdId,
table.telegramChatId,
table.telegramMessageId
),
householdUpdateUnique: uniqueIndex('payment_confirmations_household_tg_update_unique').on(
table.householdId,
table.telegramUpdateId
),
householdStatusIdx: index('payment_confirmations_household_status_idx').on(
table.householdId,
table.status
),
memberCreatedIdx: index('payment_confirmations_member_created_idx').on(
table.memberId,
table.createdAt
)
})
)
export const paymentRecords = pgTable(
'payment_records',
{
id: uuid('id').defaultRandom().primaryKey(),
householdId: uuid('household_id')
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
cycleId: uuid('cycle_id')
.notNull()
.references(() => billingCycles.id, { onDelete: 'cascade' }),
memberId: uuid('member_id')
.notNull()
.references(() => members.id, { onDelete: 'restrict' }),
kind: text('kind').notNull(),
amountMinor: bigint('amount_minor', { mode: 'bigint' }).notNull(),
currency: text('currency').notNull(),
confirmationId: uuid('confirmation_id').references(() => paymentConfirmations.id, {
onDelete: 'set null'
}),
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
cycleMemberIdx: index('payment_records_cycle_member_idx').on(table.cycleId, table.memberId),
cycleKindIdx: index('payment_records_cycle_kind_idx').on(table.cycleId, table.kind),
confirmationUnique: uniqueIndex('payment_records_confirmation_unique').on(table.confirmationId)
})
)
export const settlements = pgTable( export const settlements = pgTable(
'settlements', 'settlements',
{ {
@@ -548,4 +625,6 @@ export type UtilityBill = typeof utilityBills.$inferSelect
export type PurchaseEntry = typeof purchaseEntries.$inferSelect export type PurchaseEntry = typeof purchaseEntries.$inferSelect
export type PurchaseMessage = typeof purchaseMessages.$inferSelect export type PurchaseMessage = typeof purchaseMessages.$inferSelect
export type AnonymousMessage = typeof anonymousMessages.$inferSelect export type AnonymousMessage = typeof anonymousMessages.$inferSelect
export type PaymentConfirmation = typeof paymentConfirmations.$inferSelect
export type PaymentRecord = typeof paymentRecords.$inferSelect
export type Settlement = typeof settlements.$inferSelect export type Settlement = typeof settlements.$inferSelect

View File

@@ -46,6 +46,83 @@ export interface FinanceUtilityBillRecord {
createdAt: Instant createdAt: Instant
} }
export type FinancePaymentKind = 'rent' | 'utilities'
export interface FinancePaymentRecord {
id: string
memberId: string
kind: FinancePaymentKind
amountMinor: bigint
currency: CurrencyCode
recordedAt: Instant
}
export interface FinanceSettlementSnapshotLineRecord {
memberId: string
rentShareMinor: bigint
utilityShareMinor: bigint
purchaseOffsetMinor: bigint
netDueMinor: bigint
}
export interface FinancePaymentConfirmationMessage {
senderTelegramUserId: string
rawText: string
normalizedText: string
telegramChatId: string
telegramMessageId: string
telegramThreadId: string
telegramUpdateId: string
attachmentCount: number
messageSentAt: Instant | null
}
export type FinancePaymentConfirmationReviewReason =
| 'member_not_found'
| 'cycle_not_found'
| 'settlement_not_ready'
| 'intent_missing'
| 'kind_ambiguous'
| 'multiple_members'
| 'non_positive_amount'
export type FinancePaymentConfirmationSaveInput =
| (FinancePaymentConfirmationMessage & {
status: 'recorded'
cycleId: string
memberId: string
kind: FinancePaymentKind
amountMinor: bigint
currency: CurrencyCode
explicitAmountMinor: bigint | null
explicitCurrency: CurrencyCode | null
recordedAt: Instant
})
| (FinancePaymentConfirmationMessage & {
status: 'needs_review'
cycleId: string | null
memberId: string | null
kind: FinancePaymentKind | null
amountMinor: bigint | null
currency: CurrencyCode | null
explicitAmountMinor: bigint | null
explicitCurrency: CurrencyCode | null
reviewReason: FinancePaymentConfirmationReviewReason
})
export type FinancePaymentConfirmationSaveResult =
| {
status: 'duplicate'
}
| {
status: 'recorded'
paymentRecord: FinancePaymentRecord
}
| {
status: 'needs_review'
reviewReason: FinancePaymentConfirmationReviewReason
}
export interface SettlementSnapshotLineRecord { export interface SettlementSnapshotLineRecord {
memberId: string memberId: string
rentShareMinor: bigint rentShareMinor: bigint
@@ -91,9 +168,16 @@ export interface FinanceRepository {
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null> getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
getUtilityTotalForCycle(cycleId: string): Promise<bigint> getUtilityTotalForCycle(cycleId: string): Promise<bigint>
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]> listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
listPaymentRecordsForCycle(cycleId: string): Promise<readonly FinancePaymentRecord[]>
listParsedPurchasesForRange( listParsedPurchasesForRange(
start: Instant, start: Instant,
end: Instant end: Instant
): Promise<readonly FinanceParsedPurchaseRecord[]> ): Promise<readonly FinanceParsedPurchaseRecord[]>
getSettlementSnapshotLines(
cycleId: string
): Promise<readonly FinanceSettlementSnapshotLineRecord[]>
savePaymentConfirmation(
input: FinancePaymentConfirmationSaveInput
): Promise<FinancePaymentConfirmationSaveResult>
replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void> replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void>
} }

View File

@@ -1,7 +1,7 @@
import type { CurrencyCode, SupportedLocale } from '@household/domain' import type { CurrencyCode, SupportedLocale } from '@household/domain'
import type { ReminderTarget } from './reminders' import type { ReminderTarget } from './reminders'
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders', 'payments'] as const
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number] export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]

View File

@@ -31,6 +31,12 @@ export type {
export type { export type {
FinanceCycleRecord, FinanceCycleRecord,
FinanceCycleExchangeRateRecord, FinanceCycleExchangeRateRecord,
FinancePaymentConfirmationReviewReason,
FinancePaymentConfirmationSaveInput,
FinancePaymentConfirmationSaveResult,
FinancePaymentKind,
FinancePaymentRecord,
FinanceSettlementSnapshotLineRecord,
FinanceMemberRecord, FinanceMemberRecord,
FinanceParsedPurchaseRecord, FinanceParsedPurchaseRecord,
FinanceRentRuleRecord, FinanceRentRuleRecord,