# Android renewal webhook fix — 17 September 2026

Status: locally implemented and tested, **not deployed**. No cloud delivery configuration was changed. The separate iOS Step 5 work is paused.

**Follow-up:** the status above describes the original parser patch. The user subsequently confirmed HTTP 200 on the isolated staging test topic. The actual Play topic still had no subscriber at the follow-up check. Backend environment isolation is now implemented locally; use [the routing/activation guide](android-webhook-environment-routing.md) for the next deployment and cloud setup. The original patch's scope/test totals below are historical.

## Runtime scope and compatibility

Only `PurchaseAndroidController::handleAndroidWebhook` and its new private `resolveWebhookSubscriptionState` helper change. The receipt endpoints, Android compatibility/v2 selector, acknowledgement helpers, Apple handlers, routes, configuration and migrations are unchanged.

Google RTDN messages do not carry an app version. This fix supports purchases from older apps (including 1.4.4), versionless clients and newer apps regardless of `ANDROID_PURCHASE_NEW_FLOW_ENABLED`. That switch still selects checkout receipt handlers; it does not switch this webhook. No new APK is needed for this fix.

The handler now:

- Authenticates Google push before inspecting the payload; validates the configured package and wrapped Pub/Sub envelope.
- Accepts current renewal messages without `subscriptionId`; legacy messages containing that field also work. The current Google `subscriptionsv2` response supplies the product and expiry, not the optional legacy hint.
- Accepts a well-formed Console `testNotification` as HTTP 200 with `test_notification=true`. It logs `Google Play test notification received.` without changing purchases, creating a purchase-event row, or sending Telegram.
- Uses only already-linked local purchase tokens. Missing ownership remains retryable; no cross-database lookup or automatic account assignment is added.
- Preserves existing deduplication. A processed Pub/Sub message ID is not processed again.
- Updates the same purchase row and its aggregate account entitlement before sending the existing `subscription_renewed` Telegram alert for an active renewal.
- Leaves the event retryable if Telegram is enabled but sending returns false; renewed access stays saved. Disabled Telegram does not cause retries solely for skipped alerts.
- Refuses ambiguous supported tiers or malformed current store state rather than overwriting a valid entitlement. This patch does not add bundled-subscription support.

Telegram delivery is not exactly once: a network timeout after sending, or a crash before event completion, can cause a repeated alert on retry. No transactional outbox was added. Historical missed renewals are not backfilled, and unsupported notification families are not implemented here.

Reference: [Google RTDN format](https://developer.android.com/google/play/billing/rtdn-reference), [current subscription state](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2).

## Staging prerequisites

1. Deploy this patch using the existing deployment process. Existing purchase/webhook tables are required; this patch adds no migration.
2. Verify effective `GOOGLE_PLAY_PACKAGE_NAME`, `GOOGLE_PUBSUB_AUDIENCE`, `GOOGLE_PUBSUB_SERVICE_ACCOUNT`, Google Publisher credentials and Telegram settings privately.
3. Configure an authenticated **wrapped** Pub/Sub push delivery connection. The topic `projects/conjugador/topics/conjugate-subscription-events` had zero subscriptions in the 17 September live check. Code deployment alone does not fix that.
4. Review staging/production routing before connecting the shared topic. Do not redirect live customer notifications into staging or expose customer tokens in QA notes. `testNotification` is a Console connectivity test, not a marker for sandbox purchases.

The route remains `POST /api/android-subscription-webhook`. The anticipated staging destination is `https://apidev.appconjugate.com/api/android-subscription-webhook`. It has not been configured by this patch.

## Acceptance test

1. Send a Play Console test message. Verify receipt at staging, HTTP 200 and the bounded diagnostic log. No Telegram or purchase update is expected for this message.
2. Use the already-working 1.4.4 staging APK with an active acknowledged Google test subscription. Close the app before the next renewal.
3. Verify Google actually renewed it, the same purchase row has a later `expires_at` and refreshed `last_store_checked_at`, and Telegram reports an Android renewal.
4. Verify a redelivery of that processed message is skipped. Repeat older-client checkout/restore smoke tests with the Android switch ON and OFF.
5. Verify store outages do not overwrite access, and enabled Telegram failures produce a retryable `renewal_notification_failed` event without undoing the renewal.

A valid unmatched token returns 404 (`purchase_not_linked`) so a delivery racing receipt validation can retry. Store/processing failures return 5xx. An old expired test subscription is not proof of a delivery failure; verify current Google state. Do not replay production tokens or discard pending messages to make the test pass.

## Automated verification

```sh
php vendor/bin/phpunit tests/Unit/Database/AndroidRenewalWebhookTest.php --do-not-cache-result
php vendor/bin/phpunit tests/Unit --do-not-cache-result
php -l app/Http/Controllers/PurchaseAndroidController.php
git diff --check
```

Results: **108 unit tests, 622 assertions passed**, including 25 new webhook tests and two explicit 1.4.4 checkout cases. The total includes the already-present paused iOS tests. Store/authentication/Telegram services are mocked; database tests replace the model's named `mysql` connection with SQLite `:memory:` before querying. No real database or payment was used. This is not live Google delivery or production-MySQL acceptance.
