Skip to content

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

CheckImplementation
Signature verificationHMAC-SHA256 with timing-safe comparison
Replay preventionTimestamp validation (5 min tolerance)
IdempotencyEvent ID deduplication with 7-day TTL
Raw body usageNever use parsed/stringified JSON
HTTPS onlyReject HTTP webhook endpoints
IP allowlistOptional: 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
// - FirebasePushSender

Delivery 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' }
  });
});