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
- Amount validation – Must be positive
- Currency validation – Must be supported (NGN, USD, etc.)
- Email required – For payment gateway
- Metadata captured – orderId, customerId, serviceId
- Reference uniqueness – Each transaction has unique reference
Payment Verification
- Verify before processing – Always verify with gateway
- Idempotency – Don't process same reference twice
- Amount matching – Verify amount matches expected
- Status check – Only process 'success' transactions
- Timeout handling – Mark 'abandoned' after 15 minutes
Payment Release (Seller Payout)
- Order must be completed – Only release for completed orders
- Commission deduction – Deduct platform commission
- Recipient registered – Seller must have bank account
- Minimum payout – Enforce minimum withdrawal amount
- OTP verification – Some transfers require OTP
Refund Rules
- Full refund only – No partial refunds
- Timeframe – Refund within 5-7 business days
- Original payment method – Refund to source
- Status update – Mark Payment as 'refunded'
Use Cases
Payment Processing
- Initialize payment – Create payment transaction
- Redirect customer – Send to payment authorization URL
- Handle callback – Process gateway redirect
- Verify payment – Confirm with gateway
- Create order – After successful verification
- Update payment status – Mark as 'received'
Seller Payout
- Order completed – Customer approved delivery
- Calculate amount – totalAmount - commission
- Check recipient – Seller has registered bank account
- Initialize transfer – Create payout transaction
- Finalize transfer – Complete with OTP if required
- Update payment status – Mark as 'released'
- Record account event – Log payout
Refund Processing
- Determine refund reason – Rejection or cancellation
- Verify eligibility – Check order state
- Calculate refund – Full payment amount
- Issue refund – Process through gateway
- Update payment status – Mark as 'refunded'
- 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.