Skip to content

Credits wallet: one-time purchases & subscription included credits

This page describes the implemented behavior for:

  • One-time credit packs (Shopify appPurchaseOneTimeCreate) and how dollars land in shops.credit_balance
  • Subscription included credits (PAID_PLAN_INCLUDED_CREDITS_USD) on first period and each renewal while the merchant never lapses from paid
  • Wallet usage after cancel, top-ups only with an active subscription, and the “no more included credits after lapse” flag

Related: Free plan reply cap · Billing & usage plan (Phase 5 narrative) · Shopify billing & payments


Store / tableFieldRole
shopscredit_balanceDecimal USD (not cents). Paid AI wallet; decremented per reply / knowledge cost.
shopslast_paid_included_credits_period_endLast AppSubscription.currentPeriodEnd for which subscription included credits were granted (idempotency).
shopssuppress_included_credits_after_subscription_lapseOnce true, PAID_PLAN_INCLUDED_CREDITS_USD is never auto-granted again (including after resubscribe). Set when the shop leaves paid (cancel / no active subscription).
shop_one_time_chargesshopify_charge_id, amount_cents, credited_to_balanceOne-time purchase row; credited_to_balance means this row has already increased credit_balance (avoids double-credit on reconcile).

Plan config: app/lib/plans.server.js (env: PAID_PLAN_MONTHLY_PRICE, FREE_PLAN_REPLIES_CAP, PAID_PLAN_INCLUDED_CREDITS_USD, ADD_ON_CREDIT_AMOUNTS for packs).


  1. Merchant posts from Billing to POST /app/billing/buy-credits (app/routes/app.billing.buy-credits.jsx).
  2. Gate: Shopify currentAppInstallation.activeSubscriptions must be non-empty. Without an active subscription the action returns 403 — merchants cannot buy top-up credits while unsubscribed (even if they still have a leftover balance).
  3. appPurchaseOneTimeCreate uses a returnUrl like {origin}/app/billing/buy-credits/confirm?amount={usd}.
  4. After approval, Shopify redirects back with charge_id (and often without preserving amount=).

Primary path — charge_id (app/routes/app.billing.buy-credits.confirm.jsx):

  • Loader reads charge_id (numeric id or full gid://shopify/AppPurchaseOneTime/...).
  • Queries node { ... on AppPurchaseOneTime { id status price { amount currencyCode } } }.
  • Requires ACTIVE and USD price; amount must match an allowed pack from ADD_ON_CREDIT_AMOUNTS.
  • Idempotency: if a shop_one_time_charges row already exists for that Shopify id with credited_to_balance, skip.
  • Otherwise credit_balance += price (USD) and create/update rows with credited_to_balance = true.

Legacy path: if only amount= is present (older redirects), same validation and credit apply (prefer charge_id in production).

Billing loader reconcile (app/routes/app.billing.jsx)

Section titled “Billing loader reconcile (app/routes/app.billing.jsx)”

On each Billing load, the app fetches Shopify oneTimePurchases and:

  • Links a Shopify purchase to an existing row that has no shopify_charge_id but same amount and was already credited (legacy confirm).
  • Inserts missing purchases and, for ACTIVE/COMPLETED mapping, increments credit_balance in a transaction and sets credited_to_balance.

Repair pass: any COMPLETED row with shopify_charge_id set and credited_to_balance = false gets balance applied once (fixes history rows that existed without a balance bump). If a legacy confirm-only row exists for the same amount, only the flag is flipped (no second bump).

This fixes cases where Shopify showed “Credits purchased” in history but credit_balance stayed 0 because confirm never ran with a valid amount.

Combined recurring + one-time rows power the Billing Billing history table; statuses come from DB + Shopify-backed rows.


Subscription included credits (PAID_PLAN_INCLUDED_CREDITS_USD)

Section titled “Subscription included credits (PAID_PLAN_INCLUDED_CREDITS_USD)”

Configured via .envPAID_PLAN_INCLUDED_CREDITS_USD ( 0 disables grants and hides the dollar line in upgrade copy).

Grant helper: app/lib/paid-plan-included-credits.server.jsgrantPaidPlanIncludedCreditsForPeriod(shopId, periodEnd).

TriggerNotes
app/routes/app.billing.confirm.jsxAfter subscription is ACTIVE, uses GraphQL currentPeriodEnd.
webhooks.app_subscriptions.update.jsxWhen status === "ACTIVE" and payload has current_period_end.
app/routes/app.billing.jsx loaderIf Shopify reports an active subscription, uses activeSubscriptions[0].currentPeriodEnd (covers missed webhooks).
  • updateMany on shops: only updates if last_paid_included_credits_period_end is null or the incoming periodEnd (same instant = same billing bucket → at most one grant per Shopify period).
  • suppress_included_credits_after_subscription_lapse is set true when the shop becomes free: webhook (CANCELLED / DECLINED) and Billing loader when effectivePlan === "free" and shop.plan === "paid" (Shopify has no active sub).
  • While true, grantPaidPlanIncludedCreditsForPeriod returns immediatelyno further included subscription credits, even if the merchant subscribes again.
  • credit_balance is not cleared on cancel; leftover USD remains usable (see below).

If the merchant never triggers the lapse path, suppress_... stays false and each new currentPeriodEnd from Shopify can grant one included amount per period (same idempotency as above).


Chat / knowledge: “credit wallet” vs free reply cap

Section titled “Chat / knowledge: “credit wallet” vs free reply cap”

app/lib/limits.server.js

  • shopUsesCreditWallet(shop)plan === "paid" OR credit_balance > 0.
  • Wallet path: checkCreditBalance, deductCreditsForReply, and (for chat) paid LLM apply when useCreditWallet is true.
  • Free reply cap applies only when not using the wallet (pure free, zero balance).
  • After cancel, plan may be free but positive credit_balance → wallet still deducts until zero, then free-tier cap applies.

app/routes/app.knowledge.jsx uses the same wallet helper for paid-cost fetches.


  • Credits remaining shows USD when paid or when free with a positive balance (leftover after cancel).
  • Top-up buttons live under the paid subscription section; server still enforces active subscription on buy-credits.

ConcernFile
Plan env + pack listapp/lib/plans.server.js
Included credits grantapp/lib/paid-plan-included-credits.server.js
Subscription confirmapp/routes/app.billing.confirm.jsx
Buy pack + subscription checkapp/routes/app.billing.buy-credits.jsx
One-time confirmapp/routes/app.billing.buy-credits.confirm.jsx
Billing loader, reconcile, repair, included grantapp/routes/app.billing.jsx
Subscription webhookapp/routes/webhooks.app_subscriptions.update.jsx
Limits / walletapp/lib/limits.server.js
Chat orchestrationapp/lib/rag.server.js

  • credit_balance as decimal USD (replacing older cents naming in docs/code history).
  • shop_one_time_charges.credited_to_balance
  • shops.last_paid_included_credits_period_end
  • shops.suppress_included_credits_after_subscription_lapse

Run npx prisma migrate deploy in each environment after pulling schema changes.