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
78 changes: 47 additions & 31 deletions src/services/kycFraud.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,8 @@ function applyPlattScaling(rawScore: number, params: { A: number; B: number }):

// ─── Third-Party Fraud Service ────────────────────────────────────────────────

import { CircuitBreaker, CircuitBreakerRegistry, DefaultValueFallback } from '../utils/circuitBreaker';

export async function getThirdPartyFraudScore(
input: FraudInput,
): Promise<{ score: number; signals: FraudSignal[] } | null> {
Expand All @@ -493,41 +495,55 @@ export async function getThirdPartyFraudScore(
return null;
}

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.kycFraud.thirdPartyTimeoutMs);
let cb = CircuitBreakerRegistry.get('kyc_fraud');
if (!cb) {
cb = new CircuitBreaker('kyc_fraud', {
failureRateThreshold: 0.5,
minimumRequests: 5,
latencyThresholdMs: 5000,
openTimeoutMs: 60000,
halfOpenMaxRequests: 3
}, new DefaultValueFallback<{ score: number; signals: FraudSignal[] } | null>(null));
CircuitBreakerRegistry.set('kyc_fraud', cb);
}

const response = await fetch(config.kycFraud.thirdPartyApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.kycFraud.thirdPartyApiKey}`,
},
body: JSON.stringify({
userId: input.userId,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
deviceFingerprint: input.deviceFingerprint,
documentType: input.documentType,
}),
signal: controller.signal,
});
return cb.execute(async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.kycFraud.thirdPartyTimeoutMs);

clearTimeout(timer);
const response = await fetch(config.kycFraud.thirdPartyApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.kycFraud.thirdPartyApiKey}`,
},
body: JSON.stringify({
userId: input.userId,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
deviceFingerprint: input.deviceFingerprint,
documentType: input.documentType,
}),
signal: controller.signal,
});

if (!response.ok) {
logger.warn(`Third-party fraud service returned ${response.status}`);
return null;
}
clearTimeout(timer);

const result = await response.json() as { score?: number; signals?: FraudSignal[] };
return {
score: result.score ?? 0,
signals: (result.signals ?? []).map((s: any) => ({
signal: s.signal ?? 'thirdPartyFlag',
severity: s.severity ?? 'medium',
detail: s.detail ?? 'Third-party fraud signal',
})),
};
if (!response.ok) {
logger.warn(`Third-party fraud service returned ${response.status}`);
throw new Error(`Third-party fraud service returned ${response.status}`);
}

