2026 10 11 xp pro purchase design - xopherdeep/do-it-for-the-xp GitHub Wiki

XP Pro: pricing, paywall and purchase flow

Date: 2026-10-11 ยท Status: draft for review

1. Goal

Do It For The XP earns money from a family subscription called XP Pro, sold on the web (Stripe) and in the iOS and Android apps (App Store and Play, through RevenueCat). Gatekeeper, the COMPASS licensing plugin, is the single source of truth for who has Pro. The free app keeps the whole chore loop, so a family can run their house on it without paying.

Success means: a parent on any platform can buy Pro, the app unlocks within seconds, the unlock follows the family to their other devices, cancellations and refunds remove it, and nothing a family made is ever deleted when Pro lapses.

Decisions (agreed 2026-10-11, gates revised 2026-10-12)

Topic Decision
Channels Web (Stripe through Bazaar) and native stores (Apple and Google through RevenueCat) at launch
Source of truth Gatekeeper. Stripe and RevenueCat are adapters that report into it
Webhooks Every inbound event arrives through Hookshot, which authenticates it
Identity A random Family ID per install, an XP-PRO- license key per family, email backup. No passwords. Real accounts wait for Family Sync
Price $4.99/month, $39.99/year, 7-day free trial on both; Founder's Lifetime $79 one-time, for a limited time
Unit Per family, never per kid
Pro-only at launch Temple Creator; advanced scheduling; AI helpers
Free forever Unlimited heroes; custom quests and custom beasts; claim and battle; GM approval; XP/GP/AP; rewards shop; the basic Gold Bank; Do This, Not That; every class, every region, built-in temples
Later Pro (roadmap, out of scope here) Parent reports; a Pro cosmetic set for heroes; Gold Bank+ (interest, savings goals, allowance); Family Sync (multiplayer across devices) as the headline

What competitors gate (researched 2026-10-12)

Paid feature Who charges for it Our answer
Advanced scheduling, auto-assigning chores S'mores Up ($4.99/mo) Pro: advanced scheduling, AI schedule suggestions
Reports (daily/weekly/monthly, skipped tasks) S'mores Up, Joon ($12.99/mo, $89.99/yr) Later Pro: parent reports
Money skills (jars, allowance, investing) S'mores Up (MoneyWise), Homey ($4.99/mo, $49.99/yr; 1 jar free), BusyKid (about $4/mo) Basic Gold Bank free; later Pro: Gold Bank+
Penalty management, reward approval S'mores Up Free: they are part of our core loop (Do This, Not That; HP; the approval queue)
Game customization and difficulty Joon Pro: Temple Creator. Custom quests and beasts stay free
Family-size limits Homey (3 members free; reviewers complain) None: unlimited heroes free
Exclusive cosmetics, monthly item sets Habitica Later Pro: a cosmetic set that adds, never takes away
Separate parent and kid devices Joon (two apps) Later Pro: Family Sync

Nobody charges for the basic loop. They charge for parent power tools, money features and cosmetics, which is the line this spec draws.

2. Architecture

                 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Web (Cloudflare Pages) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
 Go Pro screen โ”€โ”€โ”ค GET doit.forthexp.com/buy/doitforthexp/{plan}    โ”‚
                 โ”‚   ?family_id&device_id&return_url=โ€ฆ/#/pro/return โ”‚
                 โ””โ”€โ”€โ–บ Bazaar โ”€โ–บ Stripe Checkout โ”€โ–บ Stripe webhook โ”€โ”€โ”
                 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ iOS / Android โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
 Go Pro screen โ”€โ”€โ”ค RevenueCat SDK (appUserID = family_id)          โ”‚ โ”‚
                 โ””โ”€โ”€โ–บ App Store / Play โ”€โ–บ RevenueCat webhook โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚
                                                                   โ–ผ โ–ผ
                                      Hookshot receive endpoint (authenticates each source)
                                                                   โ”‚  do_action xophz_hookshot_incoming
                                     Gatekeeper XP Pro: Stripe adapter, RevenueCat adapter
                                       โ€ข grants or extends the family's license
                                       โ€ข expires or revokes on cancel, refund, expiry
                                       โ€ข emails the key when an email is known
                                                                   โ”‚
 App โ—„โ”€โ”€ POST /compass/v1/gatekeeper/xp-pro/claim     {family_id, device_id}
     โ—„โ”€โ”€ POST /compass/v1/gatekeeper/xp-pro/validate  {license_key, device_id}

