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

  1. Verificare la firma: HMAC-SHA256 con il vostro `webhookSigningSecret`
  2. Idempotenza: trattare una transazione una sola volta anche se il webhook viene ricevuto più volte
  3. 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.