mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
feat(payments): track household payment confirmations
This commit is contained in:
@@ -360,6 +360,30 @@ export function createDbFinanceRepository(
|
||||
}))
|
||||
},
|
||||
|
||||
async listPaymentRecordsForCycle(cycleId) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.paymentRecords.id,
|
||||
memberId: schema.paymentRecords.memberId,
|
||||
kind: schema.paymentRecords.kind,
|
||||
amountMinor: schema.paymentRecords.amountMinor,
|
||||
currency: schema.paymentRecords.currency,
|
||||
recordedAt: schema.paymentRecords.recordedAt
|
||||
})
|
||||
.from(schema.paymentRecords)
|
||||
.where(eq(schema.paymentRecords.cycleId, cycleId))
|
||||
.orderBy(schema.paymentRecords.recordedAt)
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
memberId: row.memberId,
|
||||
kind: row.kind === 'utilities' ? 'utilities' : 'rent',
|
||||
amountMinor: row.amountMinor,
|
||||
currency: toCurrencyCode(row.currency),
|
||||
recordedAt: instantFromDatabaseValue(row.recordedAt)!
|
||||
}))
|
||||
},
|
||||
|
||||
async listParsedPurchasesForRange(start, end) {
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -392,6 +416,121 @@ export function createDbFinanceRepository(
|
||||
}))
|
||||
},
|
||||
|
||||
async getSettlementSnapshotLines(cycleId) {
|
||||
const rows = await db
|
||||
.select({
|
||||
memberId: schema.settlementLines.memberId,
|
||||
rentShareMinor: schema.settlementLines.rentShareMinor,
|
||||
utilityShareMinor: schema.settlementLines.utilityShareMinor,
|
||||
purchaseOffsetMinor: schema.settlementLines.purchaseOffsetMinor,
|
||||
netDueMinor: schema.settlementLines.netDueMinor
|
||||
})
|
||||
.from(schema.settlementLines)
|
||||
.innerJoin(
|
||||
schema.settlements,
|
||||
eq(schema.settlementLines.settlementId, schema.settlements.id)
|
||||
)
|
||||
.where(eq(schema.settlements.cycleId, cycleId))
|
||||
|
||||
return rows.map((row) => ({
|
||||
memberId: row.memberId,
|
||||
rentShareMinor: row.rentShareMinor,
|
||||
utilityShareMinor: row.utilityShareMinor,
|
||||
purchaseOffsetMinor: row.purchaseOffsetMinor,
|
||||
netDueMinor: row.netDueMinor
|
||||
}))
|
||||
},
|
||||
|
||||
async savePaymentConfirmation(input) {
|
||||
return db.transaction(async (tx) => {
|
||||
const insertedConfirmation = await tx
|
||||
.insert(schema.paymentConfirmations)
|
||||
.values({
|
||||
householdId,
|
||||
cycleId: input.cycleId,
|
||||
memberId: input.memberId,
|
||||
senderTelegramUserId: input.senderTelegramUserId,
|
||||
rawText: input.rawText,
|
||||
normalizedText: input.normalizedText,
|
||||
detectedKind: input.kind,
|
||||
explicitAmountMinor: input.explicitAmountMinor,
|
||||
explicitCurrency: input.explicitCurrency,
|
||||
resolvedAmountMinor: input.amountMinor,
|
||||
resolvedCurrency: input.currency,
|
||||
status: input.status,
|
||||
reviewReason: input.status === 'needs_review' ? input.reviewReason : null,
|
||||
attachmentCount: input.attachmentCount,
|
||||
telegramChatId: input.telegramChatId,
|
||||
telegramMessageId: input.telegramMessageId,
|
||||
telegramThreadId: input.telegramThreadId,
|
||||
telegramUpdateId: input.telegramUpdateId,
|
||||
messageSentAt: input.messageSentAt ? instantToDate(input.messageSentAt) : null
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.paymentConfirmations.householdId,
|
||||
schema.paymentConfirmations.telegramChatId,
|
||||
schema.paymentConfirmations.telegramMessageId
|
||||
]
|
||||
})
|
||||
.returning({
|
||||
id: schema.paymentConfirmations.id
|
||||
})
|
||||
|
||||
const confirmationId = insertedConfirmation[0]?.id
|
||||
if (!confirmationId) {
|
||||
return {
|
||||
status: 'duplicate' as const
|
||||
}
|
||||
}
|
||||
|
||||
if (input.status === 'needs_review') {
|
||||
return {
|
||||
status: 'needs_review' as const,
|
||||
reviewReason: input.reviewReason
|
||||
}
|
||||
}
|
||||
|
||||
const insertedPayment = await tx
|
||||
.insert(schema.paymentRecords)
|
||||
.values({
|
||||
householdId,
|
||||
cycleId: input.cycleId,
|
||||
memberId: input.memberId,
|
||||
kind: input.kind,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
confirmationId,
|
||||
recordedAt: instantToDate(input.recordedAt)
|
||||
})
|
||||
.returning({
|
||||
id: schema.paymentRecords.id,
|
||||
memberId: schema.paymentRecords.memberId,
|
||||
kind: schema.paymentRecords.kind,
|
||||
amountMinor: schema.paymentRecords.amountMinor,
|
||||
currency: schema.paymentRecords.currency,
|
||||
recordedAt: schema.paymentRecords.recordedAt
|
||||
})
|
||||
|
||||
const paymentRow = insertedPayment[0]
|
||||
if (!paymentRow) {
|
||||
throw new Error('Failed to persist payment record')
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'recorded' as const,
|
||||
paymentRecord: {
|
||||
id: paymentRow.id,
|
||||
memberId: paymentRow.memberId,
|
||||
kind: paymentRow.kind === 'utilities' ? 'utilities' : 'rent',
|
||||
amountMinor: paymentRow.amountMinor,
|
||||
currency: toCurrencyCode(paymentRow.currency),
|
||||
recordedAt: instantFromDatabaseValue(paymentRow.recordedAt)!
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async replaceSettlementSnapshot(snapshot) {
|
||||
await db.transaction(async (tx) => {
|
||||
const upserted = await tx
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
|
||||
@@ -31,3 +31,8 @@ export {
|
||||
type PurchaseParserLlmFallback,
|
||||
type PurchaseParserMode
|
||||
} from './purchase-parser'
|
||||
export {
|
||||
createPaymentConfirmationService,
|
||||
type PaymentConfirmationService,
|
||||
type PaymentConfirmationSubmitResult
|
||||
} from './payment-confirmation-service'
|
||||
|
||||
36
packages/application/src/payment-confirmation-parser.test.ts
Normal file
36
packages/application/src/payment-confirmation-parser.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
143
packages/application/src/payment-confirmation-parser.ts
Normal file
143
packages/application/src/payment-confirmation-parser.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
258
packages/application/src/payment-confirmation-service.test.ts
Normal file
258
packages/application/src/payment-confirmation-service.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087",
|
||||
"0010_wild_molecule_man.sql": "46027a6ac770cdc2efd4c3eb5bb53f09d1b852c70fdc46a2434e5a7064587245",
|
||||
"0011_previous_ezekiel_stane.sql": "d996e64d3854de22e36dedeaa94e46774399163d90263bbb05e0b9199af79b70",
|
||||
"0012_clumsy_maestro.sql": "173797fb435c6acd7c268c624942d6f19a887c329bcef409a3dde1249baaeb8a"
|
||||
"0012_clumsy_maestro.sql": "173797fb435c6acd7c268c624942d6f19a887c329bcef409a3dde1249baaeb8a",
|
||||
"0013_wild_avengers.sql": "76254db09c9d623134712aee57a5896aa4a5b416e45d0f6c69dec1fec5b32af4"
|
||||
}
|
||||
}
|
||||
|
||||
51
packages/db/drizzle/0013_wild_avengers.sql
Normal file
51
packages/db/drizzle/0013_wild_avengers.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE "payment_confirmations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"cycle_id" uuid,
|
||||
"member_id" uuid,
|
||||
"sender_telegram_user_id" text NOT NULL,
|
||||
"raw_text" text NOT NULL,
|
||||
"normalized_text" text NOT NULL,
|
||||
"detected_kind" text,
|
||||
"explicit_amount_minor" bigint,
|
||||
"explicit_currency" text,
|
||||
"resolved_amount_minor" bigint,
|
||||
"resolved_currency" text,
|
||||
"status" text NOT NULL,
|
||||
"review_reason" text,
|
||||
"attachment_count" integer DEFAULT 0 NOT NULL,
|
||||
"telegram_chat_id" text NOT NULL,
|
||||
"telegram_message_id" text NOT NULL,
|
||||
"telegram_thread_id" text NOT NULL,
|
||||
"telegram_update_id" text NOT NULL,
|
||||
"message_sent_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "payment_records" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"cycle_id" uuid NOT NULL,
|
||||
"member_id" uuid NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"amount_minor" bigint NOT NULL,
|
||||
"currency" text NOT NULL,
|
||||
"confirmation_id" uuid,
|
||||
"recorded_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "payment_confirmations" ADD CONSTRAINT "payment_confirmations_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payment_confirmations" ADD CONSTRAINT "payment_confirmations_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payment_confirmations" ADD CONSTRAINT "payment_confirmations_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payment_records" ADD CONSTRAINT "payment_records_confirmation_id_payment_confirmations_id_fk" FOREIGN KEY ("confirmation_id") REFERENCES "public"."payment_confirmations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "payment_confirmations_household_tg_message_unique" ON "payment_confirmations" USING btree ("household_id","telegram_chat_id","telegram_message_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "payment_confirmations_household_tg_update_unique" ON "payment_confirmations" USING btree ("household_id","telegram_update_id");--> statement-breakpoint
|
||||
CREATE INDEX "payment_confirmations_household_status_idx" ON "payment_confirmations" USING btree ("household_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "payment_confirmations_member_created_idx" ON "payment_confirmations" USING btree ("member_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "payment_records_cycle_member_idx" ON "payment_records" USING btree ("cycle_id","member_id");--> statement-breakpoint
|
||||
CREATE INDEX "payment_records_cycle_kind_idx" ON "payment_records" USING btree ("cycle_id","kind");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "payment_records_confirmation_unique" ON "payment_records" USING btree ("confirmation_id");
|
||||
2945
packages/db/drizzle/meta/0013_snapshot.json
Normal file
2945
packages/db/drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,13 @@
|
||||
"when": 1773146577992,
|
||||
"tag": "0012_clumsy_maestro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1773147481265,
|
||||
"tag": "0013_wild_avengers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -481,6 +481,83 @@ export const anonymousMessages = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
export const paymentConfirmations = pgTable(
|
||||
'payment_confirmations',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
householdId: uuid('household_id')
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: 'cascade' }),
|
||||
cycleId: uuid('cycle_id').references(() => billingCycles.id, { onDelete: 'set null' }),
|
||||
memberId: uuid('member_id').references(() => members.id, { onDelete: 'set null' }),
|
||||
senderTelegramUserId: text('sender_telegram_user_id').notNull(),
|
||||
rawText: text('raw_text').notNull(),
|
||||
normalizedText: text('normalized_text').notNull(),
|
||||
detectedKind: text('detected_kind'),
|
||||
explicitAmountMinor: bigint('explicit_amount_minor', { mode: 'bigint' }),
|
||||
explicitCurrency: text('explicit_currency'),
|
||||
resolvedAmountMinor: bigint('resolved_amount_minor', { mode: 'bigint' }),
|
||||
resolvedCurrency: text('resolved_currency'),
|
||||
status: text('status').notNull(),
|
||||
reviewReason: text('review_reason'),
|
||||
attachmentCount: integer('attachment_count').default(0).notNull(),
|
||||
telegramChatId: text('telegram_chat_id').notNull(),
|
||||
telegramMessageId: text('telegram_message_id').notNull(),
|
||||
telegramThreadId: text('telegram_thread_id').notNull(),
|
||||
telegramUpdateId: text('telegram_update_id').notNull(),
|
||||
messageSentAt: timestamp('message_sent_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
householdMessageUnique: uniqueIndex('payment_confirmations_household_tg_message_unique').on(
|
||||
table.householdId,
|
||||
table.telegramChatId,
|
||||
table.telegramMessageId
|
||||
),
|
||||
householdUpdateUnique: uniqueIndex('payment_confirmations_household_tg_update_unique').on(
|
||||
table.householdId,
|
||||
table.telegramUpdateId
|
||||
),
|
||||
householdStatusIdx: index('payment_confirmations_household_status_idx').on(
|
||||
table.householdId,
|
||||
table.status
|
||||
),
|
||||
memberCreatedIdx: index('payment_confirmations_member_created_idx').on(
|
||||
table.memberId,
|
||||
table.createdAt
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
export const paymentRecords = pgTable(
|
||||
'payment_records',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
householdId: uuid('household_id')
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: 'cascade' }),
|
||||
cycleId: uuid('cycle_id')
|
||||
.notNull()
|
||||
.references(() => billingCycles.id, { onDelete: 'cascade' }),
|
||||
memberId: uuid('member_id')
|
||||
.notNull()
|
||||
.references(() => members.id, { onDelete: 'restrict' }),
|
||||
kind: text('kind').notNull(),
|
||||
amountMinor: bigint('amount_minor', { mode: 'bigint' }).notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
confirmationId: uuid('confirmation_id').references(() => paymentConfirmations.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
cycleMemberIdx: index('payment_records_cycle_member_idx').on(table.cycleId, table.memberId),
|
||||
cycleKindIdx: index('payment_records_cycle_kind_idx').on(table.cycleId, table.kind),
|
||||
confirmationUnique: uniqueIndex('payment_records_confirmation_unique').on(table.confirmationId)
|
||||
})
|
||||
)
|
||||
|
||||
export const settlements = pgTable(
|
||||
'settlements',
|
||||
{
|
||||
@@ -548,4 +625,6 @@ export type UtilityBill = typeof utilityBills.$inferSelect
|
||||
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
||||
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
||||
export type AnonymousMessage = typeof anonymousMessages.$inferSelect
|
||||
export type PaymentConfirmation = typeof paymentConfirmations.$inferSelect
|
||||
export type PaymentRecord = typeof paymentRecords.$inferSelect
|
||||
export type Settlement = typeof settlements.$inferSelect
|
||||
|
||||
@@ -46,6 +46,83 @@ export interface FinanceUtilityBillRecord {
|
||||
createdAt: Instant
|
||||
}
|
||||
|
||||
export type FinancePaymentKind = 'rent' | 'utilities'
|
||||
|
||||
export interface FinancePaymentRecord {
|
||||
id: string
|
||||
memberId: string
|
||||
kind: FinancePaymentKind
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
recordedAt: Instant
|
||||
}
|
||||
|
||||
export interface FinanceSettlementSnapshotLineRecord {
|
||||
memberId: string
|
||||
rentShareMinor: bigint
|
||||
utilityShareMinor: bigint
|
||||
purchaseOffsetMinor: bigint
|
||||
netDueMinor: bigint
|
||||
}
|
||||
|
||||
export interface FinancePaymentConfirmationMessage {
|
||||
senderTelegramUserId: string
|
||||
rawText: string
|
||||
normalizedText: string
|
||||
telegramChatId: string
|
||||
telegramMessageId: string
|
||||
telegramThreadId: string
|
||||
telegramUpdateId: string
|
||||
attachmentCount: number
|
||||
messageSentAt: Instant | null
|
||||
}
|
||||
|
||||
export type FinancePaymentConfirmationReviewReason =
|
||||
| 'member_not_found'
|
||||
| 'cycle_not_found'
|
||||
| 'settlement_not_ready'
|
||||
| 'intent_missing'
|
||||
| 'kind_ambiguous'
|
||||
| 'multiple_members'
|
||||
| 'non_positive_amount'
|
||||
|
||||
export type FinancePaymentConfirmationSaveInput =
|
||||
| (FinancePaymentConfirmationMessage & {
|
||||
status: 'recorded'
|
||||
cycleId: string
|
||||
memberId: string
|
||||
kind: FinancePaymentKind
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
explicitAmountMinor: bigint | null
|
||||
explicitCurrency: CurrencyCode | null
|
||||
recordedAt: Instant
|
||||
})
|
||||
| (FinancePaymentConfirmationMessage & {
|
||||
status: 'needs_review'
|
||||
cycleId: string | null
|
||||
memberId: string | null
|
||||
kind: FinancePaymentKind | null
|
||||
amountMinor: bigint | null
|
||||
currency: CurrencyCode | null
|
||||
explicitAmountMinor: bigint | null
|
||||
explicitCurrency: CurrencyCode | null
|
||||
reviewReason: FinancePaymentConfirmationReviewReason
|
||||
})
|
||||
|
||||
export type FinancePaymentConfirmationSaveResult =
|
||||
| {
|
||||
status: 'duplicate'
|
||||
}
|
||||
| {
|
||||
status: 'recorded'
|
||||
paymentRecord: FinancePaymentRecord
|
||||
}
|
||||
| {
|
||||
status: 'needs_review'
|
||||
reviewReason: FinancePaymentConfirmationReviewReason
|
||||
}
|
||||
|
||||
export interface SettlementSnapshotLineRecord {
|
||||
memberId: string
|
||||
rentShareMinor: bigint
|
||||
@@ -91,9 +168,16 @@ export interface FinanceRepository {
|
||||
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
|
||||
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
||||
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
||||
listPaymentRecordsForCycle(cycleId: string): Promise<readonly FinancePaymentRecord[]>
|
||||
listParsedPurchasesForRange(
|
||||
start: Instant,
|
||||
end: Instant
|
||||
): Promise<readonly FinanceParsedPurchaseRecord[]>
|
||||
getSettlementSnapshotLines(
|
||||
cycleId: string
|
||||
): Promise<readonly FinanceSettlementSnapshotLineRecord[]>
|
||||
savePaymentConfirmation(
|
||||
input: FinancePaymentConfirmationSaveInput
|
||||
): Promise<FinancePaymentConfirmationSaveResult>
|
||||
replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CurrencyCode, SupportedLocale } from '@household/domain'
|
||||
import type { ReminderTarget } from './reminders'
|
||||
|
||||
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const
|
||||
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders', 'payments'] as const
|
||||
|
||||
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ export type {
|
||||
export type {
|
||||
FinanceCycleRecord,
|
||||
FinanceCycleExchangeRateRecord,
|
||||
FinancePaymentConfirmationReviewReason,
|
||||
FinancePaymentConfirmationSaveInput,
|
||||
FinancePaymentConfirmationSaveResult,
|
||||
FinancePaymentKind,
|
||||
FinancePaymentRecord,
|
||||
FinanceSettlementSnapshotLineRecord,
|
||||
FinanceMemberRecord,
|
||||
FinanceParsedPurchaseRecord,
|
||||
FinanceRentRuleRecord,
|
||||
|
||||
Reference in New Issue
Block a user