Blog d'Obi Madu
Retour à tous les articles
MobileSystem DesignInfrastructureBackend

Architecture de facturation mobile fiable : Ce que j'ai appris à mes dépens

Un regard concret sur la résolution des problèmes de facturation des abonnements mobiles lorsque les webhooks, les achats en sandbox et l'identité des utilisateurs échouent.

Architecture de facturation mobile fiable : Ce que j'ai appris à mes dépens

La facturation des abonnements mobiles semble souvent trompeusement simple de l'extérieur. Le discours commercial standard suggère qu'il suffit d'ajouter un SDK de paywall, de configurer vos produits dans les magasins d'applications respectifs, d'écouter les webhooks, puis de mettre à jour votre base de données chaque fois qu'un achat réussit. Malheureusement, ce récit n'est qu'à moitié vrai. Le parcours idéal fonctionne à merveille jusqu'à ce que vous heurtiez inévitablement les bords plus inconfortables du système : les environnements sandbox, les champs d'identité manquants, les conditions de course, les webhooks retardés, les achats restaurés, les renouvellements, les annulations, et la réalité gênante que le magasin d'applications est propriétaire de la transaction tandis que votre application est propriétaire de l'utilisateur.

Je me suis heurté de plein fouet à cette réalité en intégrant Superwall aux abonnements Google Play. Alors que les achats de production semblaient corrects, les tests en sandbox ont rapidement dégénéré en un véritable gâchis. Les achats de test se terminaient avec succès dans l'application, mais mon backend ne parvenait pas à déterminer de manière fiable quel utilisateur devait réellement recevoir l'accès premium. Ce qui a commencé comme une intégration de paywall simple est soudainement devenu un problème de systèmes distribués centré sur l'identité, la propriété et la réconciliation. Cet article détaille l'architecture à laquelle j'ai finalement abouti, et les leçons douloureuses qui m'ont forcé à y arriver.

L'architecture à laquelle je voulais croire

La première version de mon flux de facturation suivait naturellement le modèle standard basé sur les webhooks. L'utilisateur appuyait sur un bouton de mise à niveau, Superwall présentait le paywall, gérait l'achat, puis envoyait un webhook à mon backend. Le backend lisait simplement l'ID de l'utilisateur à partir du webhook entrant et mettait à niveau cet utilisateur spécifique.

Dans le scénario idéal, la charge utile (payload) du webhook ressemblait à ceci :

{
  "data": {
    "originalAppUserId": "user_123",
    "transactionId": "GPA.3373-4052-0812-08085",
    "productId": "pro:pro-monthly"
  }
}

Cette charge utile contient tout ce qu'un développeur backend souhaite. Elle inclut le produit, la transaction et, surtout, l'identité de l'utilisateur au niveau de l'application. Compte tenu de ces données propres, l'octroi de l'accès est simple. Pour les achats de production réels, ce flux fonctionnait exceptionnellement bien. Les utilisateurs achetaient des abonnements, les webhooks arrivaient rapidement et la base de données les passait au plan premium. Puis, j'ai commencé à tester correctement avec la sandbox Google Play.

La sandbox a brisé l'hypothèse

Les tests en sandbox Google Play sont incroyablement utiles car les délais d'abonnement sont considérablement accélérés. Un abonnement mensuel peut se renouveler toutes les quelques minutes, ce qui permet de tester de manière approfondie les flux de renouvellement, d'annulation, d'expiration et de réabonnement sans attendre un mois réel. Mais mes premiers achats en sandbox ont échoué d'une manière très confuse. L'achat a été effectué dans l'application, mais l'utilisateur n'a pas été mis à niveau.

Les journaux du backend ont rapidement expliqué pourquoi :

{
  "data": {
    "originalAppUserId": null,
    "userAttributes": null,
    "transactionId": "GPA.3373-4052-0812-08085",
    "originalTransactionId": "GPA.3373-4052-0812-08085",
    "productId": "pro:pro-monthly",
    "environment": "SANDBOX"
  }
}

