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:
@@ -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 () => ({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<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: {
|
||||
allowedOrigins: readonly string[]
|
||||
botToken: string
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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