Billing system guidelines for the Polar-backed plan catalog, customer and subscription bootstrap, checkout vs subscription-change behavior, usage and credit sync, billing UI, and future billing-lock work. Use when modifying billing products or product copy, Polar configuration assumptions, billing backend flows, billing account-management UI, usage snapshot handling, or billing-related product rules.
Polar is intended to be the billing source of truth for products, customers, subscriptions, and usage-derived billing state.
The app mirrors enough billing state locally to drive UI and app behavior. In practice, the repo stores a synced product catalog, synced customers and subscriptions through the vendored Polar component, and a local usage snapshot derived from Polar customer-state webhook payloads.
FreeFree subscription after account resolution.€10 of usage every billing month.Pay As You Go€10 of usage every billing month.Pro€40 / month.€60 of included usage every billing month.Free < Pay As You Go < Pro.The shared catalog lives in billing.ts.
billing_PRODUCTS stores the exact product names, display names, recurringCreditsCents per plan, legacy benefit description keys, and meter metadata the app recognizes.recurringCreditsCents is the canonical per-month credit amount for each plan. The benefits map is kept for stable description lookup but no longer drives credit amounts or UI copy.billing_get_recurring_credits_cents returns the per-plan recurring credit amount for the monthly credits engine and the billing UI.billing_get_product_order, billing_compare_product_order, and billing_get_plan_change_kind derive catalog ordering plus upgrade vs downgrade behavior from the canonical plan order.See Glossary — shared/billing.ts for precise signatures and behavior.
The backend billing module lives in billing.ts.
billing wraps the vendored Polar component and currently allows only signed-in users through getUserInfo.list_products, get_current_user_subscription, and get_usage_snapshot provide the billing panel data.generate_checkout_link creates Polar checkout sessions.change_current_subscription handles paid-plan changes. Free -> paid is intentionally not handled there and goes through checkout instead.generate_customer_portal_url opens the Polar customer portal.bootstrap_free_subscription creates the local Polar customer and the Free subscription when missing.billing_enqueue_free_subscription_bootstrap enqueues that bootstrap work through billing_workpool_bootstrap.handle_polar_customer_state_update ingests the customer.state_changed webhook payload into billing_usage_snapshots and then triggers the monthly credits engine for that user.The monthly credits engine lives at the // #region monthly credits block in billing.ts and is the only app code path that grants recurring credits for every plan (Free, Pay As You Go, Pro). Polar meter_credit benefits are detached from the live products in the Polar dashboard so they never grant credits in parallel.
The Polar customer.state_changed webhook is the sole trigger for monthly grants; there is no cron-driven reconciliation pass. Trust the webhook to deliver every state change.
handle_polar_customer_state_update upserts billing_usage_snapshots and, when the webhook payload includes an active subscription, enqueues grant_monthly_credits on billing_workpool_usage_event with userId, subscriptionId, productId, and periodStart taken directly from the Polar payload.grant_monthly_credits (internalAction) resolves the Polar product by productId, reads billing_get_recurring_credits_cents(product.name), and when recurringAmountCents > 0 ingests one negative-amount usage event (billing_EVENTS.pressUsage / press_usage_event) through Polar eventsIngest with externalId monthly-grant:<userId>:<subscriptionId>:<periodStart>. Polar's immutable usage event plus duplicate detection is the authority for whether that period was already granted.customer.state_changed deliveries for the same (user, subscription, period) may enqueue the same action multiple times. Treat this as intentional: Polar reports the later ingests as duplicates, so the billing ledger stays idempotent without any local snapshot cursor.Use this section as the authoritative glossary for symbols named elsewhere in this skill.
billing_PRODUCTSconst object keyed by plan id (Free, Pro, "Pay As You Go").name, UI displayName, recurringCreditsCents, optional meter block, and legacy benefits descriptions for tests and old webhooks.billing_get_product_order(productName: string) => numberFree < Pay As You Go < Pro, or Infinity for unknown names so they sort after known plans.billing_compare_product_order(leftProductName: string, rightProductName: string) => numberbilling_get_product_order, with a localeCompare fallback when both orders are non-finite.billing_get_recurring_credits_cents(productName: string) => numberbilling_PRODUCTS[productName].recurringCreditsCents, or 0 if the name is not in the catalog. Used by the monthly grant action and billing UI for “included usage” amounts.billing_get_plan_change_kind(currentProductName: string, targetProductName: string) => "upgrade" | "downgrade" | nullnull if either product is unknown, orders are equal, or the change is not strictly up/down; otherwise returns upgrade vs downgrade from catalog order.billing_get_product_display_name(productName: string) => stringdisplayName from billing_PRODUCTS when present, otherwise the raw productName. (Useful for UI; not always cited above but part of the same module.)billingPolar<DataModel> instance (export const billing).@convex-dev/polar integration: getUserInfo restricts billing to signed-in Convex users; exposes billing.api(), billing.listProducts, webhook registration, and Polar server mode from POLAR_SERVER.list_productsquery{} → array of synced Polar products (empty when user is not signed in).billing.listProducts after auth check.get_current_user_subscriptionquery{} → current subscription document from the Polar component without the nested product field, or null.list_products already loaded catalog.get_usage_snapshotquery{} → billing_usage_snapshots doc for the signed-in user or null.generate_checkout_linkactionallowed_origins. Used for Free -> paid and new paid signups.change_current_subscriptionactionFree -> paid.generate_customer_portal_urlactionbootstrap_free_subscriptioninternalAction{ userId, email }Free subscription in Polar and local mirror when the user has no subscription. Invoked via workpool.billing_enqueue_free_subscription_bootstrapexport async function)(ctx: ActionCtx, { userId, email })internal.billing.bootstrap_free_subscription on billing_workpool_bootstrap (single-flight style, retries on failure).handle_polar_customer_state_updateinternalMutation{ payload } (Polar customer.state_changed webhook shape)userId, updates billing_usage_snapshots (subscription + meter snapshot), then enqueues grant_monthly_credits directly from the webhook payload when an active subscription is present. Sole trigger for monthly grants.grant_monthly_creditsinternalAction{ userId, subscriptionId, productId, periodStart }externalId plus Polar duplicate detection is the authority for whether that period was already granted.The auth-side trigger lives in users.ts.
/api/auth/resolve-user resolves or creates the Convex user, writes Clerk external_id, then enqueues the Free subscription bootstrap.Free subscription appears locally.When you test signup flows that must also create a Polar customer, do not use Clerk's example.com sample addresses. Use a real email domain that accepts mail, such as gmail.com, while still keeping Clerk's test suffix.
Clerk docs: "Any email with the
+clerk_testsubaddress is a test email address. No emails will be sent ... code424242."
[email protected] or [email protected].424242.+clerk_test suffix so Clerk stays in test-email mode.example.com can be rejected by Polar validation.The main billing UI lives in billing-account-management-panel.tsx.
Manage subscription entrypoint.Free -> paid uses checkout through billing-checkout-button.tsx, passing the current Free subscription id.paid -> Free and paid -> paid use billing-change-plan-button.tsx, which calls change_current_subscription.billing_get_recurring_credits_cents (the Convex monthly credits engine is the only code path that grants recurring credits, while Polar event idempotency is the authority for already-granted periods).billing_get_recurring_credits_cents for the included-usage line.customer.state_changed to show current due amount, remaining credits, renewal timing, and pending downgrade timing.billing_cancel_polar_subscription_jobs as the scheduler row for that work. Keep one row per user, replace the stored jobId when you reschedule, and clear the row only when the matching work finishes successfully or an explicit cancel removes it.billing_usage_snapshots are mirrored local billing state, not billing authority. Keep them through phase 1 and delete them only during phase 2 of account deletion.purgeUserRecord as the single operator flag:purgeUserRecord: false keeps the immediate local hard-delete path, keeps the final tombstoned user row, and schedules the same retryable period-end cancellation used by the normal delete flow.purgeUserRecord: true cancels any scheduled period-end cancellation first, revokes the subscription immediately, deletes the Polar customer immediately, and purges the final local tombstone.Free, Pay As You Go, and Pro.Free Included Usage, Free Usage, and Pro Included Usage. These names remain stable in the catalog because tests and historical webhook payloads still reference them.Press app usage as the canonical usage meter name in the catalog.press_usage_event as the canonical event name for usage ingestion.meter_credit benefits detached from every Polar product. The Convex monthly credits engine is the only code path that grants recurring credits; running both would double-grant.billing_PRODUCTS.<plan>.recurringCreditsCents and are applied by the monthly credits engine.Free plan lock once the included usage is exhausted.Free-plan limits.