Stabilize purchase functionality: fix ID prefix, uniqueness, and split participant inclusion

This commit is contained in:
2026-03-13 22:29:17 +04:00
parent 31dd1dc2ee
commit 1274cefc0f
14 changed files with 489 additions and 19 deletions

View File

@@ -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

View File

@@ -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
}
]

View File

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

View File

@@ -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