Skip to content

Pricing & Membership Restructure - April 2026

Status: Planning / Impact Analysis Date: 2026-04-08 Source: z-2-reqirements/20260405 会员和积分 价格调整 包含IOS第一版本.docx


Table of Contents


1. Executive Summary

This change restructures the MysticX pricing across two platforms:

Web: Increase monthly credit grants (Gold 4,000 -> 6,000, Diamond 24,000 -> 30,000), introduce yearly plans with one-time lump-sum credit grants (Gold $89.99/100K credits, Diamond $299.99/1M credits), update lowest-price upsell text.

iOS (new): Launch with Gold-only subscriptions (Weekly $7.99/1,500 credits, Monthly $19.99/6,000 credits, Yearly $89.99/100,000 credits), 3-day free trial on all plans, no credit packs, no Diamond tier.

Google Play / Android: Android will follow the same mobile monetization direction as iOS: Gold-only subscriptions sold through Google Play Billing, with RevenueCat as the shared mobile subscription layer.

Key Risks

  1. Yearly one-time grant - The current system grants credits on every invoice.payment_succeeded event. Yearly plans need a single lump-sum of 100K/1M credits instead of monthly recurring.
  2. Credit reclamation on yearly cancel - The requirements doc says "if cancel plan, credits reclaimed" for yearly subscribers. This is a new concept that doesn't exist in the current system.
  3. iOS platform separation - The iOS product catalog is deliberately different from web. We need platform-aware logic in the subscription flow.
  4. Weekly subscription - A new billing period that doesn't exist in the current system.
  5. iOS Apple IAP + RevenueCat integration - iOS billing is now confirmed to use Apple IAP with RevenueCat. The main risk is no longer choosing a provider, but implementing entitlement sync, user identity mapping, trial behavior, and grant logic correctly.
  6. Google Play Billing + RevenueCat integration - Android is now confirmed to use Google Play Billing with the same Gold-only mobile catalog as iOS. The main risk is correct product mapping, entitlement sync, renewal/cancel/refund handling, and cross-platform account linkage.

2. Current vs New Pricing (Side-by-Side)

2a. Web Subscriptions

PlanCurrent MonthlyNEW MonthlyCurrent YearlyNEW Yearly
Gold$19.99, 4,000 SE/mo$19.99, 6,000 SE/mo$167.92/yr ($13.99/mo), 4,000 SE/mo$89.99/yr, 100,000 SE one-time
Diamond$69.99, 24,000 SE/mo$69.99, 30,000 SE/mo$587.92/yr ($48.99/mo), 24,000 SE/mo$299.99/yr, 1,000,000 SE one-time

Key changes:

  • Monthly credit grants increased (Gold +50%, Diamond +25%)
  • Yearly prices drastically reduced (Gold: $167.92 -> $89.99 = 46% cheaper; Diamond: $587.92 -> $299.99 = 49% cheaper)
  • Yearly credits are now one-time lump-sum, not monthly recurring
  • Effective yearly cash discount becomes ~62% for Gold and ~64% for Diamond; if the UI keeps a "75% off" badge for Diamond, treat that as marketing copy instead of a computed discount value
  • Default recommended plan: Gold Yearly
  • Diamond yearly shows "75% off" badge

2b. Web Credit Packs (UNCHANGED)

PackCreditsPrice
Taster250$1.99
Mini600$3.99
Starter2,000$9.99
Best Value4,000$14.99

No changes to credit pack pricing or amounts.

2c. iOS Subscriptions (NEW)

PlanPriceCreditsNotes
Gold Weekly$7.991,500 SENew billing period
Gold Monthly$19.996,000 SESame as web
Gold Yearly$89.99100,000 SE one-timeSame as web
  • All plans include 3-day free trial
  • No credit packs on iOS
  • No Diamond tier on iOS
  • Default recommended: Gold Yearly
  • Push notifications for daily credit claiming

2d. Google Play / Android Plan

Android is confirmed to follow the same first-version mobile catalog as iOS:

  • Android uses the same mobile subscription catalog as iOS: Gold Weekly, Gold Monthly, Gold Yearly
  • Android also does not offer credit packs or Diamond in the first mobile version
  • Android uses Google Play Billing inside the app, with RevenueCat as the subscription layer
  • Free trial and pricing labels still need separate configuration in Google Play Console even when the commercial design matches iOS

2e. SE Unit Price Comparison (New Structure)

ProductPriceSEUnit Price ($/SE)
Taster pack$1.99250$0.00796
Mini pack$3.99600$0.00665
Starter pack$9.992,000$0.00500
iOS Gold Weekly$7.991,500$0.00533
Best Value pack$14.994,000$0.00375
Gold Monthly$19.996,000$0.00333
Diamond Monthly$69.9930,000$0.00233
Gold Yearly$89.99100,000$0.00090
Diamond Yearly$299.991,000,000$0.00030

The staircase is clean and self-consistent: every higher-commitment product gives a lower unit price.

The new cheapest SE is Diamond Yearly at $0.0003/SE. For context, the current cheapest (displayed in the CreditPurchaseModal upsell) is the Best Value pack with Diamond bonus: $14.99 / 4,600 = ~$0.0033/SE. This is a 91% reduction in the lowest per-SE price.