const result = await response.json() as { score?: number; signals?: FraudSignal[] };
return {
score: result.score ?? 0,
signals: (result.signals ?? []).map((s: any) => ({
signal: s.signal ?? 'thirdPartyFlag',
severity: s.severity ?? 'medium',
detail: s.detail ?? 'Third-party fraud signal',
})),
};
});
} catch (err: any) {
logger.warn('Third-party fraud service unavailable, skipping', { error: err.message });
return null;
Expand Down
44 changes: 29 additions & 15 deletions src/services/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { EmailPreferenceService } from './email-preference.service';
import { sanitizeObject, sanitizeString } from '../utils/sanitization';
import { classifyDeliveryError } from '../utils/deliveryErrors';
import { AdminNotificationPreferenceService } from './adminNotificationPreference.service';

import { CircuitBreaker, CircuitBreakerRegistry, FailFastFallback } from '../utils/circuitBreaker';
const EMAIL_DELIVERY_MAX_ATTEMPTS = 3;
const EMAIL_DELIVERY_BASE_DELAY_MS = 200;

Expand Down Expand Up @@ -180,21 +180,35 @@ export class NotificationService {
attachments?: Array<{ filename: string; content: Buffer; contentType?: string }>;
} = {}
): Promise<void> {
try {
await this.transporter.sendMail({
from: options.from || config.email.from,
to,
subject,
html,
text: options.text,
attachments: options.attachments,
});

logger.info(`Email sent to ${to}`);
} catch (error) {
logger.error('Error sending email:', error);
throw error;
let cb = CircuitBreakerRegistry.get('email');
if (!cb) {
cb = new CircuitBreaker('email', {
failureRateThreshold: 0.5,
minimumRequests: 5,
latencyThresholdMs: 10000,
openTimeoutMs: 60000,
halfOpenMaxRequests: 3
}, new FailFastFallback());
CircuitBreakerRegistry.set('email', cb);
}

return cb.execute(async () => {
try {
await this.transporter.sendMail({
from: options.from || config.email.from,
to,
subject,
html,
text: options.text,
attachments: options.attachments,
});

logger.info(`Email sent to ${to}`);
} catch (error) {
logger.error('Error sending email:', error);
throw error;
}
});
}

/**
Expand Down
76 changes: 46 additions & 30 deletions src/services/payment/stellarProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import logger from '../../config/logger';
* ever called) — see pledge.worker.ts. Do not rely on this provider alone
* for idempotency.
*/
import { CircuitBreaker, CircuitBreakerRegistry, FailFastFallback } from '../../utils/circuitBreaker';

export class StellarProvider implements PaymentProvider {
readonly name = 'stellar' as const;
private server: Server;
Expand All @@ -30,43 +32,57 @@ export class StellarProvider implements PaymentProvider {
this.server = new Server(networkUrl);
this.escrowKeypair = Keypair.fromSecret(escrowSecretKey);
this.networkPassphrase = networkPassphrase;

if (!CircuitBreakerRegistry.has('stellar')) {
CircuitBreakerRegistry.set('stellar', new CircuitBreaker('stellar', {
failureRateThreshold: 0.5,
minimumRequests: 5,
latencyThresholdMs: 5000,
openTimeoutMs: 60000,
halfOpenMaxRequests: 3
}, new FailFastFallback()));
}
}

async charge(options: ChargeOptions): Promise<ChargeResult> {
try {
const account = await this.server.getAccount(this.escrowKeypair.publicKey());
const cb = CircuitBreakerRegistry.get('stellar')!;

const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.networkPassphrase,
})
.addOperation(
Operation.payment({
destination: this.escrowKeypair.publicKey(),
asset: Asset.native(),
amount: options.amount.toFixed(7),
}),
)
.addMemo(Memo.text(options.idempotencyKey.slice(0, 28)))
.setTimeout(30)
.build();
return cb.execute(async () => {
try {
const account = await this.server.getAccount(this.escrowKeypair.publicKey());

tx.sign(this.escrowKeypair);
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.networkPassphrase,
})
.addOperation(
Operation.payment({
destination: this.escrowKeypair.publicKey(),
asset: Asset.native(),
amount: options.amount.toFixed(7),
}),
)
.addMemo(Memo.text(options.idempotencyKey.slice(0, 28)))
.setTimeout(30)
.build();

const result: any = await this.server.sendTransaction(tx);
tx.sign(this.escrowKeypair);

if (result.status === 'ERROR' || result.status === 'FAILED') {
throw new PaymentError(
`Stellar transaction failed (status: ${result.status}): ${JSON.stringify(result.errorResult ?? '')}`,
true,
);
}
const result: any = await this.server.sendTransaction(tx);

return { providerReference: result.hash, provider: 'stellar', raw: result };
} catch (error: any) {
if (error instanceof PaymentError) throw error;
logger.error('Stellar charge failed', { pledgeId: options.pledgeId, error: error.message });
throw new PaymentError(error.message ?? 'Stellar charge failed', true);
}
if (result.status === 'ERROR' || result.status === 'FAILED') {
throw new PaymentError(
`Stellar transaction failed (status: ${result.status}): ${JSON.stringify(result.errorResult ?? '')}`,
true,
);
}

return { providerReference: result.hash, provider: 'stellar', raw: result };
} catch (error: any) {
if (error instanceof PaymentError) throw error;
logger.error('Stellar charge failed', { pledgeId: options.pledgeId, error: error.message });
throw new PaymentError(error.message ?? 'Stellar charge failed', true);
}
});
}
}
75 changes: 44 additions & 31 deletions src/services/payment/stripeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,23 @@ import logger from '../../config/logger';
* and are expected to be wired in separately; `stripeCustomerId` /
* `stripePaymentMethodId` on ChargeOptions are the seam for that future work.
*/
import { CircuitBreaker, CircuitBreakerRegistry, FailFastFallback } from '../../utils/circuitBreaker';

