# Backend de validation des achats In-App Google Play (PHP) — Plan d'implémentation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Construire un backend PHP (hébergé sur android.dynseo.com) qui valide les achats In-App Google Play des apps Stimart et relaie l'achat validé au serveur shop, en respectant le contrat I/O exact du backend iOS.

**Architecture:** Endpoints PHP fins (`htdocs/*.php`) qui lisent `$_POST`, construisent les dépendances réelles (Google Play via `google/apiclient`, shop via curl, mail, logs) et délèguent à des **handlers** purs et testables (`lib/handlers.php`). Les handlers dépendent d'interfaces (`GooglePlayInterface`, `ShopClient`, `Mailer`, `Logger`), donc testables avec des fakes. Les cas d'erreur sont signalés par des exceptions (`PurchaseError` portant le JSON à renvoyer ; `GooglePlayException` pour les échecs API). Les valeurs « à confirmer » (deviceType, nom du champ token, casse selling) sont des constantes de config modifiables en une ligne.

**Tech Stack:** PHP 8.1+, Composer, `google/apiclient` ^2.15 (Google Play Developer API v3 / androidpublisher), PHPUnit ^10 pour les tests.

---

## Structure des fichiers

| Fichier | Responsabilité |
|---------|----------------|
| `composer.json` | Dépendances + autoload |
| `phpunit.xml` | Config tests |
| `htdocs/lib/exceptions.php` | `PurchaseError`, `GooglePlayException` |
| `htdocs/lib/interfaces.php` | `GooglePlayInterface`, `ShopClient`, `Mailer`, `Logger` |
| `htdocs/lib/config.php` | `inapp_config()` — map app→package, URLs, constantes à confirmer |
| `htdocs/lib/handlers.php` | `handleSubscription()`, `handleConsumable()` — logique pure testable |
| `htdocs/lib/notifications.php` | `decodeRtdn()` — décodage payload Pub/Sub |
| `htdocs/lib/GooglePlay.php` | Adapter réel `google/apiclient` (pas en autoload unitaire) |
| `htdocs/lib/io.php` | `CurlShopClient`, `PhpMailer`, `FileLogger` (pas en autoload unitaire) |
| `htdocs/validPayment.php` | Endpoint abonnements → shop prod |
| `htdocs/validPaymentTest.php` | Endpoint abonnements → testshop |
| `htdocs/validConsumable.php` | Endpoint consommables → shop prod |
| `htdocs/validConsumableTest.php` | Endpoint consommables → testshop |
| `htdocs/googlePlayNotifications.php` | Webhook RTDN |
| `tests/Fakes.php` | Implémentations fakes des interfaces |
| `tests/SubscriptionHandlerTest.php` | Tests handler abonnements |
| `tests/ConsumableHandlerTest.php` | Tests handler consommables |
| `tests/NotificationsTest.php` | Tests `decodeRtdn()` |
| `credentials/service-account.json` | Clé SA partagée (gitignored) |

---

## Task 1: Setup Composer, autoload et squelette de dossiers

**Files:**
- Create: `composer.json`
- Create: `phpunit.xml`
- Create: `htdocs/lib/` (dossier)
- Create: `tests/` (dossier)
- Modify: `.gitignore`

- [ ] **Step 1: Créer `composer.json`**

```json
{
    "name": "dynseo/android-inapp",
    "description": "Backend de validation des achats In-App Google Play pour les apps Stimart",
    "require": {
        "php": ">=8.1",
        "google/apiclient": "^2.15"
    },
    "require-dev": {
        "phpunit/phpunit": "^10"
    },
    "autoload": {
        "files": [
            "htdocs/lib/exceptions.php",
            "htdocs/lib/interfaces.php",
            "htdocs/lib/config.php",
            "htdocs/lib/handlers.php",
            "htdocs/lib/notifications.php"
        ]
    }
}
```

Note : `GooglePlay.php` et `io.php` ne sont **pas** en autoload — ils sont requis explicitement par les endpoints, pour que les tests unitaires ne chargent jamais le SDK Google ni `mail()`/`curl`.

- [ ] **Step 2: Créer `phpunit.xml`**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="inapp">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>
```

- [ ] **Step 3: Créer les dossiers et un .gitkeep**

Run: `mkdir -p htdocs/lib tests credentials && touch credentials/.gitkeep`

- [ ] **Step 4: Mettre à jour `.gitignore`**

Ajouter à la fin de `.gitignore` :

```
# Dépendances PHP
/vendor/

# Clé service account Google (NE JAMAIS COMMITER)
credentials/service-account.json
```

- [ ] **Step 5: Installer les dépendances**

Run: `composer install`
Expected: `vendor/` créé, `google/apiclient` et `phpunit` installés, message « Generating autoload files ».

- [ ] **Step 6: Commit**

```bash
git add composer.json composer.lock phpunit.xml .gitignore credentials/.gitkeep
git commit -m "chore: setup composer, phpunit et squelette du backend PHP In-App"
```

---

## Task 2: Exceptions

**Files:**
- Create: `htdocs/lib/exceptions.php`
- Test: `tests/SubscriptionHandlerTest.php` (les exceptions seront exercées indirectement dès Task 5)

- [ ] **Step 1: Écrire `htdocs/lib/exceptions.php`**

```php
<?php

/**
 * Erreur métier renvoyée à l'app sous forme de JSON brut (ex. {"error":"UNKNOWN_APP"}).
 * Le body est exactement ce qui doit être echo + exit côté endpoint.
 */
class PurchaseError extends \Exception
{
    public string $body;

    public function __construct(string $body)
    {
        parent::__construct($body);
        $this->body = $body;
    }
}

/**
 * Échec d'un appel à la Google Play Developer API (code HTTP + message).
 * Attrapée par chaque handler qui la mappe vers le bon PurchaseError.
 */
class GooglePlayException extends \Exception
{
    public int $httpCode;

    public function __construct(string $message, int $httpCode = 0)
    {
        parent::__construct($message);
        $this->httpCode = $httpCode;
    }
}
```

- [ ] **Step 2: Lint**

Run: `php -l htdocs/lib/exceptions.php`
Expected: `No syntax errors detected in htdocs/lib/exceptions.php`

- [ ] **Step 3: Commit**

```bash
git add htdocs/lib/exceptions.php
git commit -m "feat: exceptions PurchaseError et GooglePlayException"
```

---

## Task 3: Interfaces

**Files:**
- Create: `htdocs/lib/interfaces.php`

- [ ] **Step 1: Écrire `htdocs/lib/interfaces.php`**

```php
<?php

