Billing Pipeline
Billing runs through Shopify’s Billing API. Plans live in lib/billing/plans.ts
(PLANS: starter/growth/scale/pro; TRIAL_DAYS=14, TRIAL_LIMIT=100,
QUALIFIED_TRIAL_LIMIT=200). Helpers: getPlanLimit(), getOverageRate(),
isStandardPlan(), CLEARED_CUSTOM_PLAN_FIELDS.
Source of truth
Shopify is authoritative; our stores row is a synced cache. One helper,
reconcileStoreBilling(store) (lib/server/billing-reconcile.ts), is the single place that
maps Shopify’s live subscription state onto our DB. It runs on every load of /billing
and the merchant dashboard (/api/shopify/metrics), so both surfaces always agree — even
after a missed callback/webhook. It never throws (returns last-known state on error) and
returns { planId, subscriptionStatus, customPlan, customPrice, customTryOnLimit, customOverageRate }. Selection logic is the pure module lib/billing/subscription-select.ts
(selectActiveSubscription, resolvePlanIdFromName, staleActiveSubscriptionIds).
Single-active guarantee (App Store 1.2.2)
A store can never have more than one ACTIVE subscription, enforced in three layers:
- Shopify-native —
createShopifySubscriptionsetsreplacementBehavior: STANDARD, so approving a new charge cancels the current one (immediate for monthly plans). - App-side cancel-others on every activation trigger —
cancelOtherActiveSubscriptions(keepId)runs in the callback, theapp_subscriptions/updatewebhook (the most reliable trigger — fires on the status change regardless of redirects), and reconcile. - Reconcile sweep — picks the authoritative active sub (the one matching
subscription_id, else the newest bycurrentPeriodEnd) and cancels stale extras.
Subscribe flow
POST /api/shopify/billing/subscribe:
- Resolve store + access token.
- Duplicate guard —
getActiveShopifySubscriptions; pick the active sub viaselectActiveSubscription(never the arbitrary first). If it’s already the requested plan → reconcile the DB and return{ alreadyActive }. - Settle overage before replacement — if the current sub is active with
overage_pending > 0,settlePendingOverage()charges it on the still-active usage line item (once Shopify cancels the old sub you can no longer bill it). Records anoverage_chargedevent. - Cancel any existing pending charge (only one pending can ever exist).
- Compute remaining trial days (
trial_ends_at - now) →trialDays. createShopifySubscription(appSubscriptionCreate,replacementBehavior: STANDARD) →confirmationUrl.- Persist
subscription_id,subscription_status='pending',plan_id; recordsubscription_created. - Client redirects the top window to
confirmationUrl.
Callback flow
GET /api/shopify/billing/callback (Shopify navigates here after approval):
getShopifySubscription.ACTIVE/PENDING→ set status, reset usage counters + period dates (new period), and clear custom-plan fields when the plan is standard (isStandardPlan); recordactivated.- On
ACTIVE→cancelOtherActiveSubscriptions(layer 2). DECLINED/EXPIRED→ drop totrial+CLEARED_CUSTOM_PLAN_FIELDS; recorddeclined.- Redirect back INTO Shopify admin — decode the
hostparam →https://{admin-host}/apps/{client_id}/merchant.
Webhook
POST /api/webhooks/app/subscriptions/update:
- Maps Shopify status → internal; looks up the store by GID (
getStoreBySubscriptionId). - On transition to active → reset usage + period start; clear custom on a standard plan.
- On active →
cancelOtherActiveSubscriptions(layer 2 — fires even if the redirect callback never ran, e.g. a reviewer on a slow connection). - On cancelled/expired → drop to
trial+ clear custom. - Records the lifecycle event (
activated/cancelled/expired/frozen).
Custom plans
Created by the admin tool (/api/admin/billing, create_custom_plan): a Shopify
subscription whose name does not match any “Tryvio <tier>”, with plan_id='custom'
and custom_plan + custom_price/custom_try_on_limit/custom_overage_rate set. Reconcile
resolves an active custom sub back to custom (the name matches no tier, via
resolvePlanIdFromName) and keeps the custom fields. Moving onto a standard tier clears them
(CLEARED_CUSTOM_PLAN_FIELDS) so getPlanLimit/getOverageRate never return a stale custom
value — this was the “0 / 51,000,000 try-ons” bug.
Usage accounting
- Gate —
checkBillingGate(shopDomain)blocks on cancelled/expired/frozen and on trial limit/expiry; otherwise calls the atomic RPCincrement_try_ons(store, planLimit):try_ons_used += 1, andoverage_pending += 1when already at/over the limit. Trial never accrues overage (it blocks at the limit instead). Only successful generations count. - Monthly rollover — Shopify sends no webhook on a 30-day renewal, so the daily cron
/api/cron/billing-overage(02:00 UTC) sweeps every active store (getActiveSubscribedStores). For each: (a)settlePendingOveragechargesoverage_pendingviacreateShopifyUsageRecordagainst the usage line item (capped by the plan’scappedAmount); (b) ifcurrentPeriodEndadvanced past the storedcurrent_period_ends_at, it resetstry_ons_used/overage_pending, sets the new period bounds, and records aperiod_rolled_overevent with the closing period’s usage snapshot.
Proration (Shopify’s job)
We never compute or issue credits for plan changes — Shopify prorates automatically (upgrades are charged the prorated difference; downgrades issue an application credit usable only toward future app purchases). Issuing our own credits would risk double-crediting. Our only money concern is usage overage, settled before any plan replacement (see subscribe step 3).
Audit ledger
Every billing-relevant transition is appended to billing_events via
recordBillingEvent() (best-effort — never breaks a flow). Sources:
merchant/callback/webhook/reconcile/cron/admin. Captures plan changes (from→to),
per-period usage snapshots, overage charges, status transitions, and failures
(success=false + error_message). Read via getBillingEventsForStore(storeId); surfaced
in the admin dashboard’s expanded merchant row (“Billing history”) and
GET /api/admin/billing-history?storeId=.
Tests
Vitest (npx vitest run): subscription-select, billing-reconcile (decision tree),
plans (limit/overage incl. the 51M regression), billing-overage, and route tests for
subscribe / callback / webhook / cron (single-active, custom-clear, overage settle, rollover).