Les champs d'identité manquaient complètement. Le webhook déclarait effectivement : "Quelqu'un vient d'acheter cet abonnement, et voici l'ID de transaction, mais je n'ai absolument aucune idée de qui c'est." Cette seule omission a ruiné l'ensemble de la conception car mon backend dépendait fortement du webhook pour répondre à la question la plus critique : quel utilisateur possède cet achat ?

Le problème architectural plus profond est que Google Play ne connaît fondamentalement pas mes identifiants d'utilisateur internes. L'abonnement appartient à un compte Google, tandis que l'utilisateur de mon application appartient à mon système d'authentification propriétaire. Superwall peut certainement essayer de relier ces deux domaines d'identité distincts, mais considérer ce pont comme une source de vérité infaillible dans chaque environnement est une erreur. Une fois que j'ai compris cette déconnexion, l'ancienne architecture a cessé de paraître simple et a commencé à sembler incroyablement fragile.

Les correctifs qui ont échoué

Avant d'arriver au bon modèle, j'ai naturellement essayé quelques correctifs évidents qui ont finalement échoué.

Ces échecs répétés m'ont forcé à séparer soigneusement deux concepts distincts que j'avais accidentellement fusionnés dans ma conception initiale.

La propriété et le cycle de vie sont des problèmes différents

La percée majeure a été de réaliser que la propriété de l'abonnement et le cycle de vie de l'abonnement ne sont pas du tout le même problème. La propriété répond à la question : qui a acheté cet abonnement ? Le cycle de vie répond à : quel est l'état actuel de cet abonnement ? Je m'étais appuyé sur les webhooks pour les deux, ce qui était mon erreur fondamentale.

La propriété n'a besoin d'être établie qu'une seule fois, et elle doit être strictement établie par une action d'utilisateur authentifié. Les changements de cycle de vie, en revanche, se produisent de manière répétée au fil du temps via des renouvellements, des annulations, des expirations, des remboursements et des réabonnements. Les webhooks sont fantastiques pour les mises à jour asynchrones du cycle de vie, mais ils constituent une très mauvaise source primaire pour la propriété lorsque des champs d'identité critiques peuvent manquer.

L'élément de données crucial qui a finalement rendu cela possible a été la lignée de la transaction :

{
  "transactionId": "GPA.3373-4052-0812-08085",
  "originalTransactionId": "GPA.3373-4052-0812-08085"
}

L'originalTransactionId identifie fermement la racine de l'abonnement. Il reste complètement stable à travers les renouvellements et les événements du cycle de vie, ce qui en fait la clé parfaite pour lier un abonnement de magasin à un utilisateur d'application spécifique. La deuxième pièce importante du puzzle était le purchaseToken, qui est facilement disponible sur le client après un achat sur Google Play. Le backend peut utiliser ce jeton pour demander directement à Google Play si l'achat est réellement valide.

Cette réalisation a conduit à une bien meilleure architecture : lier la propriété immédiatement en utilisant le contexte du client authentifié, vérifier l'achat directement avec Google Play, puis s'appuyer strictement sur les webhooks pour la réconciliation du cycle de vie par la suite.

L'architecture qui a fonctionné

Dans la conception finale, le webhook n'est plus l'autorité qui décide qui obtient l'accès premium. Au lieu de cela, il sert de messager en arrière-plan qui maintient l'état global de l'abonnement à jour.

Le flux d'achat immédiat commence désormais sur le client. Lorsque Superwall signale qu'une transaction est terminée, l'application capture la lignée de la transaction et le jeton d'achat. Elle envoie ensuite ces valeurs spécifiques à mon backend sous la session d'authentification normale et sécurisée de l'utilisateur.

POST /v1/billing/superwall-binding
Authorization: Bearer <user_token>
Content-Type: application/json

{
  "store": "PLAY_STORE",
  "originalTransactionId": "GPA.3373-4052-0812-08085",
  "transactionId": "GPA.3373-4052-0812-08085",
  "purchaseToken": "AOP..."
}

De manière cruciale, le backend ne fait confiance à aucun ID utilisateur caché à l'intérieur du corps de la requête. Il s'appuie entièrement sur l'en-tête Authorization pour identifier l'utilisateur. Ensuite, il crée une liaison qui stipule définitivement : cet utilisateur authentifié possède cet ID de transaction d'origine. Cette liaison devient l'enregistrement de propriété permanent.

