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
| Flag | Default | Effect |
|---|---|---|
VITE_BILLING_ENABLED | false | Master switch for checkout, the entitlement gate, and /billing. |
VITE_BILLING_LICENSE_MODE | false | Shows 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:
| 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 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:
- A signed-in user chooses a paid plan on
/billing. startCheckout(productId)calls the JWT-verifiedcreate-checkoutEdge Function.- The function creates a Creem checkout and inserts a
paymentsledger row withstatus = 'paying'and a generatedorder_no. - The renderer accepts only an
https://*.creem.iocheckout URL, then opens it through the Tauri external opener. The capabilities ACL scopes that opener tohttps://**rather than granting arbitrary native access. - Creem calls the public
creem-webhookfunction. Its service-role write updates the payment and upserts the user'ssubscriptionsread-model row. - 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:
| State | Meaning |
|---|---|
disabled | Billing is off; every feature is allowed and no Supabase read occurs. |
unknown | Auth, network, or RLS lookup failed; render a neutral pending state. |
free | The signed-in user has no subscription row. |
active | The entitlement is active, trialing, or canceled but still within its paid period. One-time purchases do not lapse. |
expired | A row exists but no longer grants access. |
The shipped feature map is a replaceable example:
| Feature key | Unlocking tiers |
|---|---|
export_pdf | Pro, Lifetime |
sync_cloud | Pro, Lifetime |
unlimited_projects | Pro, Lifetime |
priority_support | Pro, Lifetime |
custom_branding | Lifetime |
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=trueLicenseKeyCard 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.iocheckout URL. - Confirm
payments.statusbecomessuccessand a matchingsubscriptionsrow becomesactive. - Return through
soar-tauri://billingor 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.