/**
 * Accès à la Google Play Developer API. Les méthodes renvoient des tableaux
 * normalisés (et non des objets SDK) pour que les handlers restent simples
 * et testables avec des fakes.
 *
 * @throws GooglePlayException en cas d'échec d'appel API
 * @throws PurchaseError       (INVALID_PRIVATE_KEY) si la clé SA est illisible
 */
interface GooglePlayInterface
{
    /** @return array{subscriptionState:?string, lineItems:array<array{expiryTime:?string, productId:?string}>} */
    public function getSubscription(string $packageName, string $purchaseToken): array;

    /** @return array{purchaseState:?int, acknowledgementState:?int, productId:?string} */
    public function getProduct(string $packageName, string $productId, string $purchaseToken): array;

    public function acknowledgeProduct(string $packageName, string $productId, string $purchaseToken): void;
}

interface ShopClient
{
    /** POST x-www-form-urlencoded ; renvoie le corps brut de la réponse. */
    public function post(string $url, array $fields): string;
}

interface Mailer
{
    public function send(string $recipients, string $subject, string $text): void;
}

interface Logger
{
    public function append(string $text): void;
}
```

- [ ] **Step 2: Lint**

Run: `php -l htdocs/lib/interfaces.php`
Expected: `No syntax errors detected`

- [ ] **Step 3: Commit**

```bash
git add htdocs/lib/interfaces.php
git commit -m "feat: interfaces GooglePlay/Shop/Mailer/Logger"
```

---

## Task 4: Config

**Files:**
- Create: `htdocs/lib/config.php`
- Test: `tests/ConfigTest.php`

- [ ] **Step 1: Écrire le test `tests/ConfigTest.php`**

```php
<?php

use PHPUnit\Framework\TestCase;

class ConfigTest extends TestCase
{
    public function test_mappe_les_trois_apps_stimart(): void
    {
        $c = inapp_config();
        $this->assertSame('com.dynseo.stimart.edith', $c['APP_PACKAGES']['EHPAD']);
        $this->assertSame('com.dynseo.stimart.joe', $c['APP_PACKAGES']['PAPY']);
        $this->assertSame('com.dynseo.stimart.coco', $c['APP_PACKAGES']['COCO']);
    }

    public function test_valeurs_a_confirmer_par_defaut(): void
    {
        $c = inapp_config();
        $this->assertSame('A', $c['SHOP_DEVICE_TYPE']);
        $this->assertSame('purchaseToken', $c['SHOP_TOKEN_FIELD']);
        $this->assertSame('SELLING_TYPE', $c['SUBSCRIPTION_SELLING_KEY']);
        $this->assertSame('sellingType', $c['CONSUMABLE_SELLING_KEY']);
    }
}
```

- [ ] **Step 2: Lancer le test (échec attendu)**

Run: `vendor/bin/phpunit tests/ConfigTest.php`
Expected: FAIL — `Call to undefined function inapp_config()`

- [ ] **Step 3: Écrire `htdocs/lib/config.php`**

```php
<?php

/**
 * Configuration centrale du backend.
 *
 * Les entrées marquées « À CONFIRMER » sont les seuls points incertains du
 * contrat shop : elles se changent ici en une ligne sans toucher au reste.
 */
function inapp_config(): array
{
    return [
        // Mapping code app (envoyé en clair par l'app) -> package Android
        'APP_PACKAGES' => [
            'EHPAD' => 'com.dynseo.stimart.edith',
            'PAPY'  => 'com.dynseo.stimart.joe',
            'COCO'  => 'com.dynseo.stimart.coco',
        ],

        // URLs shop (le &deviceType=... est ajouté par l'endpoint)
        'SHOP_URL_PROD' => 'https://shop.dynseo.com/shop?service=inAppPaymentConfirmation',
        'SHOP_URL_TEST' => 'https://testshop.dynseo.com/shop?service=inAppPaymentConfirmation',

        // À CONFIRMER avec l'équipe shop
        'SHOP_DEVICE_TYPE'        => 'A',
        'SHOP_TOKEN_FIELD'        => 'purchaseToken',  // sinon 'receipt' (abos) / 'transactionId' (conso)
        'SUBSCRIPTION_SELLING_KEY' => 'SELLING_TYPE',  // incohérence de casse iOS conservée
        'CONSUMABLE_SELLING_KEY'   => 'sellingType',

        // Destinataires emails (prod)
        'MAIL_RECIPIENTS' => 'sauquet@itssauquet.com, justine.monsaingeon@dynseo.com, pierrecome@dynseo.com',

        // Chemins (le SA est hors racine web en prod ; ../.. depuis htdocs/lib)
        'SERVICE_ACCOUNT_PATH' => __DIR__ . '/../../credentials/service-account.json',
        'LOG_PATH'             => __DIR__ . '/../../log_inApp.txt',
    ];
}
```

- [ ] **Step 4: Lancer le test (succès attendu)**

Run: `vendor/bin/phpunit tests/ConfigTest.php`
Expected: PASS (2 tests, 5 assertions)

- [ ] **Step 5: Commit**

```bash
git add htdocs/lib/config.php tests/ConfigTest.php
git commit -m "feat: config centrale (mapping apps + constantes a confirmer)"
```

---

## Task 5: Fakes de test

**Files:**
- Create: `tests/Fakes.php`

Ces fakes implémentent les interfaces et enregistrent les appels, pour vérifier précisément ce que les handlers envoient.

- [ ] **Step 1: Écrire `tests/Fakes.php`**

```php
<?php

class FakeGooglePlay implements GooglePlayInterface
{
    public array $subscription = ['subscriptionState' => 'SUBSCRIPTION_STATE_ACTIVE', 'lineItems' => []];
    public array $product = ['purchaseState' => 0, 'acknowledgementState' => 1, 'productId' => null];
    public ?\Throwable $throwOnGet = null;
    public array $acknowledged = [];

    public function getSubscription(string $packageName, string $purchaseToken): array
    {
        if ($this->throwOnGet) throw $this->throwOnGet;
        return $this->subscription;
    }

    public function getProduct(string $packageName, string $productId, string $purchaseToken): array
    {
        if ($this->throwOnGet) throw $this->throwOnGet;
        return $this->product;
    }

    public function acknowledgeProduct(string $packageName, string $productId, string $purchaseToken): void
    {
        $this->acknowledged[] = [$packageName, $productId, $purchaseToken];
    }
}

class FakeShop implements ShopClient
{
    public string $lastUrl = '';
    public array $lastFields = [];
    public string $response = '{"status":"ok"}';

    public function post(string $url, array $fields): string
    {
        $this->lastUrl = $url;
        $this->lastFields = $fields;
        return $this->response;
    }
}

