From d8c5ff083d061f94d9aa71b006c7802b9bfa91eb Mon Sep 17 00:00:00 2001 From: davedumto Date: Sat, 31 Jan 2026 10:18:34 +0100 Subject: [PATCH 1/4] feat(compliance): implement KYC tiers, withdrawal limits, and terms acceptance Add comprehensive compliance system with KYC verification tiers, withdrawal limit enforcement, and terms acceptance flow. Features: - KYC tier system (Bronze/Silver/Gold/Platinum) with configurable limits - Real-time withdrawal validation with limit checks - Terms acceptance dialog with scroll-to-read enforcement - Hold states and geo-restriction handling - Document upload flow for tier upgrades - Mobile-responsive wallet page with centered layout - Appeal system for rejected verifications API Routes: - /api/compliance/status - Get user compliance and limits - /api/compliance/terms - Get/accept terms and conditions - /api/compliance/upgrade - Request tier upgrade - /api/withdrawal/validate - Validate withdrawal amounts - /api/withdrawal/submit - Submit withdrawal requests Components: - TierUpgradeDialog - Multi-step tier upgrade with document upload - TermsDialog - Scrollable terms with acceptance tracking - LimitsDisplay - Real-time limit usage visualization - HoldMessage - Alert for account holds --- lib/services/terms.ts | 255 +++++++++++++++++++++++++++++ lib/services/verification.ts | 304 +++++++++++++++++++++++++++++++++++ lib/services/withdrawal.ts | 101 ++++++++++++ types/compliance.ts | 98 +++++++++++ types/geography.ts | 20 +++ types/terms.ts | 28 ++++ types/withdrawal.ts | 46 ++++++ 7 files changed, 852 insertions(+) create mode 100644 lib/services/terms.ts create mode 100644 lib/services/verification.ts create mode 100644 lib/services/withdrawal.ts create mode 100644 types/compliance.ts create mode 100644 types/geography.ts create mode 100644 types/terms.ts create mode 100644 types/withdrawal.ts diff --git a/lib/services/terms.ts b/lib/services/terms.ts new file mode 100644 index 0000000..4e69027 --- /dev/null +++ b/lib/services/terms.ts @@ -0,0 +1,255 @@ +import { + TermsVersion, + TermsAcceptance, + UserTermsStatus, +} from "@/types/terms"; + +// Mock Data Stores +const MOCK_TERMS_VERSIONS: TermsVersion[] = [ + { + id: 'terms-v1', + version: '1.0.0', + title: 'Withdrawal Terms and Conditions', + summary: 'Standard terms for fiat withdrawal services', + content: `# Withdrawal Terms and Conditions + +## 1. General Terms + +By using Boundless Bounties withdrawal services, you agree to comply with these terms and all applicable laws and regulations. + +## 2. Withdrawal Limits + +Withdrawal limits are determined by your KYC verification tier: +- Unverified: $100 daily, $500 monthly +- Basic: $1,000 daily, $15,000 monthly +- Verified: $5,000 daily, $75,000 monthly +- Enhanced: $25,000 daily, $300,000 monthly + +## 3. Processing Times + +- Withdrawals are processed within 1-5 business days +- Additional verification may be required for large transactions +- Weekends and holidays may extend processing times + +## 4. Fees + +- Standard withdrawal fee: $2.50 per transaction +- Additional fees may apply for expedited processing +- Currency conversion fees apply for non-USD withdrawals + +## 5. Compliance Requirements + +- You must maintain accurate personal information +- Additional verification may be requested at any time +- We reserve the right to freeze or cancel transactions for compliance reasons + +## 6. Geographic Restrictions + +- Services may not be available in all jurisdictions +- You must not use VPN or proxy services to bypass restrictions +- We comply with all local financial regulations + +## 7. Account Security + +- You are responsible for maintaining account security +- Report any unauthorized access immediately +- Two-factor authentication is strongly recommended + +## 8. Dispute Resolution + +- Disputes must be reported within 30 days +- We will investigate all disputes thoroughly +- Resolution may take 15-30 business days + +## 9. Changes to Terms + +- We reserve the right to update these terms +- You will be notified of material changes +- Continued use constitutes acceptance of new terms + +## 10. Contact + +For questions or concerns, contact support@boundless.fi`, + effectiveDate: '2024-01-01T00:00:00Z', + requiresReacceptance: false, + createdAt: '2024-01-01T00:00:00Z', + }, +]; + +const MOCK_TERMS_ACCEPTANCES: Record = {}; + +export class TermsService { + /** + * Get current active terms version + */ + static async getCurrentTermsVersion(): Promise { + await this.simulateDelay(); + + // Return the latest version + const sorted = [...MOCK_TERMS_VERSIONS].sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + return { ...sorted[0] }; + } + + /** + * Get user's terms acceptance status + */ + static async getUserTermsStatus(userId: string): Promise { + await this.simulateDelay(); + + const currentTerms = await this.getCurrentTermsVersion(); + const userAcceptances = MOCK_TERMS_ACCEPTANCES[userId] || []; + + // Find latest acceptance + const sortedAcceptances = [...userAcceptances].sort((a, b) => + new Date(b.acceptedAt).getTime() - new Date(a.acceptedAt).getTime() + ); + + const lastAcceptance = sortedAcceptances[0]; + + // Check if current version is accepted + const hasAcceptedCurrent = userAcceptances.some( + acceptance => acceptance.termsVersionId === currentTerms.id + ); + + // Determine if reacceptance is required + const requiresAcceptance = !hasAcceptedCurrent || + (currentTerms.requiresReacceptance && !hasAcceptedCurrent); + + return { + hasAcceptedCurrent, + currentVersion: currentTerms.version, + lastAcceptedVersion: lastAcceptance?.termsVersion, + lastAcceptedAt: lastAcceptance?.acceptedAt, + requiresAcceptance, + }; + } + + /** + * Record terms acceptance + */ + static async acceptTerms( + userId: string, + termsVersionId: string, + metadata: { + ipAddress: string; + userAgent: string; + } + ): Promise { + await this.simulateDelay(); + + // Verify terms version exists + const termsVersion = MOCK_TERMS_VERSIONS.find(v => v.id === termsVersionId); + if (!termsVersion) { + throw new Error('Terms version not found'); + } + + // Check if already accepted + const existingAcceptances = MOCK_TERMS_ACCEPTANCES[userId] || []; + const alreadyAccepted = existingAcceptances.some( + a => a.termsVersionId === termsVersionId + ); + + if (alreadyAccepted) { + // Return existing acceptance + return existingAcceptances.find(a => a.termsVersionId === termsVersionId)!; + } + + // Create new acceptance record + const acceptance: TermsAcceptance = { + id: `acceptance-${userId}-${termsVersionId}-${Date.now()}`, + userId, + termsVersionId, + termsVersion: termsVersion.version, + acceptedAt: new Date().toISOString(), + ipAddress: metadata.ipAddress, + userAgent: metadata.userAgent, + }; + + // Store acceptance + if (!MOCK_TERMS_ACCEPTANCES[userId]) { + MOCK_TERMS_ACCEPTANCES[userId] = []; + } + MOCK_TERMS_ACCEPTANCES[userId].push(acceptance); + + return { ...acceptance }; + } + + /** + * Check if user needs to accept updated terms + */ + static async requiresReacceptance(userId: string): Promise { + const status = await this.getUserTermsStatus(userId); + return status.requiresAcceptance; + } + + /** + * Get terms acceptance history for user + */ + static async getAcceptanceHistory(userId: string): Promise { + await this.simulateDelay(); + + const acceptances = MOCK_TERMS_ACCEPTANCES[userId] || []; + + // Sort by acceptance date (newest first) + return [...acceptances].sort((a, b) => + new Date(b.acceptedAt).getTime() - new Date(a.acceptedAt).getTime() + ); + } + + /** + * Get terms version by ID + */ + static async getTermsVersion(versionId: string): Promise { + await this.simulateDelay(); + + const version = MOCK_TERMS_VERSIONS.find(v => v.id === versionId); + return version ? { ...version } : null; + } + + /** + * Get all terms versions + */ + static async getAllTermsVersions(): Promise { + await this.simulateDelay(); + + return [...MOCK_TERMS_VERSIONS].sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + } + + /** + * Create new terms version (admin function) + */ + static async createTermsVersion( + version: Omit + ): Promise { + await this.simulateDelay(); + + const newVersion: TermsVersion = { + ...version, + id: `terms-v${MOCK_TERMS_VERSIONS.length + 1}`, + createdAt: new Date().toISOString(), + }; + + MOCK_TERMS_VERSIONS.push(newVersion); + return { ...newVersion }; + } + + /** + * Simulate database delay + */ + private static async simulateDelay(): Promise { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + /** + * Clear all data (for testing) + */ + static clearAllData(): void { + Object.keys(MOCK_TERMS_ACCEPTANCES).forEach(key => delete MOCK_TERMS_ACCEPTANCES[key]); + MOCK_TERMS_VERSIONS.length = 1; // Keep only the first version + } +} diff --git a/lib/services/verification.ts b/lib/services/verification.ts new file mode 100644 index 0000000..a2a0e1f --- /dev/null +++ b/lib/services/verification.ts @@ -0,0 +1,304 @@ +import { + VerificationRequest, + VerificationDocument, + KYCTier, + VerificationStatus, + DocumentType, +} from "@/types/compliance"; +import { ComplianceService } from "./compliance"; +import { EmailService } from "./email"; + +// Mock Data Store +const MOCK_VERIFICATION_REQUESTS: Record = {}; +const MOCK_DOCUMENTS: Record = {}; + +export class VerificationService { + /** + * Create new verification request + */ + static async createVerificationRequest( + userId: string, + targetTier: KYCTier + ): Promise { + await this.simulateDelay(); + + // Check if user already has a pending request + const existing = Object.values(MOCK_VERIFICATION_REQUESTS).find( + req => req.userId === userId && req.status === 'PENDING' + ); + + if (existing) { + throw new Error('You already have a pending verification request'); + } + + // Get current compliance to validate tier upgrade + const compliance = await ComplianceService.getUserCompliance(userId); + const tiers: KYCTier[] = ['UNVERIFIED', 'BASIC', 'VERIFIED', 'ENHANCED']; + const currentIndex = tiers.indexOf(compliance.currentTier); + const targetIndex = tiers.indexOf(targetTier); + + if (targetIndex <= currentIndex) { + throw new Error('Target tier must be higher than current tier'); + } + + // Create verification request + const requestId = `vreq-${userId}-${Date.now()}`; + const request: VerificationRequest = { + id: requestId, + userId, + targetTier, + status: 'PENDING', + submittedAt: new Date().toISOString(), + documents: [], + }; + + MOCK_VERIFICATION_REQUESTS[requestId] = request; + MOCK_DOCUMENTS[requestId] = []; + + // Update user's verification status + await ComplianceService.updateVerificationStatus(userId, 'PENDING'); + + return { ...request }; + } + + /** + * Get user's current verification status + */ + static async getVerificationStatus(userId: string): Promise { + await this.simulateDelay(); + + // Find most recent verification request for user + const requests = Object.values(MOCK_VERIFICATION_REQUESTS) + .filter(req => req.userId === userId) + .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()); + + if (requests.length === 0) { + return null; + } + + const request = requests[0]; + + // Attach documents + const documents = MOCK_DOCUMENTS[request.id] || []; + return { + ...request, + documents: [...documents], + }; + } + + /** + * Upload verification document + */ + static async uploadDocument( + requestId: string, + document: { + type: DocumentType; + fileName: string; + file: File | Blob; + } + ): Promise { + await this.simulateDelay(); + + const request = MOCK_VERIFICATION_REQUESTS[requestId]; + if (!request) { + throw new Error('Verification request not found'); + } + + if (request.status !== 'PENDING') { + throw new Error('Cannot upload documents to non-pending request'); + } + + // Simulate file upload (in real implementation, upload to S3/cloud storage) + const fileUrl = `https://storage.boundless.fi/documents/${requestId}/${document.fileName}`; + + // Create document record + const doc: VerificationDocument = { + id: `doc-${requestId}-${Date.now()}`, + type: document.type, + fileName: document.fileName, + fileUrl, + uploadedAt: new Date().toISOString(), + verified: false, + }; + + // Store document + if (!MOCK_DOCUMENTS[requestId]) { + MOCK_DOCUMENTS[requestId] = []; + } + MOCK_DOCUMENTS[requestId].push(doc); + + // Update request's documents array + request.documents = MOCK_DOCUMENTS[requestId]; + + return { ...doc }; + } + + /** + * Update verification request status (admin/automated function) + */ + static async updateVerificationStatus( + requestId: string, + status: VerificationStatus, + reason?: string + ): Promise { + await this.simulateDelay(); + + const request = MOCK_VERIFICATION_REQUESTS[requestId]; + if (!request) { + throw new Error('Verification request not found'); + } + + // Update request status + request.status = status; + request.reviewedAt = new Date().toISOString(); + + if (status === 'REJECTED' && reason) { + request.rejectionReason = reason; + } + + // If approved, upgrade user's tier + if (status === 'APPROVED') { + await ComplianceService.upgradeTier(request.userId, request.targetTier); + } + + // Update user's verification status in compliance + await ComplianceService.updateVerificationStatus(request.userId, status); + + // Send notification (in real implementation) + await this.notifyStatusChange(request.userId, status); + + return true; + } + + /** + * Delete/remove a document + */ + static async deleteDocument(requestId: string, documentId: string): Promise { + await this.simulateDelay(); + + const request = MOCK_VERIFICATION_REQUESTS[requestId]; + if (!request || request.status !== 'PENDING') { + throw new Error('Cannot delete documents from non-pending request'); + } + + const documents = MOCK_DOCUMENTS[requestId] || []; + const index = documents.findIndex(doc => doc.id === documentId); + + if (index === -1) { + throw new Error('Document not found'); + } + + // Remove document + documents.splice(index, 1); + request.documents = documents; + + return true; + } + + /** + * Get required document types for a tier + */ + static getRequiredDocuments(targetTier: KYCTier): DocumentType[] { + switch (targetTier) { + case 'BASIC': + return ['GOVERNMENT_ID']; + case 'VERIFIED': + return ['GOVERNMENT_ID', 'PROOF_OF_ADDRESS']; + case 'ENHANCED': + return ['GOVERNMENT_ID', 'PROOF_OF_ADDRESS', 'SELFIE']; + default: + return []; + } + } + + /** + * Check if verification request has all required documents + */ + static hasRequiredDocuments(request: VerificationRequest): boolean { + const required = this.getRequiredDocuments(request.targetTier); + const uploaded = request.documents.map(doc => doc.type); + + return required.every(type => uploaded.includes(type)); + } + + /** + * Send status change notification + */ + static async notifyStatusChange( + userId: string, + newStatus: VerificationStatus + ): Promise { + const userEmail = `user-${userId}@example.com`; + const request = Object.values(MOCK_VERIFICATION_REQUESTS) + .find(req => req.userId === userId && req.status === newStatus); + + await EmailService.sendVerificationStatusEmail( + userEmail, + newStatus, + request?.targetTier, + request?.rejectionReason + ); + } + + /** + * Get all verification requests for user (history) + */ + static async getVerificationHistory(userId: string): Promise { + await this.simulateDelay(); + + const requests = Object.values(MOCK_VERIFICATION_REQUESTS) + .filter(req => req.userId === userId) + .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()); + + // Attach documents to each request + return requests.map(req => ({ + ...req, + documents: [...(MOCK_DOCUMENTS[req.id] || [])], + })); + } + + /** + * Cancel pending verification request + */ + static async cancelVerificationRequest(requestId: string, userId: string): Promise { + await this.simulateDelay(); + + const request = MOCK_VERIFICATION_REQUESTS[requestId]; + if (!request) { + throw new Error('Verification request not found'); + } + + if (request.userId !== userId) { + throw new Error('Unauthorized'); + } + + if (request.status !== 'PENDING') { + throw new Error('Can only cancel pending requests'); + } + + // Update status + request.status = 'REJECTED'; + request.rejectionReason = 'Cancelled by user'; + request.reviewedAt = new Date().toISOString(); + + // Update compliance status + await ComplianceService.updateVerificationStatus(userId, 'NOT_STARTED'); + + return true; + } + + /** + * Simulate database delay + */ + private static async simulateDelay(ms: number = 100): Promise { + await new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Clear all data (for testing) + */ + static clearAllData(): void { + Object.keys(MOCK_VERIFICATION_REQUESTS).forEach(key => delete MOCK_VERIFICATION_REQUESTS[key]); + Object.keys(MOCK_DOCUMENTS).forEach(key => delete MOCK_DOCUMENTS[key]); + } +} diff --git a/lib/services/withdrawal.ts b/lib/services/withdrawal.ts new file mode 100644 index 0000000..5958bb9 --- /dev/null +++ b/lib/services/withdrawal.ts @@ -0,0 +1,101 @@ +import { WithdrawalRequest, WithdrawalValidationResult } from "@/types/withdrawal"; +import { ComplianceService } from "./compliance"; +import { TermsService } from "./terms"; +import { GeoRestrictionService } from "./geo-restriction"; +import { mockWalletWithAssets } from "@/lib/mock-wallet"; + +const MOCK_WITHDRAWALS: Record = {}; + +export class WithdrawalService { + static async validate(userId: string, amount: number, ip: string): Promise { + const result: WithdrawalValidationResult = { + valid: true, + errors: [], + warnings: [], + blockers: {}, + }; + + // Check balance + if (amount > mockWalletWithAssets.balance) { + result.valid = false; + result.errors.push('Insufficient balance'); + result.blockers.insufficientBalance = true; + } + + // Check limits + const compliance = await ComplianceService.getUserCompliance(userId); + const limitCheck = await ComplianceService.validateWithdrawalAmount(userId, amount); + + if (!limitCheck.valid) { + result.valid = false; + result.errors.push(`Exceeds ${limitCheck.exceededLimit} limit`); + result.blockers.exceedsLimit = true; + result.blockers.limitType = limitCheck.exceededLimit; + } + + // Check hold state + if (compliance.holdState !== 'NONE') { + result.valid = false; + result.errors.push(`Account is ${compliance.holdState.toLowerCase()}`); + result.blockers.complianceHold = true; + } + + // Check terms + const termsStatus = await TermsService.getUserTermsStatus(userId); + if (termsStatus.requiresAcceptance) { + result.valid = false; + result.errors.push('Terms must be accepted'); + result.blockers.termsNotAccepted = true; + } + + // Check location + const location = await GeoRestrictionService.checkLocation(ip); + if (location.isRestricted) { + result.valid = false; + result.errors.push('Withdrawals not available in your region'); + result.blockers.restrictedJurisdiction = true; + } + + return result; + } + + static async submit(userId: string, amount: number, currency: string, destinationId: string, ip: string): Promise { + const validation = await this.validate(userId, amount, ip); + if (!validation.valid) { + throw new Error(validation.errors[0] || 'Withdrawal validation failed'); + } + + const compliance = await ComplianceService.getUserCompliance(userId); + + const withdrawal: WithdrawalRequest = { + id: `wd-${Date.now()}`, + userId, + amount, + currency, + destinationId, + fee: 2.50, + netAmount: amount - 2.50, + status: 'PENDING', + compliance: { + tierAtSubmission: compliance.currentTier, + limitsChecked: true, + termsAccepted: true, + geoCheckPassed: true, + }, + createdAt: new Date().toISOString(), + }; + + if (!MOCK_WITHDRAWALS[userId]) { + MOCK_WITHDRAWALS[userId] = []; + } + MOCK_WITHDRAWALS[userId].push(withdrawal); + + await ComplianceService.trackWithdrawal(userId, amount); + + return withdrawal; + } + + static async getHistory(userId: string): Promise { + return MOCK_WITHDRAWALS[userId] || []; + } +} diff --git a/types/compliance.ts b/types/compliance.ts new file mode 100644 index 0000000..184b2ad --- /dev/null +++ b/types/compliance.ts @@ -0,0 +1,98 @@ +// KYC Verification Tiers +export type KYCTier = 'UNVERIFIED' | 'BASIC' | 'VERIFIED' | 'ENHANCED'; + +// Compliance Hold States +export type ComplianceHoldState = 'NONE' | 'REVIEW' | 'SUSPENDED' | 'BLOCKED'; + +// Verification Status +export type VerificationStatus = 'NOT_STARTED' | 'PENDING' | 'APPROVED' | 'REJECTED'; + +// Verification Document Types +export type DocumentType = 'GOVERNMENT_ID' | 'PROOF_OF_ADDRESS' | 'SELFIE' | 'ADDITIONAL'; + +export interface WithdrawalLimits { + daily: number; + weekly: number; + monthly: number; + perTransaction: number; +} + +export interface WithdrawalUsage { + dailyUsed: number; + weeklyUsed: number; + monthlyUsed: number; + lifetimeTotal: number; + lastResetDaily: string; + lastResetWeekly: string; + lastResetMonthly: string; +} + +export interface KYCTierConfig { + tier: KYCTier; + limits: WithdrawalLimits; + requirements: string[]; + processingTime?: string; + description: string; +} + +export interface UserCompliance { + userId: string; + currentTier: KYCTier; + limits: WithdrawalLimits; + usage: WithdrawalUsage; + holdState: ComplianceHoldState; + holdReason?: string; + verificationStatus: VerificationStatus; + restrictedJurisdiction: boolean; + jurisdictionCode?: string; + createdAt: string; + updatedAt: string; +} + +export interface VerificationRequest { + id: string; + userId: string; + targetTier: KYCTier; + status: VerificationStatus; + submittedAt: string; + reviewedAt?: string; + rejectionReason?: string; + documents: VerificationDocument[]; + notes?: string; +} + +export interface VerificationDocument { + id: string; + type: DocumentType; + fileName: string; + fileUrl: string; + uploadedAt: string; + verified: boolean; +} + +// Helper type for remaining limits calculation +export interface RemainingLimits { + daily: number; + weekly: number; + monthly: number; + percentUsed: { + daily: number; + weekly: number; + monthly: number; + }; +} + +// Appeal types +export type AppealStatus = 'PENDING' | 'UNDER_REVIEW' | 'APPROVED' | 'DENIED'; + +export interface VerificationAppeal { + id: string; + userId: string; + verificationRequestId: string; + reason: string; + additionalInfo?: string; + status: AppealStatus; + submittedAt: string; + reviewedAt?: string; + reviewNotes?: string; +} diff --git a/types/geography.ts b/types/geography.ts new file mode 100644 index 0000000..b7698f9 --- /dev/null +++ b/types/geography.ts @@ -0,0 +1,20 @@ +export interface RestrictedJurisdiction { + code: string; // ISO country code or US state code + name: string; + type: 'COUNTRY' | 'STATE'; + reason: string; + effectiveDate: string; +} + +export interface UserLocation { + ip: string; + countryCode: string; + countryName: string; + regionCode?: string; + regionName?: string; + city?: string; + isVPN: boolean; + isProxy: boolean; + isRestricted: boolean; + restrictionReason?: string; +} diff --git a/types/terms.ts b/types/terms.ts new file mode 100644 index 0000000..99b1abf --- /dev/null +++ b/types/terms.ts @@ -0,0 +1,28 @@ +export interface TermsVersion { + id: string; + version: string; + title: string; + content: string; + summary?: string; + effectiveDate: string; + requiresReacceptance: boolean; + createdAt: string; +} + +export interface TermsAcceptance { + id: string; + userId: string; + termsVersionId: string; + termsVersion: string; + acceptedAt: string; + ipAddress: string; + userAgent: string; +} + +export interface UserTermsStatus { + hasAcceptedCurrent: boolean; + currentVersion: string; + lastAcceptedVersion?: string; + lastAcceptedAt?: string; + requiresAcceptance: boolean; +} diff --git a/types/withdrawal.ts b/types/withdrawal.ts new file mode 100644 index 0000000..b3a4f83 --- /dev/null +++ b/types/withdrawal.ts @@ -0,0 +1,46 @@ +import { KYCTier } from './compliance'; + +export interface WithdrawalRequest { + id: string; + userId: string; + amount: number; + currency: string; + destinationId: string; // Bank account ID + destinationName?: string; + fee: number; + netAmount: number; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; + compliance: { + tierAtSubmission: KYCTier; + limitsChecked: boolean; + termsAccepted: boolean; + geoCheckPassed: boolean; + }; + createdAt: string; + processedAt?: string; + completedAt?: string; + failureReason?: string; +} + +export interface WithdrawalValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; + blockers: { + insufficientBalance?: boolean; + exceedsLimit?: boolean; + limitType?: 'daily' | 'weekly' | 'monthly' | 'perTransaction'; + complianceHold?: boolean; + restrictedJurisdiction?: boolean; + termsNotAccepted?: boolean; + unverifiedAccount?: boolean; + }; +} + +export interface WithdrawalHistoryFilters { + startDate?: string; + endDate?: string; + status?: WithdrawalRequest['status']; + limit?: number; + offset?: number; +} From 6d6fe4c255f85ba051e20a20d81ffdb8c55d10b2 Mon Sep 17 00:00:00 2001 From: davedumto Date: Sat, 31 Jan 2026 10:19:01 +0100 Subject: [PATCH 2/4] feat(compliance): implement KYC tiers, withdrawal limits, and terms acceptance Add comprehensive compliance system with KYC verification tiers, withdrawal limit enforcement, and terms acceptance flow. Features: - KYC tier system (Bronze/Silver/Gold/Platinum) with configurable limits - Real-time withdrawal validation with limit checks - Terms acceptance dialog with scroll-to-read enforcement - Hold states and geo-restriction handling - Document upload flow for tier upgrades - Mobile-responsive wallet page with centered layout - Appeal system for rejected verifications API Routes: - /api/compliance/status - Get user compliance and limits - /api/compliance/terms - Get/accept terms and conditions - /api/compliance/upgrade - Request tier upgrade - /api/withdrawal/validate - Validate withdrawal amounts - /api/withdrawal/submit - Submit withdrawal requests Components: - TierUpgradeDialog - Multi-step tier upgrade with document upload - TermsDialog - Scrollable terms with acceptance tracking - LimitsDisplay - Real-time limit usage visualization - HoldMessage - Alert for account holds --- components/ui/checkbox.tsx | 2 +- components/wallet/withdrawal-section.tsx | 131 +++++++-- hooks/use-compliance.ts | 42 +++ hooks/use-withdrawal.ts | 22 ++ lib/query/query-keys.ts | 15 + lib/services/appeal.ts | 44 +++ lib/services/compliance.ts | 350 +++++++++++++++++++++++ lib/services/email.ts | 145 ++++++++++ lib/services/geo-restriction.ts | 104 +++++++ 9 files changed, 824 insertions(+), 31 deletions(-) create mode 100644 hooks/use-compliance.ts create mode 100644 hooks/use-withdrawal.ts create mode 100644 lib/services/appeal.ts create mode 100644 lib/services/compliance.ts create mode 100644 lib/services/email.ts create mode 100644 lib/services/geo-restriction.ts diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx index cb0b07b..05cf97e 100644 --- a/components/ui/checkbox.tsx +++ b/components/ui/checkbox.tsx @@ -14,7 +14,7 @@ function Checkbox({ (null); + + const { data: complianceData } = useComplianceStatus(); + const validateMutation = useValidateWithdrawal(); + const submitMutation = useSubmitWithdrawal(); - // Mock bank accounts const bankAccounts = [ { id: '1', name: 'Chase Bank', last4: '4242', isPrimary: true }, ]; const parsedAmount = parseFloat(amount); - const isValidAmount = !isNaN(parsedAmount) && - isFinite(parsedAmount) && - parsedAmount >= 10 && - parsedAmount <= walletInfo.balance; + const isValidAmount = !isNaN(parsedAmount) && isFinite(parsedAmount) && parsedAmount >= 10; + + useEffect(() => { + if (isValidAmount && parsedAmount <= walletInfo.balance) { + validateMutation.mutate(parsedAmount, { + onSuccess: (result) => { + if (!result.valid) { + setValidationError(result.errors[0] || 'Validation failed'); + } else { + setValidationError(null); + } + }, + }); + } else { + setValidationError(null); + } + }, [parsedAmount]); + + const handleWithdraw = async () => { + if (!isValidAmount || !complianceData) return; + + try { + await submitMutation.mutateAsync({ + amount: parsedAmount, + currency: 'USD', + destinationId: bankAccounts[0].id, + }); + alert('Withdrawal submitted successfully!'); + setAmount(''); + } catch (error: any) { + alert(error.message || 'Withdrawal failed'); + } + }; const formatCurrency = (amount: number) => { return new Intl.NumberFormat("en-US", { @@ -34,8 +76,40 @@ export function WithdrawalSection({ walletInfo }: WithdrawalSectionProps) { }).format(amount); }; + const canWithdraw = isValidAmount && + parsedAmount <= walletInfo.balance && + !validationError && + complianceData?.compliance.holdState === 'NONE' && + !complianceData?.termsStatus.requiresAcceptance; + return (
+ {complianceData && ( + <> + + + { + setShowTermsDialog(false); + }} + /> + + {complianceData.nextTier && ( + + )} + + )} +