Note: The requirements doc says the lowest price is $0.0002/SE for Diamond Yearly. But $299.99 / 1,000,000 = $0.0002999, which rounds to $0.0003. The doc was likely using a round number. We should display the computed value via bestPerCredit.toFixed(4) which yields "0.0003".

Scope note: This unit-price comparison uses the headline credit grant only. It does not include daily rewards, member discounts, or other membership perks.


3. Impact Analysis by Area

3a. Configuration Layer

FileWhat ChangesComplexity
config/stripe.tsUpdate web yearly pricing; add GOLD_WEEKLY only if weekly is sold through Stripe; otherwise keep weekly in mobile billing configMedium
config/stripe-credits.tsMONTHLY_GRANT_AMOUNTS Gold 4000->6000, Diamond 24000->30000Low
env.ts / billing envAdd STRIPE_PRICE_GOLD_WEEKLY only if weekly is sold via Stripe; otherwise add Apple / Google / RevenueCat product identifiersMedium

3b. Backend / Business Logic

FileWhat ChangesComplexity
lib/stripe-handlers.tsDistinguish yearly vs monthly grants (one-time lump sum vs recurring); implement credit reclamation on yearly cancelHIGH
lib/auth.tsxKeep Stripe subscription config web-scoped; iOS subscriptions are handled outside Better Auth Stripe via Apple IAP + RevenueCatMedium
Prisma schemaMay need billingInterval field on Subscription model (or detect from Stripe metadata)Medium
Mobile billing integrationAdd Apple IAP and Google Play Billing entitlement sync path through RevenueCatHIGH

3c. UI Components

FileWhat ChangesComplexity
PricingSection.tsxmonthlyCredits values (4000->6000, 24000->30000); yearly pricing display; yearly credit one-time display; default selection to Gold YearlyMedium
ComparePlansSection.tsxMonthly SE values, max monthly potential recalculationMedium
CreditPurchaseModal.tsxbestPerCredit changes (now based on Diamond Yearly); upsell text in 12 localesMedium
MembershipContent.tsxMONTHLY_GRANTS constant (4000->6000, 24000->30000)Low

3d. Mobile Store-Specific

AreaWhat's NeededComplexity
Product catalog configMobile store config for iOS + Google Play, or a shared mobile catalog with per-store product IDsMedium
Free trial configApp Store / Google Play trial period setupMedium
Mobile billing validationRevenueCat webhook / entitlement sync for Apple and Google products, plus store-side console setupHIGH
Push notificationsiOS / Android app feature for daily credit claiming reminderOut of scope (app team)
No credit packsMobile UI should hide credit pack purchase flowLow (app-side)

3e. Documentation

FileWhat Changes
docs/credit-system.mdUpdate all pricing tables, add yearly plans section
docs/credit-system.zh-CN.mdSame, Chinese version

3f. Tests

FileWhat Changes
__tests__/stripe/credit-grants.test.tsUpdate MONTHLY_GRANT_AMOUNTS expected values; add yearly one-time grant tests
__tests__/stripe/webhook-handlers.test.tsUpdate differential credit calculations; add yearly grant tests
__tests__/stripe/integration-handlers.test.tsUpdate expected grant amounts (4000->6000, 24000->30000); add yearly scenarios

4. Detailed File-by-File Change Plan

4.1 config/stripe-credits.ts

typescript
// BEFORE
export const MONTHLY_GRANT_AMOUNTS: Record<string, number> = {
  FREE: 0,
  GOLD: 4_000,
  DIAMOND: 24_000,
};

// AFTER
export const MONTHLY_GRANT_AMOUNTS: Record<string, number> = {
  FREE: 0,
  GOLD: 6_000,
  DIAMOND: 30_000,
};

// NEW: One-time yearly grant amounts (lump sum on subscription start)
export const YEARLY_GRANT_AMOUNTS: Record<string, number> = {
  FREE: 0,
  GOLD: 100_000,
  DIAMOND: 1_000_000,
};

4.2 config/stripe.ts

typescript
// BEFORE
export const STRIPE_PRICES = {
  GOLD_MONTHLY: process.env.STRIPE_PRICE_GOLD_MONTHLY ?? 'price_gold_monthly',
  GOLD_YEARLY: process.env.STRIPE_PRICE_GOLD_YEARLY ?? 'price_gold_yearly',
  DIAMOND_MONTHLY: process.env.STRIPE_PRICE_DIAMOND_MONTHLY ?? 'price_diamond_monthly',
  DIAMOND_YEARLY: process.env.STRIPE_PRICE_DIAMOND_YEARLY ?? 'price_diamond_yearly',
} as const;

// AFTER - add GOLD_WEEKLY only if weekly is sold through Stripe
export const STRIPE_PRICES = {
  GOLD_WEEKLY: process.env.STRIPE_PRICE_GOLD_WEEKLY ?? 'price_gold_weekly',
  GOLD_MONTHLY: process.env.STRIPE_PRICE_GOLD_MONTHLY ?? 'price_gold_monthly',
  GOLD_YEARLY: process.env.STRIPE_PRICE_GOLD_YEARLY ?? 'price_gold_yearly',
  DIAMOND_MONTHLY: process.env.STRIPE_PRICE_DIAMOND_MONTHLY ?? 'price_diamond_monthly',
  DIAMOND_YEARLY: process.env.STRIPE_PRICE_DIAMOND_YEARLY ?? 'price_diamond_yearly',
} as const;