class FakeMailer implements Mailer
{
    public array $sent = [];
    public function send(string $recipients, string $subject, string $text): void
    {
        $this->sent[] = ['recipients' => $recipients, 'subject' => $subject, 'text' => $text];
    }
}

class FakeLogger implements Logger
{
    public array $lines = [];
    public function append(string $text): void { $this->lines[] = $text; }
}

/** Config de test minimale, indépendante de inapp_config(). */
function test_config(): array
{
    return [
        'APP_PACKAGES' => ['EHPAD' => 'com.dynseo.stimart.edith'],
        'SHOP_URL'     => 'https://shop.example/shop?service=inAppPaymentConfirmation&deviceType=A',
        'SHOP_TOKEN_FIELD'        => 'purchaseToken',
        'SUBSCRIPTION_SELLING_KEY' => 'SELLING_TYPE',
        'CONSUMABLE_SELLING_KEY'   => 'sellingType',
        'MAIL_RECIPIENTS' => 'test@example.com',
    ];
}
```

- [ ] **Step 2: Lint**

Run: `php -l tests/Fakes.php`
Expected: `No syntax errors detected`

- [ ] **Step 3: Commit**

```bash
git add tests/Fakes.php
git commit -m "test: fakes pour GooglePlay/Shop/Mailer/Logger"
```

---

## Task 6: Handler abonnements (`handleSubscription`)

**Files:**
- Create: `htdocs/lib/handlers.php`
- Test: `tests/SubscriptionHandlerTest.php`

- [ ] **Step 1: Écrire le test `tests/SubscriptionHandlerTest.php`**

```php
<?php

use PHPUnit\Framework\TestCase;

require_once __DIR__ . '/Fakes.php';

class SubscriptionHandlerTest extends TestCase
{
    private function post(array $over = []): array
    {
        return array_merge([
            'serialNumber' => 'SN1', 'app' => 'EHPAD', 'lang' => 'fr',
            'appVersion' => '1.0', 'purchaseType' => 'PURCHASE',
            'productId' => 'stimart.edith.sub.monthly', 'purchaseToken' => 'TOK',
        ], $over);
    }

    public function test_app_inconnue_renvoie_UNKNOWN_APP(): void
    {
        $this->expectException(PurchaseError::class);
        $this->expectExceptionMessage('{"error":"UNKNOWN_APP"}');
        handleSubscription($this->post(['app' => 'XXX']), test_config(),
            new FakeGooglePlay(), new FakeShop(), new FakeMailer(), new FakeLogger());
    }

    public function test_sans_line_item_renvoie_NO_ACTIVE_SUBSCRIPTION_FOUND(): void
    {
        $g = new FakeGooglePlay();
        $g->subscription = ['subscriptionState' => 'SUBSCRIPTION_STATE_ACTIVE', 'lineItems' => []];
        $this->expectException(PurchaseError::class);
        $this->expectExceptionMessage('{"error":"NO_ACTIVE_SUBSCRIPTION_FOUND"}');
        handleSubscription($this->post(), test_config(), $g, new FakeShop(), new FakeMailer(), new FakeLogger());
    }

    public function test_etat_non_actif_renvoie_INVALID_PURCHASE_et_envoie_email(): void
    {
        $g = new FakeGooglePlay();
        $g->subscription = ['subscriptionState' => 'SUBSCRIPTION_STATE_EXPIRED',
            'lineItems' => [['expiryTime' => '2020-01-01T00:00:00Z', 'productId' => 'p']]];
        $mailer = new FakeMailer();
        try {
            handleSubscription($this->post(), test_config(), $g, new FakeShop(), $mailer, new FakeLogger());
            $this->fail('PurchaseError attendue');
        } catch (PurchaseError $e) {
            $this->assertSame('{"error":"INVALID_PURCHASE"}', $e->body);
        }
        $this->assertCount(1, $mailer->sent);
        $this->assertStringContainsString('[SHOP][ANDROID]', $mailer->sent[0]['subject']);
    }

    public function test_erreur_api_renvoie_INVALID_PURCHASE_et_envoie_email(): void
    {
        $g = new FakeGooglePlay();
        $g->throwOnGet = new GooglePlayException('boom', 500);
        $mailer = new FakeMailer();
        try {
            handleSubscription($this->post(), test_config(), $g, new FakeShop(), $mailer, new FakeLogger());
            $this->fail('PurchaseError attendue');
        } catch (PurchaseError $e) {
            $this->assertSame('{"error":"INVALID_PURCHASE"}', $e->body);
        }
        $this->assertCount(1, $mailer->sent);
    }

    public function test_achat_valide_poste_les_bons_champs_au_shop(): void
    {
        $g = new FakeGooglePlay();
        $g->subscription = ['subscriptionState' => 'SUBSCRIPTION_STATE_ACTIVE', 'lineItems' => [
            ['expiryTime' => '2026-01-01T00:00:00Z', 'productId' => 'p1'],
            ['expiryTime' => '2026-07-15T10:00:00Z', 'productId' => 'p2'],
        ]];
        $shop = new FakeShop();
        $out = handleSubscription($this->post(), test_config(), $g, $shop, new FakeMailer(), new FakeLogger());

        $this->assertSame('{"status":"ok"}', $out);
        $f = $shop->lastFields;
        $this->assertSame('2026-07-15', $f['endDate']);            // dernier expiryTime
        $this->assertSame('stimart', $f['productName']);            // explode('.', productId)[0]
        $this->assertSame('SN1', $f['serialNumber']);
        $this->assertSame('EHPAD', $f['app']);
        $this->assertSame('fr', $f['lang']);
        $this->assertSame('1.0', $f['appVersion']);
        $this->assertSame('TOK', $f['purchaseToken']);             // SHOP_TOKEN_FIELD
        $this->assertSame('FIRST_PURCHASE', $f['purchaseType']);   // PURCHASE -> FIRST_PURCHASE
        $this->assertSame('subscription', $f['SELLING_TYPE']);
    }

    public function test_purchaseType_non_PURCHASE_devient_RENEWAL(): void
    {
        $g = new FakeGooglePlay();
        $g->subscription = ['subscriptionState' => 'SUBSCRIPTION_STATE_ACTIVE',
            'lineItems' => [['expiryTime' => '2026-07-15T10:00:00Z', 'productId' => 'p']]];
        $shop = new FakeShop();
        handleSubscription($this->post(['purchaseType' => 'RESTORE']), test_config(), $g, $shop, new FakeMailer(), new FakeLogger());
        $this->assertSame('RENEWAL', $shop->lastFields['purchaseType']);
    }
}
```

- [ ] **Step 2: Lancer le test (échec attendu)**

Run: `vendor/bin/phpunit tests/SubscriptionHandlerTest.php`
Expected: FAIL — `Call to undefined function handleSubscription()`

- [ ] **Step 3: Écrire `htdocs/lib/handlers.php` (partie abonnements)**

```php
<?php

