Work / Chitragupt / Wiki / Decisions
2026-09-11-android-ships-through-play-billing
Decisioncanonicalverified 2026-09-11
DECISION.2026-09-11.ANDROID-SHIPS-THROUGH-PLAY-BILLINGAndroid 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
isPastAyrefuses 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[]gainspayment_provider("razorpay" | "play", defaulted so existing rows still parse) andstore_transaction_id.razorpay_payment_idis now null on Play rows. This matters:subscription/refund.tslooks 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.activateUnlockInTransactiontakes aUnlockPaymentRefunion instead of a barerazorpayPaymentId, 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/mobileand carry their own tag prefix:mobile-v*. The webv*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 onmain, and OTA rollback.scripts/check-ota-safe.mjsrefuses an OTA that needs native code the installed binary does not have.apps/mobile'sbuildtask 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.jsonhas 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.jsonsubmits 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é