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. It now returns { cancelled, failures } so a per-subscription cancel error is no longer silently swallowed — it surfaces in the caller’s logs.
  3. Reconcile sweep — picks the authoritative active sub (the one matching subscription_id, else the newest by currentPeriodEnd) and cancels stale extras.

Webhook domain fallback (RC#2, 2026-06-15). The webhook used to look up the store ONLY by getStoreBySubscriptionId(subscriptionGid). During a plan change, stores.subscription_id has already moved to the NEW subscription by the time Shopify’s webhook for the OLD subscription arrives, so the lookup missed (webhook.store_not_found) and layer 2 never ran for that event — leaving the store with two active subscriptions and a flip-flopping displayed plan. The webhook now falls back to getStoreByDomain(shop) (from the x-shopify-shop-domain header) on a subscription-id miss, and runs the authoritative reconcileStoreBilling instead of a manual cancel — logged as webhook.reconcile_by_domain. See Webhooks for the full route behavior.

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 a miss, falls back to getStoreByDomain(shop) and reconciles instead (see the callout above and Webhooks).
  • 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.

Before creating a custom subscription, the admin action inspects Shopify’s active subscriptions. If an unknown/custom subscription already exists and its recurring price and usage cap exactly match the submitted terms, the action restores subscription_id, status, period end, and the custom fields in stores without creating a new charge. A standard active plan or a pricing mismatch returns 409; only an installation with no active subscription can create a new pending charge.

Every successful custom create/recovery also writes the versioned private app-data metafield tryvio.billing_contract_v1 on currentAppInstallation. It stores the Tryvio-owned included try-on limit and overage rate together with the Shopify subscription id/name, recurring price, usage cap, currency, status, and timestamps. Billing snapshot and reconcile accept it only when the id/name/currency/price/cap match Shopify’s live active subscription and line items. This lets the UI render immediately and repairs a restored stores row lazily without creating a charge. Legacy custom subscriptions require one manual seed because Shopify never received their Tryvio-only limit/rate. Standard plans need no contract because their names map to fixed tiers.

For usage-based custom plans, custom_try_on_limit = 0 means zero included try-ons, not a hard limit of zero. The merchant dashboard should render this as billable usage (for example, 32 billable try-ons used this period) instead of 32 / 0.

If a restored custom subscription has current_period_starts_at but no current_period_ends_at, analytics ROI uses a 30-day fallback billing period from the start date. This keeps ROI cost available after DB recovery while billing reconcile/cron continue to converge the stored period.

Usage accounting

A try-on counts only if it delivers an image. Quota is consumed up-front at the gate (atomic, race-safe cap), then refunded on any non-delivery. We deliberately keep the up-front increment rather than counting on success — the atomic gate is what stops concurrent requests from overshooting the plan/trial cap (Architecture A).

  • Gate (consume up-front)checkBillingGate(shopDomain, sessionId) blocks on cancelled/expired/frozen and on trial limit/expiry; otherwise calls the atomic RPC increment_try_ons_v2(store, planLimit, sessionId): try_ons_used += 1, and overage_pending += 1 when already at/over the limit — identical semantics to v1 (increment_try_ons, kept for backward compat) plus an atomic tryon_billing_ledger INSERT in the same transaction (see Billing ledger below). Trial never accrues overage (it blocks at the limit instead). The shopper’s daily rate-limit is incremented here too.
  • Refund on non-delivery — every terminal outcome that did NOT deliver an image refunds both counters. The non-delivery outcomes are: provider failed, provider reported succeeded but returned no output image (no_output), or a hung generation that timed out. A pure classifyProviderOutcome(snapshot)delivered | failed | no_output | pending (lib/billing/tryon-refund.ts) is the single source of truth and drives both completion paths (provider callback + client poll). finalizeFailedStorefrontTryOn then refunds:
    • Merchant quota via decrement_try_ons_v2(store, planLimit, sessionId, refundReason) — the exact inverse of increment (try_ons_used -= 1 floored at 0; overage_pending -= 1 floored at 0 only when the current try_ons_used > plan_limit, i.e. this try-on was an overage one), plus flipping the session’s latest billable ledger row to refunded (with refund_reason) in the same transaction — v1 (decrement_try_ons) stays for backward compat. Mirrored + unit-tested by computeTryOnRefund. checkBillingGate/the refund path now attribute the non-delivery to a session id + one of provider_failed | no_output | timeout.
    • Shopper rate-limit via decrementRateLimitUsage.
    • Exactly once — callback, poll, and the timeout cron can all reach the same failure, so the refund is gated by an atomic claim claimTryOnSessionFailed (UPDATE tryon_sessions SET status='failed' WHERE status NOT IN (failed,completed,cancelled) RETURNING id). Only the claim winner refunds; everyone else returns { refunded: false }. A late delivered result never resurrects a session already finalized as failed.
    • Traceability — recorded on the tryon_generation_failed analytics event (reason, quotaRefunded, tryOnsUsedAfter, overagePendingAfter, rateLimitRefunded).
  • Timeout safety net — the daily cron /api/cron/tryon-timeout (02:15 UTC; Vercel Hobby allows daily crons only) finalizes storefront sessions stuck in generating past a 15-min stale threshold as failed(timeout) + refund. Only storefront sessions (those carrying a shopDomain metadata marker, which went through the gate) are swept; demo sessions are skipped. The threshold is well beyond the ~90s generation + 3-min client-poll window so it can never refund a still-running generation. Callback/poll handle the common cases promptly; this catches the rare provider hang within ~24h.
  • 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.

Billing ledger (2026-07-10)

tryon_billing_ledger (migration 20260710_tryon_billing_ledger.sql) is one immutable row per billable try-on, written atomically with the gate counter increment. It exists because none of the other candidates are a reliable, range-sliceable billing source of truth: stores.try_ons_used is a mutable running counter that can’t be sliced by date; tryon_provider_jobs rows cascade-delete with their session during retention cleanup; analytics_events has historically over-counted (see the finalize race below).

Columns: store_id, session_id (nullable — deliberately no FK to tryon_sessions, so ledger rows survive session retention-cleanup), status ('billable' | 'refunded'), was_overage (whether this try-on accrued overage_pending at increment time), plan_limit_at_increment, source ('gate' | 'backfill'), created_at, refunded_at, refund_reason.

  • Write pathincrement_try_ons_v2/decrement_try_ons_v2 (above) are the only writers.
  • One-time backfill — seeded from tryon_provider_jobs: 3,806 billable + 8 refunded rows, source='backfill', all belonging to the single custom-0 store at the time. Backfill accuracy vs. the real Shopify charge for that period: $379.70 vs. $380.30 (0.16% off). Rows written at the gate going forward (source='gate') are exact.
  • Cost usage — see Analytics & ROI for how the merchant “Tryvio cost” metric reads this table.

Success finalize race (fixed 2026-07-10)

Both completion paths for a successful try-on — the provider callback and the widget’s status poll — used to pass a non-atomic read-check and could each insert a tryon_generation_succeeded analytics event for the same session (~45% of generations double-fired the event). This never affected billing (quota is only ever consumed once, at the gate), but it inflated analytics_events success counts.

Fixed in finalizeSuccessfulStorefrontTryOn (lib/server/storefront-tryon.ts) with an atomic single-winner claim, claimTryOnProviderJobCompleted (UPDATE tryon_provider_jobs ... WHERE status IN ('pending','running') RETURNING), mirroring the failure path’s claimTryOnSessionFailed. Only the claim winner writes the tryon_output row and the tryon_generation_succeeded event.

⚠️

analytics_events rows from before 2026-07-10 may still contain these duplicates. Use tryon_billing_ledger for billable try-on counts, not analytics_events.

POST /api/events now rejects (400) client-submitted event types tryon_generation_succeeded and tryon_generation_failed — the widget never sent them, and they’re server-only now. Product-affinity derivation still triggers off the client-sent tryon_result_viewed event.

Merchant dashboard vs billing usage

The merchant dashboard intentionally mixes two different surfaces:

  • Product analytics KPIs come from analytics_events and tryon_billing_ledger, filtered by the dashboard date range (occurred_at). Button taps is raw tryon_widget_click events. Try-ons = generated looks, the same unit billing counts (a session with 5 looks = 5 try-ons): the billable count from tryon_billing_ledger for the range when ledger coverage exists, else COUNT(DISTINCT provider_job_id) over tryon_generation_succeeded events. This replaced the old session-based “Try-ons” card and the “Completed try-ons” card, which is gone — session counting (distinct sessions with ≥1 succeeded generation) now lives only in the funnel and conversion metrics, not on any Overview card. See Analytics & ROI for the full glossary.
  • Billing usage comes from the stores cache (try_ons_used, overage_pending) for the current Shopify billing period. It is updated by the billing gate/refund path, not by the dashboard analytics aggregation.
  • ROI cost comes from the active plan/custom billing cache plus usage charges for the selected analytics range. Fixed subscription cost is prorated to the selected range; usage charges are added where available.

These numbers can differ legitimately:

  • Dashboard range can be last 1/7/30/90 days, while billing usage is the current Shopify period.
  • Button taps can exceed try-ons because shoppers may open the widget and never upload/generate.
  • Billing can show more/less than the selected dashboard range after a period rollover or custom date filter.

The invariant is narrower: for the same current billing period, delivered storefront try-ons should match stores.try_ons_used after refunds settle. If analytics_events contains duplicate pre-2026-07-10 success events, the dashboard’s analytics_events fallback deduplicates by provider_job_id; billing is still protected because the quota increment happens once at /api/storefront/try-on and non-delivery refunds are idempotent.

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).