/**
 * Dump de tous les params POST (pour les emails de debug), comme côté iOS.
 */
function post_dump(array $post): string
{
    $text = "\n\nTest des params recu en post :\n\n";
    foreach ($post as $key => $value) {
        if ($value !== null && $value !== '') {
            $text .= $key . ' = ' . $value . "\n\n";
        }
    }
    return $text;
}

/**
 * Valide un ABONNEMENT Google Play et le relaie au shop.
 * Renvoie le corps de réponse du shop (à echo). Lève PurchaseError pour
 * tous les cas d'arrêt (UNKNOWN_APP, INVALID_PRIVATE_KEY, INVALID_PURCHASE,
 * NO_ACTIVE_SUBSCRIPTION_FOUND).
 */
function handleSubscription(array $post, array $config, GooglePlayInterface $google, ShopClient $shop, Mailer $mailer, Logger $logger): string
{
    $purchaseType  = ($post['purchaseType'] ?? '') === 'PURCHASE' ? 'FIRST_PURCHASE' : 'RENEWAL';
    $serialNumber  = $post['serialNumber'] ?? '';
    $app           = $post['app'] ?? '';
    $appVersion    = $post['appVersion'] ?? '';
    $lang          = $post['lang'] ?? '';
    $productId     = $post['productId'] ?? '';
    $purchaseToken = $post['purchaseToken'] ?? '';

    $logger->append("\n" . date('j.n.Y') . ' ' . $purchaseType . ' ' . $serialNumber . ' ' . $app . ' ' . $appVersion . "\n" . $purchaseToken);

    if (!isset($config['APP_PACKAGES'][$app])) {
        throw new PurchaseError('{"error":"UNKNOWN_APP"}');
    }
    $packageName = $config['APP_PACKAGES'][$app];

    try {
        $sub = $google->getSubscription($packageName, $purchaseToken); // peut lever PurchaseError(INVALID_PRIVATE_KEY)
    } catch (GooglePlayException $e) {
        $logger->append('[DEBUG] subscriptionsv2 FAILED http=' . $e->httpCode . ' ' . substr($e->getMessage(), 0, 500));
        $mailer->send($config['MAIL_RECIPIENTS'], '[SHOP][ANDROID] Echec inApp',
            'Google verification failed (HTTP ' . $e->httpCode . ') : ' . $e->getMessage() . post_dump($post));
        throw new PurchaseError('{"error":"INVALID_PURCHASE"}');
    }

    $logger->append('[DEBUG] subscriptionsv2 state=' . ($sub['subscriptionState'] ?? 'null'));

    $lineItems = $sub['lineItems'] ?? [];
    if (empty($lineItems)) {
        throw new PurchaseError('{"error":"NO_ACTIVE_SUBSCRIPTION_FOUND"}');
    }
    if (($sub['subscriptionState'] ?? '') !== 'SUBSCRIPTION_STATE_ACTIVE') {
        $mailer->send($config['MAIL_RECIPIENTS'], '[SHOP][ANDROID] Echec inApp',
            'Subscription not active : ' . ($sub['subscriptionState'] ?? 'null') . post_dump($post));
        throw new PurchaseError('{"error":"INVALID_PURCHASE"}');
    }

    $expiryTimes = array_map(fn($li) => strtotime($li['expiryTime'] ?? ''), $lineItems);
    $endDate     = date('Y-m-d', max($expiryTimes));
    $productName = explode('.', $productId)[0];

    $fields = [
        'endDate'                          => $endDate,
        'productName'                      => $productName,
        'serialNumber'                     => $serialNumber,
        'app'                              => $app,
        'lang'                             => $lang,
        'appVersion'                       => $appVersion,
        $config['SHOP_TOKEN_FIELD']        => $purchaseToken,
        'purchaseType'                     => $purchaseType,
        $config['SUBSCRIPTION_SELLING_KEY'] => 'subscription',
    ];
    $response = $shop->post($config['SHOP_URL'], $fields);

    $subject = $purchaseType === 'FIRST_PURCHASE'
        ? '[SHOP][ANDROID] Nouvel achat inApp - premier achat'
        : '[SHOP][ANDROID] Nouvel evenement inApp';
    $mailer->send($config['MAIL_RECIPIENTS'], $subject,
        'Product id : ' . $productId . "\n\nEndDate : " . $endDate . "\n\nServer response : " . $response . post_dump($post));

    return $response;
}
```

- [ ] **Step 4: Lancer le test (succès attendu)**

Run: `vendor/bin/phpunit tests/SubscriptionHandlerTest.php`
Expected: PASS (6 tests)

- [ ] **Step 5: Commit**

```bash
git add htdocs/lib/handlers.php tests/SubscriptionHandlerTest.php
git commit -m "feat: handleSubscription + tests (abonnements)"
```

---

## Task 7: Handler consommables (`handleConsumable`)

**Files:**
- Modify: `htdocs/lib/handlers.php` (ajouter `handleConsumable`)
- Test: `tests/ConsumableHandlerTest.php`

- [ ] **Step 1: Écrire le test `tests/ConsumableHandlerTest.php`**

```php
<?php

use PHPUnit\Framework\TestCase;

require_once __DIR__ . '/Fakes.php';

class ConsumableHandlerTest extends TestCase
{
    private function post(array $over = []): array
    {
        return array_merge([
            'app' => 'EHPAD', 'appVersion' => '1.0', 'lang' => 'fr', 'serialNumber' => 'SN1',
            'purchaseType' => 'PURCHASE', 'productId' => 'coins_100', 'purchaseToken' => 'TOK',
        ], $over);
    }

    public function test_app_inconnue_renvoie_UNKNOWN_APP(): void
    {
        $this->expectException(PurchaseError::class);
        $this->expectExceptionMessage('{"error":"UNKNOWN_APP"}');
        handleConsumable($this->post(['app' => 'XXX']), test_config(),
            new FakeGooglePlay(), new FakeShop(), new FakeMailer(), new FakeLogger());
    }

