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.

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)

Internal — not for distribution