Billing

Flag-gated Creem checkout, entitlement gating, and license activation.

Billing is Flag-gated and defaults off. Leaving VITE_BILLING_ENABLED=false keeps checkout actions, entitlement reads, feature gates, and the active Billing experience dark, so the starter can also be a non-commercial desktop app.

Enable it only after configuring Supabase, the billing migration, Creem products, and Edge Function secrets. The Creem Setup page walks through that provider-side work.

Flags

FlagDefaultEffect
VITE_BILLING_ENABLEDfalseMaster switch for checkout, the entitlement gate, and /billing.
VITE_BILLING_LICENSE_MODEfalseShows the optional license-key activation card.

Everything below assumes VITE_BILLING_ENABLED=true.

Price configuration

shared/price-config.ts is the 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 public build configuration, not credentials. The secret Creem API key belongs only in Supabase Edge secrets. planTierForProductId() maps a known product ID to pro or lifetime; a missing or unknown ID resolves to free, so an unconfigured or stale product cannot unlock a paid tier. Its shape matches soar-next's price configuration for teams sharing a catalog between web and desktop.

Checkout and entitlement flow

The purchase itself takes place in the system browser:

  1. A signed-in user chooses a paid plan on /billing.
  2. startCheckout(productId) calls the JWT-verified create-checkout Edge Function.
  3. The function creates a Creem checkout and inserts a payments ledger row with status = 'paying' and a generated order_no.
  4. The renderer accepts only an https://*.creem.io checkout URL, then opens it through the Tauri external opener. The capabilities ACL scopes that opener to https://** rather than granting arbitrary native access.
  5. Creem calls the public creem-webhook function. Its service-role write updates the payment and upserts the user's subscriptions read-model row.
  6. The browser can return through soar-tauri://billing?status=success&orderId=…, normally by way of an optional hosted bounce page. The Billing route refreshes when opened, and the store also refreshes when the app window regains focus.

The return URL is a convenience, not purchase proof. The app grants access only from the RLS-protected subscriptions row written by the verified webhook.

BILLING_SUCCESS_URL is optional. When set as an Edge secret, create-checkout appends status=success&orderId=<orderNo> and sends Creem to that hosted page. The included sample page forwards the user to soar-tauri://billing; update its scheme after rebranding.

Entitlement and feature gates

src/stores/subscription-store.ts derives the account-bound state consumed by src/lib/feature-gate.ts:

StateMeaning
disabledBilling is off; every feature is allowed and no Supabase read occurs.
unknownAuth, network, or RLS lookup failed; render a neutral pending state.
freeThe signed-in user has no subscription row.
activeThe entitlement is active, trialing, or canceled but still within its paid period. One-time purchases do not lapse.
expiredA row exists but no longer grants access.

The shipped feature map is a replaceable example:

Feature keyUnlocking tiers
export_pdfPro, Lifetime
sync_cloudPro, Lifetime
unlimited_projectsPro, Lifetime
priority_supportPro, Lifetime
custom_brandingLifetime

Wrap premium UI with <FeatureGate>. It accepts feature, children, plus optional fallback and pending content:

<FeatureGate
  feature="custom_branding"
  pending={<MutedNotice />}
  fallback={<UpgradePrompt />}
>
  <CustomBrandingPanel />
</FeatureGate>

An unknown entitlement renders pending, never the paywall. That avoids flashing an upgrade prompt over a paid user's feature during a temporary offline or RLS failure.

Customer portal

The Manage action calls the JWT-verified customer-portal function. It derives the user from the Supabase token, reads only that user's customer_id from the RLS-scoped subscription row, requests a Creem portal session, and returns a validated https://*.creem.io URL. The renderer validates that URL again before opening it in the system browser.

Optional license mode

License mode is an alternative activation path for products sold with Creem license keys, such as manually issued or one-time licenses:

VITE_BILLING_LICENSE_MODE=true

LicenseKeyCard sends the key to the JWT-verified activate-license function. The function validates it against Creem and upserts the same user’s subscriptions row as an active one_time entitlement with a far-future period end. The app then stores the key only in the OS keychain through the allow-listed license-key secure slot (SecureKey::LicenseKey in Rust).

The local key is not an entitlement grant: the Supabase subscription row remains the gate’s source of truth. Deactivation marks that row canceled and clears the local key even if the best-effort server release cannot be reached. In the current template, canceled subscriptions stay entitled until period_end; a license activation sets that end to 2999, so deactivation is not immediate revocation. If you need immediate revocation, change the function to end the period (or change the entitlement policy) and test that behavior. Because checkout and license activation share one subscription row per user, treat license mode as an intentional alternate purchase path rather than enabling it without designing how it fits your catalog.

Verify billing

  • Enable billing and set real test-environment VITE_CREEM_PRODUCT_* IDs.
  • Deploy the Edge Functions and configure the Creem secrets.
  • Sign in, open /billing, and start a sandbox checkout.
  • Confirm the system browser opens a creem.io checkout URL.
  • Confirm payments.status becomes success and a matching subscriptions row becomes active.
  • Return through soar-tauri://billing or focus the app; confirm the current plan refreshes.
  • If using license mode, activate a test key and confirm its subscription row before relying on its local keychain copy.

On this page