    public function test_erreur_api_renvoie_GOOGLE_VERIFICATION_FAILED_et_email(): void
    {
        $g = new FakeGooglePlay();
        $g->throwOnGet = new GooglePlayException('not found', 404);
        $mailer = new FakeMailer();
        try {
            handleConsumable($this->post(), test_config(), $g, new FakeShop(), $mailer, new FakeLogger());
            $this->fail('PurchaseError attendue');
        } catch (PurchaseError $e) {
            $this->assertSame('{"error":"GOOGLE_VERIFICATION_FAILED","httpCode":404}', $e->body);
        }
        $this->assertCount(1, $mailer->sent);
    }

    public function test_purchaseState_non_zero_renvoie_PRODUCT_ID_MISMATCH(): void
    {
        $g = new FakeGooglePlay();
        $g->product = ['purchaseState' => 1, 'acknowledgementState' => 1, 'productId' => null];
        $this->expectException(PurchaseError::class);
        $this->expectExceptionMessageMatches('/PRODUCT_ID_MISMATCH/');
        handleConsumable($this->post(), test_config(), $g, new FakeShop(), new FakeMailer(), new FakeLogger());
    }

    public function test_productId_different_renvoie_PRODUCT_ID_MISMATCH(): void
    {
        $g = new FakeGooglePlay();
        $g->product = ['purchaseState' => 0, 'acknowledgementState' => 1, 'productId' => 'autre_produit'];
        try {
            handleConsumable($this->post(), test_config(), $g, new FakeShop(), new FakeMailer(), new FakeLogger());
            $this->fail('PurchaseError attendue');
        } catch (PurchaseError $e) {
            $this->assertStringContainsString('"expected":"coins_100"', $e->body);
            $this->assertStringContainsString('"got":"autre_produit"', $e->body);
        }
    }

    public function test_achat_valide_acknowledge_et_poste_au_shop(): void
    {
        $g = new FakeGooglePlay();
        $g->product = ['purchaseState' => 0, 'acknowledgementState' => 0, 'productId' => 'coins_100'];
        $shop = new FakeShop();
        $out = handleConsumable($this->post(), test_config(), $g, $shop, new FakeMailer(), new FakeLogger());

        $this->assertSame('{"status":"ok"}', $out);
        $this->assertCount(1, $g->acknowledged);                       // acknowledge appelé
        $f = $shop->lastFields;
        $this->assertSame('coins_100', $f['productName']);
        $this->assertSame('SN1', $f['serialNumber']);
        $this->assertSame('TOK', $f['purchaseToken']);
        $this->assertSame('consumable', $f['sellingType']);
        $this->assertSame('PURCHASE', $f['purchaseType']);             // repris tel quel
    }

    public function test_deja_acknowledge_ne_rappelle_pas_acknowledge(): void
    {
        $g = new FakeGooglePlay();
        $g->product = ['purchaseState' => 0, 'acknowledgementState' => 1, 'productId' => 'coins_100'];
        handleConsumable($this->post(), test_config(), $g, new FakeShop(), new FakeMailer(), new FakeLogger());
        $this->assertCount(0, $g->acknowledged);
    }

    public function test_currentPermanentToken_caste_en_int(): void
    {
        $g = new FakeGooglePlay();
        $g->product = ['purchaseState' => 0, 'acknowledgementState' => 1, 'productId' => 'coins_100'];
        $shop = new FakeShop();
        $shop->response = '{"currentPermanentToken":"42","x":"y"}';
        $out = handleConsumable($this->post(), test_config(), $g, $shop, new FakeMailer(), new FakeLogger());
        $decoded = json_decode($out, true);
        $this->assertSame(42, $decoded['currentPermanentToken']);
        $this->assertSame('y', $decoded['x']);
    }
}
```

- [ ] **Step 2: Lancer le test (échec attendu)**

Run: `vendor/bin/phpunit tests/ConsumableHandlerTest.php`
Expected: FAIL — `Call to undefined function handleConsumable()`

- [ ] **Step 3: Ajouter `handleConsumable` à la fin de `htdocs/lib/handlers.php`** (avant la fin du fichier, après `handleSubscription`)

```php

/**
 * Valide un CONSOMMABLE Google Play, l'acknowledge (best-effort), puis le relaie
 * au shop. Renvoie le corps de réponse du shop (à echo, currentPermanentToken
 * casté en int si présent). Lève PurchaseError pour les cas d'arrêt.
 */
function handleConsumable(array $post, array $config, GooglePlayInterface $google, ShopClient $shop, Mailer $mailer, Logger $logger): string
{
    $app           = $post['app'] ?? '';
    $appVersion    = $post['appVersion'] ?? '';
    $lang          = $post['lang'] ?? '';
    $serialNumber  = $post['serialNumber'] ?? '';
    $purchaseType  = $post['purchaseType'] ?? '';
    $productId     = $post['productId'] ?? '';
    $purchaseToken = $post['purchaseToken'] ?? '';

    $logger->append("\n" . date('j.n.Y') . ' ' . $purchaseType . ' ' . $serialNumber . ' ' . $app . ' ' . $appVersion . ' purchaseToken:' . $purchaseToken);

    if (!isset($config['APP_PACKAGES'][$app])) {
        throw new PurchaseError('{"error":"UNKNOWN_APP"}');
    }
    $packageName = $config['APP_PACKAGES'][$app];

    try {
        $product = $google->getProduct($packageName, $productId, $purchaseToken); // peut lever PurchaseError(INVALID_PRIVATE_KEY)
    } catch (GooglePlayException $e) {
        $logger->append('[DEBUG] products.get FAILED http=' . $e->httpCode . ' ' . substr($e->getMessage(), 0, 500));
        $mailer->send($config['MAIL_RECIPIENTS'], '[SHOP][ANDROID] Echec inApp Consumable',
            'HTTP Code : ' . $e->httpCode . "\n\nResponse : " . $e->getMessage() . post_dump($post));
        throw new PurchaseError('{"error":"GOOGLE_VERIFICATION_FAILED","httpCode":' . $e->httpCode . '}');
    }

    $logger->append('[DEBUG] products.get purchaseState=' . ($product['purchaseState'] ?? 'null'));

    if (($product['purchaseState'] ?? -1) !== 0) {
        throw new PurchaseError('{"error":"PRODUCT_ID_MISMATCH","reason":"purchaseState","got":"' . ($product['purchaseState'] ?? '') . '"}');
    }
    if (!empty($product['productId']) && $product['productId'] !== $productId) {
        throw new PurchaseError('{"error":"PRODUCT_ID_MISMATCH","expected":"' . $productId . '","got":"' . $product['productId'] . '"}');
    }

    if (($product['acknowledgementState'] ?? 1) === 0) {
        try {
            $google->acknowledgeProduct($packageName, $productId, $purchaseToken);
        } catch (\Throwable $e) {
            $logger->append('[DEBUG] acknowledge FAILED ' . $e->getMessage());
        }
    }

    $fields = [
        'productName'                    => $productId,
        'serialNumber'                   => $serialNumber,
        'app'                            => $app,
        'lang'                           => $lang,
        'appVersion'                     => $appVersion,
        'purchaseType'                   => $purchaseType,
        $config['SHOP_TOKEN_FIELD']      => $purchaseToken,
        $config['CONSUMABLE_SELLING_KEY'] => 'consumable',
    ];
    $response = $shop->post($config['SHOP_URL'], $fields);

    $mailer->send($config['MAIL_RECIPIENTS'], '[SHOP][ANDROID] Nouvel achat inApp Consumable',
        'Product id : ' . $productId . "\n\nSelling type : consumable\n\nServer response : " . $response . post_dump($post));

    $decoded = json_decode($response, true);
    if (is_array($decoded) && isset($decoded['currentPermanentToken'])) {
        $decoded['currentPermanentToken'] = (int) $decoded['currentPermanentToken'];
        return json_encode($decoded);
    }
    return $response;
}
```

- [ ] **Step 4: Lancer le test (succès attendu)**

Run: `vendor/bin/phpunit tests/ConsumableHandlerTest.php`
Expected: PASS (7 tests)

- [ ] **Step 5: Commit**

```bash
git add htdocs/lib/handlers.php tests/ConsumableHandlerTest.php
git commit -m "feat: handleConsumable + tests (consommables)"
```

---

## Task 8: Décodage des notifications RTDN (`decodeRtdn`)

**Files:**
- Create: `htdocs/lib/notifications.php`
- Test: `tests/NotificationsTest.php`

- [ ] **Step 1: Écrire le test `tests/NotificationsTest.php`**

```php
<?php

