import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { Redis } from "ioredis"; import { randomUUID } from "crypto"; export type OperationType = "voice" | "network" | "plan" | "cancellation"; interface OperationTimestamps { voice?: number; network?: number; plan?: number; cancellation?: number; } /** * Rate limiter for Freebit API operations. * * Enforces timing constraints between operations to prevent them from * canceling each other: * - PA05-06 (voice features): Runs with immediate effect * - PA05-38 (contract line): Runs with immediate effect * - PA05-21 (plan change): Requires runTime parameter, scheduled for 1st of following month * - PA05-21 and PA02-04 (cancellation) cannot coexist * - These must run 30 minutes apart to avoid canceling each other */ @Injectable() export class FreebitRateLimiterService { private readonly operationTimestamps = new Map(); private lastCleanupTime = 0; private readonly cleanupIntervalMs = 5 * 60 * 1000; // Run cleanup at most every 5 minutes private readonly staleThresholdMs = 35 * 60 * 1000; // Remove entries older than 35 minutes private readonly windowMs = 30 * 60 * 1000; // 30 minute window between operations private readonly lockTtlMs = Math.min(this.windowMs, 10 * 60 * 1000); constructor( @Inject("REDIS_CLIENT") private readonly redis: Redis, @Inject(Logger) private readonly logger: Logger ) {} /** * Cleanup stale entries from operationTimestamps to prevent memory leak. * Entries are considered stale when all their timestamps are older than staleThresholdMs. */ private cleanupStaleEntries(): void { const now = Date.now(); // Rate limit cleanup to avoid performance overhead if (now - this.lastCleanupTime < this.cleanupIntervalMs) { return; } this.lastCleanupTime = now; const staleThreshold = now - this.staleThresholdMs; const accountsToRemove: string[] = []; for (const [account, entry] of this.operationTimestamps) { const timestamps = [entry.voice, entry.network, entry.plan, entry.cancellation].filter( (t): t is number => typeof t === "number" ); // If all timestamps are stale (or entry is empty), mark for removal // EXCEPT: Never clean up entries with a cancellation - cancellation blocks plan changes permanently // until the account is actually cancelled or the cancellation is reverted externally const hasCancellation = typeof entry.cancellation === "number"; if ( !hasCancellation && (timestamps.length === 0 || timestamps.every(t => t < staleThreshold)) ) { accountsToRemove.push(account); } } for (const account of accountsToRemove) { this.operationTimestamps.delete(account); } if (accountsToRemove.length > 0) { this.logger.debug(`Cleaned up ${accountsToRemove.length} stale operation timestamp entries`); } } /** * Get operation timestamps for an account, initializing if needed. */ private getOperationWindowFromMemory(account: string): OperationTimestamps { this.cleanupStaleEntries(); if (!this.operationTimestamps.has(account)) { this.operationTimestamps.set(account, {}); } return this.operationTimestamps.get(account)!; } private buildKey(account: string): string { return `freebit:ops:${account}`; } private buildLockKey(account: string): string { return `freebit:ops:lock:${account}`; } private async acquireLock(account: string, token: string): Promise { const key = this.buildLockKey(account); const result = await this.redis.set(key, token, "PX", this.lockTtlMs, "NX"); return result === "OK"; } private async releaseLock(account: string, token: string): Promise { const key = this.buildLockKey(account); const script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; try { await this.redis.eval(script, 1, key, token); } catch (error) { this.logger.warn("Failed to release Freebit operation lock", { account, error: error instanceof Error ? error.message : String(error), }); } } /** * Execute a Freebit operation with spacing checks and a distributed lock. * Ensures no concurrent operations violate the 30-minute spacing rule. */ async executeWithSpacing( account: string, op: OperationType, operation: () => Promise ): Promise { const token = randomUUID(); try { const acquired = await this.acquireLock(account, token); if (!acquired) { throw new BadRequestException( "Another SIM operation is in progress. Please try again shortly." ); } await this.assertOperationSpacing(account, op); const result = await operation(); await this.stampOperation(account, op); return result; } finally { await this.releaseLock(account, token); } } private async getOperationWindow(account: string): Promise { const key = this.buildKey(account); try { const raw = await this.redis.hgetall(key); const parsed: OperationTimestamps = {}; for (const [field, value] of Object.entries(raw)) { const num = Number(value); if (!isNaN(num)) { parsed[field as keyof OperationTimestamps] = num; } } return parsed; } catch (error) { this.logger.warn("Failed to read Freebit rate limit window from Redis", { account, error: error instanceof Error ? error.message : String(error), }); return this.getOperationWindowFromMemory(account); } } /** * Assert that an operation can be performed based on timing constraints. * Throws BadRequestException if the operation violates timing rules. */ async assertOperationSpacing(account: string, op: OperationType): Promise { const now = Date.now(); const entry = await this.getOperationWindow(account); if (op === "voice") { if (entry.plan && now - entry.plan < this.windowMs) { throw new BadRequestException( "Voice feature changes must be at least 30 minutes apart from plan changes. Please try again later." ); } if (entry.network && now - entry.network < this.windowMs) { throw new BadRequestException( "Voice feature changes must be at least 30 minutes apart from network type updates. Please try again later." ); } } if (op === "network") { if (entry.voice && now - entry.voice < this.windowMs) { throw new BadRequestException( "Network type updates must be requested 30 minutes after voice option changes. Please try again later." ); } if (entry.plan && now - entry.plan < this.windowMs) { throw new BadRequestException( "Network type updates must be requested at least 30 minutes apart from plan changes. Please try again later." ); } } if (op === "plan") { if (entry.voice && now - entry.voice < this.windowMs) { throw new BadRequestException( "Plan changes must be requested 30 minutes after voice option changes. Please try again later." ); } if (entry.network && now - entry.network < this.windowMs) { throw new BadRequestException( "Plan changes must be requested 30 minutes after network type updates. Please try again later." ); } if (entry.cancellation) { throw new BadRequestException( "This subscription has a pending cancellation. Plan changes are no longer permitted." ); } } } /** * Clear all rate limit timestamps for an account (for testing/debugging) */ async clearRateLimitForAccount(account: string): Promise { const key = this.buildKey(account); try { await this.redis.del(key); this.operationTimestamps.delete(account); this.logger.log(`Cleared rate limit state for account ${account}`); } catch (error) { this.logger.warn("Failed to clear rate limit state", { account, error: error instanceof Error ? error.message : String(error), }); } } /** * Get remaining wait time for a specific operation type (in seconds) */ async getRemainingWaitTime(account: string, op: OperationType): Promise { const entry = await this.getOperationWindow(account); const now = Date.now(); if (op === "network") { const voiceWait = entry.voice ? Math.max(0, this.windowMs - (now - entry.voice)) : 0; const planWait = entry.plan ? Math.max(0, this.windowMs - (now - entry.plan)) : 0; return Math.ceil(Math.max(voiceWait, planWait) / 1000); } if (op === "voice") { const planWait = entry.plan ? Math.max(0, this.windowMs - (now - entry.plan)) : 0; const networkWait = entry.network ? Math.max(0, this.windowMs - (now - entry.network)) : 0; return Math.ceil(Math.max(planWait, networkWait) / 1000); } return 0; } /** * Record that an operation was performed for an account. */ async stampOperation(account: string, op: OperationType): Promise { const now = Date.now(); const entry = this.getOperationWindowFromMemory(account); entry[op] = now; const key = this.buildKey(account); try { await this.redis.hset(key, op, String(now)); const hasCancellation = op === "cancellation" || entry.cancellation !== undefined; if (hasCancellation) { await this.redis.persist(key); } else { await this.redis.expire(key, Math.ceil(this.staleThresholdMs / 1000)); } } catch (error) { this.logger.warn("Failed to persist Freebit rate limit window to Redis", { account, op, error: error instanceof Error ? error.message : String(error), }); } } }