Billing
Flag-gated Creem checkout, entitlement gating, and license activation.
Billing is Flag-gated and defaults off. When you leave
VITE_BILLING_ENABLED=false, the Billing route, checkout actions, subscription
reads, and paywalls stay dark so the template works as a non-commercial desktop
app.
Turn billing on only after Supabase, the billing migration, Creem products, and Edge Function secrets are configured.
Flags
| Flag | Default | Effect |
|---|---|---|
VITE_BILLING_ENABLED | false | Enables Billing page actions, Creem checkout, customer portal, subscription reads, and feature gates. |
VITE_BILLING_LICENSE_MODE | false | Shows the optional license-key card and enables app/services/license/. |
Everything below assumes VITE_BILLING_ENABLED=true.
Price configuration
shared/price-config.ts is the single renderer-side price map. It defines three
tiers:
| Tier | Product shape | Product env |
|---|---|---|
| Free | No Creem product | None |
| Pro | Monthly and yearly subscriptions | VITE_CREEM_PRODUCT_PRO_MONTHLY, VITE_CREEM_PRODUCT_PRO_YEARLY |
| Lifetime | One-time purchase | VITE_CREEM_PRODUCT_LIFETIME |
Product IDs are renderer-public values. They are not secrets; the secret Creem API key lives only in Supabase Edge secrets.
planTierForProductId(productId) maps known Creem product IDs back to pro or
lifetime. Missing or unknown IDs resolve to free, so an unconfigured build
does not accidentally unlock paid features. The shape mirrors soar-next's
price-config model, which helps teams that share product IDs across web and
desktop.
Checkout flow
The desktop purchase path is browser-based:
- A signed-in user opens
/billing. PricingCardresolves the selected plan to a Creem product ID.startCheckout(productId)invokes the JWT-verifiedcreate-checkoutEdge Function.- The function creates a Creem checkout and inserts a
paymentsrow withstatus = 'paying'and a generatedorder_no. - The renderer validates the returned URL is an
https://*.creem.ioURL and opens it in the system browser throughwindow.conveyor.window.webOpenUrl. - Creem calls the public
creem-webhookfunction after payment. - The webhook updates the
paymentsrow and upserts the user'ssubscriptionsrow with the service-role key. - The user returns to
soar-electron://billing?status=success&orderId=..., or simply focuses the app again; the subscription store refreshes either way.
The app never treats the browser return as proof of purchase. Entitlement comes
from the Supabase subscriptions row written by the verified webhook.
BILLING_SUCCESS_URL is optional. When set, create-checkout points Creem at
a hosted bounce page, then appends status=success&orderId=<orderNo>. The
bounce page should open soar-electron://billing?... or your rebranded
scheme.
Entitlement state
app/stores/subscription-store.ts reads the account-bound entitlement model:
| State | Meaning |
|---|---|
disabled | Billing is off; feature gates allow everything and no Supabase read happens. |
unknown | Auth, network, or RLS read failed; gates render their neutral pending state. |
free | No subscription row exists for the signed-in user. |
active | The row is active, trialing, or canceled but still inside its paid period. |
expired | The row exists but is no longer entitled. |
Recurring plans must have period_end in the future. One-time lifetime rows
remain entitled even without a recurring period.
The store refreshes on auth-state changes, on window focus, and when the Billing page mounts. That window-focus refresh matters on desktop because a browser checkout may not hand a deep link back to the app reliably on every OS.
Feature gates
app/lib/feature-gate.ts defines the paid feature matrix:
| Feature key | Unlocking tiers |
|---|---|
export_pdf | Pro, Lifetime |
sync_cloud | Pro, Lifetime |
unlimited_projects | Pro, Lifetime |
priority_support | Pro, Lifetime |
custom_branding | Lifetime |
Use <FeatureGate> from app/components/FeatureGate.tsx:
<FeatureGate
feature="custom_branding"
pending={<MutedNotice />}
fallback={<UpgradePrompt />}
>
<CustomBrandingPanel />
</FeatureGate>unknown renders pending, not the paywall. This avoids flashing a paywall
over owned features when the app is offline or the entitlement read is still
loading.
Customer portal
The Manage action calls openCustomerPortal(), which invokes the JWT-verified
customer-portal Edge Function. The function:
- resolves the signed-in user from the Supabase JWT;
- reads that user's
subscriptions.customer_id; - asks Creem for a customer portal URL;
- returns only an
https://*.creem.ioURL.
The renderer validates the URL again before opening it externally.
License mode
License mode is optional. Enable it with:
VITE_BILLING_LICENSE_MODE=trueLicenseKeyCard calls app/services/license/, which invokes the JWT-verified
activate-license Edge Function. On activation:
- the Edge Function validates the key against Creem's License Keys API;
- service-role Supabase writes upsert the user's
subscriptionsrow asproduct_type = 'one_time',status = 'active', andperiod_end = '2999-12-31T00:00:00.000Z'; - the renderer stores the license key locally in the
license-keysecure slot through ElectronsafeStorage; - feature access still comes from the Supabase subscription row, not from the local key.
On deactivation, the function marks the subscription canceled and the renderer
clears the local secure-storage key. The service clears local state even if the
server-side deactivate request is unreachable.
Use license mode for offline-friendly or manually issued one-time products. For normal subscriptions, keep the checkout/webhook flow as the source of truth.
Verify billing
- Set
VITE_BILLING_ENABLED=trueand realVITE_CREEM_PRODUCT_*IDs. - Deploy the Edge Functions and set Creem secrets.
- Sign in, open
/billing, and start a sandbox checkout. - Confirm the browser opens a
creem.iocheckout URL. - Complete payment and confirm
payments.status = 'success'. - Confirm
subscriptions.status = 'active'for the sameuser_id. - Return through
soar-electron://billingor focus the app and confirm the current plan updates. - If license mode is enabled, activate a test license and confirm the subscription row is written before relying on local secure storage.