use PHPUnit\Framework\TestCase;

class NotificationsTest extends TestCase
{
    public function test_decode_le_payload_pubsub_base64(): void
    {
        $payload = ['version' => '1.0', 'packageName' => 'com.dynseo.stimart.edith',
            'subscriptionNotification' => ['notificationType' => 4, 'purchaseToken' => 'TK']];
        $envelope = ['message' => ['data' => base64_encode(json_encode($payload))]];

        $decoded = decodeRtdn(json_encode($envelope));
        $this->assertSame('com.dynseo.stimart.edith', $decoded['packageName']);
        $this->assertSame(4, $decoded['subscriptionNotification']['notificationType']);
    }

    public function test_enveloppe_invalide(): void
    {
        $this->assertSame(['error' => 'INVALID_ENVELOPE'], decodeRtdn('pas du json'));
    }

    public function test_enveloppe_sans_data_renvoie_l_enveloppe(): void
    {
        $decoded = decodeRtdn(json_encode(['message' => ['messageId' => '1']]));
        $this->assertArrayHasKey('envelope', $decoded);
    }
}
```

- [ ] **Step 2: Lancer le test (échec attendu)**

Run: `vendor/bin/phpunit tests/NotificationsTest.php`
Expected: FAIL — `Call to undefined function decodeRtdn()`

- [ ] **Step 3: Écrire `htdocs/lib/notifications.php`**

```php
<?php

/**
 * Décode l'enveloppe Pub/Sub d'une Real-time Developer Notification Google.
 * Renvoie le payload Google décodé, ou un tableau d'erreur explicite.
 */
function decodeRtdn(string $raw): array
{
    $env = json_decode($raw, true);
    if (!is_array($env)) {
        return ['error' => 'INVALID_ENVELOPE'];
    }
    $data = $env['message']['data'] ?? null;
    if ($data === null) {
        return ['envelope' => $env];
    }
    $payload = json_decode(base64_decode($data), true);
    return is_array($payload) ? $payload : ['error' => 'INVALID_PAYLOAD'];
}
```

- [ ] **Step 4: Lancer le test (succès attendu)**

Run: `vendor/bin/phpunit tests/NotificationsTest.php`
Expected: PASS (3 tests)

- [ ] **Step 5: Commit**

```bash
git add htdocs/lib/notifications.php tests/NotificationsTest.php
git commit -m "feat: decodeRtdn + tests (webhooks RTDN)"
```

---

## Task 9: Adapter réel GooglePlay (`google/apiclient`)

**Files:**
- Create: `htdocs/lib/GooglePlay.php`

Pas de test unitaire (nécessite réseau + clé SA réelle). On vérifie le lint et le comportement INVALID_PRIVATE_KEY via un test léger d'intégration.

- [ ] **Step 1: Écrire `htdocs/lib/GooglePlay.php`**

```php
<?php

/**
 * Adapter réel vers la Google Play Developer API (androidpublisher v3) via google/apiclient.
 * Le client est initialisé paresseusement : la clé SA n'est lue qu'au premier appel.
 */
class GooglePlay implements GooglePlayInterface
{
    private ?\Google\Service\AndroidPublisher $service = null;
    private string $keyPath;

    public function __construct(string $serviceAccountPath)
    {
        $this->keyPath = $serviceAccountPath;
    }

    private function service(): \Google\Service\AndroidPublisher
    {
        if ($this->service !== null) {
            return $this->service;
        }
        if (!is_readable($this->keyPath)) {
            throw new PurchaseError('{"error":"INVALID_PRIVATE_KEY"}');
        }
        try {
            $client = new \Google\Client();
            $client->setAuthConfig($this->keyPath);
            $client->addScope('https://www.googleapis.com/auth/androidpublisher');
            $this->service = new \Google\Service\AndroidPublisher($client);
        } catch (\Throwable $e) {
            throw new PurchaseError('{"error":"INVALID_PRIVATE_KEY"}');
        }
        return $this->service;
    }

    public function getSubscription(string $packageName, string $purchaseToken): array
    {
        try {
            $r = $this->service()->purchases_subscriptionsv2->get($packageName, $purchaseToken);
        } catch (\Google\Service\Exception $e) {
            throw new GooglePlayException($e->getMessage(), $e->getCode());
        }
        $lineItems = [];
        foreach (($r->getLineItems() ?? []) as $li) {
            $lineItems[] = ['expiryTime' => $li->getExpiryTime(), 'productId' => $li->getProductId()];
        }
        return ['subscriptionState' => $r->getSubscriptionState(), 'lineItems' => $lineItems];
    }

    public function getProduct(string $packageName, string $productId, string $purchaseToken): array
    {
        try {
            $r = $this->service()->purchases_products->get($packageName, $productId, $purchaseToken);
        } catch (\Google\Service\Exception $e) {
            throw new GooglePlayException($e->getMessage(), $e->getCode());
        }
        return [
            'purchaseState'        => $r->getPurchaseState(),
            'acknowledgementState' => $r->getAcknowledgementState(),
            'productId'            => method_exists($r, 'getProductId') ? $r->getProductId() : null,
        ];
    }

