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

@@ -331,6 +331,11 @@ function createFinanceService(): FinanceCommandService {
currency: (currencyArg ?? 'GEL') as 'GEL' | 'USD', currency: (currencyArg ?? 'GEL') as 'GEL' | 'USD',
period: '2026-03' period: '2026-03'
}), }),
addPurchase: async () => ({
purchaseId: 'test-purchase',
amount: Money.fromMinor(0n, 'GEL'),
currency: 'GEL'
}),
updatePayment: async () => null, updatePayment: async () => null,
deletePayment: async () => false, deletePayment: async () => false,
generateDashboard: async () => ({ generateDashboard: async () => ({

View File

@@ -235,6 +235,11 @@ function createFinanceService(): FinanceCommandService {
updatePurchase: async () => null, updatePurchase: async () => null,
deletePurchase: async () => false, deletePurchase: async () => false,
addPayment: async () => null, addPayment: async () => null,
addPurchase: async () => ({
purchaseId: 'test-purchase',
amount: Money.fromMinor(0n, 'GEL'),
currency: 'GEL'
}),
updatePayment: async () => null, updatePayment: async () => null,
deletePayment: async () => false, deletePayment: async () => false,
generateDashboard: async () => createDashboard(), generateDashboard: async () => createDashboard(),

View File

@@ -64,6 +64,7 @@ import {
} from './miniapp-admin' } from './miniapp-admin'
import { import {
createMiniAppAddPaymentHandler, createMiniAppAddPaymentHandler,
createMiniAppAddPurchaseHandler,
createMiniAppAddUtilityBillHandler, createMiniAppAddUtilityBillHandler,
createMiniAppBillingCycleHandler, createMiniAppBillingCycleHandler,
createMiniAppCloseCycleHandler, createMiniAppCloseCycleHandler,
@@ -731,6 +732,15 @@ const server = createBotWebhookServer({
logger: getLogger('miniapp-billing') logger: getLogger('miniapp-billing')
}) })
: undefined, : undefined,
miniAppAddPurchase: householdOnboardingService
? createMiniAppAddPurchaseHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppUpdatePurchase: householdOnboardingService miniAppUpdatePurchase: householdOnboardingService
? createMiniAppUpdatePurchaseHandler({ ? createMiniAppUpdatePurchaseHandler({
allowedOrigins: runtime.miniAppAllowedOrigins, allowedOrigins: runtime.miniAppAllowedOrigins,

View File

@@ -9,6 +9,7 @@ import type {
} from '@household/ports' } from '@household/ports'
import { import {
createMiniAppAddPurchaseHandler,
createMiniAppAddUtilityBillHandler, createMiniAppAddUtilityBillHandler,
createMiniAppBillingCycleHandler, createMiniAppBillingCycleHandler,
createMiniAppDeleteUtilityBillHandler, createMiniAppDeleteUtilityBillHandler,
@@ -213,6 +214,11 @@ function createFinanceServiceStub(): FinanceCommandService {
currency: 'USD', currency: 'USD',
period: '2026-03' period: '2026-03'
}), }),
addPurchase: async () => ({
purchaseId: 'test-purchase',
amount: Money.fromMinor(0n, 'GEL'),
currency: 'GEL'
}),
updatePayment: async () => ({ updatePayment: async () => ({
paymentId: 'payment-1', paymentId: 'payment-1',
amount: Money.fromMinor(10000n, 'USD'), amount: Money.fromMinor(10000n, 'USD'),
@@ -559,3 +565,74 @@ describe('createMiniAppUpdatePurchaseHandler', () => {
}) })
}) })
}) })
describe('createMiniAppAddPurchaseHandler', () => {
test('forwards purchase creation with split to the finance service', async () => {
const repository = onboardingRepository()
let capturedArgs: any = null
const handler = createMiniAppAddPurchaseHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
onboardingService: createHouseholdOnboardingService({
repository
}),
financeServiceForHousehold: () => ({
...createFinanceServiceStub(),
addPurchase: async (
description: string,
amountArg: string,
payerMemberId: string,
currencyArg?: string,
split?: any
) => {
capturedArgs = { description, amountArg, payerMemberId, currencyArg, split }
return {
purchaseId: 'new-purchase-1',
amount: Money.fromMinor(3000n, 'GEL'),
currency: 'GEL' as const
}
}
})
})
const response = await handler.handler(
new Request('http://localhost/api/miniapp/admin/purchases/add', {
method: 'POST',
headers: {
origin: 'http://localhost:5173',
'content-type': 'application/json'
},
body: JSON.stringify({
initData: initData(),
description: 'Pizza',
amountMajor: '30',
currency: 'GEL',
split: {
mode: 'equal',
participants: [
{ memberId: 'member-123456', included: true },
{ memberId: 'member-999', included: true }
]
}
})
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true, authorized: true })
expect(capturedArgs).toEqual({
description: 'Pizza',
amountArg: '30',
payerMemberId: 'member-123456',
currencyArg: 'GEL',
split: {
mode: 'equal',
participants: [
{ memberId: 'member-123456', included: true },
{ memberId: 'member-999', included: true }
]
}
})
})
})

