Integrare SenePay: Pagamento mobile money per il mercato senegalese
In questo articolo vi spiego come ho integrato SenePay per gestire i pagamenti mobile money (Wave, Orange Money, Free Money) su baccalaureat.sn e amarsyll.pro. Un'architettura sicura con webhook, idempotenza e voter Symfony.
Il contesto di mercato
In Senegal, il pagamento tramite mobile money (Wave, Orange Money, Free Money) è il mezzo di pagamento dominante. Le carte bancarie sono molto poco utilizzate. Da qui la scelta di SenePay, un aggregatore di pagamenti mobile money che copre 16 paesi africani.
Modello freemium:
- Contenuto di base gratuito → motore del traffico organico
- Funzionalità premium a 1 000 FCFA (compilazioni PDF, quiz illimitati)
- Commissione SenePay: 3,6 % prelevata al momento del payin
L'architettura del paywall
1. Il flag premium con scadenza
Il premium è memorizzato come un flag con data di scadenza nell'entità User:
// src/Entity/User.php
#[ORMColumn]
private bool $isPremium = false;
#[ORMColumn(nullable: true)]
private ?DateTimeImmutable $premiumUntil = null;
public function hasPremiumAccess(): bool
{
return $this->isPremium
&& $this->premiumUntil !== null
&& $this->premiumUntil > new DateTimeImmutable();
}
2. Il Voter Symfony per controllare l'accesso
Un voter Symfony centralizza la logica di autorizzazione:
// src/Security/Voter/PremiumVoter.php
class PremiumVoter extends Voter
{
public const PREMIUM_ACCESS = 'PREMIUM_ACCESS';
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === self::PREMIUM_ACCESS;
}
protected function voteOnAttribute(string $attr, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
return $user instanceof User && $user->hasPremiumAccess();
}
}
Utilizzo nei controller:
$this->denyAccessUnlessGranted(PremiumVoter::PREMIUM_ACCESS);
Il webhook: non fidarsi mai del browser
Regola d'oro: si attiva il premium solo sul webhook server-to-server, mai sul ritorno del browser (falsificabile).
Endpoint webhook
// src/Controller/Webhook/SenePayController.php
#[Route('/webhook/senepay', name: 'senepay_webhook', methods: ['POST'])]
public function webhook(Request $request): Response
{
$payload = json_decode($request->getContent(), true);
// 1. Vérification de la signature HMAC-SHA256
if (!$this->senePay->verifySignature($request)) {
return new Response('Invalid signature', 403);
}
// 2. Idempotence : traiter une transaction une seule fois
$reference = $payload['orderReference'] ?? $payload['sessionToken'];
if ($this->paymentRepo->isAlreadyProcessed($reference)) {
return new Response('OK', 200);
}
// 3. Traitement selon l'événement
if ($payload['event'] === 'checkout.session.completed') {
$user = $this->resolveUser($payload['metadata']);
$user->grantPremium(new DateInterval('P30D')); // 30 jours
$this->em->flush();
}
return new Response('OK', 200);
}
Le tre regole d'oro dei webhook
- Verificare la firma: HMAC-SHA256 con il vostro `webhookSigningSecret`
- Idempotenza: trattare una transazione una sola volta anche se il webhook viene ricevuto più volte
- Fonte di verità = il server: il ritorno del browser è UX, non una validazione
Creazione di una sessione di pagamento
// src/Service/SenePayService.php
public function createSession(User $user, int $amount, string $reference): array
{
$response = $this->httpClient->post('https://api.sene-pay.com/api/v1/checkout/sessions', [
'headers' => [
'X-Api-Key' => $this->apiKey,
'X-Api-Secret' => $this->apiSecret,
'Content-Type' => 'application/json',
],
'json' => [
'amount' => $amount,
'currency' => 'XOF',
'orderReference' => $reference,
'description' => 'Accès premium 30 jours',
'returnUrl' => $this->returnUrl,
'cancelUrl' => $this->cancelUrl,
'webhookUrl' => $this->webhookUrl,
'country' => 'SN',
'metadata' => [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
],
'expiresInMinutes' => 60,
],
]);
$data = json_decode($response->getContent(), true);
// Rediriger le client vers la page de paiement
return [
'checkoutUrl' => $data['checkoutUrl'],
'sessionToken' => $data['sessionToken'],
];
}
Gli stati di pagamento
| Stato | Descrizione | Azione lato esercente |
|---|---|---|
Open |
Sessione creata, in attesa di pagamento | Mostrare la pagina di pagamento |
Processing |
Pagamento avviato, in corso | Attendere il webhook |
Complete |
✅ Pagamento riuscito | Attivare il premium |
Failed |
❌ Pagamento fallito | Notificare il cliente |
Expired |
⏰ Sessione scaduta | Consentire un nuovo tentativo |
Test in modalità Sandbox
SenePay offre un ambiente sandbox con numeri di test:
Pays : Sénégal (SN)
- 700000001 → Succès (Complete)
- 700000002 → En attente (Processing → Complete après 10s)
- 700000003 → Échec (Failed)
Codes OTP de test (Orange Money) :
- 123456 → Succès
- 000000 → Échec
- 111111 → Échec
Le buone pratiche adottate
- Adattare il mezzo di pagamento al mercato: mobile money in Senegal, niente carta bancaria
- Un voter Symfony centralizza correttamente il controllo dell'accesso premium
- Il webhook è l'unica fonte di verità: il ritorno del browser è solo UX
- Verificare la firma HMAC per proteggere i webhook
- Idempotenza: trattare ogni transazione una sola volta
- Metadata: passare l'ID utente nei metadati per ritrovarlo nel webhook
- Scadenza automatica: il premium scade dopo 30 giorni
Conclusione
L'integrazione di SenePay mi ha permesso di monetizzare baccalaureat.sn rimanendo accessibile al mercato senegalese. L'architettura con webhook sicuro e voter Symfony garantisce una gestione affidabile degli accessi premium.
Prossimo articolo: Vi mostrerò come ho integrato l'API Direct SenePay per gestire i pagamenti senza pagina di checkout ospitata.