mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
feat(payments): track household payment confirmations
This commit is contained in:
@@ -67,7 +67,7 @@ function bindRejectionMessage(
|
|||||||
|
|
||||||
function bindTopicUsageMessage(
|
function bindTopicUsageMessage(
|
||||||
locale: BotLocale,
|
locale: BotLocale,
|
||||||
role: 'purchase' | 'feedback' | 'reminders'
|
role: 'purchase' | 'feedback' | 'reminders' | 'payments'
|
||||||
): string {
|
): string {
|
||||||
const t = getBotTranslations(locale).setup
|
const t = getBotTranslations(locale).setup
|
||||||
|
|
||||||
@@ -78,12 +78,14 @@ function bindTopicUsageMessage(
|
|||||||
return t.useBindFeedbackTopicInGroup
|
return t.useBindFeedbackTopicInGroup
|
||||||
case 'reminders':
|
case 'reminders':
|
||||||
return t.useBindRemindersTopicInGroup
|
return t.useBindRemindersTopicInGroup
|
||||||
|
case 'payments':
|
||||||
|
return t.useBindPaymentsTopicInGroup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindTopicSuccessMessage(
|
function bindTopicSuccessMessage(
|
||||||
locale: BotLocale,
|
locale: BotLocale,
|
||||||
role: 'purchase' | 'feedback' | 'reminders',
|
role: 'purchase' | 'feedback' | 'reminders' | 'payments',
|
||||||
householdName: string,
|
householdName: string,
|
||||||
threadId: string
|
threadId: string
|
||||||
): string {
|
): string {
|
||||||
@@ -96,6 +98,8 @@ function bindTopicSuccessMessage(
|
|||||||
return t.feedbackTopicSaved(householdName, threadId)
|
return t.feedbackTopicSaved(householdName, threadId)
|
||||||
case 'reminders':
|
case 'reminders':
|
||||||
return t.remindersTopicSaved(householdName, threadId)
|
return t.remindersTopicSaved(householdName, threadId)
|
||||||
|
case 'payments':
|
||||||
|
return t.paymentsTopicSaved(householdName, threadId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +222,7 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
}): void {
|
}): void {
|
||||||
async function handleBindTopicCommand(
|
async function handleBindTopicCommand(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
role: 'purchase' | 'feedback' | 'reminders'
|
role: 'purchase' | 'feedback' | 'reminders' | 'payments'
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const locale = await resolveReplyLocale({
|
const locale = await resolveReplyLocale({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -455,6 +459,10 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
await handleBindTopicCommand(ctx, 'reminders')
|
await handleBindTopicCommand(ctx, 'reminders')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
options.bot.command('bind_payments_topic', async (ctx) => {
|
||||||
|
await handleBindTopicCommand(ctx, 'payments')
|
||||||
|
})
|
||||||
|
|
||||||
options.bot.command('pending_members', async (ctx) => {
|
options.bot.command('pending_members', async (ctx) => {
|
||||||
const locale = await resolveReplyLocale({
|
const locale = await resolveReplyLocale({
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
bind_purchase_topic: 'Bind the current topic as purchases',
|
bind_purchase_topic: 'Bind the current topic as purchases',
|
||||||
bind_feedback_topic: 'Bind the current topic as feedback',
|
bind_feedback_topic: 'Bind the current topic as feedback',
|
||||||
bind_reminders_topic: 'Bind the current topic as reminders',
|
bind_reminders_topic: 'Bind the current topic as reminders',
|
||||||
|
bind_payments_topic: 'Bind the current topic as payments',
|
||||||
pending_members: 'List pending household join requests',
|
pending_members: 'List pending household join requests',
|
||||||
approve_member: 'Approve a pending household member'
|
approve_member: 'Approve a pending household member'
|
||||||
},
|
},
|
||||||
@@ -53,7 +54,7 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
[
|
[
|
||||||
`Household ${created ? 'created' : 'already registered'}: ${householdName}`,
|
`Household ${created ? 'created' : 'already registered'}: ${householdName}`,
|
||||||
`Chat ID: ${telegramChatId}`,
|
`Chat ID: ${telegramChatId}`,
|
||||||
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic. If you want a dedicated reminders topic, open it and run /bind_reminders_topic.',
|
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic. If you want dedicated reminders or payments topics, open them and run /bind_reminders_topic or /bind_payments_topic.',
|
||||||
'Members should open the bot chat from the button below and confirm the join request there.'
|
'Members should open the bot chat from the button below and confirm the join request there.'
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.',
|
useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.',
|
||||||
@@ -65,6 +66,9 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
useBindRemindersTopicInGroup: 'Use /bind_reminders_topic inside the household group topic.',
|
useBindRemindersTopicInGroup: 'Use /bind_reminders_topic inside the household group topic.',
|
||||||
remindersTopicSaved: (householdName, threadId) =>
|
remindersTopicSaved: (householdName, threadId) =>
|
||||||
`Reminders topic saved for ${householdName} (thread ${threadId}).`,
|
`Reminders topic saved for ${householdName} (thread ${threadId}).`,
|
||||||
|
useBindPaymentsTopicInGroup: 'Use /bind_payments_topic inside the household group topic.',
|
||||||
|
paymentsTopicSaved: (householdName, threadId) =>
|
||||||
|
`Payments topic saved for ${householdName} (thread ${threadId}).`,
|
||||||
usePendingMembersInGroup: 'Use /pending_members inside the household group.',
|
usePendingMembersInGroup: 'Use /pending_members inside the household group.',
|
||||||
useApproveMemberInGroup: 'Use /approve_member inside the household group.',
|
useApproveMemberInGroup: 'Use /approve_member inside the household group.',
|
||||||
approveMemberUsage: 'Usage: /approve_member <telegram_user_id>',
|
approveMemberUsage: 'Usage: /approve_member <telegram_user_id>',
|
||||||
@@ -147,5 +151,13 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
recorded: (summary) => `Recorded purchase: ${summary}`,
|
recorded: (summary) => `Recorded purchase: ${summary}`,
|
||||||
savedForReview: (summary) => `Saved for review: ${summary}`,
|
savedForReview: (summary) => `Saved for review: ${summary}`,
|
||||||
parseFailed: "Saved for review: I couldn't parse this purchase yet."
|
parseFailed: "Saved for review: I couldn't parse this purchase yet."
|
||||||
|
},
|
||||||
|
payments: {
|
||||||
|
topicMissing:
|
||||||
|
'Payments topic is not configured for this household yet. Ask an admin to run /bind_payments_topic.',
|
||||||
|
recorded: (kind, amount, currency) =>
|
||||||
|
`Recorded ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}`,
|
||||||
|
savedForReview: 'Saved this payment confirmation for review.',
|
||||||
|
duplicate: 'This payment confirmation was already processed.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
bind_purchase_topic: 'Назначить текущий топик для покупок',
|
bind_purchase_topic: 'Назначить текущий топик для покупок',
|
||||||
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
|
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
|
||||||
bind_reminders_topic: 'Назначить текущий топик для напоминаний',
|
bind_reminders_topic: 'Назначить текущий топик для напоминаний',
|
||||||
|
bind_payments_topic: 'Назначить текущий топик для оплат',
|
||||||
pending_members: 'Показать ожидающие заявки на вступление',
|
pending_members: 'Показать ожидающие заявки на вступление',
|
||||||
approve_member: 'Подтвердить участника дома'
|
approve_member: 'Подтвердить участника дома'
|
||||||
},
|
},
|
||||||
@@ -55,7 +56,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
[
|
[
|
||||||
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
|
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
|
||||||
`ID чата: ${telegramChatId}`,
|
`ID чата: ${telegramChatId}`,
|
||||||
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельный топик для напоминаний, откройте его и выполните /bind_reminders_topic.',
|
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельные топики для напоминаний или оплат, откройте их и выполните /bind_reminders_topic или /bind_payments_topic.',
|
||||||
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
|
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.',
|
useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.',
|
||||||
@@ -67,6 +68,9 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
useBindRemindersTopicInGroup: 'Используйте /bind_reminders_topic внутри топика группы дома.',
|
useBindRemindersTopicInGroup: 'Используйте /bind_reminders_topic внутри топика группы дома.',
|
||||||
remindersTopicSaved: (householdName, threadId) =>
|
remindersTopicSaved: (householdName, threadId) =>
|
||||||
`Топик напоминаний сохранён для ${householdName} (тред ${threadId}).`,
|
`Топик напоминаний сохранён для ${householdName} (тред ${threadId}).`,
|
||||||
|
useBindPaymentsTopicInGroup: 'Используйте /bind_payments_topic внутри топика группы дома.',
|
||||||
|
paymentsTopicSaved: (householdName, threadId) =>
|
||||||
|
`Топик оплат сохранён для ${householdName} (тред ${threadId}).`,
|
||||||
usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.',
|
usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.',
|
||||||
useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.',
|
useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.',
|
||||||
approveMemberUsage: 'Использование: /approve_member <telegram_user_id>',
|
approveMemberUsage: 'Использование: /approve_member <telegram_user_id>',
|
||||||
@@ -150,5 +154,13 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
recorded: (summary) => `Покупка сохранена: ${summary}`,
|
recorded: (summary) => `Покупка сохранена: ${summary}`,
|
||||||
savedForReview: (summary) => `Сохранено на проверку: ${summary}`,
|
savedForReview: (summary) => `Сохранено на проверку: ${summary}`,
|
||||||
parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.'
|
parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.'
|
||||||
|
},
|
||||||
|
payments: {
|
||||||
|
topicMissing:
|
||||||
|
'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.',
|
||||||
|
recorded: (kind, amount, currency) =>
|
||||||
|
`Оплата ${kind === 'rent' ? 'аренды' : 'коммуналки'} сохранена: ${amount} ${currency}`,
|
||||||
|
savedForReview: 'Это подтверждение оплаты сохранено на проверку.',
|
||||||
|
duplicate: 'Это подтверждение оплаты уже было обработано.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type TelegramCommandName =
|
|||||||
| 'bind_purchase_topic'
|
| 'bind_purchase_topic'
|
||||||
| 'bind_feedback_topic'
|
| 'bind_feedback_topic'
|
||||||
| 'bind_reminders_topic'
|
| 'bind_reminders_topic'
|
||||||
|
| 'bind_payments_topic'
|
||||||
| 'pending_members'
|
| 'pending_members'
|
||||||
| 'approve_member'
|
| 'approve_member'
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export interface BotCommandDescriptions {
|
|||||||
bind_purchase_topic: string
|
bind_purchase_topic: string
|
||||||
bind_feedback_topic: string
|
bind_feedback_topic: string
|
||||||
bind_reminders_topic: string
|
bind_reminders_topic: string
|
||||||
|
bind_payments_topic: string
|
||||||
pending_members: string
|
pending_members: string
|
||||||
approve_member: string
|
approve_member: string
|
||||||
}
|
}
|
||||||
@@ -77,6 +79,8 @@ export interface BotTranslationCatalog {
|
|||||||
feedbackTopicSaved: (householdName: string, threadId: string) => string
|
feedbackTopicSaved: (householdName: string, threadId: string) => string
|
||||||
useBindRemindersTopicInGroup: string
|
useBindRemindersTopicInGroup: string
|
||||||
remindersTopicSaved: (householdName: string, threadId: string) => string
|
remindersTopicSaved: (householdName: string, threadId: string) => string
|
||||||
|
useBindPaymentsTopicInGroup: string
|
||||||
|
paymentsTopicSaved: (householdName: string, threadId: string) => string
|
||||||
usePendingMembersInGroup: string
|
usePendingMembersInGroup: string
|
||||||
useApproveMemberInGroup: string
|
useApproveMemberInGroup: string
|
||||||
approveMemberUsage: string
|
approveMemberUsage: string
|
||||||
@@ -153,4 +157,10 @@ export interface BotTranslationCatalog {
|
|||||||
savedForReview: (summary: string) => string
|
savedForReview: (summary: string) => string
|
||||||
parseFailed: string
|
parseFailed: string
|
||||||
}
|
}
|
||||||
|
payments: {
|
||||||
|
topicMissing: string
|
||||||
|
recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
|
||||||
|
savedForReview: string
|
||||||
|
duplicate: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
createLocalePreferenceService,
|
createLocalePreferenceService,
|
||||||
createMiniAppAdminService,
|
createMiniAppAdminService,
|
||||||
createHouseholdSetupService,
|
createHouseholdSetupService,
|
||||||
createReminderJobService
|
createReminderJobService,
|
||||||
|
createPaymentConfirmationService
|
||||||
} from '@household/application'
|
} from '@household/application'
|
||||||
import {
|
import {
|
||||||
createDbAnonymousFeedbackRepository,
|
createDbAnonymousFeedbackRepository,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
createPurchaseMessageRepository,
|
createPurchaseMessageRepository,
|
||||||
registerConfiguredPurchaseTopicIngestion
|
registerConfiguredPurchaseTopicIngestion
|
||||||
} from './purchase-topic-ingestion'
|
} from './purchase-topic-ingestion'
|
||||||
|
import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion'
|
||||||
import { createReminderJobsHandler } from './reminder-jobs'
|
import { createReminderJobsHandler } from './reminder-jobs'
|
||||||
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
||||||
import { createBotWebhookServer } from './server'
|
import { createBotWebhookServer } from './server'
|
||||||
@@ -72,6 +74,10 @@ const bot = createTelegramBot(
|
|||||||
const webhookHandler = webhookCallback(bot, 'std/http')
|
const webhookHandler = webhookCallback(bot, 'std/http')
|
||||||
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
|
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
|
||||||
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
|
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
|
||||||
|
const paymentConfirmationServices = new Map<
|
||||||
|
string,
|
||||||
|
ReturnType<typeof createPaymentConfirmationService>
|
||||||
|
>()
|
||||||
const exchangeRateProvider = createNbgExchangeRateProvider({
|
const exchangeRateProvider = createNbgExchangeRateProvider({
|
||||||
logger: getLogger('fx')
|
logger: getLogger('fx')
|
||||||
})
|
})
|
||||||
@@ -105,10 +111,7 @@ function financeServiceForHousehold(householdId: string) {
|
|||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
|
||||||
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId)
|
const repositoryClient = financeRepositoryForHousehold(householdId)
|
||||||
financeRepositoryClients.set(householdId, repositoryClient)
|
|
||||||
shutdownTasks.push(repositoryClient.close)
|
|
||||||
|
|
||||||
const service = createFinanceCommandService({
|
const service = createFinanceCommandService({
|
||||||
householdId,
|
householdId,
|
||||||
repository: repositoryClient.repository,
|
repository: repositoryClient.repository,
|
||||||
@@ -119,6 +122,35 @@ function financeServiceForHousehold(householdId: string) {
|
|||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function financeRepositoryForHousehold(householdId: string) {
|
||||||
|
const existing = financeRepositoryClients.get(householdId)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId)
|
||||||
|
financeRepositoryClients.set(householdId, repositoryClient)
|
||||||
|
shutdownTasks.push(repositoryClient.close)
|
||||||
|
return repositoryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentConfirmationServiceForHousehold(householdId: string) {
|
||||||
|
const existing = paymentConfirmationServices.get(householdId)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createPaymentConfirmationService({
|
||||||
|
householdId,
|
||||||
|
financeService: financeServiceForHousehold(householdId),
|
||||||
|
repository: financeRepositoryForHousehold(householdId).repository,
|
||||||
|
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
|
||||||
|
exchangeRateProvider
|
||||||
|
})
|
||||||
|
paymentConfirmationServices.set(householdId, service)
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
function anonymousFeedbackServiceForHousehold(householdId: string) {
|
function anonymousFeedbackServiceForHousehold(householdId: string) {
|
||||||
const existing = anonymousFeedbackServices.get(householdId)
|
const existing = anonymousFeedbackServices.get(householdId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -160,6 +192,15 @@ if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
|
|||||||
logger: getLogger('purchase-ingestion')
|
logger: getLogger('purchase-ingestion')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
registerConfiguredPaymentTopicIngestion(
|
||||||
|
bot,
|
||||||
|
householdConfigurationRepositoryClient.repository,
|
||||||
|
paymentConfirmationServiceForHousehold,
|
||||||
|
{
|
||||||
|
logger: getLogger('payment-ingestion')
|
||||||
|
}
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function repository(
|
|||||||
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
listPaymentRecordsForCycle: async () => [],
|
||||||
listParsedPurchasesForRange: async () => [
|
listParsedPurchasesForRange: async () => [
|
||||||
{
|
{
|
||||||
id: 'purchase-1',
|
id: 'purchase-1',
|
||||||
@@ -71,6 +72,12 @@ function repository(
|
|||||||
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
|
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
getSettlementSnapshotLines: async () => [],
|
||||||
|
savePaymentConfirmation: async () =>
|
||||||
|
({
|
||||||
|
status: 'needs_review',
|
||||||
|
reviewReason: 'settlement_not_ready'
|
||||||
|
}) as const,
|
||||||
replaceSettlementSnapshot: async () => {}
|
replaceSettlementSnapshot: async () => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export function createMiniAppDashboardHandler(options: {
|
|||||||
period: dashboard.period,
|
period: dashboard.period,
|
||||||
currency: dashboard.currency,
|
currency: dashboard.currency,
|
||||||
totalDueMajor: dashboard.totalDue.toMajorString(),
|
totalDueMajor: dashboard.totalDue.toMajorString(),
|
||||||
|
totalPaidMajor: dashboard.totalPaid.toMajorString(),
|
||||||
|
totalRemainingMajor: dashboard.totalRemaining.toMajorString(),
|
||||||
rentSourceAmountMajor: dashboard.rentSourceAmount.toMajorString(),
|
rentSourceAmountMajor: dashboard.rentSourceAmount.toMajorString(),
|
||||||
rentSourceCurrency: dashboard.rentSourceAmount.currency,
|
rentSourceCurrency: dashboard.rentSourceAmount.currency,
|
||||||
rentDisplayAmountMajor: dashboard.rentDisplayAmount.toMajorString(),
|
rentDisplayAmountMajor: dashboard.rentDisplayAmount.toMajorString(),
|
||||||
@@ -101,6 +103,8 @@ export function createMiniAppDashboardHandler(options: {
|
|||||||
utilityShareMajor: line.utilityShare.toMajorString(),
|
utilityShareMajor: line.utilityShare.toMajorString(),
|
||||||
purchaseOffsetMajor: line.purchaseOffset.toMajorString(),
|
purchaseOffsetMajor: line.purchaseOffset.toMajorString(),
|
||||||
netDueMajor: line.netDue.toMajorString(),
|
netDueMajor: line.netDue.toMajorString(),
|
||||||
|
paidMajor: line.paid.toMajorString(),
|
||||||
|
remainingMajor: line.remaining.toMajorString(),
|
||||||
explanations: line.explanations
|
explanations: line.explanations
|
||||||
})),
|
})),
|
||||||
ledger: dashboard.ledger.map((entry) => ({
|
ledger: dashboard.ledger.map((entry) => ({
|
||||||
|
|||||||
169
apps/bot/src/payment-topic-ingestion.test.ts
Normal file
169
apps/bot/src/payment-topic-ingestion.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { instantFromIso, Money } from '@household/domain'
|
||||||
|
import { createTelegramBot } from './bot'
|
||||||
|
import {
|
||||||
|
buildPaymentAcknowledgement,
|
||||||
|
registerConfiguredPaymentTopicIngestion,
|
||||||
|
resolveConfiguredPaymentTopicRecord,
|
||||||
|
type PaymentTopicCandidate
|
||||||
|
} from './payment-topic-ingestion'
|
||||||
|
|
||||||
|
function candidate(overrides: Partial<PaymentTopicCandidate> = {}): PaymentTopicCandidate {
|
||||||
|
return {
|
||||||
|
updateId: 1,
|
||||||
|
chatId: '-10012345',
|
||||||
|
messageId: '10',
|
||||||
|
threadId: '888',
|
||||||
|
senderTelegramUserId: '10002',
|
||||||
|
rawText: 'за жилье закинул',
|
||||||
|
attachmentCount: 0,
|
||||||
|
messageSentAt: instantFromIso('2026-03-20T00:00:00.000Z'),
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentUpdate(text: string) {
|
||||||
|
return {
|
||||||
|
update_id: 1001,
|
||||||
|
message: {
|
||||||
|
message_id: 55,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
message_thread_id: 888,
|
||||||
|
is_topic_message: true,
|
||||||
|
chat: {
|
||||||
|
id: -10012345,
|
||||||
|
type: 'supergroup'
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
id: 10002,
|
||||||
|
is_bot: false,
|
||||||
|
first_name: 'Mia'
|
||||||
|
},
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveConfiguredPaymentTopicRecord', () => {
|
||||||
|
test('returns record when the topic role is payments', () => {
|
||||||
|
const record = resolveConfiguredPaymentTopicRecord(candidate(), {
|
||||||
|
householdId: 'household-1',
|
||||||
|
role: 'payments',
|
||||||
|
telegramThreadId: '888',
|
||||||
|
topicName: 'Быт'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(record).not.toBeNull()
|
||||||
|
expect(record?.householdId).toBe('household-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skips non-payments topic bindings', () => {
|
||||||
|
const record = resolveConfiguredPaymentTopicRecord(candidate(), {
|
||||||
|
householdId: 'household-1',
|
||||||
|
role: 'feedback',
|
||||||
|
telegramThreadId: '888',
|
||||||
|
topicName: 'Анонимно'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(record).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildPaymentAcknowledgement', () => {
|
||||||
|
test('returns localized recorded acknowledgement', () => {
|
||||||
|
expect(
|
||||||
|
buildPaymentAcknowledgement('ru', {
|
||||||
|
status: 'recorded',
|
||||||
|
kind: 'rent',
|
||||||
|
amountMajor: '472.50',
|
||||||
|
currency: 'GEL'
|
||||||
|
})
|
||||||
|
).toBe('Оплата аренды сохранена: 472.50 GEL')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns review acknowledgement', () => {
|
||||||
|
expect(
|
||||||
|
buildPaymentAcknowledgement('en', {
|
||||||
|
status: 'needs_review'
|
||||||
|
})
|
||||||
|
).toBe('Saved this payment confirmation for review.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('registerConfiguredPaymentTopicIngestion', () => {
|
||||||
|
test('replies in-topic after a payment confirmation is recorded', async () => {
|
||||||
|
const bot = createTelegramBot('000000:test-token')
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.botInfo = {
|
||||||
|
id: 999000,
|
||||||
|
is_bot: true,
|
||||||
|
first_name: 'Household Test Bot',
|
||||||
|
username: 'household_test_bot',
|
||||||
|
can_join_groups: true,
|
||||||
|
can_read_all_group_messages: false,
|
||||||
|
supports_inline_queries: false,
|
||||||
|
can_connect_to_business: false,
|
||||||
|
has_main_web_app: false,
|
||||||
|
has_topics_enabled: true,
|
||||||
|
allows_users_to_create_topics: false
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
message_id: calls.length,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: -10012345,
|
||||||
|
type: 'supergroup'
|
||||||
|
},
|
||||||
|
text: 'ok'
|
||||||
|
}
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
registerConfiguredPaymentTopicIngestion(
|
||||||
|
bot,
|
||||||
|
{
|
||||||
|
getHouseholdChatByHouseholdId: async () => ({
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Test bot',
|
||||||
|
telegramChatId: '-10012345',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Test bot',
|
||||||
|
defaultLocale: 'ru'
|
||||||
|
}),
|
||||||
|
findHouseholdTopicByTelegramContext: async () => ({
|
||||||
|
householdId: 'household-1',
|
||||||
|
role: 'payments',
|
||||||
|
telegramThreadId: '888',
|
||||||
|
topicName: 'Быт'
|
||||||
|
})
|
||||||
|
} as never,
|
||||||
|
() => ({
|
||||||
|
submit: async () => ({
|
||||||
|
status: 'recorded',
|
||||||
|
kind: 'rent',
|
||||||
|
amount: Money.fromMajor('472.50', 'GEL')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.handleUpdate(paymentUpdate('за жилье закинул') as never)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0]?.method).toBe('sendMessage')
|
||||||
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
|
chat_id: -10012345,
|
||||||
|
reply_parameters: {
|
||||||
|
message_id: 55
|
||||||
|
},
|
||||||
|
text: 'Оплата аренды сохранена: 472.50 GEL'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
225
apps/bot/src/payment-topic-ingestion.ts
Normal file
225
apps/bot/src/payment-topic-ingestion.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import type { PaymentConfirmationService } from '@household/application'
|
||||||
|
import { instantFromEpochSeconds, type Instant } from '@household/domain'
|
||||||
|
import type { Bot, Context } from 'grammy'
|
||||||
|
import type { Logger } from '@household/observability'
|
||||||
|
import type {
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdTopicBindingRecord
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
import { getBotTranslations, type BotLocale } from './i18n'
|
||||||
|
|
||||||
|
export interface PaymentTopicCandidate {
|
||||||
|
updateId: number
|
||||||
|
chatId: string
|
||||||
|
messageId: string
|
||||||
|
threadId: string
|
||||||
|
senderTelegramUserId: string
|
||||||
|
rawText: string
|
||||||
|
attachmentCount: number
|
||||||
|
messageSentAt: Instant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentTopicRecord extends PaymentTopicCandidate {
|
||||||
|
householdId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessageText(ctx: Context): string | null {
|
||||||
|
const message = ctx.message
|
||||||
|
if (!message) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('text' in message && typeof message.text === 'string') {
|
||||||
|
return message.text
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('caption' in message && typeof message.caption === 'string') {
|
||||||
|
return message.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentCount(ctx: Context): number {
|
||||||
|
const message = ctx.message
|
||||||
|
if (!message) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('photo' in message && Array.isArray(message.photo)) {
|
||||||
|
return message.photo.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('document' in message && message.document) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCandidateFromContext(ctx: Context): PaymentTopicCandidate | null {
|
||||||
|
const message = ctx.message
|
||||||
|
const rawText = readMessageText(ctx)
|
||||||
|
if (!message || !rawText) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('is_topic_message' in message) || message.is_topic_message !== true) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('message_thread_id' in message) || message.message_thread_id === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderTelegramUserId = ctx.from?.id?.toString()
|
||||||
|
if (!senderTelegramUserId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateId: ctx.update.update_id,
|
||||||
|
chatId: message.chat.id.toString(),
|
||||||
|
messageId: message.message_id.toString(),
|
||||||
|
threadId: message.message_thread_id.toString(),
|
||||||
|
senderTelegramUserId,
|
||||||
|
rawText,
|
||||||
|
attachmentCount: attachmentCount(ctx),
|
||||||
|
messageSentAt: instantFromEpochSeconds(message.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredPaymentTopicRecord(
|
||||||
|
value: PaymentTopicCandidate,
|
||||||
|
binding: HouseholdTopicBindingRecord
|
||||||
|
): PaymentTopicRecord | null {
|
||||||
|
const normalizedText = value.rawText.trim()
|
||||||
|
if (normalizedText.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.role !== 'payments') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
rawText: normalizedText,
|
||||||
|
householdId: binding.householdId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPaymentAcknowledgement(
|
||||||
|
locale: BotLocale,
|
||||||
|
result:
|
||||||
|
| { status: 'duplicate' }
|
||||||
|
| {
|
||||||
|
status: 'recorded'
|
||||||
|
kind: 'rent' | 'utilities'
|
||||||
|
amountMajor: string
|
||||||
|
currency: 'USD' | 'GEL'
|
||||||
|
}
|
||||||
|
| { status: 'needs_review' }
|
||||||
|
): string | null {
|
||||||
|
const t = getBotTranslations(locale).payments
|
||||||
|
|
||||||
|
switch (result.status) {
|
||||||
|
case 'duplicate':
|
||||||
|
return null
|
||||||
|
case 'recorded':
|
||||||
|
return t.recorded(result.kind, result.amountMajor, result.currency)
|
||||||
|
case 'needs_review':
|
||||||
|
return t.savedForReview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyToPaymentMessage(ctx: Context, text: string): Promise<void> {
|
||||||
|
const message = ctx.msg
|
||||||
|
if (!message) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.reply(text, {
|
||||||
|
reply_parameters: {
|
||||||
|
message_id: message.message_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerConfiguredPaymentTopicIngestion(
|
||||||
|
bot: Bot,
|
||||||
|
householdConfigurationRepository: HouseholdConfigurationRepository,
|
||||||
|
paymentServiceForHousehold: (householdId: string) => PaymentConfirmationService,
|
||||||
|
options: {
|
||||||
|
logger?: Logger
|
||||||
|
} = {}
|
||||||
|
): void {
|
||||||
|
bot.on('message', async (ctx, next) => {
|
||||||
|
const candidate = toCandidateFromContext(ctx)
|
||||||
|
if (!candidate) {
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = await householdConfigurationRepository.findHouseholdTopicByTelegramContext({
|
||||||
|
telegramChatId: candidate.chatId,
|
||||||
|
telegramThreadId: candidate.threadId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!binding) {
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = resolveConfiguredPaymentTopicRecord(candidate, binding)
|
||||||
|
if (!record) {
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await paymentServiceForHousehold(record.householdId).submit({
|
||||||
|
senderTelegramUserId: record.senderTelegramUserId,
|
||||||
|
rawText: record.rawText,
|
||||||
|
telegramChatId: record.chatId,
|
||||||
|
telegramMessageId: record.messageId,
|
||||||
|
telegramThreadId: record.threadId,
|
||||||
|
telegramUpdateId: String(record.updateId),
|
||||||
|
attachmentCount: record.attachmentCount,
|
||||||
|
messageSentAt: record.messageSentAt
|
||||||
|
})
|
||||||
|
const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId(
|
||||||
|
record.householdId
|
||||||
|
)
|
||||||
|
const locale = householdChat?.defaultLocale ?? 'en'
|
||||||
|
const acknowledgement = buildPaymentAcknowledgement(
|
||||||
|
locale,
|
||||||
|
result.status === 'recorded'
|
||||||
|
? {
|
||||||
|
status: 'recorded',
|
||||||
|
kind: result.kind,
|
||||||
|
amountMajor: result.amount.toMajorString(),
|
||||||
|
currency: result.amount.currency
|
||||||
|
}
|
||||||
|
: result
|
||||||
|
)
|
||||||
|
|
||||||
|
if (acknowledgement) {
|
||||||
|
await replyToPaymentMessage(ctx, acknowledgement)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
options.logger?.error(
|
||||||
|
{
|
||||||
|
event: 'payment.ingest_failed',
|
||||||
|
chatId: record.chatId,
|
||||||
|
threadId: record.threadId,
|
||||||
|
messageId: record.messageId,
|
||||||
|
updateId: record.updateId,
|
||||||
|
error
|
||||||
|
},
|
||||||
|
'Failed to ingest payment confirmation'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [
|
|||||||
'bind_purchase_topic',
|
'bind_purchase_topic',
|
||||||
'bind_feedback_topic',
|
'bind_feedback_topic',
|
||||||
'bind_reminders_topic',
|
'bind_reminders_topic',
|
||||||
|
'bind_payments_topic',
|
||||||
'pending_members',
|
'pending_members',
|
||||||
'approve_member'
|
'approve_member'
|
||||||
] as const satisfies readonly TelegramCommandName[]
|
] as const satisfies readonly TelegramCommandName[]
|
||||||
|
|||||||
@@ -160,6 +160,20 @@ function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function memberRemainingClass(member: MiniAppDashboard['members'][number]): string {
|
||||||
|
const remainingMinor = majorStringToMinor(member.remainingMajor)
|
||||||
|
|
||||||
|
if (remainingMinor < 0n) {
|
||||||
|
return 'is-credit'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingMinor === 0n) {
|
||||||
|
return 'is-settled'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'is-due'
|
||||||
|
}
|
||||||
|
|
||||||
function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string {
|
function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string {
|
||||||
return `${entry.displayAmountMajor} ${entry.displayCurrency}`
|
return `${entry.displayAmountMajor} ${entry.displayCurrency}`
|
||||||
}
|
}
|
||||||
@@ -405,6 +419,8 @@ function App() {
|
|||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'GEL',
|
currency: 'GEL',
|
||||||
totalDueMajor: '1030.00',
|
totalDueMajor: '1030.00',
|
||||||
|
totalPaidMajor: '501.00',
|
||||||
|
totalRemainingMajor: '529.00',
|
||||||
rentSourceAmountMajor: '700.00',
|
rentSourceAmountMajor: '700.00',
|
||||||
rentSourceCurrency: 'USD',
|
rentSourceCurrency: 'USD',
|
||||||
rentDisplayAmountMajor: '1932.00',
|
rentDisplayAmountMajor: '1932.00',
|
||||||
@@ -418,6 +434,8 @@ function App() {
|
|||||||
utilityShareMajor: '32.00',
|
utilityShareMajor: '32.00',
|
||||||
purchaseOffsetMajor: '-14.00',
|
purchaseOffsetMajor: '-14.00',
|
||||||
netDueMajor: '501.00',
|
netDueMajor: '501.00',
|
||||||
|
paidMajor: '501.00',
|
||||||
|
remainingMajor: '0.00',
|
||||||
explanations: ['Equal utility split', 'Shared purchase offset']
|
explanations: ['Equal utility split', 'Shared purchase offset']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -427,6 +445,8 @@ function App() {
|
|||||||
utilityShareMajor: '32.00',
|
utilityShareMajor: '32.00',
|
||||||
purchaseOffsetMajor: '14.00',
|
purchaseOffsetMajor: '14.00',
|
||||||
netDueMajor: '529.00',
|
netDueMajor: '529.00',
|
||||||
|
paidMajor: '0.00',
|
||||||
|
remainingMajor: '529.00',
|
||||||
explanations: ['Equal utility split']
|
explanations: ['Equal utility split']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -893,6 +913,18 @@ function App() {
|
|||||||
{currentMemberLine()!.netDueMajor} {data.currency}
|
{currentMemberLine()!.netDueMajor} {data.currency}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>{copy().paidLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{currentMemberLine()!.paidMajor} {data.currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>{copy().remainingLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{currentMemberLine()!.remainingMajor} {data.currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -907,7 +939,7 @@ function App() {
|
|||||||
<header>
|
<header>
|
||||||
<strong>{member.displayName}</strong>
|
<strong>{member.displayName}</strong>
|
||||||
<span>
|
<span>
|
||||||
{member.netDueMajor} {data.currency}
|
{member.remainingMajor} {data.currency}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<p>
|
<p>
|
||||||
@@ -922,6 +954,12 @@ function App() {
|
|||||||
<p>
|
<p>
|
||||||
{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}
|
{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
{copy().paidLabel}: {member.paidMajor} {data.currency}
|
||||||
|
</p>
|
||||||
|
<p class={`balance-status ${memberRemainingClass(member)}`}>
|
||||||
|
{copy().remainingLabel}: {member.remainingMajor} {data.currency}
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -1554,6 +1592,18 @@ function App() {
|
|||||||
{dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'}
|
{dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'}
|
||||||
</strong>
|
</strong>
|
||||||
</article>
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span>{copy().paidLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{dashboard() ? `${dashboard()!.totalPaidMajor} ${dashboard()!.currency}` : '—'}
|
||||||
|
</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span>{copy().remainingLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{dashboard() ? `${dashboard()!.totalRemainingMajor} ${dashboard()!.currency}` : '—'}
|
||||||
|
</strong>
|
||||||
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<span>{copy().membersCount}</span>
|
<span>{copy().membersCount}</span>
|
||||||
<strong>{dashboardMemberCount(dashboard())}</strong>
|
<strong>{dashboardMemberCount(dashboard())}</strong>
|
||||||
@@ -1578,7 +1628,7 @@ function App() {
|
|||||||
<header>
|
<header>
|
||||||
<strong>{copy().yourBalanceTitle}</strong>
|
<strong>{copy().yourBalanceTitle}</strong>
|
||||||
<span>
|
<span>
|
||||||
{currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''}
|
{currentMemberLine()!.remainingMajor} {dashboard()?.currency ?? ''}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<p>{copy().yourBalanceBody}</p>
|
<p>{copy().yourBalanceBody}</p>
|
||||||
@@ -1613,6 +1663,18 @@ function App() {
|
|||||||
{currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''}
|
{currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>{copy().paidLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{currentMemberLine()!.paidMajor} {dashboard()?.currency ?? ''}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>{copy().remainingLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{currentMemberLine()!.remainingMajor} {dashboard()?.currency ?? ''}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export const dictionary = {
|
|||||||
overviewBody:
|
overviewBody:
|
||||||
'Use the sections below to review balances, ledger entries, and household access.',
|
'Use the sections below to review balances, ledger entries, and household access.',
|
||||||
totalDue: 'Total due',
|
totalDue: 'Total due',
|
||||||
|
paidLabel: 'Paid',
|
||||||
|
remainingLabel: 'Remaining',
|
||||||
membersCount: 'Members',
|
membersCount: 'Members',
|
||||||
ledgerEntries: 'Ledger entries',
|
ledgerEntries: 'Ledger entries',
|
||||||
pendingRequests: 'Pending requests',
|
pendingRequests: 'Pending requests',
|
||||||
@@ -159,6 +161,8 @@ export const dictionary = {
|
|||||||
overviewTitle: 'Текущий цикл',
|
overviewTitle: 'Текущий цикл',
|
||||||
overviewBody: 'Ниже можно посмотреть балансы, записи леджера и доступ к household.',
|
overviewBody: 'Ниже можно посмотреть балансы, записи леджера и доступ к household.',
|
||||||
totalDue: 'Итого к оплате',
|
totalDue: 'Итого к оплате',
|
||||||
|
paidLabel: 'Оплачено',
|
||||||
|
remainingLabel: 'Осталось',
|
||||||
membersCount: 'Участники',
|
membersCount: 'Участники',
|
||||||
ledgerEntries: 'Записи леджера',
|
ledgerEntries: 'Записи леджера',
|
||||||
pendingRequests: 'Ожидают подтверждения',
|
pendingRequests: 'Ожидают подтверждения',
|
||||||
|
|||||||
@@ -270,6 +270,18 @@ button {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.balance-status.is-credit {
|
||||||
|
color: #95e2b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-status.is-settled {
|
||||||
|
color: #d6d0c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-status.is-due {
|
||||||
|
color: #f7b389;
|
||||||
|
}
|
||||||
|
|
||||||
.home-grid {
|
.home-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export interface MiniAppDashboard {
|
|||||||
period: string
|
period: string
|
||||||
currency: 'USD' | 'GEL'
|
currency: 'USD' | 'GEL'
|
||||||
totalDueMajor: string
|
totalDueMajor: string
|
||||||
|
totalPaidMajor: string
|
||||||
|
totalRemainingMajor: string
|
||||||
rentSourceAmountMajor: string
|
rentSourceAmountMajor: string
|
||||||
rentSourceCurrency: 'USD' | 'GEL'
|
rentSourceCurrency: 'USD' | 'GEL'
|
||||||
rentDisplayAmountMajor: string
|
rentDisplayAmountMajor: string
|
||||||
@@ -80,6 +82,8 @@ export interface MiniAppDashboard {
|
|||||||
utilityShareMajor: string
|
utilityShareMajor: string
|
||||||
purchaseOffsetMajor: string
|
purchaseOffsetMajor: string
|
||||||
netDueMajor: string
|
netDueMajor: string
|
||||||
|
paidMajor: string
|
||||||
|
remainingMajor: string
|
||||||
explanations: readonly string[]
|
explanations: readonly string[]
|
||||||
}[]
|
}[]
|
||||||
ledger: {
|
ledger: {
|
||||||
|
|||||||
39
docs/specs/HOUSEBOT-080-payment-confirmations.md
Normal file
39
docs/specs/HOUSEBOT-080-payment-confirmations.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# HOUSEBOT-080 Payment Confirmations From Household Topic
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Track when members confirm rent or utility payments from a dedicated household topic, without forcing them to type an exact amount every time.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- add a `payments` household topic role and `/bind_payments_topic`
|
||||||
|
- ingest text or caption-based confirmations from the configured payments topic
|
||||||
|
- persist every confirmation message idempotently
|
||||||
|
- record deterministic payment entries when the bot can resolve the amount safely
|
||||||
|
- keep ambiguous confirmations in `needs_review` instead of guessing
|
||||||
|
- expose paid and remaining amounts in the finance dashboard
|
||||||
|
|
||||||
|
## Parsing rules
|
||||||
|
|
||||||
|
- detect `rent` intent from phrases like `за жилье`, `аренда`, `paid rent`
|
||||||
|
- detect `utilities` intent from phrases like `коммуналка`, `газ`, `электричество`, `utilities`
|
||||||
|
- treat generic confirmations like `готово` as review-required
|
||||||
|
- treat multi-person confirmations like `за двоих` or `за Кирилла и себя` as review-required
|
||||||
|
- parse explicit amounts when present
|
||||||
|
- if no amount is present:
|
||||||
|
- `rent` resolves to the member's current rent share
|
||||||
|
- `utilities` resolves to `utilityShare + purchaseOffset`
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
- `payment_confirmations`
|
||||||
|
- stores raw Telegram message context and normalized review state
|
||||||
|
- `payment_records`
|
||||||
|
- stores accepted cycle-scoped payments in settlement currency
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- a member can say `за жилье закинул` or `оплатил коммуналку` in the configured payments topic
|
||||||
|
- the bot records the payment against the current cycle when resolution is deterministic
|
||||||
|
- the dashboard shows `due`, `paid`, and `remaining`
|
||||||
|
- ambiguous confirmations are stored for review, not silently converted into money movements
|
||||||
@@ -360,6 +360,30 @@ export function createDbFinanceRepository(
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listPaymentRecordsForCycle(cycleId) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: schema.paymentRecords.id,
|
||||||
|
memberId: schema.paymentRecords.memberId,
|
||||||
|
kind: schema.paymentRecords.kind,
|
||||||
|
amountMinor: schema.paymentRecords.amountMinor,
|
||||||
|
currency: schema.paymentRecords.currency,
|
||||||
|
recordedAt: schema.paymentRecords.recordedAt
|
||||||
|
})
|
||||||
|
.from(schema.paymentRecords)
|
||||||
|
.where(eq(schema.paymentRecords.cycleId, cycleId))
|
||||||
|
.orderBy(schema.paymentRecords.recordedAt)
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
memberId: row.memberId,
|
||||||
|
kind: row.kind === 'utilities' ? 'utilities' : 'rent',
|
||||||
|
amountMinor: row.amountMinor,
|
||||||
|
currency: toCurrencyCode(row.currency),
|
||||||
|
recordedAt: instantFromDatabaseValue(row.recordedAt)!
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
async listParsedPurchasesForRange(start, end) {
|
async listParsedPurchasesForRange(start, end) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -392,6 +416,121 @@ export function createDbFinanceRepository(
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getSettlementSnapshotLines(cycleId) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
memberId: schema.settlementLines.memberId,
|
||||||
|
rentShareMinor: schema.settlementLines.rentShareMinor,
|
||||||
|
utilityShareMinor: schema.settlementLines.utilityShareMinor,
|
||||||
|
purchaseOffsetMinor: schema.settlementLines.purchaseOffsetMinor,
|
||||||
|
netDueMinor: schema.settlementLines.netDueMinor
|
||||||
|
})
|
||||||
|
.from(schema.settlementLines)
|
||||||
|
.innerJoin(
|
||||||
|
schema.settlements,
|
||||||
|
eq(schema.settlementLines.settlementId, schema.settlements.id)
|
||||||
|
)
|
||||||
|
.where(eq(schema.settlements.cycleId, cycleId))
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
memberId: row.memberId,
|
||||||
|
rentShareMinor: row.rentShareMinor,
|
||||||
|
utilityShareMinor: row.utilityShareMinor,
|
||||||
|
purchaseOffsetMinor: row.purchaseOffsetMinor,
|
||||||
|
netDueMinor: row.netDueMinor
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePaymentConfirmation(input) {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const insertedConfirmation = await tx
|
||||||
|
.insert(schema.paymentConfirmations)
|
||||||
|
.values({
|
||||||
|
householdId,
|
||||||
|
cycleId: input.cycleId,
|
||||||
|
memberId: input.memberId,
|
||||||
|
senderTelegramUserId: input.senderTelegramUserId,
|
||||||
|
rawText: input.rawText,
|
||||||
|
normalizedText: input.normalizedText,
|
||||||
|
detectedKind: input.kind,
|
||||||
|
explicitAmountMinor: input.explicitAmountMinor,
|
||||||
|
explicitCurrency: input.explicitCurrency,
|
||||||
|
resolvedAmountMinor: input.amountMinor,
|
||||||
|
resolvedCurrency: input.currency,
|
||||||
|
status: input.status,
|
||||||
|
reviewReason: input.status === 'needs_review' ? input.reviewReason : null,
|
||||||
|
attachmentCount: input.attachmentCount,
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramMessageId: input.telegramMessageId,
|
||||||
|
telegramThreadId: input.telegramThreadId,
|
||||||
|
telegramUpdateId: input.telegramUpdateId,
|
||||||
|
messageSentAt: input.messageSentAt ? instantToDate(input.messageSentAt) : null
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({
|
||||||
|
target: [
|
||||||
|
schema.paymentConfirmations.householdId,
|
||||||
|
schema.paymentConfirmations.telegramChatId,
|
||||||
|
schema.paymentConfirmations.telegramMessageId
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: schema.paymentConfirmations.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmationId = insertedConfirmation[0]?.id
|
||||||
|
if (!confirmationId) {
|
||||||
|
return {
|
||||||
|
status: 'duplicate' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.status === 'needs_review') {
|
||||||
|
return {
|
||||||
|
status: 'needs_review' as const,
|
||||||
|
reviewReason: input.reviewReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedPayment = await tx
|
||||||
|
.insert(schema.paymentRecords)
|
||||||
|
.values({
|
||||||
|
householdId,
|
||||||
|
cycleId: input.cycleId,
|
||||||
|
memberId: input.memberId,
|
||||||
|
kind: input.kind,
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
currency: input.currency,
|
||||||
|
confirmationId,
|
||||||
|
recordedAt: instantToDate(input.recordedAt)
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: schema.paymentRecords.id,
|
||||||
|
memberId: schema.paymentRecords.memberId,
|
||||||
|
kind: schema.paymentRecords.kind,
|
||||||
|
amountMinor: schema.paymentRecords.amountMinor,
|
||||||
|
currency: schema.paymentRecords.currency,
|
||||||
|
recordedAt: schema.paymentRecords.recordedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
const paymentRow = insertedPayment[0]
|
||||||
|
if (!paymentRow) {
|
||||||
|
throw new Error('Failed to persist payment record')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'recorded' as const,
|
||||||
|
paymentRecord: {
|
||||||
|
id: paymentRow.id,
|
||||||
|
memberId: paymentRow.memberId,
|
||||||
|
kind: paymentRow.kind === 'utilities' ? 'utilities' : 'rent',
|
||||||
|
amountMinor: paymentRow.amountMinor,
|
||||||
|
currency: toCurrencyCode(paymentRow.currency),
|
||||||
|
recordedAt: instantFromDatabaseValue(paymentRow.recordedAt)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
async replaceSettlementSnapshot(snapshot) {
|
async replaceSettlementSnapshot(snapshot) {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const upserted = await tx
|
const upserted = await tx
|
||||||
|
|||||||
@@ -125,10 +125,25 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
return this.utilityBills
|
return this.utilityBills
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listPaymentRecordsForCycle() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
|
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
|
||||||
return this.purchases
|
return this.purchases
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSettlementSnapshotLines() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePaymentConfirmation() {
|
||||||
|
return {
|
||||||
|
status: 'needs_review' as const,
|
||||||
|
reviewReason: 'settlement_not_ready' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void> {
|
async replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void> {
|
||||||
this.replacedSnapshot = snapshot
|
this.replacedSnapshot = snapshot
|
||||||
}
|
}
|
||||||
@@ -347,9 +362,11 @@ describe('createFinanceCommandService', () => {
|
|||||||
[
|
[
|
||||||
'Statement for 2026-03',
|
'Statement for 2026-03',
|
||||||
'Rent: 700.00 USD (~1890.00 GEL)',
|
'Rent: 700.00 USD (~1890.00 GEL)',
|
||||||
'- Alice: 990.00 GEL',
|
'- Alice: due 990.00 GEL, paid 0.00 GEL, remaining 990.00 GEL',
|
||||||
'- Bob: 1020.00 GEL',
|
'- Bob: due 1020.00 GEL, paid 0.00 GEL, remaining 1020.00 GEL',
|
||||||
'Total: 2010.00 GEL'
|
'Total due: 2010.00 GEL',
|
||||||
|
'Total paid: 0.00 GEL',
|
||||||
|
'Total remaining: 2010.00 GEL'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
)
|
)
|
||||||
expect(repository.replacedSnapshot).not.toBeNull()
|
expect(repository.replacedSnapshot).not.toBeNull()
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export interface FinanceDashboardMemberLine {
|
|||||||
utilityShare: Money
|
utilityShare: Money
|
||||||
purchaseOffset: Money
|
purchaseOffset: Money
|
||||||
netDue: Money
|
netDue: Money
|
||||||
|
paid: Money
|
||||||
|
remaining: Money
|
||||||
explanations: readonly string[]
|
explanations: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +109,8 @@ export interface FinanceDashboard {
|
|||||||
period: string
|
period: string
|
||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
totalDue: Money
|
totalDue: Money
|
||||||
|
totalPaid: Money
|
||||||
|
totalRemaining: Money
|
||||||
rentSourceAmount: Money
|
rentSourceAmount: Money
|
||||||
rentDisplayAmount: Money
|
rentDisplayAmount: Money
|
||||||
rentFxRateMicros: bigint | null
|
rentFxRateMicros: bigint | null
|
||||||
@@ -238,6 +242,7 @@ async function buildFinanceDashboard(
|
|||||||
dependencies.repository.listParsedPurchasesForRange(start, end),
|
dependencies.repository.listParsedPurchasesForRange(start, end),
|
||||||
dependencies.repository.listUtilityBillsForCycle(cycle.id)
|
dependencies.repository.listUtilityBillsForCycle(cycle.id)
|
||||||
])
|
])
|
||||||
|
const paymentRecords = await dependencies.repository.listPaymentRecordsForCycle(cycle.id)
|
||||||
|
|
||||||
const convertedRent = await convertIntoCycleCurrency(dependencies, {
|
const convertedRent = await convertIntoCycleCurrency(dependencies, {
|
||||||
cycle,
|
cycle,
|
||||||
@@ -338,6 +343,14 @@ async function buildFinanceDashboard(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const memberNameById = new Map(members.map((member) => [member.id, member.displayName]))
|
const memberNameById = new Map(members.map((member) => [member.id, member.displayName]))
|
||||||
|
const paymentsByMemberId = new Map<string, Money>()
|
||||||
|
for (const payment of paymentRecords) {
|
||||||
|
const current = paymentsByMemberId.get(payment.memberId) ?? Money.zero(cycle.currency)
|
||||||
|
paymentsByMemberId.set(
|
||||||
|
payment.memberId,
|
||||||
|
current.add(Money.fromMinor(payment.amountMinor, payment.currency))
|
||||||
|
)
|
||||||
|
}
|
||||||
const dashboardMembers = settlement.lines.map((line) => ({
|
const dashboardMembers = settlement.lines.map((line) => ({
|
||||||
memberId: line.memberId.toString(),
|
memberId: line.memberId.toString(),
|
||||||
displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(),
|
displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(),
|
||||||
@@ -345,6 +358,10 @@ async function buildFinanceDashboard(
|
|||||||
utilityShare: line.utilityShare,
|
utilityShare: line.utilityShare,
|
||||||
purchaseOffset: line.purchaseOffset,
|
purchaseOffset: line.purchaseOffset,
|
||||||
netDue: line.netDue,
|
netDue: line.netDue,
|
||||||
|
paid: paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency),
|
||||||
|
remaining: line.netDue.subtract(
|
||||||
|
paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency)
|
||||||
|
),
|
||||||
explanations: line.explanations
|
explanations: line.explanations
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -389,6 +406,14 @@ async function buildFinanceDashboard(
|
|||||||
period: cycle.period,
|
period: cycle.period,
|
||||||
currency: cycle.currency,
|
currency: cycle.currency,
|
||||||
totalDue: settlement.totalDue,
|
totalDue: settlement.totalDue,
|
||||||
|
totalPaid: paymentRecords.reduce(
|
||||||
|
(sum, payment) => sum.add(Money.fromMinor(payment.amountMinor, payment.currency)),
|
||||||
|
Money.zero(cycle.currency)
|
||||||
|
),
|
||||||
|
totalRemaining: dashboardMembers.reduce(
|
||||||
|
(sum, member) => sum.add(member.remaining),
|
||||||
|
Money.zero(cycle.currency)
|
||||||
|
),
|
||||||
rentSourceAmount: convertedRent.originalAmount,
|
rentSourceAmount: convertedRent.originalAmount,
|
||||||
rentDisplayAmount: convertedRent.settlementAmount,
|
rentDisplayAmount: convertedRent.settlementAmount,
|
||||||
rentFxRateMicros: convertedRent.fxRateMicros,
|
rentFxRateMicros: convertedRent.fxRateMicros,
|
||||||
@@ -560,7 +585,7 @@ export function createFinanceCommandService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statementLines = dashboard.members.map((line) => {
|
const statementLines = dashboard.members.map((line) => {
|
||||||
return `- ${line.displayName}: ${line.netDue.toMajorString()} ${dashboard.currency}`
|
return `- ${line.displayName}: due ${line.netDue.toMajorString()} ${dashboard.currency}, paid ${line.paid.toMajorString()} ${dashboard.currency}, remaining ${line.remaining.toMajorString()} ${dashboard.currency}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const rentLine =
|
const rentLine =
|
||||||
@@ -572,7 +597,9 @@ export function createFinanceCommandService(
|
|||||||
`Statement for ${dashboard.period}`,
|
`Statement for ${dashboard.period}`,
|
||||||
rentLine,
|
rentLine,
|
||||||
...statementLines,
|
...statementLines,
|
||||||
`Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`
|
`Total due: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`,
|
||||||
|
`Total paid: ${dashboard.totalPaid.toMajorString()} ${dashboard.currency}`,
|
||||||
|
`Total remaining: ${dashboard.totalRemaining.toMajorString()} ${dashboard.currency}`
|
||||||
].join('\n')
|
].join('\n')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,8 @@ export {
|
|||||||
type PurchaseParserLlmFallback,
|
type PurchaseParserLlmFallback,
|
||||||
type PurchaseParserMode
|
type PurchaseParserMode
|
||||||
} from './purchase-parser'
|
} from './purchase-parser'
|
||||||
|
export {
|
||||||
|
createPaymentConfirmationService,
|
||||||
|
type PaymentConfirmationService,
|
||||||
|
type PaymentConfirmationSubmitResult
|
||||||
|
} from './payment-confirmation-service'
|
||||||
|
|||||||
36
packages/application/src/payment-confirmation-parser.test.ts
Normal file
36
packages/application/src/payment-confirmation-parser.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { parsePaymentConfirmationMessage } from './payment-confirmation-parser'
|
||||||
|
|
||||||
|
describe('parsePaymentConfirmationMessage', () => {
|
||||||
|
test('detects rent confirmation without explicit amount', () => {
|
||||||
|
const result = parsePaymentConfirmationMessage('за жилье закинул', 'GEL')
|
||||||
|
|
||||||
|
expect(result.kind).toBe('rent')
|
||||||
|
expect(result.explicitAmount).toBeNull()
|
||||||
|
expect(result.reviewReason).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects utility confirmation with explicit default-currency amount', () => {
|
||||||
|
const result = parsePaymentConfirmationMessage('оплатил газ 120', 'GEL')
|
||||||
|
|
||||||
|
expect(result.kind).toBe('utilities')
|
||||||
|
expect(result.explicitAmount?.amountMinor).toBe(12000n)
|
||||||
|
expect(result.explicitAmount?.currency).toBe('GEL')
|
||||||
|
expect(result.reviewReason).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps multi-member confirmations for review', () => {
|
||||||
|
const result = parsePaymentConfirmationMessage('перевел за Кирилла и себя', 'GEL')
|
||||||
|
|
||||||
|
expect(result.kind).toBeNull()
|
||||||
|
expect(result.reviewReason).toBe('multiple_members')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps generic done messages for review', () => {
|
||||||
|
const result = parsePaymentConfirmationMessage('готово', 'GEL')
|
||||||
|
|
||||||
|
expect(result.kind).toBeNull()
|
||||||
|
expect(result.reviewReason).toBe('kind_ambiguous')
|
||||||
|
})
|
||||||
|
})
|
||||||
143
packages/application/src/payment-confirmation-parser.ts
Normal file
143
packages/application/src/payment-confirmation-parser.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Money, type CurrencyCode } from '@household/domain'
|
||||||
|
import type { FinancePaymentKind, FinancePaymentConfirmationReviewReason } from '@household/ports'
|
||||||
|
|
||||||
|
export interface ParsedPaymentConfirmation {
|
||||||
|
normalizedText: string
|
||||||
|
kind: FinancePaymentKind | null
|
||||||
|
explicitAmount: Money | null
|
||||||
|
reviewReason: FinancePaymentConfirmationReviewReason | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rentKeywords = [/\b(rent|housing|apartment|landlord)\b/i, /жиль[её]/i, /аренд/i] as const
|
||||||
|
|
||||||
|
const utilityKeywords = [
|
||||||
|
/\b(utilities|utility|gas|water|electricity|internet|cleaning)\b/i,
|
||||||
|
/коммун/i,
|
||||||
|
/газ/i,
|
||||||
|
/вод/i,
|
||||||
|
/элект/i,
|
||||||
|
/свет/i,
|
||||||
|
/интернет/i,
|
||||||
|
/уборк/i
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const paymentIntentKeywords = [
|
||||||
|
/\b(paid|pay|sent|done|transfer(red)?)\b/i,
|
||||||
|
/оплат/i,
|
||||||
|
/закинул/i,
|
||||||
|
/закину/i,
|
||||||
|
/перев[её]л/i,
|
||||||
|
/перевела/i,
|
||||||
|
/скинул/i,
|
||||||
|
/скинула/i,
|
||||||
|
/отправил/i,
|
||||||
|
/отправила/i,
|
||||||
|
/готово/i
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const multiMemberKeywords = [
|
||||||
|
/за\s+двоих/i,
|
||||||
|
/\bfor\s+two\b/i,
|
||||||
|
/за\s+.*\s+и\s+себя/i,
|
||||||
|
/за\s+.*\s+и\s+меня/i
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function hasMatch(patterns: readonly RegExp[], value: string): boolean {
|
||||||
|
return patterns.some((pattern) => pattern.test(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExplicitAmount(rawText: string, defaultCurrency: CurrencyCode): Money | null {
|
||||||
|
const symbolMatch = rawText.match(/(?:^|[^\d])(\$|₾)\s*(\d+(?:[.,]\d{1,2})?)/i)
|
||||||
|
if (symbolMatch) {
|
||||||
|
const currency = symbolMatch[1] === '$' ? 'USD' : 'GEL'
|
||||||
|
return Money.fromMajor(symbolMatch[2]!.replace(',', '.'), currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffixMatch = rawText.match(/(\d+(?:[.,]\d{1,2})?)\s*(usd|gel|лари|лар|ლარი|ლარ|₾|\$)\b/i)
|
||||||
|
if (suffixMatch) {
|
||||||
|
const rawCurrency = suffixMatch[2]!.toUpperCase()
|
||||||
|
const currency = rawCurrency === 'USD' || rawCurrency === '$' ? 'USD' : 'GEL'
|
||||||
|
|
||||||
|
return Money.fromMajor(suffixMatch[1]!.replace(',', '.'), currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bareAmountMatch = rawText.match(/(?:^|[^\d])(\d+(?:[.,]\d{1,2})?)(?:\s|$)/)
|
||||||
|
if (!bareAmountMatch) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return Money.fromMajor(bareAmountMatch[1]!.replace(',', '.'), defaultCurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePaymentConfirmationMessage(
|
||||||
|
rawText: string,
|
||||||
|
defaultCurrency: CurrencyCode
|
||||||
|
): ParsedPaymentConfirmation {
|
||||||
|
const normalizedText = rawText.trim().replaceAll(/\s+/g, ' ')
|
||||||
|
const lowercase = normalizedText.toLowerCase()
|
||||||
|
|
||||||
|
if (normalizedText.length === 0) {
|
||||||
|
return {
|
||||||
|
normalizedText,
|
||||||
|
kind: null,
|
||||||
|
explicitAmount: null,
|
||||||
|
reviewReason: 'intent_missing'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMatch(multiMemberKeywords, lowercase)) {
|
||||||
|
return {
|
||||||
|
normalizedText,
|
||||||
|
kind: null,
|
||||||
|
explicitAmount: parseExplicitAmount(normalizedText, defaultCurrency),
|
||||||
|
reviewReason: 'multiple_members'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMatch(paymentIntentKeywords, lowercase)) {
|
||||||
|
return {
|
||||||
|
normalizedText,
|
||||||
|
kind: null,
|
||||||
|
explicitAmount: parseExplicitAmount(normalizedText, defaultCurrency),
|
||||||
|
reviewReason: 'intent_missing'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesRent = hasMatch(rentKeywords, lowercase)
|
||||||
|
const matchesUtilities = hasMatch(utilityKeywords, lowercase)
|
||||||
|
const explicitAmount = parseExplicitAmount(normalizedText, defaultCurrency)
|
||||||
|
|
||||||
|
if (matchesRent && matchesUtilities) {
|
||||||
|
return {
|
||||||
|
normalizedText,
|
||||||
|
kind: null,
|
||||||
|
explicitAmount,
|
||||||
|
reviewReason: 'kind_ambiguous'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesRent) {
|
||||||
|
return {
|
||||||
|
normalizedText,
|
||||||
|
kind: 'rent',
|
||||||
|
explicitAmount,
|
||||||
|
reviewReason: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesUtilities) {
|
||||||
|
return {
|
||||||
|
normalizedText,
|
||||||
|
kind: 'utilities',
|
||||||
|
explicitAmount,
|
||||||
|
reviewReason: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalizedText,
|
||||||
|
kind: null,
|
||||||
|
explicitAmount,
|
||||||
|
reviewReason: 'kind_ambiguous'
|
||||||
|
}
|
||||||
|
}
|
||||||
258
packages/application/src/payment-confirmation-service.test.ts
Normal file
258
packages/application/src/payment-confirmation-service.test.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { Money, instantFromIso, type CurrencyCode } from '@household/domain'
|
||||||
|
import type {
|
||||||
|
ExchangeRateProvider,
|
||||||
|
FinancePaymentConfirmationSaveInput,
|
||||||
|
FinancePaymentConfirmationSaveResult,
|
||||||
|
FinanceRepository,
|
||||||
|
HouseholdConfigurationRepository
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
import { createPaymentConfirmationService } from './payment-confirmation-service'
|
||||||
|
|
||||||
|
const settingsRepository: Pick<HouseholdConfigurationRepository, 'getHouseholdBillingSettings'> = {
|
||||||
|
async getHouseholdBillingSettings(householdId) {
|
||||||
|
return {
|
||||||
|
householdId,
|
||||||
|
settlementCurrency: 'GEL',
|
||||||
|
rentAmountMinor: 70000n,
|
||||||
|
rentCurrency: 'USD',
|
||||||
|
rentDueDay: 20,
|
||||||
|
rentWarningDay: 17,
|
||||||
|
utilitiesDueDay: 4,
|
||||||
|
utilitiesReminderDay: 3,
|
||||||
|
timezone: 'Asia/Tbilisi'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchangeRateProvider: ExchangeRateProvider = {
|
||||||
|
async getRate(input) {
|
||||||
|
return {
|
||||||
|
baseCurrency: input.baseCurrency,
|
||||||
|
quoteCurrency: input.quoteCurrency,
|
||||||
|
rateMicros: input.baseCurrency === input.quoteCurrency ? 1_000_000n : 2_700_000n,
|
||||||
|
effectiveDate: input.effectiveDate,
|
||||||
|
source: 'nbg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRepositoryStub(): Pick<
|
||||||
|
FinanceRepository,
|
||||||
|
| 'getOpenCycle'
|
||||||
|
| 'getLatestCycle'
|
||||||
|
| 'getCycleExchangeRate'
|
||||||
|
| 'saveCycleExchangeRate'
|
||||||
|
| 'savePaymentConfirmation'
|
||||||
|
> & {
|
||||||
|
saved: FinancePaymentConfirmationSaveInput[]
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
saved: [],
|
||||||
|
async getOpenCycle() {
|
||||||
|
return {
|
||||||
|
id: 'cycle-1',
|
||||||
|
period: '2026-03',
|
||||||
|
currency: 'GEL' as CurrencyCode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getLatestCycle() {
|
||||||
|
return {
|
||||||
|
id: 'cycle-1',
|
||||||
|
period: '2026-03',
|
||||||
|
currency: 'GEL' as CurrencyCode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getCycleExchangeRate() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async saveCycleExchangeRate(input) {
|
||||||
|
return input
|
||||||
|
},
|
||||||
|
async savePaymentConfirmation(input): Promise<FinancePaymentConfirmationSaveResult> {
|
||||||
|
this.saved.push(input)
|
||||||
|
|
||||||
|
if (input.status === 'needs_review') {
|
||||||
|
return {
|
||||||
|
status: 'needs_review',
|
||||||
|
reviewReason: input.reviewReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'recorded',
|
||||||
|
paymentRecord: {
|
||||||
|
id: 'payment-1',
|
||||||
|
memberId: input.memberId,
|
||||||
|
kind: input.kind,
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
currency: input.currency,
|
||||||
|
recordedAt: input.recordedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createPaymentConfirmationService', () => {
|
||||||
|
test('resolves rent confirmations against the current member due', async () => {
|
||||||
|
const repository = createRepositoryStub()
|
||||||
|
const service = createPaymentConfirmationService({
|
||||||
|
householdId: 'household-1',
|
||||||
|
financeService: {
|
||||||
|
getMemberByTelegramUserId: async () => ({
|
||||||
|
id: 'member-1',
|
||||||
|
telegramUserId: '123',
|
||||||
|
displayName: 'Stas',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: false
|
||||||
|
}),
|
||||||
|
generateDashboard: async () => ({
|
||||||
|
period: '2026-03',
|
||||||
|
currency: 'GEL',
|
||||||
|
totalDue: Money.fromMajor('1030', 'GEL'),
|
||||||
|
totalPaid: Money.zero('GEL'),
|
||||||
|
totalRemaining: Money.fromMajor('1030', 'GEL'),
|
||||||
|
rentSourceAmount: Money.fromMajor('700', 'USD'),
|
||||||
|
rentDisplayAmount: Money.fromMajor('1890', 'GEL'),
|
||||||
|
rentFxRateMicros: 2_700_000n,
|
||||||
|
rentFxEffectiveDate: '2026-03-17',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
memberId: 'member-1',
|
||||||
|
displayName: 'Stas',
|
||||||
|
rentShare: Money.fromMajor('472.50', 'GEL'),
|
||||||
|
utilityShare: Money.fromMajor('40', 'GEL'),
|
||||||
|
purchaseOffset: Money.fromMajor('-12', 'GEL'),
|
||||||
|
netDue: Money.fromMajor('500.50', 'GEL'),
|
||||||
|
paid: Money.zero('GEL'),
|
||||||
|
remaining: Money.fromMajor('500.50', 'GEL'),
|
||||||
|
explanations: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ledger: []
|
||||||
|
})
|
||||||
|
},
|
||||||
|
repository,
|
||||||
|
householdConfigurationRepository: settingsRepository,
|
||||||
|
exchangeRateProvider
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.submit({
|
||||||
|
senderTelegramUserId: '123',
|
||||||
|
rawText: 'за жилье закинул',
|
||||||
|
telegramChatId: '-1001',
|
||||||
|
telegramMessageId: '10',
|
||||||
|
telegramThreadId: '4',
|
||||||
|
telegramUpdateId: '200',
|
||||||
|
attachmentCount: 0,
|
||||||
|
messageSentAt: instantFromIso('2026-03-20T09:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'recorded',
|
||||||
|
kind: 'rent',
|
||||||
|
amount: Money.fromMajor('472.50', 'GEL')
|
||||||
|
})
|
||||||
|
expect(repository.saved[0]?.status).toBe('recorded')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('converts explicit rent amounts into cycle currency', async () => {
|
||||||
|
const repository = createRepositoryStub()
|
||||||
|
const service = createPaymentConfirmationService({
|
||||||
|
householdId: 'household-1',
|
||||||
|
financeService: {
|
||||||
|
getMemberByTelegramUserId: async () => ({
|
||||||
|
id: 'member-1',
|
||||||
|
telegramUserId: '123',
|
||||||
|
displayName: 'Stas',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: false
|
||||||
|
}),
|
||||||
|
generateDashboard: async () => ({
|
||||||
|
period: '2026-03',
|
||||||
|
currency: 'GEL',
|
||||||
|
totalDue: Money.fromMajor('1030', 'GEL'),
|
||||||
|
totalPaid: Money.zero('GEL'),
|
||||||
|
totalRemaining: Money.fromMajor('1030', 'GEL'),
|
||||||
|
rentSourceAmount: Money.fromMajor('700', 'USD'),
|
||||||
|
rentDisplayAmount: Money.fromMajor('1890', 'GEL'),
|
||||||
|
rentFxRateMicros: 2_700_000n,
|
||||||
|
rentFxEffectiveDate: '2026-03-17',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
memberId: 'member-1',
|
||||||
|
displayName: 'Stas',
|
||||||
|
rentShare: Money.fromMajor('472.50', 'GEL'),
|
||||||
|
utilityShare: Money.fromMajor('40', 'GEL'),
|
||||||
|
purchaseOffset: Money.fromMajor('-12', 'GEL'),
|
||||||
|
netDue: Money.fromMajor('500.50', 'GEL'),
|
||||||
|
paid: Money.zero('GEL'),
|
||||||
|
remaining: Money.fromMajor('500.50', 'GEL'),
|
||||||
|
explanations: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ledger: []
|
||||||
|
})
|
||||||
|
},
|
||||||
|
repository,
|
||||||
|
householdConfigurationRepository: settingsRepository,
|
||||||
|
exchangeRateProvider
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.submit({
|
||||||
|
senderTelegramUserId: '123',
|
||||||
|
rawText: 'paid rent $175',
|
||||||
|
telegramChatId: '-1001',
|
||||||
|
telegramMessageId: '11',
|
||||||
|
telegramThreadId: '4',
|
||||||
|
telegramUpdateId: '201',
|
||||||
|
attachmentCount: 0,
|
||||||
|
messageSentAt: instantFromIso('2026-03-20T09:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'recorded',
|
||||||
|
kind: 'rent',
|
||||||
|
amount: Money.fromMajor('472.50', 'GEL')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps ambiguous confirmations for review', async () => {
|
||||||
|
const repository = createRepositoryStub()
|
||||||
|
const service = createPaymentConfirmationService({
|
||||||
|
householdId: 'household-1',
|
||||||
|
financeService: {
|
||||||
|
getMemberByTelegramUserId: async () => ({
|
||||||
|
id: 'member-1',
|
||||||
|
telegramUserId: '123',
|
||||||
|
displayName: 'Stas',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: false
|
||||||
|
}),
|
||||||
|
generateDashboard: async () => null
|
||||||
|
},
|
||||||
|
repository,
|
||||||
|
householdConfigurationRepository: settingsRepository,
|
||||||
|
exchangeRateProvider
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.submit({
|
||||||
|
senderTelegramUserId: '123',
|
||||||
|
rawText: 'готово',
|
||||||
|
telegramChatId: '-1001',
|
||||||
|
telegramMessageId: '12',
|
||||||
|
telegramThreadId: '4',
|
||||||
|
telegramUpdateId: '202',
|
||||||
|
attachmentCount: 1,
|
||||||
|
messageSentAt: instantFromIso('2026-03-20T09:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'needs_review',
|
||||||
|
reason: 'kind_ambiguous'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
368
packages/application/src/payment-confirmation-service.ts
Normal file
368
packages/application/src/payment-confirmation-service.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import type {
|
||||||
|
ExchangeRateProvider,
|
||||||
|
FinancePaymentKind,
|
||||||
|
FinanceRepository,
|
||||||
|
HouseholdConfigurationRepository
|
||||||
|
} from '@household/ports'
|
||||||
|
import {
|
||||||
|
BillingPeriod,
|
||||||
|
Money,
|
||||||
|
Temporal,
|
||||||
|
convertMoney,
|
||||||
|
nowInstant,
|
||||||
|
type CurrencyCode
|
||||||
|
} from '@household/domain'
|
||||||
|
|
||||||
|
import type { FinanceCommandService } from './finance-command-service'
|
||||||
|
import { parsePaymentConfirmationMessage } from './payment-confirmation-parser'
|
||||||
|
|
||||||
|
function billingPeriodLockDate(period: BillingPeriod, day: number): Temporal.PlainDate {
|
||||||
|
const firstDay = Temporal.PlainDate.from({
|
||||||
|
year: period.year,
|
||||||
|
month: period.month,
|
||||||
|
day: 1
|
||||||
|
})
|
||||||
|
const clampedDay = Math.min(day, firstDay.daysInMonth)
|
||||||
|
|
||||||
|
return Temporal.PlainDate.from({
|
||||||
|
year: period.year,
|
||||||
|
month: period.month,
|
||||||
|
day: clampedDay
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDateInTimezone(timezone: string): Temporal.PlainDate {
|
||||||
|
return nowInstant().toZonedDateTimeISO(timezone).toPlainDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertIntoCycleCurrency(
|
||||||
|
dependencies: {
|
||||||
|
repository: Pick<FinanceRepository, 'getCycleExchangeRate' | 'saveCycleExchangeRate'>
|
||||||
|
exchangeRateProvider: ExchangeRateProvider
|
||||||
|
cycleId: string
|
||||||
|
cycleCurrency: CurrencyCode
|
||||||
|
period: BillingPeriod
|
||||||
|
timezone: string
|
||||||
|
lockDay: number
|
||||||
|
},
|
||||||
|
amount: Money
|
||||||
|
): Promise<{
|
||||||
|
amount: Money
|
||||||
|
explicitAmountMinor: bigint
|
||||||
|
explicitCurrency: CurrencyCode
|
||||||
|
}> {
|
||||||
|
if (amount.currency === dependencies.cycleCurrency) {
|
||||||
|
return {
|
||||||
|
amount,
|
||||||
|
explicitAmountMinor: amount.amountMinor,
|
||||||
|
explicitCurrency: amount.currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRate = await dependencies.repository.getCycleExchangeRate(
|
||||||
|
dependencies.cycleId,
|
||||||
|
amount.currency,
|
||||||
|
dependencies.cycleCurrency
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingRate) {
|
||||||
|
return {
|
||||||
|
amount: convertMoney(amount, dependencies.cycleCurrency, existingRate.rateMicros),
|
||||||
|
explicitAmountMinor: amount.amountMinor,
|
||||||
|
explicitCurrency: amount.currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockDate = billingPeriodLockDate(dependencies.period, dependencies.lockDay)
|
||||||
|
const currentLocalDate = localDateInTimezone(dependencies.timezone)
|
||||||
|
const shouldPersist = Temporal.PlainDate.compare(currentLocalDate, lockDate) >= 0
|
||||||
|
const quote = await dependencies.exchangeRateProvider.getRate({
|
||||||
|
baseCurrency: amount.currency,
|
||||||
|
quoteCurrency: dependencies.cycleCurrency,
|
||||||
|
effectiveDate: lockDate.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (shouldPersist) {
|
||||||
|
await dependencies.repository.saveCycleExchangeRate({
|
||||||
|
cycleId: dependencies.cycleId,
|
||||||
|
sourceCurrency: quote.baseCurrency,
|
||||||
|
targetCurrency: quote.quoteCurrency,
|
||||||
|
rateMicros: quote.rateMicros,
|
||||||
|
effectiveDate: quote.effectiveDate,
|
||||||
|
source: quote.source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount: convertMoney(amount, dependencies.cycleCurrency, quote.rateMicros),
|
||||||
|
explicitAmountMinor: amount.amountMinor,
|
||||||
|
explicitCurrency: amount.currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentConfirmationMessageInput {
|
||||||
|
senderTelegramUserId: string
|
||||||
|
rawText: string
|
||||||
|
telegramChatId: string
|
||||||
|
telegramMessageId: string
|
||||||
|
telegramThreadId: string
|
||||||
|
telegramUpdateId: string
|
||||||
|
attachmentCount: number
|
||||||
|
messageSentAt: Temporal.Instant | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentConfirmationSubmitResult =
|
||||||
|
| {
|
||||||
|
status: 'duplicate'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'recorded'
|
||||||
|
kind: FinancePaymentKind
|
||||||
|
amount: Money
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'needs_review'
|
||||||
|
reason:
|
||||||
|
| 'member_not_found'
|
||||||
|
| 'cycle_not_found'
|
||||||
|
| 'settlement_not_ready'
|
||||||
|
| 'intent_missing'
|
||||||
|
| 'kind_ambiguous'
|
||||||
|
| 'multiple_members'
|
||||||
|
| 'non_positive_amount'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentConfirmationService {
|
||||||
|
submit(input: PaymentConfirmationMessageInput): Promise<PaymentConfirmationSubmitResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPaymentConfirmationService(input: {
|
||||||
|
householdId: string
|
||||||
|
financeService: Pick<FinanceCommandService, 'getMemberByTelegramUserId' | 'generateDashboard'>
|
||||||
|
repository: Pick<
|
||||||
|
FinanceRepository,
|
||||||
|
| 'getOpenCycle'
|
||||||
|
| 'getLatestCycle'
|
||||||
|
| 'getCycleExchangeRate'
|
||||||
|
| 'saveCycleExchangeRate'
|
||||||
|
| 'savePaymentConfirmation'
|
||||||
|
>
|
||||||
|
householdConfigurationRepository: Pick<
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
'getHouseholdBillingSettings'
|
||||||
|
>
|
||||||
|
exchangeRateProvider: ExchangeRateProvider
|
||||||
|
}): PaymentConfirmationService {
|
||||||
|
return {
|
||||||
|
async submit(message) {
|
||||||
|
const member = await input.financeService.getMemberByTelegramUserId(
|
||||||
|
message.senderTelegramUserId
|
||||||
|
)
|
||||||
|
if (!member) {
|
||||||
|
const saveResult = await input.repository.savePaymentConfirmation({
|
||||||
|
...message,
|
||||||
|
normalizedText: message.rawText.trim().replaceAll(/\s+/g, ' '),
|
||||||
|
status: 'needs_review',
|
||||||
|
cycleId: null,
|
||||||
|
memberId: null,
|
||||||
|
kind: null,
|
||||||
|
amountMinor: null,
|
||||||
|
currency: null,
|
||||||
|
explicitAmountMinor: null,
|
||||||
|
explicitCurrency: null,
|
||||||
|
reviewReason: 'member_not_found'
|
||||||
|
})
|
||||||
|
|
||||||
|
return saveResult.status === 'duplicate'
|
||||||
|
? saveResult
|
||||||
|
: {
|
||||||
|
status: 'needs_review',
|
||||||
|
reason: 'member_not_found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cycle, settings] = await Promise.all([
|
||||||
|
input.repository
|
||||||
|
.getOpenCycle()
|
||||||
|
.then((openCycle) => openCycle ?? input.repository.getLatestCycle()),
|
||||||
|
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!cycle) {
|
||||||
|
const saveResult = await input.repository.savePaymentConfirmation({
|
||||||
|
...message,
|
||||||
|
normalizedText: message.rawText.trim().replaceAll(/\s+/g, ' '),
|
||||||
|
status: 'needs_review',
|
||||||
|
cycleId: null,
|
||||||
|
memberId: member.id,
|
||||||
|
kind: null,
|
||||||
|
amountMinor: null,
|
||||||
|
currency: null,
|
||||||
|
explicitAmountMinor: null,
|
||||||
|
explicitCurrency: null,
|
||||||
|
reviewReason: 'cycle_not_found'
|
||||||
|
})
|
||||||
|
|
||||||
|
return saveResult.status === 'duplicate'
|
||||||
|
? saveResult
|
||||||
|
: {
|
||||||
|
status: 'needs_review',
|
||||||
|
reason: 'cycle_not_found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parsePaymentConfirmationMessage(message.rawText, settings.settlementCurrency)
|
||||||
|
|
||||||
|
if (!parsed.kind || parsed.reviewReason) {
|
||||||
|
const saveResult = await input.repository.savePaymentConfirmation({
|
||||||
|
...message,
|
||||||
|
normalizedText: parsed.normalizedText,
|
||||||
|
status: 'needs_review',
|
||||||
|
cycleId: cycle.id,
|
||||||
|
memberId: member.id,
|
||||||
|
kind: parsed.kind,
|
||||||
|
amountMinor: null,
|
||||||
|
currency: null,
|
||||||
|
explicitAmountMinor: parsed.explicitAmount?.amountMinor ?? null,
|
||||||
|
explicitCurrency: parsed.explicitAmount?.currency ?? null,
|
||||||
|
reviewReason: parsed.reviewReason ?? 'kind_ambiguous'
|
||||||
|
})
|
||||||
|
|
||||||
|
return saveResult.status === 'duplicate'
|
||||||
|
? saveResult
|
||||||
|
: {
|
||||||
|
status: 'needs_review',
|
||||||
|
reason: parsed.reviewReason ?? 'kind_ambiguous'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = await input.financeService.generateDashboard(cycle.period)
|
||||||
|
if (!dashboard) {
|
||||||
|
const saveResult = await input.repository.savePaymentConfirmation({
|
||||||
|
...message,
|
||||||
|
normalizedText: parsed.normalizedText,
|
||||||
|
status: 'needs_review',
|
||||||
|
cycleId: cycle.id,
|
||||||
|
memberId: member.id,
|
||||||
|
kind: parsed.kind,
|
||||||
|
amountMinor: null,
|
||||||
|
currency: null,
|
||||||
|
explicitAmountMinor: parsed.explicitAmount?.amountMinor ?? null,
|
||||||
|
explicitCurrency: parsed.explicitAmount?.currency ?? null,
|
||||||
|
reviewReason: 'settlement_not_ready'
|
||||||
|
})
|
||||||
|
|
||||||
|
return saveResult.status === 'duplicate'
|
||||||
|
? saveResult
|
||||||
|
: {
|
||||||
|
status: 'needs_review',
|
||||||
|
reason: 'settlement_not_ready'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberLine = dashboard.members.find((line) => line.memberId === member.id)
|
||||||
|
if (!memberLine) {
|
||||||
|
const saveResult = await input.repository.savePaymentConfirmation({
|
||||||
|
...message,
|
||||||
|
normalizedText: parsed.normalizedText,
|
||||||
|
status: 'needs_review',
|
||||||
|
cycleId: cycle.id,
|
||||||
|
memberId: member.id,
|
||||||
|
kind: parsed.kind,
|
||||||
|
amountMinor: null,
|
||||||
|
currency: null,
|
||||||
|
explicitAmountMinor: parsed.explicitAmount?.amountMinor ?? null,
|
||||||
|
explicitCurrency: parsed.explicitAmount?.currency ?? null,
|
||||||
|
reviewReason: 'settlement_not_ready'
|
||||||
|
})
|
||||||
|
|
||||||
|
return saveResult.status === 'duplicate'
|
||||||
|
? saveResult
|
||||||
|
: {
|
||||||
|
status: 'needs_review',
|
||||||
|
reason: 'settlement_not_ready'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferredAmount =
|
||||||
|
parsed.kind === 'rent'
|
||||||
|
? memberLine.rentShare
|
||||||
|
: memberLine.utilityShare.add(memberLine.purchaseOffset)
|
||||||
|
|
||||||
|
const resolvedAmount = parsed.explicitAmount
|
||||||
|
? (
|
||||||
|
await convertIntoCycleCurrency(
|
||||||
|
{
|
||||||
|
repository: input.repository,
|
||||||
|
exchangeRateProvider: input.exchangeRateProvider,
|
||||||
|
cycleId: cycle.id,
|
||||||
|
cycleCurrency: dashboard.currency,
|
||||||
|
period: BillingPeriod.fromString(cycle.period),
|
||||||
|
timezone: settings.timezone,
|
||||||
|
lockDay:
|
||||||
|
parsed.kind === 'rent' ? settings.rentWarningDay : settings.utilitiesReminderDay
|
||||||
|
},
|
||||||
|
parsed.explicitAmount
|
||||||
|
)
|
||||||
|
).amount
|
||||||
|
: inferredAmount
|
||||||
|
|
||||||
|
if (resolvedAmount.amountMinor <= 0n) {
|
||||||
|
const saveResult = await input.repository.savePaymentConfirmation({
|
||||||
|
...message,
|
||||||
|
normalizedText: parsed.normalizedText,
|
||||||
|
status: 'needs_review',
|
||||||
|
cycleId: cycle.id,
|
||||||
|
memberId: member.id,
|
||||||
|
kind: parsed.kind,
|
||||||
|
amountMinor: null,
|
||||||
|
currency: null,
|
||||||
|
explicitAmountMinor: parsed.explicitAmount?.amountMinor ?? null,
|
||||||
|
explicitCurrency: parsed.explicitAmount?.currency ?? null,
|
||||||
|
reviewReason: 'non_positive_amount'
|
||||||
|
})
|
||||||
|
|
||||||
|
return saveResult.status === 'duplicate'
|
||||||
|
? saveResult
|
||||||
|
: {
|
||||||
|
status: 'needs_review',
|
||||||
|
reason: 'non_positive_amount'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveResult = await input.repository.savePaymentConfirmation({
|
||||||
|
...message,
|
||||||
|
normalizedText: parsed.normalizedText,
|
||||||
|
status: 'recorded',
|
||||||
|
cycleId: cycle.id,
|
||||||
|
memberId: member.id,
|
||||||
|
kind: parsed.kind,
|
||||||
|
amountMinor: resolvedAmount.amountMinor,
|
||||||
|
currency: resolvedAmount.currency,
|
||||||
|
explicitAmountMinor: parsed.explicitAmount?.amountMinor ?? null,
|
||||||
|
explicitCurrency: parsed.explicitAmount?.currency ?? null,
|
||||||
|
recordedAt: message.messageSentAt ?? nowInstant()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (saveResult.status === 'duplicate') {
|
||||||
|
return saveResult
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveResult.status === 'needs_review') {
|
||||||
|
return {
|
||||||
|
status: 'needs_review',
|
||||||
|
reason: saveResult.reviewReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'recorded',
|
||||||
|
kind: saveResult.paymentRecord.kind,
|
||||||
|
amount: Money.fromMinor(
|
||||||
|
saveResult.paymentRecord.amountMinor,
|
||||||
|
saveResult.paymentRecord.currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087",
|
"0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087",
|
||||||
"0010_wild_molecule_man.sql": "46027a6ac770cdc2efd4c3eb5bb53f09d1b852c70fdc46a2434e5a7064587245",
|
"0010_wild_molecule_man.sql": "46027a6ac770cdc2efd4c3eb5bb53f09d1b852c70fdc46a2434e5a7064587245",
|
||||||
"0011_previous_ezekiel_stane.sql": "d996e64d3854de22e36dedeaa94e46774399163d90263bbb05e0b9199af79b70",
|
"0011_previous_ezekiel_stane.sql": "d996e64d3854de22e36dedeaa94e46774399163d90263bbb05e0b9199af79b70",
|
||||||
"0012_clumsy_maestro.sql": "173797fb435c6acd7c268c624942d6f19a887c329bcef409a3dde1249baaeb8a"
|
"0012_clumsy_maestro.sql": "173797fb435c6acd7c268c624942d6f19a887c329bcef409a3dde1249baaeb8a",
|
||||||
|
"0013_wild_avengers.sql": "76254db09c9d623134712aee57a5896aa4a5b416e45d0f6c69dec1fec5b32af4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
packages/db/drizzle/0013_wild_avengers.sql
Normal file
51
packages/db/drizzle/0013_wild_avengers.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
CREATE TABLE "payment_confirmations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"cycle_id" uuid,
|
||||||
|
"member_id" uuid,
|
||||||
|
"sender_telegram_user_id" text NOT NULL,
|
||||||
|
"raw_text" text NOT NULL,
|
||||||
|
"normalized_text" text NOT NULL,
|
||||||
|
"detected_kind" text,
|
||||||
|
"explicit_amount_minor" bigint,
|
||||||
|
"explicit_currency" text,
|
||||||
|
"resolved_amount_minor" bigint,
|
||||||
|
"resolved_currency" text,
|
||||||
|
"status" text NOT NULL,
|
||||||
|
"review_reason" text,
|
||||||
|
"attachment_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"telegram_chat_id" text NOT NULL,
|
||||||
|
"telegram_message_id" text NOT NULL,
|
||||||
|
"telegram_thread_id" text NOT NULL,
|
||||||
|
"telegram_update_id" text NOT NULL,
|
||||||
|
"message_sent_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "payment_records" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"cycle_id" uuid NOT NULL,
|
||||||
|
"member_id" uuid NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"amount_minor" bigint NOT NULL,
|
||||||
|
"currency" text NOT NULL,
|
||||||
|
"confirmation_id" uuid,
|
||||||
|
"recorded_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "payment_confirmations" ADD CONSTRAINT "payment_confirmations_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "payment_confirmations" ADD CONSTRAINT "payment_confirmations_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "payment_confirmations" ADD CONSTRAINT "payment_confirmations_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_confirmation_id_payment_confirmations_id_fk" FOREIGN KEY ("confirmation_id") REFERENCES "public"."payment_confirmations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "payment_confirmations_household_tg_message_unique" ON "payment_confirmations" USING btree ("household_id","telegram_chat_id","telegram_message_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "payment_confirmations_household_tg_update_unique" ON "payment_confirmations" USING btree ("household_id","telegram_update_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "payment_confirmations_household_status_idx" ON "payment_confirmations" USING btree ("household_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "payment_confirmations_member_created_idx" ON "payment_confirmations" USING btree ("member_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "payment_records_cycle_member_idx" ON "payment_records" USING btree ("cycle_id","member_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "payment_records_cycle_kind_idx" ON "payment_records" USING btree ("cycle_id","kind");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "payment_records_confirmation_unique" ON "payment_records" USING btree ("confirmation_id");
|
||||||
2945
packages/db/drizzle/meta/0013_snapshot.json
Normal file
2945
packages/db/drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,13 @@
|
|||||||
"when": 1773146577992,
|
"when": 1773146577992,
|
||||||
"tag": "0012_clumsy_maestro",
|
"tag": "0012_clumsy_maestro",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773147481265,
|
||||||
|
"tag": "0013_wild_avengers",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -481,6 +481,83 @@ export const anonymousMessages = pgTable(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const paymentConfirmations = pgTable(
|
||||||
|
'payment_confirmations',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
householdId: uuid('household_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
|
cycleId: uuid('cycle_id').references(() => billingCycles.id, { onDelete: 'set null' }),
|
||||||
|
memberId: uuid('member_id').references(() => members.id, { onDelete: 'set null' }),
|
||||||
|
senderTelegramUserId: text('sender_telegram_user_id').notNull(),
|
||||||
|
rawText: text('raw_text').notNull(),
|
||||||
|
normalizedText: text('normalized_text').notNull(),
|
||||||
|
detectedKind: text('detected_kind'),
|
||||||
|
explicitAmountMinor: bigint('explicit_amount_minor', { mode: 'bigint' }),
|
||||||
|
explicitCurrency: text('explicit_currency'),
|
||||||
|
resolvedAmountMinor: bigint('resolved_amount_minor', { mode: 'bigint' }),
|
||||||
|
resolvedCurrency: text('resolved_currency'),
|
||||||
|
status: text('status').notNull(),
|
||||||
|
reviewReason: text('review_reason'),
|
||||||
|
attachmentCount: integer('attachment_count').default(0).notNull(),
|
||||||
|
telegramChatId: text('telegram_chat_id').notNull(),
|
||||||
|
telegramMessageId: text('telegram_message_id').notNull(),
|
||||||
|
telegramThreadId: text('telegram_thread_id').notNull(),
|
||||||
|
telegramUpdateId: text('telegram_update_id').notNull(),
|
||||||
|
messageSentAt: timestamp('message_sent_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
householdMessageUnique: uniqueIndex('payment_confirmations_household_tg_message_unique').on(
|
||||||
|
table.householdId,
|
||||||
|
table.telegramChatId,
|
||||||
|
table.telegramMessageId
|
||||||
|
),
|
||||||
|
householdUpdateUnique: uniqueIndex('payment_confirmations_household_tg_update_unique').on(
|
||||||
|
table.householdId,
|
||||||
|
table.telegramUpdateId
|
||||||
|
),
|
||||||
|
householdStatusIdx: index('payment_confirmations_household_status_idx').on(
|
||||||
|
table.householdId,
|
||||||
|
table.status
|
||||||
|
),
|
||||||
|
memberCreatedIdx: index('payment_confirmations_member_created_idx').on(
|
||||||
|
table.memberId,
|
||||||
|
table.createdAt
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const paymentRecords = pgTable(
|
||||||
|
'payment_records',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
householdId: uuid('household_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
|
cycleId: uuid('cycle_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => billingCycles.id, { onDelete: 'cascade' }),
|
||||||
|
memberId: uuid('member_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => members.id, { onDelete: 'restrict' }),
|
||||||
|
kind: text('kind').notNull(),
|
||||||
|
amountMinor: bigint('amount_minor', { mode: 'bigint' }).notNull(),
|
||||||
|
currency: text('currency').notNull(),
|
||||||
|
confirmationId: uuid('confirmation_id').references(() => paymentConfirmations.id, {
|
||||||
|
onDelete: 'set null'
|
||||||
|
}),
|
||||||
|
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
cycleMemberIdx: index('payment_records_cycle_member_idx').on(table.cycleId, table.memberId),
|
||||||
|
cycleKindIdx: index('payment_records_cycle_kind_idx').on(table.cycleId, table.kind),
|
||||||
|
confirmationUnique: uniqueIndex('payment_records_confirmation_unique').on(table.confirmationId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const settlements = pgTable(
|
export const settlements = pgTable(
|
||||||
'settlements',
|
'settlements',
|
||||||
{
|
{
|
||||||
@@ -548,4 +625,6 @@ export type UtilityBill = typeof utilityBills.$inferSelect
|
|||||||
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
||||||
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
||||||
export type AnonymousMessage = typeof anonymousMessages.$inferSelect
|
export type AnonymousMessage = typeof anonymousMessages.$inferSelect
|
||||||
|
export type PaymentConfirmation = typeof paymentConfirmations.$inferSelect
|
||||||
|
export type PaymentRecord = typeof paymentRecords.$inferSelect
|
||||||
export type Settlement = typeof settlements.$inferSelect
|
export type Settlement = typeof settlements.$inferSelect
|
||||||
|
|||||||
@@ -46,6 +46,83 @@ export interface FinanceUtilityBillRecord {
|
|||||||
createdAt: Instant
|
createdAt: Instant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FinancePaymentKind = 'rent' | 'utilities'
|
||||||
|
|
||||||
|
export interface FinancePaymentRecord {
|
||||||
|
id: string
|
||||||
|
memberId: string
|
||||||
|
kind: FinancePaymentKind
|
||||||
|
amountMinor: bigint
|
||||||
|
currency: CurrencyCode
|
||||||
|
recordedAt: Instant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinanceSettlementSnapshotLineRecord {
|
||||||
|
memberId: string
|
||||||
|
rentShareMinor: bigint
|
||||||
|
utilityShareMinor: bigint
|
||||||
|
purchaseOffsetMinor: bigint
|
||||||
|
netDueMinor: bigint
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinancePaymentConfirmationMessage {
|
||||||
|
senderTelegramUserId: string
|
||||||
|
rawText: string
|
||||||
|
normalizedText: string
|
||||||
|
telegramChatId: string
|
||||||
|
telegramMessageId: string
|
||||||
|
telegramThreadId: string
|
||||||
|
telegramUpdateId: string
|
||||||
|
attachmentCount: number
|
||||||
|
messageSentAt: Instant | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FinancePaymentConfirmationReviewReason =
|
||||||
|
| 'member_not_found'
|
||||||
|
| 'cycle_not_found'
|
||||||
|
| 'settlement_not_ready'
|
||||||
|
| 'intent_missing'
|
||||||
|
| 'kind_ambiguous'
|
||||||
|
| 'multiple_members'
|
||||||
|
| 'non_positive_amount'
|
||||||
|
|
||||||
|
export type FinancePaymentConfirmationSaveInput =
|
||||||
|
| (FinancePaymentConfirmationMessage & {
|
||||||
|
status: 'recorded'
|
||||||
|
cycleId: string
|
||||||
|
memberId: string
|
||||||
|
kind: FinancePaymentKind
|
||||||
|
amountMinor: bigint
|
||||||
|
currency: CurrencyCode
|
||||||
|
explicitAmountMinor: bigint | null
|
||||||
|
explicitCurrency: CurrencyCode | null
|
||||||
|
recordedAt: Instant
|
||||||
|
})
|
||||||
|
| (FinancePaymentConfirmationMessage & {
|
||||||
|
status: 'needs_review'
|
||||||
|
cycleId: string | null
|
||||||
|
memberId: string | null
|
||||||
|
kind: FinancePaymentKind | null
|
||||||
|
amountMinor: bigint | null
|
||||||
|
currency: CurrencyCode | null
|
||||||
|
explicitAmountMinor: bigint | null
|
||||||
|
explicitCurrency: CurrencyCode | null
|
||||||
|
reviewReason: FinancePaymentConfirmationReviewReason
|
||||||
|
})
|
||||||
|
|
||||||
|
export type FinancePaymentConfirmationSaveResult =
|
||||||
|
| {
|
||||||
|
status: 'duplicate'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'recorded'
|
||||||
|
paymentRecord: FinancePaymentRecord
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'needs_review'
|
||||||
|
reviewReason: FinancePaymentConfirmationReviewReason
|
||||||
|
}
|
||||||
|
|
||||||
export interface SettlementSnapshotLineRecord {
|
export interface SettlementSnapshotLineRecord {
|
||||||
memberId: string
|
memberId: string
|
||||||
rentShareMinor: bigint
|
rentShareMinor: bigint
|
||||||
@@ -91,9 +168,16 @@ export interface FinanceRepository {
|
|||||||
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
|
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
|
||||||
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
||||||
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
||||||
|
listPaymentRecordsForCycle(cycleId: string): Promise<readonly FinancePaymentRecord[]>
|
||||||
listParsedPurchasesForRange(
|
listParsedPurchasesForRange(
|
||||||
start: Instant,
|
start: Instant,
|
||||||
end: Instant
|
end: Instant
|
||||||
): Promise<readonly FinanceParsedPurchaseRecord[]>
|
): Promise<readonly FinanceParsedPurchaseRecord[]>
|
||||||
|
getSettlementSnapshotLines(
|
||||||
|
cycleId: string
|
||||||
|
): Promise<readonly FinanceSettlementSnapshotLineRecord[]>
|
||||||
|
savePaymentConfirmation(
|
||||||
|
input: FinancePaymentConfirmationSaveInput
|
||||||
|
): Promise<FinancePaymentConfirmationSaveResult>
|
||||||
replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void>
|
replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CurrencyCode, SupportedLocale } from '@household/domain'
|
import type { CurrencyCode, SupportedLocale } from '@household/domain'
|
||||||
import type { ReminderTarget } from './reminders'
|
import type { ReminderTarget } from './reminders'
|
||||||
|
|
||||||
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const
|
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders', 'payments'] as const
|
||||||
|
|
||||||
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
|
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export type {
|
|||||||
export type {
|
export type {
|
||||||
FinanceCycleRecord,
|
FinanceCycleRecord,
|
||||||
FinanceCycleExchangeRateRecord,
|
FinanceCycleExchangeRateRecord,
|
||||||
|
FinancePaymentConfirmationReviewReason,
|
||||||
|
FinancePaymentConfirmationSaveInput,
|
||||||
|
FinancePaymentConfirmationSaveResult,
|
||||||
|
FinancePaymentKind,
|
||||||
|
FinancePaymentRecord,
|
||||||
|
FinanceSettlementSnapshotLineRecord,
|
||||||
FinanceMemberRecord,
|
FinanceMemberRecord,
|
||||||
FinanceParsedPurchaseRecord,
|
FinanceParsedPurchaseRecord,
|
||||||
FinanceRentRuleRecord,
|
FinanceRentRuleRecord,
|
||||||
|
|||||||
Reference in New Issue
Block a user