Skip to content

Payments Domain

The Payments domain handles payment gateway integration through an abstract interface. It provides a clean abstraction for processing payments, verifying transactions, and managing payment lifecycle, with concrete implementation for Paystack.

Overview

The Payments domain is implemented as an abstract gateway interface to decouple the business logic from specific payment providers. This allows:

  • Swappable payment providers – Easy migration between gateways
  • Testability – Mock payment gateway in tests
  • Multi-gateway support – Future support for multiple providers

Current implementation: Paystack (Nigeria/Africa)

Payment Gateway Interface

AbstractPaymentGateway

// domain/payments/payment.gateway.ts
export abstract class AbstractPaymentGateway {
  abstract initializeTransaction(
    email: string,
    amount: Decimal,
    currency: string,
    metadata?: Record<string, any>
  ): Promise<PaymentInitResponse>;

  abstract verifyTransaction(
    reference: string
  ): Promise<PaymentVerifyResponse>;

  abstract initializeTransfer(
    recipient: string,
    amount: Decimal,
    currency: string,
    reason?: string
  ): Promise<TransferInitResponse>;

  abstract finalizeTransfer(
    transferCode: string,
    otp: string
  ): Promise<TransferFinalizeResponse>;

  abstract createTransferRecipient(
    type: 'bank',
    name: string,
    accountNumber: string,
    bankCode: string,
    currency: string
  ): Promise<TransferRecipientResponse>;
}

Response Types

export interface PaymentInitResponse {
  success: boolean;
  authorizationUrl: string;
  reference: string;
  accessCode?: string;
}

export interface PaymentVerifyResponse {
  success: boolean;
  amount: number;
  currency: string;
  status: 'success' | 'failed' | 'abandoned';
  reference: string;
  paidAt?: Date;
  channel?: string;
  metadata?: Record<string, any>;
}

export interface TransferInitResponse {
  success: boolean;
  transferCode: string;
  reference: string;
}

export interface TransferFinalizeResponse {
  success: boolean;
  status: 'success' | 'failed' | 'pending';
  transferCode: string;
  reference: string;
}

export interface TransferRecipientResponse {
  success: boolean;
  recipientCode: string;
  details?: {
    accountNumber: string;
    accountName: string;
    bankCode: string;
    bankName: string;
  };
}

Payment Operations

Initialize Transaction

Start a new payment transaction:

const response = await paymentGateway.initializeTransaction(
  'customer@example.com',
  new Decimal(10000), // Amount in smallest currency unit (kobo, cents)
  'NGN',
  {
    orderId: 'order-123',
    customerId: 'user-456',
    serviceId: 'service-789'
  }
);

// Response
{
  success: true,
  authorizationUrl: 'https://checkout.paystack.com/abc123',
  reference: 'ref_xyz789',
  accessCode: 'ac_def456'
}

Usage flow: 1. Application calls initializeTransaction() 2. Gateway returns authorization URL 3. Customer redirected to payment page 4. Customer completes payment 5. Gateway redirects back with reference 6. Application verifies transaction

Verify Transaction

Confirm payment completion:

const response = await paymentGateway.verifyTransaction('ref_xyz789');

// Response
{
  success: true,
  amount: 10000,
  currency: 'NGN',
  status: 'success',
  reference: 'ref_xyz789',
  paidAt: new Date('2024-01-15T10:30:00Z'),
  channel: 'card',
  metadata: {
    orderId: 'order-123',
    customerId: 'user-456',
    serviceId: 'service-789'
  }
}

Verification checks: - Transaction exists - Payment status is 'success' - Amount matches expected value - Currency matches - Reference not already processed

Create Transfer Recipient

Register bank account for payouts:

const response = await paymentGateway.createTransferRecipient(
  'bank',
  'John Doe',
  '0123456789',
  '058', // GTBank
  'NGN'
);

// Response
{
  success: true,
  recipientCode: 'rcp_abc123',
  details: {
    accountNumber: '0123456789',
    accountName: 'John Doe',
    bankCode: '058',
    bankName: 'GTBank'
  }
}

Use cases: - Seller account registration - Withdrawal setup - Payout configuration

