Files
household-bot/packages/application/src/payment-confirmation-service.ts

369 lines
11 KiB
TypeScript

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
)
}
}
}
}