    public function acknowledgeProduct(string $packageName, string $productId, string $purchaseToken): void
    {
        $req = new \Google\Service\AndroidPublisher\ProductPurchasesAcknowledgeRequest();
        $this->service()->purchases_products->acknowledge($packageName, $productId, $purchaseToken, $req);
    }
}
```

- [ ] **Step 2: Lint**

Run: `php -l htdocs/lib/GooglePlay.php`
Expected: `No syntax errors detected`

- [ ] **Step 3: Vérifier INVALID_PRIVATE_KEY avec une clé absente**

Run:
```bash
php -r 'require "vendor/autoload.php"; require "htdocs/lib/exceptions.php"; require "htdocs/lib/interfaces.php"; require "htdocs/lib/GooglePlay.php"; $g=new GooglePlay("/tmp/nope.json"); try { $g->getProduct("p","x","t"); } catch (PurchaseError $e){ echo $e->body; }'
```
Expected: `{"error":"INVALID_PRIVATE_KEY"}`

- [ ] **Step 4: Commit**

```bash
git add htdocs/lib/GooglePlay.php
git commit -m "feat: adapter GooglePlay reel (google/apiclient androidpublisher v3)"
```

---

## Task 10: Implémentations IO réelles (shop curl, mail, logger)

**Files:**
- Create: `htdocs/lib/io.php`

- [ ] **Step 1: Écrire `htdocs/lib/io.php`**

```php
<?php

/** POST x-www-form-urlencoded vers le shop via curl. */
class CurlShopClient implements ShopClient
{
    public function post(string $url, array $fields): string
    {
        $pairs = [];
        foreach ($fields as $k => $v) {
            if ($v !== null && $v !== '') {
                $pairs[] = $k . '=' . urlencode((string) $v);
            }
        }
        $body = implode('&', $pairs);

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        $resp = curl_exec($ch);
        curl_close($ch);

        return $resp === false ? '' : $resp;
    }
}

/** Envoi d'email via mail() avec sujet encodé MIME. */
class PhpMailer implements Mailer
{
    public function send(string $recipients, string $subject, string $text): void
    {
        mail($recipients, mb_encode_mimeheader($subject), $text);
    }
}

/** Append dans le fichier de log (best-effort). */
class FileLogger implements Logger
{
    private string $path;

    public function __construct(string $path)
    {
        $this->path = $path;
    }

    public function append(string $text): void
    {
        $h = @fopen($this->path, 'a+');
        if ($h) {
            fwrite($h, $text . "\n");
            fclose($h);
        }
    }
}
```

- [ ] **Step 2: Lint**

Run: `php -l htdocs/lib/io.php`
Expected: `No syntax errors detected`

- [ ] **Step 3: Commit**

```bash
git add htdocs/lib/io.php
git commit -m "feat: implementations IO reelles (CurlShopClient, PhpMailer, FileLogger)"
```

---

## Task 11: Endpoints abonnements (`validPayment.php` + variante test)

**Files:**
- Create: `htdocs/validPayment.php`
- Create: `htdocs/validPaymentTest.php`

- [ ] **Step 1: Écrire `htdocs/validPayment.php`**

```php
<?php

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/lib/exceptions.php';
require_once __DIR__ . '/lib/interfaces.php';
require_once __DIR__ . '/lib/config.php';
require_once __DIR__ . '/lib/handlers.php';
require_once __DIR__ . '/lib/GooglePlay.php';
require_once __DIR__ . '/lib/io.php';

$config = inapp_config();
$config['SHOP_URL'] = $config['SHOP_URL_PROD'] . '&deviceType=' . $config['SHOP_DEVICE_TYPE'];

$google = new GooglePlay($config['SERVICE_ACCOUNT_PATH']);
$shop   = new CurlShopClient();
$mailer = new PhpMailer();
$logger = new FileLogger($config['LOG_PATH']);

try {
    echo handleSubscription($_POST, $config, $google, $shop, $mailer, $logger);
} catch (PurchaseError $e) {
    echo $e->body;
}
```

- [ ] **Step 2: Écrire `htdocs/validPaymentTest.php`** (identique sauf l'URL test)

```php
<?php

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/lib/exceptions.php';
require_once __DIR__ . '/lib/interfaces.php';
require_once __DIR__ . '/lib/config.php';
require_once __DIR__ . '/lib/handlers.php';
require_once __DIR__ . '/lib/GooglePlay.php';
require_once __DIR__ . '/lib/io.php';

$config = inapp_config();
$config['SHOP_URL'] = $config['SHOP_URL_TEST'] . '&deviceType=' . $config['SHOP_DEVICE_TYPE'];

$google = new GooglePlay($config['SERVICE_ACCOUNT_PATH']);
$shop   = new CurlShopClient();
$mailer = new PhpMailer();
$logger = new FileLogger($config['LOG_PATH']);

try {
    echo handleSubscription($_POST, $config, $google, $shop, $mailer, $logger);
} catch (PurchaseError $e) {
    echo $e->body;
}
```

- [ ] **Step 3: Lint**

Run: `php -l htdocs/validPayment.php && php -l htdocs/validPaymentTest.php`
Expected: `No syntax errors detected` pour les deux.

- [ ] **Step 4: Commit**

```bash
git add htdocs/validPayment.php htdocs/validPaymentTest.php
git commit -m "feat: endpoints validPayment.php (prod) et validPaymentTest.php"
```

---

## Task 12: Endpoints consommables (`validConsumable.php` + variante test)

**Files:**
- Create: `htdocs/validConsumable.php`
- Create: `htdocs/validConsumableTest.php`

- [ ] **Step 1: Écrire `htdocs/validConsumable.php`**

```php
<?php

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/lib/exceptions.php';
require_once __DIR__ . '/lib/interfaces.php';
require_once __DIR__ . '/lib/config.php';
require_once __DIR__ . '/lib/handlers.php';
require_once __DIR__ . '/lib/GooglePlay.php';
require_once __DIR__ . '/lib/io.php';

$config = inapp_config();
$config['SHOP_URL'] = $config['SHOP_URL_PROD'] . '&deviceType=' . $config['SHOP_DEVICE_TYPE'];

$google = new GooglePlay($config['SERVICE_ACCOUNT_PATH']);
$shop   = new CurlShopClient();
$mailer = new PhpMailer();
$logger = new FileLogger($config['LOG_PATH']);

