# Purchase backend Step 1 — deployment and test runbook

This release changes only backend purchase validation and server notifications. The mobile IAP coordinator is Step 2 and is intentionally not included.

## Required configuration before deployment

The webhook endpoints fail closed with HTTP 503 when trust configuration is absent. Configure these values before deploying the code.

### Google Play

- `GOOGLE_PLAY_PACKAGE_NAME=app.conjugate.and`
- `GOOGLE_CLIENT_EMAIL` and `GOOGLE_PRIVATE_KEY`: Android Publisher service account.
- `GOOGLE_PUBSUB_AUDIENCE`: exact HTTPS push endpoint configured on the Pub/Sub subscription, including path.
- `GOOGLE_PUBSUB_SERVICE_ACCOUNT`: service-account email used as the Pub/Sub authenticated push identity.

Configure the Pub/Sub push subscription to include an OIDC token with the same audience and service account. Do not use a shared query-string secret.

### App Store

- `APPLE_SHARED_SECRET`: existing subscription shared secret.
- `APPLE_STORE_BUNDLE_ID=app.conjugate.ios`
- `APPLE_STORE_APP_ID`: numeric App Store application ID.
- `APPLE_STORE_ISSUER_ID`: App Store Connect API issuer ID.
- `APPLE_STORE_KEY_ID`: In-App Purchase API key ID.
- `APPLE_STORE_PRIVATE_KEY_PATH`: absolute server path to the `.p8` private key, outside the Git checkout.
- `APPLE_STORE_ROOT_CA_PATHS`: comma-separated absolute paths to trusted Apple root CA PEM files, outside the Git checkout.
- `APPLE_STORE_ALLOWED_ENVIRONMENTS=Production,Sandbox` during sandbox testing. Production may be narrowed if sandbox uses a separate deployment.

Use root certificates downloaded from Apple's Certificate Authority page. Do not trust the `x5c` root supplied by the webhook itself.

## Database rollout

Back up the `purchases` table first. Then review and run:

```bash
php artisan migrate --pretend
php artisan migrate --force
```

The migration:

- adds store state, expiry, receipt hash, and acknowledgement retry fields;
- preserves the earliest purchase row for each historical duplicate receipt;
- marks later duplicate rows `duplicate` with `is_pro=0`;
- creates `store_webhook_events` with a unique provider/event identifier.

Verify the duplicate count before production rollout:

```sql
SELECT receipt, COUNT(*) AS duplicate_count
FROM purchases
WHERE receipt IS NOT NULL
GROUP BY receipt
HAVING COUNT(*) > 1;
```

## Scheduler requirement

The normal Laravel scheduler must run every minute. Step 1 schedules `purchases:retry-google-acknowledgements` every 15 minutes.

Manual dry observation:

```bash
php artisan purchases:retry-google-acknowledgements --limit=10
```

## Automated checks

```bash
php artisan route:list --path=subscription-webhook
./vendor/bin/phpunit tests/Unit
```

Expected Step 1 result in the audited environment: 18 tests and 74 assertions passing.
The complete suite was also verified with an isolated SQLite database: 34 tests and 100 assertions passing.

## Sandbox test matrix

1. Google successful purchase: entitlement is granted and acknowledgement becomes `acknowledged`.
2. Google pending payment: API returns HTTP 202 with `state=pending`; Pro is not granted.
3. Google cancellation with future expiry: Pro remains active through `expires_at`.
4. Google revoked/expired purchase: that purchase becomes inactive; another active tier still wins.
5. Kill the backend acknowledgement request: entitlement remains active and the scheduled command retries.
6. Replay the same Pub/Sub `messageId`: only one entitlement/notification side effect occurs.
7. Send a Pub/Sub request without/with the wrong OIDC token: HTTP 401, no state mutation.
8. Send an unknown Google notification type: HTTP 200 with `ignored=true`, no state mutation.
9. Apple sandbox purchase: receipt identity is checked, App Store Server API status is queried, and current tier/expiry is stored.
10. Replay the same Apple `notificationUUID`: only one side effect occurs.
11. Send a modified/unsigned Apple JWS: HTTP 401, no state mutation.
12. Disable App Store API access temporarily: webhook returns 503 and its dedupe record remains retryable.
13. Apple renewal disabled/cancelled before expiry: current App Store status keeps access until expiry.
14. Apple refund/revocation: authoritative status removes that entitlement.

## Operational checks

- No logs should contain raw receipts, purchase tokens, signed payloads, or decoded webhook bodies.
- Inspect `store_webhook_events` for recurring `failed` rows.
- Inspect Android purchases with `acknowledgement_status=retry_pending` older than one hour.
- Confirm the production cron invokes `php artisan schedule:run`.
- Keep the old mobile purchase flow enabled for this step; its response contract remains compatible except that pending is now explicit HTTP 202.
