From 1274cefc0f21266626483c1d12f25055d8f5700b Mon Sep 17 00:00:00 2001 From: whekin Date: Fri, 13 Mar 2026 22:29:17 +0400 Subject: [PATCH] Stabilize purchase functionality: fix ID prefix, uniqueness, and split participant inclusion --- apps/bot/src/dm-assistant.test.ts | 5 + apps/bot/src/finance-commands.test.ts | 5 + apps/bot/src/index.ts | 10 ++ apps/bot/src/miniapp-billing.test.ts | 77 +++++++++++ apps/bot/src/miniapp-billing.ts | 120 ++++++++++++++++++ apps/bot/src/miniapp-dashboard.test.ts | 15 +++ apps/bot/src/payment-topic-ingestion.test.ts | 5 + apps/bot/src/reminder-topic-utilities.test.ts | 7 +- apps/miniapp/src/lib/ledger-helpers.ts | 18 ++- apps/miniapp/src/routes/ledger.tsx | 41 +++--- .../adapters-db/src/finance-repository.ts | 89 +++++++++++++ .../src/finance-command-service.test.ts | 21 +++ .../src/finance-command-service.ts | 81 ++++++++++++ packages/ports/src/finance.ts | 14 ++ 14 files changed, 489 insertions(+), 19 deletions(-) diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index f676efb..b3bfb4a 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -331,6 +331,11 @@ function createFinanceService(): FinanceCommandService { currency: (currencyArg ?? 'GEL') as 'GEL' | 'USD', period: '2026-03' }), + addPurchase: async () => ({ + purchaseId: 'test-purchase', + amount: Money.fromMinor(0n, 'GEL'), + currency: 'GEL' + }), updatePayment: async () => null, deletePayment: async () => false, generateDashboard: async () => ({ diff --git a/apps/bot/src/finance-commands.test.ts b/apps/bot/src/finance-commands.test.ts index fc41c38..01f1991 100644 --- a/apps/bot/src/finance-commands.test.ts +++ b/apps/bot/src/finance-commands.test.ts @@ -235,6 +235,11 @@ function createFinanceService(): FinanceCommandService { updatePurchase: async () => null, deletePurchase: async () => false, addPayment: async () => null, + addPurchase: async () => ({ + purchaseId: 'test-purchase', + amount: Money.fromMinor(0n, 'GEL'), + currency: 'GEL' + }), updatePayment: async () => null, deletePayment: async () => false, generateDashboard: async () => createDashboard(), diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 4ee3c52..2b78446 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -64,6 +64,7 @@ import { } from './miniapp-admin' import { createMiniAppAddPaymentHandler, + createMiniAppAddPurchaseHandler, createMiniAppAddUtilityBillHandler, createMiniAppBillingCycleHandler, createMiniAppCloseCycleHandler, @@ -731,6 +732,15 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-billing') }) : undefined, + miniAppAddPurchase: householdOnboardingService + ? createMiniAppAddPurchaseHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, miniAppUpdatePurchase: householdOnboardingService ? createMiniAppUpdatePurchaseHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index 54056fb..cae65dc 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -9,6 +9,7 @@ import type { } from '@household/ports' import { + createMiniAppAddPurchaseHandler, createMiniAppAddUtilityBillHandler, createMiniAppBillingCycleHandler, createMiniAppDeleteUtilityBillHandler, @@ -213,6 +214,11 @@ function createFinanceServiceStub(): FinanceCommandService { currency: 'USD', period: '2026-03' }), + addPurchase: async () => ({ + purchaseId: 'test-purchase', + amount: Money.fromMinor(0n, 'GEL'), + currency: 'GEL' + }), updatePayment: async () => ({ paymentId: 'payment-1', 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 } + ] + } + }) + }) +}) diff --git a/apps/bot/src/miniapp-billing.ts b/apps/bot/src/miniapp-billing.ts index e77ff46..7f57842 100644 --- a/apps/bot/src/miniapp-billing.ts +++ b/apps/bot/src/miniapp-billing.ts @@ -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<{ initData: 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 +} { + 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: { allowedOrigins: readonly string[] botToken: string diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 93a4482..f619e1c 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -45,6 +45,21 @@ function repository( saveCycleExchangeRate: async (input) => input, addUtilityBill: async () => {}, 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, updateUtilityBill: async () => null, deleteUtilityBill: async () => false, diff --git a/apps/bot/src/payment-topic-ingestion.test.ts b/apps/bot/src/payment-topic-ingestion.test.ts index a900c12..a1b14de 100644 --- a/apps/bot/src/payment-topic-ingestion.test.ts +++ b/apps/bot/src/payment-topic-ingestion.test.ts @@ -163,6 +163,11 @@ function createFinanceService(): FinanceCommandService { updatePurchase: async () => null, deletePurchase: async () => false, addPayment: async () => null, + addPurchase: async () => ({ + purchaseId: 'test-purchase', + amount: Money.fromMinor(0n, 'GEL'), + currency: 'GEL' + }), updatePayment: async () => null, deletePayment: async () => false, generateDashboard: async () => ({ diff --git a/apps/bot/src/reminder-topic-utilities.test.ts b/apps/bot/src/reminder-topic-utilities.test.ts index 9416d86..070fb51 100644 --- a/apps/bot/src/reminder-topic-utilities.test.ts +++ b/apps/bot/src/reminder-topic-utilities.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test' 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 { createTelegramBot } from './bot' @@ -233,6 +233,11 @@ function createFinanceService(): FinanceCommandService & { updatePurchase: async () => null, deletePurchase: async () => false, addPayment: async () => null, + addPurchase: async () => ({ + purchaseId: 'test-purchase', + amount: Money.fromMinor(0n, 'GEL'), + currency: 'GEL' + }), updatePayment: async () => null, deletePayment: async () => false, generateDashboard: async () => null, diff --git a/apps/miniapp/src/lib/ledger-helpers.ts b/apps/miniapp/src/lib/ledger-helpers.ts index cbdd420..61bedc2 100644 --- a/apps/miniapp/src/lib/ledger-helpers.ts +++ b/apps/miniapp/src/lib/ledger-helpers.ts @@ -243,7 +243,23 @@ export function rebalancePurchaseSplit( } // 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) if (active.length > 0) { const count = BigInt(active.length) diff --git a/apps/miniapp/src/routes/ledger.tsx b/apps/miniapp/src/routes/ledger.tsx index 1a42349..277508e 100644 --- a/apps/miniapp/src/routes/ledger.tsx +++ b/apps/miniapp/src/routes/ledger.tsx @@ -266,18 +266,19 @@ export default function LedgerRoute() { const [addingPayment, setAddingPayment] = createSignal(false) const addPurchaseButtonText = createMemo(() => { - if (addingPurchase()) return copy().purchaseSaveAction // or maybe adding... - if (newPurchase().splitInputMode === 'equal') return copy().purchaseSaveAction - if (!validatePurchaseDraft(newPurchase()).valid) return copy().purchaseBalanceAction + if (addingPurchase()) return copy().savingPurchase + if (newPurchase().splitInputMode !== 'equal' && !validatePurchaseDraft(newPurchase()).valid) { + return copy().purchaseBalanceAction + } return copy().purchaseSaveAction }) const editPurchaseButtonText = createMemo(() => { - const draft = purchaseDraft() if (savingPurchase()) return copy().savingPurchase - if (!draft) return copy().purchaseSaveAction - if (draft.splitInputMode === 'equal') return copy().purchaseSaveAction - if (!validatePurchaseDraft(draft).valid) return copy().purchaseBalanceAction + const draft = purchaseDraft() + if (draft && draft.splitInputMode !== 'equal' && !validatePurchaseDraft(draft).valid) { + return copy().purchaseBalanceAction + } return copy().purchaseSaveAction }) @@ -387,8 +388,8 @@ export default function LedgerRoute() { participants: draft.participants.map((p) => ({ memberId: p.memberId, included: p.included, - ...(p.shareAmountMajor && draft.splitMode === 'custom_amounts' - ? { shareAmountMajor: p.shareAmountMajor } + ...(draft.splitMode === 'custom_amounts' + ? { shareAmountMajor: p.shareAmountMajor || '0.00' } : {}) })) } @@ -433,8 +434,8 @@ export default function LedgerRoute() { participants: draft.participants.map((p) => ({ memberId: p.memberId, included: p.included, - ...(p.shareAmountMajor && draft.splitMode === 'custom_amounts' - ? { shareAmountMajor: p.shareAmountMajor } + ...(draft.splitMode === 'custom_amounts' + ? { shareAmountMajor: p.shareAmountMajor || '0.00' } : {}) })) } @@ -714,11 +715,13 @@ export default function LedgerRoute() { loading={addingPurchase()} disabled={!newPurchase().description.trim() || !newPurchase().amountMajor.trim()} onClick={() => { - if ( - newPurchase().splitInputMode !== 'equal' && - !validatePurchaseDraft(newPurchase()).valid - ) { - setNewPurchase((p) => rebalancePurchaseSplit(p, null, null)) + const draft = newPurchase() + if (draft.splitInputMode !== 'equal' && !validatePurchaseDraft(draft).valid) { + const rebalanced = rebalancePurchaseSplit(draft, null, null) + setNewPurchase(rebalanced) + if (validatePurchaseDraft(rebalanced).valid) { + void handleAddPurchase() + } } else { void handleAddPurchase() } @@ -816,7 +819,11 @@ export default function LedgerRoute() { draft.splitInputMode !== 'equal' && !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 { void handleSavePurchase() } diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index f58b20a..f51db29 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -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 diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index 53ebc8b..539b9f7 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -61,6 +61,7 @@ class FinanceRepositoryStub implements FinanceRepository { replacedSnapshot: SettlementSnapshotRecord | null = null cycleExchangeRates = new Map() lastUpdatedPurchaseInput: Parameters[0] | null = null + lastAddedPurchaseInput: Parameters[0] | null = null async getMemberByTelegramUserId(): Promise { return this.member @@ -131,6 +132,24 @@ class FinanceRepositoryStub implements FinanceRepository { this.lastUtilityBill = input } + async addParsedPurchase(input: Parameters[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 } ] diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index 9ded31d..be3b8ea 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -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 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) }, diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts index d10a8d8..c7b27e0 100644 --- a/packages/ports/src/finance.ts +++ b/packages/ports/src/finance.ts @@ -172,6 +172,20 @@ export interface FinanceRepository { currency: CurrencyCode createdByMemberId: string }): Promise + 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 updateParsedPurchase(input: { purchaseId: string amountMinor: bigint