Subscription model review
Status: historical implementation review; remediation is tracked by #269 and #303
The freemium recommendation in this July 26 review was superseded by the
approved subscription-only commercial contract in #302. The canonical
lifecycle implemented for #269 is documented in
docs/release/subscription-lifecycle.md.
Reviewed from the repository implementation on July 26, 2026. This document describes the behavior implemented in code; App Store Connect and Google Play Console pricing, trial configuration, subscription groups/base plans, and territory availability are not stored in this repository and must be reviewed separately.
Executive summary
MyRecipes currently proposes a simple freemium model:
- A signed-in user may create up to five recipes for free.
- Premium is intended to provide unlimited recipes across devices.
- Premium is offered as monthly and yearly auto-renewing subscriptions:
myrecipes_premium_monthly_subandmyrecipes_premium_yearly_sub. - The displayed title, description, localized price, and currency come from the platform store rather than being hard-coded in the app.
The model is understandable, but the entitlement is not actually enforced.
canCreateRecipe() calculates whether a user is below the five-recipe limit,
but no recipe creation path calls it, and Firestore rules allow an eligible
recipe-box owner to create recipes without checking a subscription or quota.
There are also release-blocking lifecycle and data-reading defects. In
particular, verified subscription data is written under
users/{uid}.subscription but the client parses the entire user document as if
the subscription fields were at its root. Renewals do not refresh the stored
expiry after the first verified transaction, and there is no server
notification/reconciliation path for renewal, cancellation, refund, revocation,
billing retry, or expiration.
Implemented customer model
Free tier
The global free allowance is freeRecipes = 5. The app counts recipe
documents whose creator_uid matches the current user. Existing recipes are
not intended to become inaccessible after the limit is reached; the stated
restriction is on creating additional recipes.
Only user-created recipe count is considered. The implementation does not currently define how imports, shared recipes, copies, restored data, deleted recipes, or recipes transferred between boxes affect the allowance. Because the count is of current documents, deleting a recipe appears intended to restore one free slot.
Premium tier
There is one feature tier, Premium, sold at two billing intervals:
| Product | Store identifier | Intended type |
|---|---|---|
| Premium monthly | myrecipes_premium_monthly_sub |
Auto-renewing subscription |
| Premium yearly | myrecipes_premium_yearly_sub |
Auto-renewing subscription |
No prices or annual discount are defined in the repository. Those values, and any introductory offer, must be confirmed in each store.
The Settings screen describes Premium as “Unlimited Recipes across all your devices.” No other premium-only capability is enforced in code.
Trial
The client data model contains a trial state and labels it “Free Trial,” but
there is no implemented trial-start action, trial eligibility check, or trial
granting service. Store verification maps a valid store purchase to active
or, on Google, grace; it does not identify a store trial.
The trial checks also compare a SubscriptionState enum to the string
'trial', so they can never return true. A trial should therefore not be
advertised until its source of truth, duration, eligibility, conversion, and
abuse controls are explicitly implemented.
Current technical flow
IAPServicequeries the two product identifiers from the platform store.- The subscription page shows localized store metadata and starts a purchase
through Flutter's
in_app_purchasepackage. - Purchased and restored transactions are sent to the callable Firebase
Function
record_purchase_call. - The callable requires Firebase Authentication and one-time App Check, validates an Apple receipt or Google Play purchase token, and accepts only the two allowlisted product IDs.
- A transaction ownership record is created in
purchase_transactions/{hash}and an entitlement is written tousers/{uid}.subscription. - Only after successful server verification does the client call
completePurchase. - The subscription page can initiate restore. Its management button always opens Apple's subscription-management URL.
The server correctly keeps raw receipts/tokens out of Firestore, prevents clients from writing trusted subscription fields, and attempts to bind a store subscription to one Firebase user. These are sound foundations, but they do not yet make the full lifecycle reliable.
Deployment concerns
P0 — release blockers
- The five-recipe paywall is not enforced.
IAPService.canCreateRecipe()has no callers outside the service. Recipe creation and import flows can proceed without it, and Firestore rules enforce ownership but not quota or entitlement. A modified client can always bypass a UI-only check.
Address by defining all quota-consuming operations, enforcing them through a trusted backend transaction/callable, and using that same authoritative decision in the UI. At minimum, every create/import/copy path needs a consistent gate and race-safe count or server-maintained usage value.
- The client reads the subscription document incorrectly.
The Function writes fields inside the nested
subscriptionmap, whileFSUserHelper.getUserSubscription()passes the complete user document toSubscription.fromMap(). The same root-level parse occurs inUser.fromDocument(). The resulting object defaults to inactive/unknown, so a successfully verified purchaser can still appear unsubscribed.
Parse doc.data()['subscription'] as a map and add serialization tests using
the exact document shape written by the Function.
- Renewals cannot extend the stored entitlement.
purchase_transactionsis keyed by platform plus original transaction ID. Once that record exists, subsequent verification returnsalreadyProcessedwithout updating the user's expiry. The first stored expiration therefore remains stale through renewals. A product change under the same original transaction can also be rejected as a replay because its product ID differs.
Store individual renewal events idempotently (transaction/order ID) while maintaining a separately recomputed entitlement keyed by original subscription identity. Always reconcile the latest verified state, including valid upgrades/downgrades.
- There is no subscription lifecycle synchronization. State changes are processed only when the app receives a purchase/restore event. There is no App Store Server Notification, Google Real-time Developer Notification, scheduled reconciliation, or signed-in refresh that can reliably process renewals, cancellations, refunds, revocations, billing retry, grace-period exit, or expiration.
Implement store server notifications plus an idempotent reconciliation
service. Treat the stored entitlement as a server-derived cache with a
lastVerifiedAt and fail-safe refresh policy.
- Access trusts
isActivewithout consistently checking expiry.hasActiveSubscriptionandcanCreateRecipe()use the stored boolean even afterexpiresDatehas passed. A stale record can grant access indefinitely. Use one authoritative computed entitlement (active,grace, or valid trial, and not expired) everywhere; do not persist a boolean that can contradict dates/state unless it is atomically derived.
P1 — required before store submission
-
Initialization can run repeatedly. The proxy provider calls
initWithUser()wheneverUsersProviderupdates. Each call can query the store and replace the purchase-stream subscription without first cancelling the old listener. Make initialization idempotent per Firebase UID and handle sign-out/account switching explicitly. -
Store availability is not checked.
availablestarts astrue; the code never callsInAppPurchase.isAvailable(). Query failures are not caught, and loading/error/empty-product states are not presented usefully. -
Purchase failures are effectively silent. Verification exceptions, purchase errors, restore errors, and pending states are logged but not surfaced to the customer. The UI provides no disabled/loading state, retry, success confirmation, or actionable support information.
-
Platform management is Apple-only. The management action always opens
apps.apple.com, including on Android and macOS. Add platform-appropriate management deep links and confirm supported purchase platforms. The release documentation currently says only iOS and macOS Firebase configurations ship, while Google verification code is present. -
Trial behavior is internally inconsistent. Trial enum comparisons use a string,
Subscription.isTrialhas the same defect, and neither verifier derives trial status. Remove trial messaging or implement and test it. -
The secondary notifier is defective and apparently unused.
IAPNotifier.loadingis derived from store availability rather thanIAPService.isLoading; itsisExpiredassignment shadows the field; and it asks forsubscriptionExpiredwhen there may be no subscription, which throws. Prefer one entitlement provider/source of truth. -
Product typing relies on the ID suffix. Products ending in
_subare treated as subscriptions. The present IDs conform, but explicit product metadata is safer and avoids silently buying a future subscription as a consumable. -
The schema/state vocabulary is incomplete. The model lists
free,trial,active,grace,retry,expired,revoked, andunknown, but server verification emits onlyactiveand Googlegrace. Define customer access for billing retry, grace, paused, pending, refunded, revoked, and account-deleted states on both stores. -
Plan switching and account ownership need product tests. Verify monthly-to-yearly and yearly-to-monthly transitions, cross-device restore, Firebase account switching on one store account, Family Sharing decisions, Google resubscribe/linked tokens, Apple sandbox/TestFlight receipts, and a purchase made before Firebase sign-in.
-
Store and policy configuration is not auditable here. Confirm matching product identifiers, subscription group/base plans, tax category, localized descriptions, territory availability, grace period, account hold, price consent, restore behavior, privacy policy, terms, support URL, and required disclosures in both consoles.
P2 — product and operating limitations
- There is no in-app explanation of exactly what remains available after cancellation. Existing recipe read/edit/export behavior should be guaranteed and documented; locking a customer's existing data would be a poor default.
- A hard limit of five recipes may be too early for users to experience the value of organization, shopping lists, import, and cross-device sync.
- The value proposition has only one paid differentiator: unlimited recipe creation. This may not support recurring payment unless ongoing cloud/sync, household collaboration, importing, meal planning, and convenience features are made clear.
- Counting current recipe documents lets delete-and-replace remain free. That may be desirable, but it should be an explicit “up to five saved recipes” policy rather than “five recipes created.”
- There is no entitlement/support tooling documented for refunds, goodwill
grants, migrations, disputed ownership, or customer account merges, despite
the model containing
adminand other grant sources. - Analytics needed to choose price and threshold are not defined. Track paywall views, quota reached, product load failure, purchase start/success/ failure, restore, trial conversion (if used), renewal, churn, and feature retention without recording receipt secrets.
Recommended release model
For the initial release, keep the commercial model simple:
Free
- Up to a clearly stated number of saved personal recipes.
- Full ability to view, edit, delete, and export those recipes.
- Core shopping-list experience.
- Shared recipes should be readable; define whether copying one consumes a personal recipe slot.
Premium
- Unlimited saved recipes.
- Cross-device sync and backup.
- Recipe import/capture convenience features.
- Household/recipe-box sharing, if these have meaningful ongoing cloud cost and are reliable enough to support.
- Monthly and yearly billing, with yearly presented as the best-value default.
Do not launch a custom trial state. If conversion data supports a trial, use a store-managed introductory trial attached to the same subscription products and derive access from the verified store transaction. This reduces custom eligibility and abuse logic.
The exact free limit and prices should be selected from beta data, not code intuition. Measure how many recipes retained users save before they experience repeat value. A limit of 10–20 saved recipes, or a time/feature-based premium gate after users complete the core loop, may convert better than five while still making Premium understandable.
Alternative subscription models
1. Feature-based freemium
Keep recipe storage generous or unlimited locally, and charge for recurring services: cloud sync/backup, household collaboration, automatic imports, advanced meal planning, nutrition, and smart shopping tools.
This aligns recurring revenue with recurring value and cloud cost. It is the strongest long-term option if enough premium features are ready.
2. Local lifetime purchase plus optional cloud subscription
Offer a one-time “MyRecipes Pro” purchase for unlimited on-device recipe management, with a separate subscription for sync, backup, collaboration, and future hosted services.
This is customer-friendly for a personal recipe utility and can improve conversion among subscription-averse users, but it creates two entitlements and requires precise messaging about what is local versus hosted.
3. Household plan
Sell Premium to a household owner and include a small number of members sharing recipe boxes and shopping lists. This maps naturally to the app's sharing features and provides a stronger recurring-value story.
It requires server-authoritative membership, owner-transfer/cancellation rules, seat limits, abuse controls, and a decision about store Family Sharing.
4. Usage credits
Keep manual recipes free and sell consumable credits for expensive operations such as AI extraction, OCR, nutrition analysis, or bulk web imports.
Use this only for features with clear per-use cost. Credits are a poor fit for basic recipe storage and add purchase/support complexity.
5. Time-limited full-access trial
Allow all premium features for a short store-managed trial, followed by read-only access to existing data plus the normal free allowance.
This gives users enough time to experience sync and household workflows, but trial conversion and cancellation disclosures must be exceptionally clear.
Release acceptance checklist
- [ ] One server-authoritative entitlement policy is documented and used by every quota-consuming operation.
- [ ] Nested subscription serialization is fixed and covered by Dart tests.
- [ ] Initial purchase, every renewal, plan change, expiration, cancellation, refund, revocation, grace period, billing recovery, and restore are covered by server reconciliation tests.
- [ ] App Store Server Notifications and Google RTDN are configured, verified, monitored, retried, and idempotent.
- [ ] Purchase ownership remains bound to the correct Firebase account across restore, renewal, and account switching.
- [ ] The app checks store availability and shows useful loading, empty, pending, success, error, retry, and restore states.
- [ ] Platform-correct subscription management links work.
- [ ] Store products, prices, annual savings language, trial/intro offers, locales, tax, territories, screenshots, disclosures, privacy policy, terms, and support contact are reviewed in both consoles.
- [ ] Free users can always read, edit, delete, and export existing data after reaching the limit or losing Premium.
- [ ] App Check and store verification release gates in
docs/release/app-check-and-purchases.mdare complete. - [ ] Alerts and dashboards cover verification failures, notification lag, entitlement reconciliation failures, purchase funnel, renewal, and churn.
- [ ] Sandbox/TestFlight and Play license-tester end-to-end test evidence is retained for the release.
Relevant implementation files
lib/in_app_purchase/product_ids.dartlib/in_app_purchase/iap_service.dartlib/in_app_purchase/subscription_page.dartlib/in_app_purchase/iap_provider.dartlib/model/subscription.dartlib/db/firestore/users/user_helper.dartfunctions/src/index.tsfunctions/src/purchase_verifier.tsfirestore.rulesdocs/release/app-check-and-purchases.md