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

Trial lifecycle (#307)

stores.subscription_status is a plain text column (no DB constraint). The full value set: trial | trial_expired | active | pending | cancelled | frozen | expired.

The admin never reads these raw: counts, filters, badges and MRR go through the customer segment derivation — see Admin → Customer segments (#724). Notably, active inside the trial window (trial_ends_at in the future) is “Trial — plan chosen”, not Paying.

The only writer of the trial ↔ trial_expired transitions is reconcileStoreBilling (lib/server/billing-reconcile.ts) — and only after its live Shopify checks (above) have confirmed there is no active subscription:

  • a trial store past trial_ends_at → trial_expired.
  • a trial_expired store whose window was extended/cleared → reopens to trial.

The decision is the pure unit lib/billing/trial-lifecycle.ts (resolveTrialLifecycle); the write is an atomic, status-guarded claim, claimTrialStatusTransition (lib/server/supabase-admin.ts — UPDATE ... WHERE subscription_status = <from>), so concurrent reconciles (dashboard load racing the nightly cron) produce exactly one transition and one billing_events audit row (event_type = trial_expired / trial_reopened, metadata carries trialEndsAt).

Guardrails:

  • trial_ends_at IS NULL = an open-ended trial — it never expires.
  • A pending (unapproved) charge beats expiry — the store is left untouched.
  • A dead Shopify access token → no transition (degrade, never guess).
  • Uninstalled stores are never swept.
  • Frozen or missing shop (#646) — Shopify refuses the Admin API for a shop that cannot be billed and says so in the HTTP status: 402 (shop frozen — its app subscriptions are FROZEN, never ACTIVE) or 404 (no such shop). For a store with no subscription_id (we never created a charge) that refusal is the live answer “no active subscription”, so the lifecycle settles as if the list came back empty. The audit row’s metadata says so: { trialEndsAt, basis: "shop_unavailable", shopifyHttpStatus }. Not included: 423 (locked — can still be billed), 401/403, throttles, 5xx, a token we cannot mint, and any store with a subscription_id — those stay undecided. The rule is the pure shopUnavailableProvesNoSubscription (lib/billing/trial-lifecycle.ts); GraphQL failures carry their status as ShopifyHttpError (lib/server/shopify-throttle.ts). Reversible: if the shop comes back with an active subscription, the nightly sweep re-reads every trial_expired store and reconciles it to active.

Daily sweep — GET /api/cron/trial-expiry (apps/web/vercel.json, 02:45 UTC): finds candidates via listTrialExpiryCandidates() — installed stores (status='active') that are either subscription_status='trial' with a past trial_ends_at, or already subscription_status='trial_expired' — and feeds each through reconcileStoreBilling. One store’s failure never stops the sweep. No money moves anywhere in this path.

Undecided stores are visible (#646). When the reconciler cannot make the live check it returns the last-known state with unresolved: { reason: "no_token" | "shopify_error", error?, httpStatus? } instead of passing it off as a decision. The sweep counts those as failed, lists them in the response (failures: [{ shopDomain, reason, httpStatus?, error? }]) and logs each one as cron.trial_expiry.store_failed at error — prod’s app_config.log_level is error, so the old warn lines never reached app_logs and 12 frozen shops sat in trial for up to 56 days with no trace. Find them with select shop_domain, fields from app_logs where event = 'cron.trial_expiry.store_failed' order by created_at desc. A frozen shop that is already trial_expired is settled, not failed, so it is not reported every night.

Gating — resolveStoreGate (lib/billing/store-gate.ts) treats trial_expired identically to an expired-by-date trial: code trial_limit_reached. A future trial_ends_at un-gates immediately even while the status is still trial_expired — an admin-extended trial works instantly, before the next reconcile runs.

Merchant UI stays honest — resolveBillingView (lib/billing/billing-view.ts) derives “expired” from trialEndsAt, never the cached status. trial_expired deliberately does not satisfy its everSubscribed derivation; status === "expired" remains the only subscription_status value meaning “previously subscribed”.

Trial warnings and the goodbye (#311, #610)

Why. The “your trial ends in N days” warning used to be decided on dashboard load (checkMerchantNotifications), by exact day equality, with a dedupe key (trial-3d) that did not carry the trial window. A merchant who never reopened the app — the zombie-trial population — was never warned, and nothing at all was sent when a trial actually ended. It is now a schedule.

Hourly sweep — GET /api/cron/trial-comms (vercel.json, 25 * * * *; hourly because the 1-day window is only 24 h wide). Auth isAuthorizedCronRequest; trial_comms_lock against overlap. ONE read of every installed trial / trial_expired store (listTrialCommsCandidates) + ONE read of their existing trial-% notification keys (listTrialNotificationKeys), then the pure decision per store, then notifyTrialMessage for the due ones, 3 at a time (runBounded). A store that fails is counted and the sweep continues (#329).

The decision — lib/billing/trial-comms.ts (pure, tested per AC):

  • dueTrialMessage(store, now) answers with a range: ending_3d when ≤ 72 h are left, ending_1d when ≤ 24 h, expired for up to 3 days after the end (TRIAL_EXPIRY_GRACE_MS). Entering a window is enough; a store first seen inside the last day gets only ending_1d.
  • expired requires subscription_status = 'trial_expired' — the state cron/trial-expiry persists only after the live Shopify check. A trial row past its end may be a merchant who subscribed.
  • Silent for: not trial/trial_expired (subscribed between the two warnings), status ≠ active (uninstalled), custom_plan, unlimited_tryons, is_test, a null/unparseable trial_ends_at, and any expiry older than the grace — the backlog of long-expired trials is never mailed retroactively (#311 AC5).
  • trialDedupeKey(message, trialEndsAt) = trial-<message>-<YYYY-MM-DD>. The end date is in the key (#512), so an operator-extended trial is warned again; the unique index notifications (store_id, dedupe_key) makes each message land once per window however often the cron runs (real seam: scripts/real-seam/rs-311-trial-comms-seams.mjs).

Delivery — notifyTrialMessage (lib/server/merchant-notifications.ts): the notification row is the dedupe gate; the email goes out only when it was freshly created, through the billing-warning sender (billing-scope unsubscribe, email_events requested/send_failed/suppressed with type = trial_ending | trial_expired). Address = owner_email, else platform_connections.metadata.email (#513); none → bell only. Language = dashboard_locale.

  • Warnings use templates.trialEnding with the real days left and an embedded /plan CTA.
  • The goodbye first reads the store’s lifetime numbers (getMerchantMetrics from created_at), its synced product count and its billing currency (cached stores.billing_currency, else live shopBillingPreferences) concurrently. A failed metrics read writes nothing, so the next run retries. trialOutcome picks the letter: used (its own impressions / try-ons / add-to-carts / attributed orders + the Starter price in its currency; an unknown currency omits the price) or unused (the one blocking step — sync / theme block / unknown — and an offer to do it). templates.trialExpired is composed from the #348 win-back sentences, minus the apology and the extension offer.

Ships dark — the send switch. Nothing is sent until app_config.trial_comms_send reads exactly on (isTrialCommsSendOn; absent, off, any other value or an unreadable config = dry run — fail-closed like order_fee_terms_takeover). While it is off, every hourly run is a dry run that upserts its list into app_config.trial_comms_dry_run (JSON: reason, wouldSendCount, one row per candidate with due, reason, wouldSend, emailOnFile), so the owner can review a real prod list before approving. Flipping it is a prod config change behind the owner’s write grant, bound to the committed statement: scripts/agents/trial-comms-switch.mjs on|off with scripts/agents/trial-comms-switch-on.sql / trial-comms-switch-off.sql (commands in the script header). off stops future sends on the next run; nothing already sent is recalled.

Dry run — ?dryRun=1 returns the same list and writes, locks and sends nothing (not even the report row). OUTBOUND_DISABLED (#563) also forces the dry run (so a staging refresh cannot burn dedupe keys), and CRON_DISABLED blocks the scheduler in middleware like every cron. Every silence carries a reason from trialMessageDecision: not_installed, custom_plan, unlimited_tryons, test_store, not_on_trial, no_trial_end, window_not_open, awaiting_expiry_confirmation, expired_beyond_grace, or already_sent.

Operator dry run — scripts/agents/trial-comms-dry-run.mjs --target=staging|dev|prod [--at=ISO]. Imports planTrialComms from the cron’s own module and runs the cron’s two reads, so the list is produced by the code that sends. Prod needs a read grant. --at rehearses another instant.

Win-back interaction (#348). The win-back letter still goes to every eligible store, but its opening apology (“without a word from us beforehand”) is omitted for a store holding a trial_ending / trial_expired notification (listStoreIdsWithNotificationTypes → warnedBeforehand) — a removal, no new copy.

Mandatory plan gate (#336)

Every merchant must have an approved (or at least created) subscription to use the app — there is no “use it free forever on the trial” path anymore.

The gate itself. requireInstalledMerchantPageAccess (lib/server/merchant-page-access.ts), already the single entry point every merchant page calls, redirects to /plan whenever needsBillingGate(store) is true. That check is a pure rule, lib/billing/billing-gate.ts: no gate when subscription_status === "active" or custom_plan === true; subscription_status === "pending" gates into a “finish approving in Shopify” state; every other status (trial, trial_expired, cancelled, frozen, expired) gates into “pick a plan”. The /plan page is the one page allowed to render without a plan — it opts out via requireInstalledMerchantPageAccess({ allowWithoutPlan: true }), otherwise the gate page would redirect to itself. The storefront widget and all API routes are untouched by this redirect — pausing shopper-facing try-ons stays entirely store-gate.ts’s job (below), same as before #336.

/plan (app/plan/page.tsx + plan-client.tsx) shows the 4 standard plans as cards — Growth preselected with a “Recommended” badge, Pro carrying “Best value” — plus a trust block: the exact first-charge date (trial_ends_at, formatted), “cancel anytime during the trial at no cost”, a 30-day money-back guarantee (handled manually by ops via a Partners refund/app credit — no in-app refund flow), and “billed by Shopify”. A store that already has an approved plan is redirected server-side to /account?tab=billing — except when the page is opened in ?activate=1 mode (the early-activation flow below).

Trial-on-plan semantics. Subscribing while still in the trial creates the Shopify subscription with trialDays = the store’s remaining trial days (14 at install, counting down from there — see Trial lifecycle). While trial_ends_at is still in the future, an ACTIVE standard plan does not run on its paid quota yet — it runs on the same FREE allowance the bare trial uses (100 try-ons, or 200 for a qualified trial — trialAllowance()): resolveStoreGate (lib/billing/store-gate.ts) pauses the store at that allowance with code trial_limit_reached, and nothing is ever overage during this window — no money can move before the trial genuinely ends. Past the window this branch no-ops and plan quota + overage semantics apply exactly as before #336. Custom plans are exempt from this allowance rule (owner-negotiated; their install-time window is not a base-fee signal).

Early activation (the second confirmation). Burning the free allowance fires notifyTrialAllowanceBurned — a crossing-triggered call from inside checkBillingGate (lib/server/billing.ts), on the try-on that takes try_ons_used from below the allowance to at or past it. It creates an in-app notification linking to /plan?activate=1 and, unless the owner is billing-scope-unsubscribed, an email from the trialAllowanceBurned template. From /plan?activate=1, “Activate now” calls POST /api/shopify/billing/subscribe with { immediate: true }, which creates a replacement subscription with trialDays: 0 — immediate: true deliberately bypasses the route’s normal “already active, same plan → short-circuit” check, since that check would otherwise treat the in-trial active subscription as already satisfying the request. The subscribe route records the replacement as subscription_created with metadata.immediate = true, and that record — not the immediate=1 its returnUrl still carries (#729: the callback is reachable by anyone, and the parameter let an anonymous GET end a live store’s trial) — is what closes the trial window (sets trial_ends_at to now) once the replacement comes back verified ACTIVE, while the window is still open. The subscribe route writes that row BEFORE the store row moves and fails the request (no approval URL) if it cannot, because a lost marker would bill the merchant now and leave the trial cap on. Whichever of the pair lands first does it — the callback or the app_subscriptions/update webhook — and the #602 bonus is claimed once per window either way. An abandoned second approval leaves trial_ends_at untouched, so the original subscription’s day-15 auto-start still fires normally.

Consent cushion (#409, 2026-08-21 — the Kylyan incident). A hot store burned its 100 free try-ons in 17 hours and the #336 pause kept its widget dark for 33 hours while the burn notice sat unread (no owner_email, so no email either). Owner decisions, encoded here:

  • Consent on /plan — a checkbox (default ✅) on the gate screen: “If I use up the free try-ons before my trial ends, keep the widget on and include 100 more; my first charge stays on the date Shopify shows.” Ticking it makes a phone number mandatory (E.164, lib/billing/phone.ts). The subscribe route (POST /api/shopify/billing/subscribe) validates both (400 phone_required when the consent arrives without a dialable phone) and persists stores.continue_on_trial_burn + stores.owner_phone next to the pending subscription (migration 20260821_trial_cushion.sql). immediate: true (“Activate now”) ignores the consent — it closes the window. An old client that sends neither field leaves the column untouched (default false = #336 behaviour). The consent exists only while the store’s trial window is open (#777). Client and route decide it with the one pure rule resolveTrialBurnConsent (lib/billing/trial-consent.ts): consent = ticked AND not immediate AND trial_ends_at in the future; the phone is required only WITH that consent. After the trial the checkbox is not rendered, the client sends continueOnTrialBurn: false, and the route — reading the window from the store row, never the request — treats a true like “Activate now” (written false, never phone_required). Before #777 the default-ticked hidden box made the plan button (“Continue with Starter”) do nothing for every expired-trial store without a phone.
  • Gate — trialCeiling() (lib/billing/billing-gate.ts) = trialAllowance() + trialCushion(), where the cushion is the FIXED TRIAL_CONTINUE_ALLOWANCE (100) with consent and 0 without. resolveStoreGate pauses at the ceiling instead of the allowance; everything else in the branch is unchanged — still never overage, still never the plan quota, still exempting custom plans. Why a fixed 100 and not the plan quota: a subscription cancelled inside its Shopify trial is billed nothing and the consent is not a payment obligation, so the cushion bounds the exposure (~100 × the provider cost) instead of handing out 1,200 Scale try-ons for free.
  • Two crossings, four notices (checkBillingGate, lib/server/billing.ts): allowance burned → consent ON: notifyTrialCushionStarted (“your widget stays on, 100 more included, Activate now is optional” — template trialCushionStarted, notification type trial_cushion_started, dedupe trial-cushion-<trial-end>); consent OFF: the #336 notifyTrialAllowanceBurned. Cushion burned → notifyTrialAllowanceBurned with allowance = the ceiling the merchant actually saw. Both crossings also call notifyOwnerTrialBurn — a Telegram message on the ops bot (TELEGRAM_OPS_CHAT_ID) with shop, plan, auto-start date, try-ons used, whether the widget stayed on, the phone and the email — so the owner calls instead of waiting for an email to be read. Best-effort, skips silently when ops Telegram is not configured.
  • Usage truth — GET /api/shopify/billing/state adds continueOnTrialBurn, trialCushion, trialCushionSize, ownerPhone; the billing page’s in-trial banner shows “X of 200 free try-ons used during the trial (100 free + 100 cushion …)” instead of implying the plan quota.
  • Admin — the ”☆ Grant Qual.” button used to render only for subscription_status === 'trial', so an approved-in-window store (exactly the Kylyan state) had no lever on /admin; it now also renders for active + open window, with a “Free allowance X/ceiling — PAUSED” meta and the phone.
  • owner_email fallback — GET /api/auth/callback fills stores.owner_email from Shopify’s shop.email right after completeShopifyInstall when it is empty (the setup route still captures it later; this closes the “never finished setup → no billing email ever” gap). Non-fatal.

Day 15 (no early activation). Nothing runs on that date — there is no cron or job for it. The window simply lapses: trial_ends_at moves into the past, resolveStoreGate’s allowance branch no longer applies, the widget un-pauses, and Shopify itself starts charging the base fee on the subscription that was approved back at install (it was created with the real trialDays up front, so Shopify — not our code — ends the free period). Counter semantics differ by path: early activation creates a fresh subscription, so the callback’s existing “first activation resets usage” rule applies and try_ons_used resets to 0 for the new period; the day-15 auto path is the same subscription continuing, so the try-ons burned during the trial window (up to the allowance, ≤100/≤200) carry straight into billing period 1 — nothing resets.

Activation receipt (AC4). The callback fires notifySubscriptionActivated (template subscriptionActivated, stating the trial-end/first-charge date) only on a subscription’s first activation (!alreadyActivated, the same guard the usage-reset logic above uses) — a later re-approval of an already-active subscription (e.g. a cap raise) never re-sends it.

Rollout announcement. POST /api/admin/billing-gate-announce (admin-session-gated) emails every installed, non-test store that has no plan yet the one-time explanation of the change (the billingGateAnnouncement template) plus an in-app notification linking to /plan. Idempotent: the notification’s dedupe key is the constant "billing-gate-announce" (one per store, ever), so re-invoking the route after a partial failure only sends to the stores that didn’t already get one.

Single-active guarantee (App Store 1.2.2)

A store can never have more than one ACTIVE subscription, enforced in three layers:

  1. Shopify-native — createShopifySubscription sets replacementBehavior: STANDARD, so approving a new charge cancels the current one (immediate for monthly plans).
  2. App-side cancel-others on every activation trigger — cancelOtherActiveSubscriptions(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 guard — getActiveShopifySubscriptions; 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). It is reachable by anyone — Shopify redirects the merchant’s browser here and signs nothing — so it acts only on what Shopify and our own records prove, and a replay of the URL changes nothing (#729):

  1. getShopifySubscription — the truth it acts on.
  2. ACTIVE, first activation of this subscription → set status, reset usage counters + period dates, clear custom-plan fields on a standard plan, record ONE activated row (with metadata.cappedAmount). A PENDING subscription never resets anything (an upgrade in flight keeps the old cycle’s unsettled overage). Already activated + same status → no write at all; a re-approval is recorded only for a cap raise WE asked for (the admin’s cap_raise_requested row with that amount) that no activated row carries yet. Comparing against the last activation instead read the webhook’s row, which has no cap, as a move.
  3. Early activation (our subscription_created record says immediate) inside an open trial window → close the window + the #602 bonus (see above).
  4. On ACTIVE → cancelOtherActiveSubscriptions (layer 2).
  5. DECLINED/EXPIRED → drop to trial + CLEARED_CUSTOM_PLAN_FIELDS; record declined.
  6. Redirect back INTO Shopify admin — the host param only when it decodes to exactly admin.shopify.com/store/<handle> or <handle>.myshopify.com/admin (#729: .includes() sent visitors to any domain containing those words), else derived from shop → https://{admin-host}/apps/{client_id}/account?tab=billing.

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; an early activation (our record) also closes the open trial window + the #602 bonus (#729).
  • 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) — except an activated the callback already recorded for this subscription (#729: every real activation used to land twice, and the admin counted paid activations at 2×; it now counts distinct subscriptions).

Uninstall settles the billing cache (#531, #578)

stores has two status columns: status (is the app installed?) and subscription_status (the billing cache). Shopify cancels every app subscription on uninstall, but its app_subscriptions/update does not reliably reach an app that is already gone — before #531 the uninstall handler moved only status, so a merchant who left kept reading active and was counted as a paying merchant (prod 2026-08-31: 10 “paying”, 8 real; the #541 terms-gate audience: 8, really 6).

The transition — pure settleBillingOnUninstall (lib/billing/uninstall-billing.ts), applied only to a live status (active / pending / frozen; a trial store keeps its trial):

columnbecomes
subscription_statuscancelled (Shopify’s own status for the subscription after uninstall)
plan_idtrial, custom pricing cleared (CLEARED_CUSTOM_PLAN_FIELDS)
subscription_idkept — cancelled + an id is the record that the merchant had a plan (hasEverSubscribed), so a reinstall is never shown a fresh trial
overage_pending0 — written off: no live subscription can carry the usage record

plus one billing_events row cancelled (shopify_status=CANCELLED, overage_count = the amount written off, metadata = { reason: "app_uninstalled", fromStatus, overageWrittenOff }). A settled row is cancelled, not live, so every writer below is idempotent.

Three writers, one transition:

  1. handleShopifyUninstallWebhook (lib/server/shopify.ts) — source: webhook, right after status = 'uninstalled'. A failed write fails the webhook, so Shopify’s retry settles it.
  2. reconcileStoreBilling — an uninstalled store is settled locally, without any Shopify call (source: reconcile, log billing.reconcile.uninstalled_settled); an already-settled one is returned as-is.
  3. The backstop for a missed webhook: the nightly GET /api/cron/billing-overage sweeps getActiveSubscribedStores(), which still returns an active row on an uninstalled store — the cron hands it to the reconciler and skips it (no token read, no settle, no rollover, no email). Its ?dryRun=1 counts it as skipped.

Reading it — one helper. “Is this an active subscriber?” is hasActiveSubscription(store) (same module): status !== 'uninstalled' && subscription_status === 'active'. It backs needsTermsReapproval (the terms-gate audience), the admin merchants KPIs, the per-store profitability MRR (getStoreProfitability now selects status) and the activation ladder’s isPaying. A caller must SELECT status, or an uninstalled store reads as installed.

Reinstall — see the next section. In short: the billing row stays cancelled / trial with its subscription id, so the mandatory plan gate sends the merchant to /plan for a new approval and the billing view reads “expired”, never “trial”. If Shopify does report an ACTIVE subscription, reconcileStoreBilling restores active on the next load as for any other store.

Reinstall resilience — the lifecycle log (#531)

Measured on prod 2026-09-14 (read-only): install_events held one row — the managed-install token exchange (/api/auth/session, how nearly every install arrives) never wrote one, only the OAuth callback did; and install_events.store_id was ON DELETE CASCADE, so shop/redact (48h after an uninstall) deleted what existed. nhkwm1-tu — paid Starter, uninstalled 09-01 — was redacted and reinstalled on 09-10 as a brand-new row: fresh trial window, trialDaysLeft: 14 on its new subscription. A second full trial, and no record that it had been a customer.

What is logged — completeShopifyInstall (lib/server/shopify.ts, the one function BOTH install paths call) writes the lifecycle event, classified by pure classifyInstall (lib/billing/install-lifecycle.ts) from the row that existed before and the shop’s history:

beforeevent
no row, no historyinstall
row status='uninstalled'reinstall
no row, but history (redacted, came back)reinstall
installed row (scope update / token refresh)none — a re-auth is not a lifecycle event

handleShopifyUninstallWebhook writes uninstall. Every row carries billing with a before and an after state (status, subscription_status, plan, subscription id, trial window, try-ons used, pending overage) and the attribution signal the callback observed (channel/ref; the token-exchange path has none). The event write is non-fatal (shopify.install_event_record_failed).

Surviving the redact — migration 20260914b_install_lifecycle.sql: store_id nullable, ON DELETE SET NULL, index on (shop_domain, occurred_at desc). History is read by domain (listInstallEventsForShop).

No second trial — when the shop comes back as a NEW row with history, resumeBillingForRecreatedStore replaces the fresh window upsertStore just stamped with the last uninstall’s recorded after: the original trial window; for a merchant who had a plan also cancelled + subscription id + plan trial; for a trial-only merchant the try-ons already used. Pre-snapshot history falls back to a window starting at the first recorded event. The orphaned rows are re-linked (relinkInstallEvents). A failed resume fails the install on purpose — a silent fallback is the second free trial. /plan’s trialDaysLeft still comes from trial_ends_at, which is now the original window, so a reinstall gets at most the REMAINDER of its first trial, never a new one.

One “ever subscribed” derivation — hasEverSubscribed (a subscription id, or status expired) feeds resolveBillingView in both /api/shopify/billing/state and /api/shopify/metrics.

Admin — the store page (/admin/store/[storeId]) shows an Install lifecycle panel: first installed, install status, uninstall and reinstall counts with the last dates, trial consumed, ever subscribed, and every event with its billing change. Served additively by GET /api/admin/billing-history (field lifecycle with events and summary, null when unreadable).

Retention (GDPR / Shopify API License §6.2(3)) — detached rows (their store was redacted) are not kept indefinitely: the daily /api/cron/log-maintenance runs purgeDetachedInstallEvents, which strips the attribution ref from every detached row at once and deletes all detached rows of a shop whose newest event is older than DETACHED_LIFECYCLE_RETENTION_DAYS (29 — inside Shopify’s “within 30 days when the Merchant uninstalls” and our Privacy Policy’s “installed + 30 days after uninstall”). So a shop that returns inside that window resumes its history; one that returns later starts as a new store. Both statements are guarded on store_id IS NULL; a failed purge is logged (cron.log_maintenance.lifecycle_purge_failed) and retried the next day. The column-by-column legal basis is in 05_tasks/proof/578/PROOF.md (## GDPR basis).

Activation chase side effect — /api/cron/activation-chase reads install_events; with installs through the token exchange now logged, the #312 chase starts seeing them (its email goes to stores at zero products 24h/72h after install).

Rows written before the fix — migration 20260914_settle_uninstalled_billing.sql applies the same transition to every status='uninstalled' row with a live status (ledger source: admin, metadata.backfill, previous custom pricing kept in metadata.previousCustom). Proven on dev by scripts/real-seam/rs-531-uninstall-settles-billing.mjs.

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.

Cap sizing guardrail (#63)

A custom plan’s cappedAmount is the maximum overage Shopify can ever charge in a 30-day cycle. Set too low it is exhausted mid-cycle: Shopify rejects the usage record, overage_cap_reached flips and try-ons pause until the merchant approves a raise (the Icedout trap). Standard tiers never hit this because plans.ts ships sane caps; custom plans had no sizing at all.

assessCapSizing/suggestCappedAmount (lib/billing/custom-plan.ts, pure + unit-tested) suggest 2× a full extra month of quota at the plan’s own overage rate, rounded up to a clean 50, never below 200 (in the plan’s billing currency). Both admin custom-plan forms render the shared components/cap-sizing.tsx hint under the cap field — the suggested value while it is empty, a warning once it is undersized, and a one-click “Use <suggested>”.

It is warn-and-confirm, not a hard block: an undersized cap makes create_custom_plan return 400 { needsCapConfirm: true, capSizing } and the form show a confirm step; re-submitting with the additive confirmLowCap: true creates the plan with the operator’s number unchanged (nothing ever resizes a money ceiling silently) and records capSuggested/capConfirmedLow on the subscription_created billing event. Recovering an existing Shopify subscription is never blocked by the guardrail — that cap already exists on Shopify. Existing caps are out of scope: use raise_cap (the cap monitor’s 70/80/100% alerts) for those.

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.

Order fees (#439 pricing, #441 engine)

The evidence is merchant-visible (#494). order_fees.evidence is not an internal audit trail — /billing renders it. Expanding a fee row shows the products that were tried on together, the LITERAL line attributes we matched on (_tryvio_session_id, _tryvio_role), a deep link to that order in the merchant’s OWN Shopify admin, and the orders we considered and did NOT charge with a plain-words reason.

Two consequences for anyone changing the engine:

  • the evidence blob is a published surface. Renaming a field or dropping one is a merchant-facing change, not a refactor. order-fee-evidence.ts is the pure boundary that turns it into words.
  • a new UpsellReason needs a sentence. describeNotCharged degrades unknown reasons to a neutral line rather than printing the enum, so nothing breaks — but the merchant then gets a vaguer answer than we could have given. Add the reason to NOT_CHARGED_REASON in the same change.

A third revenue line beside the plan fee and per-try-on overage: a percentage of orders Tryvio actually grew. #441 is the engine; #439 is the statement of it on the nine pricing surfaces.

Published rate — one source. PLANS[x].orderFeeUpsellPercent in lib/billing/plans.ts (1% on all four plans today). Nothing else may hold the number: lib/billing/order-fee-copy.ts turns it into the canonical wording every surface renders, and lib/billing/order-fee-surfaces.test.ts walks all nine surfaces as source — code surfaces must carry no percentage literal beside the fee wording, text surfaces (docs MDX, APPSTORE_LISTING.md) must state the catalog’s current value. Changing a plan’s rate is one edit in plans.ts; the suite then names every page that has not followed. At runtime #441 resolves per-store override → app_config per plan → app_config global → this published value, and records which source it used on each fee row.

What qualifies — two independent rules (#645, owner decision 2026-09-16: “if someone uses the bundle code we charge too”).

  • Rule A — the bundle discount code, checked first. If the order’s discount codes include one of Tryvio’s own bundle codes, that alone qualifies it — no combined try-on required. The code family is allBundleTierCodes() (lib/billing/bundle-tiers.ts): every code bundleTierCode() can mint, for each count from MIN_TIER_COUNT to MAX_TIER_COUNT (today TRYVIOBUNDLE, TRYVIOBUNDLE3, TRYVIOBUNDLE4). We match against this generated family rather than the store’s currently-configured ladder for two reasons: an order sitting in the look-back window may carry a code from a tier the merchant has since removed from their ladder, and a per-store ladder read taken at run time can go stale between when the order was placed and when the cron classifies it. A match sets reason: "bundle_discount_applied" and stamps evidence.matchedDiscountCode with the winning code. A store with a nonzero bundle discount that did NOT use its code on the order falls through to bundle_discount_missing.
  • Rule B — the original combined-try-on path, unchanged. For a store whose bundle discount is 0% — no code exists to key on — the old definition still decides: one _tryvio_session_id where the shopper tried on the primary AND a bundle_complement together, and bought both. The proof of “together” is tried_products rows with source = 'bundle_outfit' in that session — an add-to-cart is not proof, and neither are two separate single-product try-ons. Near-misses (complement only, second product never tried, two sessions, related_product) each have their own reason code, stored on the row.

Rule A only adds qualifying orders; every order that qualified under Rule B before #645 still does. Why it mattered: bundle_outfit try-ons went to zero platform-wide on 2026-09-10 (0/day since, against 33–121 ordinary try-ons/day), so the Rule-B precondition alone silently stopped every fee — Rule A recovers the orders that used the bundle code without a combined generation.

Rate resolution. resolveOrderFeePercent, pure, precedence per-store override → app_config per plan → app_config global → PLANS[x].orderFeeUpsellPercent. The winning source is stamped on every row. A half-broken money config never partially applies: unparsable JSON in order_fee_percent_upsell_by_plan means “no per-plan overrides at all”, not “some of them”.

Fee maths. computeOrderFee rounds half-up through a decimal string, cloning computeCommissionAmount — in binary float 2.675 * 100 === 267.49999999999997, which rounds down and quietly shorts a cent per order.

The three switches between a merge and a merchant’s card

A settled Shopify usage record cannot be deleted, so the irreversible step is guarded three times over, and accrual is deliberately gated by none of them — a store that cannot be charged still gets its rows, with the reason on each:

  1. app_config.order_fee_enabled — ships false. Merging the release starts nothing.
  2. stores.usage_terms_version >= 2 — see R1 below.
  3. An active subscription with cap headroom — the only place money can legally move.

R1 — the usage-terms gate (why existing merchants are not charged)

Our usage line item carries a terms string the merchant approved, and Shopify has no mutation that edits it: appSubscriptionLineItemUpdate changes only the capped amount. Charging an order fee against terms that say “overage charges for try-on generations” would be billing for something the merchant never agreed to.

So terms are versioned. USAGE_TERMS_V2 (in lib/server/shopify.ts) names the order fee, every new appSubscriptionCreate uses it, and the billing callback records version 2 on the store — at APPROVAL, never at creation, because a merchant who is shown the new terms and declines keeps their old subscription and must not be marked as having accepted. Everyone approved before this shipped stays version 1 and accrues as skipped_terms_not_approved forever, until someone deliberately runs a re-approval takeover (app_config.order_fee_terms_takeover, default off).

USAGE_TERMS_V2’s wording (“…on orders with a Tryvio upsell”) and USAGE_TERMS_VERSION (still 2) are unchanged by #645. An order carrying the bundle discount code we mint for our own Complete the Look upsell is, in plain language, an order with a Tryvio upsell — Rule A is read as covered by that sentence, not as widening it. Editing the approved string would cost every merchant a fresh re-approval, which #645 does not need.

#663 — forward-only, never charged backwards (owner decision 2026-09-16: “we will never charge backwards”). A fee row’s status is decided once, when its order is classified, and is never re-decided later. Concretely: skipped_terms_not_approved is not retried once the merchant accepts updated terms — only orders classified after the acceptance can qualify; skipped_no_subscription is not recovered by re-subscribing; and skipped_currency is not recovered by later aligning the billing currency. These three statuses are now collectively called written off, in the merchant’s /billing panel, in the admin ledger, and on the row itself. (skipped_cap is not written off — see Cap carry-forward below.)

  • The list — WRITTEN_OFF_STATUSES / isWrittenOffStatus() (lib/billing/order-fees.ts): skipped_terms_not_approved, skipped_no_subscription, skipped_currency. excluded is deliberately not in the list — its fee is 0, so there is nothing to write off. skipped_cap remains a valid historical status on rows written before this change — there is no migration to move them, and nothing writes that status any more going forward.
  • #669 — the schema accepts every status the code writes. Until 2026-10-01 skipped_currency (#608) was missing from order_fees_status_check: the settle step’s UPDATE threw 23514 and that store’s whole settlement, the correctly-denominated rows included, was abandoned every night (latent — no store had mismatched currencies yet). Migration 20261001_order_fees_status_skipped_currency.sql replaces the constraint with all seven values. ORDER_FEE_STATUSES (lib/billing/order-fees.ts) is now the one list the code may write — the OrderFeeStatus type derives from it — and order-fee-status-check.test.ts fails the build when the newest migration’s constraint lacks one of them; scripts/real-seam/rs-669-order-fee-statuses.mjs writes each through the real dev schema.
  • Why it’s terminal, not just documented — three code sites: the status is frozen at accrual time in lib/server/order-fee-run.ts (nothing revisits a row after it’s classified); recordOrderFees (lib/server/supabase-admin.ts) upserts with ignoreDuplicates: true, so a later run over the same order can never overwrite an already-written status; and the settlement sweep reads only rows with status = 'accrued', so a written-off row is structurally invisible to the code path that ever moves money.
  • The stamp — a written-off row carries evidence.writtenOff = true, so the merchant-visible evidence blob (see above) reads as a decision, not a stuck job nobody noticed.
  • Reporting — StoreFeeResult and the nightly cron’s totals both gained a writtenOff: { count, amount, byStatus } field, so a run’s output states how much was written off and under which status without anyone having to query the ledger by hand.
  • Deliberately not a DB status — written_off does not exist as a value in the order_fees status column; there is no migration for it. The four statuses above are unchanged in the database — “written off” lives entirely in evidence.writtenOff plus the copy layer (merchant panel, admin ledger). This keeps the terminal-status code paths (frozen at accrual, upsert ignoreDuplicates, settlement reads accrued only) as the single source of truth for what can never be charged, rather than adding a second status that those three sites would also have to know about.
  • The real case — Icedout’s order #16419 (EUR 0.60) was written at 02:20 UTC on 2026-09-09; the merchant approved the updated terms at 12:41 the same day. That EUR 0.60 stays written off — it accrued before the approval, and the approval only starts billing orders classified after it.

Cap carry-forward (owner decision 2026-09-16)

“it’s carried to the next [cycle] … and it has to be recorded in his billing history — when the merchant opens the plan and sees what we charged him for, it should be clear that it’s from last month.” A fee that the plan’s monthly spending cap had no room for is carried to the next billing cycle, not written off — this reverses the earlier framing of the cap as a write-off.

  • Mechanism — the row just stays accrued. The settlement sweep only ever reads status = 'accrued' rows, so “carry it” and “leave it for the next run to find” are the same thing: nothing new to build, no new status. skipped_cap survives purely as a historical status on rows written before this change (see above); the code path that used to write it now defers instead.
  • Deferral bookkeeping — each time a fee is deferred, evidence.capDeferrals increments and evidence.lastCapDeferredAt is set, so a row that carries repeatedly shows its own history. Pure helpers capDeferralCount() / isCarriedFee() (lib/billing/order-fees.ts); the DB write is deferOrderFeesForCap() (lib/server/supabase-admin.ts), which updates only evidence and is guarded by .eq("status", "accrued") so it can never touch a row that already moved on.
  • Fairness — oldest first. splitByCapHeadroom charges fees in date order, so a carried fee is always next in line the following cycle and can never be starved by newer fees jumping the queue.
  • Re-checked, not guaranteed. A carried fee is evaluated against the next cycle’s cap exactly like a new one; if it still doesn’t fit, it carries again.
  • A carried fee whose order gets refunded before it’s ever billed is dropped, not charged — the run moves the accrued row to excluded via excludeRefundedOrderFees(). Because a carried fee can be arbitrarily older than the normal settlement window, the nightly Shopify order look-back is widened to reach the oldest unsettled fee (getOldestUnsettledOrderFeePaidAt()), so a refund can’t hide behind the usual 9-day window. A fee that has already settled is still never reversed — that policy is unchanged.
  • Currency still resolves before the cap split (R6, below) — a carried fee is always charged in the currency it accrued in, never re-denominated on the later cycle.
  • Merchant-visible in the period it’s actually billed. getOrderFeeTotalsForPeriod now matches order_paid_at >= since OR settled_at >= since, so a carried fee shows up in the billing period it lands in, not only the one its order was placed in. /billing renders a “carried from <Month Year>” line under the fee’s status, and describeFeeTiming() (lib/billing/order-fee-evidence.ts) adds the same fact in a sentence inside the “Why this fee?” panel.
  • Reporting — the nightly cron’s totals gained carriedCount / carriedAmount / refundedAfterAccrual; StoreFeeResult gained a carried: { count, amount } field and refundedAfterAccrual, mirroring the writtenOff reporting above.

The nightly run

GET /api/cron/order-fees (02:20 UTC, after billing-overage — both charge against the same cap and overage is the older promise). Per active store, with bounded concurrency and an app_config run lock: list paid orders in [now - (delay + 2)d, now], classify, insert-ignore the rows, then settle everything older than order_fee_settle_delay_days (default 7) with one usage record per store per run plus an order_fee_charged billing event.

?dryRun=1 walks the identical path with zero writes and zero usage records — mandatory before the first real run for a zone:billing change.

Refunds, the cap, and currency

  • Refunds (R4): usage records cannot be deleted, so the engine settles late rather than reversing. Only orders paid ≥ N days ago are charged; a full refund before settlement is excluded; a partial refund shrinks the base; a refund after settlement is not reversed.
  • Cap (R2): fees share the plan’s cappedAmount with overage. What fits is charged; the rest stays accrued and is carried to the next cycle (see Cap carry-forward above) — it is not written off. 🚨 The order-fee path must never call markCapExhausted — an unbilled fee is our revenue problem, and pausing the merchant’s widget over it would make it theirs.
  • Currency (R6): the base is the order’s shop money; the usage record is in the subscription currency. A mismatch we cannot convert is excluded and flagged, never charged at a made-up rate.

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 cron /api/cron/tryon-timeout (every 30 min, 15,45 * * * * since #369 — it ran once a night at 02:15 under a Hobby-plan limit this project no longer has, and a merchant waited 13.5h for a refund) 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 ~45 min (15-min threshold + at most one 30-min gap).

    The same cron runs two more passes after it, because the session-level safety net above only sees sessions in generating — and money leaks in the states it cannot see:

    1. Abandoned provider jobs (#219). The try-on route inserts the tryon_provider_jobs row pending before the provider submit; when the submit throws, the row is orphaned. Prod scan 2026-07-30 found 269 such rows (2026-07-03 → 07-30) whose sessions were already failed — invisible to every sweep. The pass closes each one through the atomic single-winner claim claimTryOnProviderJobAbandoned (UPDATE … WHERE id = ? AND status IN ('pending','running') RETURNING), losing safely to a late provider callback, then hands the row to finalizeFailedStorefrontTryOn so a still-live session is claimed + refunded exactly once. Bounded to 300 rows/run, oldest first, in Promise.all batches of 10. It deliberately does not bump updated_at — that column orders “the session’s latest job” for the health check and the widget poll, so bumping it would let a swept 3-week-old orphan mask a real signal.

    2. Undelivered ledger sweep (#186, widened by #219) — lib/billing/no-job-sweep.ts (pure + unit-tested). The gate charges before a provider job exists, so a shopper rate-limit 429, a product-not-synced 404 or any pre-submit throw bills a unit that no refund path owns. A billable ledger row >1h old is refunded when its session exists, has no output, is not generating/completed, and has no provider job that can still deliver. That last clause is the #219 widening: originally the rule was “no job at all”, which excluded both the orphan class above and the retry class — a shopper retrying reuses the session id, so the gate charges twice while claimTryOnSessionFailed (one-shot per session) can only refund once. Refunds go through the same decrement_try_ons_v2 RPC as everything else; its billable → refunded row flip is the idempotency boundary and lets a 2-unit session be refunded twice. Default reach is 7 days; a manual, CRON_SECRET-authenticated ?backlogDays=N (clamped 1…90) widens it for a one-off owner-approved backlog run, so the daily job can never silently widen its own money reach.

      ⚠️ Those four rules live in TWO places and must never drift. Candidate selection is the SQL function no_job_sweep_candidates(p_oldest_allowed, p_newest_allowed, p_limit) (migration 20260731b_…), and selectNoJobRefunds re-applies them in the cron as a second guard. The split exists because the first version expressed them only in TS: the query took the 200 oldest billable rows and filtered afterwards, so on prod (9,934 billable rows in the window) all 200 were delivered try-ons and the 367 refundable ones — scattered over four weeks — were never loaded. Two real backlog runs returned noJobRefunded: 0, and re-running could not help: nothing gets flipped, so the same 200 rows come back forever. SQL owns the reach; the pure module stays the authority on the rules and can only ever drop a candidate, never add one. Change a rule → change both, and keep no-job-sweep-reach.test.ts green (it asserts the two agree over a ledger where the refundable rows sit outside the first 200). The response field noJobCandidates reports how many rows the query reached, so a future reach regression cannot read as “nothing to do”. Measured on prod: 62 ms for the whole predicate.

    Cron auth for this route and webhook-retry is the shared lib/server/cron-auth.ts::isAuthorizedCronRequest — getServerEnv().CRON_SECRET compared with timingSafeEqual over SHA-256 digests (STANDARDS §6). The other cron routes still use !== on process.env.

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

Dry-run mode (#263)

The two money crons above and the grant_credit admin action all accept a read-only twin that reports what the mutating run would do without moving anything. Purpose: a money change’s proof now records predicted-vs-actual (05_tasks/proof/README.md’s Dry-run: block) before it ever touches prod money — the release train refuses a zone:billing issue without that block.

  • GET /api/cron/tryon-timeout?dryRun=1 — performs only the reads (stale sessions, abandoned provider jobs, no-job ledger candidates + the refund facts each would produce) and returns { dryRun: true, predicted: { refunded, jobsClosed, jobRefunds, noJobCandidates, noJobRefunded } }, one counter per wet-run counter. Zero mutations — no claim, no finalize, no decrement. Still takes the shared cron lock, so the snapshot can’t be mutated mid-read. Same CRON_SECRET auth as the wet run.
  • GET /api/cron/billing-overage?dryRun=1 — walks the same store set read-only. Per store: pendingSnapshot, accrued (getBillableAccruedSinceLastSettlement("cron")), chargeUnits + driftAmount (the real computeSettlementCharge #148 clamp), rate (getOverageRate), amountBeforeCredit, currency, wouldRollOver; plus a summary predicted: { charged, rolledOver, skipped, failed, driftFlagged }. No claim, no Shopify usage record, no DB write, no notification. amountBeforeCredit is named deliberately — only the atomic claim path in the wet run can consume the store’s goodwill credit, so the dry run cannot predict the post-credit amount.
  • POST /api/admin/billing grant_credit accepts an optional dryRun: true: it validates the request and resolves currency/test-mode exactly like the wet path, then returns { dryRun: true, predicted: { amount, currencyCode, description, test } } without calling the Partner API or writing a billing event. The #176 bounds on the amount (positive, ≤ 5000) are still enforced on the dry-run path.

Merchant usage & billing-cap warnings (#309, #346)

⚠️

Why this exists. Markovi Ochila (our #2 merchant by usage) accrued 18 overage charges (~$182 on a $39.99 starter plan) between 07-19 and 07-30. The only warning they ever got was in-app, evaluated only on dashboard load — and it arrived 19 seconds after their card was refused. #309 replaced that with graduated, race-safe, emailed warnings. #346 (owner decision, 2026-08-12) then found an active store could still receive up to 7 billing emails a period — 4 from this engine (60/80/90/100% of the plan allowance) plus 3 from a separate Shopify usage-cap engine (70/80/100% of cappedAmount) — and folded the cap facts into this engine’s email, capping the total at 5.

Pure logic — dueUsageThreshold({ used, limit }) (lib/billing/usage-thresholds.ts) returns the highest due threshold out of [60, 80, 90, 100] (owner-tuned) or null. Non-positive limit always returns null — a custom all-overage contract (custom_try_on_limit = 0) has no allowance to warn about. resolvePlanFitNudge adds the “what to do about it” number to the warning:

  • upgrade (paid standard plans) — reuses recommendUpgrade, and returns null whenever it finds no strictly cheaper tier (owner call, 2026-08-13: a higher plan is recommended ONLY when it would genuinely save the merchant money — no “headroom”/predictability pitch). Concretely, on Starter ($39, 200 included, $0.22 overage) Growth ($99) only becomes cheaper past roughly 473 try-ons; below that, staying on Starter is genuinely cheaper, so no nudge is shown. overagePaid = realised overage units × the store’s overage rate (still reported when a nudge fires). #346 — it also accepts an optional forecastUsed: when the caller supplies a projection (the forecast, below), the cheaper-plan comparison runs on the PROJECTED volume instead of realised usage, and the result carries a basis: "forecast" | "realised" field so the email can say “At that volume, <Plan> is the cheaper plan” instead of implying money already spent. Without a projection it falls back to realised usage.
  • trial-pick (trial stores) — the cheapest standard plan for the trial’s current volume, a direct “pick this” ask (this path is unconditional — a trial has no “current plan” to compare against, so the saves-money-only rule above doesn’t apply here).
  • null for custom plans — bespoke pricing, the public ladder does not apply.

notifyUsageThreshold is THE money email (#346) — notifyUsageThreshold(store, { threshold, used, limit, getCurrency, cap }) (lib/server/merchant-notifications.ts) is the only place a threshold becomes a notification/email, and since #346 the Shopify cap facts travel inside it too, via an optional cap: { pct, cappedAmount } input supplied by the caller that already knows them (the nightly cron). The notification’s unique dedupe_key — usage-<threshold>-<period> (period = current_period_ends_at’s date, or "trial") — is the crossing memory: whichever evaluator gets there first inserts it, everyone else’s insert is a no-op, so it’s one notification + one email per threshold per billing period regardless of how many evaluators race (concurrent try-ons, overlapping cron runs). The email is sent only when the insert was fresh. Trial stores are filtered at threshold 100 — that moment belongs to the existing F4 notifyTrialQuotaExhausted flow, which already emails a conversion CTA.

The money box (#346) — every threshold email renders a fact block built by the pure module lib/billing/billing-money-email.ts (buildMoneyFacts): plan label + monthly fee, try-ons used / included, the Shopify billing cap (when known, from cap), and the projection (below, when shown). The monthly fee is the merchant’s OWN approved price (#647): a list-price change in plans.ts (Starter $39.99 → $39, 2026-09-14) never reaches an existing subscription, so the fee is read from the subscription’s recurring line (getShopifySubscription(...).recurringPrice, passed as getPlanPrice by the gate, the cron and the dashboard load) and falls back to the catalogue only when Shopify returns none. The plan-fit nudge and the cap-warning upgrade compare against the same figure (currentPlanPrice), and /billing’s “Your plan” card reads currentPlanPrice from GET /api/shopify/billing/state. All money renders with toFixed(2), never toLocaleString (the #279 runtime-locale trap). MoneyFacts carries a hasOverage boolean (owner call, 2026-08-13: true when overage has been charged this period OR units are accruing) — the “Overage charged so far” and “Accrued, not yet charged” rows render only when it is true. Below the allowance those figures are structurally zero, and a permanent “0.00” trained the merchant to skip the whole box, so they now appear the moment either number can be anything else. The “charged” figure is a new read the engine performs itself — getSettledOverageAmountSince(storeId, sinceIso) (lib/server/supabase-admin.ts) sums successful overage_charged rows in billing_events for the store since current_period_starts_at; it complements overage_pending (which only holds what has accrued since the last settlement), so the two never overlap. The read happens lazily, only after the notification was freshly created, and concurrently with the currency read via Promise.all. It returns null when the period start is unknown or the read fails, in which case the “overage charged” line is omitted from the email rather than printed as a false 0.00.

The forecast (#346) — forecastPeriod (same pure module) is a linear projection, used ÷ days elapsed × period length, reported as projected try-ons and projected total money (plan fee + projected overage). It also carries daysElapsed and periodDays (owner call, 2026-08-13) so the copy can name the exact window it computed from. It is omitted entirely unless at least 5 days have elapsed in the period OR the store has at least 50 try-ons — an early-period email shows facts only, never a wild number — and also omitted when the period window is unknown/degenerate or the clock is skewed. It never projects below what the store has already used. Trials never carry a forecast (a trial allowance is one-off, not monthly).

The pace paragraph (#346, owner call 2026-08-13) — “Where this period is heading” is a standalone FACT block in templates.usageThreshold (lib/server/email.ts), independent of whether any plan is being recommended. It renders whenever a forecast exists and projects past the allowance (forecast.projectedOverageUnits > 0), and shows its full working: try-ons used so far, the days elapsed and the period length (from daysElapsed/periodDays above), the projected finish, how far that is past the allowance, the overage rate, and the resulting projected bill vs the plan fee — closing with an explicit “that is an estimate from your usage so far — not a charge”. This replaced the plan nudge as the thing that fires below the crossover point: where the old copy pitched a plan on “headroom”, the paragraph now states the pace as arithmetic the merchant can redo themselves, and says nothing about upgrading unless resolvePlanFitNudge (above) independently finds a genuinely cheaper plan.

At 100%, the “two ways out” (#346, owner call 2026-08-13) — templates.usageThreshold always offers raising the Shopify billing cap (that is what keeps the widget serving), and adds the plan option only when resolvePlanFitNudge found a cheaper plan (nudge.kind === "upgrade"). When none does, the copy says plainly that the current plan is still the cheaper option and that we will say so the moment that changes — never a two-choice list built around a plan we can’t actually recommend.

The earned-revenue block, 80% and up (#346, owner call 2026-08-13) — buildPerformanceFacts (same pure module) + performanceBox (lib/server/email.ts) render: attributed revenue and order count, try-on shopper conversion vs the non-try-on baseline, return on Tryvio spend (with the amount spent shown as the denominator, so a bare “120×” is checkable), and estimated revenue by period end. Data comes from getMerchantMetrics via readPerformanceFacts (lib/server/merchant-notifications.ts) — the one heavy read in the composer, gated three ways: threshold ≥ 80, paid stores only (never trials), and only after the notification was freshly created. Hard guards inside buildPerformanceFacts itself: returns null (the block drops whole) when there is no attributed revenue, no attributed order, or no successful try-on; it drops the ROI ratio but keeps the plain facts when revenue and spend are in different currencies (the #66 AC3 rule — never divide across currencies in silence); and a failed read costs only the block, never the email (wrapped in the same safeRead used for currency/settled-overage).

In-app notifications carry the same numbers as the email (#346, owner call 2026-08-13) — buildNotificationBody (same pure module) composes the bell’s body, e.g. “200 of 200 included try-ons used. At this pace the period will bill around USD 61.99 instead of USD 39.99.” — with the plan sentence under the identical saves-money-only rule as the email. New helper setNotificationBody(storeId, dedupeKey, body) (lib/server/supabase-admin.ts) updates the notification row. The ordering inside notifyUsageThreshold is deliberate:

  1. The notification INSERT (createNotification, generic body) stays first because it is the dedupe gate — the dedupeKey per threshold+period must land before anything else, and it must not wait on reads we refuse to pay on a duplicate.
  2. The body is rewritten once, afterwards, on the freshly-created row (created returned true).
  3. That rewrite happens before the store.owner_email check — a store we cannot email is exactly the one for which the bell is its only channel. A failed rewrite (wrapped in safeRead) never costs the email that follows.

Three evaluators, one engine:

  1. checkBillingGate (lib/server/billing.ts) evaluates the POST-increment count in real time on every try-on, fire-and-forget. A store that subscribes already over its limit gets its 100% warning on its first try-on — strictly before the first overage charge, which only happens at the USD settle threshold or the nightly cron.
  2. The nightly api/cron/billing-overage sweeps every active store, evaluating evaluateCapStatus (the cap facts) before the usage-threshold notify so the cap { pct, cappedAmount } can be passed into the same email (#346). Also catches a store that crossed a threshold without a subsequent try-on (rolled-over usage on subscribe, an admin backfill). Skipped on the same run that detects a period rollover — the counters were just reset.
  3. Dashboard load (checkMerchantNotifications) routes its old 80/100 usage-milestone check through the same engine. The old path’s un-deduped email — resent on every metrics load — is gone.

Currency resolution is lazy — getCurrency is only invoked once a warning is confirmed fresh (≤4 Shopify calls per store per period): the gate path fetches the live subscription (resolveBillingCurrencyForStore, degrades to null → USD on any failure), the cron passes sub.currencyCode, the dashboard passes the metrics route’s billingCurrency. Unknown/missing currencies resolve to USD via resolveBillingCurrency.

Shopify usage-cap warnings, email only at 100 (#346) — notifyOverageCap(store, { level, cappedAmount, currency, upgrade }) (lib/server/merchant-notifications.ts) still watches the Shopify usage cap fill — 70% / 80% / 100% of cappedAmount — from the nightly cron’s post-settlement balance, with one in-app notification per (level, billing period) on the unchanged dedupe key overage-cap-<level>-<period>. Since #346, only level 100 sends an email: levels 70 and 80 still create their in-app notification but no longer mail — their money now rides inside the notifyUsageThreshold email above instead, so the same merchant is never mailed twice about the same period. All 7 in-app notifications a period still exist; only the emails consolidated. Level 100 remains the one exception and still emails immediately regardless of the quota stage, because the widget pauses for shoppers the moment the cap is reached — its subject now names the pause, “Action needed: your try-on widget is pausing — billing cap reached” (templates.overageCap). It fires from two places: the nightly cron, and markCapExhausted (lib/server/billing-overage.ts), invoked the moment Shopify rejects a settle with “Total price exceeds balance remaining” — so the pause is announced the same day, not the next morning’s cron.

No email from the settlement path itself (#346) — an explicit owner product decision (2026-08-12): no email is ever sent per overage charge, on a successful or a failed settlement. src/lib/server/billing-overage.test.ts asserts this as a regression, not just a comment someone could quietly delete.

Auditability — every billing warning email (usage thresholds and the level-100 cap-reached warning, both routed through the shared sendBillingWarningEmail) is recorded in the existing email_events table via recordEmailEvent: requested (with the Resend id), send_failed (with the error), or suppressed (reason unsubscribed_billing). No schema change.

Billing-scope unsubscribe — these emails carry unsubscribeUrl(email, "billing"), adding &s=billing to the link. /unsubscribe records that scope in email_unsubscribes; addUnsubscribe only flips stores.owner_email_marketing_consent off for the marketing scope. So a marketing opt-out never silences billing warnings (that’s the unsubscribe page’s public promise), while a billing opt-out suppresses the email but still leaves the in-app notification.

getActiveSubscribedStores (used by the cron sweep) now also selects subscription_status.

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_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 (incl. the trial lifecycle’s trial_expired/trial_reopened), 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=.

Outreach offers (#723)

What it is. Our cold-outreach emails promise discounts: uk-offer-v1 = 20% off the first 3 months (UK campaign #696), bg-retouch-697 = 20% off the first month (BG re-touch letter #697). The catalogue lives in apps/web/src/lib/billing/offers.ts (OFFER_CATALOGUE); its numbers are COPIED onto the store’s row when applied, so editing the catalogue never changes a promise already made. An admin applies an offer from the store’s admin page (panel “Outreach offer”) — nothing is automatic until an admin applies one.

Tables (migration supabase/migrations/20260929_store_offers.sql):

  • store_offers — one row per store per offer, unique (store_id, offer_key). Columns: mode, status (pending | active | ended | cancelled), pending_subscription_id + pending_discount_cycles, subscription_id, discount_starts_at + discount_cycles, delivered_before, eligibility jsonb (what the admin saw at apply time).
  • store_offer_credits — one row per discounted invoice paid by credit, unique (offer_id, cycle), status claimed | granted.

Three delivery modes, decided at apply time from the live Shopify state (chooseOfferMode):

  • native — no active subscription: the discount rides the store’s FIRST plan approval via appSubscriptionCreate → appRecurringPricingDetails.discount = { value: { percentage: 0.2 }, durationLimitInIntervals: N } (percentage is a FRACTION; recurring line only — Shopify has no discount on usage lines). With trialDays the discount starts after the trial.
  • reapprove — subscribed and the trial is over: the merchant gets a message with a link to their Plan & billing tab inside Shopify admin (/account?tab=billing&offer=confirm); the banner’s “Confirm in Shopify” button calls the subscribe route with applyOffer: true, which re-buys the SAME plan with the discount and replacementBehavior: APPLY_IMMEDIATELY (STANDARD could defer a cheaper replacement to the end of the cycle as a “downgrade”). The link goes to our own route, not a pre-made Shopify confirmation URL, because a pending charge expires after 2 days.
  • credit — subscribed and STILL IN TRIAL: a Partner API app credit (appCreditCreate, lib/server/shopify-partners.ts) of percent × the LIVE base fee, in the subscription’s own currency, once per invoice. Reason (Shopify docs, “Offer subscription discounts”): “If an app subscription is currently in a trial period and you add an app subscription discount, then the trial ends and the subscription with the discount applies immediately.” A link would have charged an in-trial merchant on the spot. First case: Bangle Bangle (UK, Starter £31.99, trial until 12.10) → £6.40 credit per invoice, £25.59 paid, £19.20 over 3 invoices.

Money rules. A credit is CLAIMED in store_offer_credits before Shopify is called, stamped granted after; a Shopify refusal releases the claim (retry next run) and alerts the ops Telegram. A second credit for one invoice is impossible. Test stores / SHOPIFY_BILLING_TEST / development only ever get TEST credits. Amounts are always computed server-side; the admin API takes only the offer key/id, never a number. First verified live 29.09: a TEST credit on the prod smoke store succeeded (gid://shopify/AppCredit/32023052289) — the first time the Partner API credit path ever worked against the prod app.

Plan changes (owner decision: the remaining cycles follow the merchant). The subscribe route asks discountForNewSubscription for every new subscription: a native/reapprove offer attaches the REMAINING cycles to the new plan; a replacement INSIDE the trial never carries a discount — the offer converts to credits instead (convertOfferToCredit, which banks what the running schedule already delivered into delivered_before). A discounted subscription replaced outside the subscribe route (e.g. an admin custom plan) is detected by the daily run and converted to credits too.

Activation. Both the billing callback and the app_subscriptions/update webhook call onOfferSubscriptionActivated; the guarded update on pending_subscription_id lets exactly one of the pair act. It records offer_activated, sets discount_starts_at (trial end if trial days remain, else approval) and sends C5 (or C7 for a plan change).

Daily run — GET /api/cron/offer-lifecycle (vercel.json, 5 9 * * *, lock offer_lifecycle_lock, ?dryRun=1 = plan only; covered by CRON_DISABLED). Pure planner planOfferLifecycle:

  • credit mode → grant the credit for the upcoming invoice if uncovered.
  • reapprove pending → C4 reminder 3 days after the link and 3 days before the invoice, then 2 days before the invoice switch to credit and pay that invoice.
  • a discount riding a subscription → C8 seven days before the first full-price charge (computed from discount_starts_at + cycles × 30 days, never from Shopify’s countdown whose semantics are undocumented), then ended.

Merchant communication. Every step is a support-chat message from us (chat + bell + email + mirrored in the store’s Telegram topic) via postOwnerMessage in lib/server/support-chat.ts, exactly once per bell dedupe_key `offer:<offerId>:<step>[:n]`, in the dashboard language (24 locales, keys offers.msg_*):

  • C2 — offer waiting for the first plan.
  • C3 — discount ready + link.
  • C4 — reminder.
  • C5 — discount active.
  • C6 — credit added to invoice k of N.
  • C7 — moved with you to a new plan.
  • C8 — last discounted month, full price from {date}.

(C1 is the first manual message the owner sent.) The merchant’s billing card shows one of: confirm banner, “offer waiting” (+ the discounted price on each plan card), “N discounted months left” (from Shopify’s live remainingDurationInIntervals when the discount rides the subscription), or “discount as a credit on each invoice”; nothing once the offer is over.

Billing events: offer_applied, offer_activated, offer_credit_granted, offer_ended (also used for an admin withdrawal with reason cancelled_by_admin).

Admin API — /api/admin/billing:

  • get_offers
  • apply_offer `{ offerKey }`
  • grant_offer_credit `{ offerId, dryRun? }` — uses the same planner as the daily run, so it cannot grant what the run would refuse.
  • cancel_offer `{ offerId }` — a discount already on a Shopify subscription keeps running there; the operator is told.

Out of scope. The 90-day money-back guarantee is handled by hand (Partner Dashboard refund or app credit) on the merchant’s request.

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