Skip to content
Merged
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
81 changes: 71 additions & 10 deletions backend/src/payment-service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { RateLimiter, RateLimitConfig, RateLimitResult } from './rate-limiter';
import { RateLimiter, RateLimitConfig, RateLimitResult } from './rate-limiter';
import { kycService, KYCStatus } from './services/kyc-service';
import { notifyPaymentWebhook } from './services/paymentWebhookService';
import { EmailNotificationService } from './services/emailNotificationService';
import logger, { auditLogger } from './utils/logger';
import { PaymentRequest as SharedPaymentRequest, PaymentResponse, RateLimitInfo, createApiResponse } from '../../shared/types';
import { accountingService } from './accounting-service';
import { CircuitBreaker, CircuitBreakerConfig, CircuitOpenError } from './utils/circuitBreaker';


// Legacy interface for backward compatibility - deprecated

export interface PaymentRequest {
meter_id: string;
amount: number;
Expand All @@ -25,6 +25,41 @@ export interface PaymentResult extends PaymentResponse {
error?: string;
}

// ─── Input validation helpers ────────────────────────────────────────────────

/** Alphanumeric + hyphens + underscores, 1-50 chars, no spaces/special chars. */
const METER_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;

/** Alphanumeric + hyphens + underscores, 1-100 chars. */
const USER_ID_RE = /^[a-zA-Z0-9_-]{1,100}$/;

function validatePaymentRequest(request: PaymentRequest): string | null {
const { meter_id, amount, userId } = request;

// Validate userId
if (!userId || typeof userId !== 'string' || !USER_ID_RE.test(userId.trim())) {
return 'Invalid user ID format';
}

// Validate meter_id
if (!meter_id || typeof meter_id !== 'string' || !METER_ID_RE.test(meter_id.trim())) {
return 'Invalid meter ID format';
}

// Validate amount
if (
amount === undefined ||
amount === null ||
typeof amount !== 'number' ||
!Number.isFinite(amount) ||
amount <= 0
) {
return 'Invalid amount: must be a finite positive number';
}

return null; // valid
}

// Helper function to convert legacy PaymentRequest to standardized format
function convertToStandardRequest(legacyRequest: PaymentRequest): SharedPaymentRequest {
return {
Expand All @@ -36,6 +71,13 @@ function convertToStandardRequest(legacyRequest: PaymentRequest): SharedPaymentR
};
}

/** Default circuit breaker configuration for the payment provider. */
const DEFAULT_CB_CONFIG: CircuitBreakerConfig = {
failureThreshold: 5,
recoveryTimeMs: 60000, // 60 seconds
name: 'payment-provider',
};

export class PaymentService {
private rateLimiter: RateLimiter;
private rateLimitConfig: RateLimitConfig;
Expand All @@ -50,7 +92,8 @@ export class PaymentService {
}

/**
* Process payment with rate limiting
* Process payment with input validation, rate limiting, and circuit breaker
* protection around the external provider call.
*/
async processPayment(request: PaymentRequest): Promise<PaymentResult> {
const validationError = this.validatePaymentRequest(request);
Expand All @@ -65,6 +108,16 @@ export class PaymentService {
const paymentId = this.generatePaymentId();

try {
// 0. Input validation
const validationError = validatePaymentRequest(request);
if (validationError) {
return {
success: false,
error: validationError,
timestamp: new Date().toISOString()
};
}

// 1. KYC Check
const kycStatus = await kycService.getStatus(request.userId);
auditLogger.log('KYC status check', {
Expand Down Expand Up @@ -137,8 +190,9 @@ export class PaymentService {

// Check rate limit

// 3. Rate limit check
const rateLimitResult = await this.rateLimiter.checkLimit(request.userId);

// Convert RateLimitResult to RateLimitInfo for standardized response
const rateLimitInfo: RateLimitInfo = {
remainingRequests: rateLimitResult.remainingRequests,
Expand All @@ -149,7 +203,6 @@ export class PaymentService {
// The limit is available in the config
limit: (this as any).rateLimiter.config.maxRequests
};


if (!rateLimitResult.allowed && !rateLimitResult.queued) {
const timestamp = new Date().toISOString();
Expand Down Expand Up @@ -181,13 +234,12 @@ export class PaymentService {
return result;
}


if (rateLimitResult.queued) {
const timestamp = new Date().toISOString();
logger.info('Payment queued', { userId: request.userId, queuePosition: rateLimitResult.queuePosition });
auditLogger.log('Payment queued for processing', {
userId: request.userId,
meterId: request.meter_id,
auditLogger.log('Payment queued for processing', {
userId: request.userId,
meterId: request.meter_id,
amount: request.amount,
paymentId,
queuePosition: rateLimitResult.queuePosition,
Expand Down Expand Up @@ -217,7 +269,9 @@ export class PaymentService {
this.pendingPayments.set(paymentId, request);

try {
const transactionId = await this.executePayment(request);
const transactionId = await this.circuitBreaker.execute(() =>
this.executePayment(request)
);

auditLogger.log('Payment executed successfully', {
userId: request.userId,
Expand Down Expand Up @@ -531,4 +585,11 @@ export class PaymentService {
// For now, return false to indicate not implemented
return false;
}

/**
* Get the current state of the circuit breaker.
*/
getCircuitBreakerState() {
return this.circuitBreaker.getState();
}
}
111 changes: 68 additions & 43 deletions backend/src/services/multiProviderPaymentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { ProviderService } from './providerService';
import { ProviderPaymentRequest, ProviderPaymentResult, UtilityProvider } from '../types/provider';
import { notifyPaymentWebhook } from './paymentWebhookService';
import logger, { auditLogger } from '../utils/logger';
import { CircuitBreaker, CircuitBreakerConfig, CircuitOpenError } from '../utils/circuitBreaker';

/** Default circuit breaker config applied per provider. */
const DEFAULT_PROVIDER_CB_CONFIG: Omit<CircuitBreakerConfig, 'name'> = {
failureThreshold: 5,
recoveryTimeMs: 60000, // 60 seconds
};

export class MultiProviderPaymentService {
private rateLimiter: RateLimiter;
Expand All @@ -15,25 +22,36 @@ export class MultiProviderPaymentService {
this.rateLimiter = new RateLimiter(rateLimitConfig);
this.providerService = providerService;
this.initializeProviderRateLimiters();
this.initializeProviderCircuitBreakers();
}

/**
* Initialize rate limiters for each provider
*/
/** Initialize rate limiters for each provider */
private initializeProviderRateLimiters(): void {
const providers = this.providerService.getActiveProviders();

providers.forEach(provider => {
const providerRateLimitConfig: RateLimitConfig = {
windowMs: this.defaultRateLimitConfig.windowMs,
maxRequests: this.defaultRateLimitConfig.maxRequests,
queueSize: this.defaultRateLimitConfig.queueSize ?? 10,
};

this.providerRateLimiters.set(provider.id, new RateLimiter(providerRateLimitConfig));
});
}

/** Initialize circuit breakers for each active provider. */
private initializeProviderCircuitBreakers(): void {
const providers = this.providerService.getActiveProviders();
providers.forEach(provider => {
this.providerCircuitBreakers.set(
provider.id,
new CircuitBreaker({
...DEFAULT_PROVIDER_CB_CONFIG,
name: "provider:" + provider.id,
})
);
});
}

/**
* Ensure a provider-specific rate limiter exists.
*/
Expand All @@ -56,6 +74,33 @@ export class MultiProviderPaymentService {
/**
* Process payment with multi-provider support
*/
private getOrCreateCircuitBreaker(providerId: string): CircuitBreaker {
if (!this.providerCircuitBreakers.has(providerId)) {
this.providerCircuitBreakers.set(
providerId,
new CircuitBreaker({
...DEFAULT_PROVIDER_CB_CONFIG,
name: "provider:" + providerId,
})
);
}
return this.providerCircuitBreakers.get(providerId)!;
}

/**
* Get or lazily create a rate limiter for a provider.
*/
private getOrCreateRateLimiter(providerId: string): RateLimiter {
if (!this.providerRateLimiters.has(providerId)) {
this.providerRateLimiters.set(
providerId,
new RateLimiter({ windowMs: 60 * 1000, maxRequests: 5, queueSize: 10 })
);
}
return this.providerRateLimiters.get(providerId)!;
}

/** Process payment with multi-provider support */
async processPayment(request: ProviderPaymentRequest): Promise<ProviderPaymentResult> {
const paymentId = `provider_${request.providerId}_${Date.now()}_${Math.random().toString(36).substr(2, 8)}`;

Expand Down Expand Up @@ -90,9 +135,6 @@ export class MultiProviderPaymentService {
};
}

// Check if provider supports the meter type (would need meter info from database)
// For now, we'll proceed assuming the provider supports the meter type

// Check rate limit for the specific provider
const providerRateLimiter = this.ensureProviderRateLimiter(request.providerId);
const rateLimitResult = await providerRateLimiter.checkLimit(request.userId);
Expand All @@ -103,7 +145,7 @@ export class MultiProviderPaymentService {
logger.warn('Payment rejected: provider rate limit exceeded', {
userId: request.userId,
providerId: request.providerId,
rateLimitResult
rateLimitResult
});
auditLogger.security('Payment rejected: provider rate limit exceeded', {
userId: request.userId,
Expand Down Expand Up @@ -139,7 +181,7 @@ export class MultiProviderPaymentService {
logger.info('Payment queued for provider', {
userId: request.userId,
providerId: request.providerId,
queuePosition: rateLimitResult.queuePosition
queuePosition: rateLimitResult.queuePosition
});
auditLogger.log('Payment queued for provider', {
userId: request.userId,
Expand Down Expand Up @@ -235,9 +277,7 @@ export class MultiProviderPaymentService {
}
}

/**
* Execute payment using a specific provider's contract
*/
/** Execute payment using a specific provider's contract */
private async executeProviderPayment(request: ProviderPaymentRequest, provider: UtilityProvider): Promise<string> {
const { updateTransactionStatus } = await import('./websocketService');

Expand Down Expand Up @@ -278,7 +318,7 @@ export class MultiProviderPaymentService {

await tx.signAndSend({
signTransaction: async (transaction: any) => {
logger.debug('Signing payment transaction', {
logger.debug('Signing payment transaction', {
meter_id: request.meter_id,
providerId: request.providerId,
providerName: provider.name,
Expand All @@ -292,13 +332,11 @@ export class MultiProviderPaymentService {
return transactionId;
}

/**
* Get total paid amount for a meter using a specific provider
*/
/** Get total paid amount for a meter using a specific provider */
async getTotalPaid(meterId: string, providerId: string): Promise<{ total: number; provider: UtilityProvider }> {
const provider = this.providerService.getProviderById(providerId);
if (!provider || !provider.isActive) {
throw new Error(`Provider ${providerId} is not available`);
throw new Error("Provider " + providerId + " is not available");
}

// Import the client dynamically
Expand All @@ -313,62 +351,49 @@ export class MultiProviderPaymentService {
const result = await client.get_total_paid({ meter_id: meterId });
const total = Number(result.result);

return {
total,
provider
};
return { total, provider };
}

/**
* Get rate limit status for a user across all providers
*/
/** Get rate limit status for a user across all providers */
getRateLimitStatus(userId: string): Record<string, any> {
const status: Record<string, any> = {};

this.providerService.getActiveProviders().forEach((provider) => {
const rateLimiter = this.ensureProviderRateLimiter(provider.id);
status[provider.id] = rateLimiter.getStatus(userId);
});

return status;
}

/**
* Get rate limit status for a specific provider
*/
/** Get rate limit status for a specific provider */
getProviderRateLimitStatus(userId: string, providerId: string): any {
const rateLimiter = this.ensureProviderRateLimiter(providerId);
return rateLimiter.getStatus(userId);
}

/**
* Get user-friendly rate limit error message
*/
/** Get circuit breaker state for a specific provider */
getProviderCircuitBreakerState(providerId: string): string {
const cb = this.providerCircuitBreakers.get(providerId);
if (!cb) return 'UNKNOWN';
return cb.getState();
}

private getRateLimitError(rateLimit: any): string {
const waitTime = Math.ceil((rateLimit.resetTime.getTime() - Date.now()) / 1000);
return `Rate limit exceeded. Please wait ${waitTime} seconds before trying again.`;
return "Rate limit exceeded. Please wait " + waitTime + " seconds before trying again.";
}

/**
* Get queue message
*/
private getQueueMessage(rateLimit: any): string {
if (rateLimit.queuePosition) {
return `Payment queued. You are position #${rateLimit.queuePosition} in the queue.`;
return "Payment queued. You are position #" + rateLimit.queuePosition + " in the queue.";
}
return 'Payment queued. Please wait for processing.';
}

/**
* Get available providers for a user
*/
getAvailableProviders(): UtilityProvider[] {
return this.providerService.getActiveProviders();
}

/**
* Get providers that support a specific meter type
*/
getProvidersByMeterType(meterType: 'electricity' | 'water' | 'gas'): UtilityProvider[] {
return this.providerService.getProvidersByMeterType(meterType);
}
Expand Down
Loading
Loading