Integration Abstraction
Payment Gateway Interface
typescript
interface IPaymentGateway {
createPaymentIntent(params: CreatePaymentParams): Promise<PaymentIntent>;
confirmPayment(intentId: string): Promise<PaymentResult>;
createPayout(params: CreatePayoutParams): Promise<PayoutResult>;
handleWebhook(payload: Buffer, signature: string): Promise<WebhookResult>;
verifyWebhookSignature(payload: Buffer, signature: string): boolean;
}
// Implementations:
// - StripeGateway (International)
// - YooKassaGateway (Russia/CIS)Webhook Security (Critical)
Payment webhooks must be verified to prevent:
- Spoofed events triggering fraudulent order fulfillment
- Replay attacks reprocessing old events
- Tampered payloads changing amounts or statuses
Signature Verification (Industry Standard: HMAC-SHA256)
typescript
import { createHmac, timingSafeEqual } from 'crypto';
export class WebhookVerifier {
constructor(private readonly secret: string) {}
/**
* Verify webhook signature using HMAC-SHA256
* Used by: Stripe, GitHub, Shopify, Slack, YooKassa
*/
verify(payload: Buffer, signature: string, timestamp?: number): boolean {
// 1. Check timestamp to prevent replay attacks (5 min tolerance)
if (timestamp) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
throw new Error('Webhook timestamp too old (possible replay attack)');
}
}
// 2. Compute expected signature
const signedPayload = timestamp
? `${timestamp}.${payload.toString()}`
: payload.toString();
const expectedSignature = createHmac('sha256', this.secret)
.update(signedPayload)
.digest('hex');
// 3. Use timing-safe comparison to prevent timing attacks
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (signatureBuffer.length !== expectedBuffer.length) {
return false;
}
return timingSafeEqual(signatureBuffer, expectedBuffer);
}
}Stripe Webhook Handler Example
typescript
@Controller('webhooks')
export class StripeWebhookController {
constructor(
private readonly paymentService: PaymentService,
private readonly config: ConfigService,
) {}
@Post('stripe')
@HttpCode(200)
async handleStripeWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string,
): Promise<{ received: boolean }> {
// CRITICAL: Use raw body, NOT parsed JSON
const rawBody = req.rawBody;
if (!rawBody) {
throw new BadRequestException('Missing raw body');
}
// Verify signature using Stripe SDK
const webhookSecret = this.config.get('STRIPE_WEBHOOK_SECRET');
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(
rawBody,
signature,
webhookSecret,
);
} catch (err) {
throw new BadRequestException(`Webhook signature verification failed`);
}
// Process event with idempotency check
await this.paymentService.processWebhookEvent(event);
return { received: true };
}
}Webhook Event Processing
typescript
async processWebhookEvent(event: PaymentWebhookEvent): Promise<void> {
// 1. Idempotency check - prevent duplicate processing
const idempotencyKey = `webhook:${event.id}`;
const existing = await this.cache.get(idempotencyKey);
if (existing) {
this.logger.info(`Webhook ${event.id} already processed, skipping`);
return;
}
// 2. Process based on event type
switch (event.type) {
case 'payment_intent.succeeded':
await this.handlePaymentSuccess(event.data);
break;
case 'payment_intent.payment_failed':
await this.handlePaymentFailure(event.data);
break;
case 'charge.refunded':
await this.handleRefund(event.data);
break;
case 'charge.dispute.created':
await this.handleDispute(event.data);
break;
}
// 3. Mark as processed (TTL: 7 days to handle delayed retries)
await this.cache.set(idempotencyKey, 'processed', 7 * 24 * 60 * 60);
}Webhook Security Checklist
| Check | Implementation |
|---|---|
| Signature verification | HMAC-SHA256 with timing-safe comparison |
| Replay prevention | Timestamp validation (5 min tolerance) |
| Idempotency | Event ID deduplication with 7-day TTL |
| Raw body usage | Never use parsed/stringified JSON |
| HTTPS only | Reject HTTP webhook endpoints |
| IP allowlist | Optional: Restrict to provider IP ranges |
Notification Service Interface
typescript
interface INotificationSender {
sendEmail(params: EmailParams): Promise<SendResult>;
sendSMS(params: SMSParams): Promise<SendResult>;
sendPush(params: PushParams): Promise<SendResult>;
}
// Implementations:
// - SendGridEmailSender
// - TwilioSMSSender
// - FirebasePushSenderDelivery Service Interface
typescript
interface IDeliveryProvider {
getQuotes(params: QuoteParams): Promise<ShippingOption[]>;
createShipment(params: ShipmentParams): Promise<Shipment>;
trackShipment(trackingNumber: string): Promise<TrackingInfo>;
}
// Implementations:
// - CDEKProvider (Russia)
// - DHLProvider (International)Inventory Reservation (E-Commerce)
Problem
During checkout, stock can be oversold if multiple users try to buy the same item simultaneously.
Solution: Soft Reservation with Timeout
sql
CREATE TABLE product.inventory_reservations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
product_id UUID NOT NULL REFERENCES product.products(id),
cart_id UUID REFERENCES product.carts(id),
order_id UUID REFERENCES product.orders(id),
quantity INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'RESERVED',
-- RESERVED, COMMITTED, RELEASED, EXPIRED
reserved_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL, -- e.g., 15 minutes from reservation
committed_at TIMESTAMP,
CONSTRAINT positive_quantity CHECK (quantity > 0)
);
CREATE INDEX idx_reservations_product ON product.inventory_reservations(product_id, status);
CREATE INDEX idx_reservations_expires ON product.inventory_reservations(expires_at) WHERE status = 'RESERVED';Reservation Flow
User adds to cart
│
▼
┌──────────────────┐
│ Check available │ available = stock_quantity - SUM(reserved where RESERVED)
│ quantity │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Create soft │ status=RESERVED, expires_at=NOW()+15min
│ reservation │
└────────┬─────────┘
│
┌────┴────┐
│ │
▼ ▼
Checkout Timeout
│ │
▼ ▼
COMMITTED EXPIRED (pg-boss cleanup job)Cleanup Job (pg-boss)
typescript
// Runs every minute to release expired reservations
boss.schedule('release-expired-reservations', '* * * * *', {}, {
retryLimit: 3
});
boss.work('release-expired-reservations', async () => {
await db.inventoryReservation.updateMany({
where: { status: 'RESERVED', expiresAt: { lt: new Date() }},
data: { status: 'EXPIRED' }
});
});