diff --git a/app/services/hooks/useBatchTransactions.ts b/app/services/hooks/useBatchTransactions.ts index 641eed03..6da3912e 100644 --- a/app/services/hooks/useBatchTransactions.ts +++ b/app/services/hooks/useBatchTransactions.ts @@ -1,8 +1,4 @@ -// ════════════════════════════════════════════════════════════════ -// REACT HOOK - Batch transaction management -// ════════════════════════════════════════════════════════════════ - -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { BatchTransactionService, BatchExecutionResult, @@ -16,13 +12,16 @@ import { interface UseBatchTransactionsProps { chunkSize?: number; + rollbackHandler?: (result: BatchExecutionResult) => Promise; } -export function useBatchTransactions({ chunkSize = 50 }: UseBatchTransactionsProps = {}) { +export function useBatchTransactions({ chunkSize = 50, rollbackHandler }: UseBatchTransactionsProps = {}) { const [service] = useState(() => new BatchTransactionService(chunkSize)); const [isRunning, setIsRunning] = useState(false); const [lastResult, setLastResult] = useState(null); const [progress, setProgress] = useState(null); + const rollbackHandlerRef = useRef(rollbackHandler); + rollbackHandlerRef.current = rollbackHandler; const executeCreate = useCallback( async ( @@ -140,6 +139,25 @@ export function useBatchTransactions({ chunkSize = 50 }: UseBatchTransactionsPro setProgress(null); }, [service]); + const rollbackLast = useCallback(async () => { + if (!lastResult) { + console.warn('No batch result to rollback'); + return null; + } + const handler = rollbackHandlerRef.current; + if (!handler) { + console.warn('No rollbackHandler configured for useBatchTransactions'); + return lastResult; + } + try { + await handler(lastResult); + return lastResult; + } catch (error) { + console.error('Rollback failed:', error); + throw error; + } + }, [lastResult]); + return { isRunning, lastResult, @@ -149,6 +167,7 @@ export function useBatchTransactions({ chunkSize = 50 }: UseBatchTransactionsPro executeCancel, executeCharge, retryFailed, + rollbackLast, clearResult, getGasEstimate: (count: number) => service.getGasEstimate(count), setChunkSize: (size: number) => service.setChunkSize(size), @@ -156,4 +175,4 @@ export function useBatchTransactions({ chunkSize = 50 }: UseBatchTransactionsPro }; } -export default useBatchTransactions; +export default useBatchTransactions; \ No newline at end of file diff --git a/backend/server/createApiServer.ts b/backend/server/createApiServer.ts index 98b650e1..211333aa 100644 --- a/backend/server/createApiServer.ts +++ b/backend/server/createApiServer.ts @@ -1,12 +1,13 @@ /** * SubTrackr public API HTTP server factory. * - * Mounts CDN-cacheable routes behind edge-cache header middleware. + * Mounts CDn-cacheable routes behind edge-cache header middleware. + * Additional batch subscription routes are mounted with atomic execution support. */ import express, { type Express } from 'express'; import { cacheHeadersMiddleware } from '../shared/middleware'; -import { createPublicApiRouter, createThemeRouter } from '../subscription/router'; +import { createPublicApiRouter, createThemeRouter, createBatchRouter } from '../subscription/router'; import { API_VERSION_HEADER, API_VERSION_VALUE } from '../services/shared/apiResponse'; export interface CreateApiServerOptions { @@ -34,6 +35,8 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express { app.use(cacheHeadersMiddleware()); app.use(createPublicApiRouter()); app.use('/api/v1/merchant', createThemeRouter()); + // Batch subscription operations with atomic execution (all-or-nothing semantics) + app.use('/api/v1/batch', createBatchRouter()); app.use((_req, res) => { res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Not found' } }); diff --git a/backend/services/batchChargeService.ts b/backend/services/batchChargeService.ts index d796cca2..63969fa5 100644 --- a/backend/services/batchChargeService.ts +++ b/backend/services/batchChargeService.ts @@ -15,6 +15,7 @@ export interface BatchChargeOptions { singleTransactionGas?: number; batchBaseGas?: number; perItemGas?: number; + rollbackChargeFn?: (subscriptionId: string, amount: number) => Promise; } export interface BatchChargeResult { @@ -35,12 +36,14 @@ export interface BatchChargeResult { startedAt: number; completedAt: number; errors: string[]; + rolledBackItems?: number; + rollbackErrors?: string[]; } export class BatchChargeService { - private intervalHandle: ReturnType | null = null; + private intervalHandle: ReturnType | null = null; private lastMatchTimestamp = 0; - private runHistory: BatchChargeResult[] = []; + private RunHistory: BatchChargeResult[] = []; private maxHistory = 50; private cronExpression = '0 0 * * *'; private checkIntervalMs = 60_000; @@ -48,7 +51,7 @@ export class BatchChargeService { private batchBaseGas = 50_000; private perItemGas = 100_000; - constructor(options?: { checkIntervalMs?: number; singleTransactionGas?: number; batchBaseGas?: number; perItemGas?: number }) { + constructor(options?: { checkIntervalMs?: number; singleTransactionGas?: number; batchBaseGas?: number; perItemGas?: number } = {}) { if (options?.checkIntervalMs) this.checkIntervalMs = options.checkIntervalMs; if (options?.singleTransactionGas) this.singleTransactionGas = options.singleTransactionGas; if (options?.batchBaseGas) this.batchBaseGas = options.batchBaseGas; @@ -99,9 +102,9 @@ export class BatchChargeService { monitoring: MonitoringService, options?: BatchChargeOptions, ): Promise { - const atomic = options?.atomic ?? false; - const includeOverdue = options?.includeOverdue ?? true; - const maxBatchSize = options?.maxBatchSize ?? 100; + const atomic = options?.atomic ?> false; + const includeOverdue = options?.includeOverdue ?> true; + const maxBatchSize = options?.maxBatchSize ?> 100; const candidates = includeOverdue ? [...BatchChargeService.selectDueToday(subscriptions), ...BatchChargeService.selectOverdue(subscriptions)] @@ -117,7 +120,10 @@ export class BatchChargeService { let failedItems = 0; let skippedItems = 0; let amountCharged = 0; + let rolledBackItems = 0; const errors: string[] = []; + const rollbackErrors: string[] = []; + const successfulCharges: Array<{ subscriptionId: string; amount: number }> = []; let state: BatchChargeResult['state'] = 'completed'; for (let idx = 0; idx < items.length; idx += 1) { @@ -138,12 +144,35 @@ export class BatchChargeService { if (success) { successfulItems += 1; amountCharged += item.amount; + successfulCharges.push({ subscriptionId: item.subscriptionId, amount: item.amount }); } else { failedItems += 1; errors.push(transaction.errorMessage || 'Charge failed'); if (atomic) { skippedItems = items.length - idx - 1; state = 'failed'; + + // Atomic rollback: reverse all previously successful charges + const rollbackFn = options?.rollbackChargeFn; + if (rollbackFn) { + for (const charged of successfulCharges.reverse()) { + try { + const rollbackSuccess = await rollbackFn(charged.subscriptionId, charged.amount); + if (rollbackSuccess) { + rolledBackItems += 1; + successfulItems -= 1; + amountCharged -= charged.amount; + } else { + rollbackErrors.push(`Rollback failed for ${charged.subscriptionId}`); + } + } catch (rollbackError) { + const message = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + rollbackErrors.push(`Rollback error for ${charged.subscriptionId}: ${message}`); + } + } + } else { + rollbackErrors.push('No rollbackChargeFn provided; partial charges may remain after atomic failure.'); + } break; } } @@ -167,6 +196,8 @@ export class BatchChargeService { startedAt, completedAt, errors, + ...(rolledBackItems > 0 ? { rolledBackItems } : {}), + ...(rollbackErrors.length > 0 ? { rollbackErrors } : {}), }; this.recordRun(result); @@ -213,7 +244,7 @@ export class BatchChargeService { } private matchesCron(date: Date): boolean { - const parts = this.cronExpression.trim().split(/\s+/); + const parts = this.cronExpression.trim().split(/\\s+/); if (parts.length !== 5) { return false; } @@ -224,13 +255,11 @@ export class BatchChargeService { const month = date.getMonth() + 1; const weekday = date.getDay(); - return ( - this.matchCronField(minuteExpr, minute) && + return (this.matchCronField(minuteExpr, minute) && this.matchCronField(hourExpr, hour) && this.matchCronField(domExpr, day) && this.matchCronField(monthExpr, month) && - this.matchCronField(dowExpr, weekday) - ); + this.matchCronField(dowExpr, weekday)); } private matchCronField(expression: string, value: number): boolean { @@ -257,4 +286,4 @@ export class BatchChargeService { private cronMinuteKey(date: Date): number { return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes()); } -} +} \ No newline at end of file