Update from task 3e5226c4-39e1-4ad8-bdff-5098b858fd51#4
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| // ============== CONFIG ==============\n\nconst ORANGE_MONEY_CONFIG = {\n MAROC: {\n baseUrl: process.env.VITE_ORANGE_MONEY_MAROC_URL || 'https://api.orange.com/maroc',\n clientId: process.env.VITE_ORANGE_MONEY_MAROC_CLIENT_ID || '',\n clientSecret: process.env.VITE_ORANGE_MONEY_MAROC_SECRET || '',\n currency: 'MAD',\n }, | ||
| SENEGAL: {\n baseUrl: process.env.VITE_ORANGE_MONEY_SENEGAL_URL || 'https://api.orange.com/senegal',\n clientId: process.env.VITE_ORANGE_MONEY_SENEGAL_CLIENT_ID || '',\n clientSecret: process.env.VITE_ORANGE_MONEY_SENEGAL_SECRET || '',\n currency: 'XOF',\n },\n COTE_DIVOIRE: {\n baseUrl: process.env.VITE_ORANGE_MONEY_CI_URL || 'https://api.orange.com/ci',\n clientId: process.env.VITE_ORANGE_MONEY_CI_CLIENT_ID || '',\n clientSecret: process.env.VITE_ORANGE_MONEY_CI_SECRET || '',\n currency: 'XOF',\n },\n};\n\n// ============== TYPES ==============\n\nexport interface OrangeMoneyPaymentRequest {\n amount: number;\n currency: string;\n phoneNumber: string; // Format: +2126XXXXXXXX ou 06XXXXXXXX\n country: 'MAROC' | 'SENEGAL' | 'COTE_DIVOIRE' | 'MALI' | 'BURKINA';\n orderId: string;\n description?: string;\n returnUrl: string;\n cancelUrl: string;\n webhookUrl: string;\n}\n\nexport interface OrangeMoneyPaymentResponse {\n paymentId: string;\n status: 'pending' | 'completed' | 'failed' | 'cancelled';\n authorizationUrl?: string; // Pour redirection web\n otpRequired?: boolean; // Si OTP requis\n message: string;\n}\n\nexport interface OrangeMoneyWebhookPayload {\n paymentId: string;\n orderId: string;\n status: 'SUCCESS' | 'FAILED' | 'PENDING';\n amount: number;\n currency: string;\n phoneNumber: string;\n timestamp: string;\n signature: string;\n}\n\n// ============== SERVICE ==============\n\nclass OrangeMoneyService {\n private async getAccessToken(country: keyof typeof ORANGE_MONEY_CONFIG): Promise<string> {\n const config = ORANGE_MONEY_CONFIG[country];\n \n if (!config.clientId || !config.clientSecret) {\n throw new Error(`Orange Money ${country} credentials not configured`);\n }\n\n try {\n const response = await axios.post(\n `${config.baseUrl}/oauth/token`,\n { grant_type: 'client_credentials' },\n {\n auth: {\n username: config.clientId,\n password: config.clientSecret,\n },\n headers: {\n 'Content-Type': 'application/json',\n },\n }\n );\n\n return response.data.access_token;\n } catch (error: any) {\n console.error('Orange Money token error:', error.response?.data || error.message);\n throw new Error(`Failed to get Orange Money access token: ${error.message}`);\n }\n }\n\n /** | ||
| * Initier un paiement Mobile Money\n */\n async initiatePayment(request: OrangeMoneyPaymentRequest): Promise<OrangeMoneyPaymentResponse> {\n const { amount, currency, phoneNumber, country, orderId, description, returnUrl, cancelUrl, webhookUrl } = request;\n \n try {\n const token = await this.getAccessToken(country);\n const config = ORANGE_MONEY_CONFIG[country];\n\n // Normaliser le numéro de téléphone\n const normalizedPhone = this.normalizePhoneNumber(phoneNumber, country);\n\n const payload = {\n merchant_key: config.clientId,\n amount: amount.toString(),\n currency: currency,\n order_id: orderId,\n customer_phone: normalizedPhone,\n description: description || `Paiement UNY - Commande ${orderId}`,\n return_url: returnUrl,\n cancel_url: cancelUrl,\n webhook_url: webhookUrl,\n };\n\n const response = await axios.post(\n `${config.baseUrl}/v2/payment/mobile/initiate`,\n payload,\n {\n headers: {\n 'Authorization': `Bearer ${token}`,\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n }\n );\n\n const data = response.data;\n\n return {\n paymentId: data.payment_id || data.reference,\n status: data.status || 'pending',\n authorizationUrl: data.authorization_url,\n otpRequired: data.otp_required || false,\n message: data.message || 'Paiement initié avec succès',\n };\n } catch (error: any) {\n console.error('Orange Money payment error:', error.response?.data || error.message);\n throw new Error(`Orange Money payment failed: ${error.message}`);\n }\n }\n\n /** | ||
| * Vérifier le statut d'un paiement\n */\n async checkPaymentStatus(\n paymentId: string,\n country: keyof typeof ORANGE_MONEY_CONFIG\n ): Promise<{ status: string; amount?: number; timestamp?: string }> {\n try {\n const token = await this.getAccessToken(country);\n const config = ORANGE_MONEY_CONFIG[country];\n\n const response = await axios.get(\n `${config.baseUrl}/v2/payment/status/${paymentId}`,\n {\n headers: {\n 'Authorization': `Bearer ${token}`,\n 'Accept': 'application/json',\n },\n }\n );\n\n const data = response.data;\n\n return {\n status: data.status,\n amount: data.amount ? parseFloat(data.amount) : undefined,\n timestamp: data.timestamp,\n };\n } catch (error: any) {\n console.error('Orange Money status check error:', error.response?.data || error.message);\n throw new Error(`Failed to check payment status: ${error.message}`);\n }\n }\n\n /** |
There was a problem hiding this comment.
CRITICAL: Node.js crypto module usage in frontend code
The require('crypto') call will not work in a browser/Vite frontend environment. This code appears to be frontend-facing due to Vite environment variables (process.env.VITE_*). Webhook signature validation should happen on the backend, not in frontend payment service methods.
| * Initier un paiement Wave | ||
| */\n async initiatePayment(request: WavePaymentRequest): Promise<WavePaymentResponse> {\n const { amount, currency, phoneNumber, country, orderId, description, webhookUrl } = request;\n \n try {\n const config = WAVE_CONFIG[country];\n\n if (!config.apiKey) {\n throw new Error(`Wave ${country} API key not configured`);\n }\n\n // Normaliser le numéro de téléphone\n const normalizedPhone = this.normalizePhoneNumber(phoneNumber, country);\n\n const payload = {\n amount: Math.round(amount), // Wave utilise des entiers (pas de décimales)\n currency: currency,\n reference: orderId,\n description: description || `Paiement UNY - Commande ${orderId}`,\n customer_phone: normalizedPhone,\n webhook_url: webhookUrl,\n };\n\n const response = await axios.post(\n `${config.baseUrl}/payment/request`,\n payload,\n {\n headers: {\n 'Authorization': `Bearer ${config.apiKey}`,\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n }\n );\n\n const data = response.data;\n\n return {\n paymentId: data.id || data.payment_id,\n status: this.mapStatus(data.status),\n qrCodeUrl: data.qr_code_url,\n deepLink: data.deep_link,\n message: 'Paiement Wave initié avec succès',\n };\n } catch (error: any) {\n console.error('Wave payment error:', error.response?.data || error.message);\n throw new Error(`Wave payment failed: ${error.message}`);\n }\n }\n\n /** | ||
| * Créer un QR code de paiement statique\n * Utile pour les paiements en présentiel\n */\n async createStaticQRCode(\n merchantId: string,\n amount?: number,\n country: 'SENEGAL' | 'COTE_DIVOIRE' = 'SENEGAL'\n ): Promise<{ qrCodeUrl: string; qrCodeData: string }> {\n try {\n const config = WAVE_CONFIG[country];\n\n const response = await axios.post(\n `${config.baseUrl}/merchant/${merchantId}/qr`,\n amount ? { amount } : {},\n {\n headers: {\n 'Authorization': `Bearer ${config.apiKey}`,\n 'Content-Type': 'application/json',\n },\n }\n );\n\n return {\n qrCodeUrl: response.data.qr_code_url,\n qrCodeData: response.data.qr_code_data,\n };\n } catch (error: any) {\n console.error('Wave QR code error:', error.response?.data || error.message);\n throw new Error(`Failed to create QR code: ${error.message}`);\n }\n }\n\n /** | ||
| * Vérifier le statut d'un paiement\n */\n async checkPaymentStatus(\n paymentId: string,\n country: 'SENEGAL' | 'COTE_DIVOIRE'\n ): Promise<{ status: string; amount?: number; paidAt?: string }> {\n try {\n const config = WAVE_CONFIG[country];\n\n const response = await axios.get(\n `${config.baseUrl}/payment/${paymentId}`,\n {\n headers: {\n 'Authorization': `Bearer ${config.apiKey}`,\n },\n }\n );\n\n const data = response.data;\n\n return {\n status: this.mapStatus(data.status),\n amount: data.amount,\n paidAt: data.paid_at,\n };\n } catch (error: any) {\n console.error('Wave status check error:', error.response?.data || error.message);\n throw new Error(`Failed to check payment status: ${error.message}`);\n }\n }\n\n /** |
There was a problem hiding this comment.
CRITICAL: Node.js crypto module usage in frontend code
The require('crypto') call will not work in a browser/Vite frontend environment. This code appears to be frontend-facing due to Vite environment variables (process.env.VITE_*). Webhook signature validation should happen on the backend, not in frontend payment service methods.
| * Vérifier le statut d'un paiement STK Push\n */\n async checkSTKPushStatus(\n checkoutRequestID: string,\n country: 'KENYA' | 'TANZANIA'\n ): Promise<{ status: string; amount?: number; mpesaReceiptNumber?: string }> {\n try {\n const token = await this.getAccessToken(country);\n const config = MPESA_CONFIG[country];\n\n // Générer le password\n const timestamp = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14);\n const password = Buffer.from(\n `${config.shortcode}${config.passkey}${timestamp}`\n ).toString('base64');\n\n const payload = {\n BusinessShortCode: config.shortcode,\n Password: password,\n Timestamp: timestamp,\n CheckoutRequestID: checkoutRequestID,\n };\n\n const response = await axios.post(\n `${config.baseUrl}/mpesa/stkpushquery/v1/query`,\n payload,\n {\n headers: {\n 'Authorization': `Bearer ${token}`,\n 'Content-Type': 'application/json',\n },\n }\n );\n\n const data = response.data;\n\n if (data.ResultCode === 0) {\n // Paiement réussi\n const metadata = data.CallbackMetadata?.Item || [];\n const amount = metadata.find((item: any) => item.Name === 'Amount')?.Value;\n const receiptNumber = metadata.find((item: any) => item.Name === 'MpesaReceiptNumber')?.Value;\n\n return {\n status: 'completed',\n amount,\n mpesaReceiptNumber: receiptNumber,\n };\n } else if (data.ResultCode === 1032 || data.ResultCode === 500) {\n // Annulé ou expiré\n return {\n status: 'cancelled',\n };\n } else {\n // Échec\n return {\n status: 'failed',\n };\n }\n } catch (error: any) {\n console.error('M-Pesa status check error:', error.response?.data || error.message);\n throw new Error(`Failed to check M-Pesa status: ${error.message}`);\n }\n }\n\n /** | ||
| * Traiter le callback webhook de M-Pesa\n */\n processWebhookCallback(payload: MPesaWebhookPayload): {\n status: 'success' | 'failed' | 'cancelled';\n amount?: number;\n mpesaReceiptNumber?: string;\n phoneNumber?: string;\n transactionDate?: string;\n } {\n const stkCallback = payload.Body.stkCallback;\n \n if (stkCallback.ResultCode === 0) {\n // Succès\n const metadata = stkCallback.CallbackMetadata?.Item || [];\n \n const amount = metadata.find((item) => item.Name === 'Amount')?.Value;\n const mpesaReceiptNumber = metadata.find((item) => item.Name === 'MpesaReceiptNumber')?.Value;\n const phoneNumber = metadata.find((item) => item.Name === 'PhoneNumber')?.Value;\n const transactionDate = metadata.find((item) => item.Name === 'TransactionDate')?.Value;\n\n return {\n status: 'success',\n amount,\n mpesaReceiptNumber,\n phoneNumber,\n transactionDate,\n };\n } else if (stkCallback.ResultCode === 1032) {\n // Annulé par l'utilisateur\n return {\n status: 'cancelled',\n };\n } else {\n // Échec\n return {\n status: 'failed',\n };\n }\n }\n\n /** | ||
| * Rembourser un paiement (B2C - Business to Customer)\n */\n async refundPayment(\n phoneNumber: string,\n amount: number,\n reason: string,\n country: 'KENYA' | 'TANZANIA'\n ): Promise<{ success: boolean; conversationId?: string; originatorConversationId?: string }> {\n try {\n const token = await this.getAccessToken(country);\n const config = MPESA_CONFIG[country];\n\n const normalizedPhone = this.normalizePhoneNumber(phoneNumber, country);\n\n const payload = {\n InitiatorName: process.env.MPESA_INITIATOR_NAME || 'UNYPlatform',\n SecurityCredential: this.generateSecurityCredential(token), // À implémenter selon la doc M-Pesa\n CommandID: 'BusinessPayment',\n Amount: Math.round(amount),\n PartyA: config.shortcode,\n PartyB: normalizedPhone,\n Remarks: reason || 'Remboursement',\n QueueTimeOutURL: `${process.env.VITE_APP_URL}/api/payments/mpesa/timeout`,\n ResultURL: `${process.env.VITE_APP_URL}/api/payments/mpesa/result`,\n Occasion: 'Refund',\n };\n\n const response = await axios.post(\n `${config.baseUrl}/mpesa/b2c/v1/paymentrequest`,\n payload,\n {\n headers: {\n 'Authorization': `Bearer ${token}`,\n 'Content-Type': 'application/json',\n },\n }\n );\n\n const data = response.data;\n\n return {\n success: data.ResponseCode === '0',\n conversationId: data.ConversationID,\n originatorConversationId: data.OriginatorConversationID,\n };\n } catch (error: any) {\n console.error('M-Pesa refund error:', error.response?.data || error.message);\n return {\n success: false,\n };\n }\n }\n\n /** | ||
| * Générer le Security Credential pour les transactions B2C\n * Nécessite le certificat public de M-Pesa\n */\n private generateSecurityCredential(token: string): string {\n // Cette méthode nécessite le certificat public M-Pesa\n // et une clé privée pour signer la requête\n // Voir: https://developer.mpesa.com/docs/security\n \n // Pour l'instant, retourne un placeholder\n // En production, implémenter le chiffrement RSA avec le certificat M-Pesa\n return Buffer.from('placeholder_credential').toString('base64');\n }\n\n /** |
There was a problem hiding this comment.
WARNING: Placeholder security credential implementation
The generateSecurityCredential method returns a hardcoded base64 string instead of implementing proper RSA encryption with the M-Pesa public certificate. This is a critical security vulnerability that must be fixed before production use.
| * Traiter le callback webhook de M-Pesa\n */\n processWebhookCallback(payload: MPesaWebhookPayload): {\n status: 'success' | 'failed' | 'cancelled';\n amount?: number;\n mpesaReceiptNumber?: string;\n phoneNumber?: string;\n transactionDate?: string;\n } {\n const stkCallback = payload.Body.stkCallback;\n \n if (stkCallback.ResultCode === 0) {\n // Succès\n const metadata = stkCallback.CallbackMetadata?.Item || [];\n \n const amount = metadata.find((item) => item.Name === 'Amount')?.Value;\n const mpesaReceiptNumber = metadata.find((item) => item.Name === 'MpesaReceiptNumber')?.Value;\n const phoneNumber = metadata.find((item) => item.Name === 'PhoneNumber')?.Value;\n const transactionDate = metadata.find((item) => item.Name === 'TransactionDate')?.Value;\n\n return {\n status: 'success',\n amount,\n mpesaReceiptNumber,\n phoneNumber,\n transactionDate,\n };\n } else if (stkCallback.ResultCode === 1032) {\n // Annulé par l'utilisateur\n return {\n status: 'cancelled',\n };\n } else {\n // Échec\n return {\n status: 'failed',\n };\n }\n }\n\n /** | ||
| * Rembourser un paiement (B2C - Business to Customer)\n */\n async refundPayment(\n phoneNumber: string,\n amount: number,\n reason: string,\n country: 'KENYA' | 'TANZANIA'\n ): Promise<{ success: boolean; conversationId?: string; originatorConversationId?: string }> {\n try {\n const token = await this.getAccessToken(country);\n const config = MPESA_CONFIG[country];\n\n const normalizedPhone = this.normalizePhoneNumber(phoneNumber, country);\n\n const payload = {\n InitiatorName: process.env.MPESA_INITIATOR_NAME || 'UNYPlatform',\n SecurityCredential: this.generateSecurityCredential(token), // À implémenter selon la doc M-Pesa\n CommandID: 'BusinessPayment',\n Amount: Math.round(amount),\n PartyA: config.shortcode,\n PartyB: normalizedPhone,\n Remarks: reason || 'Remboursement',\n QueueTimeOutURL: `${process.env.VITE_APP_URL}/api/payments/mpesa/timeout`,\n ResultURL: `${process.env.VITE_APP_URL}/api/payments/mpesa/result`,\n Occasion: 'Refund',\n };\n\n const response = await axios.post(\n `${config.baseUrl}/mpesa/b2c/v1/paymentrequest`,\n payload,\n {\n headers: {\n 'Authorization': `Bearer ${token}`,\n 'Content-Type': 'application/json',\n },\n }\n );\n\n const data = response.data;\n\n return {\n success: data.ResponseCode === '0',\n conversationId: data.ConversationID,\n originatorConversationId: data.OriginatorConversationID,\n };\n } catch (error: any) {\n console.error('M-Pesa refund error:', error.response?.data || error.message);\n return {\n success: false,\n };\n }\n }\n\n /** | ||
| * Générer le Security Credential pour les transactions B2C\n * Nécessite le certificat public de M-Pesa\n */\n private generateSecurityCredential(token: string): string {\n // Cette méthode nécessite le certificat public M-Pesa\n // et une clé privée pour signer la requête\n // Voir: https://developer.mpesa.com/docs/security\n \n // Pour l'instant, retourne un placeholder\n // En production, implémenter le chiffrement RSA avec le certificat M-Pesa\n return Buffer.from('placeholder_credential').toString('base64');\n }\n\n /** | ||
| * Normaliser le numéro de téléphone\n */\n private normalizePhoneNumber(phoneNumber: string, country: string): string {\n let cleaned = phoneNumber.replace(/[\\s\\-\\(\\)]/g, '');\n\n switch (country) {\n case 'KENYA':\n if (cleaned.startsWith('+254')) {\n return cleaned.substring(1);\n }\n if (cleaned.startsWith('254')) {\n return cleaned;\n }\n if (cleaned.startsWith('0') && cleaned.length === 10) {\n return '254' + cleaned.substring(1);\n }\n throw new Error('Numéro kenyan invalide. Format attendu: 07XXXXXXXX ou +2547XXXXXXXX');\n\n case 'TANZANIA':\n if (cleaned.startsWith('+255')) {\n return cleaned.substring(1);\n }\n if (cleaned.startsWith('255')) {\n return cleaned;\n }\n if (cleaned.startsWith('0') && cleaned.length === 10) {\n return '255' + cleaned.substring(1);\n }\n throw new Error('Numéro tanzanien invalide. Format attendu: 07XXXXXXXX ou +2557XXXXXXXX');\n\n default:\n return cleaned.replace(/^\\+/, '');\n }\n }\n}\n\nexport const mpesaService = new MPesaService();\nexport default mpesaService; |
There was a problem hiding this comment.
SUGGESTION: Missing phone number normalization for Mozambique and RDC
The normalizePhoneNumber method handles Kenya and Tanzania but not Mozambique and RDC, despite these being included in the country type union. Consider adding support for these countries or removing them from the type if not supported.
Code Review SummaryStatus: 4 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICAL
The The WARNING
The SUGGESTION
The Files Reviewed (4 files)
Reviewed by nemotron-3-super-120b-a12b-20230311:free · 449,844 tokens |
This PR was created by qwen-chat coder for task 3e5226c4-39e1-4ad8-bdff-5098b858fd51.