export class StripeProvider implements PaymentProvider {
readonly name = 'stripe' as const;
private stripe: Stripe;

constructor(secretKey: string) {
this.stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
if (!CircuitBreakerRegistry.has('stripe')) {
CircuitBreakerRegistry.set('stripe', new CircuitBreaker('stripe', {
failureRateThreshold: 0.5,
minimumRequests: 5,
latencyThresholdMs: 5000,
openTimeoutMs: 60000,
halfOpenMaxRequests: 3
}, new FailFastFallback()));
}
}

async charge(options: ChargeOptions): Promise<ChargeResult> {
Expand All @@ -31,39 +42,41 @@ export class StripeProvider implements PaymentProvider {
);
}

try {
const intent = await this.stripe.paymentIntents.create(
{
amount: Math.round(options.amount * 100),
currency: options.currency.toLowerCase(),
customer: options.stripeCustomerId,
payment_method: options.stripePaymentMethodId,
off_session: true,
confirm: true,
metadata: {
pledgeId: options.pledgeId,
donorId: options.donorId,
campaignId: options.campaignId,
},
},
{ idempotencyKey: options.idempotencyKey },
);
const cb = CircuitBreakerRegistry.get('stripe')!;

if (intent.status !== 'succeeded') {
throw new PaymentError(
`Stripe payment intent ${intent.id} did not succeed (status: ${intent.status})`,
true,
return cb.execute(async () => {
try {
const intent = await this.stripe.paymentIntents.create(
{
amount: Math.round(options.amount * 100),
currency: options.currency.toLowerCase(),
customer: options.stripeCustomerId,
payment_method: options.stripePaymentMethodId,
off_session: true,
confirm: true,
metadata: {
pledgeId: options.pledgeId,
donorId: options.donorId,
campaignId: options.campaignId,
},
},
{ idempotencyKey: options.idempotencyKey },
);
}

return { providerReference: intent.id, provider: 'stripe', raw: intent };
} catch (error: any) {
if (error instanceof PaymentError) throw error;
logger.error('Stripe charge failed', { pledgeId: options.pledgeId, error: error.message });
// Stripe card errors are declines and not worth infinite retry, but the
// worker's existing MAX_RETRIES + dead-letter path already bounds
// retries, so we mark everything retryable here and let that path work.
throw new PaymentError(error.message ?? 'Stripe charge failed', true);
}
if (intent.status !== 'succeeded') {
throw new PaymentError(
`Stripe payment intent ${intent.id} did not succeed (status: ${intent.status})`,
true,
);
}

return { providerReference: intent.id, provider: 'stripe', raw: intent };
} catch (error: any) {
if (error instanceof PaymentError) throw error;
logger.error('Stripe charge failed', { pledgeId: options.pledgeId, error: error.message });
throw new PaymentError(error.message ?? 'Stripe charge failed', true);
}
});
}
}

46 changes: 46 additions & 0 deletions src/utils/circuitBreaker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { CircuitBreaker, CircuitBreakerState, CircuitBreakerRegistry, FailFastFallback } from './circuitBreaker';

describe('CircuitBreaker', () => {
beforeEach(() => {
CircuitBreakerRegistry.clear();
});

it('should transition to OPEN when failure threshold is exceeded', async () => {
const cb = new CircuitBreaker('test', {
failureRateThreshold: 0.5,
minimumRequests: 2,
latencyThresholdMs: 5000,
openTimeoutMs: 1000,
halfOpenMaxRequests: 1
}, new FailFastFallback());

await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow('fail');
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow('fail');

expect(cb.getState()).toBe(CircuitBreakerState.OPEN);
});

it('should transition to HALF_OPEN after timeout', async () => {
jest.useFakeTimers();
const cb = new CircuitBreaker('test2', {
failureRateThreshold: 0.5,
minimumRequests: 2,
latencyThresholdMs: 5000,
openTimeoutMs: 1000,
halfOpenMaxRequests: 1
}, new FailFastFallback());

await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow('fail');
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow('fail');

expect(cb.getState()).toBe(CircuitBreakerState.OPEN);

jest.advanceTimersByTime(1100);

// Should allow one request as HALF_OPEN
await expect(cb.execute(async () => 'success')).resolves.toBe('success');
expect(cb.getState()).toBe(CircuitBreakerState.CLOSED);

jest.useRealTimers();
});
});
Loading