# Conception — Backend de validation des achats In-App Google Play (PHP, android.dynseo.com)

**Date :** 2026-06-02
**Statut :** validé (conception), en attente plan d'implémentation

## Objectif

Construire un backend PHP, hébergé sur `https://android.dynseo.com/`, qui valide les
achats In-App Google Play des apps Stimart de Dynseo (EHPAD/PAPY/COCO), puis transmet
l'achat validé au serveur « shop » de Dynseo. Le backend respecte **exactement le même
contrat d'entrées/sorties que le backend iOS existant** (`apple.dynseo.com`), de façon à
ce que le serveur shop traite iOS et Android de manière homogène. Seule la couche de
vérification (Apple → Google Play) change.

Référence iOS : `~/Dynseo/apple.dynseo.com/htdocs/{validPayment.php, validConsumable.php,
appleServerNotifications.php, validPaymentTest.php}`.

## Décisions d'architecture (validées)

- **Auth Google :** SDK officiel `google/apiclient` via Composer (l'hôte supporte Composer).
- **API abonnements :** `purchases.subscriptionsv2.get` (token seul) — cohérent avec le
  service Node existant du dépôt.
- **API consommables :** `purchases.products.get`, puis `purchases.products.acknowledge`
  (best-effort, non bloquant) pour éviter le remboursement auto par Google après 3 jours.
- **Service account :** un seul SA partagé ayant accès aux 3 packages Stimart.
- **Valeurs « à confirmer » :** centralisées en constantes de config (pas de devinette en
  dur), modifiables en une ligne.

## Mapping app → package

| Code app | Package Android |
|----------|------------------|
| EHPAD | `com.dynseo.stimart.edith` |
| PAPY | `com.dynseo.stimart.joe` |
| COCO | `com.dynseo.stimart.coco` |

Le code app est envoyé en clair par l'app et sert de clé pour résoudre le package.

## Serveur shop cible (contrat à respecter à l'identique)

- Prod : `POST https://shop.dynseo.com/shop?service=inAppPaymentConfirmation&deviceType=A`
- Test : `POST https://testshop.dynseo.com/shop?service=inAppPaymentConfirmation&deviceType=A`
- Format : `application/x-www-form-urlencoded`. Le shop renvoie du JSON, relayé tel quel
  à l'app via `echo`.

## Arborescence (calquée sur apple.dynseo.com)

```
android-inapp/
├── composer.json                       # dépend de google/apiclient
├── htdocs/                             # racine web sur android.dynseo.com
│   ├── validPayment.php                # abonnements → shop.dynseo.com (prod)
│   ├── validPaymentTest.php            # abonnements → testshop.dynseo.com
│   ├── validConsumable.php             # consommables → shop (prod)
│   ├── validConsumableTest.php         # consommables → testshop
│   ├── googlePlayNotifications.php     # webhook RTDN (Pub/Sub)
│   └── lib/
│       ├── config.php                  # map app→package, URLs shop, destinataires, constantes À CONFIRMER
│       ├── GooglePlay.php              # auth Google_Client + get abonnement/produit + acknowledge
│       └── helpers.php                 # logRequest(), debugLog(), postToShop(), sendMail()
└── credentials/
    └── service-account.json            # SA partagé (gitignored ; hors racine web en prod)
```

Les logs tombent dans `../log_inApp.txt` (relatif à `htdocs/`), comme iOS. Le code Node
existant (`src/`) reste intact — c'est une exploration antérieure ; le PHP est le livrable
déployé. La logique commune est factorisée dans `lib/` au lieu d'être dupliquée dans chaque
endpoint comme côté iOS.

## Composants

### `lib/config.php`
Unique source des valeurs « à confirmer » et de la configuration :

- `APP_PACKAGES` : `['EHPAD' => 'com.dynseo.stimart.edith', 'PAPY' => 'com.dynseo.stimart.joe', 'COCO' => 'com.dynseo.stimart.coco']`
- `SHOP_URL_PROD`, `SHOP_URL_TEST` (sans le `deviceType`, ajouté via `SHOP_DEVICE_TYPE`)
- `SHOP_DEVICE_TYPE = 'A'` — **à confirmer avec l'équipe shop**
- `SHOP_TOKEN_FIELD = 'purchaseToken'` — **à confirmer** ; bascule en une ligne vers
  `receipt` (abos) / `transactionId` (consommables) si le shop lit ces noms
- `SUBSCRIPTION_SELLING_KEY = 'SELLING_TYPE'` et `CONSUMABLE_SELLING_KEY = 'sellingType'`
  — l'incohérence de casse iOS, **conservée à l'identique** ; à vérifier ce que le shop lit
- `MAIL_RECIPIENTS_PROD = 'sauquet@itssauquet.com, justine.monsaingeon@dynseo.com, pierrecome@dynseo.com'`
- `SERVICE_ACCOUNT_PATH` : chemin vers le JSON SA (hors racine web en prod)
- `LOG_PATH = '../log_inApp.txt'`

### `lib/GooglePlay.php`
Encapsule `Google\Client` :

- Constructeur : `setAuthConfig(SERVICE_ACCOUNT_PATH)`, `addScope('https://www.googleapis.com/auth/androidpublisher')`. Si la clé est illisible → exception attrapée en `INVALID_PRIVATE_KEY`.
- `getSubscriptionV2($packageName, $purchaseToken)` → `Google_Service_AndroidPublisher::purchases_subscriptionsv2->get(...)`.
- `getProduct($packageName, $productId, $purchaseToken)` → `purchases_products->get(...)`.
- `acknowledgeProduct($packageName, $productId, $purchaseToken)` → `purchases_products->acknowledge(...)` (best-effort).

### `lib/helpers.php`
- `logRequest($purchaseType, $serialNumber, $app, $appVersion, $purchaseToken)` — append dans `LOG_PATH`.
- `debugLog($lines)` — append des lignes `[DEBUG]` (URL appelée, code HTTP, réponse tronquée à 500 car., erreur éventuelle).
- `postToShop($url, array $fields)` — curl POST `x-www-form-urlencoded` (valeurs `urlencode`), renvoie la réponse brute.
- `sendMail($recipients, $subject, $text)` — `mail()` avec sujets `[SHOP][ANDROID] …` et dump complet de `$_POST`.

## Flux — `validPayment.php` (abonnements)

**POST entrants :** `serialNumber, app, lang, appVersion, purchaseType, productId, purchaseToken`

1. `purchaseType` : `PURCHASE` → `FIRST_PURCHASE`, sinon `RENEWAL`.
2. Append de la requête dans `../log_inApp.txt` (date, purchaseType, serialNumber, app, appVersion, purchaseToken).
3. Résolution du package depuis `app` via `APP_PACKAGES` → sinon `exit('{"error":"UNKNOWN_APP"}')`.
4. Création du client Google → clé illisible → `exit('{"error":"INVALID_PRIVATE_KEY"}')`.
5. `getSubscriptionV2(pkg, purchaseToken)` + log `[DEBUG]` (URL, code, réponse tronquée, erreur).
6. Erreur API ou abonnement non actif (`subscriptionState != SUBSCRIPTION_STATE_ACTIVE`) →
   email d'échec `[SHOP][ANDROID] Echec inApp` + `exit('{"error":"INVALID_PURCHASE"}')`.
   Aucun line item / abonnement actif trouvé → `exit('{"error":"NO_ACTIVE_SUBSCRIPTION_FOUND"}')`.
7. `endDate = date('Y-m-d', strtotime(expiryTime du dernier lineItems[]))`.
   `productName = explode('.', productId)[0]`.
8. POST au shop (`deviceType=A`) :
   ```
   endDate       = <Y-m-d calculé>
   productName   = <productId nettoyé>
   serialNumber  = <repris>
   app           = <repris>
   lang          = <repris>
   appVersion    = <repris>
   purchaseToken = <repris>            # via SHOP_TOKEN_FIELD
   purchaseType  = FIRST_PURCHASE | RENEWAL
   SELLING_TYPE  = subscription        # via SUBSCRIPTION_SELLING_KEY
   ```
9. Email de succès `[SHOP][ANDROID] Nouvel evenement inApp` (ou « premier achat » si FIRST_PURCHASE).
10. `echo` du JSON shop tel quel.

`validPaymentTest.php` : identique mais `SHOP_URL_TEST`.

## Flux — `validConsumable.php` (consommables)

**POST entrants :** `app, appVersion, lang, serialNumber, purchaseType, productId, purchaseToken`

1. Append de la requête dans le log.
2. Résolution du package (`UNKNOWN_APP`) → client Google (`INVALID_PRIVATE_KEY`).
3. `getProduct(pkg, productId, purchaseToken)` + log `[DEBUG]`.
4. Échec API → email d'échec + `exit('{"error":"GOOGLE_VERIFICATION_FAILED",...}')`.
   `purchaseState != 0` (non « purchased ») ou `productId` Google différent du `productId`
   reçu → `exit('{"error":"PRODUCT_ID_MISMATCH","expected":"...","got":"..."}')`.
5. `acknowledgeProduct(...)` (best-effort, non bloquant, échec loggé seulement).
6. POST au shop :
   ```
   productName   = <productId>
   serialNumber  = <repris>
   app           = <repris>
   lang          = <repris>
   appVersion    = <repris>
   purchaseType  = <repris>
   purchaseToken = <repris>            # via SHOP_TOKEN_FIELD
   sellingType   = consumable          # via CONSUMABLE_SELLING_KEY
   ```
7. Email de succès `[SHOP][ANDROID] Nouvel achat inApp Consumable`.
8. Si la réponse shop contient `currentPermanentToken` → cast en `(int)` puis `echo` du JSON
   ré-encodé ; sinon `echo` brut. (Comportement identique à iOS.)

`validConsumableTest.php` : identique mais `SHOP_URL_TEST`.

## `googlePlayNotifications.php` (webhooks RTDN)

Équivalent de `appleServerNotifications.php`. Réceptionne les Real-time Developer
Notifications via Pub/Sub push :

1. Lecture du corps brut (`php://input`), `json_decode`.
2. Extraction de `message.data`, décodage base64, `json_decode` du payload Google.
3. Log du payload complet dans `../log_inApp.txt` + email debug `[SHOP][ANDROID]` aux
   destinataires (payload + champs reçus).
4. Réponse `HTTP 200`.

Logique métier (renouvellements, expirations, remboursements) branchée plus tard, comme
côté iOS.

## Codes d'erreur (JSON brut `{"error":"…"}`)

| Code | Condition |
|------|-----------|
| `UNKNOWN_APP` | `app` absent de `APP_PACKAGES` |
| `INVALID_PRIVATE_KEY` | clé service account illisible |
| `INVALID_PURCHASE` | abonnement non actif / erreur de vérification (abos) |
| `NO_ACTIVE_SUBSCRIPTION_FOUND` | aucun line item actif (abos) |
| `GOOGLE_VERIFICATION_FAILED` | échec API Google (consommables) |
| `PRODUCT_ID_MISMATCH` | `purchaseState != 0` ou productId différent (consommables) |

## Comportements transverses (identiques à iOS)

- **Logs :** append de chaque requête dans `../log_inApp.txt` (date, purchaseType,
  serialNumber, app, appVersion, purchaseToken) + lignes `[DEBUG]` pour les appels Google.
- **Emails :** à chaque achat (succès/échec) via `mail()`, sujets `[SHOP][ANDROID] …`
  (remplaçant `[IOS]`), avec le détail de tous les params `$_POST` reçus.
- **Variantes Test :** `validPaymentTest.php` / `validConsumableTest.php` pointant sur
  `testshop.dynseo.com`.

## Tableau de correspondance iOS → Android

| iOS | Android |
|-----|---------|
| `receipt` / `transactionId` | `purchaseToken` (+ `productId`) |
| `verifyReceipt` / App Store Server API | Google Play Developer API (androidpublisher v3) |
| JWT ES256 + clé `.p8` | Service account OAuth2 + clé JSON (via `google/apiclient`) |
| App Store Server Notifications | Real-time Developer Notifications (Pub/Sub) |
| `deviceType=I` | `deviceType=A` *(à confirmer)* |
| `expires_date_ms` → `endDate` | `expiryTime` (subscriptionsv2) → `endDate` |
| `SELLING_TYPE=subscription` / `sellingType=consumable` | identique |

## Tests

Pas de credentials de test live pour les packages Stimart au démarrage, donc :

1. `php -l` (lint) sur chaque fichier.
2. Petit harnais de test injectant un faux `GooglePlay` pour exercer les branches des
   endpoints (abonnement actif / inactif / erreur API / productId mismatch / unknown app /
   clé illisible) et vérifier la chaîne POST shop exacte et le JSON d'erreur attendu.
3. Test end-to-end manuel contre `testshop.dynseo.com` une fois qu'un vrai `purchaseToken`
   et la clé SA des packages Stimart sont disponibles.

## Points à confirmer avant intégration finale

1. Valeur exacte de `deviceType` attendue par le shop pour Android (`A` ?).
2. Nom de paramètre exact attendu par le shop pour le token Android (`purchaseToken` vs
   `receipt`/`transactionId`).
3. Incohérence de casse `SELLING_TYPE` (abos) vs `sellingType` (consommables) — ce que le
   shop lit réellement.
4. Service account Dynseo ayant accès aux 3 packages Stimart (la clé `budget-de-couple`
   présente dans le dépôt ne couvre que `com.budgetdecouple.app`) — clé JSON à fournir et
   à déposer dans `credentials/`.