View File

@@ -283,6 +283,70 @@ async function readUtilityBillDeletePayload(request: Request): Promise<{
} }
} }
async function readAddPurchasePayload(request: Request): Promise<{
initData: string
description: string
amountMajor: string
currency?: string
split?: {
mode: 'equal' | 'custom_amounts'
participants: {
memberId: string
included?: boolean
shareAmountMajor?: string
}[]
}
}> {
const parsed = await parseJsonBody<{
initData?: string
description?: string
amountMajor?: string
currency?: string
split?: {
mode?: string
participants?: {
memberId?: string
included?: boolean
shareAmountMajor?: string
}[]
}
}>(request)
const initData = parsed.initData?.trim()
if (!initData) {
throw new Error('Missing initData')
}
const description = parsed.description?.trim()
if (!description) {
throw new Error('Missing description')
}
const amountMajor = parsed.amountMajor?.trim()
if (!amountMajor) {
throw new Error('Missing amountMajor')
}
return {
initData,
description,
amountMajor,
...(parsed.currency !== undefined
? {
currency: parsed.currency
}
: {}),
...(parsed.split !== undefined
? {
split: {
mode: (parsed.split.mode ?? 'equal') as 'equal' | 'custom_amounts',
participants: (parsed.split.participants ?? []).filter(
(p): p is { memberId: string; included?: boolean; shareAmountMajor?: string } =>
p.memberId !== undefined
)
}
}
: {})
}
}
async function readPurchaseMutationPayload(request: Request): Promise<{ async function readPurchaseMutationPayload(request: Request): Promise<{
initData: string initData: string
purchaseId: string purchaseId: string
@@ -854,6 +918,62 @@ export function createMiniAppDeleteUtilityBillHandler(options: {
} }
} }
export function createMiniAppAddPurchaseHandler(options: {
allowedOrigins: readonly string[]
botToken: string
financeServiceForHousehold: (householdId: string) => FinanceCommandService
onboardingService: HouseholdOnboardingService
logger?: Logger
}): {
handler: (request: Request) => Promise<Response>
} {
const sessionService = createMiniAppSessionService({
botToken: options.botToken,
onboardingService: options.onboardingService
})
return {
handler: async (request) => {
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
if (request.method === 'OPTIONS') {
return miniAppJsonResponse({ ok: true }, 204, origin)
}
if (request.method !== 'POST') {
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
}
try {
const auth = await authenticateAdminSession(
request.clone() as Request,
sessionService,
origin
)
if (auth instanceof Response) {
return auth
}
const payload = await readAddPurchasePayload(request)
if (!payload.description || !payload.amountMajor) {
return miniAppJsonResponse({ ok: false, error: 'Missing purchase fields' }, 400, origin)
}
const service = options.financeServiceForHousehold(auth.member.householdId)
await service.addPurchase(
payload.description,
payload.amountMajor,
auth.member.id,
payload.currency,
payload.split
)
return miniAppJsonResponse({ ok: true, authorized: true }, 200, origin)
} catch (error) {
return miniAppErrorResponse(error, origin, options.logger)
}
}
}
}
export function createMiniAppUpdatePurchaseHandler(options: { export function createMiniAppUpdatePurchaseHandler(options: {
allowedOrigins: readonly string[] allowedOrigins: readonly string[]
botToken: string botToken: string

View File

@@ -45,6 +45,21 @@ function repository(
saveCycleExchangeRate: async (input) => input, saveCycleExchangeRate: async (input) => input,
addUtilityBill: async () => {}, addUtilityBill: async () => {},
updateParsedPurchase: async () => null, updateParsedPurchase: async () => null,
addParsedPurchase: async (input) => ({
id: 'purchase-new',
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 ?? null
})) ?? []
}),
deleteParsedPurchase: async () => false, deleteParsedPurchase: async () => false,
updateUtilityBill: async () => null, updateUtilityBill: async () => null,
deleteUtilityBill: async () => false, deleteUtilityBill: async () => false,

View File

@@ -163,6 +163,11 @@ function createFinanceService(): FinanceCommandService {
updatePurchase: async () => null, updatePurchase: async () => null,
deletePurchase: async () => false, deletePurchase: async () => false,
addPayment: async () => null, addPayment: async () => null,
addPurchase: async () => ({
purchaseId: 'test-purchase',
amount: Money.fromMinor(0n, 'GEL'),
currency: 'GEL'
}),
updatePayment: async () => null, updatePayment: async () => null,
deletePayment: async () => false, deletePayment: async () => false,
generateDashboard: async () => ({ generateDashboard: async () => ({

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { FinanceCommandService } from '@household/application' import type { FinanceCommandService } from '@household/application'
import { instantFromIso, nowInstant } from '@household/domain' import { instantFromIso, Money, nowInstant } from '@household/domain'
import type { TelegramPendingActionRecord, TelegramPendingActionRepository } from '@household/ports' import type { TelegramPendingActionRecord, TelegramPendingActionRepository } from '@household/ports'
import { createTelegramBot } from './bot' import { createTelegramBot } from './bot'
@@ -233,6 +233,11 @@ function createFinanceService(): FinanceCommandService & {
updatePurchase: async () => null, updatePurchase: async () => null,
deletePurchase: async () => false, deletePurchase: async () => false,
addPayment: async () => null, addPayment: async () => null,
addPurchase: async () => ({
purchaseId: 'test-purchase',
amount: Money.fromMinor(0n, 'GEL'),
currency: 'GEL'
}),
updatePayment: async () => null, updatePayment: async () => null,
deletePayment: async () => false, deletePayment: async () => false,
generateDashboard: async () => null, generateDashboard: async () => null,

View File

@@ -243,7 +243,23 @@ export function rebalancePurchaseSplit(
} }
// Special case: if it's 'equal' mode and we aren't handling a specific change, force equal // Special case: if it's 'equal' mode and we aren't handling a specific change, force equal
if (draft.splitInputMode === 'equal' && changedMemberId === null) { // Also initialize equal split for exact/percentage modes when no specific change provided
if (draft.splitInputMode !== 'equal' && changedMemberId === null) {
const active = participants.map((p, idx) => ({ ...p, idx })).filter((p) => p.included)
if (active.length > 0) {
const count = BigInt(active.length)
const baseShare = totalMinor / count
const remainder = totalMinor % count
active.forEach((p, i) => {
const share = baseShare + (BigInt(i) < remainder ? 1n : 0n)
participants[p.idx] = {
...participants[p.idx]!,
shareAmountMajor: minorToMajorString(share),
isAutoCalculated: true
}
})
}
} else if (draft.splitInputMode === 'equal' && changedMemberId === null) {
const active = participants.map((p, idx) => ({ ...p, idx })).filter((p) => p.included) const active = participants.map((p, idx) => ({ ...p, idx })).filter((p) => p.included)
if (active.length > 0) { if (active.length > 0) {
const count = BigInt(active.length) const count = BigInt(active.length)

View File

@@ -266,18 +266,19 @@ export default function LedgerRoute() {
const [addingPayment, setAddingPayment] = createSignal(false) const [addingPayment, setAddingPayment] = createSignal(false)
const addPurchaseButtonText = createMemo(() => { const addPurchaseButtonText = createMemo(() => {
if (addingPurchase()) return copy().purchaseSaveAction // or maybe adding... if (addingPurchase()) return copy().savingPurchase
if (newPurchase().splitInputMode === 'equal') return copy().purchaseSaveAction if (newPurchase().splitInputMode !== 'equal' && !validatePurchaseDraft(newPurchase()).valid) {
if (!validatePurchaseDraft(newPurchase()).valid) return copy().purchaseBalanceAction return copy().purchaseBalanceAction
}
return copy().purchaseSaveAction return copy().purchaseSaveAction
}) })
const editPurchaseButtonText = createMemo(() => { const editPurchaseButtonText = createMemo(() => {
const draft = purchaseDraft()
if (savingPurchase()) return copy().savingPurchase if (savingPurchase()) return copy().savingPurchase
if (!draft) return copy().purchaseSaveAction const draft = purchaseDraft()
if (draft.splitInputMode === 'equal') return copy().purchaseSaveAction if (draft && draft.splitInputMode !== 'equal' && !validatePurchaseDraft(draft).valid) {
if (!validatePurchaseDraft(draft).valid) return copy().purchaseBalanceAction return copy().purchaseBalanceAction
}
return copy().purchaseSaveAction return copy().purchaseSaveAction
}) })
@@ -387,8 +388,8 @@ export default function LedgerRoute() {
participants: draft.participants.map((p) => ({ participants: draft.participants.map((p) => ({
memberId: p.memberId, memberId: p.memberId,
included: p.included, included: p.included,
...(p.shareAmountMajor && draft.splitMode === 'custom_amounts' ...(draft.splitMode === 'custom_amounts'
? { shareAmountMajor: p.shareAmountMajor } ? { shareAmountMajor: p.shareAmountMajor || '0.00' }
: {}) : {})
})) }))
} }
@@ -433,8 +434,8 @@ export default function LedgerRoute() {
participants: draft.participants.map((p) => ({ participants: draft.participants.map((p) => ({
memberId: p.memberId, memberId: p.memberId,
included: p.included, included: p.included,
...(p.shareAmountMajor && draft.splitMode === 'custom_amounts' ...(draft.splitMode === 'custom_amounts'
? { shareAmountMajor: p.shareAmountMajor } ? { shareAmountMajor: p.shareAmountMajor || '0.00' }
: {}) : {})
})) }))
} }
@@ -714,11 +715,13 @@ export default function LedgerRoute() {
loading={addingPurchase()} loading={addingPurchase()}
disabled={!newPurchase().description.trim() || !newPurchase().amountMajor.trim()} disabled={!newPurchase().description.trim() || !newPurchase().amountMajor.trim()}
onClick={() => { onClick={() => {
if ( const draft = newPurchase()
newPurchase().splitInputMode !== 'equal' && if (draft.splitInputMode !== 'equal' && !validatePurchaseDraft(draft).valid) {
!validatePurchaseDraft(newPurchase()).valid const rebalanced = rebalancePurchaseSplit(draft, null, null)
) { setNewPurchase(rebalanced)
setNewPurchase((p) => rebalancePurchaseSplit(p, null, null)) if (validatePurchaseDraft(rebalanced).valid) {
void handleAddPurchase()
}
} else { } else {
void handleAddPurchase() void handleAddPurchase()
} }
@@ -816,7 +819,11 @@ export default function LedgerRoute() {
draft.splitInputMode !== 'equal' && draft.splitInputMode !== 'equal' &&
!validatePurchaseDraft(draft).valid !validatePurchaseDraft(draft).valid
) { ) {
setPurchaseDraft((d) => (d ? rebalancePurchaseSplit(d, null, null) : d)) const rebalanced = rebalancePurchaseSplit(draft, null, null)
setPurchaseDraft(rebalanced)
if (validatePurchaseDraft(rebalanced).valid) {
void handleSavePurchase()
}
} else { } else {
void handleSavePurchase() void handleSavePurchase()
} }

View File

@@ -8,6 +8,7 @@ import {
nowInstant, nowInstant,
type CurrencyCode type CurrencyCode
} from '@household/domain' } from '@household/domain'
import { randomUUID } from 'node:crypto'
function toCurrencyCode(raw: string): CurrencyCode { function toCurrencyCode(raw: string): CurrencyCode {
const normalized = raw.trim().toUpperCase() 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) { async updateParsedPurchase(input) {
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const rows = await tx const rows = await tx

View File

@@ -61,6 +61,7 @@ class FinanceRepositoryStub implements FinanceRepository {
replacedSnapshot: SettlementSnapshotRecord | null = null replacedSnapshot: SettlementSnapshotRecord | null = null
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>() cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null
lastAddedPurchaseInput: Parameters<FinanceRepository['addParsedPurchase']>[0] | null = null
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> { async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
return this.member return this.member
@@ -131,6 +132,24 @@ class FinanceRepositoryStub implements FinanceRepository {
this.lastUtilityBill = input 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() { async updateUtilityBill() {
return null return null
} }
@@ -655,10 +674,12 @@ describe('createFinanceCommandService', () => {
participants: [ participants: [
{ {
memberId: 'alice', memberId: 'alice',
included: true,
shareAmountMinor: 2000n shareAmountMinor: 2000n
}, },
{ {
memberId: 'bob', memberId: 'bob',
included: true,
shareAmountMinor: 1000n shareAmountMinor: 1000n
} }
] ]

View File

@@ -644,6 +644,7 @@ export interface FinanceCommandService {
mode: 'equal' | 'custom_amounts' mode: 'equal' | 'custom_amounts'
participants: readonly { participants: readonly {
memberId: string memberId: string
included?: boolean
shareAmountMajor?: string shareAmountMajor?: string
}[] }[]
} }
@@ -652,6 +653,24 @@ export interface FinanceCommandService {
amount: Money amount: Money
currency: CurrencyCode currency: CurrencyCode
} | null> } | 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> deletePurchase(purchaseId: string): Promise<boolean>
addPayment( addPayment(
memberId: string, memberId: string,
@@ -900,6 +919,7 @@ export function createFinanceCommandService(
splitMode: split.mode, splitMode: split.mode,
participants: split.participants.map((participant) => ({ participants: split.participants.map((participant) => ({
memberId: participant.memberId, memberId: participant.memberId,
included: participant.included ?? true,
shareAmountMinor: shareAmountMinor:
participant.shareAmountMajor !== undefined participant.shareAmountMajor !== undefined
? Money.fromMajor(participant.shareAmountMajor, currency).amountMinor ? 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) { deletePurchase(purchaseId) {
return repository.deleteParsedPurchase(purchaseId) return repository.deleteParsedPurchase(purchaseId)
}, },

View File

@@ -172,6 +172,20 @@ export interface FinanceRepository {
currency: CurrencyCode currency: CurrencyCode
createdByMemberId: string createdByMemberId: string
}): Promise<void> }): 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: { updateParsedPurchase(input: {
purchaseId: string purchaseId: string
amountMinor: bigint amountMinor: bigint