iPhone seulement (StoreKit 2 via le paquet in_app_purchase). Android : rien côté serveur pour l'instant.
App (StoreKit 2) API App Store
achat / restauration / renouvellement
└─ JWS de la transaction ──► POST /me/subscription/verify {transaction_jws, claim}
├─ storekit.transaction() : chaîne + signature + champs
├─ users.subscription_original_id / is_subscriber / subscription_expires_at
└─ subscription_events (type APP) si nouveau sur ce compte
◄── POST /subscriptions/apple/notifications {signedPayload}
├─ storekit.notification() : même vérification
├─ dédoublonnage par notificationUUID
├─ users.subscription_expires_at (si joueur connu)
└─ subscription_events (type Apple)
| Variable | Défaut | Rôle |
|---|---|---|
SUBSCRIPTION_ENABLED |
false |
voir « Ce que le flag coupe » |
PRODUCT_MONTHLY |
com.soupappstudio.slidz.plus.monthly |
produit accepté, envoyé dans /config → products |
PRODUCT_YEARLY |
com.soupappstudio.slidz.plus.yearly |
idem ; l'annuel compte pour 1/12 dans le revenu mensuel |
APPLE_BUNDLE_ID |
com.soupappstudio.slidz |
bundleId exigé dans la transaction |
XP_SUB_BONUS |
1500 | bonus quotidien (POST /me/bonus/claim) |
QUESTIONS_PER_DAY / QUESTIONS_PER_DAY_PLUS |
10 / 30 | création de questions par jour local |
GROUPS_PER_OWNER / GROUP_MAX_MEMBERS |
5 / 20 | groupes possédés, membres par groupe |
Aucune clé Apple n'est nécessaire : seule la racine publique back/app/certs/AppleRootCA-G3.cer est dans l'image.
services/storekit.py, decode)| Étape | Contrôle | Erreur (StoreKitError) |
|---|---|---|
| 1 | en-tête JWS lisible, champ x5c (certificats DER en base64) |
« signature illisible » |
| 2 | exactement 3 certificats : feuille, intermédiaire, racine | « chaîne de certificats incomplète » |
| 3 | empreinte de la racine reçue = empreinte de AppleRootCA-G3.cer |
« certificat racine inconnu » |
| 4 | intermediate.verify_directly_issued_by(root), leaf.verify_directly_issued_by(intermediate) |
« chaîne de certificats invalide » |
| 5 | OID Apple : intermédiaire 1.2.840.113635.100.6.2.1 (WWDR), feuille 1.2.840.113635.100.6.11.1 (reçus App Store) |
« certificats hors App Store » |
| 6 | not_valid_before ≤ maintenant ≤ not_valid_after pour les 3 certificats |
« certificat expiré » |
| 7 | clé publique de la feuille = courbe elliptique | « clé inattendue » |
| 8 | jwt.decode(token, clé, algorithms=["ES256"]) |
message de PyJWT |
Pas de contrôle de révocation (OCSP) des certificats.
transaction)| Champ Apple | Règle | Sortie |
|---|---|---|
bundleId |
doit valoir APPLE_BUNDLE_ID |
— |
productId |
PRODUCT_MONTHLY ou PRODUCT_YEARLY |
product |
originalTransactionId |
obligatoire | original_id (identifie l'abonnement, de l'achat à la fin) |
expiresDate (ms) |
obligatoire | expires_at |
revocationDate (ms) |
si présent : expires_at = min(expiresDate, revocationDate) |
— |
offerType |
1 intro, 2 promo, 3 code, 4 winback ; intro + offerDiscountType = FREE_TRIAL → trial |
offer |
price |
entier en millièmes, TTC ; sinon None |
price |
currency, environment |
copiés (Production ou Sandbox) |
— |
Environnements : aucune distinction à la vérification. Un achat Sandbox (TestFlight, revue Apple) rend abonné comme
un vrai. La séparation se fait seulement dans les statistiques (environment).
POST /me/subscription/verify (routers/subscriptions.py, verify_subscription)| Cas | Effet | Réponse |
|---|---|---|
SUBSCRIPTION_ENABLED faux |
rien | 403 |
| JWS refusé | journal transaction refusée |
422 |
original_id déjà sur un autre compte et claim faux |
rien | 200, profil inchangé |
original_id différent de celui du compte |
ligne subscription_events type APP |
— |
original_id sur un autre compte et claim vrai |
l'autre compte perd tout (original_id, is_subscriber, date) ; flush avant (colonne unique) |
— |
| sinon | subscription_original_id, is_subscriber = true, subscription_expires_at |
200, profil |
claim côté app (app/lib/api/purchases.dart, _handle) : vrai pour une restauration, ou pour l'achat qui suit
willBuy() (« S'abonner »). Les achats pending sont ignorés ; completePurchase est appelé après l'envoi, même si le
serveur a refusé. L'écoute démarre dans Session.refresh seulement si subscription_enabled et sur iOS.
apple_notifications)URL à déclarer (production et Sandbox) : https://api.slidzapp.com/subscriptions/apple/notifications.
storekit.notification(signedPayload) : même decode. data.bundleId doit être absent ou égal àAPPLE_BUNDLE_ID. Échec → 400 (Apple réessaie).signedTransactionInfo (ex. TEST) → 200, rien.id = notificationUUID sans tirets (32 caractères) = clé primaire de subscription_events. Déjàsubscription_original_id. Trouvé → subscription_expires_at = expires_at de la transaction.subscription_events (type et sous-type Apple, user_id éventuellement nul). 200.Aucun type n'est filtré : toute notification qui porte une transaction met à jour la date de fin. C'est la date
portée par la transaction qui fait foi : prolongée pour DID_RENEW, passée pour EXPIRED, REFUND ou REVOKE.
La route n'est pas coupée par SUBSCRIPTION_ENABLED.
User.subscribed = is_subscriber and (subscription_expires_at is None or subscription_expires_at > maintenant)
Aucune tâche ne tourne à l'expiration : la comparaison de date suffit. is_subscriber ne repasse jamais à faux tout
seul (seulement par claim d'un autre compte ou suppression du compte). Suppression (services/accounts.py) :
is_subscriber = false, date et original_id à NULL ; l'abonnement Apple continue.
| Fonction | Coupée par SUBSCRIPTION_ENABLED = false ? |
Où |
|---|---|---|
POST /me/subscription/verify |
oui (403) | routers/subscriptions.py |
Bonus quotidien POST /me/bonus/claim |
oui (403) | routers/me.py (claim_bonus) |
| Écoute StoreKit, page Slidz+, entrée du menu, carte bonus | oui (côté app, via /config) |
session.dart, subscription_screen.dart, profile_screen.dart, day_sheet.dart |
| Icônes Slidz+ dans le menu | oui (filtrées par l'app, le serveur les envoie toujours) | app_icon_screen.dart |
| Notifications App Store | non | routers/subscriptions.py |
| Création de groupe | non : user.subscribed or user.is_admin |
routers/groups.py (create) |
| Quota de questions 30/jour | non : user.subscribed |
routers/creation.py |
| Refus des pubs pour un abonné | non : user.subscribed |
services/ads.py (grant) |
| Badge sur le profil | non : is_subscriber = user.subscribed |
services/serialize.py |
back/app/services/app_icons.py (CATALOG). available() renvoie les icônes de la journée (heure de/config → app_icons : {id, label, plus, until}.((mois, jour), (mois, jour)) de la même année : une période à cheval sur le 31 décembre nestart > end).app/lib/api/app_icon.dart, iosNames) et retire les plus si le flag estslidz/app_icon. Le serveur ne sait jamais quelle icône est choisie.Assets.xcassets/AppIcon-*.appiconset, listeASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES) : toute nouvelle icône demande une version de l'app.subscription_events| Colonne | Contenu |
|---|---|
id (PK, 32) |
notificationUUID sans tirets, ou identifiant aléatoire pour APP |
original_id (index) |
abonnement Apple |
user_id (nullable) |
joueur si connu au moment de l'événement |
type, subtype |
notificationType / subtype Apple, ou APP |
product, environment, price (millièmes TTC), currency, offer |
copiés de la transaction |
expires_at |
date de fin après l'événement |
created_at (index) |
réception par le serveur (pas la date Apple) |
Gardée sans limite de durée.
services/subscription_stats.py, subscription_stats)Route : GET /api/admin/subscriptions?from&to&environment=Production|Sandbox. Tous les événements de l'environnement
sont chargés en mémoire et groupés par original_id (ordre created_at). Revenus en euros TTC, avant commission
d'Apple ; seuls les événements currency = EUR entrent dans les montants.
t (snapshot)Pour chaque abonnement : h = ses événements avec created_at < t, last = le dernier.
| Chiffre | Formule |
|---|---|
| actif | h non vide et last.expires_at > t |
trial |
actifs avec last.offer = trial |
paying |
active − trial |
cancelling |
actifs dont le dernier DID_CHANGE_RENEWAL_STATUS a le sous-type AUTO_RENEW_DISABLED (essais compris) |
mrr_eur |
Σ sur les actifs hors essai : prix du dernier événement payant (price > 0, offer ≠ trial, EUR) ; ÷ 12 si last.product = PRODUCT_YEARLY |
by_product |
actifs par last.product |
Calculé à t = début et à t = min(fin, maintenant).
[début, fin[ (counts), comparés à la période précédente de même longueur| Clé | Formule |
|---|---|
new |
abonnements dont le premier événement tombe dans la période |
trials |
parmi new, premier événement avec offer = trial |
converted |
DID_RENEW dans la période dont l'événement précédent du même abonnement a offer = trial |
renewals |
DID_RENEW |
cancellations |
DID_CHANGE_RENEWAL_STATUS + AUTO_RENEW_DISABLED |
lost |
EXPIRED, REFUND, REVOKE |
refunds |
REFUND |
revenue_eur |
Σ prix des SUBSCRIBED + DID_RENEW − Σ prix des REFUND (EUR) |
| Indicateur dérivé | Formule |
|---|---|
trial_conversion_pct |
pct(converted, trials) |
active_players |
joueurs actifs sur la période (analytics.active_users, toutes plateformes) |
paying_pct |
pct(at_end.paying, active_players) |
series |
par jour local : abonnements nés ce jour, événements LOST ce jour |
subscriber_profile |
abonnements nés dans la période : médiane de (début − users.created_at).days et médiane des answers du joueur avant le début |
funnel |
événements paywall_view (vues) et purchase (tentatives ; achats si result = done) par props.source, dans la période ; conversion_pct = pct(purchases, views) ; 30 jours au plus (purge des événements) |
recent |
les 20 derniers événements de l'environnement, toutes dates |
pct(a, b) = round(100 × a / b, 1), 0 si b = 0. Voir Statistiques.
models.py (commentaire de SubscriptionEvent.type) dit PURCHASE pour un achat présenté par l'app : le code écritAPP.routers/admin.py, user_row) : renvoie u.is_subscriber sans la date deuser.subscribed.isSubscriber || isFounder (app/lib/screens/inbox/inbox_screen.dart,canCreateGroup) ; le serveur exige subscribed or is_admin. Un fondateur non admin voit le bouton mais reçoit 403 ;APP n'ont pas de prix compté : sans notifications Apple configurées, revenue_eur reste à 0 et lesAPP.mrr_eur prend le produit du dernier événement et le prix du dernier événement payé : après un changementcreated_at est l'heure de réception : une notification rejouée par Apple en retard est datée du jour de réception./config envoie les icônes Slidz+ même quand le flag est faux ; c'est l'app qui les cache.Code : back/app/services/storekit.py (decode, transaction, notification), back/app/routers/subscriptions.py
(verify_subscription, apple_notifications, history), back/app/services/subscription_stats.py
(subscription_stats, snapshot, counts), back/app/models.py (User.subscribed, SubscriptionEvent),
back/app/services/app_icons.py (available), back/app/services/accounts.py, app/lib/api/purchases.dart,
app/lib/api/app_icon.dart, app/lib/screens/profile/app_icon_screen.dart