Le contexte marché

Au Sénégal, on paie par mobile money (Orange Money, Wave), pas par carte. D'où SenePay, qui agrège ces moyens. Le freemium : contenu de base gratuit (le moteur du trafic), fonctionnalités premium à 1 000 FCFA.

L'architecture du paywall

Le premium est un flag avec expiration, contrôlé par un voter Symfony :

public function hasPremiumAccess(): bool
{
    return $this->isPremium
        && $this->premiumUntil !== null
        && $this->premiumUntil > new \DateTimeImmutable();
}
class PremiumVoter extends Voter
{
    protected function voteOnAttribute(string $attr, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        return $user instanceof User && $user->hasPremiumAccess();
    }
}
$this->denyAccessUnlessGranted('PREMIUM_ACCESS');

Le webhook : ne jamais faire confiance au navigateur

Le point critique : on active le premium uniquement sur webhook serveur-à-serveur, jamais sur le retour navigateur (falsifiable).

#[Route('/webhook/senepay', name: 'senepay_webhook', methods: ['POST'])]
public function webhook(Request $request): Response
{
    $data = json_decode($request->getContent(), true);

    if (!$this->senePay->verifySignature($request)) {
        return new Response('Invalid signature', 403);
    }
    if ($this->paymentRepo->isAlreadyProcessed($data['reference'])) {
        return new Response('OK', 200); // idempotence
    }
    if ($data['status'] === 'success') {
        $user = $this->resolveUser($data['reference']);
        $user->grantPremium(new \DateInterval('P30D'));
        $this->em->flush();
    }
    return new Response('OK', 200);
}

Les trois règles d'or

  1. Vérifier la signature du webhook.
  2. Idempotence : traiter une transaction une seule fois.
  3. Source de vérité = le serveur. Le retour navigateur, c'est de l'UX.

Ce que je retiens

  • Adapter le moyen de paiement au marché : mobile money au Sénégal.
  • Un voter Symfony centralise proprement le contrôle d'accès premium.
  • Le webhook est la seule source de vérité.