Off-Ramp to Fiat

@@ -68,6 +142,12 @@ export function WithdrawalSection({ walletInfo }: WithdrawalSectionProps) { Balance: {formatCurrency(walletInfo.balance)} Min: $10.00
+ {validationError && ( + + + {validationError} + + )}
@@ -110,16 +190,22 @@ export function WithdrawalSection({ walletInfo }: WithdrawalSectionProps) {
+ {complianceData?.termsStatus.requiresAcceptance && ( + + )} + @@ -148,22 +234,7 @@ export function WithdrawalSection({ walletInfo }: WithdrawalSectionProps) {

Limits & Settings

-
-
- Daily Limit - $5,000 / $5,000 -
-
-
-
-
- Monthly Limit - $50,000 / $50,000 -
-
-
-
-
+ setShowUpgradeDialog(true)} /> diff --git a/hooks/use-compliance.ts b/hooks/use-compliance.ts new file mode 100644 index 0000000..7163ee9 --- /dev/null +++ b/hooks/use-compliance.ts @@ -0,0 +1,42 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { get, post } from '@/lib/api/client'; +import { UserCompliance, RemainingLimits, KYCTier, VerificationRequest } from '@/types/compliance'; +import { UserTermsStatus } from '@/types/terms'; + +interface ComplianceStatus { + compliance: UserCompliance; + remaining: RemainingLimits; + termsStatus: UserTermsStatus; + nextTier: KYCTier | null; +} + +export function useComplianceStatus() { + return useQuery({ + queryKey: ['compliance', 'status'], + queryFn: () => get('/api/compliance/status'), + }); +} + +export function useAcceptTerms() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (termsVersionId: string) => + post('/api/compliance/terms', { termsVersionId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['compliance'] }); + }, + }); +} + +export function useUpgradeTier() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (targetTier: KYCTier) => + post('/api/compliance/upgrade', { targetTier }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['compliance'] }); + }, + }); +} diff --git a/hooks/use-withdrawal.ts b/hooks/use-withdrawal.ts new file mode 100644 index 0000000..970694c --- /dev/null +++ b/hooks/use-withdrawal.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { post } from '@/lib/api/client'; +import { WithdrawalValidationResult, WithdrawalRequest } from '@/types/withdrawal'; + +export function useValidateWithdrawal() { + return useMutation({ + mutationFn: (amount: number) => + post('/api/withdrawal/validate', { amount }), + }); +} + +export function useSubmitWithdrawal() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: { amount: number; currency: string; destinationId: string }) => + post('/api/withdrawal/submit', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['compliance'] }); + }, + }); +} diff --git a/lib/query/query-keys.ts b/lib/query/query-keys.ts index d4956fd..6343804 100644 --- a/lib/query/query-keys.ts +++ b/lib/query/query-keys.ts @@ -36,3 +36,18 @@ export const authKeys = { all: ['auth'] as const, session: () => [...authKeys.all, 'session'] as const, }; + +export const complianceKeys = { + all: ['compliance'] as const, + status: () => [...complianceKeys.all, 'status'] as const, +}; + +export const termsKeys = { + all: ['terms'] as const, + current: () => [...termsKeys.all, 'current'] as const, +}; + +export const withdrawalKeys = { + all: ['withdrawal'] as const, + history: () => [...withdrawalKeys.all, 'history'] as const, +}; diff --git a/lib/services/appeal.ts b/lib/services/appeal.ts new file mode 100644 index 0000000..272bd49 --- /dev/null +++ b/lib/services/appeal.ts @@ -0,0 +1,44 @@ +import { VerificationAppeal, AppealStatus } from "@/types/compliance"; +import { EmailService } from "./email"; + +const MOCK_APPEALS: Record = {}; + +export class AppealService { + static async submitAppeal( + userId: string, + verificationRequestId: string, + reason: string, + additionalInfo?: string + ): Promise { + const appealId = `appeal-${userId}-${Date.now()}`; + const appeal: VerificationAppeal = { + id: appealId, + userId, + verificationRequestId, + reason, + additionalInfo, + status: 'PENDING', + submittedAt: new Date().toISOString(), + }; + + MOCK_APPEALS[appealId] = appeal; + + const userEmail = `user-${userId}@example.com`; + await EmailService.sendAppealConfirmation(userEmail, appealId); + + return { ...appeal }; + } + + static async getAppeal(verificationRequestId: string): Promise { + const appeal = Object.values(MOCK_APPEALS) + .find(a => a.verificationRequestId === verificationRequestId); + + return appeal ? { ...appeal } : null; + } + + static async getUserAppeals(userId: string): Promise { + return Object.values(MOCK_APPEALS) + .filter(a => a.userId === userId) + .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()); + } +} diff --git a/lib/services/compliance.ts b/lib/services/compliance.ts new file mode 100644 index 0000000..07437bd --- /dev/null +++ b/lib/services/compliance.ts @@ -0,0 +1,350 @@ +import { + UserCompliance, + KYCTier, + KYCTierConfig, + WithdrawalLimits, + WithdrawalUsage, + RemainingLimits, + ComplianceHoldState, + VerificationStatus, +} from "@/types/compliance"; + +// Mock Data Store (In-memory for prototype) +const MOCK_COMPLIANCE_DB: Record = {}; + +export class ComplianceService { + // KYC Tier Configuration + static readonly TIER_CONFIGS: Record = { + UNVERIFIED: { + tier: 'UNVERIFIED', + description: 'Basic account access with limited withdrawal capabilities', + limits: { + daily: 100, + weekly: 300, + monthly: 500, + perTransaction: 50, + }, + requirements: [ + 'Email verification required', + 'No additional documentation needed', + ], + processingTime: 'Instant', + }, + BASIC: { + tier: 'BASIC', + description: 'Standard account with moderate withdrawal limits', + limits: { + daily: 1000, + weekly: 5000, + monthly: 15000, + perTransaction: 500, + }, + requirements: [ + 'Email verification', + 'Phone number verification', + 'Basic personal information (name, date of birth)', + ], + processingTime: '1-2 business days', + }, + VERIFIED: { + tier: 'VERIFIED', + description: 'Verified account with higher withdrawal limits', + limits: { + daily: 5000, + weekly: 25000, + monthly: 75000, + perTransaction: 2500, + }, + requirements: [ + 'All BASIC requirements', + 'Government-issued ID (passport, driver\'s license)', + 'Proof of address (utility bill, bank statement)', + ], + processingTime: '2-5 business days', + }, + ENHANCED: { + tier: 'ENHANCED', + description: 'Premium account with maximum withdrawal limits', + limits: { + daily: 25000, + weekly: 100000, + monthly: 300000, + perTransaction: 10000, + }, + requirements: [ + 'All VERIFIED requirements', + 'Enhanced identity verification', + 'Video verification call', + 'Source of funds documentation', + ], + processingTime: '5-10 business days', + }, + }; + + /** + * Get user's current compliance status + */ + static async getUserCompliance(userId: string): Promise { + await this.simulateDelay(); + + // Return existing or create new + if (!MOCK_COMPLIANCE_DB[userId]) { + MOCK_COMPLIANCE_DB[userId] = this.createDefaultCompliance(userId); + } + + return { ...MOCK_COMPLIANCE_DB[userId] }; + } + + /** + * Create default compliance record for new user + */ + private static createDefaultCompliance(userId: string): UserCompliance { + const now = new Date().toISOString(); + const tier: KYCTier = 'UNVERIFIED'; + + return { + userId, + currentTier: tier, + limits: { ...this.TIER_CONFIGS[tier].limits }, + usage: { + dailyUsed: 0, + weeklyUsed: 0, + monthlyUsed: 0, + lifetimeTotal: 0, + lastResetDaily: now, + lastResetWeekly: now, + lastResetMonthly: now, + }, + holdState: 'NONE', + verificationStatus: 'NOT_STARTED', + restrictedJurisdiction: false, + createdAt: now, + updatedAt: now, + }; + } + + /** + * Calculate remaining withdrawal amounts with rolling windows + */ + static async getRemainingLimits(userId: string): Promise { + const compliance = await this.getUserCompliance(userId); + + // Reset usage if windows have passed + await this.resetExpiredWindows(userId, compliance); + + const { limits, usage } = compliance; + + const dailyRemaining = Math.max(0, limits.daily - usage.dailyUsed); + const weeklyRemaining = Math.max(0, limits.weekly - usage.weeklyUsed); + const monthlyRemaining = Math.max(0, limits.monthly - usage.monthlyUsed); + + return { + daily: dailyRemaining, + weekly: weeklyRemaining, + monthly: monthlyRemaining, + percentUsed: { + daily: limits.daily > 0 ? Math.min(100, (usage.dailyUsed / limits.daily) * 100) : 0, + weekly: limits.weekly > 0 ? Math.min(100, (usage.weeklyUsed / limits.weekly) * 100) : 0, + monthly: limits.monthly > 0 ? Math.min(100, (usage.monthlyUsed / limits.monthly) * 100) : 0, + }, + }; + } + + /** + * Reset usage for expired rolling windows + */ + private static async resetExpiredWindows( + userId: string, + compliance: UserCompliance + ): Promise { + const now = new Date(); + const { usage } = compliance; + let updated = false; + + // Daily reset (24 hours) + const lastDaily = new Date(usage.lastResetDaily); + if (now.getTime() - lastDaily.getTime() >= 24 * 60 * 60 * 1000) { + compliance.usage.dailyUsed = 0; + compliance.usage.lastResetDaily = now.toISOString(); + updated = true; + } + + // Weekly reset (7 days) + const lastWeekly = new Date(usage.lastResetWeekly); + if (now.getTime() - lastWeekly.getTime() >= 7 * 24 * 60 * 60 * 1000) { + compliance.usage.weeklyUsed = 0; + compliance.usage.lastResetWeekly = now.toISOString(); + updated = true; + } + + // Monthly reset (30 days) + const lastMonthly = new Date(usage.lastResetMonthly); + if (now.getTime() - lastMonthly.getTime() >= 30 * 24 * 60 * 60 * 1000) { + compliance.usage.monthlyUsed = 0; + compliance.usage.lastResetMonthly = now.toISOString(); + updated = true; + } + + if (updated) { + compliance.updatedAt = now.toISOString(); + MOCK_COMPLIANCE_DB[userId] = compliance; + } + } + + /** + * Check if withdrawal would exceed any limits + */ + static async validateWithdrawalAmount( + userId: string, + amount: number + ): Promise<{ valid: boolean; exceededLimit?: 'daily' | 'weekly' | 'monthly' | 'perTransaction' }> { + const compliance = await this.getUserCompliance(userId); + await this.resetExpiredWindows(userId, compliance); + + const { limits, usage } = compliance; + + // Check per-transaction limit + if (amount > limits.perTransaction) { + return { valid: false, exceededLimit: 'perTransaction' }; + } + + // Check daily limit + if (usage.dailyUsed + amount > limits.daily) { + return { valid: false, exceededLimit: 'daily' }; + } + + // Check weekly limit + if (usage.weeklyUsed + amount > limits.weekly) { + return { valid: false, exceededLimit: 'weekly' }; + } + + // Check monthly limit + if (usage.monthlyUsed + amount > limits.monthly) { + return { valid: false, exceededLimit: 'monthly' }; + } + + return { valid: true }; + } + + /** + * Track withdrawal usage (call after successful withdrawal) + */ + static async trackWithdrawal(userId: string, amount: number): Promise { + const compliance = await this.getUserCompliance(userId); + + compliance.usage.dailyUsed += amount; + compliance.usage.weeklyUsed += amount; + compliance.usage.monthlyUsed += amount; + compliance.usage.lifetimeTotal += amount; + compliance.updatedAt = new Date().toISOString(); + + MOCK_COMPLIANCE_DB[userId] = compliance; + } + + /** + * Upgrade user tier after verification approval + */ + static async upgradeTier(userId: string, newTier: KYCTier): Promise { + const compliance = await this.getUserCompliance(userId); + + // Update tier and limits + compliance.currentTier = newTier; + compliance.limits = { ...this.TIER_CONFIGS[newTier].limits }; + compliance.verificationStatus = 'APPROVED'; + compliance.updatedAt = new Date().toISOString(); + + MOCK_COMPLIANCE_DB[userId] = compliance; + return true; + } + + /** + * Set hold state on user account + */ + static async setHoldState( + userId: string, + state: ComplianceHoldState, + reason?: string + ): Promise { + const compliance = await this.getUserCompliance(userId); + + compliance.holdState = state; + compliance.holdReason = reason; + compliance.updatedAt = new Date().toISOString(); + + MOCK_COMPLIANCE_DB[userId] = compliance; + } + + /** + * Update verification status + */ + static async updateVerificationStatus( + userId: string, + status: VerificationStatus + ): Promise { + const compliance = await this.getUserCompliance(userId); + + compliance.verificationStatus = status; + compliance.updatedAt = new Date().toISOString(); + + MOCK_COMPLIANCE_DB[userId] = compliance; + } + + /** + * Mark user as restricted jurisdiction + */ + static async setJurisdictionRestriction( + userId: string, + restricted: boolean, + jurisdictionCode?: string + ): Promise { + const compliance = await this.getUserCompliance(userId); + + compliance.restrictedJurisdiction = restricted; + compliance.jurisdictionCode = jurisdictionCode; + compliance.updatedAt = new Date().toISOString(); + + MOCK_COMPLIANCE_DB[userId] = compliance; + } + + /** + * Get tier configuration + */ + static getTierConfig(tier: KYCTier): KYCTierConfig { + return { ...this.TIER_CONFIGS[tier] }; + } + + /** + * Get all tier configurations + */ + static getAllTierConfigs(): KYCTierConfig[] { + return Object.values(this.TIER_CONFIGS); + } + + /** + * Get next tier + */ + static getNextTier(currentTier: KYCTier): KYCTier | null { + const tiers: KYCTier[] = ['UNVERIFIED', 'BASIC', 'VERIFIED', 'ENHANCED']; + const currentIndex = tiers.indexOf(currentTier); + + if (currentIndex === -1 || currentIndex === tiers.length - 1) { + return null; + } + + return tiers[currentIndex + 1]; + } + + /** + * Simulate database delay + */ + private static async simulateDelay(): Promise { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + /** + * Clear all compliance data (for testing) + */ + static clearAllData(): void { + Object.keys(MOCK_COMPLIANCE_DB).forEach(key => delete MOCK_COMPLIANCE_DB[key]); + } +} diff --git a/lib/services/email.ts b/lib/services/email.ts new file mode 100644 index 0000000..3ad26a7 --- /dev/null +++ b/lib/services/email.ts @@ -0,0 +1,145 @@ +import { VerificationStatus } from "@/types/compliance"; + +interface EmailData { + to: string; + subject: string; + body: string; +} + +export class EmailService { + static async sendVerificationStatusEmail( + userEmail: string, + status: VerificationStatus, + tier?: string, + reason?: string + ): Promise { + const emailData = this.buildVerificationEmail(userEmail, status, tier, reason); + await this.send(emailData); + } + + static async sendWithdrawalConfirmation( + userEmail: string, + amount: number, + currency: string, + withdrawalId: string + ): Promise { + const emailData: EmailData = { + to: userEmail, + subject: "Withdrawal Confirmation", + body: ` +

