Skip to content

Update from task 3e5226c4-39e1-4ad8-bdff-5098b858fd51#4

Merged
Vitalcheffe merged 1 commit intomainfrom
code-review-feedback-8fd51
Apr 23, 2026
Merged

Update from task 3e5226c4-39e1-4ad8-bdff-5098b858fd51#4
Vitalcheffe merged 1 commit intomainfrom
code-review-feedback-8fd51

Conversation

@Vitalcheffe
Copy link
Copy Markdown
Owner

This PR was created by qwen-chat coder for task 3e5226c4-39e1-4ad8-bdff-5098b858fd51.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
uny Ready Ready Preview, Comment, Open in v0 Apr 23, 2026 1:26pm

@Vitalcheffe Vitalcheffe merged commit 5ca324e into main Apr 23, 2026
4 of 6 checks passed
// ============== 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 /**
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/payments/wave.ts
* 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 /**
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/payments/m-pesa.ts
* 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 /**
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/payments/m-pesa.ts
* 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kilo-code-bot
Copy link
Copy Markdown

kilo-code-bot Bot commented Apr 23, 2026

Code Review Summary

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 2
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

CRITICAL

File Line Issue
lib/payments/orange-money.ts 23 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. |
| lib/payments/wave.ts | 22 | 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. |

WARNING

File Line Issue
lib/payments/m-pesa.ts 25 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. |

SUGGESTION

File Line Issue
lib/payments/m-pesa.ts 26 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. |

Files Reviewed (4 files)
  • lib/payments/orange-money.ts - 1 issues
  • lib/payments/wave.ts - 1 issues
  • lib/payments/m-pesa.ts - 2 issues
  • context/WalletContext.tsx - 0 issues

Reviewed by nemotron-3-super-120b-a12b-20230311:free · 449,844 tokens

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants