Skip to content
Merged
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
25 changes: 25 additions & 0 deletions backend/src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { Request } from 'express';
import { AuditInterceptor, Audit } from '../audit/audit.interceptor';
import { ReferralAnalyticsService } from '../referrals/referral-analytics.service';
import { FunnelStatsDto, TopReferrersDto, CohortComparisonDto, RewardSpendDto, UserReferralStatsDto } from '../referrals/dto/referral-analytics.dto';
import { OffRampService } from '../offramp/offramp.service';
import { AdminOffRampQueryDto, OffRampResponseDto } from '../offramp/dto/offramp.dto';
import { GeoService } from '../geo/geo.service';

@ApiTags('admin')
Expand All @@ -37,6 +39,7 @@ export class AdminController {
private readonly adminService: AdminService,
private readonly receiptService: ReceiptService,
private readonly referralAnalyticsService: ReferralAnalyticsService,
private readonly offRampService: OffRampService,
private readonly geoService: GeoService,
) {}

Expand Down Expand Up @@ -123,6 +126,28 @@ export class AdminController {
return this.receiptService.generateTransactionReceiptAdmin(id);
}

// ── Off-Ramp Admin ──────────────────────────────────────────────────────────

@Get('offramps')
@Roles(AdminRole.ADMIN, AdminRole.SUPERADMIN)
@ApiOperation({ summary: 'List all off-ramp orders with optional filters' })
async listOffRamps(@Query() query: AdminOffRampQueryDto) {
return this.offRampService.adminList(query);
}

@Get('offramps/:id')
@Roles(AdminRole.ADMIN, AdminRole.SUPERADMIN)
@ApiOperation({ summary: 'Get a single off-ramp order by ID' })
async getOffRamp(@Param('id') id: string): Promise<OffRampResponseDto> {
return this.offRampService.adminGetById(id);
}