Code ownership:

Piece Lives in Why
Plan catalog and /buy/doitforthexp/โ€ฆ resolver xophz-compass-xp (new class-xophz-compass-xp-pro.php) Same pattern as the existing class-xophz-compass-xp-subscriptions.php resolver for /buy/xp/โ€ฆ
Licenses, adapters, REST endpoints xophz-compass-gatekeeper (new class-gatekeeper-xp-pro*.php) Gatekeeper already owns licenses and Hookshot fulfillment
Bearer authentication for RevenueCat xophz-compass-hookshot (Hookshot_Signature) Hookshot authenticates every inbound webhook
AI helpers proxy xophz-compass-gatekeeper (class-gatekeeper-xp-pro-ai.php), using COMPASS core's Google connector Pro AI must check the license, and the Gemini key must stay on the server
Checkout session creation xophz-compass-bazaar (unchanged) The existing dynamic resolver path already does everything needed
Paywall, purchase UI, entitlement cache do-it-for-the-xp

/buy/xp/โ€ฆ and /buy/forthexp/โ€ฆ already belong to the XP developer-API plans; the family product uses doitforthexp so the two never collide.

The XP plugin's existing POST /xp/v1/pro and _xp_is_pro user meta are a different thing: an admin-set flag on WordPress users of the COMPASS XP network. XP Pro for the family app never reads or writes them; family entitlement lives only in Gatekeeper's gk_xp_pro_* records.

3. Server

3.1 Plan catalog and Bazaar resolver (xophz-compass-xp)

Xophz_Compass_Xp_Pro::get_plans() returns:

Plan Stripe mode Price Interval Trial Store product id
monthly subscription 4.99 month 7 days xp_pro_monthly
yearly subscription 39.99 year 7 days xp_pro_yearly
lifetime payment 79.00 โ€” โ€” xp_pro_lifetime

lifetime is sold only while the option xp_pro_lifetime_on_sale is true (default true at launch).

