From 198852193180b5b3403b2da2b204e4fdc861e1e0 Mon Sep 17 00:00:00 2001 From: whekin Date: Tue, 10 Mar 2026 17:00:45 +0400 Subject: [PATCH] feat(payments): track household payment confirmations --- apps/bot/src/household-setup.ts | 14 +- apps/bot/src/i18n/locales/en.ts | 14 +- apps/bot/src/i18n/locales/ru.ts | 14 +- apps/bot/src/i18n/types.ts | 10 + apps/bot/src/index.ts | 51 +- apps/bot/src/miniapp-dashboard.test.ts | 7 + apps/bot/src/miniapp-dashboard.ts | 4 + apps/bot/src/payment-topic-ingestion.test.ts | 169 + apps/bot/src/payment-topic-ingestion.ts | 225 ++ apps/bot/src/telegram-commands.ts | 1 + apps/miniapp/src/App.tsx | 66 +- apps/miniapp/src/i18n.ts | 4 + apps/miniapp/src/index.css | 12 + apps/miniapp/src/miniapp-api.ts | 4 + .../HOUSEBOT-080-payment-confirmations.md | 39 + .../adapters-db/src/finance-repository.ts | 139 + .../src/finance-command-service.test.ts | 23 +- .../src/finance-command-service.ts | 31 +- packages/application/src/index.ts | 5 + .../src/payment-confirmation-parser.test.ts | 36 + .../src/payment-confirmation-parser.ts | 143 + .../src/payment-confirmation-service.test.ts | 258 ++ .../src/payment-confirmation-service.ts | 368 ++ packages/db/drizzle-checksums.json | 3 +- packages/db/drizzle/0013_wild_avengers.sql | 51 + packages/db/drizzle/meta/0013_snapshot.json | 2945 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 79 + packages/ports/src/finance.ts | 84 + packages/ports/src/household-config.ts | 2 +- packages/ports/src/index.ts | 6 + 31 files changed, 4795 insertions(+), 19 deletions(-) create mode 100644 apps/bot/src/payment-topic-ingestion.test.ts create mode 100644 apps/bot/src/payment-topic-ingestion.ts create mode 100644 docs/specs/HOUSEBOT-080-payment-confirmations.md create mode 100644 packages/application/src/payment-confirmation-parser.test.ts create mode 100644 packages/application/src/payment-confirmation-parser.ts create mode 100644 packages/application/src/payment-confirmation-service.test.ts create mode 100644 packages/application/src/payment-confirmation-service.ts create mode 100644 packages/db/drizzle/0013_wild_avengers.sql create mode 100644 packages/db/drizzle/meta/0013_snapshot.json diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index 20b8a8c..1fdabd4 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -67,7 +67,7 @@ function bindRejectionMessage( function bindTopicUsageMessage( locale: BotLocale, - role: 'purchase' | 'feedback' | 'reminders' + role: 'purchase' | 'feedback' | 'reminders' | 'payments' ): string { const t = getBotTranslations(locale).setup @@ -78,12 +78,14 @@ function bindTopicUsageMessage( return t.useBindFeedbackTopicInGroup case 'reminders': return t.useBindRemindersTopicInGroup + case 'payments': + return t.useBindPaymentsTopicInGroup } } function bindTopicSuccessMessage( locale: BotLocale, - role: 'purchase' | 'feedback' | 'reminders', + role: 'purchase' | 'feedback' | 'reminders' | 'payments', householdName: string, threadId: string ): string { @@ -96,6 +98,8 @@ function bindTopicSuccessMessage( return t.feedbackTopicSaved(householdName, threadId) case 'reminders': return t.remindersTopicSaved(householdName, threadId) + case 'payments': + return t.paymentsTopicSaved(householdName, threadId) } } @@ -218,7 +222,7 @@ export function registerHouseholdSetupCommands(options: { }): void { async function handleBindTopicCommand( ctx: Context, - role: 'purchase' | 'feedback' | 'reminders' + role: 'purchase' | 'feedback' | 'reminders' | 'payments' ): Promise { const locale = await resolveReplyLocale({ ctx, @@ -455,6 +459,10 @@ export function registerHouseholdSetupCommands(options: { await handleBindTopicCommand(ctx, 'reminders') }) + options.bot.command('bind_payments_topic', async (ctx) => { + await handleBindTopicCommand(ctx, 'payments') + }) + options.bot.command('pending_members', async (ctx) => { const locale = await resolveReplyLocale({ ctx, diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index f3856b3..50403bf 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -11,6 +11,7 @@ export const enBotTranslations: BotTranslationCatalog = { bind_purchase_topic: 'Bind the current topic as purchases', bind_feedback_topic: 'Bind the current topic as feedback', 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', approve_member: 'Approve a pending household member' }, @@ -53,7 +54,7 @@ export const enBotTranslations: BotTranslationCatalog = { [ `Household ${created ? 'created' : 'already registered'}: ${householdName}`, `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.' ].join('\n'), 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.', remindersTopicSaved: (householdName, 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.', useApproveMemberInGroup: 'Use /approve_member inside the household group.', approveMemberUsage: 'Usage: /approve_member ', @@ -147,5 +151,13 @@ export const enBotTranslations: BotTranslationCatalog = { recorded: (summary) => `Recorded purchase: ${summary}`, savedForReview: (summary) => `Saved for review: ${summary}`, 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.' } } diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index 6f840a2..7822a15 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -11,6 +11,7 @@ export const ruBotTranslations: BotTranslationCatalog = { bind_purchase_topic: 'Назначить текущий топик для покупок', bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений', bind_reminders_topic: 'Назначить текущий топик для напоминаний', + bind_payments_topic: 'Назначить текущий топик для оплат', pending_members: 'Показать ожидающие заявки на вступление', approve_member: 'Подтвердить участника дома' }, @@ -55,7 +56,7 @@ export const ruBotTranslations: BotTranslationCatalog = { [ `${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`, `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'), useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.', @@ -67,6 +68,9 @@ export const ruBotTranslations: BotTranslationCatalog = { useBindRemindersTopicInGroup: 'Используйте /bind_reminders_topic внутри топика группы дома.', remindersTopicSaved: (householdName, threadId) => `Топик напоминаний сохранён для ${householdName} (тред ${threadId}).`, + useBindPaymentsTopicInGroup: 'Используйте /bind_payments_topic внутри топика группы дома.', + paymentsTopicSaved: (householdName, threadId) => + `Топик оплат сохранён для ${householdName} (тред ${threadId}).`, usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.', useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.', approveMemberUsage: 'Использование: /approve_member ', @@ -150,5 +154,13 @@ export const ruBotTranslations: BotTranslationCatalog = { recorded: (summary) => `Покупка сохранена: ${summary}`, savedForReview: (summary) => `Сохранено на проверку: ${summary}`, parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.' + }, + payments: { + topicMissing: + 'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.', + recorded: (kind, amount, currency) => + `Оплата ${kind === 'rent' ? 'аренды' : 'коммуналки'} сохранена: ${amount} ${currency}`, + savedForReview: 'Это подтверждение оплаты сохранено на проверку.', + duplicate: 'Это подтверждение оплаты уже было обработано.' } } diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 7367c2b..862a333 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -9,6 +9,7 @@ export type TelegramCommandName = | 'bind_purchase_topic' | 'bind_feedback_topic' | 'bind_reminders_topic' + | 'bind_payments_topic' | 'pending_members' | 'approve_member' @@ -21,6 +22,7 @@ export interface BotCommandDescriptions { bind_purchase_topic: string bind_feedback_topic: string bind_reminders_topic: string + bind_payments_topic: string pending_members: string approve_member: string } @@ -77,6 +79,8 @@ export interface BotTranslationCatalog { feedbackTopicSaved: (householdName: string, threadId: string) => string useBindRemindersTopicInGroup: string remindersTopicSaved: (householdName: string, threadId: string) => string + useBindPaymentsTopicInGroup: string + paymentsTopicSaved: (householdName: string, threadId: string) => string usePendingMembersInGroup: string useApproveMemberInGroup: string approveMemberUsage: string @@ -153,4 +157,10 @@ export interface BotTranslationCatalog { savedForReview: (summary: string) => string parseFailed: string } + payments: { + topicMissing: string + recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string + savedForReview: string + duplicate: string + } } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 5c1620d..dfb9607 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -8,7 +8,8 @@ import { createLocalePreferenceService, createMiniAppAdminService, createHouseholdSetupService, - createReminderJobService + createReminderJobService, + createPaymentConfirmationService } from '@household/application' import { createDbAnonymousFeedbackRepository, @@ -29,6 +30,7 @@ import { createPurchaseMessageRepository, registerConfiguredPurchaseTopicIngestion } from './purchase-topic-ingestion' +import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion' import { createReminderJobsHandler } from './reminder-jobs' import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createBotWebhookServer } from './server' @@ -72,6 +74,10 @@ const bot = createTelegramBot( const webhookHandler = webhookCallback(bot, 'std/http') const financeRepositoryClients = new Map>() const financeServices = new Map>() +const paymentConfirmationServices = new Map< + string, + ReturnType +>() const exchangeRateProvider = createNbgExchangeRateProvider({ logger: getLogger('fx') }) @@ -105,10 +111,7 @@ function financeServiceForHousehold(householdId: string) { return existing } - const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId) - financeRepositoryClients.set(householdId, repositoryClient) - shutdownTasks.push(repositoryClient.close) - + const repositoryClient = financeRepositoryForHousehold(householdId) const service = createFinanceCommandService({ householdId, repository: repositoryClient.repository, @@ -119,6 +122,35 @@ function financeServiceForHousehold(householdId: string) { 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) { const existing = anonymousFeedbackServices.get(householdId) if (existing) { @@ -160,6 +192,15 @@ if (runtime.databaseUrl && householdConfigurationRepositoryClient) { logger: getLogger('purchase-ingestion') } ) + + registerConfiguredPaymentTopicIngestion( + bot, + householdConfigurationRepositoryClient.repository, + paymentConfirmationServiceForHousehold, + { + logger: getLogger('payment-ingestion') + } + ) } else { logger.warn( { diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 797cd64..20b5fa5 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -61,6 +61,7 @@ function repository( createdAt: instantFromIso('2026-03-12T12:00:00.000Z') } ], + listPaymentRecordsForCycle: async () => [], listParsedPurchasesForRange: async () => [ { id: 'purchase-1', @@ -71,6 +72,12 @@ function repository( occurredAt: instantFromIso('2026-03-12T11:00:00.000Z') } ], + getSettlementSnapshotLines: async () => [], + savePaymentConfirmation: async () => + ({ + status: 'needs_review', + reviewReason: 'settlement_not_ready' + }) as const, replaceSettlementSnapshot: async () => {} } } diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index 8469ab7..d2a67b5 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -89,6 +89,8 @@ export function createMiniAppDashboardHandler(options: { period: dashboard.period, currency: dashboard.currency, totalDueMajor: dashboard.totalDue.toMajorString(), + totalPaidMajor: dashboard.totalPaid.toMajorString(), + totalRemainingMajor: dashboard.totalRemaining.toMajorString(), rentSourceAmountMajor: dashboard.rentSourceAmount.toMajorString(), rentSourceCurrency: dashboard.rentSourceAmount.currency, rentDisplayAmountMajor: dashboard.rentDisplayAmount.toMajorString(), @@ -101,6 +103,8 @@ export function createMiniAppDashboardHandler(options: { utilityShareMajor: line.utilityShare.toMajorString(), purchaseOffsetMajor: line.purchaseOffset.toMajorString(), netDueMajor: line.netDue.toMajorString(), + paidMajor: line.paid.toMajorString(), + remainingMajor: line.remaining.toMajorString(), explanations: line.explanations })), ledger: dashboard.ledger.map((entry) => ({ diff --git a/apps/bot/src/payment-topic-ingestion.test.ts b/apps/bot/src/payment-topic-ingestion.test.ts new file mode 100644 index 0000000..0742fee --- /dev/null +++ b/apps/bot/src/payment-topic-ingestion.test.ts @@ -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 { + 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' + }) + }) +}) diff --git a/apps/bot/src/payment-topic-ingestion.ts b/apps/bot/src/payment-topic-ingestion.ts new file mode 100644 index 0000000..8b3a626 --- /dev/null +++ b/apps/bot/src/payment-topic-ingestion.ts @@ -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 { + 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' + ) + } + }) +} diff --git a/apps/bot/src/telegram-commands.ts b/apps/bot/src/telegram-commands.ts index 305605e..6eb0d2f 100644 --- a/apps/bot/src/telegram-commands.ts +++ b/apps/bot/src/telegram-commands.ts @@ -27,6 +27,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [ 'bind_purchase_topic', 'bind_feedback_topic', 'bind_reminders_topic', + 'bind_payments_topic', 'pending_members', 'approve_member' ] as const satisfies readonly TelegramCommandName[] diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 8799afa..3e36ff7 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -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 { return `${entry.displayAmountMajor} ${entry.displayCurrency}` } @@ -405,6 +419,8 @@ function App() { period: '2026-03', currency: 'GEL', totalDueMajor: '1030.00', + totalPaidMajor: '501.00', + totalRemainingMajor: '529.00', rentSourceAmountMajor: '700.00', rentSourceCurrency: 'USD', rentDisplayAmountMajor: '1932.00', @@ -418,6 +434,8 @@ function App() { utilityShareMajor: '32.00', purchaseOffsetMajor: '-14.00', netDueMajor: '501.00', + paidMajor: '501.00', + remainingMajor: '0.00', explanations: ['Equal utility split', 'Shared purchase offset'] }, { @@ -427,6 +445,8 @@ function App() { utilityShareMajor: '32.00', purchaseOffsetMajor: '14.00', netDueMajor: '529.00', + paidMajor: '0.00', + remainingMajor: '529.00', explanations: ['Equal utility split'] } ], @@ -893,6 +913,18 @@ function App() { {currentMemberLine()!.netDueMajor} {data.currency} +
+ {copy().paidLabel} + + {currentMemberLine()!.paidMajor} {data.currency} + +
+
+ {copy().remainingLabel} + + {currentMemberLine()!.remainingMajor} {data.currency} + +
) : null} @@ -907,7 +939,7 @@ function App() {
{member.displayName} - {member.netDueMajor} {data.currency} + {member.remainingMajor} {data.currency}

@@ -922,6 +954,12 @@ function App() {

{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}

+

+ {copy().paidLabel}: {member.paidMajor} {data.currency} +

+

+ {copy().remainingLabel}: {member.remainingMajor} {data.currency} +

))} @@ -1554,6 +1592,18 @@ function App() { {dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'} +
+ {copy().paidLabel} + + {dashboard() ? `${dashboard()!.totalPaidMajor} ${dashboard()!.currency}` : '—'} + +
+
+ {copy().remainingLabel} + + {dashboard() ? `${dashboard()!.totalRemainingMajor} ${dashboard()!.currency}` : '—'} + +
{copy().membersCount} {dashboardMemberCount(dashboard())} @@ -1578,7 +1628,7 @@ function App() {
{copy().yourBalanceTitle} - {currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''} + {currentMemberLine()!.remainingMajor} {dashboard()?.currency ?? ''}

{copy().yourBalanceBody}

@@ -1613,6 +1663,18 @@ function App() { {currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''} +
+ {copy().paidLabel} + + {currentMemberLine()!.paidMajor} {dashboard()?.currency ?? ''} + +
+
+ {copy().remainingLabel} + + {currentMemberLine()!.remainingMajor} {dashboard()?.currency ?? ''} + +
) : ( diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 99e0bed..0fd6765 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -42,6 +42,8 @@ export const dictionary = { overviewBody: 'Use the sections below to review balances, ledger entries, and household access.', totalDue: 'Total due', + paidLabel: 'Paid', + remainingLabel: 'Remaining', membersCount: 'Members', ledgerEntries: 'Ledger entries', pendingRequests: 'Pending requests', @@ -159,6 +161,8 @@ export const dictionary = { overviewTitle: 'Текущий цикл', overviewBody: 'Ниже можно посмотреть балансы, записи леджера и доступ к household.', totalDue: 'Итого к оплате', + paidLabel: 'Оплачено', + remainingLabel: 'Осталось', membersCount: 'Участники', ledgerEntries: 'Записи леджера', pendingRequests: 'Ожидают подтверждения', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 48eefad..a5d4d2a 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -270,6 +270,18 @@ button { margin-top: 6px; } +.balance-status.is-credit { + color: #95e2b0; +} + +.balance-status.is-settled { + color: #d6d0c9; +} + +.balance-status.is-due { + color: #f7b389; +} + .home-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index e277fd7..42f69cb 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -68,6 +68,8 @@ export interface MiniAppDashboard { period: string currency: 'USD' | 'GEL' totalDueMajor: string + totalPaidMajor: string + totalRemainingMajor: string rentSourceAmountMajor: string rentSourceCurrency: 'USD' | 'GEL' rentDisplayAmountMajor: string @@ -80,6 +82,8 @@ export interface MiniAppDashboard { utilityShareMajor: string purchaseOffsetMajor: string netDueMajor: string + paidMajor: string + remainingMajor: string explanations: readonly string[] }[] ledger: { diff --git a/docs/specs/HOUSEBOT-080-payment-confirmations.md b/docs/specs/HOUSEBOT-080-payment-confirmations.md new file mode 100644 index 0000000..f40bb8f --- /dev/null +++ b/docs/specs/HOUSEBOT-080-payment-confirmations.md @@ -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 diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index 6f9ff8c..5d47cc5 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -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) { const rows = await db .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) { await db.transaction(async (tx) => { const upserted = await tx diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index 52f46c4..8bdda46 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -125,10 +125,25 @@ class FinanceRepositoryStub implements FinanceRepository { return this.utilityBills } + async listPaymentRecordsForCycle() { + return [] + } + async listParsedPurchasesForRange(): Promise { 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 { this.replacedSnapshot = snapshot } @@ -347,9 +362,11 @@ describe('createFinanceCommandService', () => { [ 'Statement for 2026-03', 'Rent: 700.00 USD (~1890.00 GEL)', - '- Alice: 990.00 GEL', - '- Bob: 1020.00 GEL', - 'Total: 2010.00 GEL' + '- Alice: due 990.00 GEL, paid 0.00 GEL, remaining 990.00 GEL', + '- Bob: due 1020.00 GEL, paid 0.00 GEL, remaining 1020.00 GEL', + 'Total due: 2010.00 GEL', + 'Total paid: 0.00 GEL', + 'Total remaining: 2010.00 GEL' ].join('\n') ) expect(repository.replacedSnapshot).not.toBeNull() diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index e314383..21e3d8e 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -86,6 +86,8 @@ export interface FinanceDashboardMemberLine { utilityShare: Money purchaseOffset: Money netDue: Money + paid: Money + remaining: Money explanations: readonly string[] } @@ -107,6 +109,8 @@ export interface FinanceDashboard { period: string currency: CurrencyCode totalDue: Money + totalPaid: Money + totalRemaining: Money rentSourceAmount: Money rentDisplayAmount: Money rentFxRateMicros: bigint | null @@ -238,6 +242,7 @@ async function buildFinanceDashboard( dependencies.repository.listParsedPurchasesForRange(start, end), dependencies.repository.listUtilityBillsForCycle(cycle.id) ]) + const paymentRecords = await dependencies.repository.listPaymentRecordsForCycle(cycle.id) const convertedRent = await convertIntoCycleCurrency(dependencies, { cycle, @@ -338,6 +343,14 @@ async function buildFinanceDashboard( }) const memberNameById = new Map(members.map((member) => [member.id, member.displayName])) + const paymentsByMemberId = new Map() + 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) => ({ memberId: line.memberId.toString(), displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(), @@ -345,6 +358,10 @@ async function buildFinanceDashboard( utilityShare: line.utilityShare, purchaseOffset: line.purchaseOffset, 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 })) @@ -389,6 +406,14 @@ async function buildFinanceDashboard( period: cycle.period, currency: cycle.currency, 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, rentDisplayAmount: convertedRent.settlementAmount, rentFxRateMicros: convertedRent.fxRateMicros, @@ -560,7 +585,7 @@ export function createFinanceCommandService( } 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 = @@ -572,7 +597,9 @@ export function createFinanceCommandService( `Statement for ${dashboard.period}`, rentLine, ...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') }, diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index ba09cfb..d4f4555 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -31,3 +31,8 @@ export { type PurchaseParserLlmFallback, type PurchaseParserMode } from './purchase-parser' +export { + createPaymentConfirmationService, + type PaymentConfirmationService, + type PaymentConfirmationSubmitResult +} from './payment-confirmation-service' diff --git a/packages/application/src/payment-confirmation-parser.test.ts b/packages/application/src/payment-confirmation-parser.test.ts new file mode 100644 index 0000000..5fc9713 --- /dev/null +++ b/packages/application/src/payment-confirmation-parser.test.ts @@ -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') + }) +}) diff --git a/packages/application/src/payment-confirmation-parser.ts b/packages/application/src/payment-confirmation-parser.ts new file mode 100644 index 0000000..fe8c551 --- /dev/null +++ b/packages/application/src/payment-confirmation-parser.ts @@ -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' + } +} diff --git a/packages/application/src/payment-confirmation-service.test.ts b/packages/application/src/payment-confirmation-service.test.ts new file mode 100644 index 0000000..9b060ae --- /dev/null +++ b/packages/application/src/payment-confirmation-service.test.ts @@ -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 = { + 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 { + 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' + }) + }) +}) diff --git a/packages/application/src/payment-confirmation-service.ts b/packages/application/src/payment-confirmation-service.ts new file mode 100644 index 0000000..f9437a7 --- /dev/null +++ b/packages/application/src/payment-confirmation-service.ts @@ -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 + 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 +} + +export function createPaymentConfirmationService(input: { + householdId: string + financeService: Pick + 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 + ) + } + } + } +} diff --git a/packages/db/drizzle-checksums.json b/packages/db/drizzle-checksums.json index c0d0b55..e246283 100644 --- a/packages/db/drizzle-checksums.json +++ b/packages/db/drizzle-checksums.json @@ -13,6 +13,7 @@ "0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087", "0010_wild_molecule_man.sql": "46027a6ac770cdc2efd4c3eb5bb53f09d1b852c70fdc46a2434e5a7064587245", "0011_previous_ezekiel_stane.sql": "d996e64d3854de22e36dedeaa94e46774399163d90263bbb05e0b9199af79b70", - "0012_clumsy_maestro.sql": "173797fb435c6acd7c268c624942d6f19a887c329bcef409a3dde1249baaeb8a" + "0012_clumsy_maestro.sql": "173797fb435c6acd7c268c624942d6f19a887c329bcef409a3dde1249baaeb8a", + "0013_wild_avengers.sql": "76254db09c9d623134712aee57a5896aa4a5b416e45d0f6c69dec1fec5b32af4" } } diff --git a/packages/db/drizzle/0013_wild_avengers.sql b/packages/db/drizzle/0013_wild_avengers.sql new file mode 100644 index 0000000..095dc34 --- /dev/null +++ b/packages/db/drizzle/0013_wild_avengers.sql @@ -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"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0013_snapshot.json b/packages/db/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..d6d7dbf --- /dev/null +++ b/packages/db/drizzle/meta/0013_snapshot.json @@ -0,0 +1,2945 @@ +{ + "id": "d21fa4de-b88e-45fd-baa7-6b731edc2f9e", + "prevId": "d5fd7c48-cafb-43f3-84df-8fed4d12aeff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.anonymous_messages": { + "name": "anonymous_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submitted_by_member_id": { + "name": "submitted_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sanitized_text": { + "name": "sanitized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderation_reason": { + "name": "moderation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "posted_chat_id": { + "name": "posted_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_thread_id": { + "name": "posted_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_message_id": { + "name": "posted_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "anonymous_messages_household_tg_update_unique": { + "name": "anonymous_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_member_created_idx": { + "name": "anonymous_messages_member_created_idx", + "columns": [ + { + "expression": "submitted_by_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_status_created_idx": { + "name": "anonymous_messages_status_created_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anonymous_messages_household_id_households_id_fk": { + "name": "anonymous_messages_household_id_households_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "anonymous_messages_submitted_by_member_id_members_id_fk": { + "name": "anonymous_messages_submitted_by_member_id_members_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "members", + "columnsFrom": ["submitted_by_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_cycle_exchange_rates": { + "name": "billing_cycle_exchange_rates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_currency": { + "name": "source_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_currency": { + "name": "target_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate_micros": { + "name": "rate_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "effective_date": { + "name": "effective_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'nbg'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycle_exchange_rates_cycle_pair_unique": { + "name": "billing_cycle_exchange_rates_cycle_pair_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycle_exchange_rates_cycle_idx": { + "name": "billing_cycle_exchange_rates_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycle_exchange_rates_cycle_id_billing_cycles_id_fk": { + "name": "billing_cycle_exchange_rates_cycle_id_billing_cycles_id_fk", + "tableFrom": "billing_cycle_exchange_rates", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_cycles": { + "name": "billing_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycles_household_period_unique": { + "name": "billing_cycles_household_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycles_household_period_idx": { + "name": "billing_cycles_household_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycles_household_id_households_id_fk": { + "name": "billing_cycles_household_id_households_id_fk", + "tableFrom": "billing_cycles", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_billing_settings": { + "name": "household_billing_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "settlement_currency": { + "name": "settlement_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'GEL'" + }, + "rent_amount_minor": { + "name": "rent_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "rent_currency": { + "name": "rent_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "rent_due_day": { + "name": "rent_due_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "rent_warning_day": { + "name": "rent_warning_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 17 + }, + "utilities_due_day": { + "name": "utilities_due_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "utilities_reminder_day": { + "name": "utilities_reminder_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Asia/Tbilisi'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_billing_settings_household_unique": { + "name": "household_billing_settings_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_billing_settings_household_id_households_id_fk": { + "name": "household_billing_settings_household_id_households_id_fk", + "tableFrom": "household_billing_settings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_join_tokens": { + "name": "household_join_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_telegram_user_id": { + "name": "created_by_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_join_tokens_household_unique": { + "name": "household_join_tokens_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_join_tokens_token_unique": { + "name": "household_join_tokens_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_join_tokens_household_id_households_id_fk": { + "name": "household_join_tokens_household_id_households_id_fk", + "tableFrom": "household_join_tokens", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_pending_members": { + "name": "household_pending_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_pending_members_household_user_unique": { + "name": "household_pending_members_household_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_pending_members_telegram_user_idx": { + "name": "household_pending_members_telegram_user_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_pending_members_household_id_households_id_fk": { + "name": "household_pending_members_household_id_households_id_fk", + "tableFrom": "household_pending_members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_telegram_chats": { + "name": "household_telegram_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_type": { + "name": "telegram_chat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_telegram_chats_household_unique": { + "name": "household_telegram_chats_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_telegram_chats_chat_unique": { + "name": "household_telegram_chats_chat_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_telegram_chats_household_id_households_id_fk": { + "name": "household_telegram_chats_household_id_households_id_fk", + "tableFrom": "household_telegram_chats", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_topic_bindings": { + "name": "household_topic_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_name": { + "name": "topic_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_topic_bindings_household_role_unique": { + "name": "household_topic_bindings_household_role_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_thread_unique": { + "name": "household_topic_bindings_household_thread_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_role_idx": { + "name": "household_topic_bindings_household_role_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_topic_bindings_household_id_households_id_fk": { + "name": "household_topic_bindings_household_id_households_id_fk", + "tableFrom": "household_topic_bindings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_utility_categories": { + "name": "household_utility_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_utility_categories_household_slug_unique": { + "name": "household_utility_categories_household_slug_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_utility_categories_household_sort_idx": { + "name": "household_utility_categories_household_sort_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_utility_categories_household_id_households_id_fk": { + "name": "household_utility_categories_household_id_households_id_fk", + "tableFrom": "household_utility_categories", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rent_share_weight": { + "name": "rent_share_weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_household_idx": { + "name": "members_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_household_tg_user_unique": { + "name": "members_household_tg_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_household_id_households_id_fk": { + "name": "members_household_id_households_id_fk", + "tableFrom": "members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_confirmations": { + "name": "payment_confirmations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detected_kind": { + "name": "detected_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "explicit_amount_minor": { + "name": "explicit_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "explicit_currency": { + "name": "explicit_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_amount_minor": { + "name": "resolved_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "resolved_currency": { + "name": "resolved_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_count": { + "name": "attachment_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_confirmations_household_tg_message_unique": { + "name": "payment_confirmations_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_confirmations_household_tg_update_unique": { + "name": "payment_confirmations_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_confirmations_household_status_idx": { + "name": "payment_confirmations_household_status_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_confirmations_member_created_idx": { + "name": "payment_confirmations_member_created_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_confirmations_household_id_households_id_fk": { + "name": "payment_confirmations_household_id_households_id_fk", + "tableFrom": "payment_confirmations", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_confirmations_cycle_id_billing_cycles_id_fk": { + "name": "payment_confirmations_cycle_id_billing_cycles_id_fk", + "tableFrom": "payment_confirmations", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "payment_confirmations_member_id_members_id_fk": { + "name": "payment_confirmations_member_id_members_id_fk", + "tableFrom": "payment_confirmations", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_records": { + "name": "payment_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confirmation_id": { + "name": "confirmation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_records_cycle_member_idx": { + "name": "payment_records_cycle_member_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_records_cycle_kind_idx": { + "name": "payment_records_cycle_kind_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_records_confirmation_unique": { + "name": "payment_records_confirmation_unique", + "columns": [ + { + "expression": "confirmation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_records_household_id_households_id_fk": { + "name": "payment_records_household_id_households_id_fk", + "tableFrom": "payment_records", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_records_cycle_id_billing_cycles_id_fk": { + "name": "payment_records_cycle_id_billing_cycles_id_fk", + "tableFrom": "payment_records", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_records_member_id_members_id_fk": { + "name": "payment_records_member_id_members_id_fk", + "tableFrom": "payment_records", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "payment_records_confirmation_id_payment_confirmations_id_fk": { + "name": "payment_records_confirmation_id_payment_confirmations_id_fk", + "tableFrom": "payment_records", + "tableTo": "payment_confirmations", + "columnsFrom": ["confirmation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_overrides": { + "name": "presence_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "utility_days": { + "name": "utility_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presence_overrides_cycle_member_unique": { + "name": "presence_overrides_cycle_member_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presence_overrides_cycle_idx": { + "name": "presence_overrides_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presence_overrides_cycle_id_billing_cycles_id_fk": { + "name": "presence_overrides_cycle_id_billing_cycles_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presence_overrides_member_id_members_id_fk": { + "name": "presence_overrides_member_id_members_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_bot_messages": { + "name": "processed_bot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_key": { + "name": "source_message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "processed_bot_messages_source_message_unique": { + "name": "processed_bot_messages_source_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processed_bot_messages_household_id_households_id_fk": { + "name": "processed_bot_messages_household_id_households_id_fk", + "tableFrom": "processed_bot_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_entries": { + "name": "purchase_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_entries_household_cycle_idx": { + "name": "purchase_entries_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_payer_idx": { + "name": "purchase_entries_payer_idx", + "columns": [ + { + "expression": "payer_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_household_tg_message_unique": { + "name": "purchase_entries_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_entries_household_id_households_id_fk": { + "name": "purchase_entries_household_id_households_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_entries_cycle_id_billing_cycles_id_fk": { + "name": "purchase_entries_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "purchase_entries_payer_member_id_members_id_fk": { + "name": "purchase_entries_payer_member_id_members_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "members", + "columnsFrom": ["payer_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_messages": { + "name": "purchase_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parsed_amount_minor": { + "name": "parsed_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "parsed_currency": { + "name": "parsed_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_item_description": { + "name": "parsed_item_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parser_error": { + "name": "parser_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_messages_household_thread_idx": { + "name": "purchase_messages_household_thread_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_sender_idx": { + "name": "purchase_messages_sender_idx", + "columns": [ + { + "expression": "sender_telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_message_unique": { + "name": "purchase_messages_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_update_unique": { + "name": "purchase_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_messages_household_id_households_id_fk": { + "name": "purchase_messages_household_id_households_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_messages_sender_member_id_members_id_fk": { + "name": "purchase_messages_sender_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["sender_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_rules": { + "name": "rent_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_to_period": { + "name": "effective_to_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rent_rules_household_from_period_unique": { + "name": "rent_rules_household_from_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rent_rules_household_from_period_idx": { + "name": "rent_rules_household_from_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rent_rules_household_id_households_id_fk": { + "name": "rent_rules_household_id_households_id_fk", + "tableFrom": "rent_rules", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlement_lines": { + "name": "settlement_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_share_minor": { + "name": "rent_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "utility_share_minor": { + "name": "utility_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "purchase_offset_minor": { + "name": "purchase_offset_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "net_due_minor": { + "name": "net_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "explanations": { + "name": "explanations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlement_lines_settlement_member_unique": { + "name": "settlement_lines_settlement_member_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlement_lines_settlement_idx": { + "name": "settlement_lines_settlement_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlement_lines_settlement_id_settlements_id_fk": { + "name": "settlement_lines_settlement_id_settlements_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "settlements", + "columnsFrom": ["settlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlement_lines_member_id_members_id_fk": { + "name": "settlement_lines_member_id_members_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_due_minor": { + "name": "total_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "settlements_cycle_unique": { + "name": "settlements_cycle_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_household_computed_idx": { + "name": "settlements_household_computed_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_household_id_households_id_fk": { + "name": "settlements_household_id_households_id_fk", + "tableFrom": "settlements", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlements_cycle_id_billing_cycles_id_fk": { + "name": "settlements_cycle_id_billing_cycles_id_fk", + "tableFrom": "settlements", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_pending_actions": { + "name": "telegram_pending_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_pending_actions_chat_user_unique": { + "name": "telegram_pending_actions_chat_user_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "telegram_pending_actions_user_action_idx": { + "name": "telegram_pending_actions_user_action_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.utility_bills": { + "name": "utility_bills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bill_name": { + "name": "bill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "utility_bills_cycle_idx": { + "name": "utility_bills_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "utility_bills_household_cycle_idx": { + "name": "utility_bills_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "utility_bills_household_id_households_id_fk": { + "name": "utility_bills_household_id_households_id_fk", + "tableFrom": "utility_bills", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_cycle_id_billing_cycles_id_fk": { + "name": "utility_bills_cycle_id_billing_cycles_id_fk", + "tableFrom": "utility_bills", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_created_by_member_id_members_id_fk": { + "name": "utility_bills_created_by_member_id_members_id_fk", + "tableFrom": "utility_bills", + "tableTo": "members", + "columnsFrom": ["created_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 8d70803..f9af1e4 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1773146577992, "tag": "0012_clumsy_maestro", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1773147481265, + "tag": "0013_wild_avengers", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 00236ad..ac7775d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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( 'settlements', { @@ -548,4 +625,6 @@ export type UtilityBill = typeof utilityBills.$inferSelect export type PurchaseEntry = typeof purchaseEntries.$inferSelect export type PurchaseMessage = typeof purchaseMessages.$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 diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts index 09f275c..5512e60 100644 --- a/packages/ports/src/finance.ts +++ b/packages/ports/src/finance.ts @@ -46,6 +46,83 @@ export interface FinanceUtilityBillRecord { 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 { memberId: string rentShareMinor: bigint @@ -91,9 +168,16 @@ export interface FinanceRepository { getRentRuleForPeriod(period: string): Promise getUtilityTotalForCycle(cycleId: string): Promise listUtilityBillsForCycle(cycleId: string): Promise + listPaymentRecordsForCycle(cycleId: string): Promise listParsedPurchasesForRange( start: Instant, end: Instant ): Promise + getSettlementSnapshotLines( + cycleId: string + ): Promise + savePaymentConfirmation( + input: FinancePaymentConfirmationSaveInput + ): Promise replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise } diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts index 9863f21..cc0e35b 100644 --- a/packages/ports/src/household-config.ts +++ b/packages/ports/src/household-config.ts @@ -1,7 +1,7 @@ import type { CurrencyCode, SupportedLocale } from '@household/domain' 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] diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 883455f..e4597f4 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -31,6 +31,12 @@ export type { export type { FinanceCycleRecord, FinanceCycleExchangeRateRecord, + FinancePaymentConfirmationReviewReason, + FinancePaymentConfirmationSaveInput, + FinancePaymentConfirmationSaveResult, + FinancePaymentKind, + FinancePaymentRecord, + FinanceSettlementSnapshotLineRecord, FinanceMemberRecord, FinanceParsedPurchaseRecord, FinanceRentRuleRecord,