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',
period: '2026-03'
}),
addPurchase: async () => ({
purchaseId: 'test-purchase',
amount: Money.fromMinor(0n, 'GEL'),
currency: 'GEL'
}),
updatePayment: async () => null,
deletePayment: async () => false,
generateDashboard: async () => ({

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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 () => ({

View File

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