feat(payments): track household payment confirmations

This commit is contained in:
2026-03-10 17:00:45 +04:00
parent fb85219409
commit 1988521931
31 changed files with 4795 additions and 19 deletions

View File

@@ -125,10 +125,25 @@ class FinanceRepositoryStub implements FinanceRepository {
return this.utilityBills
}
async listPaymentRecordsForCycle() {
return []
}
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
return this.purchases
}
async getSettlementSnapshotLines() {
return []
}
async savePaymentConfirmation() {
return {
status: 'needs_review' as const,
reviewReason: 'settlement_not_ready' as const
}
}
async replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void> {
this.replacedSnapshot = snapshot
}
@@ -347,9 +362,11 @@ describe('createFinanceCommandService', () => {
[
'Statement for 2026-03',
'Rent: 700.00 USD (~1890.00 GEL)',
'- Alice: 990.00 GEL',
'- Bob: 1020.00 GEL',
'Total: 2010.00 GEL'
'- Alice: due 990.00 GEL, paid 0.00 GEL, remaining 990.00 GEL',
'- Bob: due 1020.00 GEL, paid 0.00 GEL, remaining 1020.00 GEL',
'Total due: 2010.00 GEL',
'Total paid: 0.00 GEL',
'Total remaining: 2010.00 GEL'
].join('\n')
)
expect(repository.replacedSnapshot).not.toBeNull()

View File

@@ -86,6 +86,8 @@ export interface FinanceDashboardMemberLine {
utilityShare: Money
purchaseOffset: Money
netDue: Money
paid: Money
remaining: Money
explanations: readonly string[]
}
@@ -107,6 +109,8 @@ export interface FinanceDashboard {
period: string
currency: CurrencyCode
totalDue: Money
totalPaid: Money
totalRemaining: Money
rentSourceAmount: Money
rentDisplayAmount: Money
rentFxRateMicros: bigint | null
@@ -238,6 +242,7 @@ async function buildFinanceDashboard(
dependencies.repository.listParsedPurchasesForRange(start, end),
dependencies.repository.listUtilityBillsForCycle(cycle.id)
])
const paymentRecords = await dependencies.repository.listPaymentRecordsForCycle(cycle.id)
const convertedRent = await convertIntoCycleCurrency(dependencies, {
cycle,
@@ -338,6 +343,14 @@ async function buildFinanceDashboard(
})
const memberNameById = new Map(members.map((member) => [member.id, member.displayName]))
const paymentsByMemberId = new Map<string, Money>()
for (const payment of paymentRecords) {
const current = paymentsByMemberId.get(payment.memberId) ?? Money.zero(cycle.currency)
paymentsByMemberId.set(
payment.memberId,
current.add(Money.fromMinor(payment.amountMinor, payment.currency))
)
}
const dashboardMembers = settlement.lines.map((line) => ({
memberId: line.memberId.toString(),
displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(),
@@ -345,6 +358,10 @@ async function buildFinanceDashboard(
utilityShare: line.utilityShare,
purchaseOffset: line.purchaseOffset,
netDue: line.netDue,
paid: paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency),
remaining: line.netDue.subtract(
paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency)
),
explanations: line.explanations
}))
@@ -389,6 +406,14 @@ async function buildFinanceDashboard(
period: cycle.period,
currency: cycle.currency,
totalDue: settlement.totalDue,
totalPaid: paymentRecords.reduce(
(sum, payment) => sum.add(Money.fromMinor(payment.amountMinor, payment.currency)),
Money.zero(cycle.currency)
),
totalRemaining: dashboardMembers.reduce(
(sum, member) => sum.add(member.remaining),
Money.zero(cycle.currency)
),
rentSourceAmount: convertedRent.originalAmount,
rentDisplayAmount: convertedRent.settlementAmount,
rentFxRateMicros: convertedRent.fxRateMicros,
@@ -560,7 +585,7 @@ export function createFinanceCommandService(
}
const statementLines = dashboard.members.map((line) => {
return `- ${line.displayName}: ${line.netDue.toMajorString()} ${dashboard.currency}`
return `- ${line.displayName}: due ${line.netDue.toMajorString()} ${dashboard.currency}, paid ${line.paid.toMajorString()} ${dashboard.currency}, remaining ${line.remaining.toMajorString()} ${dashboard.currency}`
})
const rentLine =
@@ -572,7 +597,9 @@ export function createFinanceCommandService(
`Statement for ${dashboard.period}`,
rentLine,
...statementLines,
`Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`
`Total due: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`,
`Total paid: ${dashboard.totalPaid.toMajorString()} ${dashboard.currency}`,
`Total remaining: ${dashboard.totalRemaining.toMajorString()} ${dashboard.currency}`
].join('\n')
},

View File

@@ -31,3 +31,8 @@ export {
type PurchaseParserLlmFallback,
type PurchaseParserMode
} from './purchase-parser'
export {
createPaymentConfirmationService,
type PaymentConfirmationService,
type PaymentConfirmationSubmitResult
} from './payment-confirmation-service'

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test'
import { parsePaymentConfirmationMessage } from './payment-confirmation-parser'
describe('parsePaymentConfirmationMessage', () => {
test('detects rent confirmation without explicit amount', () => {
const result = parsePaymentConfirmationMessage('за жилье закинул', 'GEL')
expect(result.kind).toBe('rent')
expect(result.explicitAmount).toBeNull()
expect(result.reviewReason).toBeNull()
})
test('detects utility confirmation with explicit default-currency amount', () => {
const result = parsePaymentConfirmationMessage('оплатил газ 120', 'GEL')
expect(result.kind).toBe('utilities')
expect(result.explicitAmount?.amountMinor).toBe(12000n)
expect(result.explicitAmount?.currency).toBe('GEL')
expect(result.reviewReason).toBeNull()
})
test('keeps multi-member confirmations for review', () => {
const result = parsePaymentConfirmationMessage('перевел за Кирилла и себя', 'GEL')
expect(result.kind).toBeNull()
expect(result.reviewReason).toBe('multiple_members')
})
test('keeps generic done messages for review', () => {
const result = parsePaymentConfirmationMessage('готово', 'GEL')
expect(result.kind).toBeNull()
expect(result.reviewReason).toBe('kind_ambiguous')
})
})

View File

@@ -0,0 +1,143 @@
import { Money, type CurrencyCode } from '@household/domain'
import type { FinancePaymentKind, FinancePaymentConfirmationReviewReason } from '@household/ports'
export interface ParsedPaymentConfirmation {
normalizedText: string
kind: FinancePaymentKind | null
explicitAmount: Money | null
reviewReason: FinancePaymentConfirmationReviewReason | null
}
const rentKeywords = [/\b(rent|housing|apartment|landlord)\b/i, /жиль[её]/i, /аренд/i] as const
const utilityKeywords = [
/\b(utilities|utility|gas|water|electricity|internet|cleaning)\b/i,
/коммун/i,
/газ/i,
/вод/i,
/элект/i,
/свет/i,
/интернет/i,
/уборк/i
] as const
const paymentIntentKeywords = [
/\b(paid|pay|sent|done|transfer(red)?)\b/i,
/оплат/i,
/закинул/i,
/закину/i,
/перев[её]л/i,
/перевела/i,
/скинул/i,
/скинула/i,
/отправил/i,
/отправила/i,
/готово/i
] as const
const multiMemberKeywords = [
/за\s+двоих/i,
/\bfor\s+two\b/i,
/за\s+.*\s+и\s+себя/i,
/за\s+.*\s+и\s+меня/i
] as const
function hasMatch(patterns: readonly RegExp[], value: string): boolean {
return patterns.some((pattern) => pattern.test(value))
}
function parseExplicitAmount(rawText: string, defaultCurrency: CurrencyCode): Money | null {
const symbolMatch = rawText.match(/(?:^|[^\d])(\$|₾)\s*(\d+(?:[.,]\d{1,2})?)/i)
if (symbolMatch) {
const currency = symbolMatch[1] === '$' ? 'USD' : 'GEL'
return Money.fromMajor(symbolMatch[2]!.replace(',', '.'), currency)
}
const suffixMatch = rawText.match(/(\d+(?:[.,]\d{1,2})?)\s*(usd|gel|лари|лар|ლარი|ლარ|₾|\$)\b/i)
if (suffixMatch) {
const rawCurrency = suffixMatch[2]!.toUpperCase()
const currency = rawCurrency === 'USD' || rawCurrency === '$' ? 'USD' : 'GEL'
return Money.fromMajor(suffixMatch[1]!.replace(',', '.'), currency)
}
const bareAmountMatch = rawText.match(/(?:^|[^\d])(\d+(?:[.,]\d{1,2})?)(?:\s|$)/)
if (!bareAmountMatch) {
return null
}
return Money.fromMajor(bareAmountMatch[1]!.replace(',', '.'), defaultCurrency)
}
export function parsePaymentConfirmationMessage(
rawText: string,
defaultCurrency: CurrencyCode
): ParsedPaymentConfirmation {
const normalizedText = rawText.trim().replaceAll(/\s+/g, ' ')
const lowercase = normalizedText.toLowerCase()
if (normalizedText.length === 0) {
return {
normalizedText,
kind: null,
explicitAmount: null,
reviewReason: 'intent_missing'
}
}
if (hasMatch(multiMemberKeywords, lowercase)) {
return {
normalizedText,
kind: null,
explicitAmount: parseExplicitAmount(normalizedText, defaultCurrency),
reviewReason: 'multiple_members'
}
}
if (!hasMatch(paymentIntentKeywords, lowercase)) {
return {
normalizedText,
kind: null,
explicitAmount: parseExplicitAmount(normalizedText, defaultCurrency),
reviewReason: 'intent_missing'
}
}
const matchesRent = hasMatch(rentKeywords, lowercase)
const matchesUtilities = hasMatch(utilityKeywords, lowercase)
const explicitAmount = parseExplicitAmount(normalizedText, defaultCurrency)
if (matchesRent && matchesUtilities) {
return {
normalizedText,
kind: null,
explicitAmount,
reviewReason: 'kind_ambiguous'
}
}
if (matchesRent) {
return {
normalizedText,
kind: 'rent',
explicitAmount,
reviewReason: null
}
}
if (matchesUtilities) {
return {
normalizedText,
kind: 'utilities',
explicitAmount,
reviewReason: null
}
}
return {
normalizedText,
kind: null,
explicitAmount,
reviewReason: 'kind_ambiguous'
}
}

View File

@@ -0,0 +1,258 @@
import { describe, expect, test } from 'bun:test'
import { Money, instantFromIso, type CurrencyCode } from '@household/domain'
import type {
ExchangeRateProvider,
FinancePaymentConfirmationSaveInput,
FinancePaymentConfirmationSaveResult,
FinanceRepository,
HouseholdConfigurationRepository
} from '@household/ports'
import { createPaymentConfirmationService } from './payment-confirmation-service'
const settingsRepository: Pick<HouseholdConfigurationRepository, 'getHouseholdBillingSettings'> = {
async getHouseholdBillingSettings(householdId) {
return {
householdId,
settlementCurrency: 'GEL',
rentAmountMinor: 70000n,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
}
}
}
const exchangeRateProvider: ExchangeRateProvider = {
async getRate(input) {
return {
baseCurrency: input.baseCurrency,
quoteCurrency: input.quoteCurrency,
rateMicros: input.baseCurrency === input.quoteCurrency ? 1_000_000n : 2_700_000n,
effectiveDate: input.effectiveDate,
source: 'nbg'
}
}
}
function createRepositoryStub(): Pick<
FinanceRepository,
| 'getOpenCycle'
| 'getLatestCycle'
| 'getCycleExchangeRate'
| 'saveCycleExchangeRate'
| 'savePaymentConfirmation'
> & {
saved: FinancePaymentConfirmationSaveInput[]
} {
return {
saved: [],
async getOpenCycle() {
return {
id: 'cycle-1',
period: '2026-03',
currency: 'GEL' as CurrencyCode
}
},
async getLatestCycle() {
return {
id: 'cycle-1',
period: '2026-03',
currency: 'GEL' as CurrencyCode
}
},
async getCycleExchangeRate() {
return null
},
async saveCycleExchangeRate(input) {
return input
},
async savePaymentConfirmation(input): Promise<FinancePaymentConfirmationSaveResult> {
this.saved.push(input)
if (input.status === 'needs_review') {
return {
status: 'needs_review',
reviewReason: input.reviewReason
}
}
return {
status: 'recorded',
paymentRecord: {
id: 'payment-1',
memberId: input.memberId,
kind: input.kind,
amountMinor: input.amountMinor,
currency: input.currency,
recordedAt: input.recordedAt
}
}
}
}
}
describe('createPaymentConfirmationService', () => {
test('resolves rent confirmations against the current member due', async () => {
const repository = createRepositoryStub()
const service = createPaymentConfirmationService({
householdId: 'household-1',
financeService: {
getMemberByTelegramUserId: async () => ({
id: 'member-1',
telegramUserId: '123',
displayName: 'Stas',
rentShareWeight: 1,
isAdmin: false
}),
generateDashboard: async () => ({
period: '2026-03',
currency: 'GEL',
totalDue: Money.fromMajor('1030', 'GEL'),
totalPaid: Money.zero('GEL'),
totalRemaining: Money.fromMajor('1030', 'GEL'),
rentSourceAmount: Money.fromMajor('700', 'USD'),
rentDisplayAmount: Money.fromMajor('1890', 'GEL'),
rentFxRateMicros: 2_700_000n,
rentFxEffectiveDate: '2026-03-17',
members: [
{
memberId: 'member-1',
displayName: 'Stas',
rentShare: Money.fromMajor('472.50', 'GEL'),
utilityShare: Money.fromMajor('40', 'GEL'),
purchaseOffset: Money.fromMajor('-12', 'GEL'),
netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'),
explanations: []
}
],
ledger: []
})
},
repository,
householdConfigurationRepository: settingsRepository,
exchangeRateProvider
})
const result = await service.submit({
senderTelegramUserId: '123',
rawText: 'за жилье закинул',
telegramChatId: '-1001',
telegramMessageId: '10',
telegramThreadId: '4',
telegramUpdateId: '200',
attachmentCount: 0,
messageSentAt: instantFromIso('2026-03-20T09:00:00.000Z')
})
expect(result).toEqual({
status: 'recorded',
kind: 'rent',
amount: Money.fromMajor('472.50', 'GEL')
})
expect(repository.saved[0]?.status).toBe('recorded')
})
test('converts explicit rent amounts into cycle currency', async () => {
const repository = createRepositoryStub()
const service = createPaymentConfirmationService({
householdId: 'household-1',
financeService: {
getMemberByTelegramUserId: async () => ({
id: 'member-1',
telegramUserId: '123',
displayName: 'Stas',
rentShareWeight: 1,
isAdmin: false
}),
generateDashboard: async () => ({
period: '2026-03',
currency: 'GEL',
totalDue: Money.fromMajor('1030', 'GEL'),
totalPaid: Money.zero('GEL'),
totalRemaining: Money.fromMajor('1030', 'GEL'),
rentSourceAmount: Money.fromMajor('700', 'USD'),
rentDisplayAmount: Money.fromMajor('1890', 'GEL'),
rentFxRateMicros: 2_700_000n,
rentFxEffectiveDate: '2026-03-17',
members: [
{
memberId: 'member-1',
displayName: 'Stas',
rentShare: Money.fromMajor('472.50', 'GEL'),
utilityShare: Money.fromMajor('40', 'GEL'),
purchaseOffset: Money.fromMajor('-12', 'GEL'),
netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'),
explanations: []
}
],
ledger: []
})
},
repository,
householdConfigurationRepository: settingsRepository,
exchangeRateProvider
})
const result = await service.submit({
senderTelegramUserId: '123',
rawText: 'paid rent $175',
telegramChatId: '-1001',
telegramMessageId: '11',
telegramThreadId: '4',
telegramUpdateId: '201',
attachmentCount: 0,
messageSentAt: instantFromIso('2026-03-20T09:00:00.000Z')
})
expect(result).toEqual({
status: 'recorded',
kind: 'rent',
amount: Money.fromMajor('472.50', 'GEL')
})
})
test('keeps ambiguous confirmations for review', async () => {
const repository = createRepositoryStub()
const service = createPaymentConfirmationService({
householdId: 'household-1',
financeService: {
getMemberByTelegramUserId: async () => ({
id: 'member-1',
telegramUserId: '123',
displayName: 'Stas',
rentShareWeight: 1,
isAdmin: false
}),
generateDashboard: async () => null
},
repository,
householdConfigurationRepository: settingsRepository,
exchangeRateProvider
})
const result = await service.submit({
senderTelegramUserId: '123',
rawText: 'готово',
telegramChatId: '-1001',
telegramMessageId: '12',
telegramThreadId: '4',
telegramUpdateId: '202',
attachmentCount: 1,
messageSentAt: instantFromIso('2026-03-20T09:00:00.000Z')
})
expect(result).toEqual({
status: 'needs_review',
reason: 'kind_ambiguous'
})
})
})

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