Option A — Phase 5: 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).
Phase 5 objective
Section titled “Phase 5 objective”Integrate Shopify’s Billing API so merchants pay for the app. Store recurring subscriptions and one-time credit purchases in the database so you can show subscription status, next billing date, and billing history. Show a usage dashboard so merchants see how many replies were used, how much they cost (variable per reply), and what they were charged.
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. Shopify Billing API — recurring subscription
Section titled “2. Shopify Billing API — recurring subscription”2.1 How it works
Section titled “2.1 How it works”- Shopify charges the merchant automatically via their Shopify account.
- You call
appSubscriptionCreateto request a subscription; Shopify redirects the merchant to a confirmation page. - After confirmation, Shopify fires the
app_subscriptions/updatewebhook; you receive the confirmation. - Paid plan uses a dollar credit balance; each reply deducts its actual cost (no fixed cost per reply).
2.2 Create the billing route
Section titled “2.2 Create the billing route”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 to paid plan ($20/mo)</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 ”—”. - Cancel subscription: A Cancel subscription button that calls Shopify’s
appSubscriptionCancelmutation with the shop’ssubscriptionId(GID). After cancellation, Shopify sends theapp_subscriptions/updatewebhook with status CANCELLED; your webhook sets the shop to free and updatesShopSubscription. The merchant can refresh the Billing page to see the updated state.
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: If the Billing page uses only the database (shops.plan, shop_subscriptions) to decide paid vs free, then after a merchant cancels in Shopify the webhook might not have run (or failed), so the DB still shows paid and the UI shows “Plan: Paid” with no Cancel button (because the subscription list from Shopify is empty). That is confusing.
Solution: Use Shopify as the single source of truth for whether the shop is paid or free on the Billing page.
- Loader: Call Shopify
currentAppInstallation { activeSubscriptions { id name status currentPeriodEnd } }first. - Plan:
effectivePlan = activeSubscriptions.length > 0 ? "paid" : "free". Do not derive plan fromshops.planorshop_subscriptionsfor this screen. - Sync DB to match Shopify:
- When Shopify returns no active subscriptions: set
shop.plan = "free", clearshop.subscriptionIdandsubscriptionLineItemId, and updateShopSubscription(if any) tostatus = "CANCELLED",cancelledAt = now. This keeps the DB in sync even when the webhook did not run. - When Shopify returns at least one active subscription and
shop.plan !== "paid": setshop.plan = "paid"and store the first subscription’sidinshop.subscriptionId.
- When Shopify returns no active subscriptions: set
This way, after a merchant cancels, the next load sees an empty list from Shopify → plan shows Free, subscription block and Cancel buttons disappear, and the DB is updated to free/cancelled.
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”-
app/lib/plans.server.jscreated with plans: free (50 replies/month), paid ($20/mo with $10 free AI credits each month); no fixed cost per reply — each reply deducts its actual cost from credit balance. -
app/routes/app.billing.jsxcreated; shows current plan; free = replies used vs 50 with option to upgrade to paid; paid = credit balance (dollars) plus “Buy Credits” button ($10, $20, $50, $100, $200). -
app/routes/app.billing.confirm.jsxcreated; updatesshop.planto"paid", grants initial $10 tocreditBalanceCents, and upsertsShopSubscription(shopifySubscriptionId, planName, status, currentPeriodEnd, startedAt) from Shopify subscription data. -
appSubscriptionCreatemutation tested in dev (test mode); confirmation flow works end-to-end. - “Billing” link added to nav in
app.jsx. -
app/routes/webhooks.app_subscriptions.update.jsxcreated; on CANCELLED/DECLINED downgrades shop to free and updatesShopSubscription(status, cancelledAt); on ACTIVE/update upsertsShopSubscriptionwith currentPeriodEnd so “next billing date” stays correct. -
app_subscriptions/updateadded toshopify.app.tomland deployed. - Prisma schema: Shop has
subscriptionId,subscriptionLineItemId,creditBalanceCents; new modelsShopSubscriptionandShopOneTimeChargewith relations to Shop; migration run. - Recurring subscription in DB: Billing confirm and app_subscriptions/update webhook upsert
ShopSubscriptionso status and next billing date are stored and displayable. - One-time charges in DB: Buy-credits confirm inserts (or updates)
ShopOneTimeChargewith amount, credits added, status COMPLETED, and adds credits toshop.creditBalanceCents. - Billing & subscription info on screen: Billing page (or section) shows for paid: plan name, subscription status, subscription started and period end / next billing date (from
ShopSubscription); Cancel subscription button (callsappSubscriptionCancel); and a Billing history list of one-time credit purchases (date, amount, credits added, status) fromShopOneTimeCharge. Billing history is shown for all plans (free and paid) when there are one-time charges. Upgrade to paid plan section and button are shown only for free users; once the user has a paid plan, that section must not appear. - Source of truth (Shopify): Billing page derives paid vs free from Shopify
currentAppInstallation.activeSubscriptions(paid if list non-empty, free if empty). DB is synced to match (set shop to free/cancelled when Shopify returns no active subscriptions; set shop to paid when list is non-empty and DB was free). - List of subscriptions: Billing page fetches and displays all active subscriptions from Shopify in a “Your subscriptions (from Shopify)” table with Name, Status, Current period ends, and a Cancel button per row; cancel action accepts optional
subscriptionIdfrom the form. - Cancel flow: On successful cancel, action redirects to
/app/billing?cancel_success=1so the page reloads; plan shows Free and Cancel buttons disappear. Success banner shown whencancel_success=1; optionally clear the URL param after showing. - Display edge cases: Status UNKNOWN or empty shown as “Active”; when
currentPeriodEndis null, show next billing as startedAt + 30 days (approx.). Subscription ID for cancel resolved from form, then shop, then ShopSubscription, then Shopify API; normalize to GID beforeappSubscriptionCancel. - One-time charge reconciliation: On Billing load, fetch Shopify
currentAppInstallation.oneTimePurchases(first: 50); for each purchase, if noShopOneTimeChargewith thatshopifyChargeIdexists, insert a row (shopifyChargeId, amountCents, creditsAddedCents, status from Shopify, createdAt from Shopify). Use onlyshopify_charge_idfor duplicate detection (do not match by amount and date). Do not add to credit balance when backfilling. - Credits added banner: When URL has
credits_added=1, loader passescreditsAdded: trueand UI shows a success banner; Billing history shows the new transaction. - Usage stats: free = replies used; paid = credit balance (dollars); replies used; visible on billing page.
- Limit enforcement in
app/lib/limits.server.js: free = 50 replies/month; paid = allow reply only ifcreditBalanceCents≥ estimated/actual cost; deduct actual cost per reply fromcreditBalanceCents. - Add-on credit packs: $10, $20, $50, $100, $200; one-time charge → store in
ShopOneTimeChargeand add amount tocreditBalanceCents. - Daily spend limit: not implemented; paid plan allows reply when balance > $0 (last reply may go below $0).
- Email alert on minimum balance: free = remaining replies ≤ threshold; paid = credit balance (dollars) ≤ threshold; send email at most once per day;
lastLowBalanceAlertAton Shop. - Optional: Plan definitions in DB (e.g.
Plantable) for configurable pricing without code deploy.