FRE-5401: Migrate webhook idempotency to distributed Redis store

Replace in-memory Map<string, number> with Redis-based idempotency
using setIfNotExists (NX) for distributed multi-instance deployments.
Removes cleanupOldEvents (no longer needed with Redis TTL).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-15 20:27:12 -04:00
parent cba5390309
commit 7c2b585c16

View File

@@ -1,21 +1,12 @@
import Stripe from 'stripe';
import { loadBillingConfig, SubscriptionTier } from '../config/billing.config';
import { RedisService } from '@shieldsai/shared-notifications';
import type { Subscription, SubscriptionCreateSchema, SubscriptionUpdateSchema } from '../models/subscription.model';
const config = loadBillingConfig();
const stripe = new Stripe(config.stripe.apiKey, { apiVersion: '2023-10-16' });
const processedEvents = new Map<string, number>();
const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
function cleanupOldEvents(): void {
const now = Date.now();
for (const [eventId, timestamp] of processedEvents.entries()) {
if (now - timestamp > IDEMPOTENCY_TTL_MS) {
processedEvents.delete(eventId);
}
}
}
const redis = RedisService.getInstance();
const IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60;
export class BillingService {
private static instance: BillingService;
@@ -266,15 +257,17 @@ export class BillingService {
body: Buffer
): Promise<Stripe.Event | null> {
const event = stripe.webhooks.constructEvent(body, sig, config.stripe.webhookSecret);
cleanupOldEvents();
if (processedEvents.has(event.id)) {
const wasNew = await redis.setIfNotExists(
`stripe:event:${event.id}`,
'1',
IDEMPOTENCY_TTL_SECONDS
);
if (!wasNew) {
return null;
}
processedEvents.set(event.id, Date.now());
return event;
}