---
name: pollyglot/gplay-monetization
source: https://app.decimal.ai/s/pollyglot-gplay-monetization@1/SKILL.md
source_sha256: ff342f16462f
---

# gplay monetization (subscriptions + one-time products)

`gplay subscriptions` and `gplay iap` own the **monetization catalog** as
declarative, version-controlled files (ADR-0041). Shared conventions (auth,
`--package` pinning, output, exit codes, `--dry-run`/`--confirm`) are in
`gplay-cli-usage`. Both namespaces are `[experimental]`.

| Namespace | What it holds | Default `--dir` |
|---|---|---|
| `subscriptions` | subscriptions, their base plans (config + per-territory prices), their offers, lifecycle state | `./monetization/subscriptions` |
| `iap` | one-time products — v2 `monetization.onetimeproducts` **∪** legacy `inappproducts` | `./monetization/iap` |

Both sit **outside the Edit lifecycle** (like `compliance`, `device-tiers`,
`recovery`, `orders`) — direct package-scoped writes, no `editId`, so
`gplay edits begin` does not batch them.

## The loop

```
pull  →  edit the .json files  →  apply --dry-run  →  apply [--confirm]
```

One `<productId>.json` per product, holding the **API resource verbatim**
minus server-derived noise. `pull` then `apply` with no edits is a guaranteed
no-op. Commit the directory; the diff in review *is* the catalog change.

## Mirror semantics — the trap if you know `metadata`

`metadata apply` is **additive**: a locale live online but absent on disk is
left alone. Monetization is the opposite. **The directory is the complete
declared catalog** — a live subscription, product or offer with no file is a
**delete in the plan**. A monetization catalog is a closed set whose omissions
must be visible; a store listing tree is a partial view.

Consequences to internalize before running anything:

- **Never point `--dir` at a fresh/empty directory.** `apply` refuses when the
  directory holds no `.json` while the app has live products (it would delete
  them all) — but a *partially* populated directory is a legitimate plan full
  of deletes. Always `pull` first.
- **`pull` is destructive locally too.** It removes stale `.json` files so the
  directory mirrors Play. It refuses to erase a populated directory when the
  live catalog reads back empty (a mis-set `--package` or a scope loss) —
  that refusal is a signal, not an obstacle to work around. Non-`.json` files
  are never touched.
- Deleting a subscription is additionally guarded server-side: Google refuses
  to delete one with a published base plan.

## Gates

| Situation | Gate | Exit without it |
|---|---|---|
| Plan contains any delete (product or offer) | `--confirm` | `3` |
| `iap apply` promotes a live legacy product to v2 | `--migrate` | `3` |
| `subscriptions prices migrate` (reprices live subscribers) | `--confirm` | `3` |
| Creates, patches, state changes | *none* — they run directly | — |

`CI=true` never auto-confirms. `GPLAY_READONLY` refuses every `apply` and
`migrate` outright (exit `4`, not resolvable by adding a flag).

State changes are **not** gated — activate/deactivate are reversible — but
they are listed prominently in every plan view because they move buyer
availability.

## Subscriptions

```bash
gplay subscriptions pull                              # → ./monetization/subscriptions/*.json
# …edit the files…
gplay subscriptions apply --dry-run                   # ONLINE read, prints the plan, changes nothing
gplay subscriptions apply                             # creates/patches/state changes
gplay subscriptions apply --confirm                   # …when the plan also deletes
```

- **Base plans ride the parent patch.** Base plan config (billing type,
  per-territory `regionalConfigs` prices) is declared inline under `basePlans`
  and patched with the subscription — the API has no create/patch on the
  sub-resource. Its endpoints only manage *state* and subscriber price
  migration.
- **Offers are embedded but real.** `pull` nests each offer under
  `basePlans[].offers` — a **file construct the API resource does not carry**.
  `apply` splits them back out and reconciles them through the offers
  endpoints under the key `productId/basePlanId/offerId`. Don't expect that
  array in an API response.
- **`state:` declares lifecycle, reconciled via `:activate`/`:deactivate`,
  never a patch.** Declare `ACTIVE` or `INACTIVE`. **Omitting the field leaves
  state unmanaged** — the metadata stance. An unreachable transition (`DRAFT`
  from anything, `INACTIVE` from `DRAFT`) is a usage error naming it.