Withdrawal Submitted

+

Your withdrawal of ${amount} ${currency} has been submitted successfully.

+

Withdrawal ID: ${withdrawalId}

+

Processing typically takes 1-5 business days.

+ `, + }; + await this.send(emailData); + } + + static async sendTermsUpdateNotification( + userEmail: string, + version: string + ): Promise { + const emailData: EmailData = { + to: userEmail, + subject: "Terms and Conditions Updated", + body: ` +

Terms and Conditions Update

+

Our Terms and Conditions have been updated to version ${version}.

+

Please review and accept the new terms before your next withdrawal.

+

Review Terms

+ `, + }; + await this.send(emailData); + } + + static async sendAppealConfirmation( + userEmail: string, + appealId: string + ): Promise { + const emailData: EmailData = { + to: userEmail, + subject: "Verification Appeal Received", + body: ` +

Appeal Received

+

Your verification appeal has been received and is under review.

+

Appeal ID: ${appealId}

+

Our team will review your appeal within 3-5 business days.

+ `, + }; + await this.send(emailData); + } + + private static buildVerificationEmail( + userEmail: string, + status: VerificationStatus, + tier?: string, + reason?: string + ): EmailData { + const templates: Record = { + PENDING: { + subject: "Verification Request Received", + body: ` +