// BEFORE
export const SUBSCRIPTION_PRICES = {
  GOLD: { monthly: 19.99, yearlyPerMonth: 13.99, yearlyTotal: 167.92, discount: 30 },
  DIAMOND: { monthly: 69.99, yearlyPerMonth: 48.99, yearlyTotal: 587.92, discount: 30 },
} as const;

// AFTER
export const SUBSCRIPTION_PRICES = {
  GOLD: {
    weekly: 7.99,             // mobile only
    monthly: 19.99,
    yearlyPerMonth: 7.50,     // $89.99 / 12
    yearlyTotal: 89.99,
    discount: 62,             // (1 - 89.99 / (19.99*12)) * 100 ≈ 62%
  },
  DIAMOND: {
    monthly: 69.99,
    yearlyPerMonth: 25.00,    // $299.99 / 12
    yearlyTotal: 299.99,
    discount: 75,             // per requirements: "75% off"
  },
} as const;

Important: if Gold Weekly is mobile-store-only via Apple IAP / Google Play Billing, do not put it in STRIPE_PRICES. In that case, keep config/stripe.ts web-focused and store Apple / Google / RevenueCat product IDs in a separate mobile billing config.

4.3 lib/stripe-handlers.ts - Major Changes

This file requires the most significant changes. Currently, grantSubscriptionCredits() uses MONTHLY_GRANT_AMOUNTS[tier] for every invoice. The new logic needs to:

  1. Detect billing interval (monthly vs yearly) from the Stripe subscription/invoice
  2. Monthly plans: Continue granting MONTHLY_GRANT_AMOUNTS[tier] per invoice
  3. Yearly plans: Grant YEARLY_GRANT_AMOUNTS[tier] as a one-time lump sum on initial payment only
  4. Yearly cancel: Reclaim remaining credits (NEW concept)
  5. Weekly plans: Grant weekly credit amount per invoice (1,500 for Gold Weekly)

Proposed approach for interval detection:

  • Read subscription.items.data[0].price.recurring.interval from the Stripe subscription object
  • This returns 'week', 'month', or 'year'
  • Pass this info through to grantSubscriptionCredits()

New credit amounts config:

typescript
export const WEEKLY_GRANT_AMOUNTS: Record<string, number> = {
  FREE: 0,
  GOLD: 1_500,
};

Yearly credit reclamation (on cancel):

  • This is the most complex new feature
  • When a yearly subscriber cancels, we need to "reclaim" unearned credits
  • Question: How many credits to reclaim? Options:
    • A) Reclaim ALL remaining credits (user.credits = 0)
    • B) Reclaim prorated amount (e.g., if 3 months used out of 12, reclaim 9/12 of grant)
    • C) Reclaim only the original grant amount minus what was "earned" (requires tracking)
  • Question: What if the user has already spent credits below the reclaim amount?
  • Question: Does "credit reclaimed" mean the balance goes negative, or capped at 0?

4.4 lib/auth.tsx - Better Auth Stripe Plugin

Better Auth's Stripe plugin should stay scoped to web Stripe plans unless the business decides the weekly plan is also sold on the web.

typescript
// Web Stripe plans only
plans: [
  {
    name: 'gold',
    priceId: STRIPE_PRICES.GOLD_MONTHLY,
    annualDiscountPriceId: STRIPE_PRICES.GOLD_YEARLY,
  },
  {
    name: 'diamond',
    priceId: STRIPE_PRICES.DIAMOND_MONTHLY,
    annualDiscountPriceId: STRIPE_PRICES.DIAMOND_YEARLY,
  },
]

Gold Weekly is confirmed to be an iOS Apple IAP / RevenueCat product, so it should not be modeled here as a Stripe plan.

Question: Does the 3-day free trial apply to web plans too, or only iOS? The requirements doc only mentions it for iOS.

Note: Better Auth Stripe freeTrial verification is only relevant for web Stripe plans. iOS trial configuration belongs in App Store Connect and RevenueCat.

4.5 PricingSection.tsx

diff
- monthlyCredits: 4_000,   // GOLD
+ monthlyCredits: 6_000,   // GOLD
- monthlyCredits: 24_000,  // DIAMOND
+ monthlyCredits: 30_000,  // DIAMOND

// Yearly credit display: Need to show "100,000 SE Instantly!" and "1,000,000 SE Instantly!"
// Default selection: Gold Yearly instead of current default

4.6 ComparePlansSection.tsx

diff
// Monthly SE Grant row
- gold: '4,000', diamond: '24,000'
+ gold: '6,000', diamond: '30,000'

// Max Monthly Potential (Gold: 6000 grant + 80*30 daily = 8,400; Diamond: 30000 + 100*30 = 33,000)
- gold: '~6,400', diamond: '~27,000'
+ gold: '~8,400', diamond: '~33,000'

