mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
Stabilize purchase functionality: fix ID prefix, uniqueness, and split participant inclusion
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
nowInstant,
|
||||
type CurrencyCode
|
||||
} from '@household/domain'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
function toCurrencyCode(raw: string): CurrencyCode {
|
||||
const normalized = raw.trim().toUpperCase()
|
||||
@@ -339,6 +340,94 @@ export function createDbFinanceRepository(
|
||||
})
|
||||
},
|
||||
|
||||
async addParsedPurchase(input) {
|
||||
const purchaseId = randomUUID()
|
||||
|
||||
const memberRows = await db
|
||||
.select({ displayName: schema.members.displayName })
|
||||
.from(schema.members)
|
||||
.where(eq(schema.members.id, input.payerMemberId))
|
||||
.limit(1)
|
||||
|
||||
const member = memberRows[0]
|
||||
|
||||
await db.insert(schema.purchaseMessages).values({
|
||||
id: purchaseId,
|
||||
householdId,
|
||||
senderMemberId: input.payerMemberId,
|
||||
senderTelegramUserId: 'miniapp',
|
||||
senderDisplayName: member?.displayName ?? 'Mini App',
|
||||
telegramChatId: 'miniapp',
|
||||
telegramMessageId: purchaseId,
|
||||
telegramThreadId: 'miniapp',
|
||||
telegramUpdateId: purchaseId,
|
||||
rawText: input.description ?? '',
|
||||
messageSentAt: instantToDate(input.occurredAt),
|
||||
parsedItemDescription: input.description,
|
||||
parsedAmountMinor: input.amountMinor,
|
||||
parsedCurrency: input.currency,
|
||||
participantSplitMode: input.splitMode ?? 'equal',
|
||||
processingStatus: 'confirmed',
|
||||
parserError: null,
|
||||
needsReview: 0
|
||||
})
|
||||
|
||||
if (input.participants && input.participants.length > 0) {
|
||||
await db.insert(schema.purchaseMessageParticipants).values(
|
||||
input.participants.map(
|
||||
(p: { memberId: string; included?: boolean; shareAmountMinor: bigint | null }) => ({
|
||||
purchaseMessageId: purchaseId,
|
||||
memberId: p.memberId,
|
||||
included: (p.included ?? true) ? 1 : 0,
|
||||
shareAmountMinor: p.shareAmountMinor
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.purchaseMessages.id,
|
||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||
currency: schema.purchaseMessages.parsedCurrency,
|
||||
description: schema.purchaseMessages.parsedItemDescription,
|
||||
occurredAt: schema.purchaseMessages.messageSentAt,
|
||||
splitMode: schema.purchaseMessages.participantSplitMode
|
||||
})
|
||||
.from(schema.purchaseMessages)
|
||||
.where(eq(schema.purchaseMessages.id, purchaseId))
|
||||
|
||||
const row = rows[0]
|
||||
if (!row || !row.payerMemberId || row.amountMinor == null || row.currency == null) {
|
||||
throw new Error('Failed to create purchase')
|
||||
}
|
||||
|
||||
const participantRows = await db
|
||||
.select({
|
||||
memberId: schema.purchaseMessageParticipants.memberId,
|
||||
included: schema.purchaseMessageParticipants.included,
|
||||
shareAmountMinor: schema.purchaseMessageParticipants.shareAmountMinor
|
||||
})
|
||||
.from(schema.purchaseMessageParticipants)
|
||||
.where(eq(schema.purchaseMessageParticipants.purchaseMessageId, purchaseId))
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
payerMemberId: row.payerMemberId,
|
||||
amountMinor: row.amountMinor,
|
||||
currency: toCurrencyCode(row.currency),
|
||||
description: row.description,
|
||||
occurredAt: row.occurredAt ? instantFromDatabaseValue(row.occurredAt) : null,
|
||||
splitMode: row.splitMode as 'equal' | 'custom_amounts',
|
||||
participants: participantRows.map((p) => ({
|
||||
memberId: p.memberId,
|
||||
included: p.included === 1,
|
||||
shareAmountMinor: p.shareAmountMinor
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
async updateParsedPurchase(input) {
|
||||
return await db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
|
||||
@@ -61,6 +61,7 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
replacedSnapshot: SettlementSnapshotRecord | null = null
|
||||
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
|
||||
lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null
|
||||
lastAddedPurchaseInput: Parameters<FinanceRepository['addParsedPurchase']>[0] | null = null
|
||||
|
||||
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
||||
return this.member
|
||||
@@ -131,6 +132,24 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
this.lastUtilityBill = input
|
||||
}
|
||||
|
||||
async addParsedPurchase(input: Parameters<FinanceRepository['addParsedPurchase']>[0]) {
|
||||
this.lastAddedPurchaseInput = input
|
||||
return {
|
||||
id: 'purchase-1',
|
||||
payerMemberId: input.payerMemberId,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
description: input.description,
|
||||
occurredAt: input.occurredAt,
|
||||
splitMode: input.splitMode ?? 'equal',
|
||||
participants: (input.participants ?? []).map((p) => ({
|
||||
memberId: p.memberId,
|
||||
included: p.included ?? true,
|
||||
shareAmountMinor: p.shareAmountMinor
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async updateUtilityBill() {
|
||||
return null
|
||||
}
|
||||
@@ -655,10 +674,12 @@ describe('createFinanceCommandService', () => {
|
||||
participants: [
|
||||
{
|
||||
memberId: 'alice',
|
||||
included: true,
|
||||
shareAmountMinor: 2000n
|
||||
},
|
||||
{
|
||||
memberId: 'bob',
|
||||
included: true,
|
||||
shareAmountMinor: 1000n
|
||||
}
|
||||
]
|
||||
|
||||
@@ -644,6 +644,7 @@ export interface FinanceCommandService {
|
||||
mode: 'equal' | 'custom_amounts'
|
||||
participants: readonly {
|
||||
memberId: string
|
||||
included?: boolean
|
||||
shareAmountMajor?: string
|
||||
}[]
|
||||
}
|
||||
@@ -652,6 +653,24 @@ export interface FinanceCommandService {
|
||||
amount: Money
|
||||
currency: CurrencyCode
|
||||
} | null>
|
||||
addPurchase(
|
||||
description: string,
|
||||
amountArg: string,
|
||||
payerMemberId: string,
|
||||
currencyArg?: string,
|
||||
split?: {
|
||||
mode: 'equal' | 'custom_amounts'
|
||||
participants: readonly {
|
||||
memberId: string
|
||||
included?: boolean
|
||||
shareAmountMajor?: string
|
||||
}[]
|
||||
}
|
||||
): Promise<{
|
||||
purchaseId: string
|
||||
amount: Money
|
||||
currency: CurrencyCode
|
||||
}>
|
||||
deletePurchase(purchaseId: string): Promise<boolean>
|
||||
addPayment(
|
||||
memberId: string,
|
||||
@@ -900,6 +919,7 @@ export function createFinanceCommandService(
|
||||
splitMode: split.mode,
|
||||
participants: split.participants.map((participant) => ({
|
||||
memberId: participant.memberId,
|
||||
included: participant.included ?? true,
|
||||
shareAmountMinor:
|
||||
participant.shareAmountMajor !== undefined
|
||||
? Money.fromMajor(participant.shareAmountMajor, currency).amountMinor
|
||||
@@ -920,6 +940,67 @@ export function createFinanceCommandService(
|
||||
}
|
||||
},
|
||||
|
||||
async addPurchase(description, amountArg, payerMemberId, currencyArg, split) {
|
||||
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
||||
dependencies.householdId
|
||||
)
|
||||
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
|
||||
const amount = Money.fromMajor(amountArg, currency)
|
||||
|
||||
const openCycle = await repository.getOpenCycle()
|
||||
if (!openCycle) {
|
||||
throw new DomainError(DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT, 'No open billing cycle')
|
||||
}
|
||||
|
||||
if (split?.mode === 'custom_amounts') {
|
||||
if (split.participants.some((p) => p.shareAmountMajor === undefined)) {
|
||||
throw new DomainError(
|
||||
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||
'Purchase custom split must include explicit share amounts for every participant'
|
||||
)
|
||||
}
|
||||
|
||||
const totalMinor = split.participants.reduce(
|
||||
(sum, p) => sum + Money.fromMajor(p.shareAmountMajor!, currency).amountMinor,
|
||||
0n
|
||||
)
|
||||
if (totalMinor !== amount.amountMinor) {
|
||||
throw new DomainError(
|
||||
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||
'Purchase custom split must add up to the full amount'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const created = await repository.addParsedPurchase({
|
||||
cycleId: openCycle.id,
|
||||
payerMemberId,
|
||||
amountMinor: amount.amountMinor,
|
||||
currency,
|
||||
description: description.trim().length > 0 ? description.trim() : null,
|
||||
occurredAt: nowInstant(),
|
||||
...(split
|
||||
? {
|
||||
splitMode: split.mode,
|
||||
participants: split.participants.map((participant) => ({
|
||||
memberId: participant.memberId,
|
||||
included: participant.included ?? true,
|
||||
shareAmountMinor:
|
||||
participant.shareAmountMajor !== undefined
|
||||
? Money.fromMajor(participant.shareAmountMajor, currency).amountMinor
|
||||
: null
|
||||
}))
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
return {
|
||||
purchaseId: created.id,
|
||||
amount,
|
||||
currency
|
||||
}
|
||||
},
|
||||
|
||||
deletePurchase(purchaseId) {
|
||||
return repository.deleteParsedPurchase(purchaseId)
|
||||
},
|
||||
|
||||
@@ -172,6 +172,20 @@ export interface FinanceRepository {
|
||||
currency: CurrencyCode
|
||||
createdByMemberId: string
|
||||
}): Promise<void>
|
||||
addParsedPurchase(input: {
|
||||
cycleId: string
|
||||
payerMemberId: string
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
description: string | null
|
||||
occurredAt: Instant
|
||||
splitMode?: 'equal' | 'custom_amounts'
|
||||
participants?: readonly {
|
||||
memberId: string
|
||||
included?: boolean
|
||||
shareAmountMinor: bigint | null
|
||||
}[]
|
||||
}): Promise<FinanceParsedPurchaseRecord>
|
||||
updateParsedPurchase(input: {
|
||||
purchaseId: string
|
||||
amountMinor: bigint
|
||||
|
||||
Reference in New Issue
Block a user