@Post('offramps/:id/refund')
@Roles(AdminRole.SUPERADMIN)
@Audit({ action: 'offramp.refund', resourceType: 'off_ramp', resourceIdParam: 'id' })
@ApiOperation({ summary: 'Manually trigger USDC refund for a failed off-ramp order' })
async refundOffRamp(@Param('id') id: string): Promise<OffRampResponseDto> {
return this.offRampService.adminRefund(id);
@Get('geo/stats')
@Roles(AdminRole.ADMIN, AdminRole.SUPERADMIN)
@ApiOperation({
Expand Down
2 changes: 2 additions & 0 deletions backend/src/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CronAdminController } from './cron-admin.controller';
import { AuditModule } from '../audit/audit.module';
import { ReferralsModule } from '../referrals/referrals.module';
import { ReceiptModule } from '../receipt/receipt.module';
import { OffRampModule } from '../offramp/offramp.module';
import { GeoModule } from '../geo/geo.module';

@Module({
Expand All @@ -37,6 +38,7 @@ import { GeoModule } from '../geo/geo.module';
CronModule,
ReceiptModule,
ReferralsModule,
OffRampModule,
GeoModule,
],
providers: [AdminService],
Expand Down
79 changes: 62 additions & 17 deletions backend/src/offramp/dto/offramp.dto.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
IsInt,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { OffRamp, OffRampStatus } from '../entities/off-ramp.entity';

// ── Request DTOs ─────────────────────────────────────────────────────────────

export class PreviewOffRampDto {
@ApiProperty({ description: 'Amount in USDC to convert', example: 10 })
@IsNumber()
Expand Down Expand Up @@ -55,14 +59,53 @@ export class OffRampHistoryQueryDto {
limit?: number;
}

export class AdminOffRampQueryDto {
@ApiPropertyOptional({ description: 'Filter by status', enum: OffRampStatus })
@IsOptional()
@IsEnum(OffRampStatus)
status?: OffRampStatus;

@ApiPropertyOptional({ description: 'Filter by user ID' })
@IsOptional()
@IsUUID()
userId?: string;

@ApiPropertyOptional({ description: 'Start of date range (ISO 8601)', example: '2024-01-01' })
@IsOptional()
@IsDateString()
dateFrom?: string;

@ApiPropertyOptional({ description: 'End of date range (ISO 8601)', example: '2024-12-31' })
@IsOptional()
@IsDateString()
dateTo?: string;

@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }) => parseInt(value))
@IsInt()
@Min(1)
page?: number;

@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Transform(({ value }) => parseInt(value))
@IsInt()
@Min(1)
limit?: number;
}

// ── Response DTOs ────────────────────────────────────────────────────────────

export class OffRampPreviewResponseDto {
@ApiProperty() amountUsdc: number;
@ApiProperty() rate: string;
@ApiProperty() spreadPercent: number;
@ApiProperty() feeUsdc: string;
@ApiProperty() netAmountUsdc: string;
@ApiProperty() ngnAmount: string;
@ApiProperty() bankAccount: {
@ApiProperty()
bankAccount: {
id: string;
bankName: string;
accountNumber: string;
Expand All @@ -71,21 +114,22 @@ export class OffRampPreviewResponseDto {
}

export class OffRampResponseDto {
id: string;
reference: string;
amountUsdc: string;
feeUsdc: string;
netAmountUsdc: string;
rate: string;
spreadPercent: string;
ngnAmount: string;
bankAccountNumber: string;
bankName: string;
accountName: string;
status: OffRampStatus;
providerReference: string | null;
failureReason: string | null;
createdAt: Date;
@ApiProperty() id: string;
@ApiProperty() reference: string;
@ApiProperty() amountUsdc: string;
@ApiProperty() feeUsdc: string;
@ApiProperty() netAmountUsdc: string;
@ApiProperty() rate: string;
@ApiProperty() spreadPercent: string;
@ApiProperty() ngnAmount: string;
@ApiProperty() bankAccountNumber: string;
@ApiProperty() bankName: string;
@ApiProperty() accountName: string;
@ApiProperty({ enum: OffRampStatus }) status: OffRampStatus;
@ApiPropertyOptional() providerReference: string | null;
@ApiPropertyOptional() failureReason: string | null;
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;

static from(o: OffRamp): OffRampResponseDto {
const dto = new OffRampResponseDto();
Expand All @@ -104,6 +148,7 @@ export class OffRampResponseDto {
dto.providerReference = o.providerReference;
dto.failureReason = o.failureReason;
dto.createdAt = o.createdAt;
dto.updatedAt = o.updatedAt;
return dto;
}
}
163 changes: 163 additions & 0 deletions backend/src/offramp/offramp-webhook.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import { OffRampWebhookController } from './offramp-webhook.controller';
import { OffRampService } from './offramp.service';

const FAKE_SECRET = 'test-paystack-secret';

function makeSignature(body: string): string {
return crypto.createHmac('sha512', FAKE_SECRET).update(body).digest('hex');
}

function makeRequest(body: object, secret = FAKE_SECRET) {
const raw = Buffer.from(JSON.stringify(body));
return {
rawBody: raw,
body,
} as any;
}

describe('OffRampWebhookController', () => {
let controller: OffRampWebhookController;
let offRampService: jest.Mocked<OffRampService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [OffRampWebhookController],
providers: [
{
provide: OffRampService,
useValue: {
handlePaystackTransferSuccess: jest.fn().mockResolvedValue(undefined),
handlePaystackTransferFailed: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: ConfigService,
useValue: { get: jest.fn().mockReturnValue(FAKE_SECRET) },
},
],
}).compile();

controller = module.get(OffRampWebhookController);
offRampService = module.get(OffRampService) as jest.Mocked<OffRampService>;
});

// ── signature validation ──────────────────────────────────────────────────

it('rejects request with invalid signature', async () => {
const body = { event: 'transfer.success', data: { reference: 'X', transfer_code: 'Y', reason: 'X' } };
const req = makeRequest(body);

await expect(
controller.handlePaystackWebhook(req, 'bad-signature'),
).rejects.toThrow(BadRequestException);

expect(offRampService.handlePaystackTransferSuccess).not.toHaveBeenCalled();
});

it('rejects request with missing signature', async () => {
const body = { event: 'transfer.success', data: { reference: 'X', transfer_code: 'Y', reason: 'X' } };
const req = makeRequest(body);

await expect(
controller.handlePaystackWebhook(req, undefined as any),
).rejects.toThrow(BadRequestException);
});

// ── transfer.success ──────────────────────────────────────────────────────

it('dispatches transfer.success event to service', async () => {
const body = {
event: 'transfer.success',
data: { transfer_code: 'TRF_OK', reference: 'OFFRAMP-ABC123', reason: 'OFFRAMP-ABC123', id: 1, status: 'success' },
};
const raw = JSON.stringify(body);
const sig = makeSignature(raw);
const req = { rawBody: Buffer.from(raw) } as any;

const result = await controller.handlePaystackWebhook(req, sig);

expect(result).toEqual({ received: true });
expect(offRampService.handlePaystackTransferSuccess).toHaveBeenCalledWith(
'TRF_OK',
'OFFRAMP-ABC123',
);
});

// ── transfer.failed ──────────────────────────────────────────────────────

it('dispatches transfer.failed event to service', async () => {
const body = {
event: 'transfer.failed',
data: { transfer_code: 'TRF_FAIL', reference: 'OFFRAMP-ABC456', reason: 'OFFRAMP-ABC456', id: 2, status: 'failed' },
};
const raw = JSON.stringify(body);
const sig = makeSignature(raw);
const req = { rawBody: Buffer.from(raw) } as any;

const result = await controller.handlePaystackWebhook(req, sig);

expect(result).toEqual({ received: true });
expect(offRampService.handlePaystackTransferFailed).toHaveBeenCalledWith(
'TRF_FAIL',
'OFFRAMP-ABC456',
);
});

// ── transfer.reversed ────────────────────────────────────────────────────

it('treats transfer.reversed as failed', async () => {
const body = {
event: 'transfer.reversed',
data: { transfer_code: 'TRF_REV', reference: 'OFFRAMP-REV', reason: 'OFFRAMP-REV', id: 3, status: 'reversed' },
};
const raw = JSON.stringify(body);
const sig = makeSignature(raw);
const req = { rawBody: Buffer.from(raw) } as any;

await controller.handlePaystackWebhook(req, sig);

expect(offRampService.handlePaystackTransferFailed).toHaveBeenCalledWith(
'TRF_REV',
'OFFRAMP-REV',
);
});

// ── unknown event ────────────────────────────────────────────────────────

it('returns 200 and ignores unhandled event types', async () => {
const body = {
event: 'charge.success',
data: { reference: 'X', transfer_code: '', reason: 'X', id: 4, status: 'success' },
};
const raw = JSON.stringify(body);
const sig = makeSignature(raw);
const req = { rawBody: Buffer.from(raw) } as any;

const result = await controller.handlePaystackWebhook(req, sig);

expect(result).toEqual({ received: true });
expect(offRampService.handlePaystackTransferSuccess).not.toHaveBeenCalled();
expect(offRampService.handlePaystackTransferFailed).not.toHaveBeenCalled();
});

// ── service error resilience ─────────────────────────────────────────────

it('returns 200 even when service throws (prevents Paystack retry loop)', async () => {
offRampService.handlePaystackTransferSuccess.mockRejectedValue(new Error('DB error'));

const body = {
event: 'transfer.success',
data: { transfer_code: 'TRF_X', reference: 'OFFRAMP-X', reason: 'OFFRAMP-X', id: 5, status: 'success' },
};
const raw = JSON.stringify(body);
const sig = makeSignature(raw);
const req = { rawBody: Buffer.from(raw) } as any;

const result = await controller.handlePaystackWebhook(req, sig);
expect(result).toEqual({ received: true });
});
});
Loading
Loading