Appearance
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
| Layer | Dev | Production | Notes |
|---|---|---|---|
| Website | dev.lucidpal.pages.dev | lucidpal.app | CI uses nx build with defaultConfiguration: production — both deploys use the same binary |
| Angular env file | environment.prod.ts | environment.prod.ts | Both use prod config — website dev calls prod API |
| iOS app (DEBUG/TF) | api-dev.lucidpal.app (default, toggleable) | api.lucidpal.app | APIEnvironment.swift — defaults isDev = true; toggle persisted in App Group UserDefaults |
| iOS app (App Store) | — | api.lucidpal.app | api-dev URL not compiled in for App Store builds (#else branch) |
| LucidPal API | api-dev.lucidpal.app (lucidpal-api) | api.lucidpal.app (lucidpal-api-production) | Dev API is actively used by iOS DEBUG/TestFlight builds |
| Licensing service | api-dev.lucidfabrics.com (licensing-api) | api.lucidfabrics.com (licensing-api-production) | Shared DB — no isolation |
| D1 — lucidpal-api | lucidpal-db-dev (ab5eccf6) | lucidpal-db (338c0725) | Separate ✓ |
| KV — lucidpal-api | 3ce605504ebf460e | 69c4028e55b6432a | Separate ✓ |
| R2 — lucidpal-api | lucidpal-assets-dev | lucidpal-assets | Separate ✓ |
| D1 — licensing-service | licensing-db (6ee8eb93) | licensing-db (6ee8eb93) | SAME DB ⚠️ |
| KV — licensing-service | f38bec7e60c84889 | f38bec7e60c84889 | SAME KV ⚠️ |
| Stripe | LucidPal account — test key (sk_test_51TRaAI…) | LucidPal account — live key not set yet | Swap sk_live_ at launch |
Bindings — lucidpal-api (both workers)
Defined in wrangler.toml. Not secrets — set at deploy time, not via wrangler secret put.
| Binding | Type | Dev value | Prod value |
|---|---|---|---|
DB | D1 database | lucidpal-db-dev | lucidpal-db |
SESSIONS | KV namespace | 3ce605504ebf460e | 69c4028e55b6432a |
ASSETS | R2 bucket | lucidpal-assets-dev | lucidpal-assets |
TRANSCRIPTION_SESSION | Durable Object | TranscriptionSession | same |
SYNC_SESSION | Durable Object | SyncSession | same |
ENVIRONMENT | var ([vars]) | "development" | "production" |
ENVIRONMENT = "development"bypasses subscription checks in AI and Agent services — used for localwrangler devonly.
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 .freeNo 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-dbsuccessUrl / cancelUrl
Derived from the Origin request header on POST /billing/checkout (falls back to APP_URL env var).
| Context | Success URL | Cancel URL |
|---|---|---|
| Web | {origin}/#subscription-success | {origin}/#pricing |
| iOS Stripe | https://lucidpal.app/#subscription-success (APP_URL fallback — no Origin header from URLSession) | https://lucidpal.app/#pricing |
iOS Payment Region
StoreKitManager.paymentRegion (in StoreKitManager.swift):
| Region | Method | Country codes |
|---|---|---|
| 🇺🇸 US | Stripe | "US" |
| 🇪🇺 EU + EEA | Stripe | AT 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 world | Apple IAP | All 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:
| Plan | Monthly product ID | Yearly product ID |
|---|---|---|
| starter | app.lucidpal.starter.monthly | app.lucidpal.starter.yearly |
| pro | app.lucidpal.pro.monthly | app.lucidpal.pro.yearly |
| ultimate | app.lucidpal.ultimate.monthly | app.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.
| Field | Value |
|---|---|
| Account ID prefix | 51TRaAI7 |
| Stripe product | prod_UQRg3WxkmYkn43 |
| Current key type | sk_test_… (sandbox) |
| Go-live action | wrangler secret put STRIPE_SECRET_KEY --env production with sk_live_… |
Old price IDs with
ApElOUGi6bprefix (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.
| Plan | DB tier name | Monthly | Yearly |
|---|---|---|---|
| starter | STARTER | price_1TRanG77vQU9Tdum6jqQpd2A | price_1TRanH77vQU9TdumsWcgKq9X |
| pro | PRO | price_1TRanL77vQU9TdumJxoE41W4 | price_1TRanL77vQU9TdumSPBdvSp8 |
| ultimate | ULTIMATE | price_1TRanN77vQU9TdumxTzuwLX6 | price_1TRanO77vQU9TdumhkxOKgF3 |
billing.service.tsmaps frontend plan names to DB tier names viaPLAN_TO_TIER:starter → STARTER,pro → PRO,ultimate → ULTIMATE. Lookup is case-insensitive (toUpperCase()).
Stripe Webhook (production licensing worker)
| Field | Value |
|---|---|
| Endpoint | https://api.lucidfabrics.com/api/webhooks/stripe |
| Webhook ID | we_1TRavP77vQU9TdumfmouB78O |
| Events | checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed |
| Secret | STRIPE_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)
| Secret | Value |
|---|---|
LICENSING_API_URL | https://api-dev.lucidfabrics.com |
LICENSING_API_KEY | admin key (X-Admin-Key header) |
LICENSING_PRODUCT_ID | lucidpal-dev-001 (TBC — verify in Cloudflare dashboard) |
APP_URL | https://dev.lucidpal.pages.dev |
GOOGLE_CLIENT_ID | 1074612045085-q3mqvpt342ud5juiuu5q2bm69ton8u9n.apps.googleusercontent.com |
JWT_SECRET | set |
GEMINI_API_KEY | set |
DEEPGRAM_API_KEY | set |
LOOPS_API_KEY | set |
APPLE_CLIENT_ID | set |
APNS_KEY_ID | set |
APNS_TEAM_ID | set |
APNS_PRIVATE_KEY_BASE64 | set |
APNS_BUNDLE_ID | set |
Secrets — lucidpal-api-production (prod worker → api.lucidpal.app)
| Secret | Value |
|---|---|
LICENSING_API_URL | https://api.lucidfabrics.com |
LICENSING_API_KEY | admin key (X-Admin-Key header) |
LICENSING_PRODUCT_ID | lucidpal-prod-001 |
APP_URL | https://lucidpal.app |
GOOGLE_CLIENT_ID | 1074612045085-q3mqvpt342ud5juiuu5q2bm69ton8u9n.apps.googleusercontent.com |
JWT_SECRET | set |
GEMINI_API_KEY | set |
DEEPGRAM_API_KEY | set |
LOOPS_API_KEY | set |
APPLE_CLIENT_ID | set |
APNS_KEY_ID | set |
APNS_TEAM_ID | set |
APNS_PRIVATE_KEY_BASE64 | set |
APNS_BUNDLE_ID | set |
Secrets — licensing-api (dev worker → api-dev.lucidfabrics.com)
| Secret | Value |
|---|---|
STRIPE_SECRET_KEY | sk_test_51TRaAI… (LucidPal sandbox) |
JWT_SECRET | set |
ADMIN_API_KEY | set |
ENCRYPTION_KEY | set |
ED25519_PRIVATE_KEY | set |
ED25519_PUBLIC_KEY | set |
CLOUDFLARE_ANALYTICS_TOKEN | set |
STRIPE_WEBHOOK_SECRET | not set — dev worker has no webhook endpoint |
Secrets — licensing-api-production (prod worker → api.lucidfabrics.com)
| Secret | Value |
|---|---|
STRIPE_SECRET_KEY | sk_test_51TRaAI… → swap to sk_live_… at launch |
STRIPE_WEBHOOK_SECRET | whsec_pcsN6jLwkk… |
JWT_SECRET | set |
ADMIN_API_KEY | set |
ENCRYPTION_KEY | set |
ED25519_PRIVATE_KEY | set |
ED25519_PUBLIC_KEY | set |
CLOUDFLARE_ANALYTICS_TOKEN | set |
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.devlicensing-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.devlicensing-api-production — wrangler.toml [env.production.vars]
https://admin.lucidfabrics.com
https://lucidpal.app
https://lucidfabrics.com
https://dev.lucidpal.pages.devGCP OAuth — Google Sign-In
| Field | Value |
|---|---|
| Client ID | 1074612045085-q3mqvpt342ud5juiuu5q2bm69ton8u9n.apps.googleusercontent.com |
| Authorized JS origins | https://lucidpal.app, http://localhost:4600, https://dev.lucidpal.pages.dev |
| Used in | Angular environment.*.ts (all three) + GOOGLE_CLIENT_ID secret on both lucidpal-api workers |
Admin Portal — Licensing Service
| Environment | URL |
|---|---|
| Dev preview | https://dev.licensing-admin-d5k.pages.dev |
| Production | https://licensing-admin-d5k.pages.dev / https://admin.lucidfabrics.com |
To republish pricing tiers after a Stripe account change:
- Clear old price IDs in DB:
UPDATE pricing_tiers SET stripe_price_id_monthly=NULL, stripe_price_id_yearly=NULL, is_active=0 WHERE ... - Update
stripe_product_idon the product row to match the new Stripe account - 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:
| Layer | File | When to change |
|---|---|---|
| iOS UI gating | apps/lucidpal-ios/Sources/Models/FeatureGate.swift — requiredTier switch | Moving a feature between tiers |
| API enforcement | apps/lucidpal-api/src/middleware/requirePlan.ts — requirePlan(minPlan) | Protecting an endpoint |
| API service checks | AiService.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_LIMITmap inapps/lucidpal-api/src/services/ai.service.ts - Behaviour: HTTP 402 = no eligible plan; HTTP 429 = monthly cap hit
- iOS client:
PremiumManager.creditsRemainingdecremented optimistically on each message. The server does not return this value in/billing/status— the iOSBillingStatusResponsedecodescreditsRemainingasInt?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_LIMITmap inapps/lucidpal-api/src/services/agent.service.ts - Behaviour: HTTP 429 = daily cap hit
/agent/synthesizehas 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_KEYnot set - No rate limit on this endpoint (unlike
/agent/chat) maxOutputTokens: 512 for synthesize, 1024 for chat
subscriptions Table (lucidpal-db)
| Column | Type | Notes |
|---|---|---|
id | text PK | |
user_id | text FK | → users.id (cascade delete) |
stripe_customer_id | text | unique; set after first Stripe checkout |
stripe_subscription_id | text | unique; set by webhook |
apple_original_transaction_id | text | unique; set by /billing/apple/verify |
payment_source | text | 'stripe' | 'apple' |
plan | text | 'free' | 'starter' | 'pro' | 'ultimate' |
billing_period | text | 'monthly' | 'yearly' | null |
status | text | 'active' | 'inactive' | 'canceled' | 'past_due' |
current_period_end | text | ISO 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| Client | Post-payment polling schedule |
|---|---|
| Web | 3s + 8s after redirecting to #subscription-success |
| iOS Stripe | refreshEntitlement() immediately + after 2s delay |
| iOS IAP | refreshEntitlement() after transaction.finish() |
Latency: 5–20s from payment to D1 update.
Go-Live Checklist
- [ ] Set
STRIPE_SECRET_KEYtosk_live_…onlicensing-api-production - [ ] Register live Stripe webhook → get new
whsec_…→ setSTRIPE_WEBHOOK_SECRETonlicensing-api-production - [ ] Republish all LucidPal pricing tiers via admin portal (creates live Stripe prices)
- [ ] Verify
LICENSING_PRODUCT_IDis 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)