Verification Request Received

+

We've received your request to upgrade to ${tier} tier.

+

Our team will review your documents within 2-5 business days.

+

You'll receive an email once the review is complete.

+ `, + }, + APPROVED: { + subject: "Verification Approved!", + body: ` +

Congratulations!

+

Your verification for ${tier} tier has been approved.

+

Your new withdrawal limits are now active.

+

View Your Limits

+ `, + }, + REJECTED: { + subject: "Verification Update Required", + body: ` +

Additional Information Needed

+

We couldn't complete your verification for ${tier} tier.

+ ${reason ? `

Reason: ${reason}

` : ''} +

You can submit an appeal or resubmit your documents.

+

Take Action

+ `, + }, + NOT_STARTED: { + subject: "Start Your Verification", + body: ` +

Increase Your Withdrawal Limits

+

Complete verification to increase your withdrawal limits.

+

Get Started

+ `, + }, + }; + + const template = templates[status]; + return { + to: userEmail, + subject: template.subject, + body: template.body, + }; + } + + private static async send(emailData: EmailData): Promise { + // In production, integrate with email provider: + // - SendGrid: await sgMail.send(emailData) + // - AWS SES: await ses.sendEmail(emailData) + // - Resend: await resend.emails.send(emailData) + + console.log('[Email Service] Sending email:', { + to: emailData.to, + subject: emailData.subject, + }); + + // Mock delay + await new Promise(resolve => setTimeout(resolve, 100)); + + // For development, log the email + if (process.env.NODE_ENV === 'development') { + console.log('[Email Preview]:', emailData.body); + } + } +} diff --git a/lib/services/geo-restriction.ts b/lib/services/geo-restriction.ts new file mode 100644 index 0000000..19ecd01 --- /dev/null +++ b/lib/services/geo-restriction.ts @@ -0,0 +1,104 @@ +import { RestrictedJurisdiction, UserLocation } from "@/types/geography"; + +const RESTRICTED: RestrictedJurisdiction[] = [ + { code: 'CU', name: 'Cuba', type: 'COUNTRY', reason: 'OFAC sanctions', effectiveDate: '2024-01-01' }, + { code: 'IR', name: 'Iran', type: 'COUNTRY', reason: 'OFAC sanctions', effectiveDate: '2024-01-01' }, + { code: 'KP', name: 'North Korea', type: 'COUNTRY', reason: 'OFAC sanctions', effectiveDate: '2024-01-01' }, + { code: 'SY', name: 'Syria', type: 'COUNTRY', reason: 'OFAC sanctions', effectiveDate: '2024-01-01' }, + { code: 'US-NY', name: 'New York', type: 'STATE', reason: 'BitLicense requirements', effectiveDate: '2024-01-01' }, +]; + +const VPN_INDICATORS = [ + 'vpn', 'proxy', 'tor', 'relay', 'hosting', 'datacenter' +]; + +export class GeoRestrictionService { + static async checkLocation(ip: string): Promise { + // In production: call ipapi.co, MaxMind, or ip-api.com + // Example: const response = await fetch(`https://ipapi.co/${ip}/json/`) + + const mockLocation: UserLocation = { + ip, + countryCode: 'US', + countryName: 'United States', + regionCode: 'CA', + regionName: 'California', + city: 'San Francisco', + isVPN: await this.detectVPN(ip), + isProxy: await this.detectProxy(ip), + isRestricted: false, + }; + + // Check country-level restrictions + const countryRestriction = RESTRICTED.find(r => r.code === mockLocation.countryCode && r.type === 'COUNTRY'); + if (countryRestriction) { + mockLocation.isRestricted = true; + mockLocation.restrictionReason = countryRestriction.reason; + } + + // Check state-level restrictions + if (mockLocation.countryCode === 'US' && mockLocation.regionCode) { + const stateCode = `US-${mockLocation.regionCode}`; + const stateRestriction = RESTRICTED.find(r => r.code === stateCode && r.type === 'STATE'); + if (stateRestriction) { + mockLocation.isRestricted = true; + mockLocation.restrictionReason = stateRestriction.reason; + } + } + + return mockLocation; + } + + static async detectVPN(ip: string): Promise { + // In production: use VPN detection API like: + // - IPHub: https://iphub.info/ + // - IP2Proxy: https://www.ip2location.com/ + // - VPNApi: https://vpnapi.io/ + + // Mock: check if IP is in common VPN ranges + const vpnRanges = ['10.', '172.16.', '192.168.']; + return vpnRanges.some(range => ip.startsWith(range)); + } + + static async detectProxy(ip: string): Promise { + // In production: check proxy databases or use APIs + // For now, use similar logic as VPN detection + return false; + } + + static isRestricted(countryCode: string, regionCode?: string): boolean { + // Check country restriction + if (RESTRICTED.some(r => r.code === countryCode && r.type === 'COUNTRY')) { + return true; + } + + // Check state restriction + if (regionCode) { + const stateCode = `${countryCode}-${regionCode}`; + return RESTRICTED.some(r => r.code === stateCode && r.type === 'STATE'); + } + + return false; + } + + static getRestrictionReason(countryCode: string, regionCode?: string): string | null { + const countryRestriction = RESTRICTED.find(r => r.code === countryCode && r.type === 'COUNTRY'); + if (countryRestriction) { + return countryRestriction.reason; + } + + if (regionCode) { + const stateCode = `${countryCode}-${regionCode}`; + const stateRestriction = RESTRICTED.find(r => r.code === stateCode && r.type === 'STATE'); + if (stateRestriction) { + return stateRestriction.reason; + } + } + + return null; + } + + static getRestrictedJurisdictions(): RestrictedJurisdiction[] { + return [...RESTRICTED]; + } +} From 45d4199461bd2306c2357433dade389cc8d7c667 Mon Sep 17 00:00:00 2001 From: davedumto Date: Sat, 31 Jan 2026 10:19:18 +0100 Subject: [PATCH 3/4] feat(compliance): implement KYC tiers, withdrawal limits, and terms acceptance Add comprehensive compliance system with KYC verification tiers, withdrawal limit enforcement, and terms acceptance flow. Features: - KYC tier system (Bronze/Silver/Gold/Platinum) with configurable limits - Real-time withdrawal validation with limit checks - Terms acceptance dialog with scroll-to-read enforcement - Hold states and geo-restriction handling - Document upload flow for tier upgrades - Mobile-responsive wallet page with centered layout - Appeal system for rejected verifications API Routes: - /api/compliance/status - Get user compliance and limits - /api/compliance/terms - Get/accept terms and conditions - /api/compliance/upgrade - Request tier upgrade - /api/withdrawal/validate - Validate withdrawal amounts - /api/withdrawal/submit - Submit withdrawal requests Components: - TierUpgradeDialog - Multi-step tier upgrade with document upload - TermsDialog - Scrollable terms with acceptance tracking - LimitsDisplay - Real-time limit usage visualization - HoldMessage - Alert for account holds --- components/compliance/hold-message.tsx | 48 ++++++ components/compliance/limits-display.tsx | 101 +++++++++++ components/compliance/terms-dialog.tsx | 88 ++++++++++ components/compliance/tier-upgrade-dialog.tsx | 160 ++++++++++++++++++ 4 files changed, 397 insertions(+) create mode 100644 components/compliance/hold-message.tsx create mode 100644 components/compliance/limits-display.tsx create mode 100644 components/compliance/terms-dialog.tsx create mode 100644 components/compliance/tier-upgrade-dialog.tsx diff --git a/components/compliance/hold-message.tsx b/components/compliance/hold-message.tsx new file mode 100644 index 0000000..eb1a367 --- /dev/null +++ b/components/compliance/hold-message.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertCircle, Ban, Shield } from "lucide-react"; +import { ComplianceHoldState } from "@/types/compliance"; + +interface HoldMessageProps { + holdState: ComplianceHoldState; + reason?: string; +} + +export function HoldMessage({ holdState, reason }: HoldMessageProps) { + if (holdState === 'NONE') return null; + + const config = { + REVIEW: { + icon: Shield, + variant: 'default' as const, + title: 'Account Under Review', + description: 'Your account is currently under review. Withdrawals are temporarily paused.', + }, + SUSPENDED: { + icon: AlertCircle, + variant: 'destructive' as const, + title: 'Account Suspended', + description: 'Your account has been suspended. Please contact support for more information.', + }, + BLOCKED: { + icon: Ban, + variant: 'destructive' as const, + title: 'Account Blocked', + description: 'Withdrawals are blocked on your account. Please contact support immediately.', + }, + }; + + const { icon: Icon, variant, title, description } = config[holdState]; + + return ( + + + {title} + + {description} + {reason &&
Reason: {reason}
} +
+
+ ); +} diff --git a/components/compliance/limits-display.tsx b/components/compliance/limits-display.tsx new file mode 100644 index 0000000..d93fc64 --- /dev/null +++ b/components/compliance/limits-display.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ArrowUp } from "lucide-react"; +import { KYCTier } from "@/types/compliance"; +import { useComplianceStatus } from "@/hooks/use-compliance"; + +interface LimitsDisplayProps { + onUpgradeClick?: () => void; +} + +const tierColors: Record = { + UNVERIFIED: 'bg-gray-500', + BASIC: 'bg-blue-500', + VERIFIED: 'bg-green-500', + ENHANCED: 'bg-purple-500', +}; + +const formatCurrency = (amount: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount); + +export function LimitsDisplay({ onUpgradeClick }: LimitsDisplayProps) { + const { data, isLoading } = useComplianceStatus(); + + if (isLoading) { + return
Loading limits...
; + } + + if (!data) return null; + + const { compliance, remaining, nextTier } = data; + + return ( +
+
+
+ Verification Tier: + + {compliance.currentTier} + +
+ {nextTier && onUpgradeClick && ( + + )} +
+ +
+ + + +
+
+ ); +} + +function LimitBar({ label, used, total, remaining, percent }: { + label: string; + used: number; + total: number; + remaining: number; + percent: number; +}) { + return ( +
+
+ {label} + + {formatCurrency(remaining)} / {formatCurrency(total)} + +
+
+
+
+
+ ); +} diff --git a/components/compliance/terms-dialog.tsx b/components/compliance/terms-dialog.tsx new file mode 100644 index 0000000..f8823d7 --- /dev/null +++ b/components/compliance/terms-dialog.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useQuery } from "@tanstack/react-query"; +import { get } from "@/lib/api/client"; +import { TermsVersion } from "@/types/terms"; +import { useAcceptTerms } from "@/hooks/use-compliance"; + +interface TermsDialogProps { + open: boolean; + onOpenChange?: (open: boolean) => void; + onAccepted: () => void; +} + +export function TermsDialog({ open, onOpenChange, onAccepted }: TermsDialogProps) { + const [agreed, setAgreed] = useState(false); + const [scrolled, setScrolled] = useState(false); + + const { data: terms } = useQuery({ + queryKey: ['terms'], + queryFn: () => get('/api/compliance/terms'), + }); + + const acceptMutation = useAcceptTerms(); + + const handleAccept = async () => { + if (!terms || !agreed) return; + + try { + await acceptMutation.mutateAsync(terms.id); + onAccepted(); + } catch (error) { + console.error('Failed to accept terms:', error); + } + }; + + const handleScroll = (e: React.UIEvent) => { + const target = e.target as HTMLDivElement; + const bottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 50; + if (bottom) setScrolled(true); + }; + + return ( + + + + {terms?.title || 'Terms and Conditions'} + + Please review and accept the terms to continue with withdrawals. + + + +
+ {terms?.content.split('\n').map((line, i) => ( +

{line}

+ ))} +
+ +
+ setAgreed(checked as boolean)} + disabled={!scrolled} + /> + +
+ + + + +
+
+ ); +} diff --git a/components/compliance/tier-upgrade-dialog.tsx b/components/compliance/tier-upgrade-dialog.tsx new file mode 100644 index 0000000..3800a29 --- /dev/null +++ b/components/compliance/tier-upgrade-dialog.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState } from "react"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Check } from "lucide-react"; +import { KYCTier, DocumentType } from "@/types/compliance"; +import { ComplianceService } from "@/lib/services/compliance"; +import { VerificationService } from "@/lib/services/verification"; +import { useUpgradeTier } from "@/hooks/use-compliance"; +import { DocumentUpload } from "./document-upload"; + +interface TierUpgradeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + currentTier: KYCTier; + targetTier: KYCTier; +} + +const formatCurrency = (amount: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(amount); + +export function TierUpgradeDialog({ open, onOpenChange, currentTier, targetTier }: TierUpgradeDialogProps) { + const [step, setStep] = useState<'info' | 'documents'>('info'); + const [requestId, setRequestId] = useState(null); + const [uploadedDocs, setUploadedDocs] = useState>(new Set()); + const upgradeMutation = useUpgradeTier(); + const tierConfig = ComplianceService.getTierConfig(targetTier); + const requiredDocs = VerificationService.getRequiredDocuments(targetTier); + + const handleUpgrade = async () => { + try { + const request = await upgradeMutation.mutateAsync(targetTier); + setRequestId(request.id); + if (requiredDocs.length > 0) { + setStep('documents'); + } else { + onOpenChange(false); + } + } catch (error: any) { + alert(error.message || 'Failed to request upgrade'); + } + }; + + const handleDocumentUpload = async (type: DocumentType, file: File) => { + if (!requestId) return; + + await VerificationService.uploadDocument(requestId, { + type, + fileName: file.name, + file, + }); + + setUploadedDocs(prev => new Set(prev).add(type)); + }; + + const handleComplete = () => { + setStep('info'); + setUploadedDocs(new Set()); + setRequestId(null); + onOpenChange(false); + }; + + return ( + + + + Upgrade to {targetTier} Tier + + {step === 'info' + ? 'Increase your withdrawal limits by upgrading your verification tier.' + : 'Upload required documents to complete your verification.'} + + + + {step === 'info' ? ( +
+
+

New Limits

+
+
+ Daily: + {formatCurrency(tierConfig.limits.daily)} +
+
+ Weekly: + {formatCurrency(tierConfig.limits.weekly)} +
+
+ Monthly: + {formatCurrency(tierConfig.limits.monthly)} +
+
+ Per Transaction: + {formatCurrency(tierConfig.limits.perTransaction)} +
+
+
+ +
+

Requirements

+
    + {tierConfig.requirements.map((req, i) => ( +
  • + + {req} +
  • + ))} +
+
+ +
+ Processing time: {tierConfig.processingTime} +
+
+ ) : ( +
+ {requiredDocs.map((docType) => ( + handleDocumentUpload(docType, file)} + uploaded={uploadedDocs.has(docType)} + /> + ))} +
+ )} + + + {step === 'info' ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ ); +} From 051f6390aa38a273881c47b27412844ee3a2df5e Mon Sep 17 00:00:00 2001 From: davedumto Date: Sat, 31 Jan 2026 10:19:31 +0100 Subject: [PATCH 4/4] feat(compliance): implement KYC tiers, withdrawal limits, and terms acceptance Add comprehensive compliance system with KYC verification tiers, withdrawal limit enforcement, and terms acceptance flow. Features: - KYC tier system (Bronze/Silver/Gold/Platinum) with configurable limits - Real-time withdrawal validation with limit checks - Terms acceptance dialog with scroll-to-read enforcement - Hold states and geo-restriction handling - Document upload flow for tier upgrades - Mobile-responsive wallet page with centered layout - Appeal system for rejected verifications API Routes: - /api/compliance/status - Get user compliance and limits - /api/compliance/terms - Get/accept terms and conditions - /api/compliance/upgrade - Request tier upgrade - /api/withdrawal/validate - Validate withdrawal amounts - /api/withdrawal/submit - Submit withdrawal requests Components: - TierUpgradeDialog - Multi-step tier upgrade with document upload - TermsDialog - Scrollable terms with acceptance tracking - LimitsDisplay - Real-time limit usage visualization - HoldMessage - Alert for account holds --- app/api/compliance/status/route.ts | 28 +++++ app/api/compliance/terms/route.ts | 35 ++++++ app/api/compliance/upgrade/route.ts | 41 ++++++ app/api/withdrawal/submit/route.ts | 31 +++++ app/api/withdrawal/validate/route.ts | 22 ++++ app/wallet/page.tsx | 4 +- components/compliance/appeal-dialog.tsx | 96 ++++++++++++++ components/compliance/document-upload.tsx | 112 +++++++++++++++++ package-lock.json | 145 ++++++---------------- 9 files changed, 408 insertions(+), 106 deletions(-) create mode 100644 app/api/compliance/status/route.ts create mode 100644 app/api/compliance/terms/route.ts create mode 100644 app/api/compliance/upgrade/route.ts create mode 100644 app/api/withdrawal/submit/route.ts create mode 100644 app/api/withdrawal/validate/route.ts create mode 100644 components/compliance/appeal-dialog.tsx create mode 100644 components/compliance/document-upload.tsx diff --git a/app/api/compliance/status/route.ts b/app/api/compliance/status/route.ts new file mode 100644 index 0000000..9054ac5 --- /dev/null +++ b/app/api/compliance/status/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/server-auth"; +import { ComplianceService } from "@/lib/services/compliance"; +import { TermsService } from "@/lib/services/terms"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const compliance = await ComplianceService.getUserCompliance(user.id); + const remaining = await ComplianceService.getRemainingLimits(user.id); + const termsStatus = await TermsService.getUserTermsStatus(user.id); + const nextTier = ComplianceService.getNextTier(compliance.currentTier); + + return NextResponse.json({ + compliance, + remaining, + termsStatus, + nextTier, + }); + } catch (error) { + console.error("Error fetching compliance status:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/app/api/compliance/terms/route.ts b/app/api/compliance/terms/route.ts new file mode 100644 index 0000000..22ab0e5 --- /dev/null +++ b/app/api/compliance/terms/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/server-auth"; +import { TermsService } from "@/lib/services/terms"; + +export async function GET(request: NextRequest) { + try { + const terms = await TermsService.getCurrentTermsVersion(); + return NextResponse.json(terms); + } catch (error) { + return NextResponse.json({ error: "Failed to fetch terms" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { termsVersionId } = await request.json(); + const ip = request.headers.get('x-forwarded-for') || '0.0.0.0'; + const userAgent = request.headers.get('user-agent') || 'unknown'; + + const acceptance = await TermsService.acceptTerms(user.id, termsVersionId, { + ipAddress: ip, + userAgent, + }); + + return NextResponse.json(acceptance); + } catch (error) { + console.error("Error accepting terms:", error); + return NextResponse.json({ error: "Failed to accept terms" }, { status: 500 }); + } +} diff --git a/app/api/compliance/upgrade/route.ts b/app/api/compliance/upgrade/route.ts new file mode 100644 index 0000000..c54ad2e --- /dev/null +++ b/app/api/compliance/upgrade/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/server-auth"; +import { VerificationService } from "@/lib/services/verification"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { targetTier } = await request.json(); + + const verificationRequest = await VerificationService.createVerificationRequest( + user.id, + targetTier + ); + + return NextResponse.json(verificationRequest); + } catch (error: any) { + console.error("Error creating verification request:", error); + return NextResponse.json( + { error: error.message || "Failed to create verification request" }, + { status: 400 } + ); + } +} + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const status = await VerificationService.getVerificationStatus(user.id); + return NextResponse.json(status); + } catch (error) { + return NextResponse.json({ error: "Failed to fetch verification status" }, { status: 500 }); + } +} diff --git a/app/api/withdrawal/submit/route.ts b/app/api/withdrawal/submit/route.ts new file mode 100644 index 0000000..ce43753 --- /dev/null +++ b/app/api/withdrawal/submit/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/server-auth"; +import { WithdrawalService } from "@/lib/services/withdrawal"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { amount, currency, destinationId } = await request.json(); + const ip = request.headers.get('x-forwarded-for') || '0.0.0.0'; + + const withdrawal = await WithdrawalService.submit( + user.id, + amount, + currency, + destinationId, + ip + ); + + return NextResponse.json(withdrawal); + } catch (error: any) { + console.error("Error submitting withdrawal:", error); + return NextResponse.json( + { error: error.message || "Withdrawal failed" }, + { status: 400 } + ); + } +} diff --git a/app/api/withdrawal/validate/route.ts b/app/api/withdrawal/validate/route.ts new file mode 100644 index 0000000..ebabf0c --- /dev/null +++ b/app/api/withdrawal/validate/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/server-auth"; +import { WithdrawalService } from "@/lib/services/withdrawal"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { amount } = await request.json(); + const ip = request.headers.get('x-forwarded-for') || '0.0.0.0'; + + const validation = await WithdrawalService.validate(user.id, amount, ip); + + return NextResponse.json(validation); + } catch (error) { + console.error("Error validating withdrawal:", error); + return NextResponse.json({ error: "Validation failed" }, { status: 500 }); + } +} diff --git a/app/wallet/page.tsx b/app/wallet/page.tsx index 128d4b7..565d264 100644 --- a/app/wallet/page.tsx +++ b/app/wallet/page.tsx @@ -12,14 +12,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; export default function WalletPage() { return (
-
+

Wallet

Manage your earnings, assets, and withdrawals.

-
+
diff --git a/components/compliance/appeal-dialog.tsx b/components/compliance/appeal-dialog.tsx new file mode 100644 index 0000000..3cbbcd1 --- /dev/null +++ b/components/compliance/appeal-dialog.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { AppealService } from "@/lib/services/appeal"; + +interface AppealDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + verificationRequestId: string; + userId: string; + rejectionReason?: string; +} + +export function AppealDialog({ open, onOpenChange, verificationRequestId, userId, rejectionReason }: AppealDialogProps) { + const [reason, setReason] = useState(""); + const [additionalInfo, setAdditionalInfo] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + if (!reason.trim()) return; + + setSubmitting(true); + try { + await AppealService.submitAppeal(userId, verificationRequestId, reason, additionalInfo); + alert('Appeal submitted successfully. Our team will review it within 3-5 business days.'); + onOpenChange(false); + setReason(""); + setAdditionalInfo(""); + } catch (error: any) { + alert(error.message || 'Failed to submit appeal'); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + Appeal Verification Decision + + Explain why you believe the rejection should be reconsidered. + + + +
+ {rejectionReason && ( +
+

Original Rejection Reason:

+

{rejectionReason}

+
+ )} + +
+ +