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