Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions app/services/hooks/useBatchTransactions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
// ════════════════════════════════════════════════════════════════
// REACT HOOK - Batch transaction management
// ════════════════════════════════════════════════════════════════

import { useState, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import {
BatchTransactionService,
BatchExecutionResult,
Expand All @@ -16,13 +12,16 @@ import {

interface UseBatchTransactionsProps {
chunkSize?: number;
rollbackHandler?: (result: BatchExecutionResult) => Promise<void>;
}

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<BatchExecutionResult | null>(null);
const [progress, setProgress] = useState<BatchProgress | null>(null);
const rollbackHandlerRef = useRef(rollbackHandler);
rollbackHandlerRef.current = rollbackHandler;

const executeCreate = useCallback(
async (
Expand Down Expand Up @@ -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,
Expand All @@ -149,11 +167,12 @@ export function useBatchTransactions({ chunkSize = 50 }: UseBatchTransactionsPro
executeCancel,
executeCharge,
retryFailed,
rollbackLast,
clearResult,
getGasEstimate: (count: number) => service.getGasEstimate(count),
setChunkSize: (size: number) => service.setChunkSize(size),
getProgress: () => service.getProgress(),
};
}

export default useBatchTransactions;
export default useBatchTransactions;
7 changes: 5 additions & 2 deletions backend/server/createApiServer.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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' } });
Expand Down
53 changes: 41 additions & 12 deletions backend/services/batchChargeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface BatchChargeOptions {
singleTransactionGas?: number;
batchBaseGas?: number;
perItemGas?: number;
rollbackChargeFn?: (subscriptionId: string, amount: number) => Promise<boolean>;
}

export interface BatchChargeResult {
Expand All @@ -35,20 +36,22 @@ export interface BatchChargeResult {
startedAt: number;
completedAt: number;
errors: string[];
rolledBackItems?: number;
rollbackErrors?: string[];
}

export class BatchChargeService {
private intervalHandle: ReturnType<typeof setInterval> | null = null;
private intervalHandle: ReturnType<of setInterval> | null = null;
private lastMatchTimestamp = 0;
private runHistory: BatchChargeResult[] = [];
private RunHistory: BatchChargeResult[] = [];
private maxHistory = 50;
private cronExpression = '0 0 * * *';
private checkIntervalMs = 60_000;
private singleTransactionGas = 150_000;
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;
Expand Down Expand Up @@ -99,9 +102,9 @@ export class BatchChargeService {
monitoring: MonitoringService,
options?: BatchChargeOptions,
): Promise<BatchChargeResult> {
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)]
Expand All @@ -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) {
Expand All @@ -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;
}
}
Expand All @@ -167,6 +196,8 @@ export class BatchChargeService {
startedAt,
completedAt,
errors,
...(rolledBackItems > 0 ? { rolledBackItems } : {}),
...(rollbackErrors.length > 0 ? { rollbackErrors } : {}),
};

this.recordRun(result);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 {
Expand All @@ -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());
}
}
}