Shopify access token refresh

Every billing read/write path (reconcile, subscribe, callback, cron) calls Shopify through shopifyGraphql, which depends on a valid offline access token. See Auth & Sessions for the full token-refresh design (single-flight refresh, proactive renewal, cross-instance rotation tolerance, 401 retry-once). The billing-relevant consequence: POST /api/shopify/billing/subscribe returns HTTP 401 { code: "needs_reconnect" } when the token is unrecoverable, and the billing page shows “Your Shopify session expired. Please reopen the app from Shopify Admin to reconnect.” instead of a generic error. The /reconnect route re-mints the token via token exchange.

Observability (billing trace)

All billing paths emit structured logs through the shared logger, gated by the DB app_config.log_level (debug = full step trace, info = anomalies only). Logs go to console + the app_logs table; correlate by requestId.

  • billing.reconcile.{start,shopify_subs,selected,drift,multiple_active,cancel_others, cancel_others_failed,error,result}selected includes selectionReason = matched_subscription_id | newest_active.
  • billing.subscribe.{request,active_check,created,already_active,failed,needs_reconnect}.
  • billing.callback.*.
  • webhook.{received,reconcile_by_domain,processed,cancelled_others}.
  • shopify.token.{refreshed,recovered_after_race,needs_reconnect} and shopify.graphql.auth_retry (see Auth & Sessions).

Front-to-back trace. lib/client/client-logger.ts (clientLog, newTraceId) sends a fire-and-forget keepalive POST to /api/shopify/client-log, which forces the client.* namespace and logs via the normal logger. Wired into the billing page’s subscribe() (client.billing.subscribe.{click,response,redirect,error}) and the merchant dashboard metrics fetch (client.dashboard.metrics.{request,response,error}). The browser-generated traceId is also sent to the subscribe route and logged server-side as billing.subscribe.request {clientTraceId}, so a single merchant action — click through server processing — reads as one trace in app_logs.

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). Refund path: tryon-refund (inverse math + outcome classifier), finalize-failed-refund (dual refund, exactly-once claim, no_output), cron/tryon-timeout, and callback-route classification cases. decrement_try_ons is also verified directly against the DB via an increment→decrement round-trip (returns to baseline incl. the overage boundary).