# Cancelled Android subscription upgrade — fix and retest

22 September 2026. **Implemented locally and tested; not deployed.**

## What is fixed

Google can return an acknowledged, cancelled-but-unexpired subscription with an
`autoRenewingPlan` object but no `autoRenewEnabled` field. The upgrade check
previously rejected this as `ownership_ambiguous`, preventing Google checkout.

`app/Services/AndroidSubscriptionUpgrade.php::check` now interprets that **omitted**
field as renewal off **only when Google's state is `SUBSCRIPTION_STATE_CANCELED`**
and the auto-renewing-plan object is present and correctly shaped. It then returns
the existing `prov1` → `prov2` replacement decision with mode `3` (no proration).
It does not fall back to a second, independent subscription.

All earlier checks still apply: current expiry, acknowledgement, expected products,
purchase environment, same account, supported app version, and rollout switches.
Explicit `null`, string/numeric values, missing/malformed plan objects, and omitted
flags on active subscriptions still fail closed. Existing explicit boolean handling
is preserved. No generic error message or Telegram notification behavior changed.

References: [Google subscription lifecycle](https://developer.android.com/google/play/billing/lifecycle/subscriptions#cancellations),
[renewal field](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2#AutoRenewingPlan),
[default-value omission](https://protobuf.dev/programming-guides/json/#presence-and-default-values).

## Compatibility and scope

- Only the Android upgrade **preflight** parser changed. This endpoint is restricted
  to supported clients (currently 1.4.6+); existing 1.4.4 receipt validation remains unchanged.
- Existing minimum-version, main-flow and upgrade-launch switches remain effective.
- iOS code, purchase/restore validators, acknowledgements, webhooks, migrations,
  API URLs, app code, Gradle files and dependencies are unchanged.
- No database cleanup, ownership transfer, Google charge, live configuration update,
  commit, push, or deployment was performed for this fix.
- Existing Android failure/restore history is not cleared or rewritten by this patch.

## Deploy and test on staging

1. Review, commit and push the backend patch through your usual workflow.
2. In **GitHub Actions → OVH backend**, select **staging**, operation **deploy**,
   and the branch/commit containing the fix. This patch requires **no migration**;
   leave the migration option off unless separately deploying an approved schema change.
3. Verify successful deployment, not just a queued/uploaded job. No APK rebuild is
   necessary for this parser correction; the existing 1.4.6 staging app can call it.
4. Verify staging isolation using the existing read-only Artisan command:

   ```json
   ["purchases:inspect-google-subscription", "--require-isolation"]
   ```

   Run with operation **artisan**, environment **staging**. Retrieve output using
   operation **status**, the returned deployment request ID, and **Show output**.
   Expect `purchase_environment: sandbox`, `webhook_environment: sandbox`, and
   `isolation_ready: true`. An unset purchase setting defaults to `legacy`; the
   webhook setting alone does **not** protect checkout/restore.
5. If isolation is wrong, review the staging configuration using
   [the environment guide](android-purchase-environment-isolation.md). The intended
   staging values are `APP_ENV=staging`, `ANDROID_PURCHASE_ENVIRONMENT=sandbox`, and
   `GOOGLE_PLAY_WEBHOOK_PURCHASE_ENVIRONMENT=sandbox`. Refresh resolved configuration
   through the existing deployment process after any operator-approved change.
   Do not copy these settings to production or disable isolation to make QA pass.
6. Use a dedicated Google license tester and the **same Conju Gate staging login**
   throughout the sandbox purchase and upgrade. Only confirm checkout when Google
   explicitly identifies the test payment method. Do not use a real subscription
   as the starting point for a sandbox replacement.

### QA checklist

| Scenario | Expected result |
| --- | --- |
| Same-account, unexpired sandbox `prov1`, cancelled renewal; Google omits renewal flag | Choosing `prov2` reaches Google upgrade checkout, not the generic ambiguity error |
| Same case, then dismiss checkout | No new charge or upgrade; existing paid access remains |
| Same case, then approve **test card** in a separate acceptance test | Existing verified upgrade/entitlement path completes; check resulting `prov2` access |
| Normal renewing sandbox `prov1` → `prov2` | Existing upgrade behavior still works |
| Different Conju Gate account owns the sandbox purchase | Ownership block remains; no new checkout |
| Still-unexpired **real** purchase on sandbox-only staging | Explicit real-purchase-in-sandbox block, not an allowed test replacement |
| Google-confirmed expired purchase | Existing fresh-checkout behavior remains |
| Upgrade switch/main-flow rollback/minimum-version exclusion | No upgrade starts; existing paid-purchase recovery remains available |

Cancelled test subscriptions expire quickly. For the first scenario, verify that
the subscription is still unexpired when testing. An already-expired token exercises
the fresh-checkout path, not this fix.

The previously inspected personal real purchase was cancelled but did not expire
until 17 October 2026. Correct sandbox isolation intentionally continues to block
using it for a test upgrade. Seeing that specific isolation message is not a failed
parser fix. Do not delete purchase rows or repeatedly restore to get around it.

## Automated verification

- Six valid omitted-field scenarios failed before the service change and passed after it.
- **29 new regression cases** cover the omitted field, malformed data, environment
  separation, account ownership, expiry/readiness and rollout gates.
- Android upgrade suite: **73 tests / 302 assertions passed**.
- Full backend Unit suite: **274 tests / 1,610 assertions passed**, including old-app
  purchase compatibility, iOS purchases, Android renewals/webhooks and reminders.
- PHP syntax checks and `git diff --check` passed.

Commands from the backend root:

```sh
php vendor/bin/phpunit tests/Unit/Database/AndroidSubscriptionUpgradeTest.php --do-not-cache-result
php vendor/bin/phpunit --testsuite Unit --do-not-cache-result
```

These tests use mocked store services and in-memory databases. They are not proof
of deployed server configuration or on-device Google checkout success. Staging
deployment and the manual sandbox checks above remain required.
