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
37 changes: 37 additions & 0 deletions app/api/routes-d/compliance/kyb/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
177 changes: 177 additions & 0 deletions app/api/routes-d/payouts/schedule/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
8 changes: 7 additions & 1 deletion app/api/routes-d/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ export async function GET() {
merchants: {
onboarding: '/api/routes-d/merchants/onboarding',
},
compliance: {
kyb: '/api/routes-d/compliance/kyb?businessId={id}',
},
payouts: {
schedule: '/api/routes-d/payouts/schedule',
mass: '/api/routes-d/payouts/mass'
}
}
})
}

44 changes: 44 additions & 0 deletions lib/compliance-kyb.ts
Original file line number Diff line number Diff line change
@@ -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<BusinessInfo> {
// 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)',
};
}
7 changes: 7 additions & 0 deletions lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set(
(process.env.KYC_RATE_LIMIT_BYPASS_USER_IDS ?? '')
.split(',')
Expand Down
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
Loading