Ensuite, le backend vérifie immédiatement l'achat directement avec Google Play :

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{purchaseToken}

Si Google répond que le jeton d'achat est valide et que l'abonnement est actif, le backend accorde le droit immédiatement. L'utilisateur obtient un accès premium instantanément après l'achèvement de l'achat, éliminant complètement toute dépendance à une attente de webhook.

Plus tard, lorsqu'un webhook Superwall finit par arriver, il peut manquer les champs d'identité en toute sécurité et rester utile :

{
  "data": {
    "originalAppUserId": null,
    "originalTransactionId": "GPA.3373-4052-0812-08085",
    "name": "renewal",
    "expirationAt": 1775832567843
  }
}

Le backend utilise simplement l'originalTransactionId pour rechercher la liaison, puis met à jour l'enregistrement d'abonnement existant en toute sécurité. Le webhook n'a plus besoin de savoir qui est l'utilisateur, car le système le sait déjà.

Pourquoi cela gère mieux les cas particuliers

Ce modèle découplé rend tous les cas particuliers ennuyeux beaucoup plus faciles à comprendre.

Si un webhook arrive par hasard avant la liaison du client, le backend le stocke simplement en attente. Lorsque le client authentifié soumet la liaison plus tard, le backend peut rejouer en toute sécurité cet événement de cycle de vie en attente. Si un utilisateur se réabonne directement depuis l'application Play Store au lieu de mon application, aucun événement client immédiat ne se déclenchera. Cependant, le webhook peut toujours arriver et rester en attente jusqu'à ce que l'utilisateur ouvre à nouveau l'application. Une fois que le SDK détecte l'achat restauré, le client envoie la liaison et le backend se met à jour de manière transparente.

Plus important encore, si la sandbox Google Play omet les champs d'identité, rien ne se casse. La vérification directe repose sur le jeton d'achat, et la réconciliation du webhook repose sur l'ID de la transaction d'origine. Aucune étape critique ne dépend du fait que originalAppUserId soit réellement présent. C'est la différence profonde entre un système de facturation qui espère simplement que toutes les intégrations préservent l'identité, et celui qui applique son propre modèle de propriété faisant autorité.

Le modèle mental

Le magasin possède la transaction. Votre application possède l'utilisateur. Votre backend doit posséder fermement la liaison entre les deux. Une fois que vous acceptez vraiment cette dynamique, toute l'architecture devient beaucoup plus claire.

PréoccupationSource de vérité
Identité de l'utilisateurVotre système d'authentification
Validité de l'achatGoogle Play ou App Store
Propriété de l'abonnementVotre table de liaisons
Renouvellements et annulationsÉvénements du magasin via webhooks, réconciliés par des liaisons

Le client est parfaitement autorisé à transporter des données de corrélation, telles que le purchaseToken et l'originalTransactionId. Cependant, il n'est fondamentalement pas autorisé à s'accorder lui-même l'accès. Le backend doit toujours vérifier la transaction avec le magasin avant de créer des droits durables.

La leçon

Une facturation mobile fiable nécessite de traiter fondamentalement les webhooks comme des messages de cycle de vie asynchrones, plutôt que de les traiter comme des enregistrements d'identité parfaits. Les webhooks peuvent être retardés, ils peuvent arriver complètement dans le désordre, et ils peuvent facilement manquer de champs d'utilisateur critiques. Les environnements sandbox peuvent se comporter très différemment de la production. Aucun de ces facteurs ne devrait jamais décider si un acheteur légitime obtient réellement accès aux fonctionnalités pour lesquelles il a payé.

Le modèle robuste et résilient consiste à établir fermement la propriété par le biais d'une requête client authentifiée, de vérifier l'achat directement auprès du magasin et d'utiliser les webhooks uniquement pour maintenir le cycle de vie à jour au fil du temps. Ce seul changement architectural a complètement stabilisé mon système. J'ai arrêté de demander au webhook de me dire à qui appartenait l'abonnement, j'ai rendu la propriété explicite, je l'ai vérifiée à la source et j'ai laissé les webhooks faire le travail de synchronisation en arrière-plan pour lequel ils sont réellement conçus.