diff --git a/backend/package.json b/backend/package.json index a716e07c..0583a100 100644 --- a/backend/package.json +++ b/backend/package.json @@ -54,6 +54,7 @@ "bullmq": "^5.71.1", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "csv-parser": "^3.2.0", "dotenv": "^17.3.1", "imurmurhash": "^0.1.4", "ioredis": "^5.10.1", @@ -85,6 +86,7 @@ "@types/express": "^5.0.0", "@types/imurmurhash": "^0.1.4", "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", "@types/qrcode": "^1.5.5", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index ac71a6f9..ce156f6d 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: class-validator: specifier: ^0.15.1 version: 0.15.1 + csv-parser: + specifier: ^3.2.0 + version: 3.2.0 dotenv: specifier: ^17.3.1 version: 17.3.1 @@ -174,6 +177,9 @@ importers: '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/multer': + specifier: ^2.1.0 + version: 2.1.0 '@types/node': specifier: ^22.10.7 version: 22.19.15 @@ -1564,6 +1570,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.1.0': + resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==} + '@types/mysql@2.15.27': resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} @@ -2365,6 +2374,11 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csv-parser@3.2.0: + resolution: {integrity: sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==} + engines: {node: '>= 10'} + hasBin: true + dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -6183,6 +6197,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@2.1.0': + dependencies: + '@types/express': 5.0.6 + '@types/mysql@2.15.27': dependencies: '@types/node': 22.19.15 @@ -7029,6 +7047,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csv-parser@3.2.0: {} + dayjs@1.11.20: {} debug@4.4.3: diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index ab05c519..981a0025 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -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'; @ApiTags('admin') @ApiBearerAuth() @@ -36,6 +38,7 @@ export class AdminController { private readonly adminService: AdminService, private readonly receiptService: ReceiptService, private readonly referralAnalyticsService: ReferralAnalyticsService, + private readonly offRampService: OffRampService, ) {} @Get('users') @@ -120,4 +123,28 @@ export class AdminController { async getTransactionReceipt(@Param('id') id: string) { 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 { + 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 { + return this.offRampService.adminRefund(id); + } } diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index 8cb7f584..dc7e93aa 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -16,9 +16,8 @@ import { CronModule } from '../cron/cron.module'; import { CronAdminController } from './cron-admin.controller'; import { AuditModule } from '../audit/audit.module'; import { ReferralsModule } from '../referrals/referrals.module'; - -import { AnalyticsModule } from './analytics/analytics.module'; import { ReceiptModule } from '../receipt/receipt.module'; +import { OffRampModule } from '../offramp/offramp.module'; @Module({ imports: [ @@ -38,6 +37,7 @@ import { ReceiptModule } from '../receipt/receipt.module'; CronModule, ReceiptModule, ReferralsModule, + OffRampModule, ], providers: [AdminService], controllers: [AdminController, CronAdminController], diff --git a/backend/src/database/migrations/1700000000000-BulkDisbursement.ts b/backend/src/database/migrations/1700000000000-BulkDisbursement.ts new file mode 100644 index 00000000..6463ebd3 --- /dev/null +++ b/backend/src/database/migrations/1700000000000-BulkDisbursement.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class BulkDisbursement1700000000000 implements MigrationInterface { + name = 'BulkDisbursement1700000000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "bulk_disbursement_status_enum" AS ENUM('pending', 'processing', 'completed', 'failed'); + `); + await queryRunner.query(` + CREATE TABLE "bulk_disbursements" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "user_id" uuid NOT NULL, + "file_name" character varying(255) NOT NULL, + "reference" character varying(100) NOT NULL, + "total_items" integer NOT NULL DEFAULT 0, + "processed_items" integer NOT NULL DEFAULT 0, + "failed_items" integer NOT NULL DEFAULT 0, + "total_amount_usdc" numeric(24,8) NOT NULL DEFAULT 0, + "status" "bulk_disbursement_status_enum" NOT NULL DEFAULT 'pending', + "failure_reason" text, + CONSTRAINT "UQ_bulk_disbursements_reference" UNIQUE ("reference"), + CONSTRAINT "PK_bulk_disbursements_id" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_bulk_disbursements_user_id" ON "bulk_disbursements" ("user_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_bulk_disbursements_user_id_created_at" ON "bulk_disbursements" ("user_id", "created_at") + `); + + // Modify off_ramps table + await queryRunner.query(` + ALTER TABLE "off_ramps" ALTER COLUMN "bank_account_id" DROP NOT NULL; + `); + await queryRunner.query(` + ALTER TABLE "off_ramps" ADD "bulk_disbursement_id" uuid; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "off_ramps" DROP COLUMN "bulk_disbursement_id"; + `); + await queryRunner.query(` + ALTER TABLE "off_ramps" ALTER COLUMN "bank_account_id" SET NOT NULL; + `); + + await queryRunner.query(`DROP INDEX "IDX_bulk_disbursements_user_id_created_at"`); + await queryRunner.query(`DROP INDEX "IDX_bulk_disbursements_user_id"`); + await queryRunner.query(`DROP TABLE "bulk_disbursements"`); + await queryRunner.query(`DROP TYPE "bulk_disbursement_status_enum"`); + } +} diff --git a/backend/src/offramp/bulk-disbursement.processor.ts b/backend/src/offramp/bulk-disbursement.processor.ts new file mode 100644 index 00000000..50b537d0 --- /dev/null +++ b/backend/src/offramp/bulk-disbursement.processor.ts @@ -0,0 +1,111 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Job } from 'bullmq'; +import { Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as fs from 'fs'; +import csv = require('csv-parser'); +import { BulkDisbursement, BulkDisbursementStatus } from './entities/bulk-disbursement.entity'; +import { OffRampService } from './offramp.service'; + +@Processor('offramp-jobs') +export class BulkDisbursementProcessor { + private readonly logger = new Logger(BulkDisbursementProcessor.name); + + constructor( + @InjectRepository(BulkDisbursement) + private readonly bulkDisbursementRepo: Repository, + private readonly offRampService: OffRampService, + ) {} + + @Process('bulk-disbursement') + async handleBulkDisbursement(job: Job<{ bulkDisbursementId: string; filePath: string; userId: string }>) { + const { bulkDisbursementId, filePath, userId } = job.data; + + this.logger.log(`Starting processing for bulk disbursement: ${bulkDisbursementId}`); + + const bulkRecord = await this.bulkDisbursementRepo.findOne({ where: { id: bulkDisbursementId } }); + if (!bulkRecord) { + this.logger.error(`BulkDisbursement ${bulkDisbursementId} not found`); + return; + } + + await this.bulkDisbursementRepo.update(bulkDisbursementId, { status: BulkDisbursementStatus.PROCESSING }); + + const rows: any[] = []; + + try { + await new Promise((resolve, reject) => { + fs.createReadStream(filePath) + .pipe(csv()) + .on('data', (data) => rows.push(data)) + .on('end', () => resolve()) + .on('error', (err) => reject(err)); + }); + } catch (err: any) { + await this.bulkDisbursementRepo.update(bulkDisbursementId, { + status: BulkDisbursementStatus.FAILED, + failureReason: `Failed to parse CSV: ${err.message}`, + }); + return; + } + + await this.bulkDisbursementRepo.update(bulkDisbursementId, { totalItems: rows.length }); + + let processed = 0; + let failed = 0; + let totalUsdc = 0; + + for (const row of rows) { + try { + const amountUsdc = parseFloat(row.amountUsdc || row.amount); + const bankCode = row.bankCode; + const accountNumber = row.accountNumber; + const accountName = row.accountName || 'Unknown Configured'; + + if (isNaN(amountUsdc) || !bankCode || !accountNumber) { + throw new Error('Invalid row format (missing amount, bankCode or accountNumber)'); + } + + await this.offRampService.executeBulkItem(userId, { + amountUsdc, + bankCode, + accountNumber, + accountName, + bulkDisbursementId, + }); + + processed++; + totalUsdc += amountUsdc; + } catch (err: any) { + this.logger.error(`Row offramp failed: ${err.message}`); + failed++; + } + + // Periodically update progression + if ((processed + failed) % 10 === 0) { + await this.bulkDisbursementRepo.update(bulkDisbursementId, { + processedItems: processed, + failedItems: failed, + totalAmountUsdc: totalUsdc.toFixed(8) + }); + } + } + + await this.bulkDisbursementRepo.update(bulkDisbursementId, { + processedItems: processed, + failedItems: failed, + totalAmountUsdc: totalUsdc.toFixed(8), + status: failed === rows.length ? BulkDisbursementStatus.FAILED : BulkDisbursementStatus.COMPLETED, + }); + + // Cleanup CSV + try { + await fs.promises.unlink(filePath); + } catch (e) { + this.logger.warn(`Failed to unlink temp file ${filePath}`); + } + + this.logger.log(`Finished bulk disbursement ${bulkDisbursementId}: ${processed} success, ${failed} failed`); + } +} diff --git a/backend/src/offramp/dto/bulk-disbursement.dto.ts b/backend/src/offramp/dto/bulk-disbursement.dto.ts new file mode 100644 index 00000000..dd317dd8 --- /dev/null +++ b/backend/src/offramp/dto/bulk-disbursement.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { BulkDisbursement, BulkDisbursementStatus } from '../entities/bulk-disbursement.entity'; + +export class BulkDisbursementResponseDto { + @ApiProperty() id!: string; + @ApiProperty() reference!: string; + @ApiProperty() fileName!: string; + @ApiProperty() totalItems!: number; + @ApiProperty() processedItems!: number; + @ApiProperty() failedItems!: number; + @ApiProperty() totalAmountUsdc!: string; + @ApiProperty({ enum: BulkDisbursementStatus }) status!: BulkDisbursementStatus; + @ApiPropertyOptional() failureReason!: string | null; + @ApiProperty() createdAt!: Date; + @ApiProperty() updatedAt!: Date; + + static from(o: BulkDisbursement): BulkDisbursementResponseDto { + const dto = new BulkDisbursementResponseDto(); + dto.id = o.id; + dto.reference = o.reference; + dto.fileName = o.fileName; + dto.totalItems = o.totalItems; + dto.processedItems = o.processedItems; + dto.failedItems = o.failedItems; + dto.totalAmountUsdc = o.totalAmountUsdc; + dto.status = o.status; + dto.failureReason = o.failureReason; + dto.createdAt = o.createdAt; + dto.updatedAt = o.updatedAt; + return dto; + } +} diff --git a/backend/src/offramp/dto/offramp.dto.ts b/backend/src/offramp/dto/offramp.dto.ts index 77eb60b8..9b8ff768 100644 --- a/backend/src/offramp/dto/offramp.dto.ts +++ b/backend/src/offramp/dto/offramp.dto.ts @@ -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() @@ -55,6 +59,44 @@ 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; @@ -62,7 +104,8 @@ export class OffRampPreviewResponseDto { @ApiProperty() feeUsdc: string; @ApiProperty() netAmountUsdc: string; @ApiProperty() ngnAmount: string; - @ApiProperty() bankAccount: { + @ApiProperty() + bankAccount: { id: string; bankName: string; accountNumber: string; @@ -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(); @@ -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; } } diff --git a/backend/src/offramp/entities/bulk-disbursement.entity.ts b/backend/src/offramp/entities/bulk-disbursement.entity.ts new file mode 100644 index 00000000..03854181 --- /dev/null +++ b/backend/src/offramp/entities/bulk-disbursement.entity.ts @@ -0,0 +1,41 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '../../common/entities/base.entity'; + +export enum BulkDisbursementStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', +} + +@Entity('bulk_disbursements') +@Index(['userId', 'createdAt']) +export class BulkDisbursement extends BaseEntity { + @Index() + @Column({ name: 'user_id' }) + userId!: string; + + @Column({ name: 'file_name', length: 255 }) + fileName!: string; + + @Column({ name: 'reference', length: 100, unique: true }) + reference!: string; + + @Column({ name: 'total_items', type: 'int', default: 0 }) + totalItems!: number; + + @Column({ name: 'processed_items', type: 'int', default: 0 }) + processedItems!: number; + + @Column({ name: 'failed_items', type: 'int', default: 0 }) + failedItems!: number; + + @Column({ name: 'total_amount_usdc', type: 'numeric', precision: 24, scale: 8, default: 0 }) + totalAmountUsdc!: string; + + @Column({ name: 'status', type: 'enum', enum: BulkDisbursementStatus, default: BulkDisbursementStatus.PENDING }) + status!: BulkDisbursementStatus; + + @Column({ name: 'failure_reason', type: 'text', nullable: true }) + failureReason!: string | null; +} diff --git a/backend/src/offramp/entities/off-ramp.entity.ts b/backend/src/offramp/entities/off-ramp.entity.ts index 48013d01..a5326c53 100644 --- a/backend/src/offramp/entities/off-ramp.entity.ts +++ b/backend/src/offramp/entities/off-ramp.entity.ts @@ -41,8 +41,11 @@ export class OffRamp extends BaseEntity { @Column({ name: 'ngn_amount', type: 'numeric', precision: 24, scale: 2 }) ngnAmount!: string; - @Column({ name: 'bank_account_id' }) - bankAccountId!: string; + @Column({ name: 'bank_account_id', nullable: true }) + bankAccountId!: string | null; + + @Column({ name: 'bulk_disbursement_id', nullable: true }) + bulkDisbursementId!: string | null; @Column({ name: 'bank_account_number', length: 20 }) bankAccountNumber!: string; diff --git a/backend/src/offramp/offramp-webhook.controller.spec.ts b/backend/src/offramp/offramp-webhook.controller.spec.ts new file mode 100644 index 00000000..e77b1d5f --- /dev/null +++ b/backend/src/offramp/offramp-webhook.controller.spec.ts @@ -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; + + 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; + }); + + // ── 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 }); + }); +}); diff --git a/backend/src/offramp/offramp-webhook.controller.ts b/backend/src/offramp/offramp-webhook.controller.ts new file mode 100644 index 00000000..225798d8 --- /dev/null +++ b/backend/src/offramp/offramp-webhook.controller.ts @@ -0,0 +1,94 @@ +import { + BadRequestException, + Controller, + Headers, + HttpCode, + Logger, + Post, + RawBodyRequest, + Req, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import * as crypto from 'crypto'; +import { OffRampService } from './offramp.service'; +import { Public } from '../auth/decorators/public.decorator'; + +interface PaystackTransferEvent { + event: string; + data: { + id: number; + transfer_code: string; + reference: string; + reason: string; + status: string; + }; +} + +@ApiTags('Off-Ramp Webhooks') +@Controller('offramp/webhooks') +export class OffRampWebhookController { + private readonly logger = new Logger(OffRampWebhookController.name); + + constructor( + private readonly offRampService: OffRampService, + private readonly configService: ConfigService, + ) {} + + @Post('paystack') + @Public() + @HttpCode(200) + @ApiOperation({ summary: 'Receive Paystack transfer webhook events' }) + async handlePaystackWebhook( + @Req() req: RawBodyRequest, + @Headers('x-paystack-signature') signature: string, + ): Promise<{ received: boolean }> { + // 1. Verify signature + const secret = this.configService.get('PAYSTACK_SECRET_KEY'); + if (!secret) { + this.logger.error('PAYSTACK_SECRET_KEY not configured — rejecting webhook'); + throw new BadRequestException('Webhook not configured'); + } + + const rawBody = req.rawBody ?? Buffer.from(''); + const expected = crypto + .createHmac('sha512', secret) + .update(rawBody) + .digest('hex'); + + if (!signature || signature !== expected) { + this.logger.warn('Paystack webhook: invalid signature'); + throw new BadRequestException('Invalid webhook signature'); + } + + // 2. Parse and dispatch + let payload: PaystackTransferEvent; + try { + payload = JSON.parse(rawBody.toString()) as PaystackTransferEvent; + } catch { + throw new BadRequestException('Invalid JSON payload'); + } + + const { event, data } = payload; + const reference = data?.reference ?? data?.reason ?? ''; + const transferCode = data?.transfer_code ?? ''; + + this.logger.log(`Paystack webhook received: ${event} for ref=${reference}`); + + try { + if (event === 'transfer.success') { + await this.offRampService.handlePaystackTransferSuccess(transferCode, reference); + } else if (event === 'transfer.failed' || event === 'transfer.reversed') { + await this.offRampService.handlePaystackTransferFailed(transferCode, reference); + } else { + this.logger.debug(`Paystack webhook: unhandled event type '${event}' — ignoring`); + } + } catch (err: any) { + // Log but return 200 so Paystack won't retry endlessly + this.logger.error(`Error processing Paystack webhook ${event}: ${err.message}`); + } + + return { received: true }; + } +} diff --git a/backend/src/offramp/offramp.controller.ts b/backend/src/offramp/offramp.controller.ts index 106423e6..112f414b 100644 --- a/backend/src/offramp/offramp.controller.ts +++ b/backend/src/offramp/offramp.controller.ts @@ -7,8 +7,11 @@ import { Query, Req, UseGuards, + UseInterceptors, + UploadedFile } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiTags, ApiConsumes, ApiBody } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OffRampService } from './offramp.service'; import { @@ -18,6 +21,7 @@ import { OffRampResponseDto, PreviewOffRampDto, } from './dto/offramp.dto'; +import { BulkDisbursementResponseDto } from './dto/bulk-disbursement.dto'; @ApiTags('Off-Ramp') @UseGuards(JwtAuthGuard) @@ -60,4 +64,36 @@ export class OffRampController { ): Promise { return this.offRampService.getStatus(req.user.id, referenceId); } + + @Post('bulk/csv') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + required: ['file'], + }, + }) + @ApiOperation({ summary: 'Upload a CSV for bulk disbursements' }) + uploadBulk( + @UploadedFile() file: Express.Multer.File, + @Req() req: any, + ): Promise { + return this.offRampService.uploadBulkDisbursement(req.user.id, file); + } + + @Get('bulk/:id') + @ApiOperation({ summary: 'Get bulk disbursement aggregate status' }) + getBulkStatus( + @Param('id') id: string, + @Req() req: any, + ): Promise { + return this.offRampService.getBulkDisbursementStatus(req.user.id, id); + } } diff --git a/backend/src/offramp/offramp.module.ts b/backend/src/offramp/offramp.module.ts index 37eac6d5..fc8872fc 100644 --- a/backend/src/offramp/offramp.module.ts +++ b/backend/src/offramp/offramp.module.ts @@ -1,9 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bull'; import { ConfigModule } from '@nestjs/config'; import { OffRamp } from './entities/off-ramp.entity'; import { OffRampService } from './offramp.service'; import { OffRampController } from './offramp.controller'; +import { OffRampWebhookController } from './offramp-webhook.controller'; +import { OffRampProcessor } from './offramp.processor'; +import { BulkDisbursementProcessor } from './bulk-disbursement.processor'; +import { OffRampScheduler } from './offramp.scheduler'; import { BankAccount } from '../bank-accounts/entities/bank-account.entity'; import { User } from '../users/entities/user.entity'; import { TierConfig } from '../tier-config/entities/tier-config.entity'; @@ -12,17 +17,21 @@ import { Transaction } from '../transactions/entities/transaction.entity'; import { RatesModule } from '../rates/rates.module'; import { SorobanModule } from '../soroban/soroban.module'; import { PinModule } from '../pin/pin.module'; +import { FlutterwaveModule } from '../flutterwave/flutterwave.module'; +import { BulkDisbursement } from './entities/bulk-disbursement.entity'; @Module({ imports: [ ConfigModule, - TypeOrmModule.forFeature([OffRamp, BankAccount, User, TierConfig, FeeConfig, Transaction]), + TypeOrmModule.forFeature([OffRamp, BankAccount, User, TierConfig, FeeConfig, Transaction, BulkDisbursement]), + BullModule.registerQueue({ name: 'offramp-jobs' }), RatesModule, SorobanModule, PinModule, + FlutterwaveModule, ], - providers: [OffRampService], - controllers: [OffRampController], + providers: [OffRampService, OffRampProcessor, BulkDisbursementProcessor, OffRampScheduler], + controllers: [OffRampController, OffRampWebhookController], exports: [OffRampService], }) export class OffRampModule {} diff --git a/backend/src/offramp/offramp.processor.ts b/backend/src/offramp/offramp.processor.ts new file mode 100644 index 00000000..de806c81 --- /dev/null +++ b/backend/src/offramp/offramp.processor.ts @@ -0,0 +1,24 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bull'; +import { OffRampService } from './offramp.service'; + +export const RECONCILE_JOB = 'reconcile-offramp'; + +@Processor('offramp-jobs') +export class OffRampProcessor { + private readonly logger = new Logger(OffRampProcessor.name); + + constructor(private readonly offRampService: OffRampService) {} + + @Process(RECONCILE_JOB) + async handleReconcile(job: Job): Promise { + this.logger.log(`Processing job ${job.id}: ${RECONCILE_JOB}`); + try { + await this.offRampService.reconcileStaleOrders(); + } catch (err: any) { + this.logger.error(`Reconciliation job ${job.id} failed: ${err.message}`); + throw err; // rethrow so BullMQ marks it failed and retries + } + } +} diff --git a/backend/src/offramp/offramp.scheduler.ts b/backend/src/offramp/offramp.scheduler.ts new file mode 100644 index 00000000..ecbccbe3 --- /dev/null +++ b/backend/src/offramp/offramp.scheduler.ts @@ -0,0 +1,30 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Queue } from 'bull'; +import { RECONCILE_JOB } from './offramp.processor'; + +@Injectable() +export class OffRampScheduler { + private readonly logger = new Logger(OffRampScheduler.name); + + constructor( + @InjectQueue('offramp-jobs') + private readonly offrampQueue: Queue, + ) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + async scheduleReconciliation(): Promise { + this.logger.log('Enqueuing off-ramp reconciliation job'); + await this.offrampQueue.add( + RECONCILE_JOB, + {}, + { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + removeOnComplete: true, + removeOnFail: { count: 100 }, + }, + ); + } +} diff --git a/backend/src/offramp/offramp.service.spec.ts b/backend/src/offramp/offramp.service.spec.ts index 771b5fac..9d18a5b0 100644 --- a/backend/src/offramp/offramp.service.spec.ts +++ b/backend/src/offramp/offramp.service.spec.ts @@ -1,17 +1,20 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { OffRampService, SPREAD_PERCENT, RATE_LOCK_THRESHOLD } from './offramp.service'; -import { OffRamp, OffRampStatus } from './entities/off-ramp.entity'; +import { OffRamp, OffRampProvider, OffRampStatus } from './entities/off-ramp.entity'; import { BankAccount } from '../bank-accounts/entities/bank-account.entity'; import { User } from '../users/entities/user.entity'; import { TierConfig, TierName } from '../tier-config/entities/tier-config.entity'; import { FeeConfig, FeeType } from '../fee-config/entities/fee-config.entity'; -import { Transaction } from '../transactions/entities/transaction.entity'; +import { Transaction, TransactionStatus } from '../transactions/entities/transaction.entity'; import { RatesService } from '../rates/rates.service'; import { SorobanService } from '../soroban/soroban.service'; import { PinService } from '../pin/pin.service'; +import { FlutterwaveService } from '../flutterwave/flutterwave.service'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── const mockUser = (): User => ({ @@ -49,7 +52,7 @@ const mockTierConfig = (): TierConfig => isActive: true, }) as TierConfig; -const mockOffRamp = (): OffRamp => +const mockOffRamp = (overrides: Partial = {}): OffRamp => ({ id: 'offramp-uuid', userId: 'user-uuid', @@ -65,14 +68,18 @@ const mockOffRamp = (): OffRamp => accountName: 'Alice Doe', reference: 'OFFRAMP-ABC123', providerReference: null, + provider: OffRampProvider.PAYSTACK, status: OffRampStatus.PENDING, failureReason: null, transactionId: null, metadata: {}, createdAt: new Date(), updatedAt: new Date(), + ...overrides, }) as OffRamp; +// ── Test Suite ──────────────────────────────────────────────────────────────── + describe('OffRampService', () => { let service: OffRampService; let offRampRepo: any; @@ -84,21 +91,42 @@ describe('OffRampService', () => { let ratesService: jest.Mocked; let sorobanService: jest.Mocked; let pinService: jest.Mocked; + let flutterwaveService: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ OffRampService, - { provide: getRepositoryToken(OffRamp), useValue: { create: jest.fn(), save: jest.fn(), update: jest.fn(), findOne: jest.fn(), findAndCount: jest.fn() } }, + { + provide: getRepositoryToken(OffRamp), + useValue: { + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, { provide: getRepositoryToken(BankAccount), useValue: { findOne: jest.fn() } }, { provide: getRepositoryToken(User), useValue: { findOne: jest.fn() } }, { provide: getRepositoryToken(TierConfig), useValue: { findOne: jest.fn() } }, { provide: getRepositoryToken(FeeConfig), useValue: { findOne: jest.fn() } }, - { provide: getRepositoryToken(Transaction), useValue: { create: jest.fn(), save: jest.fn(), update: jest.fn() } }, + { + provide: getRepositoryToken(Transaction), + useValue: { create: jest.fn(), save: jest.fn(), update: jest.fn() }, + }, { provide: RatesService, useValue: { getRate: jest.fn() } }, { provide: SorobanService, useValue: { withdraw: jest.fn(), deposit: jest.fn() } }, { provide: PinService, useValue: { verifyPin: jest.fn() } }, - { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('test-paystack-key') } }, + { + provide: FlutterwaveService, + useValue: { initiateTransfer: jest.fn(), verifyTransfer: jest.fn() }, + }, + { + provide: ConfigService, + useValue: { get: jest.fn().mockReturnValue('test-paystack-key') }, + }, ], }).compile(); @@ -112,6 +140,7 @@ describe('OffRampService', () => { ratesService = module.get(RatesService); sorobanService = module.get(SorobanService); pinService = module.get(PinService); + flutterwaveService = module.get(FlutterwaveService); }); // ── preview ───────────────────────────────────────────────────────────────── @@ -148,45 +177,67 @@ describe('OffRampService', () => { }); }); - // ── rate lock ──────────────────────────────────────────────────────────────── + // ── checkRateLock ───────────────────────────────────────────────────────── describe('checkRateLock', () => { it('throws when rate changed more than 2%', () => { - const previewRate = 1600; - const currentRate = 1640; // 2.5% change - expect(() => service.checkRateLock(previewRate, currentRate)).toThrow( + expect(() => service.checkRateLock(1600, 1640)).toThrow( 'Rate has changed significantly. Please preview again.', ); }); it('does not throw when rate changed less than 2%', () => { - const previewRate = 1600; - const currentRate = 1615; // ~0.9% change - expect(() => service.checkRateLock(previewRate, currentRate)).not.toThrow(); + expect(() => service.checkRateLock(1600, 1615)).not.toThrow(); }); it('does not throw at exactly 2% change', () => { - const previewRate = 1600; - const currentRate = 1632; // exactly 2% - expect(() => service.checkRateLock(previewRate, currentRate)).not.toThrow(); + expect(() => service.checkRateLock(1600, 1632)).not.toThrow(); + }); + }); + + // ── computeFee ──────────────────────────────────────────────────────────── + + describe('computeFee', () => { + it('computes fee correctly with base rate', () => { + const { feeUsdc, netAmountUsdc } = service.computeFee(100, mockFeeConfig()); + expect(feeUsdc).toBe(1); // 1% of 100 + expect(netAmountUsdc).toBe(99); + }); + + it('applies minimum fee', () => { + const { feeUsdc } = service.computeFee(10, mockFeeConfig()); // 1% = 0.1, min = 0.5 + expect(feeUsdc).toBe(0.5); + }); + + it('applies maximum fee cap', () => { + const { feeUsdc } = service.computeFee(1000, mockFeeConfig()); // 1% = 10, max = 5 + expect(feeUsdc).toBe(5); + }); + + it('returns zero fee when no config', () => { + const { feeUsdc, netAmountUsdc } = service.computeFee(100, null); + expect(feeUsdc).toBe(0); + expect(netAmountUsdc).toBe(100); }); }); - // ── execute ────────────────────────────────────────────────────────────────── + // ── execute ─────────────────────────────────────────────────────────────── describe('execute', () => { - const setupMocks = () => { + const setupExecuteMocks = () => { pinService.verifyPin.mockResolvedValue(undefined); userRepo.findOne.mockResolvedValue(mockUser()); bankAccountRepo.findOne.mockResolvedValue(mockBankAccount()); tierConfigRepo.findOne.mockResolvedValue(mockTierConfig()); ratesService.getRate.mockResolvedValue({ rate: '1600', fetchedAt: new Date(), source: 'bybit', isStale: false }); feeConfigRepo.findOne.mockResolvedValue(mockFeeConfig()); - const offRamp = mockOffRamp(); - offRampRepo.create.mockReturnValue(offRamp); - offRampRepo.save.mockResolvedValue(offRamp); + + const pending = mockOffRamp(); + const initiated = mockOffRamp({ status: OffRampStatus.TRANSFER_INITIATED, providerReference: 'TRF_123' }); + offRampRepo.create.mockReturnValue(pending); + offRampRepo.save.mockResolvedValue(pending); offRampRepo.update.mockResolvedValue(undefined); - offRampRepo.findOne.mockResolvedValue({ ...offRamp, status: OffRampStatus.TRANSFER_INITIATED, providerReference: 'TRF_123' }); + offRampRepo.findOne.mockResolvedValue(initiated); // final findOne returns initiated state sorobanService.withdraw.mockResolvedValue(undefined); const tx = { id: 'tx-uuid' }; transactionRepo.create.mockReturnValue(tx); @@ -203,19 +254,57 @@ describe('OffRampService', () => { expect(pinService.verifyPin).toHaveBeenCalledWith('user-uuid', '0000'); }); - it('deducts USDC before initiating NGN transfer', async () => { - setupMocks(); + it('deducts USDC and creates transfer via Paystack on happy path', async () => { + setupExecuteMocks(); global.fetch = jest.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { recipient_code: 'RCP_123' } }) }) .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { transfer_code: 'TRF_123' } }) }); - await service.execute('user-uuid', { amountUsdc: 10, bankAccountId: 'bank-uuid', pin: '1234', previewRate: '1600' }); + const result = await service.execute('user-uuid', { + amountUsdc: 10, bankAccountId: 'bank-uuid', pin: '1234', previewRate: '1600', + }); expect(sorobanService.withdraw).toHaveBeenCalledWith('alice', '10.00000000'); expect(global.fetch).toHaveBeenCalledTimes(2); + expect(result.status).toBe(OffRampStatus.TRANSFER_INITIATED); + }); + + it('falls back to Flutterwave when Paystack fails', async () => { + setupExecuteMocks(); + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503 }); // Paystack fails + flutterwaveService.initiateTransfer.mockResolvedValue({ id: 99999, status: 'NEW' }); + + await service.execute('user-uuid', { + amountUsdc: 10, bankAccountId: 'bank-uuid', pin: '1234', previewRate: '1600', + }); + + expect(flutterwaveService.initiateTransfer).toHaveBeenCalledWith( + expect.objectContaining({ reference: expect.any(String) }), + ); + expect(offRampRepo.update).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ provider: OffRampProvider.FLUTTERWAVE }), + ); }); - it('throws when bank account not found', async () => { + it('refunds USDC when both providers fail', async () => { + setupExecuteMocks(); + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503 }); + flutterwaveService.initiateTransfer.mockRejectedValue(new Error('FLW unavailable')); + sorobanService.deposit.mockResolvedValue(undefined); + + await expect( + service.execute('user-uuid', { amountUsdc: 10, bankAccountId: 'bank-uuid', pin: '1234', previewRate: '1600' }), + ).rejects.toThrow('NGN transfer failed'); + + expect(sorobanService.deposit).toHaveBeenCalledWith('alice', '10.00000000'); + expect(offRampRepo.update).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ status: OffRampStatus.REFUNDED }), + ); + }); + + it('throws NotFoundException when bank account not found', async () => { pinService.verifyPin.mockResolvedValue(undefined); userRepo.findOne.mockResolvedValue(mockUser()); bankAccountRepo.findOne.mockResolvedValue(null); @@ -244,12 +333,11 @@ describe('OffRampService', () => { ).rejects.toThrow('Amount exceeds your tier limit'); }); - it('throws 400 when rate changed more than 2% between preview and execute', async () => { + it('throws when rate moved more than 2% between preview and execute', async () => { pinService.verifyPin.mockResolvedValue(undefined); userRepo.findOne.mockResolvedValue(mockUser()); bankAccountRepo.findOne.mockResolvedValue(mockBankAccount()); tierConfigRepo.findOne.mockResolvedValue(mockTierConfig()); - // Current rate is 1641 — 2.56% above preview rate of 1600 ratesService.getRate.mockResolvedValue({ rate: '1641', fetchedAt: new Date(), source: 'bybit', isStale: false }); feeConfigRepo.findOne.mockResolvedValue(mockFeeConfig()); @@ -258,23 +346,7 @@ describe('OffRampService', () => { ).rejects.toThrow('Rate has changed significantly. Please preview again.'); }); - it('refunds USDC when Paystack transfer fails', async () => { - setupMocks(); - global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 }); - sorobanService.deposit.mockResolvedValue(undefined); - - await expect( - service.execute('user-uuid', { amountUsdc: 10, bankAccountId: 'bank-uuid', pin: '1234', previewRate: '1600' }), - ).rejects.toThrow('NGN transfer failed'); - - expect(sorobanService.deposit).toHaveBeenCalledWith('alice', '10.00000000'); - expect(offRampRepo.update).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ status: OffRampStatus.REFUNDED }), - ); - }); - - it('creates failed Settlement when USDC deduction fails', async () => { + it('marks off-ramp failed when USDC deduction fails', async () => { pinService.verifyPin.mockResolvedValue(undefined); userRepo.findOne.mockResolvedValue(mockUser()); bankAccountRepo.findOne.mockResolvedValue(mockBankAccount()); @@ -298,49 +370,37 @@ describe('OffRampService', () => { }); }); - // ── computeFee ─────────────────────────────────────────────────────────────── - - describe('computeFee', () => { - it('computes fee correctly with base rate', () => { - const { feeUsdc, netAmountUsdc } = service.computeFee(100, mockFeeConfig()); - expect(feeUsdc).toBe(1); // 1% of 100 - expect(netAmountUsdc).toBe(99); - }); - - it('applies minimum fee', () => { - const { feeUsdc } = service.computeFee(10, mockFeeConfig()); // 1% = 0.1, min = 0.5 - expect(feeUsdc).toBe(0.5); - }); - - it('applies maximum fee cap', () => { - const { feeUsdc } = service.computeFee(1000, mockFeeConfig()); // 1% = 10, max = 5 - expect(feeUsdc).toBe(5); - }); - - it('returns zero fee when no config', () => { - const { feeUsdc, netAmountUsdc } = service.computeFee(100, null); - expect(feeUsdc).toBe(0); - expect(netAmountUsdc).toBe(100); - }); - }); - - // ── getStatus ──────────────────────────────────────────────────────────────── + // ── getStatus ───────────────────────────────────────────────────────────── describe('getStatus', () => { - it('returns off-ramp status', async () => { - offRampRepo.findOne.mockResolvedValue(mockOffRamp()); - + it('returns current status without polling when not transfer_initiated', async () => { + offRampRepo.findOne.mockResolvedValue(mockOffRamp({ status: OffRampStatus.COMPLETED })); const result = await service.getStatus('user-uuid', 'OFFRAMP-ABC123'); - expect(result.reference).toBe('OFFRAMP-ABC123'); + expect(result.status).toBe(OffRampStatus.COMPLETED); }); it('throws NotFoundException for unknown reference', async () => { offRampRepo.findOne.mockResolvedValue(null); await expect(service.getStatus('user-uuid', 'UNKNOWN')).rejects.toThrow(NotFoundException); }); + + it('polls Paystack and updates status to completed on success', async () => { + offRampRepo.findOne.mockResolvedValue( + mockOffRamp({ status: OffRampStatus.TRANSFER_INITIATED, providerReference: 'TRF_123', provider: OffRampProvider.PAYSTACK }), + ); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: { status: 'success' } }), + }); + offRampRepo.update.mockResolvedValue(undefined); + transactionRepo.update.mockResolvedValue(undefined); + + const result = await service.getStatus('user-uuid', 'OFFRAMP-ABC123'); + expect(result.status).toBe(OffRampStatus.COMPLETED); + }); }); - // ── getHistory ─────────────────────────────────────────────────────────────── + // ── getHistory ──────────────────────────────────────────────────────────── describe('getHistory', () => { it('returns paginated off-ramp history', async () => { @@ -353,4 +413,128 @@ describe('OffRampService', () => { expect(result.limit).toBe(20); }); }); + + // ── adminList ───────────────────────────────────────────────────────────── + + describe('adminList', () => { + it('returns paginated results for admin with no filters', async () => { + offRampRepo.findAndCount.mockResolvedValue([[mockOffRamp(), mockOffRamp()], 2]); + const result = await service.adminList({ page: 1, limit: 20 }); + expect(result.total).toBe(2); + expect(result.data).toHaveLength(2); + }); + + it('filters by status', async () => { + offRampRepo.findAndCount.mockResolvedValue([[mockOffRamp({ status: OffRampStatus.FAILED })], 1]); + const result = await service.adminList({ status: OffRampStatus.FAILED }); + expect(result.data[0].status).toBe(OffRampStatus.FAILED); + expect(offRampRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ status: OffRampStatus.FAILED }) }), + ); + }); + }); + + // ── adminGetById ────────────────────────────────────────────────────────── + + describe('adminGetById', () => { + it('returns the off-ramp record', async () => { + offRampRepo.findOne.mockResolvedValue(mockOffRamp()); + const result = await service.adminGetById('offramp-uuid'); + expect(result.id).toBe('offramp-uuid'); + }); + + it('throws NotFoundException when not found', async () => { + offRampRepo.findOne.mockResolvedValue(null); + await expect(service.adminGetById('bad-uuid')).rejects.toThrow(NotFoundException); + }); + }); + + // ── adminRefund ─────────────────────────────────────────────────────────── + + describe('adminRefund', () => { + it('issues USDC refund for a failed off-ramp', async () => { + offRampRepo.findOne + .mockResolvedValueOnce(mockOffRamp({ status: OffRampStatus.FAILED })) + .mockResolvedValueOnce(mockOffRamp({ status: OffRampStatus.REFUNDED })); + userRepo.findOne.mockResolvedValue(mockUser()); + sorobanService.deposit.mockResolvedValue(undefined); + offRampRepo.update.mockResolvedValue(undefined); + + const result = await service.adminRefund('offramp-uuid'); + expect(sorobanService.deposit).toHaveBeenCalledWith('alice', '10.00000000'); + expect(result.status).toBe(OffRampStatus.REFUNDED); + }); + + it('throws ForbiddenException for a completed off-ramp', async () => { + offRampRepo.findOne.mockResolvedValue(mockOffRamp({ status: OffRampStatus.COMPLETED })); + + await expect(service.adminRefund('offramp-uuid')).rejects.toThrow(ForbiddenException); + }); + + it('throws NotFoundException for unknown off-ramp id', async () => { + offRampRepo.findOne.mockResolvedValue(null); + await expect(service.adminRefund('bad-uuid')).rejects.toThrow(NotFoundException); + }); + }); + + // ── webhook handlers ────────────────────────────────────────────────────── + + describe('handlePaystackTransferSuccess', () => { + it('marks off-ramp completed and updates transaction', async () => { + offRampRepo.findOne.mockResolvedValue( + mockOffRamp({ status: OffRampStatus.TRANSFER_INITIATED, transactionId: 'tx-uuid' }), + ); + offRampRepo.update.mockResolvedValue(undefined); + transactionRepo.update.mockResolvedValue(undefined); + + await service.handlePaystackTransferSuccess('TRF_123', 'OFFRAMP-ABC123'); + + expect(offRampRepo.update).toHaveBeenCalledWith( + 'offramp-uuid', + expect.objectContaining({ status: OffRampStatus.COMPLETED }), + ); + expect(transactionRepo.update).toHaveBeenCalledWith( + 'tx-uuid', + expect.objectContaining({ status: TransactionStatus.COMPLETED }), + ); + }); + + it('is idempotent — no update if already completed', async () => { + offRampRepo.findOne.mockResolvedValue(mockOffRamp({ status: OffRampStatus.COMPLETED })); + + await service.handlePaystackTransferSuccess('TRF_123', 'OFFRAMP-ABC123'); + expect(offRampRepo.update).not.toHaveBeenCalled(); + }); + + it('no-ops gracefully for unknown reference', async () => { + offRampRepo.findOne.mockResolvedValue(null); + await expect(service.handlePaystackTransferSuccess('TRF_X', 'UNKNOWN')).resolves.not.toThrow(); + }); + }); + + describe('handlePaystackTransferFailed', () => { + it('triggers USDC refund on transfer failure', async () => { + offRampRepo.findOne.mockResolvedValue( + mockOffRamp({ status: OffRampStatus.TRANSFER_INITIATED, transactionId: 'tx-uuid' }), + ); + userRepo.findOne.mockResolvedValue(mockUser()); + sorobanService.deposit.mockResolvedValue(undefined); + offRampRepo.update.mockResolvedValue(undefined); + transactionRepo.update.mockResolvedValue(undefined); + + await service.handlePaystackTransferFailed('TRF_123', 'OFFRAMP-ABC123'); + + expect(sorobanService.deposit).toHaveBeenCalledWith('alice', '10.00000000'); + expect(transactionRepo.update).toHaveBeenCalledWith( + 'tx-uuid', + expect.objectContaining({ status: TransactionStatus.FAILED }), + ); + }); + + it('is idempotent — no update if already failed', async () => { + offRampRepo.findOne.mockResolvedValue(mockOffRamp({ status: OffRampStatus.FAILED })); + await service.handlePaystackTransferFailed('TRF_123', 'OFFRAMP-ABC123'); + expect(sorobanService.deposit).not.toHaveBeenCalled(); + }); + }); }); diff --git a/backend/src/offramp/offramp.service.ts b/backend/src/offramp/offramp.service.ts index 209606c3..3f4c7c77 100644 --- a/backend/src/offramp/offramp.service.ts +++ b/backend/src/offramp/offramp.service.ts @@ -1,14 +1,21 @@ import { BadRequestException, + ForbiddenException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Between, FindManyOptions, Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; import { randomUUID } from 'crypto'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bullmq'; import { OffRamp, OffRampProvider, OffRampStatus } from './entities/off-ramp.entity'; +import { BulkDisbursement, BulkDisbursementStatus } from './entities/bulk-disbursement.entity'; import { BankAccount } from '../bank-accounts/entities/bank-account.entity'; import { User } from '../users/entities/user.entity'; import { TierConfig } from '../tier-config/entities/tier-config.entity'; @@ -17,12 +24,15 @@ import { Transaction, TransactionStatus, TransactionType } from '../transactions import { RatesService } from '../rates/rates.service'; import { SorobanService } from '../soroban/soroban.service'; import { PinService } from '../pin/pin.service'; +import { FlutterwaveService } from '../flutterwave/flutterwave.service'; import { + AdminOffRampQueryDto, ExecuteOffRampDto, OffRampPreviewResponseDto, OffRampResponseDto, PreviewOffRampDto, } from './dto/offramp.dto'; +import { BulkDisbursementResponseDto } from './dto/bulk-disbursement.dto'; export const MIN_OFFRAMP_USDC = 1; export const SPREAD_PERCENT = 1.5; // 1.5% spread @@ -45,12 +55,54 @@ export class OffRampService { private readonly feeConfigRepo: Repository, @InjectRepository(Transaction) private readonly transactionRepo: Repository, + @InjectRepository(BulkDisbursement) + private readonly bulkDisbursementRepo: Repository, + @InjectQueue('offramp-jobs') + private readonly offrampQueue: Queue, private readonly ratesService: RatesService, private readonly sorobanService: SorobanService, private readonly pinService: PinService, + private readonly flutterwaveService: FlutterwaveService, private readonly configService: ConfigService, ) {} + // ── Bulk Disbursement ─────────────────────────────────────────────────────── + + async uploadBulkDisbursement(userId: string, file: Express.Multer.File): Promise { + if (!file) throw new BadRequestException('CSV file is required'); + + const reference = `BULK-${randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase()}`; + + const bulkDisbursement = this.bulkDisbursementRepo.create({ + userId, + fileName: file.originalname, + reference, + status: BulkDisbursementStatus.PENDING, + totalItems: 0, + }); + const saved = await this.bulkDisbursementRepo.save(bulkDisbursement); + + // Write buffer to a temp file so the processor can read it + const tempDir = os.tmpdir(); + const tempFilePath = path.join(tempDir, `${saved.id}.csv`); + await fs.promises.writeFile(tempFilePath, file.buffer); + + // Queue the job + await this.offrampQueue.add('bulk-disbursement', { + bulkDisbursementId: saved.id, + filePath: tempFilePath, + userId, + }); + + return BulkDisbursementResponseDto.from(saved); + } + + async getBulkDisbursementStatus(userId: string, id: string): Promise { + const bulkDisbursement = await this.bulkDisbursementRepo.findOne({ where: { id, userId } }); + if (!bulkDisbursement) throw new NotFoundException('Bulk disbursement not found'); + return BulkDisbursementResponseDto.from(bulkDisbursement); + } + // ── Preview ───────────────────────────────────────────────────────────────── async preview(userId: string, dto: PreviewOffRampDto): Promise { @@ -156,25 +208,41 @@ export class OffRampService { throw new BadRequestException(`Failed to deduct USDC: ${err.message}`); } - // 9. Initiate NGN transfer via Paystack + // 10. Initiate NGN transfer — try Paystack first, fall back to Flutterwave + let usedProvider = OffRampProvider.PAYSTACK; + let providerRef: string; try { - const providerRef = await this.initiateNgnTransfer( + providerRef = await this.initiateNgnTransferPaystack( bankAccount, parseFloat(ngnAmount), reference, ); - await this.offRampRepo.update(saved.id, { - status: OffRampStatus.TRANSFER_INITIATED, - providerReference: providerRef, - }); - } catch (err: any) { - // Paystack failed — refund USDC - this.logger.error(`NGN transfer failed for ${reference}: ${err.message}`); - await this.refundUsdc(user.username, dto.amountUsdc.toFixed(8), saved.id, err.message); - throw new BadRequestException(`NGN transfer failed: ${err.message}`); + } catch (paystackErr: any) { + this.logger.warn( + `Paystack transfer failed for ${reference} (${paystackErr.message}), attempting Flutterwave fallback`, + ); + try { + providerRef = await this.initiateNgnTransferFlutterwave( + bankAccount, + parseFloat(ngnAmount), + reference, + ); + usedProvider = OffRampProvider.FLUTTERWAVE; + } catch (flwErr: any) { + // Both providers failed — refund USDC + this.logger.error(`Both providers failed for ${reference}: ${flwErr.message}`); + await this.refundUsdc(user.username, dto.amountUsdc.toFixed(8), saved.id, flwErr.message); + throw new BadRequestException(`NGN transfer failed: ${flwErr.message}`); + } } - // 10. Create Transaction record + await this.offRampRepo.update(saved.id, { + status: OffRampStatus.TRANSFER_INITIATED, + providerReference: providerRef, + provider: usedProvider, + }); + + // 11. Create Transaction record const tx = this.transactionRepo.create({ userId, type: TransactionType.WITHDRAWAL, @@ -195,6 +263,106 @@ export class OffRampService { return OffRampResponseDto.from(result!); } + // ── Execute Bulk Item ─────────────────────────────────────────────────────── + + async executeBulkItem(userId: string, item: { amountUsdc: number; bankCode: string; accountNumber: string; accountName: string; bulkDisbursementId: string }): Promise { + if (item.amountUsdc < MIN_OFFRAMP_USDC) { + throw new BadRequestException(`Minimum off-ramp amount is $${MIN_OFFRAMP_USDC} USDC`); + } + + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException('User not found'); + + await this.checkSpendLimits(user, item.amountUsdc); + + const [rateData, feeConfig] = await Promise.all([ + this.ratesService.getRate('USDC', 'NGN'), + this.feeConfigRepo.findOne({ where: { feeType: FeeType.WITHDRAWAL, isActive: true } }), + ]); + + const rate = parseFloat(rateData.rate); + const { feeUsdc, netAmountUsdc } = this.computeFee(item.amountUsdc, feeConfig); + const ngnAmount = (netAmountUsdc * rate * (1 - SPREAD_PERCENT / 100)).toFixed(2); + + const reference = `OFFRAMP-${randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase()}`; + + // Pass account properties as a pseudo-entity to offramp + const dummyBankAccount = { + bankCode: item.bankCode, + accountNumber: item.accountNumber, + bankName: 'External Bank', // Real resolution depends on Provider API checks later + accountName: item.accountName, + } as any; + + const offRamp = this.offRampRepo.create({ + userId, + amountUsdc: item.amountUsdc.toFixed(8), + feeUsdc: feeUsdc.toFixed(8), + netAmountUsdc: netAmountUsdc.toFixed(8), + rate: rateData.rate, + spreadPercent: SPREAD_PERCENT.toFixed(2), + ngnAmount, + bankAccountId: null, // Nullable + bulkDisbursementId: item.bulkDisbursementId, + bankAccountNumber: dummyBankAccount.accountNumber, + bankName: dummyBankAccount.bankName, + accountName: dummyBankAccount.accountName, + reference, + status: OffRampStatus.PENDING, + }); + const saved = await this.offRampRepo.save(offRamp); + + try { + await this.sorobanService.withdraw(user.username, item.amountUsdc.toFixed(8)); + await this.offRampRepo.update(saved.id, { status: OffRampStatus.USDC_DEDUCTED }); + } catch (err: any) { + await this.offRampRepo.update(saved.id, { + status: OffRampStatus.FAILED, + failureReason: `USDC deduction failed: ${err.message}`, + }); + throw new BadRequestException(`Failed to deduct USDC: ${err.message}`); + } + + let usedProvider = OffRampProvider.PAYSTACK; + let providerRef: string; + + try { + providerRef = await this.initiateNgnTransferPaystack(dummyBankAccount, parseFloat(ngnAmount), reference); + } catch (paystackErr: any) { + this.logger.warn(`Paystack transfer failed for ${reference} (${paystackErr.message}), attempting Flutterwave fallback`); + try { + providerRef = await this.initiateNgnTransferFlutterwave(dummyBankAccount, parseFloat(ngnAmount), reference); + usedProvider = OffRampProvider.FLUTTERWAVE; + } catch (flwErr: any) { + this.logger.error(`Both providers failed for ${reference}: ${flwErr.message}`); + await this.refundUsdc(user.username, item.amountUsdc.toFixed(8), saved.id, flwErr.message); + throw new BadRequestException(`NGN transfer failed: ${flwErr.message}`); + } + } + + await this.offRampRepo.update(saved.id, { + status: OffRampStatus.TRANSFER_INITIATED, + providerReference: providerRef, + provider: usedProvider, + }); + + const tx = this.transactionRepo.create({ + userId, + type: TransactionType.WITHDRAWAL, + amountUsdc: item.amountUsdc.toFixed(8), + amount: item.amountUsdc, + currency: 'USDC', + fee: feeUsdc.toFixed(8), + balanceAfter: '0', + status: TransactionStatus.PENDING, + reference, + description: `Bulk Off-ramp item: ${item.amountUsdc} USDC → ${ngnAmount} NGN`, + metadata: { offRampId: saved.id, bankAccount: item.accountNumber }, + }); + const savedTx = await this.transactionRepo.save(tx); + await this.offRampRepo.update(saved.id, { transactionId: savedTx.id }); + } + // ── Get status ────────────────────────────────────────────────────────────── async getStatus(userId: string, referenceId: string): Promise { @@ -208,11 +376,13 @@ export class OffRampService { offRamp.status === OffRampStatus.TRANSFER_INITIATED && offRamp.providerReference ) { - const providerStatus = await this.pollProviderStatus(offRamp.providerReference); + const providerStatus = await this.pollProviderStatus( + offRamp.providerReference, + offRamp.provider, + ); if (providerStatus === 'success') { await this.offRampRepo.update(offRamp.id, { status: OffRampStatus.COMPLETED }); offRamp.status = OffRampStatus.COMPLETED; - // Update transaction to completed if (offRamp.transactionId) { await this.transactionRepo.update(offRamp.transactionId, { status: TransactionStatus.COMPLETED, @@ -252,6 +422,163 @@ export class OffRampService { }; } + // ── Admin methods ──────────────────────────────────────────────────────────── + + async adminList( + query: AdminOffRampQueryDto, + ): Promise<{ data: OffRampResponseDto[]; total: number; page: number; limit: number }> { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + + const where: FindManyOptions['where'] = {}; + if (query.status) (where as any).status = query.status; + if (query.userId) (where as any).userId = query.userId; + if (query.dateFrom && query.dateTo) { + (where as any).createdAt = Between(new Date(query.dateFrom), new Date(query.dateTo)); + } + + const [offRamps, total] = await this.offRampRepo.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { + data: offRamps.map(OffRampResponseDto.from), + total, + page, + limit, + }; + } + + async adminGetById(id: string): Promise { + const offRamp = await this.offRampRepo.findOne({ where: { id } }); + if (!offRamp) throw new NotFoundException(`Off-ramp ${id} not found`); + return OffRampResponseDto.from(offRamp); + } + + async adminRefund(id: string): Promise { + const offRamp = await this.offRampRepo.findOne({ where: { id } }); + if (!offRamp) throw new NotFoundException(`Off-ramp ${id} not found`); + + const refundableStatuses = [OffRampStatus.FAILED, OffRampStatus.USDC_DEDUCTED, OffRampStatus.TRANSFER_INITIATED]; + if (!refundableStatuses.includes(offRamp.status)) { + throw new ForbiddenException( + `Off-ramp is in status '${offRamp.status}' and cannot be refunded`, + ); + } + + const user = await this.userRepo.findOne({ where: { id: offRamp.userId } }); + if (!user) throw new NotFoundException('User associated with off-ramp not found'); + + await this.refundUsdc( + user.username, + offRamp.amountUsdc, + offRamp.id, + 'Admin-initiated manual refund', + ); + + const updated = await this.offRampRepo.findOne({ where: { id } }); + return OffRampResponseDto.from(updated!); + } + + // ── Reconciliation (called by processor) ──────────────────────────────────── + + async reconcileStaleOrders(): Promise { + const cutoff = new Date(Date.now() - 5 * 60 * 1000); // 5 minutes ago + + const stale = await this.offRampRepo + .createQueryBuilder('o') + .where('o.status = :status', { status: OffRampStatus.TRANSFER_INITIATED }) + .andWhere('o.updatedAt < :cutoff', { cutoff }) + .andWhere('o.providerReference IS NOT NULL') + .getMany(); + + this.logger.log(`Reconciling ${stale.length} stale off-ramp order(s)`); + + for (const order of stale) { + try { + const providerStatus = await this.pollProviderStatus( + order.providerReference!, + order.provider, + ); + + if (providerStatus === 'success') { + await this.offRampRepo.update(order.id, { status: OffRampStatus.COMPLETED }); + if (order.transactionId) { + await this.transactionRepo.update(order.transactionId, { + status: TransactionStatus.COMPLETED, + }); + } + this.logger.log(`Reconciled ${order.reference} → completed`); + } else if (providerStatus === 'failed') { + const user = await this.userRepo.findOne({ where: { id: order.userId } }); + if (user) { + await this.refundUsdc(user.username, order.amountUsdc, order.id, 'Reconciliation: provider reported failed'); + } + this.logger.warn(`Reconciled ${order.reference} → failed, triggered refund`); + } + } catch (err: any) { + this.logger.error(`Reconciliation error for ${order.reference}: ${err.message}`); + } + } + } + + // ── Webhook handlers (called by webhook controller) ────────────────────────── + + async handlePaystackTransferSuccess(transferCode: string, reference: string): Promise { + const offRamp = await this.offRampRepo.findOne({ where: { reference } }); + if (!offRamp) { + this.logger.warn(`Paystack webhook: no off-ramp found for reference ${reference}`); + return; + } + if (offRamp.status === OffRampStatus.COMPLETED) return; // idempotent + + await this.offRampRepo.update(offRamp.id, { + status: OffRampStatus.COMPLETED, + providerReference: transferCode, + }); + + if (offRamp.transactionId) { + await this.transactionRepo.update(offRamp.transactionId, { + status: TransactionStatus.COMPLETED, + }); + } + this.logger.log(`Paystack webhook: ${reference} marked completed`); + } + + async handlePaystackTransferFailed(transferCode: string, reference: string): Promise { + const offRamp = await this.offRampRepo.findOne({ where: { reference } }); + if (!offRamp) { + this.logger.warn(`Paystack webhook: no off-ramp found for reference ${reference}`); + return; + } + if ([OffRampStatus.FAILED, OffRampStatus.REFUNDED].includes(offRamp.status)) return; // idempotent + + const user = await this.userRepo.findOne({ where: { id: offRamp.userId } }); + if (user) { + await this.refundUsdc( + user.username, + offRamp.amountUsdc, + offRamp.id, + `Paystack webhook: transfer ${transferCode} failed`, + ); + } else { + await this.offRampRepo.update(offRamp.id, { + status: OffRampStatus.FAILED, + failureReason: `Paystack webhook: transfer ${transferCode} failed — user not found for refund`, + }); + } + + if (offRamp.transactionId) { + await this.transactionRepo.update(offRamp.transactionId, { + status: TransactionStatus.FAILED, + }); + } + this.logger.warn(`Paystack webhook: ${reference} marked failed, refund initiated`); + } + // ── Helpers ───────────────────────────────────────────────────────────────── computeFee( @@ -302,7 +629,9 @@ export class OffRampService { } } - private async initiateNgnTransfer( + // ── Provider: Paystack ─────────────────────────────────────────────────────── + + private async initiateNgnTransferPaystack( bankAccount: BankAccount, ngnAmount: number, reference: string, @@ -347,7 +676,7 @@ export class OffRampService { source: 'balance', amount: Math.round(ngnAmount * 100), // Paystack uses kobo recipient: recipientCode, - reason: `CheesePay off-ramp ${reference}`, + reason: `DabDub off-ramp ${reference}`, reference, }), }); @@ -365,31 +694,75 @@ export class OffRampService { return transferCode; } - private async pollProviderStatus(providerReference: string): Promise { - const paystackKey = this.configService.get('PAYSTACK_SECRET_KEY'); - if (!paystackKey) return 'unknown'; + // ── Provider: Flutterwave ──────────────────────────────────────────────────── - try { - const res = await fetch( - `https://api.paystack.co/transfer/${providerReference}`, - { - headers: { Authorization: `Bearer ${paystackKey}` }, - }, - ); - if (!res.ok) return 'unknown'; + private async initiateNgnTransferFlutterwave( + bankAccount: BankAccount, + ngnAmount: number, + reference: string, + ): Promise { + const result = await this.flutterwaveService.initiateTransfer({ + accountBank: bankAccount.bankCode, + accountNumber: bankAccount.accountNumber, + amount: Math.round(ngnAmount), // Flutterwave uses whole NGN + narration: `DabDub off-ramp ${reference}`, + reference, + }); - const data = (await res.json()) as { data?: { status: string } }; - const status = data.data?.status; + if (!result.id) throw new Error('Flutterwave: no transfer ID returned'); + return String(result.id); + } + + // ── Provider: Status polling ───────────────────────────────────────────────── - if (status === 'success') return 'success'; - if (status === 'failed' || status === 'reversed') return 'failed'; - return 'pending'; + async pollProviderStatus( + providerReference: string, + provider: OffRampProvider = OffRampProvider.PAYSTACK, + ): Promise<'success' | 'failed' | 'pending' | 'unknown'> { + try { + if (provider === OffRampProvider.FLUTTERWAVE) { + return await this.pollFlutterwaveStatus(providerReference); + } + return await this.pollPaystackStatus(providerReference); } catch { return 'unknown'; } } - private async refundUsdc( + private async pollPaystackStatus( + providerReference: string, + ): Promise<'success' | 'failed' | 'pending' | 'unknown'> { + const paystackKey = this.configService.get('PAYSTACK_SECRET_KEY'); + if (!paystackKey) return 'unknown'; + + const res = await fetch( + `https://api.paystack.co/transfer/${providerReference}`, + { + headers: { Authorization: `Bearer ${paystackKey}` }, + }, + ); + if (!res.ok) return 'unknown'; + + const data = (await res.json()) as { data?: { status: string } }; + const status = data.data?.status; + + if (status === 'success') return 'success'; + if (status === 'failed' || status === 'reversed') return 'failed'; + return 'pending'; + } + + private async pollFlutterwaveStatus( + transferId: string, + ): Promise<'success' | 'failed' | 'pending' | 'unknown'> { + const status = await this.flutterwaveService.verifyTransfer(Number(transferId)); + if (status === 'SUCCESSFUL') return 'success'; + if (status === 'FAILED') return 'failed'; + return 'pending'; + } + + // ── Refund ────────────────────────────────────────────────────────────────── + + async refundUsdc( username: string, amountUsdc: string, offRampId: string, diff --git a/backend/src/queue/queue.constants.ts b/backend/src/queue/queue.constants.ts index ed847d9a..47296d64 100644 --- a/backend/src/queue/queue.constants.ts +++ b/backend/src/queue/queue.constants.ts @@ -11,6 +11,7 @@ export const QUEUE_NAMES = [ 'report-jobs', 'referral-jobs', 'support-jobs', + 'offramp-jobs', ] as const; export type QueueName = (typeof QUEUE_NAMES)[number]; diff --git a/backend/test-offramp-output.txt b/backend/test-offramp-output.txt new file mode 100644 index 00000000..85ca87b6 --- /dev/null +++ b/backend/test-offramp-output.txt @@ -0,0 +1 @@ +The system cannot find the path specified. diff --git a/backend/test-output-2.txt b/backend/test-output-2.txt new file mode 100644 index 00000000..b092b40c Binary files /dev/null and b/backend/test-output-2.txt differ diff --git a/backend/test-output-utf8.txt b/backend/test-output-utf8.txt new file mode 100644 index 00000000..0fe5977c --- /dev/null +++ b/backend/test-output-utf8.txt @@ -0,0 +1,29 @@ + +> cheese-backend@0.0.1 test C:\Users\tomio\Desktop\dabdub\backend +> jest "--testPathPattern=offramp" + +node.exe : 'jest' is not +recognized as an internal +or external command, +At C:\Users\tomio\AppData\ +Roaming\npm\pnpm.ps1:24 +char:5 ++ & "node$exe" "$base +dir/node_modules/pnpm/bin/ +pnpm.cjs" $args ++ ~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~ + + CategoryInfo + : NotSpecified: (' + jest' is not r...tern + al command,:String) [ +], RemoteException + + FullyQualifiedError + Id : NativeCommandErr + or + +operable program or batch +file. +ΓÇëELIFECYCLEΓÇë Test failed. See above for more details. +ΓÇëWARNΓÇë Local package.json exists, but node_modules missing, did you mean to install?