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
trialstore pasttrial_ends_at→trial_expired. - a
trial_expiredstore whose window was extended/cleared → reopens totrial.
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, neverACTIVE) or 404 (no such shop). For a store with nosubscription_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 asubscription_id— those stay undecided. The rule is the pureshopUnavailableProvesNoSubscription(lib/billing/trial-lifecycle.ts); GraphQL failures carry their status asShopifyHttpError(lib/server/shopify-throttle.ts). Reversible: if the shop comes back with an active subscription, the nightly sweep re-reads everytrial_expiredstore and reconciles it toactive.
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_3dwhen ≤ 72 h are left,ending_1dwhen ≤ 24 h,expiredfor 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 onlyending_1d.expiredrequiressubscription_status = 'trial_expired'— the statecron/trial-expirypersists only after the live Shopify check. Atrialrow 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/unparseabletrial_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 indexnotifications (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.trialEndingwith the real days left and an embedded/planCTA. - The goodbye first reads the store’s lifetime numbers (
getMerchantMetricsfromcreated_at), its synced product count and its billing currency (cachedstores.billing_currency, else liveshopBillingPreferences) concurrently. A failed metrics read writes nothing, so the next run retries.trialOutcomepicks 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) orunused(the one blocking step — sync / theme block / unknown — and an offer to do it).templates.trialExpiredis 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_requiredwhen the consent arrives without a dialable phone) and persistsstores.continue_on_trial_burn+stores.owner_phonenext to the pending subscription (migration20260821_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 (defaultfalse= #336 behaviour). The consent exists only while the store’s trial window is open (#777). Client and route decide it with the one pure ruleresolveTrialBurnConsent(lib/billing/trial-consent.ts): consent = ticked AND notimmediateANDtrial_ends_atin the future; the phone is required only WITH that consent. After the trial the checkbox is not rendered, the client sendscontinueOnTrialBurn: false, and the route — reading the window from the store row, never the request — treats atruelike “Activate now” (writtenfalse, neverphone_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 FIXEDTRIAL_CONTINUE_ALLOWANCE(100) with consent and 0 without.resolveStoreGatepauses 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” — templatetrialCushionStarted, notification typetrial_cushion_started, dedupetrial-cushion-<trial-end>); consent OFF: the #336notifyTrialAllowanceBurned. Cushion burned →notifyTrialAllowanceBurnedwithallowance= the ceiling the merchant actually saw. Both crossings also callnotifyOwnerTrialBurn— 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/stateaddscontinueOnTrialBurn,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 foractive+ open window, with a “Free allowance X/ceiling — PAUSED” meta and the phone. owner_emailfallback —GET /api/auth/callbackfillsstores.owner_emailfrom Shopify’sshop.emailright aftercompleteShopifyInstallwhen 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:
- 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). 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):
getShopifySubscription— the truth it acts on.ACTIVE, first activation of this subscription → set status, reset usage counters + period dates, clear custom-plan fields on a standard plan, record ONEactivatedrow (withmetadata.cappedAmount). APENDINGsubscription 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’scap_raise_requestedrow with that amount) that noactivatedrow carries yet. Comparing against the last activation instead read the webhook’s row, which has no cap, as a move.- Early activation (our
subscription_createdrecord saysimmediate) inside an open trial window → close the window + the #602 bonus (see above). - On
ACTIVE→cancelOtherActiveSubscriptions(layer 2). DECLINED/EXPIRED→ drop totrial+CLEARED_CUSTOM_PLAN_FIELDS; recorddeclined.- Redirect back INTO Shopify admin — the
hostparam only when it decodes to exactlyadmin.shopify.com/store/<handle>or<handle>.myshopify.com/admin(#729:.includes()sent visitors to any domain containing those words), else derived fromshop→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 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; 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 anactivatedthe 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):
| column | becomes |
|---|---|
subscription_status | cancelled (Shopify’s own status for the subscription after uninstall) |
plan_id | trial, custom pricing cleared (CLEARED_CUSTOM_PLAN_FIELDS) |
subscription_id | kept — cancelled + an id is the record that the merchant had a plan (hasEverSubscribed), so a reinstall is never shown a fresh trial |
overage_pending | 0 — 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:
handleShopifyUninstallWebhook(lib/server/shopify.ts) —source: webhook, right afterstatus = 'uninstalled'. A failed write fails the webhook, so Shopify’s retry settles it.reconcileStoreBilling— anuninstalledstore is settled locally, without any Shopify call (source: reconcile, logbilling.reconcile.uninstalled_settled); an already-settled one is returned as-is.- The backstop for a missed webhook: the nightly
GET /api/cron/billing-overagesweepsgetActiveSubscribedStores(), which still returns anactiverow 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=1counts 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:
| before | event |
|---|---|
| no row, no history | install |
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.tsis the pure boundary that turns it into words. - a new
UpsellReasonneeds a sentence.describeNotChargeddegrades 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 toNOT_CHARGED_REASONin 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 codebundleTierCode()can mint, for each count fromMIN_TIER_COUNTtoMAX_TIER_COUNT(todayTRYVIOBUNDLE,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 setsreason: "bundle_discount_applied"and stampsevidence.matchedDiscountCodewith the winning code. A store with a nonzero bundle discount that did NOT use its code on the order falls through tobundle_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_idwhere the shopper tried on the primary AND abundle_complementtogether, and bought both. The proof of “together” istried_productsrows withsource = '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:
app_config.order_fee_enabled— shipsfalse. Merging the release starts nothing.stores.usage_terms_version >= 2— see R1 below.- 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.excludedis deliberately not in the list — its fee is 0, so there is nothing to write off.skipped_capremains 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 fromorder_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). Migration20261001_order_fees_status_skipped_currency.sqlreplaces the constraint with all seven values.ORDER_FEE_STATUSES(lib/billing/order-fees.ts) is now the one list the code may write — theOrderFeeStatustype derives from it — andorder-fee-status-check.test.tsfails the build when the newest migration’s constraint lacks one of them;scripts/real-seam/rs-669-order-fee-statuses.mjswrites 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 withignoreDuplicates: true, so a later run over the same order can never overwrite an already-written status; and the settlement sweep reads only rows withstatus = '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 —
StoreFeeResultand the nightly cron’s totals both gained awrittenOff: { 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_offdoes not exist as a value in theorder_feesstatus column; there is no migration for it. The four statuses above are unchanged in the database — “written off” lives entirely inevidence.writtenOffplus the copy layer (merchant panel, admin ledger). This keeps the terminal-status code paths (frozen at accrual, upsertignoreDuplicates, settlement readsaccruedonly) 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 readsstatus = '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_capsurvives 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.capDeferralsincrements andevidence.lastCapDeferredAtis set, so a row that carries repeatedly shows its own history. Pure helperscapDeferralCount()/isCarriedFee()(lib/billing/order-fees.ts); the DB write isdeferOrderFeesForCap()(lib/server/supabase-admin.ts), which updates onlyevidenceand is guarded by.eq("status", "accrued")so it can never touch a row that already moved on. - Fairness — oldest first.
splitByCapHeadroomcharges 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
accruedrow toexcludedviaexcludeRefundedOrderFees(). 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.
getOrderFeeTotalsForPeriodnow matchesorder_paid_at >= sinceORsettled_at >= since, so a carried fee shows up in the billing period it lands in, not only the one its order was placed in./billingrenders a “carried from <Month Year>” line under the fee’s status, anddescribeFeeTiming()(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;StoreFeeResultgained acarried: { count, amount }field andrefundedAfterAccrual, mirroring thewrittenOffreporting 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
cappedAmountwith overage. What fits is charged; the rest staysaccruedand is carried to the next cycle (see Cap carry-forward above) — it is not written off. 🚨 The order-fee path must never callmarkCapExhausted— 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
excludedand 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 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 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 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 ~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:-
Abandoned provider jobs (#219). The try-on route inserts the
tryon_provider_jobsrowpendingbefore 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 alreadyfailed— invisible to every sweep. The pass closes each one through the atomic single-winner claimclaimTryOnProviderJobAbandoned(UPDATE … WHERE id = ? AND status IN ('pending','running') RETURNING), losing safely to a late provider callback, then hands the row tofinalizeFailedStorefrontTryOnso a still-live session is claimed + refunded exactly once. Bounded to 300 rows/run, oldest first, inPromise.allbatches of 10. It deliberately does not bumpupdated_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. -
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 notgenerating/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 whileclaimTryOnSessionFailed(one-shot per session) can only refund once. Refunds go through the samedecrement_try_ons_v2RPC as everything else; itsbillable → refundedrow 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)(migration20260731b_…), andselectNoJobRefundsre-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 returnednoJobRefunded: 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 keepno-job-sweep-reach.test.tsgreen (it asserts the two agree over a ledger where the refundable rows sit outside the first 200). The response fieldnoJobCandidatesreports 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-retryis the sharedlib/server/cron-auth.ts::isAuthorizedCronRequest—getServerEnv().CRON_SECRETcompared withtimingSafeEqualover SHA-256 digests (STANDARDS §6). The other cron routes still use!==onprocess.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)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.
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. SameCRON_SECRETauth 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 realcomputeSettlementCharge#148 clamp),rate(getOverageRate),amountBeforeCredit,currency,wouldRollOver; plus a summarypredicted: { charged, rolledOver, skipped, failed, driftFlagged }. No claim, no Shopify usage record, no DB write, no notification.amountBeforeCreditis 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/billinggrant_creditaccepts an optionaldryRun: 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) — reusesrecommendUpgrade, and returnsnullwhenever 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 optionalforecastUsed: 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 abasis: "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).nullfor 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:
- The notification INSERT (
createNotification, generic body) stays first because it is the dedupe gate — thededupeKeyper threshold+period must land before anything else, and it must not wait on reads we refuse to pay on a duplicate. - The body is rewritten once, afterwards, on the freshly-created row (
createdreturnedtrue). - That rewrite happens before the
store.owner_emailcheck — a store we cannot email is exactly the one for which the bell is its only channel. A failed rewrite (wrapped insafeRead) never costs the email that follows.
Three evaluators, one engine:
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.- The nightly
api/cron/billing-overagesweeps every active store, evaluatingevaluateCapStatus(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. - 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_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 (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,eligibilityjsonb (what the admin saw at apply time).store_offer_credits— one row per discounted invoice paid by credit, unique(offer_id, cycle),statusclaimed|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 viaappSubscriptionCreate→appRecurringPricingDetails.discount = { value: { percentage: 0.2 }, durationLimitInIntervals: N }(percentage is a FRACTION; recurring line only — Shopify has no discount on usage lines). WithtrialDaysthe 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 withapplyOffer: true, which re-buys the SAME plan with the discount andreplacementBehavior: 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), thenended.
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_offersapply_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).