mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 19:24:03 +00:00
feat(payments): add transparent balance guidance
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user