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,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 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 {
proposalId: string
householdId: string
@@ -11,6 +38,16 @@ export interface PaymentProposalPayload {
currency: 'GEL' | 'USD'
}
export interface PaymentProposalBreakdown {
guidance: MemberPaymentGuidance
explicitAmount: Money | null
}
export interface PaymentBalanceReply {
kind: 'rent' | 'utilities'
guidance: MemberPaymentGuidance
}
export function parsePaymentProposalPayload(
payload: Record<string, unknown>
): 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: {
rawText: string
householdId: string
@@ -61,6 +239,7 @@ export async function maybeCreatePaymentProposal(input: {
| {
status: 'proposal'
payload: PaymentProposalPayload
breakdown: PaymentProposalBreakdown
}
> {
const settings = await input.householdConfigurationRepository.getHouseholdBillingSettings(
@@ -100,11 +279,13 @@ export async function maybeCreatePaymentProposal(input: {
}
}
const amount =
parsed.explicitAmount ??
(parsed.kind === 'rent'
? memberLine.rentShare
: memberLine.utilityShare.add(memberLine.purchaseOffset))
const guidance = buildMemberPaymentGuidance({
kind: parsed.kind,
period: dashboard.period,
memberLine,
settings
})
const amount = parsed.explicitAmount ?? guidance.proposalAmount
if (amount.amountMinor <= 0n) {
return {
@@ -121,10 +302,50 @@ export async function maybeCreatePaymentProposal(input: {
kind: parsed.kind,
amountMinor: amount.amountMinor.toString(),
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 {
const amount = Money.fromMinor(BigInt(payload.amountMinor), payload.currency)
const kindText = payload.kind === 'rent' ? 'rent' : 'utilities'