Initialize Transfer

Start a payout:

const response = await paymentGateway.initializeTransfer(
  'rcp_abc123', // Recipient code
  new Decimal(50000),
  'NGN',
  'Order #order-123 payout'
);

// Response
{
  success: true,
  transferCode: 'trf_xyz789',
  reference: 'ref_transfer_456'
}

Finalize Transfer

Complete transfer with OTP:

const response = await paymentGateway.finalizeTransfer(
  'trf_xyz789',
  '123456' // OTP from gateway
);

// Response
{
  success: true,
  status: 'success',
  transferCode: 'trf_xyz789',
  reference: 'ref_transfer_456'
}

Paystack Implementation

PaystackGateway Adapter

// adapters/order/paystack.gateway.ts
export class PaystackGateway extends AbstractPaymentGateway {
  private readonly baseUrl = 'https://api.paystack.co';
  private readonly secretKey: string;

  constructor(secretKey: string) {
    super();
    this.secretKey = secretKey;
  }

  async initializeTransaction(
    email: string,
    amount: Decimal,
    currency: string,
    metadata?: Record<string, any>
  ): Promise<PaymentInitResponse> {
    const response = await fetch(`${this.baseUrl}/transaction/initialize`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.secretKey}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        email,
        amount: amount.toNumber(),
        currency,
        metadata
      })
    });

    const data = await response.json();

    return {
      success: data.status,
      authorizationUrl: data.data.authorization_url,
      reference: data.data.reference,
      accessCode: data.data.access_code
    };
  }

  async verifyTransaction(reference: string): Promise<PaymentVerifyResponse> {
    const response = await fetch(
      `${this.baseUrl}/transaction/verify/${reference}`,
      {
        headers: {
          'Authorization': `Bearer ${this.secretKey}`
        }
      }
    );

    const data = await response.json();

    return {
      success: data.status,
      amount: data.data.amount,
      currency: data.data.currency,
      status: data.data.status,
      reference: data.data.reference,
      paidAt: new Date(data.data.paid_at),
      channel: data.data.channel,
      metadata: data.data.metadata
    };
  }

  // ... other methods
}

Configuration

// web/config/payment.config.ts
export const paymentConfig = {
  paystack: {
    secretKey: process.env.PAYSTACK_SECRET_KEY,
    publicKey: process.env.PAYSTACK_PUBLIC_KEY,
    callbackUrl: process.env.PAYSTACK_CALLBACK_URL
  }
};

Dependency Injection

// web/app.module.ts
@Module({
  providers: [
    {
      provide: AbstractPaymentGateway,
      useFactory: () => {
        return new PaystackGateway(
          process.env.PAYSTACK_SECRET_KEY
        );
      }
    }
  ]
})
export class AppModule {}

Payment Entities

Payment Entity

// domain/orders/payment.entity.ts
export class Payment {
  id: string;
  amount: Decimal; // Total amount charged
  orderAmount: Decimal; // Amount after fees
  currency: string;
  userId: string; // Customer
  status: PaymentStatus;
  createdAt: Date;

  static create(
    id: string,
    amount: Decimal,
    orderAmount: Decimal,
    currency: string,
    userId: string,
    status: PaymentStatus
  ): Payment
}

export type PaymentStatus = 'received' | 'refunded' | 'released';

Status transitions: - received – Payment collected from customer - refunded – Payment returned to customer (order rejected/cancelled) - released – Payment transferred to seller (order completed)

Payment Event Entity

// domain/accounts/account_event.entity.ts (payment-related)
export class AccountEvent {
  id: string;
  accountId: string;
  type: EventType;
  amount: Decimal;
  reference: string; // Payment ID or transfer reference
  description: string;
  createdAt: Date;
}

type EventType =
  | 'payment_received'   // Customer paid
  | 'payment_released'   // Seller paid out
  | 'payment_refunded'   // Customer refunded
  | 'commission_earned'  // Platform commission

Business Rules

Payment Collection

  1. Amount validation – Must be positive
  2. Currency validation – Must be supported (NGN, USD, etc.)
  3. Email required – For payment gateway
  4. Metadata captured – orderId, customerId, serviceId
  5. Reference uniqueness – Each transaction has unique reference

