feat(payments): add transparent balance guidance

This commit is contained in:
2026-03-11 14:52:09 +04:00
parent 8401688032
commit 79f96ba45b
25 changed files with 3855 additions and 93 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { FinanceCommandService } from '@household/application' import type { FinanceCommandService } from '@household/application'
import { Money } from '@household/domain'
import type { import type {
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
ProcessedBotMessageRepository, ProcessedBotMessageRepository,
@@ -247,57 +248,23 @@ function createFinanceService(): FinanceCommandService {
generateDashboard: async () => ({ generateDashboard: async () => ({
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
totalDue: { totalDue: Money.fromMajor('1000.00', 'GEL'),
toMajorString: () => '1000.00' totalPaid: Money.fromMajor('500.00', 'GEL'),
} as never, totalRemaining: Money.fromMajor('500.00', 'GEL'),
totalPaid: { rentSourceAmount: Money.fromMajor('700.00', 'USD'),
toMajorString: () => '500.00' rentDisplayAmount: Money.fromMajor('1890.00', 'GEL'),
} as never,
totalRemaining: {
toMajorString: () => '500.00'
} as never,
rentSourceAmount: {
currency: 'USD',
toMajorString: () => '700.00'
} as never,
rentDisplayAmount: {
toMajorString: () => '1890.00'
} as never,
rentFxRateMicros: null, rentFxRateMicros: null,
rentFxEffectiveDate: null, rentFxEffectiveDate: null,
members: [ members: [
{ {
memberId: 'member-1', memberId: 'member-1',
displayName: 'Stan', displayName: 'Stan',
rentShare: { rentShare: Money.fromMajor('700.00', 'GEL'),
amountMinor: 70000n, utilityShare: Money.fromMajor('100.00', 'GEL'),
currency: 'GEL', purchaseOffset: Money.fromMajor('50.00', 'GEL'),
toMajorString: () => '700.00' netDue: Money.fromMajor('850.00', 'GEL'),
} as never, paid: Money.fromMajor('500.00', 'GEL'),
utilityShare: { remaining: Money.fromMajor('350.00', 'GEL'),
amountMinor: 10000n,
currency: 'GEL',
toMajorString: () => '100.00'
} as never,
purchaseOffset: {
amountMinor: 5000n,
currency: 'GEL',
toMajorString: () => '50.00',
add: () => ({
amountMinor: 15000n,
currency: 'GEL',
toMajorString: () => '150.00'
})
} as never,
netDue: {
toMajorString: () => '850.00'
} as never,
paid: {
toMajorString: () => '500.00'
} as never,
remaining: {
toMajorString: () => '350.00'
} as never,
explanations: [] explanations: []
} }
], ],
@@ -307,13 +274,9 @@ function createFinanceService(): FinanceCommandService {
kind: 'purchase' as const, kind: 'purchase' as const,
title: 'Soap', title: 'Soap',
memberId: 'member-1', memberId: 'member-1',
amount: { amount: Money.fromMajor('30.00', 'GEL'),
toMajorString: () => '30.00'
} as never,
currency: 'GEL' as const, currency: 'GEL' as const,
displayAmount: { displayAmount: Money.fromMajor('30.00', 'GEL'),
toMajorString: () => '30.00'
} as never,
displayCurrency: 'GEL' as const, displayCurrency: 'GEL' as const,
fxRateMicros: null, fxRateMicros: null,
fxEffectiveDate: null, fxEffectiveDate: null,
@@ -702,7 +665,7 @@ describe('registerDmAssistant', () => {
expect(calls).toHaveLength(1) expect(calls).toHaveLength(1)
expect(calls[0]?.payload).toMatchObject({ expect(calls[0]?.payload).toMatchObject({
text: 'I can record this rent payment: 700.00 GEL. Confirm or cancel below.', text: expect.stringContaining('I can record this rent payment: 700.00 GEL.'),
reply_markup: { reply_markup: {
inline_keyboard: [ inline_keyboard: [
[ [
@@ -730,6 +693,44 @@ describe('registerDmAssistant', () => {
}) })
}) })
test('answers utilities balance questions deterministically in DM', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
registerDmAssistant({
bot,
householdConfigurationRepository: createHouseholdRepository(),
promptRepository: createPromptRepository(),
financeServiceForHousehold: () => createFinanceService(),
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
rateLimiter: createInMemoryAssistantRateLimiter({
burstLimit: 5,
burstWindowMs: 60_000,
rollingLimit: 50,
rollingWindowMs: 86_400_000
}),
usageTracker: createInMemoryAssistantUsageTracker()
})
await bot.handleUpdate(privateMessageUpdate('How much do I owe for utilities?') as never)
const replyCall = calls.find((call) => call.method === 'sendMessage')
expect(replyCall).toBeDefined()
const replyText = String((replyCall?.payload as { text?: unknown } | undefined)?.text ?? '')
expect(replyText).toContain('Current utilities payment guidance:')
expect(replyText).toContain('Utilities due: 100.00 GEL')
expect(replyText).toContain('Purchase balance: 50.00 GEL')
expect(replyText).toContain('Suggested payment under utilities adjustment: 150.00 GEL')
})
test('routes obvious purchase-like DMs into purchase confirmation flow', async () => { test('routes obvious purchase-like DMs into purchase confirmation flow', async () => {
const bot = createTestBot() const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []

View File

@@ -1,4 +1,4 @@
import type { FinanceCommandService } from '@household/application' import { buildMemberPaymentGuidance, type FinanceCommandService } from '@household/application'
import { instantFromEpochSeconds, Money } from '@household/domain' import { instantFromEpochSeconds, Money } from '@household/domain'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import type { import type {
@@ -12,7 +12,13 @@ import { resolveReplyLocale } from './bot-locale'
import { getBotTranslations, type BotLocale } from './i18n' import { getBotTranslations, type BotLocale } from './i18n'
import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant' import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant'
import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter' import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter'
import { maybeCreatePaymentProposal, parsePaymentProposalPayload } from './payment-proposals' import {
formatPaymentBalanceReplyText,
formatPaymentProposalText,
maybeCreatePaymentBalanceReply,
maybeCreatePaymentProposal,
parsePaymentProposalPayload
} from './payment-proposals'
import type { import type {
PurchaseMessageIngestionRepository, PurchaseMessageIngestionRepository,
PurchaseProposalActionResult, PurchaseProposalActionResult,
@@ -455,12 +461,34 @@ async function buildHouseholdContext(input: {
const memberLine = dashboard.members.find((line) => line.memberId === input.memberId) const memberLine = dashboard.members.find((line) => line.memberId === input.memberId)
if (memberLine) { if (memberLine) {
const rentGuidance = buildMemberPaymentGuidance({
kind: 'rent',
period: dashboard.period,
memberLine,
settings
})
const utilitiesGuidance = buildMemberPaymentGuidance({
kind: 'utilities',
period: dashboard.period,
memberLine,
settings
})
lines.push( lines.push(
`Member balance: due ${memberLine.netDue.toMajorString()} ${dashboard.currency}, paid ${memberLine.paid.toMajorString()} ${dashboard.currency}, remaining ${memberLine.remaining.toMajorString()} ${dashboard.currency}` `Member balance: due ${memberLine.netDue.toMajorString()} ${dashboard.currency}, paid ${memberLine.paid.toMajorString()} ${dashboard.currency}, remaining ${memberLine.remaining.toMajorString()} ${dashboard.currency}`
) )
lines.push( lines.push(
`Rent share: ${memberLine.rentShare.toMajorString()} ${dashboard.currency}; utility share: ${memberLine.utilityShare.toMajorString()} ${dashboard.currency}; purchase offset: ${memberLine.purchaseOffset.toMajorString()} ${dashboard.currency}` `Rent share: ${memberLine.rentShare.toMajorString()} ${dashboard.currency}; utility share: ${memberLine.utilityShare.toMajorString()} ${dashboard.currency}; purchase offset: ${memberLine.purchaseOffset.toMajorString()} ${dashboard.currency}`
) )
lines.push(
`Payment adjustment policy: ${settings.paymentBalanceAdjustmentPolicy ?? 'utilities'}`
)
lines.push(
`Rent payment guidance: base ${rentGuidance.baseAmount.toMajorString()} ${dashboard.currency}; purchase offset ${rentGuidance.purchaseOffset.toMajorString()} ${dashboard.currency}; suggested payment ${rentGuidance.proposalAmount.toMajorString()} ${dashboard.currency}; reminder ${rentGuidance.reminderDate}; due ${rentGuidance.dueDate}`
)
lines.push(
`Utilities payment guidance: base ${utilitiesGuidance.baseAmount.toMajorString()} ${dashboard.currency}; purchase offset ${utilitiesGuidance.purchaseOffset.toMajorString()} ${dashboard.currency}; suggested payment ${utilitiesGuidance.proposalAmount.toMajorString()} ${dashboard.currency}; reminder ${utilitiesGuidance.reminderDate}; due ${utilitiesGuidance.dueDate}; payment_window_open=${utilitiesGuidance.paymentWindowOpen}`
)
} }
lines.push( lines.push(
@@ -993,6 +1021,29 @@ export function registerDmAssistant(options: {
} }
const financeService = options.financeServiceForHousehold(member.householdId) const financeService = options.financeServiceForHousehold(member.householdId)
const paymentBalanceReply = await maybeCreatePaymentBalanceReply({
rawText: ctx.msg.text,
householdId: member.householdId,
memberId: member.id,
financeService,
householdConfigurationRepository: options.householdConfigurationRepository
})
if (paymentBalanceReply) {
const replyText = formatPaymentBalanceReplyText(locale, paymentBalanceReply)
options.memoryStore.appendTurn(memoryKey, {
role: 'user',
text: ctx.msg.text
})
options.memoryStore.appendTurn(memoryKey, {
role: 'assistant',
text: replyText
})
await ctx.reply(replyText)
return
}
const paymentProposal = await maybeCreatePaymentProposal({ const paymentProposal = await maybeCreatePaymentProposal({
rawText: ctx.msg.text, rawText: ctx.msg.text,
householdId: member.householdId, householdId: member.householdId,
@@ -1027,15 +1078,11 @@ export function registerDmAssistant(options: {
expiresAt: null expiresAt: null
}) })
const amount = Money.fromMinor( const proposalText = formatPaymentProposalText({
BigInt(paymentProposal.payload.amountMinor), locale,
paymentProposal.payload.currency surface: 'assistant',
) proposal: paymentProposal
const proposalText = t.paymentProposal( })
paymentProposal.payload.kind,
amount.toMajorString(),
amount.currency
)
options.memoryStore.appendTurn(memoryKey, { options.memoryStore.appendTurn(memoryKey, {
role: 'user', role: 'user',
text: ctx.msg.text text: ctx.msg.text
@@ -1155,6 +1202,20 @@ export function registerDmAssistant(options: {
} }
try { try {
const financeService = options.financeServiceForHousehold(household.householdId)
const paymentBalanceReply = await maybeCreatePaymentBalanceReply({
rawText: mention.strippedText,
householdId: household.householdId,
memberId: member.id,
financeService,
householdConfigurationRepository: options.householdConfigurationRepository
})
if (paymentBalanceReply) {
await ctx.reply(formatPaymentBalanceReplyText(locale, paymentBalanceReply))
return
}
await replyWithAssistant({ await replyWithAssistant({
ctx, ctx,
assistant: options.assistant, assistant: options.assistant,
@@ -1166,7 +1227,7 @@ export function registerDmAssistant(options: {
locale, locale,
userMessage: mention.strippedText, userMessage: mention.strippedText,
householdConfigurationRepository: options.householdConfigurationRepository, householdConfigurationRepository: options.householdConfigurationRepository,
financeService: options.financeServiceForHousehold(household.householdId), financeService,
memoryStore: options.memoryStore, memoryStore: options.memoryStore,
usageTracker: options.usageTracker, usageTracker: options.usageTracker,
logger: options.logger logger: options.logger

View File

@@ -267,6 +267,8 @@ export const enBotTranslations: BotTranslationCatalog = {
payments: { payments: {
topicMissing: topicMissing:
'Payments topic is not configured for this household yet. Ask an admin to run /bind_payments_topic.', 'Payments topic is not configured for this household yet. Ask an admin to run /bind_payments_topic.',
balanceReply: (kind) =>
kind === 'rent' ? 'Current rent payment guidance:' : 'Current utilities payment guidance:',
proposal: (kind, amount, currency) => proposal: (kind, amount, currency) =>
`I can record this ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}. Confirm or cancel below.`, `I can record this ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}. Confirm or cancel below.`,
clarification: clarification:
@@ -274,6 +276,24 @@ export const enBotTranslations: BotTranslationCatalog = {
unsupportedCurrency: unsupportedCurrency:
'I can only record payments in the household settlement currency for this topic right now.', 'I can only record payments in the household settlement currency for this topic right now.',
noBalance: 'There is no payable balance for that payment type right now.', noBalance: 'There is no payable balance for that payment type right now.',
breakdownBase: (kind, amount, currency) =>
`${kind === 'rent' ? 'Rent due' : 'Utilities due'}: ${amount} ${currency}`,
breakdownPurchaseBalance: (amount, currency) => `Purchase balance: ${amount} ${currency}`,
breakdownSuggestedTotal: (amount, currency, policy) =>
`Suggested payment under ${policy}: ${amount} ${currency}`,
breakdownRecordingAmount: (amount, currency) =>
`Amount from your message: ${amount} ${currency}`,
breakdownRemaining: (amount, currency) => `Total remaining balance: ${amount} ${currency}`,
adjustmentPolicy: (policy) =>
policy === 'utilities'
? 'utilities adjustment'
: policy === 'rent'
? 'rent adjustment'
: 'separate purchase settlement',
timingBeforeWindow: (kind, reminderDate, dueDate) =>
`${kind === 'rent' ? 'Rent' : 'Utilities'} are not due yet. Next reminder: ${reminderDate}. Due date: ${dueDate}.`,
timingDueNow: (kind, dueDate) =>
`${kind === 'rent' ? 'Rent' : 'Utilities'} are due now. Due date: ${dueDate}.`,
confirmButton: 'Confirm payment', confirmButton: 'Confirm payment',
cancelButton: 'Cancel', cancelButton: 'Cancel',
recorded: (kind, amount, currency) => recorded: (kind, amount, currency) =>

View File

@@ -270,6 +270,8 @@ export const ruBotTranslations: BotTranslationCatalog = {
payments: { payments: {
topicMissing: topicMissing:
'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.', 'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.',
balanceReply: (kind) =>
kind === 'rent' ? 'Текущая сводка по аренде:' : 'Текущая сводка по коммуналке:',
proposal: (kind, amount, currency) => proposal: (kind, amount, currency) =>
`Я могу записать эту оплату ${kind === 'rent' ? 'аренды' : 'коммуналки'}: ${amount} ${currency}. Подтвердите или отмените ниже.`, `Я могу записать эту оплату ${kind === 'rent' ? 'аренды' : 'коммуналки'}: ${amount} ${currency}. Подтвердите или отмените ниже.`,
clarification: clarification:
@@ -277,6 +279,25 @@ export const ruBotTranslations: BotTranslationCatalog = {
unsupportedCurrency: unsupportedCurrency:
'Сейчас я могу записывать оплаты в этом топике только в валюте расчётов по дому.', 'Сейчас я могу записывать оплаты в этом топике только в валюте расчётов по дому.',
noBalance: 'Сейчас для этого типа оплаты нет суммы к подтверждению.', noBalance: 'Сейчас для этого типа оплаты нет суммы к подтверждению.',
breakdownBase: (kind, amount, currency) =>
`${kind === 'rent' ? 'Аренда к оплате' : 'Коммуналка к оплате'}: ${amount} ${currency}`,
breakdownPurchaseBalance: (amount, currency) =>
`Баланс по общим покупкам: ${amount} ${currency}`,
breakdownSuggestedTotal: (amount, currency, policy) =>
`Рекомендуемая сумма по политике «${policy}»: ${amount} ${currency}`,
breakdownRecordingAmount: (amount, currency) =>
`Сумма из вашего сообщения: ${amount} ${currency}`,
breakdownRemaining: (amount, currency) => `Общий остаток: ${amount} ${currency}`,
adjustmentPolicy: (policy) =>
policy === 'utilities'
? 'зачёт через коммуналку'
: policy === 'rent'
? 'зачёт через аренду'
: 'отдельный расчёт по покупкам',
timingBeforeWindow: (kind, reminderDate, dueDate) =>
`${kind === 'rent' ? 'Аренду' : 'Коммуналку'} пока рано оплачивать. Следующее напоминание: ${reminderDate}. Срок оплаты: ${dueDate}.`,
timingDueNow: (kind, dueDate) =>
`${kind === 'rent' ? 'Аренду' : 'Коммуналку'} уже пора оплачивать. Срок оплаты: ${dueDate}.`,
confirmButton: 'Подтвердить оплату', confirmButton: 'Подтвердить оплату',
cancelButton: 'Отменить', cancelButton: 'Отменить',
recorded: (kind, amount, currency) => recorded: (kind, amount, currency) =>

View File

@@ -254,11 +254,24 @@ export interface BotTranslationCatalog {
} }
payments: { payments: {
topicMissing: string topicMissing: string
balanceReply: (kind: 'rent' | 'utilities') => string
recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
proposal: (kind: 'rent' | 'utilities', amount: string, currency: string) => string proposal: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
clarification: string clarification: string
unsupportedCurrency: string unsupportedCurrency: string
noBalance: string noBalance: string
breakdownBase: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
breakdownPurchaseBalance: (amount: string, currency: string) => string
breakdownSuggestedTotal: (amount: string, currency: string, policy: string) => string
breakdownRecordingAmount: (amount: string, currency: string) => string
breakdownRemaining: (amount: string, currency: string) => string
adjustmentPolicy: (policy: 'utilities' | 'rent' | 'separate') => string
timingBeforeWindow: (
kind: 'rent' | 'utilities',
reminderDate: string,
dueDate: string
) => string
timingDueNow: (kind: 'rent' | 'utilities', dueDate: string) => string
confirmButton: string confirmButton: string
cancelButton: string cancelButton: string
cancelled: string cancelled: string

View File

@@ -452,7 +452,8 @@ describe('createMiniAppSettingsHandler', () => {
rentWarningDay: 17, rentWarningDay: 17,
utilitiesDueDay: 4, utilitiesDueDay: 4,
utilitiesReminderDay: 3, utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi' timezone: 'Asia/Tbilisi',
paymentBalanceAdjustmentPolicy: 'utilities'
}, },
topics: [ topics: [
{ {
@@ -547,7 +548,8 @@ describe('createMiniAppUpdateSettingsHandler', () => {
rentWarningDay: 19, rentWarningDay: 19,
utilitiesDueDay: 6, utilitiesDueDay: 6,
utilitiesReminderDay: 5, utilitiesReminderDay: 5,
timezone: 'Asia/Tbilisi' timezone: 'Asia/Tbilisi',
paymentBalanceAdjustmentPolicy: 'utilities'
} }
}) })
}) })

View File

@@ -50,6 +50,7 @@ async function readApprovalPayload(request: Request): Promise<{
async function readSettingsUpdatePayload(request: Request): Promise<{ async function readSettingsUpdatePayload(request: Request): Promise<{
initData: string initData: string
settlementCurrency?: string settlementCurrency?: string
paymentBalanceAdjustmentPolicy?: string
rentAmountMajor?: string rentAmountMajor?: string
rentCurrency?: string rentCurrency?: string
rentDueDay: number rentDueDay: number
@@ -67,6 +68,7 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
const text = await clonedRequest.text() const text = await clonedRequest.text()
let parsed: { let parsed: {
settlementCurrency?: string settlementCurrency?: string
paymentBalanceAdjustmentPolicy?: string
rentAmountMajor?: string rentAmountMajor?: string
rentCurrency?: string rentCurrency?: string
rentDueDay?: number rentDueDay?: number
@@ -103,6 +105,11 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
settlementCurrency: parsed.settlementCurrency settlementCurrency: parsed.settlementCurrency
} }
: {}), : {}),
...(typeof parsed.paymentBalanceAdjustmentPolicy === 'string'
? {
paymentBalanceAdjustmentPolicy: parsed.paymentBalanceAdjustmentPolicy
}
: {}),
...(typeof parsed.rentCurrency === 'string' ...(typeof parsed.rentCurrency === 'string'
? { ? {
rentCurrency: parsed.rentCurrency rentCurrency: parsed.rentCurrency
@@ -299,6 +306,7 @@ function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) {
return { return {
householdId: settings.householdId, householdId: settings.householdId,
settlementCurrency: settings.settlementCurrency, settlementCurrency: settings.settlementCurrency,
paymentBalanceAdjustmentPolicy: settings.paymentBalanceAdjustmentPolicy ?? 'utilities',
rentAmountMinor: settings.rentAmountMinor?.toString() ?? null, rentAmountMinor: settings.rentAmountMinor?.toString() ?? null,
rentCurrency: settings.rentCurrency, rentCurrency: settings.rentCurrency,
rentDueDay: settings.rentDueDay, rentDueDay: settings.rentDueDay,
@@ -555,6 +563,11 @@ export function createMiniAppUpdateSettingsHandler(options: {
settlementCurrency: payload.settlementCurrency settlementCurrency: payload.settlementCurrency
} }
: {}), : {}),
...(payload.paymentBalanceAdjustmentPolicy
? {
paymentBalanceAdjustmentPolicy: payload.paymentBalanceAdjustmentPolicy
}
: {}),
...(payload.rentAmountMajor !== undefined ...(payload.rentAmountMajor !== undefined
? { ? {
rentAmountMajor: payload.rentAmountMajor rentAmountMajor: payload.rentAmountMajor

View File

@@ -1,7 +1,34 @@
import { parsePaymentConfirmationMessage, type FinanceCommandService } from '@household/application' import {
buildMemberPaymentGuidance,
parsePaymentConfirmationMessage,
type FinanceCommandService,
type MemberPaymentGuidance
} from '@household/application'
import { Money } from '@household/domain' import { Money } from '@household/domain'
import type { HouseholdConfigurationRepository } from '@household/ports' import type { HouseholdConfigurationRepository } from '@household/ports'
import { getBotTranslations, type BotLocale } from './i18n'
const RENT_BALANCE_KEYWORDS = [/\b(rent|housing|apartment|landlord)\b/i, /аренд/i, /жиль[её]/i]
const UTILITIES_BALANCE_KEYWORDS = [
/\b(utilities|utility|gas|water|electricity|internet|cleaning)\b/i,
/коммун/i,
/газ/i,
/вод/i,
/элект/i,
/свет/i,
/интернет/i,
/уборк/i
]
const BALANCE_QUESTION_KEYWORDS = [
/\?/,
/\b(how much|owe|due|balance|remaining)\b/i,
/сколько/i,
/долж/i,
/баланс/i,
/остат/i
]
export interface PaymentProposalPayload { export interface PaymentProposalPayload {
proposalId: string proposalId: string
householdId: string householdId: string
@@ -11,6 +38,16 @@ export interface PaymentProposalPayload {
currency: 'GEL' | 'USD' currency: 'GEL' | 'USD'
} }
export interface PaymentProposalBreakdown {
guidance: MemberPaymentGuidance
explicitAmount: Money | null
}
export interface PaymentBalanceReply {
kind: 'rent' | 'utilities'
guidance: MemberPaymentGuidance
}
export function parsePaymentProposalPayload( export function parsePaymentProposalPayload(
payload: Record<string, unknown> payload: Record<string, unknown>
): PaymentProposalPayload | null { ): PaymentProposalPayload | null {
@@ -39,6 +76,147 @@ export function parsePaymentProposalPayload(
} }
} }
function hasMatch(patterns: readonly RegExp[], value: string): boolean {
return patterns.some((pattern) => pattern.test(value))
}
function detectBalanceQuestionKind(rawText: string): 'rent' | 'utilities' | null {
const normalized = rawText.trim()
if (normalized.length === 0 || !hasMatch(BALANCE_QUESTION_KEYWORDS, normalized)) {
return null
}
const mentionsRent = hasMatch(RENT_BALANCE_KEYWORDS, normalized)
const mentionsUtilities = hasMatch(UTILITIES_BALANCE_KEYWORDS, normalized)
if (mentionsRent === mentionsUtilities) {
return null
}
return mentionsRent ? 'rent' : 'utilities'
}
function formatDateLabel(locale: BotLocale, rawDate: string): string {
const [yearRaw, monthRaw, dayRaw] = rawDate.split('-')
const year = Number(yearRaw)
const month = Number(monthRaw)
const day = Number(dayRaw)
if (
!Number.isInteger(year) ||
!Number.isInteger(month) ||
!Number.isInteger(day) ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31
) {
return rawDate
}
return new Intl.DateTimeFormat(locale === 'ru' ? 'ru-RU' : 'en-US', {
day: 'numeric',
month: 'long',
timeZone: 'UTC'
}).format(new Date(Date.UTC(year, month - 1, day)))
}
function formatPaymentBreakdown(locale: BotLocale, breakdown: PaymentProposalBreakdown): string {
const t = getBotTranslations(locale).payments
const policyLabel = t.adjustmentPolicy(breakdown.guidance.adjustmentPolicy)
const lines = [
t.breakdownBase(
breakdown.guidance.kind,
breakdown.guidance.baseAmount.toMajorString(),
breakdown.guidance.baseAmount.currency
),
t.breakdownPurchaseBalance(
breakdown.guidance.purchaseOffset.toMajorString(),
breakdown.guidance.purchaseOffset.currency
),
t.breakdownSuggestedTotal(
breakdown.guidance.proposalAmount.toMajorString(),
breakdown.guidance.proposalAmount.currency,
policyLabel
),
t.breakdownRemaining(
breakdown.guidance.totalRemaining.toMajorString(),
breakdown.guidance.totalRemaining.currency
)
]
if (
breakdown.explicitAmount &&
!breakdown.explicitAmount.equals(breakdown.guidance.proposalAmount)
) {
lines.push(
t.breakdownRecordingAmount(
breakdown.explicitAmount.toMajorString(),
breakdown.explicitAmount.currency
)
)
}
if (!breakdown.guidance.paymentWindowOpen) {
lines.push(
t.timingBeforeWindow(
breakdown.guidance.kind,
formatDateLabel(locale, breakdown.guidance.reminderDate),
formatDateLabel(locale, breakdown.guidance.dueDate)
)
)
} else if (breakdown.guidance.paymentDue) {
lines.push(
t.timingDueNow(breakdown.guidance.kind, formatDateLabel(locale, breakdown.guidance.dueDate))
)
}
return lines.join('\n')
}
export function formatPaymentProposalText(input: {
locale: BotLocale
surface: 'assistant' | 'topic'
proposal: {
payload: PaymentProposalPayload
breakdown: PaymentProposalBreakdown
}
}): string {
const amount = Money.fromMinor(
BigInt(input.proposal.payload.amountMinor),
input.proposal.payload.currency
)
const intro =
input.surface === 'assistant'
? getBotTranslations(input.locale).assistant.paymentProposal(
input.proposal.payload.kind,
amount.toMajorString(),
amount.currency
)
: getBotTranslations(input.locale).payments.proposal(
input.proposal.payload.kind,
amount.toMajorString(),
amount.currency
)
return `${intro}\n\n${formatPaymentBreakdown(input.locale, input.proposal.breakdown)}`
}
export function formatPaymentBalanceReplyText(
locale: BotLocale,
reply: PaymentBalanceReply
): string {
const t = getBotTranslations(locale).payments
return [
t.balanceReply(reply.kind),
formatPaymentBreakdown(locale, {
guidance: reply.guidance,
explicitAmount: null
})
].join('\n\n')
}
export async function maybeCreatePaymentProposal(input: { export async function maybeCreatePaymentProposal(input: {
rawText: string rawText: string
householdId: string householdId: string
@@ -61,6 +239,7 @@ export async function maybeCreatePaymentProposal(input: {
| { | {
status: 'proposal' status: 'proposal'
payload: PaymentProposalPayload payload: PaymentProposalPayload
breakdown: PaymentProposalBreakdown
} }
> { > {
const settings = await input.householdConfigurationRepository.getHouseholdBillingSettings( const settings = await input.householdConfigurationRepository.getHouseholdBillingSettings(
@@ -100,11 +279,13 @@ export async function maybeCreatePaymentProposal(input: {
} }
} }
const amount = const guidance = buildMemberPaymentGuidance({
parsed.explicitAmount ?? kind: parsed.kind,
(parsed.kind === 'rent' period: dashboard.period,
? memberLine.rentShare memberLine,
: memberLine.utilityShare.add(memberLine.purchaseOffset)) settings
})
const amount = parsed.explicitAmount ?? guidance.proposalAmount
if (amount.amountMinor <= 0n) { if (amount.amountMinor <= 0n) {
return { return {
@@ -121,10 +302,50 @@ export async function maybeCreatePaymentProposal(input: {
kind: parsed.kind, kind: parsed.kind,
amountMinor: amount.amountMinor.toString(), amountMinor: amount.amountMinor.toString(),
currency: amount.currency currency: amount.currency
},
breakdown: {
guidance,
explicitAmount: parsed.explicitAmount
} }
} }
} }
export async function maybeCreatePaymentBalanceReply(input: {
rawText: string
householdId: string
memberId: string
financeService: FinanceCommandService
householdConfigurationRepository: HouseholdConfigurationRepository
}): Promise<PaymentBalanceReply | null> {
const kind = detectBalanceQuestionKind(input.rawText)
if (!kind) {
return null
}
const [settings, dashboard] = await Promise.all([
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId),
input.financeService.generateDashboard()
])
if (!dashboard) {
return null
}
const memberLine = dashboard.members.find((line) => line.memberId === input.memberId)
if (!memberLine) {
return null
}
return {
kind,
guidance: buildMemberPaymentGuidance({
kind,
period: dashboard.period,
memberLine,
settings
})
}
}
export function synthesizePaymentConfirmationText(payload: PaymentProposalPayload): string { export function synthesizePaymentConfirmationText(payload: PaymentProposalPayload): string {
const amount = Money.fromMinor(BigInt(payload.amountMinor), payload.currency) const amount = Money.fromMinor(BigInt(payload.amountMinor), payload.currency)
const kindText = payload.kind === 'rent' ? 'rent' : 'utilities' const kindText = payload.kind === 'rent' ? 'rent' : 'utilities'

View File

@@ -332,7 +332,7 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
reply_parameters: { reply_parameters: {
message_id: 55 message_id: 55
}, },
text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.', text: expect.stringContaining('Я могу записать эту оплату аренды: 472.50 GEL.'),
reply_markup: { reply_markup: {
inline_keyboard: [ inline_keyboard: [
[ [
@@ -407,7 +407,7 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
text: 'Пока не могу подтвердить эту оплату. Уточните, это аренда или коммуналка, и при необходимости напишите сумму и валюту.' text: 'Пока не могу подтвердить эту оплату. Уточните, это аренда или коммуналка, и при необходимости напишите сумму и валюту.'
}) })
expect(calls[1]?.payload).toMatchObject({ expect(calls[1]?.payload).toMatchObject({
text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.' text: expect.stringContaining('Я могу записать эту оплату аренды: 472.50 GEL.')
}) })
}) })
@@ -639,7 +639,7 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
expect(calls).toHaveLength(1) expect(calls).toHaveLength(1)
expect(calls[0]?.payload).toMatchObject({ expect(calls[0]?.payload).toMatchObject({
text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.' text: expect.stringContaining('Я могу записать эту оплату аренды: 472.50 GEL.')
}) })
}) })
}) })

View File

@@ -1,5 +1,4 @@
import type { FinanceCommandService, PaymentConfirmationService } from '@household/application' import type { FinanceCommandService, PaymentConfirmationService } from '@household/application'
import { Money } from '@household/domain'
import { instantFromEpochSeconds, nowInstant, type Instant } from '@household/domain' import { instantFromEpochSeconds, nowInstant, type Instant } from '@household/domain'
import type { Bot, Context } from 'grammy' import type { Bot, Context } from 'grammy'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
@@ -11,6 +10,7 @@ import type {
import { getBotTranslations, type BotLocale } from './i18n' import { getBotTranslations, type BotLocale } from './i18n'
import { import {
formatPaymentProposalText,
maybeCreatePaymentProposal, maybeCreatePaymentProposal,
parsePaymentProposalPayload, parsePaymentProposalPayload,
synthesizePaymentConfirmationText synthesizePaymentConfirmationText
@@ -485,10 +485,6 @@ export function registerConfiguredPaymentTopicIngestion(
} }
if (proposal.status === 'proposal') { if (proposal.status === 'proposal') {
const amount = Money.fromMinor(
BigInt(proposal.payload.amountMinor),
proposal.payload.currency
)
await promptRepository.upsertPendingAction({ await promptRepository.upsertPendingAction({
telegramUserId: record.senderTelegramUserId, telegramUserId: record.senderTelegramUserId,
telegramChatId: record.chatId, telegramChatId: record.chatId,
@@ -508,7 +504,11 @@ export function registerConfiguredPaymentTopicIngestion(
await replyToPaymentMessage( await replyToPaymentMessage(
ctx, ctx,
t.proposal(proposal.payload.kind, amount.toMajorString(), amount.currency), formatPaymentProposalText({
locale,
surface: 'topic',
proposal
}),
paymentProposalReplyMarkup(locale, proposal.payload.proposalId) paymentProposalReplyMarkup(locale, proposal.payload.proposalId)
) )
} }

View File

@@ -345,6 +345,7 @@ function App() {
const [addingPayment, setAddingPayment] = createSignal(false) const [addingPayment, setAddingPayment] = createSignal(false)
const [billingForm, setBillingForm] = createSignal({ const [billingForm, setBillingForm] = createSignal({
settlementCurrency: 'GEL' as 'USD' | 'GEL', settlementCurrency: 'GEL' as 'USD' | 'GEL',
paymentBalanceAdjustmentPolicy: 'utilities' as 'utilities' | 'rent' | 'separate',
rentAmountMajor: '', rentAmountMajor: '',
rentCurrency: 'USD' as 'USD' | 'GEL', rentCurrency: 'USD' as 'USD' | 'GEL',
rentDueDay: 20, rentDueDay: 20,
@@ -530,6 +531,7 @@ function App() {
})) }))
setBillingForm({ setBillingForm({
settlementCurrency: payload.settings.settlementCurrency, settlementCurrency: payload.settings.settlementCurrency,
paymentBalanceAdjustmentPolicy: payload.settings.paymentBalanceAdjustmentPolicy,
rentAmountMajor: payload.settings.rentAmountMinor rentAmountMajor: payload.settings.rentAmountMinor
? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2) ? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2)
: '', : '',
@@ -2273,6 +2275,29 @@ function App() {
<option value="USD">USD</option> <option value="USD">USD</option>
</select> </select>
</label> </label>
<label class="settings-field settings-field--wide">
<span>{copy().paymentBalanceAdjustmentPolicy}</span>
<select
value={billingForm().paymentBalanceAdjustmentPolicy}
onChange={(event) =>
setBillingForm((current) => ({
...current,
paymentBalanceAdjustmentPolicy: event.currentTarget.value as
| 'utilities'
| 'rent'
| 'separate'
}))
}
>
<option value="utilities">
{copy().paymentBalanceAdjustmentUtilities}
</option>
<option value="rent">{copy().paymentBalanceAdjustmentRent}</option>
<option value="separate">
{copy().paymentBalanceAdjustmentSeparate}
</option>
</select>
</label>
<label class="settings-field"> <label class="settings-field">
<span>{copy().rentAmount}</span> <span>{copy().rentAmount}</span>
<input <input

View File

@@ -108,6 +108,10 @@ export const dictionary = {
topicUnbound: 'Unbound', topicUnbound: 'Unbound',
billingSettingsTitle: 'Billing settings', billingSettingsTitle: 'Billing settings',
settlementCurrency: 'Settlement currency', settlementCurrency: 'Settlement currency',
paymentBalanceAdjustmentPolicy: 'Purchase balance adjustment',
paymentBalanceAdjustmentUtilities: 'Adjust through utilities',
paymentBalanceAdjustmentRent: 'Adjust through rent',
paymentBalanceAdjustmentSeparate: 'Keep purchases separate',
billingCycleTitle: 'Current billing cycle', billingCycleTitle: 'Current billing cycle',
billingCycleEmpty: 'No open cycle', billingCycleEmpty: 'No open cycle',
billingCycleStatus: 'Current cycle currency: {currency}', billingCycleStatus: 'Current cycle currency: {currency}',
@@ -286,6 +290,10 @@ export const dictionary = {
topicUnbound: 'Не привязан', topicUnbound: 'Не привязан',
billingSettingsTitle: 'Настройки биллинга', billingSettingsTitle: 'Настройки биллинга',
settlementCurrency: 'Валюта расчёта', settlementCurrency: 'Валюта расчёта',
paymentBalanceAdjustmentPolicy: 'Зачёт баланса по покупкам',
paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку',
paymentBalanceAdjustmentRent: 'Зачитывать через аренду',
paymentBalanceAdjustmentSeparate: 'Держать покупки отдельно',
billingCycleTitle: 'Текущий billing cycle', billingCycleTitle: 'Текущий billing cycle',
billingCycleEmpty: 'Нет открытого цикла', billingCycleEmpty: 'Нет открытого цикла',
billingCycleStatus: 'Валюта текущего цикла: {currency}', billingCycleStatus: 'Валюта текущего цикла: {currency}',

View File

@@ -60,6 +60,7 @@ export interface MiniAppMember {
export interface MiniAppBillingSettings { export interface MiniAppBillingSettings {
householdId: string householdId: string
settlementCurrency: 'USD' | 'GEL' settlementCurrency: 'USD' | 'GEL'
paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate'
rentAmountMinor: string | null rentAmountMinor: string | null
rentCurrency: 'USD' | 'GEL' rentCurrency: 'USD' | 'GEL'
rentDueDay: number rentDueDay: number
@@ -410,6 +411,7 @@ export async function updateMiniAppBillingSettings(
initData: string, initData: string,
input: { input: {
settlementCurrency?: 'USD' | 'GEL' settlementCurrency?: 'USD' | 'GEL'
paymentBalanceAdjustmentPolicy?: 'utilities' | 'rent' | 'separate'
rentAmountMajor?: string rentAmountMajor?: string
rentCurrency: 'USD' | 'GEL' rentCurrency: 'USD' | 'GEL'
rentDueDay: number rentDueDay: number

View File

@@ -10,6 +10,7 @@ import {
import { import {
HOUSEHOLD_MEMBER_ABSENCE_POLICIES, HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES, HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES,
HOUSEHOLD_TOPIC_ROLES, HOUSEHOLD_TOPIC_ROLES,
type HouseholdMemberAbsencePolicy, type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord, type HouseholdMemberAbsencePolicyRecord,
@@ -18,6 +19,7 @@ import {
type HouseholdJoinTokenRecord, type HouseholdJoinTokenRecord,
type HouseholdMemberLifecycleStatus, type HouseholdMemberLifecycleStatus,
type HouseholdMemberRecord, type HouseholdMemberRecord,
type HouseholdPaymentBalanceAdjustmentPolicy,
type HouseholdPendingMemberRecord, type HouseholdPendingMemberRecord,
type HouseholdTelegramChatRecord, type HouseholdTelegramChatRecord,
type HouseholdTopicBindingRecord, type HouseholdTopicBindingRecord,
@@ -47,6 +49,18 @@ function normalizeMemberLifecycleStatus(raw: string): HouseholdMemberLifecycleSt
throw new Error(`Unsupported household member lifecycle status: ${raw}`) throw new Error(`Unsupported household member lifecycle status: ${raw}`)
} }
function normalizePaymentBalanceAdjustmentPolicy(
raw: string
): HouseholdPaymentBalanceAdjustmentPolicy {
const normalized = raw.trim().toLowerCase()
if ((HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES as readonly string[]).includes(normalized)) {
return normalized as HouseholdPaymentBalanceAdjustmentPolicy
}
return 'utilities'
}
function normalizeMemberAbsencePolicy(raw: string): HouseholdMemberAbsencePolicy { function normalizeMemberAbsencePolicy(raw: string): HouseholdMemberAbsencePolicy {
const normalized = raw.trim().toLowerCase() const normalized = raw.trim().toLowerCase()
@@ -206,6 +220,7 @@ function toCurrencyCode(raw: string): CurrencyCode {
function toHouseholdBillingSettingsRecord(row: { function toHouseholdBillingSettingsRecord(row: {
householdId: string householdId: string
settlementCurrency: string settlementCurrency: string
paymentBalanceAdjustmentPolicy: string
rentAmountMinor: bigint | null rentAmountMinor: bigint | null
rentCurrency: string rentCurrency: string
rentDueDay: number rentDueDay: number
@@ -217,6 +232,9 @@ function toHouseholdBillingSettingsRecord(row: {
return { return {
householdId: row.householdId, householdId: row.householdId,
settlementCurrency: toCurrencyCode(row.settlementCurrency), settlementCurrency: toCurrencyCode(row.settlementCurrency),
paymentBalanceAdjustmentPolicy: normalizePaymentBalanceAdjustmentPolicy(
row.paymentBalanceAdjustmentPolicy
),
rentAmountMinor: row.rentAmountMinor, rentAmountMinor: row.rentAmountMinor,
rentCurrency: toCurrencyCode(row.rentCurrency), rentCurrency: toCurrencyCode(row.rentCurrency),
rentDueDay: row.rentDueDay, rentDueDay: row.rentDueDay,
@@ -917,6 +935,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
.select({ .select({
householdId: schema.householdBillingSettings.householdId, householdId: schema.householdBillingSettings.householdId,
settlementCurrency: schema.householdBillingSettings.settlementCurrency, settlementCurrency: schema.householdBillingSettings.settlementCurrency,
paymentBalanceAdjustmentPolicy:
schema.householdBillingSettings.paymentBalanceAdjustmentPolicy,
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor, rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
rentCurrency: schema.householdBillingSettings.rentCurrency, rentCurrency: schema.householdBillingSettings.rentCurrency,
rentDueDay: schema.householdBillingSettings.rentDueDay, rentDueDay: schema.householdBillingSettings.rentDueDay,
@@ -948,6 +968,11 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
settlementCurrency: input.settlementCurrency settlementCurrency: input.settlementCurrency
} }
: {}), : {}),
...(input.paymentBalanceAdjustmentPolicy
? {
paymentBalanceAdjustmentPolicy: input.paymentBalanceAdjustmentPolicy
}
: {}),
...(input.rentAmountMinor !== undefined ...(input.rentAmountMinor !== undefined
? { ? {
rentAmountMinor: input.rentAmountMinor rentAmountMinor: input.rentAmountMinor
@@ -989,6 +1014,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
.returning({ .returning({
householdId: schema.householdBillingSettings.householdId, householdId: schema.householdBillingSettings.householdId,
settlementCurrency: schema.householdBillingSettings.settlementCurrency, settlementCurrency: schema.householdBillingSettings.settlementCurrency,
paymentBalanceAdjustmentPolicy:
schema.householdBillingSettings.paymentBalanceAdjustmentPolicy,
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor, rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
rentCurrency: schema.householdBillingSettings.rentCurrency, rentCurrency: schema.householdBillingSettings.rentCurrency,
rentDueDay: schema.householdBillingSettings.rentDueDay, rentDueDay: schema.householdBillingSettings.rentDueDay,

View File

@@ -139,7 +139,7 @@ class FinanceRepositoryStub implements FinanceRepository {
return false return false
} }
async updateParsedPurchase(input) { async updateParsedPurchase(input: Parameters<FinanceRepository['updateParsedPurchase']>[0]) {
this.lastUpdatedPurchaseInput = input this.lastUpdatedPurchaseInput = input
return { return {
id: input.purchaseId, id: input.purchaseId,
@@ -149,13 +149,17 @@ class FinanceRepositoryStub implements FinanceRepository {
description: input.description, description: input.description,
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'), occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
splitMode: input.splitMode ?? 'equal', splitMode: input.splitMode ?? 'equal',
participants: input.participants?.map((participant, index) => ({ ...(input.participants
? {
participants: input.participants.map((participant, index) => ({
id: `participant-${index + 1}`, id: `participant-${index + 1}`,
memberId: participant.memberId, memberId: participant.memberId,
included: participant.included !== false, included: participant.included !== false,
shareAmountMinor: participant.shareAmountMinor shareAmountMinor: participant.shareAmountMinor
})) }))
} }
: {})
}
} }
async deleteParsedPurchase() { async deleteParsedPurchase() {

View File

@@ -40,3 +40,4 @@ export {
parsePaymentConfirmationMessage, parsePaymentConfirmationMessage,
type ParsedPaymentConfirmation type ParsedPaymentConfirmation
} from './payment-confirmation-parser' } from './payment-confirmation-parser'
export { buildMemberPaymentGuidance, type MemberPaymentGuidance } from './payment-guidance'

View File

@@ -43,6 +43,7 @@ export interface MiniAppAdminService {
householdId: string householdId: string
actorIsAdmin: boolean actorIsAdmin: boolean
settlementCurrency?: string settlementCurrency?: string
paymentBalanceAdjustmentPolicy?: string
rentAmountMajor?: string rentAmountMajor?: string
rentCurrency?: string rentCurrency?: string
rentDueDay: number rentDueDay: number
@@ -228,6 +229,20 @@ export function createMiniAppAdminService(
const settlementCurrency = input.settlementCurrency const settlementCurrency = input.settlementCurrency
? parseCurrency(input.settlementCurrency) ? parseCurrency(input.settlementCurrency)
: undefined : undefined
const paymentBalanceAdjustmentPolicy = input.paymentBalanceAdjustmentPolicy
? input.paymentBalanceAdjustmentPolicy === 'utilities' ||
input.paymentBalanceAdjustmentPolicy === 'rent' ||
input.paymentBalanceAdjustmentPolicy === 'separate'
? input.paymentBalanceAdjustmentPolicy
: null
: undefined
if (paymentBalanceAdjustmentPolicy === null) {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
if (input.rentAmountMajor && input.rentAmountMajor.trim().length > 0) { if (input.rentAmountMajor && input.rentAmountMajor.trim().length > 0) {
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD') rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
@@ -244,6 +259,11 @@ export function createMiniAppAdminService(
settlementCurrency settlementCurrency
} }
: {}), : {}),
...(paymentBalanceAdjustmentPolicy
? {
paymentBalanceAdjustmentPolicy
}
: {}),
...(rentAmountMinor !== undefined ...(rentAmountMinor !== undefined
? { ? {
rentAmountMinor rentAmountMinor

View File

@@ -15,6 +15,7 @@ import {
import type { FinanceCommandService } from './finance-command-service' import type { FinanceCommandService } from './finance-command-service'
import { parsePaymentConfirmationMessage } from './payment-confirmation-parser' import { parsePaymentConfirmationMessage } from './payment-confirmation-parser'
import { buildMemberPaymentGuidance } from './payment-guidance'
function billingPeriodLockDate(period: BillingPeriod, day: number): Temporal.PlainDate { function billingPeriodLockDate(period: BillingPeriod, day: number): Temporal.PlainDate {
const firstDay = Temporal.PlainDate.from({ const firstDay = Temporal.PlainDate.from({
@@ -284,10 +285,12 @@ export function createPaymentConfirmationService(input: {
} }
} }
const inferredAmount = const guidance = buildMemberPaymentGuidance({
parsed.kind === 'rent' kind: parsed.kind,
? memberLine.rentShare period: cycle.period,
: memberLine.utilityShare.add(memberLine.purchaseOffset) memberLine,
settings
})
const resolvedAmount = parsed.explicitAmount const resolvedAmount = parsed.explicitAmount
? ( ? (
@@ -305,7 +308,7 @@ export function createPaymentConfirmationService(input: {
parsed.explicitAmount parsed.explicitAmount
) )
).amount ).amount
: inferredAmount : guidance.proposalAmount
if (resolvedAmount.amountMinor <= 0n) { if (resolvedAmount.amountMinor <= 0n) {
const saveResult = await input.repository.savePaymentConfirmation({ const saveResult = await input.repository.savePaymentConfirmation({

View File

@@ -0,0 +1,76 @@
import { BillingPeriod, Money, Temporal } from '@household/domain'
import type {
HouseholdBillingSettingsRecord,
HouseholdPaymentBalanceAdjustmentPolicy
} from '@household/ports'
import type { FinanceDashboardMemberLine } from './finance-command-service'
export interface MemberPaymentGuidance {
kind: 'rent' | 'utilities'
adjustmentPolicy: HouseholdPaymentBalanceAdjustmentPolicy
baseAmount: Money
purchaseOffset: Money
proposalAmount: Money
totalRemaining: Money
reminderDate: string
dueDate: string
paymentWindowOpen: boolean
paymentDue: boolean
}
function cycleDate(period: string, day: number): Temporal.PlainDate {
const billingPeriod = BillingPeriod.fromString(period)
const [yearRaw, monthRaw] = billingPeriod.toString().split('-')
const year = Number(yearRaw)
const month = Number(monthRaw)
const yearMonth = new Temporal.PlainYearMonth(year, month)
const boundedDay = Math.min(Math.max(day, 1), yearMonth.daysInMonth)
return new Temporal.PlainDate(year, month, boundedDay)
}
function adjustmentApplies(
policy: HouseholdPaymentBalanceAdjustmentPolicy,
kind: 'rent' | 'utilities'
): boolean {
return (policy === 'utilities' && kind === 'utilities') || (policy === 'rent' && kind === 'rent')
}
export function buildMemberPaymentGuidance(input: {
kind: 'rent' | 'utilities'
period: string
memberLine: FinanceDashboardMemberLine
settings: HouseholdBillingSettingsRecord
referenceInstant?: Temporal.Instant
}): MemberPaymentGuidance {
const policy = input.settings.paymentBalanceAdjustmentPolicy ?? 'utilities'
const baseAmount =
input.kind === 'rent' ? input.memberLine.rentShare : input.memberLine.utilityShare
const purchaseOffset = input.memberLine.purchaseOffset
const proposalAmount = adjustmentApplies(policy, input.kind)
? baseAmount.add(purchaseOffset)
: baseAmount
const reminderDay =
input.kind === 'rent' ? input.settings.rentWarningDay : input.settings.utilitiesReminderDay
const dueDay = input.kind === 'rent' ? input.settings.rentDueDay : input.settings.utilitiesDueDay
const reminderDate = cycleDate(input.period, reminderDay)
const dueDate = cycleDate(input.period, dueDay)
const localDate = (input.referenceInstant ?? Temporal.Now.instant())
.toZonedDateTimeISO(input.settings.timezone)
.toPlainDate()
return {
kind: input.kind,
adjustmentPolicy: policy,
baseAmount,
purchaseOffset,
proposalAmount,
totalRemaining: input.memberLine.remaining,
reminderDate: reminderDate.toString(),
dueDate: dueDate.toString(),
paymentWindowOpen: Temporal.PlainDate.compare(localDate, reminderDate) >= 0,
paymentDue: Temporal.PlainDate.compare(localDate, dueDate) >= 0
}
}

View File

@@ -0,0 +1 @@
ALTER TABLE "household_billing_settings" ADD COLUMN "payment_balance_adjustment_policy" text DEFAULT 'utilities' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,13 @@
"when": 1773225121790, "when": 1773225121790,
"tag": "0016_equal_susan_delgado", "tag": "0016_equal_susan_delgado",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1773226133315,
"tag": "0017_gigantic_selene",
"breakpoints": true
} }
] ]
} }

View File

@@ -27,6 +27,9 @@ export const householdBillingSettings = pgTable(
.notNull() .notNull()
.references(() => households.id, { onDelete: 'cascade' }), .references(() => households.id, { onDelete: 'cascade' }),
settlementCurrency: text('settlement_currency').default('GEL').notNull(), settlementCurrency: text('settlement_currency').default('GEL').notNull(),
paymentBalanceAdjustmentPolicy: text('payment_balance_adjustment_policy')
.default('utilities')
.notNull(),
rentAmountMinor: bigint('rent_amount_minor', { mode: 'bigint' }), rentAmountMinor: bigint('rent_amount_minor', { mode: 'bigint' }),
rentCurrency: text('rent_currency').default('USD').notNull(), rentCurrency: text('rent_currency').default('USD').notNull(),
rentDueDay: integer('rent_due_day').default(20).notNull(), rentDueDay: integer('rent_due_day').default(20).notNull(),

View File

@@ -9,10 +9,17 @@ export const HOUSEHOLD_MEMBER_ABSENCE_POLICIES = [
'away_rent_only', 'away_rent_only',
'inactive' 'inactive'
] as const ] as const
export const HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES = [
'utilities',
'rent',
'separate'
] as const
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number] export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
export type HouseholdMemberLifecycleStatus = (typeof HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES)[number] export type HouseholdMemberLifecycleStatus = (typeof HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES)[number]
export type HouseholdMemberAbsencePolicy = (typeof HOUSEHOLD_MEMBER_ABSENCE_POLICIES)[number] export type HouseholdMemberAbsencePolicy = (typeof HOUSEHOLD_MEMBER_ABSENCE_POLICIES)[number]
export type HouseholdPaymentBalanceAdjustmentPolicy =
(typeof HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES)[number]
export interface HouseholdTelegramChatRecord { export interface HouseholdTelegramChatRecord {
householdId: string householdId: string
@@ -69,6 +76,7 @@ export interface HouseholdMemberAbsencePolicyRecord {
export interface HouseholdBillingSettingsRecord { export interface HouseholdBillingSettingsRecord {
householdId: string householdId: string
settlementCurrency: CurrencyCode settlementCurrency: CurrencyCode
paymentBalanceAdjustmentPolicy?: HouseholdPaymentBalanceAdjustmentPolicy
rentAmountMinor: bigint | null rentAmountMinor: bigint | null
rentCurrency: CurrencyCode rentCurrency: CurrencyCode
rentDueDay: number rentDueDay: number
@@ -161,6 +169,7 @@ export interface HouseholdConfigurationRepository {
updateHouseholdBillingSettings(input: { updateHouseholdBillingSettings(input: {
householdId: string householdId: string
settlementCurrency?: CurrencyCode settlementCurrency?: CurrencyCode
paymentBalanceAdjustmentPolicy?: HouseholdPaymentBalanceAdjustmentPolicy
rentAmountMinor?: bigint | null rentAmountMinor?: bigint | null
rentCurrency?: CurrencyCode rentCurrency?: CurrencyCode
rentDueDay?: number rentDueDay?: number

View File

@@ -15,9 +15,11 @@ export type {
export { export {
HOUSEHOLD_MEMBER_ABSENCE_POLICIES, HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES, HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES,
HOUSEHOLD_TOPIC_ROLES, HOUSEHOLD_TOPIC_ROLES,
type HouseholdMemberAbsencePolicy, type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord, type HouseholdMemberAbsencePolicyRecord,
type HouseholdPaymentBalanceAdjustmentPolicy,
type HouseholdConfigurationRepository, type HouseholdConfigurationRepository,
type HouseholdBillingSettingsRecord, type HouseholdBillingSettingsRecord,
type HouseholdJoinTokenRecord, type HouseholdJoinTokenRecord,