Skip to content

Billing & Subscription Pipeline

Environment Reality (read this first)

Website (dev.lucidpal.pages.dev) is not a true dev environment — CI builds it with defaultConfiguration: production (environment.prod.ts), so it calls api.lucidpal.app (production lucidpal-api). The dev API (api-dev.lucidpal.app) is not used by the website at all.

iOS app (DEBUG / TestFlight) is a true dev environment — APIEnvironment.swift defaults isDev = true, routing to api-dev.lucidpal.app. Developers can toggle this in-app; the preference is persisted in App Group UserDefaults. App Store builds compile out the dev URL entirely.

The licensing-service dev worker (api-dev.lucidfabrics.com) and production worker (api.lucidfabrics.com) share the same D1 database and KV namespace — there is no data isolation between them.

Layer Matrix

LayerDevProductionNotes
Websitedev.lucidpal.pages.devlucidpal.appCI uses nx build with defaultConfiguration: production — both deploys use the same binary
Angular env fileenvironment.prod.tsenvironment.prod.tsBoth use prod config — website dev calls prod API
iOS app (DEBUG/TF)api-dev.lucidpal.app (default, toggleable)api.lucidpal.appAPIEnvironment.swift — defaults isDev = true; toggle persisted in App Group UserDefaults
iOS app (App Store)api.lucidpal.appapi-dev URL not compiled in for App Store builds (#else branch)
LucidPal APIapi-dev.lucidpal.app (lucidpal-api)api.lucidpal.app (lucidpal-api-production)Dev API is actively used by iOS DEBUG/TestFlight builds
Licensing serviceapi-dev.lucidfabrics.com (licensing-api)api.lucidfabrics.com (licensing-api-production)Shared DB — no isolation
D1 — lucidpal-apilucidpal-db-dev (ab5eccf6)lucidpal-db (338c0725)Separate ✓
KV — lucidpal-api3ce605504ebf460e69c4028e55b6432aSeparate ✓
R2 — lucidpal-apilucidpal-assets-devlucidpal-assetsSeparate ✓
D1 — licensing-servicelicensing-db (6ee8eb93)licensing-db (6ee8eb93)SAME DB ⚠️
KV — licensing-servicef38bec7e60c84889f38bec7e60c84889SAME KV ⚠️
StripeLucidPal account — test key (sk_test_51TRaAI…)LucidPal account — live key not set yetSwap sk_live_ at launch

Bindings — lucidpal-api (both workers)

Defined in wrangler.toml. Not secrets — set at deploy time, not via wrangler secret put.

BindingTypeDev valueProd value
DBD1 databaselucidpal-db-devlucidpal-db
SESSIONSKV namespace3ce605504ebf460e69c4028e55b6432a
ASSETSR2 bucketlucidpal-assets-devlucidpal-assets
TRANSCRIPTION_SESSIONDurable ObjectTranscriptionSessionsame
SYNC_SESSIONDurable ObjectSyncSessionsame
ENVIRONMENTvar ([vars])"development""production"

ENVIRONMENT = "development" bypasses subscription checks in AI and Agent services — used for local wrangler dev only.

Payment Flow

Web (Stripe)

User clicks Subscribe
  → POST /billing/checkout  (lucidpal-api)
      → GET /api/pricing?product=lucidpal   (licensing-service) — resolves Stripe priceId from DB
      → POST /api/stripe/checkout           (licensing-service) — creates Stripe Checkout Session
  → browser redirects to Stripe hosted checkout page
      → user pays
      → Stripe sends webhook → POST /api/webhooks/stripe (licensing-service) → updates licensing-db
  → browser redirects to {origin}/#subscription-success
  → /billing/status polling picks up updated state (polls at 3s + 8s post-redirect)

iOS US + EU (Stripe)

User taps "Subscribe via Web" in UpgradeView
  → StoreKitManager.createStripeCheckout() → POST /billing/checkout  (lucidpal-api)
      No Origin header sent — appUrl falls back to APP_URL (https://lucidpal.app in prod)
      successUrl = https://lucidpal.app/#subscription-success
  → UIApplication.shared.open(stripeURL)  ← opens Stripe in default Safari browser
      → user pays in Safari
      → Stripe sends webhook → licensing-service → updates licensing-db
  → user switches back to app
  → refreshEntitlement() called immediately + after 2s delay
  → UpgradeView dismisses when entitlement is no longer .free

No deep link. iOS Stripe opens in external Safari. The app polls twice (not 6×4s — that is the website's polling schedule). The lucidpal://payment/* scheme is not used.

iOS Rest of World (Apple IAP)

User taps "Get [Plan]" in UpgradeView
  → StoreKit.Product.purchase()
      → Apple servers verify payment
      → Transaction.updates listener fires with VerificationResult
  → StoreKitManager.verifyWithBackend(jwsTransaction:transactionId:)
      → POST /billing/apple/verify  (lucidpal-api, authenticated)
          → decodes JWS payload (no Apple server call — client-side decode only)
          → upserts subscriptions row in lucidpal-db
  → transaction.finish()
  → premiumManager.refreshEntitlement()

Apple also sends server-side notifications:

Apple → POST /billing/apple/notifications  (lucidpal-api, NO auth — Apple calls directly)
    → decodes signedPayload
    → updates subscription status in lucidpal-db

successUrl / cancelUrl

Derived from the Origin request header on POST /billing/checkout (falls back to APP_URL env var).

ContextSuccess URLCancel URL
Web{origin}/#subscription-success{origin}/#pricing
iOS Stripehttps://lucidpal.app/#subscription-success (APP_URL fallback — no Origin header from URLSession)https://lucidpal.app/#pricing

iOS Payment Region

StoreKitManager.paymentRegion (in StoreKitManager.swift):

RegionMethodCountry codes
🇺🇸 USStripe"US"
🇪🇺 EU + EEAStripeAT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE + NO IS LI
Rest of worldApple IAPAll other Locale.current.region codes
swift
static var paymentRegion: PaymentRegion {
    let code = Locale.current.region?.identifier ?? ""
    if code == "US" || euRegionCodes.contains(code) { return .stripe }
    return .apple
}

EU routes to Stripe already (EU Digital Markets Act entitlement active). The old doc note "EU: Apple IAP only (currently)" was stale.

Apple IAP Product IDs

Defined in StoreKitService.swift and apple-billing.service.ts:

PlanMonthly product IDYearly product ID
starterapp.lucidpal.starter.monthlyapp.lucidpal.starter.yearly
proapp.lucidpal.pro.monthlyapp.lucidpal.pro.yearly
ultimateapp.lucidpal.ultimate.monthlyapp.lucidpal.ultimate.yearly

PRODUCT_TO_PLAN in apple-billing.service.ts maps these to SubscriptionPlan values.

Stripe Account (LucidPal — dedicated)

Created 2026-04-29. Separate from BitBonsai. Owned by W M Solutions Inc.

FieldValue
Account ID prefix51TRaAI7
Stripe productprod_UQRg3WxkmYkn43
Current key typesk_test_… (sandbox)
Go-live actionwrangler secret put STRIPE_SECRET_KEY --env production with sk_live_…

Old price IDs with ApElOUGi6b prefix (BitBonsai account) are invalid. Do not use.

Stripe Price IDs

Stored in licensing-db.pricing_tiers. Created and updated via the licensing admin portal publish flow — never hardcode. Query GET /api/pricing?product=lucidpal for current values.

PlanDB tier nameMonthlyYearly
starterSTARTERprice_1TRanG77vQU9Tdum6jqQpd2Aprice_1TRanH77vQU9TdumsWcgKq9X
proPROprice_1TRanL77vQU9TdumJxoE41W4price_1TRanL77vQU9TdumSPBdvSp8
ultimateULTIMATEprice_1TRanN77vQU9TdumxTzuwLX6price_1TRanO77vQU9TdumhkxOKgF3

billing.service.ts maps frontend plan names to DB tier names via PLAN_TO_TIER: starter → STARTER, pro → PRO, ultimate → ULTIMATE. Lookup is case-insensitive (toUpperCase()).

Stripe Webhook (production licensing worker)

FieldValue
Endpointhttps://api.lucidfabrics.com/api/webhooks/stripe
Webhook IDwe_1TRavP77vQU9TdumfmouB78O
Eventscheckout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed
SecretSTRIPE_WEBHOOK_SECRET on licensing-api-production

Dev licensing worker (licensing-api) has no webhook configured — it shares the production DB so Stripe events from sandbox checkouts are written to the same tables. This is intentional for now.

Secrets — lucidpal-api (dev worker → api-dev.lucidpal.app)

SecretValue
LICENSING_API_URLhttps://api-dev.lucidfabrics.com
LICENSING_API_KEYadmin key (X-Admin-Key header)
LICENSING_PRODUCT_IDlucidpal-dev-001 (TBC — verify in Cloudflare dashboard)
APP_URLhttps://dev.lucidpal.pages.dev
GOOGLE_CLIENT_ID1074612045085-q3mqvpt342ud5juiuu5q2bm69ton8u9n.apps.googleusercontent.com
JWT_SECRETset
GEMINI_API_KEYset
DEEPGRAM_API_KEYset
LOOPS_API_KEYset
APPLE_CLIENT_IDset
APNS_KEY_IDset
APNS_TEAM_IDset
APNS_PRIVATE_KEY_BASE64set
APNS_BUNDLE_IDset

Secrets — lucidpal-api-production (prod worker → api.lucidpal.app)

SecretValue
LICENSING_API_URLhttps://api.lucidfabrics.com
LICENSING_API_KEYadmin key (X-Admin-Key header)
LICENSING_PRODUCT_IDlucidpal-prod-001
APP_URLhttps://lucidpal.app
GOOGLE_CLIENT_ID1074612045085-q3mqvpt342ud5juiuu5q2bm69ton8u9n.apps.googleusercontent.com
JWT_SECRETset
GEMINI_API_KEYset
DEEPGRAM_API_KEYset
LOOPS_API_KEYset
APPLE_CLIENT_IDset
APNS_KEY_IDset
APNS_TEAM_IDset
APNS_PRIVATE_KEY_BASE64set
APNS_BUNDLE_IDset

Secrets — licensing-api (dev worker → api-dev.lucidfabrics.com)

SecretValue
STRIPE_SECRET_KEYsk_test_51TRaAI… (LucidPal sandbox)
JWT_SECRETset
ADMIN_API_KEYset
ENCRYPTION_KEYset
ED25519_PRIVATE_KEYset
ED25519_PUBLIC_KEYset
CLOUDFLARE_ANALYTICS_TOKENset
STRIPE_WEBHOOK_SECRETnot set — dev worker has no webhook endpoint

Secrets — licensing-api-production (prod worker → api.lucidfabrics.com)

SecretValue
STRIPE_SECRET_KEYsk_test_51TRaAI…swap to sk_live_… at launch
STRIPE_WEBHOOK_SECRETwhsec_pcsN6jLwkk…
JWT_SECRETset
ADMIN_API_KEYset
ENCRYPTION_KEYset
ED25519_PRIVATE_KEYset
ED25519_PUBLIC_KEYset
CLOUDFLARE_ANALYTICS_TOKENset

CORS Origins

lucidpal-api (both dev and prod)

Allowlist in src/index.ts:

https://lucidpal.app
https://app.lucidpal.app
http://localhost:4600
https://dev.lucidpal.pages.dev

licensing-api (dev worker) — wrangler.toml [vars]

https://admin.lucidfabrics.com
https://lucidpal.app
https://lucidfabrics.com
https://licensing-admin-d5k.pages.dev
http://localhost:4204
https://dev.licensing-admin-d5k.pages.dev
https://dev.lucidpal.pages.dev

licensing-api-productionwrangler.toml [env.production.vars]

https://admin.lucidfabrics.com
https://lucidpal.app
https://lucidfabrics.com
https://dev.lucidpal.pages.dev

GCP OAuth — Google Sign-In

FieldValue
Client ID1074612045085-q3mqvpt342ud5juiuu5q2bm69ton8u9n.apps.googleusercontent.com
Authorized JS originshttps://lucidpal.app, http://localhost:4600, https://dev.lucidpal.pages.dev
Used inAngular environment.*.ts (all three) + GOOGLE_CLIENT_ID secret on both lucidpal-api workers

Admin Portal — Licensing Service

EnvironmentURL
Dev previewhttps://dev.licensing-admin-d5k.pages.dev
Productionhttps://licensing-admin-d5k.pages.dev / https://admin.lucidfabrics.com

To republish pricing tiers after a Stripe account change:

  1. Clear old price IDs in DB: UPDATE pricing_tiers SET stripe_price_id_monthly=NULL, stripe_price_id_yearly=NULL, is_active=0 WHERE ...
  2. Update stripe_product_id on the product row to match the new Stripe account
  3. Log in to admin portal → LucidPal pricing → publish each tier

Feature Gates

These are not stable facts. Tier boundaries and feature assignments change as the product evolves. This doc describes the mechanism; the code is the source of truth.

Two enforcement layers:

LayerFileWhen to change
iOS UI gatingapps/lucidpal-ios/Sources/Models/FeatureGate.swiftrequiredTier switchMoving a feature between tiers
API enforcementapps/lucidpal-api/src/middleware/requirePlan.tsrequirePlan(minPlan)Protecting an endpoint
API service checksAiService.checkChatAccess(), AgentService.assertPaidPlan()Cloud AI / agent plan requirements

iOS vs API discrepancy by design: iOS gates (FeatureGate.swift) may be stricter than the API. Trial and promotional flows can grant API access below the iOS-gated tier. When adjusting a tier boundary, update both files and keep them in sync.

Features bypassing FeatureGate

Some features are intentionally always-on in PremiumManager without going through FeatureGate.swift. These are unconditional var canUseX: Bool { true } properties.

PropertyReason
canUseDataExportData export is free for all users — no tier gate
canUseVisionOn-device, no cloud cost
canUseLiveActivitiesOn-device, no cloud cost
canUseAirpodsAutoVoiceOn-device, no cloud cost
canUseProactiveAIOn-device, no cloud cost
canUseAgentOn-device agent is free
canUseContinuousAgentOn-device, no cloud cost

When a feature moves from gated to always-free: remove the FeatureGate case, change the PremiumManager property to { true }, and update the UI to remove the upgrade prompt.

Credit & Rate Limits

Not hardcoded in this doc — check the source. Limits are defined in code and change independently per tier as the product evolves.

Cloud AI Chat — monthly credits

  • Mechanism: KV key cloud_msg:{userId}:{YYYY-MM} (60-day TTL)
  • Source of truth: MONTHLY_LIMIT map in apps/lucidpal-api/src/services/ai.service.ts
  • Behaviour: HTTP 402 = no eligible plan; HTTP 429 = monthly cap hit
  • iOS client: PremiumManager.creditsRemaining decremented optimistically on each message. The server does not return this value in /billing/status — the iOS BillingStatusResponse decodes creditsRemaining as Int? and always gets nil. Client count is the only display value.

Cloud Agent (/agent/chat) — daily turns

  • Mechanism: KV key gemini_agent:{userId}:{YYYY-MM-DD} (48h TTL)
  • Source of truth: DAILY_LIMIT map in apps/lucidpal-api/src/services/agent.service.ts
  • Behaviour: HTTP 429 = daily cap hit
  • /agent/synthesize has no daily rate limit — only the paid-plan check.

When you change a limit: update the constant in the service file, deploy the worker, done. No DB migration, no admin portal action required.

Agent Synthesis (/agent/synthesize)

Single-turn non-streaming Gemini call for paid tiers after on-device tool data is gathered.

  • HTTP 402 = no paid subscription (assertPaidPlan)
  • HTTP 503 = GEMINI_API_KEY not set
  • No rate limit on this endpoint (unlike /agent/chat)
  • maxOutputTokens: 512 for synthesize, 1024 for chat

subscriptions Table (lucidpal-db)

ColumnTypeNotes
idtext PK
user_idtext FK→ users.id (cascade delete)
stripe_customer_idtextunique; set after first Stripe checkout
stripe_subscription_idtextunique; set by webhook
apple_original_transaction_idtextunique; set by /billing/apple/verify
payment_sourcetext'stripe' | 'apple'
plantext'free' | 'starter' | 'pro' | 'ultimate'
billing_periodtext'monthly' | 'yearly' | null
statustext'active' | 'inactive' | 'canceled' | 'past_due'
current_period_endtextISO datetime; null until webhook fires

Status Caching & Webhook Latency

GET /billing/status reads D1 (cached row). Background waitUntil syncs fresh state from licensing-service — eventual consistency ~1–2s lag after webhook.

Stripe → POST /api/webhooks/stripe (licensing-service) → updates licensing-db
       → lucidpal-api /billing/status background sync → updates lucidpal-db
       → next /billing/status call returns updated state
ClientPost-payment polling schedule
Web3s + 8s after redirecting to #subscription-success
iOS StriperefreshEntitlement() immediately + after 2s delay
iOS IAPrefreshEntitlement() after transaction.finish()

Latency: 5–20s from payment to D1 update.

Go-Live Checklist

  • [ ] Set STRIPE_SECRET_KEY to sk_live_… on licensing-api-production
  • [ ] Register live Stripe webhook → get new whsec_… → set STRIPE_WEBHOOK_SECRET on licensing-api-production
  • [ ] Republish all LucidPal pricing tiers via admin portal (creates live Stripe prices)
  • [ ] Verify LICENSING_PRODUCT_ID is correct on both lucidpal-api workers (lucidpal-dev-001 / lucidpal-prod-001)
  • [ ] Register Apple IAP product IDs in App Store Connect (app.lucidpal.*.monthly/yearly)
  • [ ] Separate licensing-service dev DB from prod DB (currently shared — risk of test data contamination)

Cloudflare Infrastructure — LucidPal Resources

Cloudflare account is shared (W M Solutions Inc. — account ID eab661fba6c6a4d2e11c090abd0a811b). LucidPal-specific resources are listed below. A buyer would need all of these transferred.

Workers (licensing-service)

Worker namePurposeDomain
licensing-apiDev/staging licensing workerapi-dev.lucidfabrics.com
licensing-api-productionProduction licensing workerapi.lucidfabrics.com

Workers (lucidpal-api)

Worker namePurposeDomain
lucidpal-apiDev/staging API workerapi-dev.lucidpal.app
lucidpal-api-productionProduction API workerapi.lucidpal.app

D1 Databases

BindingNameIDShared?
DBlicensing-db6ee8eb93-ba1a-46da-a7c9-6eb648bb4a2eDev+prod share same DB ⚠️
DBlucidpal-db338c0725-…Production only
DBlucidpal-db-devab5eccf6-…Dev only

KV Namespaces

BindingPurposeIDShared?
SESSIONSlicensing session KVf38bec7e60c84889Dev+prod share ⚠️
CACHElucidpal-api KV (prod)69c4028e55b6432aProd only
CACHElucidpal-api KV (dev)3ce605504ebf460eDev only

R2 Buckets

BindingBucket namePurpose
ASSETSlucidpal-assetsProd: user uploads, avatars
ASSETSlucidpal-assets-devDev: user uploads
EXPORT_BUCKETlicensing-exportsProd: product data export snapshots
EXPORT_BUCKETlicensing-exports-devDev: export snapshots

Pages Deployments

Project nameURLRepo branch
lucidpallucidpal.appmain
lucidpal (dev)dev.lucidpal.pages.devmain
licensing-admin-d5kdev.licensing-admin-d5k.pages.devmain

DNS Zones

DomainRegistrarNotes
lucidpal.appCloudflareAll DNS managed in Cloudflare
lucidfabrics.comCloudflareShared with BitBonsai — do NOT transfer whole zone

lucidfabrics.com hosts both LucidPal (api.lucidfabrics.com) and BitBonsai records. A buyer of LucidPal only needs the api.lucidfabrics.com subdomain migrated to their own domain, not the full zone transfer.

Product Isolation & Export Playbook

All licensing-service data is scoped to a productId. LucidPal's product ID is lucidpal-default-01 (dev) and lucidpal-prod-001 (production) — set as LICENSING_PRODUCT_ID secret on lucidpal-api.

The export feature (POST /api/admin/export/trigger) produces a ZIP-compatible JSON snapshot of all product-scoped data stored in R2 (licensing-exports bucket). A buyer can use this snapshot to bootstrap their own licensing-service instance.

Exported tables: product, pricing_tiers, promo_codes, licenses, license_activations, webhook_events, audit_log.

Not exported (global / not product-scoped): admin_users, email_templates, app_config, donations.

See ~/git/licensing-service/PRODUCT_ISOLATION_PLAN.md for the full isolation and export plan.


Retention / Cancellation Flow

How It Works

When a user taps Manage Subscription in the app, PremiumService.showManageSubscriptions() calls Billing.showManageSubscriptions() → StoreKit 2's AppStore.showManageSubscriptions(in: scene). Apple presents the native subscription management sheet. If the user taps Cancel Subscription, Apple automatically shows the Retention Messaging UI configured in App Store Connect — no additional app code required.

Manage Subscription UI

ManageSubscriptionComponent (apps/lucidpal-mobile/src/app/features/premium/manage-subscription.component.ts) is a bottom sheet (75% height) that shows:

ElementCondition
Current plan name + tier colorAlways
Credits remainingWhen creditBalance > 0
Upgrade noteWhen tier === 'free'
Manage Subscription buttoniOS + active subscription
Upgrade to Starter buttonFree tier only

The component emits dismissed (closes) and upgradeRequested (opens paywall).

Entry points:

  • Settings page → Account section → "Manage Subscription" row (existing, direct action)
  • Left sidebar → App section → Subscription item → dispatches lp-open-subscription window event → settings component @HostListener('window:lp-open-subscription') sets showManageSubscription = true

App Store Connect — Subscription Group

Group: LucidPal Premium (ID: 22027381)
App ID: 6761450212

SubscriptionProduct IDASC ID
Starter Monthlyapp.lucidpal.starter.monthly6762061454
Starter Yearlyapp.lucidpal.starter.yearly6762058254
Pro Monthlyapp.lucidpal.pro.monthly6762061476
Pro Yearlyapp.lucidpal.pro.yearly6762057544
Ultimate Monthlyapp.lucidpal.ultimate.monthly6762060367
Ultimate Yearlyapp.lucidpal.ultimate.yearly6762059573

Promotional Offers — Apple Retention Messaging

Promotional offers are configured per-subscription in App Store Connect under Subscription Prices → Promotional Offers. Apple surfaces them natively at cancellation.

Offer structure:

TierOffer IDTypeValueDuration
Starter Monthlyapp.lucidpal.starter.monthly.retention.1mfreeFree$01 month
Starter Yearlyapp.lucidpal.starter.yearly.retention.1mfreeFree$01 month
Pro Monthlyapp.lucidpal.pro.monthly.retention.50pctPay as you go50% off2 months
Pro Yearlyapp.lucidpal.pro.yearly.retention.50pctPay as you go50% off2 months
Ultimate Monthlyapp.lucidpal.ultimate.monthly.retention.50pctPay as you go50% off3 months
Ultimate Yearlyapp.lucidpal.ultimate.yearly.retention.50pctPay as you go50% off3 months

Note: Promotional offers on native subscription management sheets require no app-side code. The offer appears in Apple's native cancellation flow automatically once configured in App Store Connect.

Review Information Screenshot

All 6 subscriptions require a Review Information Screenshot in App Store Connect before going live. Upload a paywall screenshot to each subscription → Review Information → Screenshot. This is what causes "Missing Metadata" in the subscription list.

Internal — not for distribution