Payment Verification

  1. Verify before processing – Always verify with gateway
  2. Idempotency – Don't process same reference twice
  3. Amount matching – Verify amount matches expected
  4. Status check – Only process 'success' transactions
  5. Timeout handling – Mark 'abandoned' after 15 minutes

Payment Release (Seller Payout)

  1. Order must be completed – Only release for completed orders
  2. Commission deduction – Deduct platform commission
  3. Recipient registered – Seller must have bank account
  4. Minimum payout – Enforce minimum withdrawal amount
  5. OTP verification – Some transfers require OTP

Refund Rules

  1. Full refund only – No partial refunds
  2. Timeframe – Refund within 5-7 business days
  3. Original payment method – Refund to source
  4. Status update – Mark Payment as 'refunded'

Use Cases

Payment Processing

  1. Initialize payment – Create payment transaction
  2. Redirect customer – Send to payment authorization URL
  3. Handle callback – Process gateway redirect
  4. Verify payment – Confirm with gateway
  5. Create order – After successful verification
  6. Update payment status – Mark as 'received'

Seller Payout

  1. Order completed – Customer approved delivery
  2. Calculate amount – totalAmount - commission
  3. Check recipient – Seller has registered bank account
  4. Initialize transfer – Create payout transaction
  5. Finalize transfer – Complete with OTP if required
  6. Update payment status – Mark as 'released'
  7. Record account event – Log payout

Refund Processing

  1. Determine refund reason – Rejection or cancellation
  2. Verify eligibility – Check order state
  3. Calculate refund – Full payment amount
  4. Issue refund – Process through gateway
  5. Update payment status – Mark as 'refunded'
  6. Record account event – Log refund

Integration Points

With Orders Domain

  • Order creation requires successful payment
  • Order.paymentId references Payment.id
  • Order completion triggers payment release
  • Order rejection/cancellation triggers refund

With Accounts Domain

  • Payment received → Credit platform account
  • Payment released → Debit platform, credit seller
  • Payment refunded → Debit platform, credit customer
  • Commission → Credit platform commission account

With Users Domain

  • Payment.userId references User.id
  • Seller.bankAccount used for payouts
  • User.email used for payment initialization

Key Workflows

Payment Collection Flow

1. Customer initiates payment
2. App calls paymentGateway.initializeTransaction()
3. Gateway returns authorization URL
4. Customer redirected to Paystack
5. Customer enters payment details
6. Paystack processes payment
7. Gateway redirects back with reference
8. App calls paymentGateway.verifyTransaction()
9. Create Payment entity (status = 'received')
10. Create Order entity

Payout Flow

1. Order reaches 'completed' state
2. Calculate payout: totalAmount - commission
3. Get seller recipient code
4. Call paymentGateway.initializeTransfer()
5. If OTP required: wait for OTP
6. Call paymentGateway.finalizeTransfer()
7. Update Payment status to 'released'
8. Record AccountEvent
9. Credit seller Account balance

Refund Flow

1. Order rejected or cancelled
2. Retrieve Payment entity
3. Verify status is 'received'
4. Call paymentGateway.refund() (or equivalent)
5. Update Payment status to 'refunded'
6. Record AccountEvent
7. Update Account balances

Error Handling

Payment Initialization Errors

try {
  const response = await paymentGateway.initializeTransaction(...);
} catch (error) {
  if (error.code === 'INVALID_AMOUNT') {
    // Amount validation failed
  } else if (error.code === 'GATEWAY_ERROR') {
    // Paystack API error
  } else if (error.code === 'NETWORK_ERROR') {
    // Connection failed
  }
}

Verification Errors

const response = await paymentGateway.verifyTransaction(reference);

if (!response.success) {
  throw new PaymentVerificationFailed(
    `Payment verification failed: ${reference}`
  );
}

if (response.status !== 'success') {
  throw new PaymentNotSuccessful(
    `Payment status: ${response.status}`
  );
}

if (response.amount !== expectedAmount) {
  throw new AmountMismatch(
    `Expected ${expectedAmount}, got ${response.amount}`
  );
}

