mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54: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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user