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
8 changes: 6 additions & 2 deletions src/application/services/PaymentProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ export class PaymentProcessor {

if (conversionResult.status === 'failed') {
// Need to refund the onramp charge
await this.refundOnramp(onrampResult.details.chargeId);
if (onrampResult.details?.chargeId) {
await this.refundOnramp(onrampResult.details.chargeId);
}
await this.updatePaymentStatus(paymentId, PaymentStatus.FAILED, `Currency conversion failed: ${conversionResult.error}`);

return {
Expand All @@ -140,7 +142,9 @@ export class PaymentProcessor {

if (offrampResult.status === 'failed') {
// Need to refund everything
await this.refundOnramp(onrampResult.details.chargeId);
if (onrampResult.details?.chargeId) {
await this.refundOnramp(onrampResult.details.chargeId);
}
await this.updatePaymentStatus(paymentId, PaymentStatus.FAILED, `Offramp failed: ${offrampResult.error}`);

return {
Expand Down
17 changes: 11 additions & 6 deletions src/application/services/PaymentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ExchangeService } from './ExchangeService';
import { WebhookService } from './WebhookService';
import { logger } from '../../infrastructure/logger';
import { v4 as uuidv4 } from 'uuid';
import { Decimal } from '@prisma/client/runtime/library';

export class PaymentService {
private feeCalculator: FeeCalculator;
Expand Down Expand Up @@ -36,19 +37,23 @@ export class PaymentService {
// 2. Calculate fees
const feeResult = await this.feeCalculator.calculateFees(request);

// 3. Calculate amounts
const sourceAmountAfterFees = request.sourceAmount - feeResult.totalFeeAmount;
const targetAmount = sourceAmountAfterFees * exchangeRate;
const netAmount = targetAmount; // Net amount recipient receives
// 3. Calculate amounts using Decimal for precision
const sourceAmountDecimal = new Decimal(request.sourceAmount);
const feeAmountDecimal = new Decimal(feeResult.totalFeeAmount);
const exchangeRateDecimal = new Decimal(exchangeRate);

const sourceAmountAfterFees = sourceAmountDecimal.minus(feeAmountDecimal);
const targetAmountDecimal = sourceAmountAfterFees.times(exchangeRateDecimal);
const netAmount = targetAmountDecimal; // Net amount recipient receives

const quote: PaymentQuote = {
sourceAmount: request.sourceAmount,
sourceCurrency: request.sourceCurrency,
targetAmount: Number(targetAmount.toFixed(2)),
targetAmount: targetAmountDecimal.toDecimalPlaces(2).toNumber(),
targetCurrency: request.targetCurrency,
exchangeRate: exchangeRate,
totalFees: feeResult.totalFeeAmount,
netAmount: Number(netAmount.toFixed(2)),
netAmount: netAmount.toDecimalPlaces(2).toNumber(),
expiresAt: new Date(Date.now() + 15 * 60 * 1000) // Expires in 15 minutes
};

Expand Down
23 changes: 17 additions & 6 deletions src/infrastructure/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,25 @@ export class WebhookSecurity {
// Block internal/private IPs in production
if (process.env.NODE_ENV === 'production') {
const hostname = parsed.hostname;
// Block localhost, private IPs, etc.

// Block localhost and loopback addresses (IPv4 and IPv6)
if (
hostname === 'localhost' ||
hostname.startsWith('127.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.')
hostname === '::1' || // IPv6 loopback
hostname.startsWith('127.') || // IPv4 loopback
hostname.startsWith('192.168.') || // Private IPv4
hostname.startsWith('10.') || // Private IPv4
hostname.startsWith('172.16.') || // Private IPv4 (172.16.0.0/12)
hostname.startsWith('172.17.') ||
hostname.startsWith('172.18.') ||
hostname.startsWith('172.19.') ||
hostname.startsWith('172.2') || // 172.20-29
hostname.startsWith('172.30.') ||
hostname.startsWith('172.31.') ||
hostname.startsWith('169.254.') || // Link-local IPv4
hostname.startsWith('fc00:') || // IPv6 Unique Local Address
hostname.startsWith('fd00:') || // IPv6 Unique Local Address
hostname.startsWith('fe80:') // IPv6 Link-local
) {
return false;
}
Expand Down
29 changes: 20 additions & 9 deletions src/infrastructure/providers/MockOffRampProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ export class MockOfframpProvider {
await this.delay(300 + Math.random() * 500);

// Mock transfer data
const statuses = ['pending', 'processing', 'completed', 'failed'];
const randomStatus = statuses[Math.floor(Math.random() * statuses.length)] as any;
const statuses: Array<'pending' | 'processing' | 'completed' | 'failed'> = ['pending', 'processing', 'completed', 'failed'];
const randomStatus = statuses[Math.floor(Math.random() * statuses.length)];

return {
id: transferId,
Expand Down Expand Up @@ -218,19 +218,30 @@ export class MockOfframpProvider {
}
};

logger.info('Offramp webhook sent', {
logger.info('Offramp webhook sending', {
eventId: webhookEvent.id,
paymentId: data.paymentId,
status: data.status,
delay: webhookDelay
});

// TODO: Actually send HTTP request to our webhook endpoint
// await fetch(this.webhookUrl + '/webhooks/offramp', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(webhookEvent)
// });
// Send HTTP request to our webhook endpoint
await fetch(this.webhookUrl + '/webhooks/offramp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'OfframpProvider-Webhook/1.0'
},
body: JSON.stringify(webhookEvent)
}).then(response => {
if (!response.ok) {
throw new Error(`Webhook delivery failed with status ${response.status}`);
}
logger.info('Offramp webhook delivered successfully', {
eventId: webhookEvent.id,
paymentId: data.paymentId
});
});

} catch (error) {
logger.error('Failed to send offramp webhook', {
Expand Down
25 changes: 18 additions & 7 deletions src/infrastructure/providers/MockStripProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,18 +203,29 @@ export class MockStripeProvider {
};

// In a real app, this would be an HTTP POST to our webhook endpoint
logger.info('Stripe webhook sent', {
logger.info('Stripe webhook sending', {
eventId: webhookEvent.id,
paymentId: data.paymentId,
status: data.status
});

// TODO: Actually send HTTP request to our webhook endpoint
// await fetch(this.webhookUrl + '/webhooks/stripe', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(webhookEvent)
// });
// Send HTTP request to our webhook endpoint
await fetch(this.webhookUrl + '/webhooks/stripe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Stripe-Webhook/1.0'
},
body: JSON.stringify(webhookEvent)
}).then(response => {
if (!response.ok) {
throw new Error(`Webhook delivery failed with status ${response.status}`);
}
logger.info('Stripe webhook delivered successfully', {
eventId: webhookEvent.id,
paymentId: data.paymentId
});
});

} catch (error) {
logger.error('Failed to send Stripe webhook', {
Expand Down
7 changes: 4 additions & 3 deletions src/presentation/middleware/validation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { logger } from '../../infrastructure/logger';
import { ValidatedRequest } from '../types';

// Validation schemas
export const createPaymentSchema = z.object({
Expand Down Expand Up @@ -37,9 +38,9 @@ export function validateRequest<T>(schema: z.ZodSchema<T>) {
try {
// Validate request body
const validatedData = schema.parse(req.body);
// Attach validated data to request
(req as any).validatedBody = validatedData;

// Attach validated data to request (properly typed)
(req as ValidatedRequest<T>).validatedBody = validatedData;

logger.debug('Request validation successful', {
endpoint: req.path,
Expand Down
16 changes: 13 additions & 3 deletions src/presentation/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,16 @@ interface PaymentEvent {
data: any;
}

interface Fee {
amount: number;
interface PrismaFee {
id: string;
paymentId: string;
type: string;
amount: any; // Prisma Decimal
currency: string;
rate: any | null;
provider: string | null;
description: string | null;
createdAt: Date;
}

const router = express.Router();
Expand Down Expand Up @@ -192,7 +200,9 @@ router.get('/payments/:id/details', async (req: express.Request, res: express.Re
}));

// Calculate totals
const totalFees = payment.fees.reduce((sum: number, fee: any) => sum + Number(fee.amount), 0);
const totalFees = payment.fees.reduce((sum: number, fee: PrismaFee) => {
return sum + Number(fee.amount);
}, 0);
const netAmount = Number(payment.targetAmount || 0) - totalFees;

const response: ApiResponse = {
Expand Down
16 changes: 8 additions & 8 deletions src/presentation/routes/payments.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import express from 'express';
import { PaymentService } from '../../application/services/PaymentService';
import {
validateCreatePayment,
validateGetQuote,
validateSupportedCurrencies
import {
validateCreatePayment,
validateGetQuote,
validateSupportedCurrencies
} from '../middleware/validation';
import { logger } from '../../infrastructure/logger';
import { CreatePaymentRequest, ApiResponse } from '../types';
import { CreatePaymentRequest, ApiResponse, ValidatedRequest, PaymentQuoteRequest } from '../types';

const router = express.Router();
const paymentService = new PaymentService();

// POST /api/v1/quote - Get payment quote
router.post('/quote',
router.post('/quote',
validateGetQuote,
validateSupportedCurrencies,
async (req: express.Request, res: express.Response) => {
try {
const quoteRequest = (req as any).validatedBody;
const quoteRequest = (req as ValidatedRequest<PaymentQuoteRequest>).validatedBody;

const quote = await paymentService.createPaymentQuote(quoteRequest);

Expand Down Expand Up @@ -53,7 +53,7 @@ router.post('/payments',
validateSupportedCurrencies,
async (req: express.Request, res: express.Response) => {
try {
const paymentRequest: CreatePaymentRequest = (req as any).validatedBody;
const paymentRequest = (req as ValidatedRequest<CreatePaymentRequest>).validatedBody;

const result = await paymentService.createPayment(paymentRequest);

Expand Down
73 changes: 41 additions & 32 deletions src/presentation/routes/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,42 +198,51 @@ async function handleStripePaymentSucceeded(stripeEvent: any): Promise<void> {
});

// Update payment status and create event
await prisma.$transaction(async (tx: PrismaTransaction) => {
// Update payment
await tx.payment.update({
where: { id: paymentId },
data: {
externalReference: paymentIntent.id,
updatedAt: new Date()
}
});
try {
await prisma.$transaction(async (tx: PrismaTransaction) => {
// Update payment
await tx.payment.update({
where: { id: paymentId },
data: {
externalReference: paymentIntent.id,
updatedAt: new Date()
}
});

// Create event
await tx.paymentEvent.create({
data: {
paymentId,
eventType: 'stripe_payment_succeeded',
// Create event
await tx.paymentEvent.create({
data: {
paymentIntentId: paymentIntent.id,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
stripeEventId: stripeEvent.id
},
source: 'stripe_webhook'
}
paymentId,
eventType: 'stripe_payment_succeeded',
data: {
paymentIntentId: paymentIntent.id,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
stripeEventId: stripeEvent.id
},
source: 'stripe_webhook'
}
});
});
});

// Send customer notification webhook
await webhookService.sendPaymentWebhook(
paymentId,
WebhookEventType.PAYMENT_PROCESSING,
{
step: 'onramp_completed',
provider: 'stripe',
externalReference: paymentIntent.id
}
);
// Send customer notification webhook
await webhookService.sendPaymentWebhook(
paymentId,
WebhookEventType.PAYMENT_PROCESSING,
{
step: 'onramp_completed',
provider: 'stripe',
externalReference: paymentIntent.id
}
);
} catch (transactionError) {
logger.error('Transaction failed in Stripe payment handling', {
stripeEventId: stripeEvent.id,
paymentId,
error: transactionError instanceof Error ? transactionError.message : transactionError
});
throw transactionError;
}

} catch (error) {
logger.error('Failed to handle Stripe payment succeeded', {
Expand Down
6 changes: 6 additions & 0 deletions src/presentation/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { IPayment, IFee } from '../../domain/interfaces';
import { Request } from 'express';

// Extended Express Request with validated body
export interface ValidatedRequest<T = any> extends Request {
validatedBody: T;
}

// API Request Types
export interface CreatePaymentRequest {
Expand Down