- **Reconciled fields only.** `listings`, `taxAndComplianceSettings`,
  `restrictedPaymentCountries`, `basePlans`. The `updateMask` is exactly the
  changed managed fields — nothing outside that projection drifts or diffs.
  `archived` is **not** reconciled (deprecated/output-only upstream).

### Prices

```bash
# Derive per-region prices from one base price — a computation, no write:
gplay subscriptions prices convert --price 4.99 --currency USD --output json
```

Paste the returned `Money` objects into a base plan's `regionalConfigs`, then
rehearse with `apply --dry-run`. `--output json` is the
`ConvertRegionPricesResponse` verbatim. "Not a write" does not mean offline:
`convert` calls the `convertRegionPrices` API (today's exchange rates), so it
needs a credential and the package axis — it just never mutates anything.

```bash
# Reprice EXISTING subscribers — money-moving, one base plan per call:
gplay subscriptions prices migrate \
  --product premium --base-plan monthly \
  --region FR --region DE \
  --oldest 2026-01-01T00:00:00Z \
  --price-increase-type opt-in \
  --dry-run                                  # offline preview, lists the gate in "requires"

gplay subscriptions prices migrate … --confirm
```

**This is the one deliberate exception to "editing files never touches a live
purchaser."** `apply` changes what **new** buyers pay; `migrate` changes what
**existing** subscribers pay. An `apply` diff never triggers a migration —
that separation is pinned by a test upstream, so don't expect a price edit to
propagate to current subscribers.

- Cohorts **older than `--oldest`** (RFC-3339) migrate, scoped to the
  `--region`s you repeat.
- `--price-increase-type opt-in` requires subscribers to accept or churn;
  `opt-out` (where Google allows it) applies automatically with notice.
- **No bulk migration** — the batch sibling is deliberately not wrapped. One
  base plan per invocation.

## One-time products (`iap`)

```bash
gplay iap pull                       # v2 ∪ legacy → ./monetization/iap/*.json
gplay iap apply --dry-run
gplay iap apply [--confirm] [--migrate]
```

`pull` unions the v2 and legacy surfaces by product ID, and **a file's origin
is its shape** — no gplay-invented marker:

| Field present | Model |
|---|---|
| `sku` | legacy `inappproducts` |
| `productId` | v2 `onetimeproducts` |

**Legacy is inert**: gplay never creates, edits or deletes a legacy product —
the only gesture is the **one-way promotion** to v2 (rewrite the file with
`productId` and apply with `--migrate`; rehearse with `--dry-run` first).
When a legacy file is involved — an unexpected refusal, a promotion to plan,
or the question of why `pull` reads two surfaces — read
[iap-legacy.md](iap-legacy.md).

## `--regions-version`

`create`/`patch` require Google's regions version string. gplay pins the
current published value (`2022/02`) and exposes `--regions-version` to
override when Google publishes a new one — a flag, not a config knob, so the
pin stays visible in CI logs.

## CI gate

```bash
gplay subscriptions apply --dry-run --output json    # the plan, a gplay-owned shape
gplay iap apply --dry-run --output json
```

`apply --output json` emits the **plan**, not an API echo — a recorded
ADR-0003 exception, like `metadata apply`, `[experimental]` until it
graduates: `{package, dryRun, changes[], summary{…}, requires[]}`, where each
change carries `op` (`create`/`patch`/`delete`/`activate`/`deactivate`, plus
`migrate` on `iap`) and its identity. `pull --output json` is the API
pass-through (the merged `ListSubscriptionsResponse`, or the composite
`{"oneTimeProducts":[…],"inappproduct":[…]}`) — but the *files* are the real
output there.

A drift check is one line — fail the job when the plan is non-empty:

```bash
gplay subscriptions apply --dry-run --output json | jq -e '.changes | length == 0'
```

## Permissions

The Discovery snapshot ties **no specific Play permission enum** to the
monetization methods, so gplay's 403 hint points at the surface rather than
naming a capability: grant the service account access to the app's
**monetization setup** in Play Console (Users & permissions), then retry.
403 → exit `11`, 404 on the package → exit `30` (verify `--package` or the pin).

Confirm the current verbs and flags with `gplay subscriptions --help`,
`gplay iap --help`, and `gplay <group> <command> --help` — both surfaces are
`[experimental]` (ADR-0010) and may still evolve.