From 7033b71a9e070868f6ea077abe4a70367edb40d5 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Thu, 26 Feb 2026 00:47:24 +0100 Subject: [PATCH 1/2] feat: add KYB compliance endpoint with rate limiting and business status retrieval --- app/api/routes-d/compliance/kyb/route.ts | 37 ++++++++++++++++++++ app/api/routes-d/route.ts | 4 ++- lib/compliance-kyb.ts | 44 ++++++++++++++++++++++++ lib/rate-limit.ts | 7 ++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 app/api/routes-d/compliance/kyb/route.ts create mode 100644 lib/compliance-kyb.ts diff --git a/app/api/routes-d/compliance/kyb/route.ts b/app/api/routes-d/compliance/kyb/route.ts new file mode 100644 index 0000000..5319176 --- /dev/null +++ b/app/api/routes-d/compliance/kyb/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getBusinessStatus } from '@/lib/compliance-kyb'; +import { buildRateLimitResponse, getClientIp, kybStatusLimiter } from '@/lib/rate-limit'; + +/** + * GET /api/routes-d/compliance/kyb + * Query params: + * - businessId (required) unique identifier of the business being checked + * + * Returns a simulated verification status for the entity. In a real + * integration this would proxy to a third‑party KYB/compliance provider. + */ +export async function GET(req: NextRequest) { + try { + const clientIp = getClientIp(req); + const statusCheck = kybStatusLimiter.check(clientIp); + if (!statusCheck.allowed) { + console.warn('[rate-limit] KYB status limit exceeded', { ip: clientIp }); + return buildRateLimitResponse(statusCheck); + } + + const businessId = req.nextUrl.searchParams.get('businessId')?.trim(); + if (!businessId) { + return NextResponse.json({ error: 'businessId query parameter is required' }, { status: 400 }); + } + + const info = await getBusinessStatus(businessId); + + return NextResponse.json({ success: true, data: info }); + } catch (err: any) { + console.error('Error fetching KYB status:', err); + return NextResponse.json( + { error: err.message || 'Failed to fetch KYB status' }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-d/route.ts b/app/api/routes-d/route.ts index db2a877..735bb01 100644 --- a/app/api/routes-d/route.ts +++ b/app/api/routes-d/route.ts @@ -43,7 +43,9 @@ export async function GET() { merchants: { onboarding: '/api/routes-d/merchants/onboarding', }, + compliance: { + kyb: '/api/routes-d/compliance/kyb?businessId={id}', + }, } }) } - diff --git a/lib/compliance-kyb.ts b/lib/compliance-kyb.ts new file mode 100644 index 0000000..8025a18 --- /dev/null +++ b/lib/compliance-kyb.ts @@ -0,0 +1,44 @@ +/** + * Stubbed business verification (KYB) helpers. + * In production this would call out to a third-party compliance provider + * using an API key / endpoint defined in environment variables. + */ + +export type KYBStatus = 'NEEDS_INFO' | 'PENDING' | 'PROCESSING' | 'ACCEPTED' | 'REJECTED'; + +export interface BusinessInfo { + id: string; + status: KYBStatus; + message?: string; + // additional fields could be added here to mirror real vendor responses +} + +const KYB_BASE_URL = process.env.NEXT_PUBLIC_KYB_ENDPOINT || ''; + +/** + * Fetch the verification status for a business entity. + * + * The only required identifier today is `businessId`; clients may use + * registration number or other fields but those are handled by the + * third-party service. For now we return a fixed pending response. + */ +export async function getBusinessStatus(businessId: string): Promise { + // placeholder implementation, simulate network call delay + if (!businessId) { + throw new Error('businessId is required'); + } + + // Example of how an actual request could look: + // const resp = await fetch(`${KYB_BASE_URL}/verify?businessId=${encodeURIComponent(businessId)}`, { + // headers: { Authorization: `Bearer ${process.env.KYB_API_KEY}` }, + // }); + // if (!resp.ok) throw new Error(`KYB request failed: ${resp.statusText}`); + // return resp.json(); + + // Simulated response + return { + id: businessId, + status: 'PENDING', + message: 'Verification in progress (simulation)', + }; +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index c492b00..de6a10b 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -222,6 +222,13 @@ export const kycStatusLimiter = new RouteRateLimiter({ windowMs: 60_000, }) +// KYB (business verification) has the same rate limits as KYC status +export const kybStatusLimiter = new RouteRateLimiter({ + id: 'kyb-status', + maxRequests: 30, + windowMs: 60_000, +}) + const KYC_BYPASS_IDS: Set = new Set( (process.env.KYC_RATE_LIMIT_BYPASS_USER_IDS ?? '') .split(',') From 77f4a6f0790a817aa393baef0b5c633237632e92 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Thu, 26 Feb 2026 00:50:38 +0100 Subject: [PATCH 2/2] feat: add schedule payout endpoint and update schema for scheduledAt field --- app/api/routes-d/payouts/schedule/route.ts | 177 +++++++++++++++++++++ app/api/routes-d/route.ts | 4 + prisma/schema.prisma | 1 + 3 files changed, 182 insertions(+) create mode 100644 app/api/routes-d/payouts/schedule/route.ts diff --git a/app/api/routes-d/payouts/schedule/route.ts b/app/api/routes-d/payouts/schedule/route.ts new file mode 100644 index 0000000..a9b5c5f --- /dev/null +++ b/app/api/routes-d/payouts/schedule/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { verifyAuthToken } from '@/lib/auth'; +import { getAccountBalance, isValidStellarAddress } from '@/lib/stellar'; + +interface SchedulePayoutItem { + amount: string; + recipient: string; + type: 'USDC' | 'BANK'; + bankCode?: string; +} + +interface SchedulePayoutRequest { + items: SchedulePayoutItem[]; + scheduledFor: string; // ISO date string +} + +// reuse some helpers from mass route (could be moved to shared module if needed) +function isValidNUBAN(accountNumber: string): boolean { + return /^\d{10}$/.test(accountNumber); +} +function isValidBankCode(bankCode: string): boolean { + return /^\d{3}$/.test(bankCode); +} + +export async function POST(request: NextRequest) { + try { + const authToken = request.headers.get('authorization')?.replace('Bearer ', ''); + if (!authToken) { + return NextResponse.json({ error: 'Unauthorized: No auth token provided' }, { status: 401 }); + } + + const claims = await verifyAuthToken(authToken); + if (!claims) { + return NextResponse.json({ error: 'Unauthorized: Invalid token' }, { status: 401 }); + } + + let user = await prisma.user.findUnique({ where: { privyId: claims.userId } }); + if (!user) { + const email = (claims as { email?: string }).email || `${claims.userId}@privy.local`; + user = await prisma.user.create({ data: { privyId: claims.userId, email } }); + } + const userId = user.id; + + const body: SchedulePayoutRequest = await request.json(); + + if (!body.items || !Array.isArray(body.items) || body.items.length === 0) { + return NextResponse.json({ error: 'Invalid request: items array is required' }, { status: 400 }); + } + + if (!body.scheduledFor) { + return NextResponse.json({ error: 'scheduledFor timestamp is required' }, { status: 400 }); + } + + const scheduledDate = new Date(body.scheduledFor); + if (isNaN(scheduledDate.getTime())) { + return NextResponse.json({ error: 'scheduledFor must be a valid ISO date' }, { status: 400 }); + } + if (scheduledDate.getTime() <= Date.now()) { + return NextResponse.json({ error: 'scheduledFor must be in the future' }, { status: 400 }); + } + + if (body.items.length > 100) { + return NextResponse.json( + { error: 'Too many items: maximum 100 items per schedule' }, + { status: 400 } + ); + } + + // validate each item similarly to mass route + for (let i = 0; i < body.items.length; i++) { + const item = body.items[i]; + if (!item.amount || !item.recipient || !item.type) { + return NextResponse.json( + { error: `Invalid item at index ${i}: amount, recipient, and type are required` }, + { status: 400 } + ); + } + if (isNaN(parseFloat(item.amount)) || parseFloat(item.amount) <= 0) { + return NextResponse.json( + { error: `Invalid amount at index ${i}: must be a positive number` }, + { status: 400 } + ); + } + if (!['USDC', 'BANK'].includes(item.type)) { + return NextResponse.json( + { error: `Invalid type at index ${i}: must be 'USDC' or 'BANK'` }, + { status: 400 } + ); + } + if (item.type === 'BANK' && !item.bankCode) { + return NextResponse.json( + { error: `Bank type at index ${i}: bankCode is required for BANK payouts` }, + { status: 400 } + ); + } + } + + // ensure wallet exists + const userWallet = await prisma.wallet.findUnique({ where: { userId } }); + if (!userWallet) { + return NextResponse.json({ error: 'User wallet not found' }, { status: 404 }); + } + + // check balance now to give user immediate feedback + const totalAmount = body.items.reduce((sum, item) => sum + parseFloat(item.amount), 0); + const balances = await getAccountBalance(userWallet.address); + const usdcBalance = balances.find((b: any) => b.asset_code === 'USDC' && b.asset_issuer === process.env.NEXT_PUBLIC_USDC_ISSUER); + const userBalanceUSDC = usdcBalance ? parseFloat(usdcBalance.balance) : 0; + + // simple fee estimate: copy calculateEstimatedFees from mass route (importing would require refactor) + const PLATFORM_FEE_RATE = 0.005; + const BANK_TRANSFER_FEE_USDC = 0.3; + const calculateEstimatedFees = (items: SchedulePayoutItem[]) => { + let total = 0; + for (const item of items) { + const amount = parseFloat(item.amount); + const platformFee = amount * PLATFORM_FEE_RATE; + const gasFeeUSDC = 0.1; + if (item.type === 'BANK') { + total += platformFee + gasFeeUSDC + BANK_TRANSFER_FEE_USDC; + } else { + total += platformFee + gasFeeUSDC; + } + } + return parseFloat(total.toFixed(7)); + }; + + const estimatedFees = calculateEstimatedFees(body.items); + const totalRequired = totalAmount + estimatedFees; + if (userBalanceUSDC < totalRequired) { + return NextResponse.json( + { error: 'Insufficient balance', details: { required: totalRequired, available: userBalanceUSDC, estimatedFees } }, + { status: 400 } + ); + } + + // create scheduled batch + const batch = await prisma.payoutBatch.create({ + data: { + userId, + totalAmount, + totalRecipients: body.items.length, + status: 'scheduled', + scheduledAt: scheduledDate, + } + }); + + // create items + await Promise.all( + body.items.map((item) => + prisma.payoutItem.create({ + data: { + batchId: batch.id, + recipientIdentifier: item.recipient, + amount: parseFloat(item.amount), + payoutType: item.type === 'USDC' ? 'stellar_usdc' : 'ngn_bank', + status: 'pending' + } + }) + ) + ); + + return NextResponse.json({ + success: true, + batchId: batch.id, + scheduledFor: scheduledDate.toISOString(), + summary: { totalItems: body.items.length, totalAmount } + }); + } catch (error: any) { + console.error('Schedule payout error:', error); + return NextResponse.json( + { error: error.message || 'Failed to schedule payout' }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-d/route.ts b/app/api/routes-d/route.ts index 735bb01..d89c1df 100644 --- a/app/api/routes-d/route.ts +++ b/app/api/routes-d/route.ts @@ -46,6 +46,10 @@ export async function GET() { compliance: { kyb: '/api/routes-d/compliance/kyb?businessId={id}', }, + payouts: { + schedule: '/api/routes-d/payouts/schedule', + mass: '/api/routes-d/payouts/mass' + } } }) } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e8b05f..e7eb511 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -571,6 +571,7 @@ model PayoutBatch { successCount Int @default(0) failedCount Int @default(0) results Json @default("[]") + scheduledAt DateTime? // optional timestamp when payout should be executed createdAt DateTime @default(now()) completedAt DateTime?