Billing & Usage
This document is the detailed plan for Phase 5 of AppiFire AI Chat (Option A). Prerequisites: Phase 4 complete (settings screen and chat widget working end-to-end).
Current implementation:
- Recurring plans: Shopify App Pricing — Partner Dashboard handles
freeandappifire-ai-chat-plan-1; upgrade/downgrade via Shopify’s plan picker (notappSubscriptionCreate). - One-time credit packs: Billing API
appPurchaseOneTimeCreate— see Credits wallet: one-time & subscription. - Balance field: production uses
shops.credit_balance(USD decimal), notcreditBalanceCents.
The sections below are the original Phase 5 narrative (manual recurring billing). Where they mention appSubscriptionCreate or activeSubscriptions.length > 0 for paid detection, treat them as historical; the live app follows shopify-app-pricing.md and credits-wallet-one-time-and-subscription.md.
Phase 5 objective
Section titled “Phase 5 objective”Integrate Shopify billing so merchants pay for the app. Recurring plans use Shopify App Pricing; credit top-ups use the Billing API. Store subscription and one-time purchase rows in the database for status, next billing date, and billing history. Show a usage dashboard (replies, variable cost per reply, credit balance).
1. Pricing model
Section titled “1. Pricing model”There is no fixed cost per reply. Each AI reply has a variable cost (depends on model, tokens, etc.). Billing is based on AI credits in dollars: you deduct the actual cost of each reply from the user’s credit balance.
- Free plan: 50 replies per month (count-based cap). No dollar credits. After 50 replies in a month, prompt to upgrade to the paid plan. Free users can upgrade to the paid plan at any time via the Billing page.
- Paid plan: $20/month subscription. Each paid user receives $10 free AI credits included each month. Each reply consumes its actual cost from that credit balance (variable per reply). When the $10 credits are exhausted, the user must buy more credits before they can continue using AI replies. Paid users see a “Buy Credits” button on the Billing page; clicking it lets them purchase additional credit packs ($10, $20, $50, $100, $200).
| Plan | Price | Included | Add-on |
|---|---|---|---|
| Free | $0 | 50 replies/month (capped) | — |
| Paid (subscription) | $20/mo | $10 free AI credits each month (dollar balance; each reply deducts its actual cost) | Buy more credits: $10, $20, $50, $100, $200 |
Store plan constants in app/lib/plans.server.js:
// No fixed cost per reply — each reply cost is different (variable). Deduct actual cost from credit balance.
export const PLANS = { free: { name: "Free", monthlyPrice: 0, includedReplies: 50, creditsIncludedDollars: 0 }, paid: { name: "Paid", monthlyPrice: 20, includedReplies: null, creditsIncludedDollars: 10 }, // $20/mo = $10 free AI credits each month};Credit balance (paid users): Track creditBalanceCents (or equivalent) on the shop. Each billing cycle, reset or grant the subscription’s $10 (e.g. add $10 when subscription renews). When a paid user gets an AI reply, deduct the actual cost of that reply (from your usage/charge record, e.g. charged_cost from OpenRouter or your markup) from creditBalanceCents. When balance reaches zero, block further AI replies until they buy more credits or the next cycle adds the monthly $10.
Free users: Reply count per month is stored on the shop: shops.free_credit_limit (from env FREE_PLAN_REPLIES_CAP at store create, default 50) and shops.free_credits_used (incremented per AI reply, reset to 0 on the 1st of each month). When free_credits_used >= free_credit_limit, AI replies are blocked and the merchant must upgrade to the paid plan. See Free-Plan-Credits.md for details.
1.1 Add-on credit purchase options
Section titled “1.1 Add-on credit purchase options”Once the included $10 credits are exhausted, users buy more credits in fixed packs:
| Pack | Price | Credits added |
|---|---|---|
| $10 | $10 | $10 AI credits |
| $20 | $20 | $20 AI credits |
| $50 | $50 | $50 AI credits |
| $100 | $100 | $100 AI credits |
| $200 | $200 | $200 AI credits |
- For paid users only: On the billing page, show a “Buy Credits” button. When the user clicks it, they can buy more credits via pack buttons/cards for $10, $20, $50, $100, $200. On successful one-time charge, add that dollar amount to the shop’s credit balance (e.g.
creditBalanceCents += amount * 100). - If Shopify allows custom amounts: Optionally let the merchant enter a custom amount and create a one-time charge; add that amount to
creditBalanceCents.
Implementation: Use Shopify’s one-time application charge API. After the merchant approves the charge, add the purchased amount to the shop’s credit balance (dollars/cents). Each AI reply then deducts its actual cost from this balance (and from the monthly $10 when applicable). Store balance in the DB (e.g. shops.creditBalanceCents).
Markup factors (configurable via .env): Set EMBEDDING_MARKUP_FACTOR and CHAT_MARKUP_FACTOR in .env (e.g. 2.0 for 2× OpenRouter cost). These control openrouter_calls.charged_cost and what you deduct from the user’s credit balance; change them without code changes.
2. Recurring plans — Shopify App Pricing (current)
Section titled “2. Recurring plans — Shopify App Pricing (current)”See Shopify App Pricing for the full flow.
Summary:
- Plans
free($0) andappifire-ai-chat-plan-1($20/mo) are defined in the Partner Dashboard. - Install: shop starts on
plan: free; no plan picker on OAuth. - Upgrade / Change plan:
app/routes/app.billing.jsxactionintent=change_plan→ redirect to
https://admin.shopify.com/store/{store}/charges/{SHOPIFY_APP_HANDLE}/pricing_plans(target: "_top"). - After picker: Shopify redirects with
plan_handle;app.billing.confirm.jsxand the billing loader syncshops.planviaresolveSubscriptionState()/syncShopPlanFromSubscriptionState()inapp/lib/billing-plans.server.js. - Paid detection: not
activeSubscriptions.length > 0— classify by plan handle or recurring price > 0 (managed Free can be an active $0 sub). - Optional: Partner API
activeSubscriptionwhenSHOPIFY_PARTNER_*env vars are set.
Deprecated for new installs: appSubscriptionCreate in-app upgrade (conflicts with Shopify App Pricing).
2.1 Historical — manual appSubscriptionCreate (pre-migration)
Section titled “2.1 Historical — manual appSubscriptionCreate (pre-migration)”The following subsections describe the old Billing API subscription flow kept for reference.
2.2 Create the billing route (historical)
Section titled “2.2 Create the billing route (historical)”File: app/routes/app.billing.jsx
import { useLoaderData } from "react-router";import { redirect } from "react-router";import { authenticate } from "../shopify.server";import db from "../db.server";import { PLANS } from "../lib/plans.server";
export const loader = async ({ request }) => { const { session, admin } = await authenticate.admin(request);
const shop = await db.shop.findFirst({ where: { shopDomain: session.shop }, select: { id: true, plan: true, creditBalanceCents: true }, });
const startOfMonth = new Date(); startOfMonth.setDate(1); startOfMonth.setHours(0, 0, 0, 0);
const repliesUsed = await db.chatMessage.count({ where: { role: "assistant", createdAt: { gte: startOfMonth }, session: { shopId: shop.id }, }, });
const freeRepliesIncluded = PLANS.free.includedReplies; // 50 const creditBalanceDollars = shop.plan === "paid" ? ((shop.creditBalanceCents ?? 0) / 100) : 0; return { currentPlan: shop.plan, plans: PLANS, repliesUsed, freeRepliesIncluded, creditBalanceDollars, };};
export const action = async ({ request }) => { const { session, admin } = await authenticate.admin(request); const formData = await request.formData(); const targetPlan = formData.get("plan"); // "paid" (only paid tier)
const plan = PLANS[targetPlan]; if (!plan || targetPlan === "free") { return { error: "Invalid plan" }; }
const response = await admin.graphql( `#graphql mutation createSubscription($name: String!, $lineItems: [AppSubscriptionLineItemInput!]!, $returnUrl: URL!, $test: Boolean) { appSubscriptionCreate(name: $name, lineItems: $lineItems, returnUrl: $returnUrl, test: $test) { userErrors { field message } confirmationUrl appSubscription { id status } } }`, { variables: { name: `AppiFire AI Chat — ${plan.name}`, test: process.env.NODE_ENV !== "production", // test charges in dev returnUrl: `${process.env.SHOPIFY_APP_URL}/app/billing/confirm`, lineItems: [ { plan: { appRecurringPricingDetails: { price: { amount: plan.monthlyPrice, currencyCode: "USD" }, interval: "EVERY_30_DAYS", }, }, }, // Paid plan uses credit balance; each reply deducts actual cost. Sell add-on credit packs ($10, $20, $50, $100, $200) as one-time charges. // ...(plan.creditsIncluded ? [{ plan: { appUsagePricingDetails: { ... } } }] : []), ], }, } );
const { data } = await response.json(); const { confirmationUrl, userErrors } = data.appSubscriptionCreate;
if (userErrors?.length) { return { error: userErrors[0].message }; }
// Redirect merchant to Shopify billing confirmation page return redirect(confirmationUrl);};
export default function BillingPage() { const { currentPlan, plans, repliesUsed, freeRepliesIncluded, creditBalanceDollars } = useLoaderData(); const isFree = currentPlan === "free"; const usagePct = isFree ? Math.min(100, Math.round((repliesUsed / freeRepliesIncluded) * 100)) : null;
return ( <s-page heading="Billing & Usage"> <s-section heading="Current usage this month"> {isFree ? ( <> <s-progress-bar value={usagePct} /> <s-paragraph> {repliesUsed} of {freeRepliesIncluded} replies used ({usagePct}%) </s-paragraph> </> ) : ( <s-paragraph> AI credit balance: <strong>${creditBalanceDollars.toFixed(2)}</strong>. Each reply uses a variable amount of credits based on actual cost. </s-paragraph> )} </s-section>
<s-section heading="Your plan"> <s-paragraph> Current plan: <strong>{plans[currentPlan]?.name ?? "Free"}</strong> </s-paragraph> </s-section>
<s-section heading="Subscribe"> {currentPlan === "paid" ? ( <s-badge status="success">You're on the $20/mo plan — $10 free AI credits included each month. Each reply deducts its actual cost. Use the "Buy Credits" button below to purchase more credits when you need them.</s-badge> ) : ( <s-card> <s-heading>Upgrade to Paid — $20/mo</s-heading> <s-paragraph> Free users can upgrade to the paid plan at any time. $10 free AI credits included each month. Each reply costs a variable amount (deducted from your balance). Purchase add-on credits when exhausted. </s-paragraph> <form method="post"> <input type="hidden" name="plan" value="paid" /> <s-button type="submit" variant="primary">Upgrade Now</s-button> </form> </s-card> )} </s-section>
{currentPlan === "paid" && ( <s-section heading="Buy Credits"> <s-paragraph>Click below to buy more credits. Amount is added to your balance; each reply deducts its actual cost.</s-paragraph> <s-stack> {[10, 20, 50, 100, 200].map((amount) => ( <form key={amount} method="post" action="/app/billing/buy-credits"> <input type="hidden" name="amount" value={amount} /> <s-button type="submit" variant="secondary">${amount} credits</s-button> </form> ))} </s-stack> </s-section> )} </s-page> );}(Implement the POST /app/billing/buy-credits action to create a one-time charge for the chosen amount; after Shopify confirms, add that dollar amount to shop.creditBalanceCents. If Shopify supports custom amounts, you can add an optional “Custom amount” field.)
2.3 Billing confirmation callback
Section titled “2.3 Billing confirmation callback”File: app/routes/app.billing.confirm.jsx
After the merchant confirms on Shopify’s billing page, they’re redirected back. Update the shop’s plan:
import { redirect } from "react-router";import { authenticate } from "../shopify.server";import db from "../db.server";
export const loader = async ({ request }) => { const { session, admin } = await authenticate.admin(request); const url = new URL(request.url); const chargeId = url.searchParams.get("charge_id");
if (!chargeId) return redirect("/app/billing");
// Fetch subscription status and currentPeriodEnd from Shopify (for storing in ShopSubscription) const res = await admin.graphql( `#graphql query getSubscription($id: ID!) { node(id: $id) { ... on AppSubscription { id status name currentPeriodEnd } } }`, { variables: { id: `gid://shopify/AppSubscription/${chargeId}` } } ); const { data } = await res.json(); const sub = data?.node;
if (sub?.status === "ACTIVE") { await db.shop.updateMany({ where: { shopDomain: session.shop }, data: { plan: "paid", subscriptionId: sub.id, subscriptionLineItemId: /* from lineItems if needed */ }, }); // Upsert ShopSubscription: shopId, shopifySubscriptionId (sub.id), planName (sub.name), status, currentPeriodEnd (sub.currentPeriodEnd), startedAt }
return redirect("/app/billing");};2.4 Credit balance and add-on credits
Section titled “2.4 Credit balance and add-on credits”Paid plan: Each subscription is $20/month and includes $10 free AI credits per month. There is no fixed cost per reply — each reply has a variable cost. Store the balance in shops.creditBalanceCents. When a paid user gets an AI reply, deduct the actual cost of that reply (e.g. from openrouter_calls.charged_cost or your computed charge) from creditBalanceCents. When the balance reaches zero, block further AI replies until they buy more credits or the next billing cycle adds the monthly $10.
Add-on credit packs: When credits are exhausted, the user buys more via one-time charges: $10, $20, $50, $100, $200. On successful one-time charge, add that dollar amount to creditBalanceCents (e.g. creditBalanceCents += amount * 100). No reply-count conversion — credits are in dollars and each reply deducts its actual cost.
Note: Store subscriptionLineItemId (and optionally subscriptionId) when the subscription is confirmed (e.g. for future usage-based line items if needed). Each billing cycle, grant the monthly $10 to the shop’s creditBalanceCents (e.g. on subscription renewal webhook or at cycle start).
3. Subscription cancelled webhook
Section titled “3. Subscription cancelled webhook”When a merchant cancels, downgrade them to the free plan.
File: app/routes/webhooks.app_subscriptions.update.jsx
First, add the webhook to shopify.app.toml:
[[webhooks.subscriptions]]topics = [ "app_subscriptions/update" ]uri = "/webhooks/app_subscriptions/update"Then create the route:
import { authenticate } from "../shopify.server";import db from "../db.server";
export const action = async ({ request }) => { const { shop, payload, topic } = await authenticate.webhook(request); console.log(`Received ${topic} for ${shop}`);
if (payload.status === "CANCELLED" || payload.status === "DECLINED") { await db.shop.updateMany({ where: { shopDomain: shop }, data: { plan: "free", subscriptionLineItemId: null }, }); }
return new Response();};4. Usage dashboard
Section titled “4. Usage dashboard”Add a usage section to the billing page (or create app/routes/app.usage.jsx) showing:
// Query for usage statsconst thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const stats = await db.openRouterCall.aggregate({ where: { shopId: shop.id, createdAt: { gte: thirtyDaysAgo } }, _sum: { openrouterCost: true, chargedCost: true, tokens: true }, _count: { id: true },});
const topQuestions = await db.chatMessage.findMany({ where: { role: "user", session: { shopId: shop.id }, createdAt: { gte: thirtyDaysAgo } }, orderBy: { createdAt: "desc" }, take: 10, select: { messageText: true, createdAt: true },});Display in the UI:
- Free: Total replies this month (vs 50 included).
- Paid: AI credit balance (dollars); total replies this month; cost per reply varies.
- OpenRouter cost vs charged cost (your margin)
- Total tokens used
- Last 10 customer questions
Because openrouter_calls logs all OpenRouter usage, this includes:
- Customer Chat calls (live replies from the widget)
- Product Knowledge calls (embeddings generated during product sync & ingestion)
- Future Store Knowledge calls (policies/pages ingestion)
4.1 Daily spend limit
Section titled “4.1 Daily spend limit”Not implemented. There is no daily spend cap. Paid plan: allow a reply whenever creditBalanceCents > 0. The last reply may bring the balance below $0 (soft cap). No Settings field or enforcement for a daily spend limit.
4.2 Email alert on minimum balance
Section titled “4.2 Email alert on minimum balance”Merchants can enable Send email alert on minimum balance and set a minimum balance threshold in Settings → Credits & spending. For free plan: alert when remaining replies (50 − used this month) fall at or below the threshold. For paid plan: alert when remaining AI credit balance (dollars) falls at or below the threshold (e.g. “Alert when balance ≤ $2.00”). Send one email so they can top up.
Implementation:
- Free: After each reply, remaining = 50 − repliesUsedThisMonth. Paid: remaining =
shop.creditBalanceCents / 100(dollars). - If
shop.emailAlertOnMinBalanceis true and the threshold is set and remaining ≤ threshold, trigger an email (e.g. queue a job or call a mailer). To avoid spamming, send at most one alert per day per shop (e.g. tracklastLowBalanceAlertAton the shop and skip if already sent today). - Optional: add
lastLowBalanceAlertAt(DateTime, nullable) to theshopstable for deduplication. Store threshold in cents for paid (e.g.minBalanceForAlertCents) so it compares withcreditBalanceCents.
5. Prisma schema additions
Section titled “5. Prisma schema additions”Shop (existing + billing fields):
model Shop { // ... existing fields ... subscriptionId String? @map("subscription_id") subscriptionLineItemId String? @map("subscription_line_item_id") creditBalanceCents Int @default(0) @map("credit_balance_cents") // Paid: AI credits in cents; each reply deducts actual cost // Credits & spending (Phase 4): emailAlertOnMinBalance, minBalanceForAlertReplies, minBalanceForAlertCents, lastLowBalanceAlertAt (daily spend limit not used) shopSubscriptions ShopSubscription[] shopOneTimeCharges ShopOneTimeCharge[]}New: store recurring subscription and one-time payments in the database.
5.1 Recurring subscription record (ShopSubscription)
Section titled “5.1 Recurring subscription record (ShopSubscription)”Stores the current recurring subscription per shop so you can show status, plan name, and next billing date (from Shopify). One active row per shop (or use the latest and show history if you add more later).
model ShopSubscription { id String @id @default(uuid()) shopId String @unique @map("shop_id") // one active subscription per shop shopifySubscriptionId String @map("shopify_subscription_id") // GID from Shopify (e.g. gid://shopify/AppSubscription/123) planName String @map("plan_name") // e.g. "Paid" status String @map("status") // ACTIVE, CANCELLED, DECLINED, FROZEN, PENDING currentPeriodEnd DateTime? @map("current_period_end") // when Shopify will bill again (from subscription) startedAt DateTime @map("started_at") // when subscription became active cancelledAt DateTime? @map("cancelled_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
@@map("shop_subscriptions")}- When to create/update: In
app.billing.confirm.jsxafter confirming the charge: fetch the subscription from Shopify (you already havecharge_id→ AppSubscription), then upsertShopSubscriptionwithstatus,currentPeriodEnd(from Shopify’scurrentPeriodEnd),startedAt,planName. - When to update: In
webhooks.app_subscriptions.update: onapp_subscriptions/updatepayload, upsert byshopIdandshopifySubscriptionId; setstatus,currentPeriodEnd,cancelledAtso the UI and “next billing date” stay in sync.
5.2 One-time charge records (ShopOneTimeCharge)
Section titled “5.2 One-time charge records (ShopOneTimeCharge)”Stores each one-time credit purchase so you can show “Billing history” (date, amount, status).
model ShopOneTimeCharge { id String @id @default(uuid()) shopId String @map("shop_id") shopifyChargeId String? @map("shopify_charge_id") // Shopify app purchase one-time ID (for reference) amountCents Int @map("amount_cents") // e.g. 1000 = $10 creditsAddedCents Int @map("credits_added_cents") // same as amount for credit packs status String @map("status") // PENDING, COMPLETED, DECLINED, REFUNDED createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
@@map("shop_one_time_charges")}- When to create: When the merchant is redirected to the one-time charge confirmation (e.g. you create the charge in
app.billing.buy-credits.jsxand redirect). You can insert a PENDING row when creating the charge (if you have a charge ID from Shopify), or insert COMPLETED in the confirm route when they return. StoreshopifyChargeIdwhen available (e.g. from the create mutation response or from reconciliation) so duplicates are detected by ID only (see section 8.6). - When to update: In
app.billing.buy-credits.confirm.jsx: when Shopify redirects back after approval, insert a COMPLETED row (or update PENDING → COMPLETED) withamountCents,creditsAddedCents, and add the same amount toshop.creditBalanceCents. Optionally storeshopifyChargeIdif returned in the callback. - Reconciliation: The Billing loader also fetches Shopify
oneTimePurchasesand inserts any missing rows keyed byshopify_charge_idonly (section 8.6); do not match by amount and date to avoid treating two different same-day purchases as one.
Run:
npx prisma migrate dev --name add_shop_subscription_and_one_time_charges6. Storing recurring subscription in the database
Section titled “6. Storing recurring subscription in the database”- Billing confirm (
app.billing.confirm.jsx): After confirming the subscription with Shopify’s GraphQLnode(id: AppSubscription/charge_id):- Update
shop.plan = "paid",shop.subscriptionId,shop.subscriptionLineItemId, and add initial credits tocreditBalanceCents(as today). - Upsert
ShopSubscription:shopId,shopifySubscriptionId(fromsub.id),planName(e.g."Paid"),status(e.g.sub.status),currentPeriodEnd(from Shopify’s subscriptioncurrentPeriodEndif available),startedAt= now.
- Update
- Webhook
app_subscriptions/update: On payload:- Find shop by
shopDomain(from webhook). - Upsert
ShopSubscriptionbyshopId(and optionallyshopifySubscriptionIdfrom payload): setstatus,currentPeriodEnd, andcancelledAtwhen status is CANCELLED/DECLINED. - If status is CANCELLED or DECLINED, set
shop.plan = "free"and clearshop.subscriptionId/subscriptionLineItemId(as today).
- Find shop by
The Billing page uses Shopify as the source of truth for paid vs free (see section 8.1). The webhook and billing confirm keep one source of truth in the DB for “is the shop on paid?” (Shop.plan) and a display-friendly record for “when will they be billed again?” (ShopSubscription.currentPeriodEnd).
7. Storing one-time charges in the database
Section titled “7. Storing one-time charges in the database”- Buy-credits confirm (
app.billing.buy-credits.confirm.jsx): When the merchant returns from Shopify’s one-time charge approval:- Insert a ShopOneTimeCharge row:
shopId,amountCents(from URL param or session),creditsAddedCents= same,status = "COMPLETED". Optionally storeshopifyChargeIdif Shopify returns it in the callback. - Add
creditsAddedCentstoshop.creditBalanceCents.
- Insert a ShopOneTimeCharge row:
- Optionally: when creating the one-time charge in
app.billing.buy-credits.jsx, insert a PENDING row (if the Shopify API returns a charge ID before redirect). Then in the confirm route, update that row to COMPLETED instead of inserting a new one.
8. Billing & subscription info screen
Section titled “8. Billing & subscription info screen”On the Billing page (or a dedicated “Subscription” / “Billing history” section), display:
Recurring subscription (paid shops):
- Plan name (from
ShopSubscription.planNameorPLANS[shop.plan].name). - Status: Active / Cancelled / etc. (from
ShopSubscription.status). - Subscription started: Always show when the subscription started (
ShopSubscription.startedAt). If not in DB, show ”—” or fetch from Shopify. - Next billing date / Period end: Format
ShopSubscription.currentPeriodEnd(e.g. “You’ll be charged again on March 22, 2025” or “Current period ends March 22, 2025”). IfcurrentPeriodEndis null, show ”—”. - Change plan (primary): Change plan opens Shopify’s hosted plan picker (pick Free to downgrade). Implemented as
intent=change_planon Billing. - Cancel subscription (legacy):
appSubscriptionCancelmay still exist in code for edge cases; downgrade UX prefers the plan picker per shopify-app-pricing.md.
Paid plan only — do not show upgrade:
- Once the merchant has a paid plan, do not show the “Upgrade to paid plan” button or the Subscribe/upgrade card. That section is shown only for free users.
One-time purchases (credit packs):
- A table or list of
ShopOneTimeChargerows for this shop: Date, Amount (e.g. $10), Credits added, Status. Order bycreatedAtdesc, limit 30. Show this Billing history section for both free and paid users whenever there are one-time charges (do not hide when plan is free).
Free plan:
- Show “Current plan: Free” and reply usage (as today). Show the Upgrade to paid plan section (card + button). Billing history (one-time charges) is still shown when the merchant has any such purchases.
Implementation: in app.billing.jsx loader, load shop (include subscriptionId for paid so cancel can use it), ShopSubscription (findFirst where shopId), and ShopOneTimeCharge (findMany where shopId, orderBy createdAt desc, take 30). Pass to the UI and render the sections above. Action: handle intent “cancel” by calling appSubscriptionCancel(id: shop.subscriptionId); return success or error so the UI can show a message; the webhook will sync plan to free.
8.1 Source of truth: Shopify (paid vs free)
Section titled “8.1 Source of truth: Shopify (paid vs free)”Problem: DB-only plan state can lag webhooks. Also, with Shopify App Pricing, a Free plan may appear as an active $0 subscription — treating “any active subscription” as paid is wrong.
Solution (current): resolveSubscriptionState() in app/lib/billing-plans.server.js:
plan_handlequery param after plan picker (if present).- Partner API
activeSubscription(optional, env-configured). - Admin API
activeSubscriptionswith line-item recurring price and name/handle classification viaclassifyAdminSubscription().
Paid only when handle is appifire-ai-chat-plan-1 or recurring amount > $0. Then syncShopPlanFromSubscriptionState() updates shops.plan, shop_subscriptions, and included credits.
When effective plan is free but DB still says paid, set plan = free, clear subscription IDs, and set suppress_included_credits_after_subscription_lapse = true.
8.2 List of all subscriptions from Shopify
Section titled “8.2 List of all subscriptions from Shopify”- Loader: Always fetch
currentAppInstallation.activeSubscriptions(id, name, status, currentPeriodEnd) and passactiveSubscriptionsto the UI. - UI: For paid shops, show a section “Your subscriptions (from Shopify)” with a table: Name, Status, Current period ends, Action (Cancel button per row).
- Cancel: Each row’s Cancel form posts
intent=cancelandsubscriptionId=<sub.id>. The action usesformData.get("subscriptionId")when present; otherwise it falls back toshop.subscriptionId, thenShopSubscription.shopifySubscriptionId, then fetches from Shopify. Normalize the id to GID (gid://shopify/AppSubscription/{id}if not already) before callingappSubscriptionCancel(id).
This lets the merchant see all active app subscriptions and cancel any one of them.
8.3 Cancel flow: redirect and success banner
Section titled “8.3 Cancel flow: redirect and success banner”- Action: On successful cancel, return
redirect("/app/billing?cancel_success=1")instead of{ cancelSuccess: true }. The page then reloads with fresh loader data; Shopify returns no active subscriptions, so plan shows Free and the subscription list (and Cancel buttons) disappear. - Loader: Read
cancel_success=1from the request URL and passcancelSuccess: trueto the UI. - UI: Show a success banner when
cancelSuccessis true (e.g. “Subscription cancelled. You will keep access until the end of the current billing period.”). Optionally useuseEffect+history.replaceStateto remove thecancel_successquery param so a manual refresh does not show the banner again. - Cancellation email: Shopify controls whether a cancellation email is sent; the app cannot trigger it. The success banner can note that the merchant can check Settings → Billing in Shopify admin.
8.4 Display edge cases
Section titled “8.4 Display edge cases”- Status: If
ShopSubscription.statusis missing or"UNKNOWN", display as “Active” on the Billing page (since they are on the paid plan). - Next billing date: If
ShopSubscription.currentPeriodEndis null, compute an approximate next billing date as startedAt + 30 days and show it as “Next billing (approx.): <date> (30 days from start).” When Shopify providescurrentPeriodEnd(e.g. from webhook or confirm), store it and show the exact date instead. - Upgrade section: The “Subscribe” / “Upgrade to paid plan” block is rendered only when
currentPlan === "free". When the plan is paid, that section is not shown at all.
8.5 Resolving subscription ID for cancel
Section titled “8.5 Resolving subscription ID for cancel”When the cancel action runs, the subscription GID may be missing in the DB. Resolve it in this order:
- Form:
formData.get("subscriptionId")(when cancelling from the “Your subscriptions” list). - Shop:
shop.subscriptionId. - DB:
ShopSubscription.shopifySubscriptionIdfor the shop. - Shopify API: Call
currentAppInstallation { activeSubscriptions { id } }and use the first subscription’sid.
If the value is numeric (or not a GID), format it as gid://shopify/AppSubscription/{id} before calling appSubscriptionCancel(id).
8.6 One-time charge reconciliation with Shopify
Section titled “8.6 One-time charge reconciliation with Shopify”Problem: A merchant may complete a one-time credit purchase and receive the Shopify email, but the app’s confirm callback might not run (e.g. redirect URL lost query params, or they closed the tab). Then there is no row in ShopOneTimeCharge and the purchase does not appear in Billing history.
Solution: On each Billing page load, reconcile one-time purchases with Shopify so missing charges are added to the DB for display.
- Fetch from Shopify: Call
currentAppInstallation { oneTimePurchases(first: 50) { nodes { id name status price { amount currencyCode } createdAt } } }. - Duplicate check by
shopify_charge_idonly: For each Shopify purchase, skip if we already have aShopOneTimeChargerow withshopifyChargeIdequal to the purchase’sid. Do not match by amount and date (same amount on the same day can be two different purchases). - Insert missing: For each purchase not found by
shopifyChargeId, insert a row:shopId,shopifyChargeId = node.id,amountCents(fromprice.amount× 100),creditsAddedCents(same),status(map Shopify ACTIVE → COMPLETED, DECLINED/EXPIRED → DECLINED, else PENDING),createdAt = node.createdAt(preserve Shopify’s date). - Re-fetch list: If any rows were inserted, re-query
ShopOneTimeCharge(orderBy createdAt desc, take 30) and use that for the Billing history table. - No balance update on backfill: When inserting a missing charge, do not add to
shop.creditBalanceCents. This avoids double-crediting if the confirm had updated the balance but failed to insert the row. Reconciliation only fixes the display/history.
8.7 Credits added success banner
Section titled “8.7 Credits added success banner”- When the merchant returns from the buy-credits confirm flow, the app redirects to
/app/billing?credits_added=1. - Loader: Read
credits_added=1from the request URL and passcreditsAdded: trueto the UI. - UI: Show a success banner when
creditsAddedis true (e.g. “Credits added to your balance. See Billing history below for the transaction.”).
9. Optional: Plan definitions in the database
Section titled “9. Optional: Plan definitions in the database”Right now Free and Paid are defined in code (app/lib/plans.server.js). If you want to change pricing or add plans without a code deploy, you can add a plans table (e.g. Plan with id, name, monthlyPriceCents, includedReplies, creditsIncludedCents, isActive) and seed Free/Paid. The billing page and limits would then read from the DB. This is optional; the plan above works with plans in code.
Checklist
Section titled “Checklist”Shopify App Pricing (current):
-
SHOPIFY_PLAN_HANDLES,mapPlanHandleToInternal()—app/lib/plans.server.js - Plan picker redirect,
resolveSubscriptionState,syncShopPlanFromSubscriptionState,merchantHasPaidPlan—app/lib/billing-plans.server.js - Optional Partner API client —
app/lib/partner-api.server.js+.env.sample - Upgrade / Change plan → hosted plan picker (no
appSubscriptionCreate) —app/routes/app.billing.jsx - Post-picker sync (
plan_handle, legacycharge_id) —app/routes/app.billing.confirm.jsx - Paid detection by handle / price > 0 (not subscription count) — billing loader + webhook
- Buy credits gated on paid plan —
app/routes/app.billing.buy-credits.jsx
Phase 5 (implemented; see credits-wallet):
- Plans, billing routes, nav, webhooks, Prisma
ShopSubscription/ShopOneTimeCharge,shops.credit_balance - One-time packs via
appPurchaseOneTimeCreate; confirm + reconcile + repair pass - Included credits
PAID_PLAN_INCLUDED_CREDITS_USD; suppress after lapse - Billing UI: usage, history, upgrade (free only), top-ups (paid),
plan_selected/credits_addedbanners - Limits: free reply cap; paid wallet + soft balance
- Optional: Plan definitions in DB
- Real low-balance email delivery (stub today; see billing-remaining-tasks)