// Need to add yearly pricing rows

4.7 CreditPurchaseModal.tsx

The bestPerCredit calculation currently uses:

typescript
const bestPerCredit = Math.min(...CREDIT_PACKS.map((p) => p.priceUsd / (p.credits + calculateBonusCredits(p.credits, 'DIAMOND'))));

This only considers credit packs. With the new pricing, the cheapest SE is Diamond Yearly ($299.99 / 1,000,000 = $0.0003). The upsell text should reference this instead.

Proposed change: Hardcode or calculate the yearly Diamond per-SE price and use it in the upsell copy. The upsell text exists in 12 locales inline (lines ~468-534) and each needs updating.

4.8 MembershipContent.tsx

diff
- const MONTHLY_GRANTS: Record<string, number> = { FREE: 0, GOLD: 4_000, DIAMOND: 24_000 };
+ const MONTHLY_GRANTS: Record<string, number> = { FREE: 0, GOLD: 6_000, DIAMOND: 30_000 };

Note: This is a duplicate of MONTHLY_GRANT_AMOUNTS in config/stripe-credits.ts. During implementation, consider importing from the config instead.


5. Architecture Challenges & Solutions

Challenge 1: Yearly One-Time Grant vs Monthly Recurring Grant

Problem: The current grantSubscriptionCredits() function uses MONTHLY_GRANT_AMOUNTS[tier] for every invoice.payment_succeeded event. Stripe yearly subscriptions only fire one invoice per year, so technically this "works" - but the grant amount would be wrong (it'd grant 6,000 instead of 100,000 for Gold Yearly).

Solution:

typescript
// In grantSubscriptionCredits(), detect the billing interval:
export async function grantSubscriptionCredits(
  deps: Pick<TStripeHandlerDeps, 'prisma' | 'addCredits'>,
  userId: string,
  plan: string,
  invoiceId: string,
  billingInterval: 'week' | 'month' | 'year' = 'month'  // NEW parameter
) {
  const tier = tierFromPlan(plan);
  let amount: number;

  switch (billingInterval) {
    case 'year':
      amount = YEARLY_GRANT_AMOUNTS[tier];
      break;
    case 'week':
      amount = WEEKLY_GRANT_AMOUNTS[tier] ?? 0;
      break;
    default:
      amount = MONTHLY_GRANT_AMOUNTS[tier];
  }
  // ... rest of idempotent grant logic
}

We need to extract the billing interval from the Stripe subscription object in handleStripeEvent() and handleSubscriptionComplete().

Challenge 2: Credit Reclamation on Yearly Cancel

Problem: Requirements say "if cancel plan, credits reclaimed" for yearly subscribers. This is a completely new concept. The current handleSubscriptionCancel() only sets tier: 'FREE'.

Proposed Solution:

  • When a yearly subscription is canceled (not at period end, but immediately):
    1. Calculate the original grant amount
    2. Deduct it from the user's balance (capped at 0 - never go negative)
    3. Create a SUBSCRIPTION_RECLAIM credit transaction for audit trail
  • When canceled at period end: No reclamation needed (user paid for the full year)

Open question: Does "cancel" here mean immediate cancellation (refund scenario), or cancel-at-period-end? Stripe supports both. The typical flow is:

  • User clicks "Cancel" -> Stripe sets cancel_at_period_end = true -> subscription stays active until period ends -> then auto-cancels
  • In this case, no reclamation is needed since they paid for the full year

If we're talking about refund scenarios (admin-initiated or dispute), then reclamation makes sense.

Challenge 3: Weekly Subscription (New Billing Period)

Problem: The current system only has monthly and yearly plans. A weekly plan is new.

Solution:

  • If weekly is sold through Stripe, create a new Stripe price with recurring.interval = 'week'
  • If weekly is iOS-only, model it in Apple IAP / RevenueCat instead of Stripe
  • Add GOLD_WEEKLY to STRIPE_PRICES only for the Stripe path
  • Add gold-weekly to Better Auth only for the Stripe path
  • Add WEEKLY_GRANT_AMOUNTS config
  • The PLAN_TO_TIER mapping needs to handle 'gold-weekly' -> 'GOLD' only on the Stripe path; Apple IAP should use a product mapping table instead
  • Weekly grants (1,500 SE) are deposited on each billing cycle, same as monthly

Challenge 4: iOS Platform Separation

Problem: iOS has a different product catalog (no credit packs, no Diamond, has Weekly plan). The web must not show the weekly plan, and iOS must not show Diamond or credit packs.

Solution options:

  • Option A: Platform parameter in API calls. The mobile app sends platform: 'ios' and the server returns filtered plan options.
  • Option B: All filtering is client-side. The iOS app simply doesn't render Diamond or credit pack UI. The backend accepts any valid subscription regardless of platform.
  • Recommended: Option B. Keep it simple. The iOS app controls its own UI. The backend doesn't need to know which platform the user subscribed from.

Note: If a user subscribes to Gold Weekly via iOS, then visits the web, the web should still show their active Gold subscription correctly. The tier is GOLD regardless of billing interval.

Challenge 5: Free Trial (3-Day)

Problem: iOS plans should offer a 3-day free trial.

Solution:

  • If the product is sold through Stripe, configure trial_period_days: 3 in Stripe or at subscription creation
  • If the product is sold through Apple IAP, configure the introductory offer / free trial in App Store Connect or RevenueCat
  • Better Auth's Stripe plugin may support a freeTrial config on web Stripe plans
  • During trial, user should still get their credit grant (or only after trial ends?)

Question: Should credits be granted during the free trial period, or only after the first payment?

Challenge 6: Upgrade Differential Credits

Problem: grantUpgradeDifferentialCredits() calculates MONTHLY_GRANT_AMOUNTS[newTier] - MONTHLY_GRANT_AMOUNTS[oldTier]. With different billing intervals, this gets complicated.

Scenarios:

FromToDifferentialNotes
Gold Monthly (6K/mo)Diamond Monthly (30K/mo)24,000Works as before
Gold Weekly (1.5K/wk)Gold Monthly (6K/mo)0Same tier, no differential. Correct.
Gold Yearly (100K upfront)Diamond Yearly (1M upfront)900,000?Extremely large. Needs decision.
Gold Monthly (6K/mo)Gold Yearly (100K one-time)???Interval change within same tier.

The last two scenarios need explicit business rules. The current differential logic only compares tiers, not intervals.

Question: What happens if a Gold Yearly user (who received 100K upfront) upgrades to Diamond Yearly? Do they get 900K more credits? Or only a prorated difference based on remaining subscription time?

Challenge 7: iOS Apple IAP + RevenueCat Implementation

Problem: Apple requires all digital goods purchased within iOS apps to use Apple's In-App Purchase (IAP) system. MysticX credits are digital goods. This means iOS subscriptions cannot go through Stripe directly — they must use StoreKit / Apple IAP.

The current architecture assumes all subscriptions flow through Stripe webhooks. iOS subscriptions would follow a completely different path:

  1. User subscribes via Apple IAP (StoreKit 2)
  2. Apple processes payment and takes 30% cut
  3. iOS app sends the transaction/receipt to our backend
  4. Backend validates the receipt with Apple's App Store Server API
  5. Backend grants subscription tier and credits

This is architecturally very different from the Stripe webhook flow.

Confirmed approach:

  • iOS subscriptions use Apple IAP
  • RevenueCat is the subscription abstraction and entitlement sync layer
  • The backend should trust RevenueCat webhook / entitlement events as the primary integration point, rather than building a direct StoreKit receipt-validation path first

Implementation requirements:

  1. Create iOS products in App Store Connect
  2. Mirror those products in RevenueCat and map them to internal tiers / billing intervals
  3. Choose a stable appUserId strategy so RevenueCat customers can be matched to MysticX users reliably
  4. Process RevenueCat webhook events to activate, renew, cancel, refund, and expire entitlements
  5. Reuse the same internal credit-grant engine after the entitlement event is validated
  6. Support restore purchases correctly when a user signs back into the app on a new device

Recommended architecture if we want the cleanest rollout:

  • Web: Keep Stripe + Better Auth for Gold Monthly, Gold Yearly, Diamond Monthly, Diamond Yearly
  • Mobile stores: Use RevenueCat for Apple IAP and Google Play Billing for Gold Weekly, Gold Monthly, Gold Yearly
  • Backend: Unify everything into one entitlement model with provider, productId, billingInterval, tier, and grant strategy

Challenge 8: Google Play Billing vs Web Stripe

Problem: Android digital subscriptions sold inside the app must go through Google Play Billing, not Stripe checkout embedded in the app. This creates the same category of store-billing split as iOS, but with different console setup, validation APIs, and webhook/event handling.

Confirmed approach:

  • Use the same mobile entitlement model for both iOS and Android
  • Use RevenueCat so Apple and Google purchases arrive through one normalized integration path
  • Keep the mobile product catalog aligned across iOS and Android
  • Validate free trial, introductory offer, renewal, cancellation, refund, and entitlement expiration on both stores separately

6. Mobile Store Plan

6.1 Backend Plan by Billing Path

Confirmed iOS path: Apple IAP + RevenueCat

  1. Keep web subscriptions on Stripe + Better Auth
  2. Create iOS products in App Store Connect and Android products in Google Play Console, not Stripe
  3. Map Apple / Google / RevenueCat products to internal tiers and billing intervals
  4. Add server-side entitlement sync / webhook handling for mobile purchases
  5. Reuse the same credit-grant engine after entitlement validation
  6. Bind RevenueCat customer identity to MysticX user identity so purchases restore to the correct account

6.2 What's Mobile App Only (Not Our Scope)

  1. Push notifications for daily credit claiming
  2. UI: Only show Gold plans (Weekly/Monthly/Yearly), no Diamond, no credit packs
  3. Payment wall design: "Founder's Pass" badge on yearly plan
  4. Credit arrival animation: "Star explosion" effect when 100K credits arrive
  5. Store-specific pricing presentation: App Store / Play Store pricing labels and trials need store console configuration and app-side display review

6.3 App Store Pricing & Apple IAP Consideration

Apple requires In-App Purchase (IAP) for digital goods in iOS apps. This means iOS subscriptions do NOT go through Stripe — they go through Apple's payment system with RevenueCat as the subscription layer. See Challenge 7 for architecture implications.

Apple takes 30% (15% for Small Business Program). Actual revenue per iOS subscription:

PlanPriceApple's Cut (30%)Net Revenue
Gold Weekly$7.99$2.40$5.59
Gold Monthly$19.99$6.00$13.99
Gold Yearly$89.99$27.00$62.99

Compare to web (Stripe takes ~2.9% + $0.30):

PlanPriceStripe FeeNet Revenue
Gold Monthly$19.99~$0.88~$19.11
Gold Yearly$89.99~$2.91~$87.08

6.4 Google Play Billing Consideration

Google Play Store requires Google Play Billing for in-app digital subscriptions on Android. The operational implications are similar to iOS, but the setup differs:

  • Products and trials are configured in Google Play Console
  • Validation uses Google Play purchase tokens / Developer API, or RevenueCat as the abstraction layer
  • Subscription lifecycle events can be consumed directly or via RevenueCat and Play RTDN integration
  • Finance should verify the actual Google Play fee rate in the current Play Console program settings before using it in margin models

6.5 Mobile vs Web Subscription Interop

If a user subscribes via iOS or Android and later visits the web:

  • Their tier is GOLD (same as web Gold)
  • The web should show their active subscription status correctly
  • The web should NOT show "upgrade to weekly" or other iOS-only options
  • If they want Diamond, they must subscribe via web

If a user subscribes to Diamond on the web and later logs into iOS or Android:

  • The app should still recognize them as DIAMOND and unlock Diamond benefits
  • The app should show Diamond as the current active plan, labeled as managed outside the App Store / Play Store
  • The app should NOT offer App Store or Google Play purchase actions for Diamond, because Diamond is not sold in the first mobile catalog
  • The app should avoid showing Gold free-trial or Gold upsell purchase cards as if the user were unsubscribed

Apple anti-steering warning (App Store Review Guideline 3.1.3(b)): Apple's guidelines allow apps to surface content the user bought elsewhere, but explicitly forbid apps from using any link, button, or copy to direct users away from IAP to make a purchase. This means:

  • Do NOT add an external link or CTA that routes to the web membership page. Even a "Manage on website" button is a violation if that page contains any pricing or purchase UI.
  • Instead, show a read-only informational label only — e.g. "Your Diamond subscription is active (managed outside the App Store)" — with no tappable link attached.
  • If a URL is genuinely unavoidable, it must deep-link to a dedicated status-only page where all pricing and purchase UI is hidden for users arriving from iOS.

Android / Google Play: Google Play's policies on external links are less restrictive than Apple's. A link to a subscription management page (not a new purchase flow) is generally permitted. However, the same read-only label approach is the safest default for the first mobile release. An Android-specific "Manage on website" link can be considered after the app is live and stable.

To make this work cleanly, the backend should store more than tier alone. It should also retain provider (stripe, apple, revenuecat), product identifier, and billing interval so the management UI can render the correct state and allowed upgrade paths.

Recommended rule: entitlement beats storefront. The mobile app can sell only Gold while still honoring a web-originated Diamond entitlement in a read-only state. The mobile app must never expose any UI that Apple could interpret as directing the user away from IAP for a purchase decision.


7. Migration & Existing User Considerations

7.1 Existing Monthly Subscribers

Gold Monthly subscribers currently get 4,000 SE/month. After the change, they'll get 6,000 SE/month. This is a free upgrade - no action needed for them. The change takes effect on their next billing cycle when invoice.payment_succeeded fires with the new MONTHLY_GRANT_AMOUNTS.

Diamond Monthly subscribers currently get 24,000 SE/month. After the change, they'll get 30,000 SE/month. Same - free upgrade.

Note: We should probably send a notification to existing subscribers: "Great news! Your monthly SE grant has been increased!"

7.2 Existing Yearly Subscribers

Current yearly subscribers are on the OLD pricing ($167.92/yr for Gold, $587.92/yr for Diamond). Stripe yearly subscriptions only fire invoice.payment_succeeded once per year at renewal.

Critical edge case: After deploying the new code, the grant logic will use billingInterval to determine amounts. When an existing yearly subscriber renews:

  • Stripe charges the old price ($167.92 / $587.92) because the price ID on their subscription hasn't changed
  • But the new code would grant the new yearly lump sum (100K / 1M credits)
  • This means existing yearly subscribers who paid MORE ($167.92 vs $89.99) would get the same 100K credits as new subscribers paying less

This is actually favorable to existing users (they get a massive credit increase for free), but may not be the intended behavior.

Question: Do we need to create NEW Stripe price IDs for the new yearly pricing ($89.99/$299.99), or update the existing ones? If we create new ones, existing subscribers keep their old price ID and we need a mapping to determine their grant amount.

Recommendation: Create new Stripe prices. For existing yearly subscribers, two options:

  • (A) Let them keep old prices and still receive old-style monthly grants (requires keeping the old grant path in code)
  • (B) Auto-migrate them to new prices at their next renewal (requires Stripe subscription update)
  • (C) Let them keep old prices but grant new yearly lump sum on renewal (generous but simple)

7.3 Notification

Requirements mention: "Send a membership benefits upgrade notification."

We should create a one-time notification for all active subscribers informing them of the increased benefits.


8. Testing Plan

8.1 Unit Tests to Update

Test FileChanges Needed
__tests__/stripe/credit-grants.test.tsUpdate MONTHLY_GRANT_AMOUNTS assertions (4000->6000, 24000->30000); add YEARLY_GRANT_AMOUNTS tests; add WEEKLY_GRANT_AMOUNTS tests
__tests__/stripe/webhook-handlers.test.tsUpdate differential calculations; add yearly one-time grant scenarios; add weekly grant scenarios
__tests__/stripe/integration-handlers.test.tsUpdate "grants 4000 credits for gold plan" -> 6000; update "grants 24000 credits for diamond plan" -> 30000; add yearly grant test

8.2 New Test Scenarios Needed

  1. Yearly subscription creation -> One-time lump sum grant of correct amount
  2. Yearly subscription renewal -> Confirm the annual lump sum repeats according to the active product / price mapping
  3. Weekly subscription invoice -> Correct weekly grant amount
  4. Yearly cancel with credit reclamation -> Credits deducted correctly
  5. Yearly cancel when user has fewer credits than reclaim amount -> Capped at 0
  6. Upgrade from Gold Monthly to Gold Yearly mid-cycle
  7. Upgrade from Gold Yearly to Diamond Yearly -> Differential calculation
  8. iOS free trial start -> No credits granted (or granted?)
  9. iOS free trial conversion to paid -> Credits granted
  10. Google Play purchase start -> Entitlement and credits granted correctly
  11. Google Play renewal / cancellation / refund -> Entitlement and grant state stay correct

8.3 Manual Testing Checklist

  • [ ] Web: Gold Monthly shows $19.99, 6,000 SE
  • [ ] Web: Diamond Monthly shows $69.99, 30,000 SE
  • [ ] Web: Gold Yearly shows $89.99, 100,000 SE (one-time)
  • [ ] Web: Diamond Yearly shows $299.99, 1,000,000 SE (one-time)
  • [ ] Web: Diamond Yearly shows "75% off" badge
  • [ ] Web: Default selection is Gold Yearly
  • [ ] Web: CreditPurchaseModal upsell text shows updated lowest price
  • [ ] Web: ComparePlansSection shows updated SE values
  • [ ] Web: MembershipContent shows correct grant amounts
  • [ ] Web: Subscribing to Gold Monthly grants 6,000 SE
  • [ ] Web: Subscribing to Gold Yearly grants 100,000 SE one-time
  • [ ] Web: Subscribing to Diamond Yearly grants 1,000,000 SE one-time
  • [ ] Web: Canceling yearly subscription reclaims credits (if implemented)
  • [ ] iOS: Only Gold plans visible (Weekly/Monthly/Yearly)
  • [ ] iOS: No credit packs visible
  • [ ] iOS: 3-day free trial works
  • [ ] iOS: Weekly subscription grants 1,500 SE per cycle
  • [ ] Android / Google Play: Same mobile catalog is shown if product confirms parity
  • [ ] Android / Google Play: Trial, renewal, cancellation, and entitlement sync work correctly

9. Implementation Checklist

Phase 1: Configuration & Backend (Do First)

  • [ ] Create new Stripe prices for web yearly products ($89.99 Gold Yearly, $299.99 Diamond Yearly)
  • [ ] Create GOLD_WEEKLY in Stripe only if weekly is also sold via Stripe
  • [ ] Add STRIPE_PRICE_GOLD_WEEKLY only if needed; otherwise add Apple / Google / RevenueCat product identifiers to env and config
  • [ ] Update config/stripe-credits.ts - monthly grants (6000/30000), add yearly/weekly grants
  • [ ] Update config/stripe.ts - prices, add weekly, update yearly amounts
  • [ ] Update lib/stripe-handlers.ts - interval-aware grants, yearly lump sum logic
  • [ ] Update lib/auth.tsx - keep Stripe plans web-scoped unless weekly is sold via Stripe
  • [ ] Update tier / product mapping to support provider-specific products
  • [ ] Add iOS RevenueCat entitlement sync / webhook integration
  • [ ] Define RevenueCat appUserId mapping and restore-purchase behavior
  • [ ] Run npx prisma generate if schema changes

Phase 2: UI Updates

  • [ ] Update PricingSection.tsx - credit values, yearly display, default selection
  • [ ] Update ComparePlansSection.tsx - monthly SE values, max potential
  • [ ] Update CreditPurchaseModal.tsx - upsell text (12 locales), bestPerCredit calculation
  • [ ] Update MembershipContent.tsx - MONTHLY_GRANTS values

Phase 3: Tests

  • [ ] Update __tests__/stripe/credit-grants.test.ts
  • [ ] Update __tests__/stripe/webhook-handlers.test.ts
  • [ ] Update __tests__/stripe/integration-handlers.test.ts
  • [ ] Add new test scenarios for yearly/weekly/trial

Phase 4: Documentation

  • [ ] Update docs/credit-system.md
  • [ ] Update docs/credit-system.zh-CN.md

Phase 5: iOS Support

  • [ ] Set up RevenueCat products, entitlements, and server webhooks for iOS
  • [ ] Define the iOS appUserId / account-linking strategy in RevenueCat
  • [ ] Verify restore purchases behavior across reinstall / sign-in changes
  • [ ] Coordinate with iOS app team on product catalog
  • [ ] Set up App Store Connect in-app purchases (Gold Weekly/Monthly/Yearly)
  • [ ] Configure free trial in App Store Connect

Phase 6: Google Play Support

  • [ ] Set up Google Play Console subscriptions (Gold Weekly/Monthly/Yearly)
  • [ ] Configure free trial / introductory offers in Google Play Console
  • [ ] Connect Google Play products and entitlement mapping in RevenueCat
  • [ ] Configure Play test users / internal testing and verify cross-platform entitlement sync

10. Open Questions for Boss

Critical (Need answers before implementation)

  1. Yearly credit reclamation - The doc says "if cancel plan, credits reclaimed." Does this mean:

    • (a) When user clicks "Cancel" and subscription ends at period end -> No reclamation (they paid for the full year)?
    • (b) When user gets a refund (dispute/admin) -> Reclaim credits?
    • (c) Immediate cancellation mid-year -> Reclaim prorated credits?
    • What if the user has already spent most of the credits? Do we allow negative balance, or cap at 0?
  2. Free trial credits - During the 3-day free trial on iOS, should users receive their credit grant (1,500/6,000/100,000)? Or only after trial converts to paid?

    • If credits are granted during trial and user cancels before trial ends, do we reclaim?
  3. Yearly upgrade differential - If a Gold Yearly user (received 100K credits) upgrades to Diamond Yearly ($299.99), should they receive 900K additional credits? That's a very large differential.

  4. Existing yearly subscribers - Do we create new Stripe Price IDs for the new yearly pricing, keeping existing subscribers on old prices? Or update the existing prices (affecting all renewals)?

  5. Weekly plan on web? - The doc mentions the weekly plan for iOS only. The web membership page section says "Weekly, Monthly, Yearly toggle (keep current for now)." Should the web show a weekly option, or is it iOS-exclusive?

  6. "75% off" calculation for Diamond Yearly - $299.99 vs $69.99*12 = $839.88. Actual discount: 64% off. The doc says "Diamond shows 75% off yearly." Should we display 75% as stated, or the mathematically accurate ~64%?

  7. Yearly membership perks - Does the upfront yearly credit grant come in addition to the normal membership perks already tied to tier, such as daily credits, discounts, and premium features? The docs currently assume yes.

  8. RevenueCat identity strategy - Should RevenueCat appUserId be bound 1:1 to our user ID, and how should we handle anonymous usage, login changes, and restore-purchase merging?

Nice To Have (Can decide later)

  1. Yearly plan display format - Should the pricing toggle stay as "Monthly / Yearly" or become a different UI? The doc says "keep current Monthly/Yearly toggle for now."

  2. "Get 100,000 Energy Instantly!" copy - The doc suggests this text for yearly plans. Should this be shown on the pricing card, the checkout page, or both?

  3. Push notification text - The doc suggests: "Allen, your daily 80 energy is ready to align with the stars." Should we use the user's actual name, and adjust the number (80/100) based on their tier? (mobile app team question)

  4. Credit arrival animation for yearly - "Star explosion effect" when 100K/1M credits arrive. Is this needed on web too, or mobile only?

  5. "Founder's Pass" badge - Is this just for the mobile paywall, or should it appear on web pricing too?

  6. Membership upgrade notification - Should we send an email, in-app notification, or both to existing subscribers about the increased benefits?


Appendix A: Changes That Do NOT Need to Happen

These areas remain unchanged:

  • Credit pack prices and amounts (Taster/Mini/Starter/Best Value)
  • Daily credit amounts (Free: 50, Gold: 80, Diamond: 100)
  • Registration bonus (300 SE)
  • Tier bonuses on credit packs (Gold +10%, Diamond +15%)
  • Credit costs (readings: 200, follow-ups: 50/100/200/400, etc.)
  • Referral rewards
  • Card skin / reader discounts (Gold 40%, Diamond 60%)

Appendix B: Billing Platform Setup Tasks

Before code deployment, these need to be created in the relevant billing platforms:

Stripe (Web)

  1. New Price: Gold Yearly - $89.99/year, recurring (replaces old $167.92/year price)
  2. New Price: Diamond Yearly - $299.99/year, recurring (replaces old $587.92/year price)
  3. Optional: Gold Weekly - $7.99/week only if weekly is sold through Stripe
  4. Update env vars: Add new Stripe price IDs to .env.production, .env.development

App Store Connect (iOS)

  1. Create Gold Weekly / Monthly / Yearly subscriptions
  2. Configure the 3-day free trial or introductory offer
  3. Connect products to RevenueCat
  4. Configure RevenueCat webhook / entitlement mapping and customer identity strategy

Google Play Console (Android)

  1. Create Gold Weekly / Monthly / Yearly subscriptions
  2. Configure free trial / introductory offers for Play subscriptions
  3. Connect products to RevenueCat
  4. Configure internal testing and test accounts