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 inshops.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
Data model (summary)
Section titled “Data model (summary)”| Store / table | Field | Role |
|---|---|---|
shops | credit_balance | Decimal USD (not cents). Paid AI wallet; decremented per reply / knowledge cost. |
shops | last_paid_included_credits_period_end | Last AppSubscription.currentPeriodEnd for which subscription included credits were granted (idempotency). |
shops | suppress_included_credits_after_subscription_lapse | Once 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_charges | shopify_charge_id, amount_cents, credited_to_balance | One-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).
One-time credit packs (buy credits)
Section titled “One-time credit packs (buy credits)”- Merchant posts from Billing to
POST /app/billing/buy-credits(app/routes/app.billing.buy-credits.jsx). - Gate: Shopify
currentAppInstallation.activeSubscriptionsmust 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). appPurchaseOneTimeCreateuses areturnUrllike{origin}/app/billing/buy-credits/confirm?amount={usd}.- After approval, Shopify redirects back with
charge_id(and often without preservingamount=).
Reflecting credits in the account
Section titled “Reflecting credits in the account”Primary path — charge_id (app/routes/app.billing.buy-credits.confirm.jsx):
- Loader reads
charge_id(numeric id or fullgid://shopify/AppPurchaseOneTime/...). - Queries
node { ... on AppPurchaseOneTime { id status price { amount currencyCode } } }. - Requires
ACTIVEand USD price; amount must match an allowed pack fromADD_ON_CREDIT_AMOUNTS. - Idempotency: if a
shop_one_time_chargesrow already exists for that Shopify id withcredited_to_balance, skip. - Otherwise
credit_balance+= price (USD) and create/update rows withcredited_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_idbut same amount and was already credited (legacy confirm). - Inserts missing purchases and, for ACTIVE/COMPLETED mapping, increments
credit_balancein a transaction and setscredited_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.
Billing history
Section titled “Billing history”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 .env → PAID_PLAN_INCLUDED_CREDITS_USD ( 0 disables grants and hides the dollar line in upgrade copy).
Grant helper: app/lib/paid-plan-included-credits.server.js → grantPaidPlanIncludedCreditsForPeriod(shopId, periodEnd).
When credits are added
Section titled “When credits are added”| Trigger | Notes |
|---|---|
app/routes/app.billing.confirm.jsx | After subscription is ACTIVE, uses GraphQL currentPeriodEnd. |
webhooks.app_subscriptions.update.jsx | When status === "ACTIVE" and payload has current_period_end. |
app/routes/app.billing.jsx loader | If Shopify reports an active subscription, uses activeSubscriptions[0].currentPeriodEnd (covers missed webhooks). |
Idempotency
Section titled “Idempotency”updateManyonshops: only updates iflast_paid_included_credits_period_endis null or ≠ the incomingperiodEnd(same instant = same billing bucket → at most one grant per Shopify period).
After cancel / non-renewal (“lapse”)
Section titled “After cancel / non-renewal (“lapse”)”suppress_included_credits_after_subscription_lapseis settruewhen the shop becomes free: webhook (CANCELLED / DECLINED) and Billing loader wheneffectivePlan === "free"andshop.plan === "paid"(Shopify has no active sub).- While
true,grantPaidPlanIncludedCreditsForPeriodreturns immediately — no further included subscription credits, even if the merchant subscribes again. credit_balanceis not cleared on cancel; leftover USD remains usable (see below).
Continuous subscription (no lapse)
Section titled “Continuous subscription (no lapse)”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"ORcredit_balance > 0.- Wallet path:
checkCreditBalance,deductCreditsForReply, and (for chat) paid LLM apply whenuseCreditWalletis true. - Free reply cap applies only when not using the wallet (pure free, zero balance).
- After cancel,
planmay befreebut positivecredit_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.
Merchant-facing Billing UI
Section titled “Merchant-facing Billing UI”- 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.
Files index
Section titled “Files index”| Concern | File |
|---|---|
| Plan env + pack list | app/lib/plans.server.js |
| Included credits grant | app/lib/paid-plan-included-credits.server.js |
| Subscription confirm | app/routes/app.billing.confirm.jsx |
| Buy pack + subscription check | app/routes/app.billing.buy-credits.jsx |
| One-time confirm | app/routes/app.billing.buy-credits.confirm.jsx |
| Billing loader, reconcile, repair, included grant | app/routes/app.billing.jsx |
| Subscription webhook | app/routes/webhooks.app_subscriptions.update.jsx |
| Limits / wallet | app/lib/limits.server.js |
| Chat orchestration | app/lib/rag.server.js |
Migrations (reference)
Section titled “Migrations (reference)”credit_balanceas decimal USD (replacing older cents naming in docs/code history).shop_one_time_charges.credited_to_balanceshops.last_paid_included_credits_period_endshops.suppress_included_credits_after_subscription_lapse
Run npx prisma migrate deploy in each environment after pulling schema changes.