Billing Pipeline

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:

  1. Shopify-nativecreateShopifySubscription sets replacementBehavior: STANDARD, so approving a new charge cancels the current one (immediate for monthly plans).
  2. App-side cancel-others on every activation triggercancelOtherActiveSubscriptions(keepId) runs in the callback, the app_subscriptions/update webhook (the most reliable trigger — fires on the status change regardless of redirects), and reconcile.
  3. Reconcile sweep — picks the authoritative active sub (the one matching subscription_id, else the newest by currentPeriodEnd) and cancels stale extras.

Subscribe flow

POST /api/shopify/billing/subscribe:

  1. Resolve store + access token.
  2. Duplicate guardgetActiveShopifySubscriptions; pick the active sub via selectActiveSubscription (never the arbitrary first). If it’s already the requested plan → reconcile the DB and return { alreadyActive }.
  3. 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 an overage_charged event.
  4. Cancel any existing pending charge (only one pending can ever exist).
  5. Compute remaining trial days (trial_ends_at - now) → trialDays.
  6. createShopifySubscription (appSubscriptionCreate, replacementBehavior: STANDARD) → confirmationUrl.
  7. Persist subscription_id, subscription_status='pending', plan_id; record subscription_created.
  8. Client redirects the top window to confirmationUrl.

Callback flow

GET /api/shopify/billing/callback (Shopify navigates here after approval):

  1. getShopifySubscription.
  2. ACTIVE/PENDING → set status, reset usage counters + period dates (new period), and clear custom-plan fields when the plan is standard (isStandardPlan); record activated.
  3. On ACTIVEcancelOtherActiveSubscriptions (layer 2).
  4. DECLINED/EXPIRED → drop to trial + CLEARED_CUSTOM_PLAN_FIELDS; record declined.
  5. Redirect back INTO Shopify admin — decode the host param → 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

  • GatecheckBillingGate(shopDomain) blocks on cancelled/expired/frozen and on trial limit/expiry; otherwise calls the atomic RPC increment_try_ons(store, planLimit): try_ons_used += 1, and overage_pending += 1 when 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) settlePendingOverage charges overage_pending via createShopifyUsageRecord against the usage line item (capped by the plan’s cappedAmount); (b) if currentPeriodEnd advanced past the stored current_period_ends_at, it resets try_ons_used/overage_pending, sets the new period bounds, and records a period_rolled_over event 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).