try {
    echo handleConsumable($_POST, $config, $google, $shop, $mailer, $logger);
} catch (PurchaseError $e) {
    echo $e->body;
}
```

- [ ] **Step 2: Écrire `htdocs/validConsumableTest.php`** (identique sauf l'URL test)

```php
<?php

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/lib/exceptions.php';
require_once __DIR__ . '/lib/interfaces.php';
require_once __DIR__ . '/lib/config.php';
require_once __DIR__ . '/lib/handlers.php';
require_once __DIR__ . '/lib/GooglePlay.php';
require_once __DIR__ . '/lib/io.php';

$config = inapp_config();
$config['SHOP_URL'] = $config['SHOP_URL_TEST'] . '&deviceType=' . $config['SHOP_DEVICE_TYPE'];

$google = new GooglePlay($config['SERVICE_ACCOUNT_PATH']);
$shop   = new CurlShopClient();
$mailer = new PhpMailer();
$logger = new FileLogger($config['LOG_PATH']);

try {
    echo handleConsumable($_POST, $config, $google, $shop, $mailer, $logger);
} catch (PurchaseError $e) {
    echo $e->body;
}
```

- [ ] **Step 3: Lint**

Run: `php -l htdocs/validConsumable.php && php -l htdocs/validConsumableTest.php`
Expected: `No syntax errors detected` pour les deux.

- [ ] **Step 4: Commit**

```bash
git add htdocs/validConsumable.php htdocs/validConsumableTest.php
git commit -m "feat: endpoints validConsumable.php (prod) et validConsumableTest.php"
```

---

## Task 13: Endpoint webhook RTDN (`googlePlayNotifications.php`)

**Files:**
- Create: `htdocs/googlePlayNotifications.php`

- [ ] **Step 1: Écrire `htdocs/googlePlayNotifications.php`**

```php
<?php

require_once __DIR__ . '/lib/config.php';
require_once __DIR__ . '/lib/notifications.php';
require_once __DIR__ . '/lib/interfaces.php';
require_once __DIR__ . '/lib/io.php';

$config = inapp_config();
$raw    = file_get_contents('php://input');
$decoded = decodeRtdn($raw ?: '');

$logger = new FileLogger($config['LOG_PATH']);
$logger->append("\n[RTDN] " . date('j.n.Y H:i:s') . "\n" . $raw . "\n" . json_encode($decoded));

(new PhpMailer())->send(
    $config['MAIL_RECIPIENTS'],
    '[SHOP][ANDROID] Notification RTDN',
    "Raw payload :\n" . $raw . "\n\nDecoded :\n" . json_encode($decoded, JSON_PRETTY_PRINT)
);

// Toujours répondre 200 pour acquitter le message Pub/Sub.
http_response_code(200);
echo 'OK';
```

- [ ] **Step 2: Lint**

Run: `php -l htdocs/googlePlayNotifications.php`
Expected: `No syntax errors detected`

- [ ] **Step 3: Commit**

```bash
git add htdocs/googlePlayNotifications.php
git commit -m "feat: endpoint webhook RTDN googlePlayNotifications.php"
```

---

## Task 14: Suite de tests complète + documentation

**Files:**
- Modify: `README.md`
- Create: `credentials/README.md`

- [ ] **Step 1: Lancer toute la suite de tests**

Run: `vendor/bin/phpunit`
Expected: PASS, ~21 tests (Config 2, Subscription 6, Consumable 7, Notifications 3, + tout ajout). 0 failure.

- [ ] **Step 2: Lint de tous les fichiers PHP**

Run: `for f in htdocs/*.php htdocs/lib/*.php; do php -l "$f"; done`
Expected: `No syntax errors detected` pour chaque fichier.

- [ ] **Step 3: Écrire `credentials/README.md`**

```markdown
# Credentials Google Play

Déposer ici la clé du **service account** Google ayant accès aux 3 apps Stimart
(`com.dynseo.stimart.edith`, `...joe`, `...coco`) dans la Play Console :

    credentials/service-account.json

Ce fichier est **gitignored** (ne jamais le committer). En production sur
android.dynseo.com, le placer idéalement hors de la racine web et ajuster
`SERVICE_ACCOUNT_PATH` dans `htdocs/lib/config.php`.

Le service account doit avoir le rôle permettant l'accès à l'API
« Google Play Android Developer » (scope androidpublisher) pour ces packages.
```

- [ ] **Step 4: Mettre à jour `README.md`** — ajouter une section décrivant le backend PHP

Ajouter à la fin de `README.md` :

```markdown

## Backend PHP (android.dynseo.com)

Le dossier `htdocs/` contient le backend PHP déployé sur https://android.dynseo.com/,
qui valide les achats In-App Google Play des apps Stimart et les relaie au serveur shop
(contrat identique au backend iOS apple.dynseo.com).

### Endpoints

| Endpoint | Rôle |
|----------|------|
| `validPayment.php` | Abonnements → shop.dynseo.com |
| `validPaymentTest.php` | Abonnements → testshop.dynseo.com |
| `validConsumable.php` | Consommables → shop.dynseo.com |
| `validConsumableTest.php` | Consommables → testshop.dynseo.com |
| `googlePlayNotifications.php` | Webhook RTDN (Pub/Sub) |

### Installation

    composer install

Déposer la clé service account dans `credentials/service-account.json` (voir
`credentials/README.md`). Configurer les valeurs « à confirmer » dans
`htdocs/lib/config.php` (deviceType, nom du champ token, casse selling).

### Tests

    vendor/bin/phpunit

### Points à confirmer avec l'équipe shop
1. `deviceType` attendu pour Android (`A` ?).
2. Nom du champ token (`purchaseToken` vs `receipt`/`transactionId`).
3. Casse `SELLING_TYPE` (abos) vs `sellingType` (consommables).
4. Service account couvrant les 3 packages Stimart.
```

- [ ] **Step 5: Commit**

```bash
git add README.md credentials/README.md
git commit -m "docs: documentation backend PHP + credentials"
```

---

## Notes de déploiement (hors périmètre du code, pour mémoire)

- Déployer `htdocs/` comme racine web de android.dynseo.com, `vendor/` et `credentials/`
  au-dessus ou hors racine web. Lancer `composer install --no-dev` en prod.
- Le log `log_inApp.txt` doit être accessible en écriture par le process PHP (chemin
  `../../log_inApp.txt` depuis `htdocs/lib` = parent de `htdocs`).
- Configurer la Subscription Push Pub/Sub des RTDN vers `https://android.dynseo.com/googlePlayNotifications.php`.
- Confirmer les 4 points « à confirmer » avant la bascule prod et ajuster `config.php`.