It hooks xophz_resolve_buy_request and handles only segments[0] === 'doitforthexp':

  • segments[1] must be a plan in the catalog that is on sale, or the resolver returns a WP_Error (404).
  • family_id must be a lowercase UUID v4; device_id must be 8โ€“64 characters of [A-Za-z0-9_-]. Anything else is a 400. Neither is ever echoed into HTML.
  • It returns handled => true, product_name (not name: Bazaar's create_stripe_session reads product_name), price, mode, interval, trial_days and metadata = { product: 'doitforthexp', plan, family_id, device_id }.
  • It leaves success_url and cancel_url unset so Bazaar builds them from return_url, which Xophz_Compass_Security::is_allowed_redirect_url checks. Bazaar appends session_id={CHECKOUT_SESSION_ID}&purchased=doitforthexp.
  • Bazaar already switches to the Stripe test key when return_url points at localhost, so local development uses test mode automatically.

GET /compass/v1/gatekeeper/xp-pro/plans (public) returns the catalog with on_sale flags so the web app shows the same prices the server charges. Gatekeeper reads the catalog through the filter gatekeeper_xp_pro_plans, which the XP plugin fills from get_plans(), so neither plugin calls the other's classes.

3.2 License records (xophz-compass-gatekeeper)

Gatekeeper_Xp_Pro_Licenses stores one record per family as its own option, gk_xp_pro_{family_id} (autoload off), plus a lookup option gk_xp_pro_key_{LICENSE_KEY} โ†’ family_id. One option per family keeps two webhooks for different families from overwriting each other, which a single shared array (the pattern in Gatekeeper_Licenses) would risk. If the business passes a few thousand families, move this to a custom table behind the same class.

array(
  'license_key'  => 'XP-PRO-7KQM-3HXA-9WZD', // 3 groups of 4 from the unambiguous alphabet already used by Gatekeeper_Licenses
  'family_id'    => 'โ€ฆuuidโ€ฆ',
  'plan'         => 'monthly' | 'yearly' | 'lifetime',
  'status'       => 'active' | 'expired' | 'revoked',
  'expires_at'   => '2026-11-18T00:00:00Z' | null,   // null = lifetime
  'env'          => 'production' | 'sandbox',
  'email'        => '[email protected]' | null,
  'sources'      => array(
    'stripe'     => array( 'customer' => 'cus_โ€ฆ', 'subscription' => 'sub_โ€ฆ', 'payment_intent' => 'pi_โ€ฆ' ),
    'revenuecat' => array( 'product_id' => 'xp_pro_yearly', 'store' => 'APP_STORE' ),
  ),
  'devices'      => array( 'device-id', โ€ฆ ),          // at most 10
  'event_ids'    => array( 'evt_โ€ฆ', โ€ฆ ),              // last 50 processed events, for idempotency
  'created_at'   => โ€ฆ, 'updated_at' => โ€ฆ,
)

Rules:

  • Entitled when status === 'active' and (expires_at is null or now < expires_at + 3 days). The 3 days cover store billing retries.
  • A grant is idempotent. It merges the source refs and keeps the license key. If a family holds both a subscription and the lifetime plan, lifetime wins (expires_at = null, plan = 'lifetime'); a later subscription event never shortens it.
  • Revoke only what the refund bought. Refunding or disputing the lifetime payment sets status = 'revoked'. Any other refund (a subscription charge) leaves an active lifetime alone and otherwise sets status = 'expired', so the next renewal turns Pro back on. An admin Restore action next to "Reset devices" reactivates a revoked record.
  • Expire only from the current paid source. The record keeps period_source (stripe:<sub id> or revenuecat:<product id>) and lifetime_source. An expiry from any other source (an old subscription, the other store) changes nothing, and expires_at only ever moves later (max).
  • Production wins. A sandbox event never changes a production record.
  • Every write to a family's record runs under a per-family MySQL lock (GET_LOCK), and the AI and rate-limit counters use atomic increments.
  • Both expired and revoked records are kept, so buying again reuses the same key.
  • A license never goes through Gatekeeper_Licenses::validate(). That path accepts made-up CX- keys (a separate task is closing it), and XP Pro keys must not depend on it.
  • When a grant creates a new key and an email is known, it sends one wp_mail with the key and the restore steps.

3.3 Webhook intake (Hookshot โ†’ Gatekeeper)

Two Hookshot webhooks are set up in wp-admin: XP Pro ยท Stripe and XP Pro ยท RevenueCat. Their post ids are saved in the option gatekeeper_xp_pro_webhooks = { stripe: <id>, revenuecat: <id> } from the Gatekeeper admin screen.

Gatekeeper_Xp_Pro_Intake hooks xophz_hookshot_incoming and processes a payload only when both hold:

  1. $webhook_id is one of the two configured ids. That id decides the adapter; the payload's shape never does.
  2. Hookshot_Signature::was_verified( $webhook_id ) is true. Hookshot lets a webhook with no secret through unsigned; an unverified XP Pro event is logged and dropped.

The existing handle_hookshot_incoming (product === 'forthexp') is left alone; it never sees doitforthexp.

Hookshot change. Hookshot_Signature::verify() today only checks HMAC signatures (Stripe t=,v1= and raw/GitHub HMAC). RevenueCat sends a fixed Authorization header instead. Add post meta hookshot_verify_mode: hmac (default, today's behavior) or bearer. In bearer mode, the Authorization header must equal Bearer {signing secret} (compared with hash_equals); on a match the request is marked verified exactly as an HMAC match is. The mode goes in the Hookshot webhook editor beside the signature header field. The Stripe webhook keeps hmac with signature header Stripe-Signature, which the existing t=,v1= parser already understands.

Stripe adapter (Gatekeeper_Xp_Pro_Stripe). Subscribe the Stripe endpoint to these events:

Event Action
checkout.session.completed with metadata.product === 'doitforthexp' and payment_status paid or no_payment_required (an unpaid session grants nothing) Grant: plan from metadata; expires_at = now + 7 days if the subscription is trialing, else the period end (subscription) or null (lifetime); record customer, subscription, payment_intent, customer_details.email; env from livemode
checkout.session.async_payment_succeeded Grant, as for a paid checkout
checkout.session.async_payment_failed Revoke that purchase (rules in 3.2)
invoice.paid for a known subscription Extend expires_at to the invoice's period end
customer.subscription.deleted for a known subscription Expire
charge.refunded (full refund) or charge.dispute.created for a known payment_intent or customer Revoke

The invoice's subscription id is read from invoice.subscription or, on newer Stripe API versions, invoice.parent.subscription_details.subscription. Renewal events don't carry the session metadata, so the adapter finds the family by the subscription or payment_intent saved at checkout, through the lookup options gk_xp_pro_sub_{id} and gk_xp_pro_pi_{id} โ†’ family_id. Events for unknown ids are ignored.

RevenueCat adapter (Gatekeeper_Xp_Pro_RevenueCat). event.app_user_id is the family_id (rejected unless it's a UUID v4). event.id is the idempotency key. event.environment sets env.

Event type Action
INITIAL_PURCHASE, RENEWAL, UNCANCELLATION, PRODUCT_CHANGE Grant or extend; expires_at from expiration_at_ms
NON_RENEWING_PURCHASE for xp_pro_lifetime Grant lifetime
CANCELLATION No change, since auto-renew is off but the paid period runs out. Revoke only when cancel_reason is CUSTOMER_SUPPORT (a refund)
EXPIRATION Expire
BILLING_ISSUE No change (the 3-day grace and the store's own retry cover it)
TRANSFER, anything else Log only

3.4 REST endpoints (namespace compass/v1/gatekeeper, all public)

Every endpoint validates family_id and device_id as in 3.1, and is rate-limited per IP with a transient (30 requests a minute; /recover 5 an hour).

Method + path Body Returns
GET /xp-pro/plans โ€” catalog and on_sale flags
POST /xp-pro/claim family_id, device_id 200 {pro, license_key, plan, expires_at, env, family_id} when a record exists; 404 {pro:false} when nothing has arrived yet. Adds the device
POST /xp-pro/validate license_key, device_id Same 200 shape (including family_id, so a restored device adopts it), or 404. Adds the device. pro is false when the record is expired or revoked
POST /xp-pro/recover email Always 202. Emails every key on file for that address. Never reveals whether one exists
POST /xp-pro/manage license_key For a Stripe-sourced license: 200 {url} to a Stripe Billing Portal session that returns to return_url. For a store-sourced one: 200 {store} so the app can open the store's subscription page

Device cap: an 11th device gets 409 {error:'device_limit'}; support can reset devices from the Gatekeeper admin screen, which lists XP Pro licenses next to the existing ones.

The family_id works like a key: whoever holds it can claim the license. It's a random UUID that never leaves the family's devices except in calls to these endpoints and in Stripe or RevenueCat metadata.

3.5 AI helpers proxy (xophz-compass-gatekeeper)

Today src/lib/services/ai/GeminiService.ts calls Gemini straight from the app with VUE_APP_GEMINI_API_KEY, which ships the key inside the public bundle and hard-codes gemini-2.0-flash (COMPASS core already treats 2.0 as retired). XP Pro moves these calls to the server:

  • POST /compass/v1/gatekeeper/xp-pro/ai with { license_key, device_id, task, input }. The license must be entitled (3.2) and the device registered, or it's a 402 {error:'pro_required'}.
  • task is one of the four the app uses today: monster_name, checklist (from a quest name), full_beast, schedule. The prompt templates move into PHP, one per task; input is the task's structured fields only (names, short text, numbers), each length-capped. The endpoint never accepts a free-form prompt, so a Pro key can't be used as a general Gemini proxy.
  • The Gemini key and model come from COMPASS core's google connector, the same resolution Xophz_Compass_Gemini_API uses (its model fallback included). Responses are parsed to the JSON shape each task's caller expects today, and a malformed reply is a 502.
  • Limit: 100 AI calls per license per day (a per-license transient counter), 429 beyond that. The number is an option, xp_pro_ai_daily_limit.

4.1 Modules

File Kind Responsibility
src/lib/databases/ProDb.ts DB, @ionic/storage __xp_pro, like FamilySettingsDb { familyId, deviceId, license: { key, plan, expiresAt, env, lastValidatedAt } | null }
src/lib/services/pro/proPlans.ts pure Plan ids, display prices for the web, the gate list (templeCreator, advancedSchedule, aiHelpers)
src/lib/services/pro/entitlement.ts pure isPro(cached, now, buildEnv), needsRecheck(cached, now); the offline rules in 4.4
src/lib/services/pro/familyIdentity.ts pure plus DB Creates familyId (UUID v4 from crypto.randomUUID) and deviceId on first launch; adoptFamily(id) after a restore
src/lib/services/pro/proClient.ts fetch The endpoints; the site comes from src/lib/api/apiHost.ts (compassRestRoot(), compassSiteRoot()): the hosting WordPress site when xophz-compass-xp serves the app, otherwise https://doit.forthexp.com
src/lib/services/pro/storeBilling.ts native only @revenuecat/purchases-capacitor: configure({ apiKey, appUserID: familyId }), offerings, purchase, restore. Keys from VUE_APP_RC_IOS_KEY / VUE_APP_RC_ANDROID_KEY (public SDK keys)
src/lib/services/ai/GeminiService.ts fetch (rewritten) Same four exported functions and return shapes, now calling /xp-pro/ai; isGeminiConfigured() becomes "is Pro". VUE_APP_GEMINI_API_KEY is removed from the app and .env.example
src/lib/store/stores/pro.ts Pinia isPro, plan, loading; init(), purchase(plan), claim(), restoreWithKey(key), recover(email), manage(), requirePro(gate)
src/router/_Guards/pro.guard.ts guard Routes with meta.pro = '<gate>' open the upsell instead of navigating when not Pro

4.2 Screens

  • Go Pro (/pro, XpGoPro page with sibling .ts/.scss). Styled as an SNES shop counter: the three plans as shop items, what Pro adds, "7 days free", Restore. On native, prices come from the RevenueCat offering (the store's local currency); on the web, from /xp-pro/plans. Lifetime shows only while it's on sale (web flag or present in the offering).
  • Upsell modal (XpProUpsell): opened by requirePro(gate). Names the feature that was tapped, offers "See XP Pro" and "Not now". Sound openMenu on open, no on dismiss.
  • Return (/pro/return): after Stripe sends the parent back. Polls claim every 2 s for up to 30 s with a "the shopkeeper is counting your goldโ€ฆ" dialog. On success: toast, then back to where the parent came from. On timeout: "Payment received; your unlock is on its way" with a Retry button, and init() keeps trying in the background. status=cancelled returns quietly to /pro.
  • XP Pro panel in Settings (XpSettings): status and plan, renewal or expiry date, the key masked with a copy button, "Restore with key", "Email me my key", and "Manage subscription" (manage()). Apple requires a visible Restore, so it calls RevenueCat restorePurchases() on native and then claim().

4.3 Purchase flows

Web. purchase(plan) sends the browser to {compassSiteRoot()}/buy/doitforthexp/{plan}?family_id=โ€ฆ&device_id=โ€ฆ&return_url={origin}/#/pro/return. Bazaar creates the Stripe session and redirects. The webhook grants; /pro/return claims.

Native. purchase(plan) โ†’ storeBilling.purchase(package). On success, poll claim() exactly as /pro/return does: the RevenueCat webhook usually lands within a few seconds. The app never unlocks from the SDK's local result alone; Gatekeeper decides.

Restore on a new device. restoreWithKey(key) โ†’ validate; on success adoptFamily(response.family_id) so this install shares the family's id, and RevenueCat gets logIn(familyId) on native so later store purchases credit the same family.

4.4 Entitlement cache and offline rules

  • init() runs at app start. It loads ProDb, then calls validate (or claim when there's no key yet) if needsRecheck: the last check was more than 24 hours ago, or expiresAt has passed.
  • Pro stays on offline while lastValidatedAt is within 30 days and (expiresAt is null or now < expiresAt + 3 days).
  • A network error keeps the cached state. A 200 with pro:false or a 404 turns Pro off but keeps the key, so a renewal or a support fix brings it back on the next check.
  • Production builds treat env === 'sandbox' licenses as not Pro; development builds accept both.
  • This is client-side enforcement in a local-first app, so a determined user could patch it. That's acceptable for a family app at this price; Family Sync will be enforced on the server.

4.5 The gates

All gates block creating or editing a Pro feature. Nothing a family already has stops working when Pro is missing or lapses: custom temples stay playable and saved schedules keep firing. There is no limit on heroes, quests or beasts.

Gate Where Free behavior
templeCreator meta.pro = 'templeCreator' on the temple create and edit routes in src/router/Admin/temples.routes.ts (xp-room-editor and the temples/:templeId/* editors); the "New temple" button calls requirePro first Built-in temples play normally and are listed read-only. Custom beasts (bestiary.routes.ts) and custom quests are free and ungated
aiHelpers The AI buttons in BeastDetailsModal.vue (name, checklist), QuestTypeTab.vue (full beast) and AchievementScheduleTab.vue (schedule) call requirePro('aiHelpers') before calling GeminiService The buttons show with a padlock and open the upsell; everything they would fill in can still be typed by hand
advancedSchedule AchievementScheduleTab.vue Free: basicSchedule once, daily (one time a day) and weekly with "which days". Pro: custom (repeat and interval), the times-of-day chips, and "times per day" above 1. Locked controls show a small padlock and open the upsell when tapped

5. Configuration checklist (no code)

  1. Stripe: a live and a test webhook endpoint pointing at the Hookshot receive URL, with the six events in 3.3. The Stripe signing secret goes into the Hookshot webhook's signing secret, header Stripe-Signature, mode hmac. Billing Portal turned on in the Stripe dashboard.
  2. RevenueCat: project with the iOS and Android apps; entitlement pro; products xp_pro_monthly, xp_pro_yearly, xp_pro_lifetime; one offering with all three. Webhook to the Hookshot receive URL with an Authorization value of Bearer <secret>; the same secret goes into the Hookshot webhook, mode bearer.
  3. App Store Connect and Play Console: the three products at $4.99, $39.99 (both with a 7-day free introductory offer) and $79, in one subscription group.
  4. WordPress (doit.forthexp.com): activate Xophz COMPASS, Bazaar, Gatekeeper, Hookshot and XP. Checked 2026-10-12: Hookshot, Bazaar, Gatekeeper (26.10.12-384) and XP are active. Bazaar's /buy/โ€ฆ router works but falls back to the checkout simulator because no Stripe secret key is set in Settings > Connectors. The front end shows a maintenance page; REST and /buy/โ€ฆ are unaffected. Also set the WordPress Address (siteurl, currently http://) to https:// to match the Site Address. The site answers through Cloudflare, so define XOPHZ_HOOKSHOT_TRUSTED_PROXIES in wp-config.php with Cloudflare's published IP ranges; otherwise every visitor shares one rate-limit bucket. Then add do-it-for-the-xp.pages.dev (and the production domain once there is one) to compass_trusted_redirect_domains; set gatekeeper_xp_pro_webhooks; xp_pro_lifetime_on_sale = true.
  5. App .env (and .env.example): VUE_APP_RC_IOS_KEY, VUE_APP_RC_ANDROID_KEY. The WordPress site needs no setting: apiHost.ts already resolves it.

6. Testing

  • App, jest (chemx test): entitlement.spec.ts (lifetime, active, inside the 3-day grace, expired, 30-day offline window, sandbox in a production build); familyIdentity.spec.ts (created once, stable, adopt); pro.store.spec.ts with a mocked proClient (claim polling stops on success and on timeout; restore adopts the family; network error keeps Pro); GeminiService.spec.ts with a mocked proClient (each function sends its task and fields, and maps the reply to today's return shape; pro_required surfaces as the upsell).
  • App, Cypress: pro.spec.js โ€” with cy.intercept stubbing claim, validate and plans: a fourth kid can be added on the free plan; /pro/return unlocks; an AI button opens the upsell when free; the Temple Creator route is blocked when free and open when Pro.
  • Server, COMPASS PHP harness (xophz-compass/tests/harness, run with node wp-content/plugins/<plugin>/tests/run-tests.mjs; PHP runs inside the u-wordpress container): the decision tables in the two adapters are pure static methods (event in, operation out) tested row by row with real-shaped Stripe and RevenueCat JSON for every row of the tables in 3.3, plus a replayed event (no double grant), an unverified webhook (dropped), and a wrong webhook id (dropped). The AI proxy: no license (402), expired license (402), unknown task (400), over-long input (400), the daily limit (429), and a canned Gemini reply per task mapped to the expected JSON. Hookshot gets its first tests/run-tests.mjs for the bearer mode.
  • End to end, manual before launch: Stripe test mode from localhost:8100 (Bazaar picks the test key on its own) through webhook, claim, renewal (Stripe test clock), cancel and refund; RevenueCat sandbox on an iOS and an Android device; restore on a second device with the key.

7. Out of scope

Family Sync and multiplayer; accounts and passwords; price experiments; ads; coupons beyond Stripe promotion codes (Bazaar already turns them on); in-app purchases of consumables or cosmetics; moving existing CX- licenses.

8. Build order

  1. Hookshot bearer verify mode.
  2. Gatekeeper: license store, both adapters, intake, endpoints, fixture runner, admin listing; then the AI proxy (3.5).
  3. XP plugin: catalog and /buy/doitforthexp resolver; /xp-pro/plans.
  4. App: ProDb, pure services and specs, proClient, Pinia store, Go Pro and Return screens, Settings panel, web purchase.
  5. App: the gates, and GeminiService moved onto the AI proxy.
  6. App: RevenueCat native purchase and restore (needs the store products from section 5).
  7. Docs: docs/XP-Membership.md rewritten from this spec; Gatekeeper, Hookshot and XP plugin docs/CHANGELOG.md entries.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