Skip to content
Ritesh FirodiyaGet in touch

Work / Chitragupt / Wiki / Decisions

2026-09-11-android-ships-through-play-billing

Decisioncanonicalverified 2026-09-11

DECISION.2026-09-11.ANDROID-SHIPS-THROUGH-PLAY-BILLING

Android ships to Google Play, and sells through Play Billing

Decision

Phase 8 (mobile) is unpaused for Android only. The app goes to the Play Console under com.chitragupt.app, and its unlocks are sold through Google Play Billing via RevenueCat — not Razorpay. iOS stays out of scope. V1 on chitragupt.ai is unaffected: the website keeps selling through Razorpay exactly as it does today.

Why

ROADMAP.md paused mobile for V1 and put it in Phase 8. The pause was about focus, not about a blocker, and the code had already moved past it — 64 screens, a clean typecheck, an EAS project with a finished Android build. What was missing was everything outside the repo: no Play Console app, no RevenueCat project, no service-account key, and no way to ship a release.

Auditing that gap surfaced a hard blocker. plugins/with-simulator-no-signing.js required @expo/config-plugins, which apps/mobile does not declare. Under pnpm's isolated linking that does not resolve, so every eas build died before it started:

PluginError: Cannot find module '@expo/config-plugins'
  - apps/mobile/plugins/with-simulator-no-signing.js

Nothing caught it. tsc --noEmit does not see plain .js plugin files, and the expo binary linked into the workspace resolves the module through its own tree and exits 0 — only the hoisted CLI that eas-cli spawns fails. It is the same class the root CLAUDE.md already records three of: a workspace must declare what it imports.

Why Play Billing rather than Razorpay. Google Play policy requires Play Billing for a purchase that unlocks the app's own features. The Razorpay rail cannot be reused for unlocks on Android without risking removal from the store. Razorpay stays the rail for CA marketplace consults, which are real-person services and exempt — the same split apps/mobile/src/app/(app)/payment/refund.tsx already assumed.

The cost is real and worth stating: Google takes 15% of the first $1M/year, against Razorpay's ~2%. A ₹249 unlock nets ~₹212 on Play against ~₹244 on the web. That is the price of being in the store.

Impact

Product

  • Prices are unchanged on both rails — tier-self ₹249, tier-ai-self ₹499, tier-pro-family ₹799, and the in-AY top-ups. A user must not pay a different amount depending on which device they happen to hold.
  • freemium-per-ay is unchanged: past years stay free, and isPastAy refuses to sell them on either rail.
  • Nothing renews. Play products are one-time, matching the per-AY model. Paywall copy that told users to "cancel anytime from store settings" was wrong and is gone.

Schema

  • ay_unlocks[] gains payment_provider ("razorpay" | "play", defaulted so existing rows still parse) and store_transaction_id.
  • razorpay_payment_id is now null on Play rows. This matters: subscription/refund.ts looks a refund up by that field and hands it to the Razorpay refund API. A Play token there would fail on a user asking for their money back. Play refunds are issued by Google.
  • activateUnlockInTransaction takes a UnlockPaymentRef union instead of a bare razorpayPaymentId, so a caller cannot write the wrong rail's reference. All four writers updated.

The SKU carries the year. Play has no way to pass an assessment year alongside a purchase, so the product id encodes it: self_per_ay__2026_27, ai_self_topup_per_ay__2026_27, and so on. The first segment is literally a key of PRICE_PAISE, so the amount recorded against an unlock comes from this repo's own table rather than RevenueCat's float — .claude/rules/money.md holds on the Play rail too. packages/shared/src/config/play-products.ts mints and parses them; an unrecognised SKU activates nothing.

CI/CD

  • Mobile releases are cut from apps/mobile and carry their own tag prefix: mobile-v*. The web v* tag deploys Cloud Run and ~186 functions; one shared prefix would spend an EAS build on every backend ship and redeploy production on every mobile ship.
  • mobile.yml (a manual build button) is replaced by four workflows: release on tag, submit, OTA update on main, and OTA rollback.
  • scripts/check-ota-safe.mjs refuses an OTA that needs native code the installed binary does not have.
  • apps/mobile's build task now resolves the Expo config and loads every local plugin, so the blocker above cannot come back silently.

Out of scope, deliberately

  • iOS. No Apple Developer account, no App Store Connect app, no credentials. eas.json has no iOS submit profile.
  • Admin and the CA portal stay web-only — 2026-06-27-mobile-end-user-only is unchanged.
  • Nothing auto-promotes to the production track. eas.json submits to internal; a human clicks promote in the Play Console.

Status

Active.

Sources

  • apps/mobile/eas.json, apps/mobile/app.json
  • .context/ops/runbooks/mobile-store-setup.md — the out-of-repo runbook
  • ~/git/income-apps/aakalan — the reference release pipeline this is adapted from
  • Google Play billing policy, "Payments" (developer programme policy)

Every project of mine is written down like this.

Read the résumé