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

FlagDefaultEffect
VITE_BILLING_ENABLEDfalseEnables Billing page actions, Creem checkout, customer portal, subscription reads, and feature gates.
VITE_BILLING_LICENSE_MODEfalseShows 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:

TierProduct shapeProduct env
FreeNo Creem productNone
ProMonthly and yearly subscriptionsVITE_CREEM_PRODUCT_PRO_MONTHLY, VITE_CREEM_PRODUCT_PRO_YEARLY
LifetimeOne-time purchaseVITE_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:

  1. A signed-in user opens /billing.
  2. PricingCard resolves the selected plan to a Creem product ID.
  3. startCheckout(productId) invokes the JWT-verified create-checkout Edge Function.
  4. The function creates a Creem checkout and inserts a payments row with status = 'paying' and a generated order_no.
  5. The renderer validates the returned URL is an https://*.creem.io URL and opens it in the system browser through window.conveyor.window.webOpenUrl.
  6. Creem calls the public creem-webhook function after payment.
  7. The webhook updates the payments row and upserts the user's subscriptions row with the service-role key.
  8. 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:

StateMeaning
disabledBilling is off; feature gates allow everything and no Supabase read happens.
unknownAuth, network, or RLS read failed; gates render their neutral pending state.
freeNo subscription row exists for the signed-in user.
activeThe row is active, trialing, or canceled but still inside its paid period.
expiredThe 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 keyUnlocking tiers
export_pdfPro, Lifetime
sync_cloudPro, Lifetime
unlimited_projectsPro, Lifetime
priority_supportPro, Lifetime
custom_brandingLifetime

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.io URL.

The renderer validates the URL again before opening it externally.

License mode

License mode is optional. Enable it with:

VITE_BILLING_LICENSE_MODE=true

LicenseKeyCard 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 subscriptions row as product_type = 'one_time', status = 'active', and period_end = '2999-12-31T00:00:00.000Z';
  • the renderer stores the license key locally in the license-key secure slot through Electron safeStorage;
  • 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=true and real VITE_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.io checkout URL.
  • Complete payment and confirm payments.status = 'success'.
  • Confirm subscriptions.status = 'active' for the same user_id.
  • Return through soar-electron://billing or 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.

On this page