Transfer Errors

try {
  const response = await paymentGateway.initializeTransfer(...);
} catch (error) {
  if (error.code === 'INSUFFICIENT_BALANCE') {
    // Platform balance too low
  } else if (error.code === 'INVALID_RECIPIENT') {
    // Recipient code invalid
  } else if (error.code === 'TRANSFER_LIMIT_EXCEEDED') {
    // Daily/monthly limit reached
  }
}

Best Practices

✅ Do

  • Always verify payments – Never trust client-side confirmation
  • Use webhooks – Implement payment webhooks for reliability
  • Store metadata – Include orderId, userId in transaction metadata
  • Log all transactions – Maintain audit trail
  • Handle idempotency – Check reference before processing
  • Test with sandbox – Use Paystack test keys in development
  • Secure API keys – Store in environment variables

❌ Don't

  • Don't skip verification – Always verify with gateway
  • Don't expose secret keys – Keep keys server-side only
  • Don't trust callback alone – Verify independently
  • Don't process duplicates – Check reference uniqueness
  • Don't hard-code amounts – Calculate dynamically
  • Don't ignore webhooks – Implement for failed callbacks
  • Don't log sensitive data – Mask card details in logs

Webhook Handling

Payment Webhook

// web/orders/payment.controller.ts
@Post('webhook/paystack')
async handlePaystackWebhook(@Body() payload: any, @Headers('x-paystack-signature') signature: string) {
  // 1. Verify webhook signature
  const isValid = this.verifyWebhookSignature(payload, signature);
  if (!isValid) {
    throw new UnauthorizedException('Invalid signature');
  }

  // 2. Handle event
  const event = payload.event;
  const data = payload.data;

  switch (event) {
    case 'charge.success':
      await this.handlePaymentSuccess(data.reference);
      break;
    case 'transfer.success':
      await this.handleTransferSuccess(data.reference);
      break;
    case 'transfer.failed':
      await this.handleTransferFailed(data.reference);
      break;
  }

  return { success: true };
}

Testing

Mock Payment Gateway

// tests/mocks/payment.gateway.mock.ts
export class MockPaymentGateway extends AbstractPaymentGateway {
  async initializeTransaction(
    email: string,
    amount: Decimal,
    currency: string,
    metadata?: Record<string, any>
  ): Promise<PaymentInitResponse> {
    return {
      success: true,
      authorizationUrl: 'https://mock.gateway.com/pay',
      reference: `mock_ref_${Date.now()}`,
      accessCode: 'mock_access_code'
    };
  }

  async verifyTransaction(reference: string): Promise<PaymentVerifyResponse> {
    return {
      success: true,
      amount: 10000,
      currency: 'NGN',
      status: 'success',
      reference,
      paidAt: new Date()
    };
  }

  // ... other methods
}

Test Cases

describe('Payment Processing', () => {
  it('should initialize payment', async () => {
    const response = await paymentGateway.initializeTransaction(
      'test@example.com',
      new Decimal(10000),
      'NGN'
    );

    expect(response.success).toBe(true);
    expect(response.authorizationUrl).toBeDefined();
    expect(response.reference).toBeDefined();
  });

  it('should verify successful payment', async () => {
    const reference = 'ref_xyz789';
    const response = await paymentGateway.verifyTransaction(reference);

    expect(response.success).toBe(true);
    expect(response.status).toBe('success');
    expect(response.amount).toBeGreaterThan(0);
  });

  it('should not process duplicate payment', async () => {
    const reference = 'ref_xyz789';

    // First processing
    await paymentService.processPayment(reference);

    // Second attempt should fail
    await expect(
      paymentService.processPayment(reference)
    ).rejects.toThrow(PaymentAlreadyProcessed);
  });
});

Summary

The Payments domain provides:

  • Abstract gateway interface for payment processing
  • Paystack implementation for Nigeria/Africa
  • Payment collection via card, bank transfer
  • Seller payouts with transfer management
  • Refund processing for cancellations
  • Webhook handling for async notifications
  • Error handling for gateway failures
  • Testability with mock implementations

This abstraction allows the platform to remain gateway-agnostic while supporting complex payment workflows.