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. It now returns{ cancelled, failures }so a per-subscription cancel error is no longer silently swallowed — it surfaces in the caller’s logs. - Reconcile sweep — picks the authoritative active sub (the one matching
subscription_id, else the newest bycurrentPeriodEnd) 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:
- 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 a miss, falls back togetStoreByDomain(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 RPCincrement_try_ons_v2(store, planLimit, sessionId):try_ons_used += 1, andoverage_pending += 1when already at/over the limit — identical semantics to v1 (increment_try_ons, kept for backward compat) plus an atomictryon_billing_ledgerINSERT 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
succeededbut returned no output image (no_output), or a hung generation that timed out. A pureclassifyProviderOutcome(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).finalizeFailedStorefrontTryOnthen refunds:- Merchant quota via
decrement_try_ons_v2(store, planLimit, sessionId, refundReason)— the exact inverse of increment (try_ons_used -= 1floored at 0;overage_pending -= 1floored at 0 only when the currenttry_ons_used > plan_limit, i.e. this try-on was an overage one), plus flipping the session’s latestbillableledger row torefunded(withrefund_reason) in the same transaction — v1 (decrement_try_ons) stays for backward compat. Mirrored + unit-tested bycomputeTryOnRefund.checkBillingGate/the refund path now attribute the non-delivery to a session id + one ofprovider_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 latedeliveredresult never resurrects a session already finalized as failed. - Traceability — recorded on the
tryon_generation_failedanalytics event (reason,quotaRefunded,tryOnsUsedAfter,overagePendingAfter,rateLimitRefunded).
- Merchant quota via
- Timeout safety net — the daily cron
/api/cron/tryon-timeout(02:15 UTC; Vercel Hobby allows daily crons only) finalizes storefront sessions stuck ingeneratingpast a 15-min stale threshold as failed(timeout) + refund. Only storefront sessions (those carrying ashopDomainmetadata 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)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.
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 path —
increment_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_eventsandtryon_billing_ledger, filtered by the dashboard date range (occurred_at).Button tapsis rawtryon_widget_clickevents.Try-ons= generated looks, the same unit billing counts (a session with 5 looks = 5 try-ons): the billable count fromtryon_billing_ledgerfor the range when ledger coverage exists, elseCOUNT(DISTINCT provider_job_id)overtryon_generation_succeededevents. 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
storescache (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}—selectedincludesselectionReason=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}andshopify.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).