Skip to content

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_sub and myrecipes_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

  1. IAPService queries the two product identifiers from the platform store.
  2. The subscription page shows localized store metadata and starts a purchase through Flutter's in_app_purchase package.
  3. Purchased and restored transactions are sent to the callable Firebase Function record_purchase_call.
  4. 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.
  5. A transaction ownership record is created in purchase_transactions/{hash} and an entitlement is written to users/{uid}.subscription.
  6. Only after successful server verification does the client call completePurchase.
  7. 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

  1. 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.

  1. The client reads the subscription document incorrectly. The Function writes fields inside the nested subscription map, while FSUserHelper.getUserSubscription() passes the complete user document to Subscription.fromMap(). The same root-level parse occurs in User.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.

  1. Renewals cannot extend the stored entitlement. purchase_transactions is keyed by platform plus original transaction ID. Once that record exists, subsequent verification returns alreadyProcessed without 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.

  1. 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.

  1. Access trusts isActive without consistently checking expiry. hasActiveSubscription and canCreateRecipe() use the stored boolean even after expiresDate has 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

  1. Initialization can run repeatedly. The proxy provider calls initWithUser() whenever UsersProvider updates. 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.

  2. Store availability is not checked. available starts as true; the code never calls InAppPurchase.isAvailable(). Query failures are not caught, and loading/error/empty-product states are not presented usefully.

  3. 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.

  4. 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.

  5. Trial behavior is internally inconsistent. Trial enum comparisons use a string, Subscription.isTrial has the same defect, and neither verifier derives trial status. Remove trial messaging or implement and test it.

  6. The secondary notifier is defective and apparently unused. IAPNotifier.loading is derived from store availability rather than IAPService.isLoading; its isExpired assignment shadows the field; and it asks for subscriptionExpired when there may be no subscription, which throws. Prefer one entitlement provider/source of truth.

  7. Product typing relies on the ID suffix. Products ending in _sub are treated as subscriptions. The present IDs conform, but explicit product metadata is safer and avoids silently buying a future subscription as a consumable.

  8. The schema/state vocabulary is incomplete. The model lists free, trial, active, grace, retry, expired, revoked, and unknown, but server verification emits only active and Google grace. Define customer access for billing retry, grace, paused, pending, refunded, revoked, and account-deleted states on both stores.

  9. 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.

  10. 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 admin and 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.

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.md are 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.dart
  • lib/in_app_purchase/iap_service.dart
  • lib/in_app_purchase/subscription_page.dart
  • lib/in_app_purchase/iap_provider.dart
  • lib/model/subscription.dart
  • lib/db/firestore/users/user_helper.dart
  • functions/src/index.ts
  • functions/src/purchase_verifier.ts
  • firestore.rules
  • docs/release/app-check-and-purchases.md