Skip to content

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).


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.


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).
PlanPriceIncludedAdd-on
Free$050 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.

Once the included $10 credits are exhausted, users buy more credits in fixed packs:

PackPriceCredits 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”
  • Shopify charges the merchant automatically via their Shopify account.
  • You call appSubscriptionCreate to request a subscription; Shopify redirects the merchant to a confirmation page.
  • After confirmation, Shopify fires the app_subscriptions/update webhook; you receive the confirmation.
  • Paid plan uses a dollar credit balance; each reply deducts its actual cost (no fixed cost per reply).

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.)

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");
};

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).


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();
};

Add a usage section to the billing page (or create app/routes/app.usage.jsx) showing:

// Query for usage stats
const 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)

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.


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:

  1. Free: After each reply, remaining = 50 − repliesUsedThisMonth. Paid: remaining = shop.creditBalanceCents / 100 (dollars).
  2. If shop.emailAlertOnMinBalance is 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. track lastLowBalanceAlertAt on the shop and skip if already sent today).
  3. Optional: add lastLowBalanceAlertAt (DateTime, nullable) to the shops table for deduplication. Store threshold in cents for paid (e.g. minBalanceForAlertCents) so it compares with creditBalanceCents.

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.jsx after confirming the charge: fetch the subscription from Shopify (you already have charge_id → AppSubscription), then upsert ShopSubscription with status, currentPeriodEnd (from Shopify’s currentPeriodEnd), startedAt, planName.
  • When to update: In webhooks.app_subscriptions.update: on app_subscriptions/update payload, upsert by shopId and shopifySubscriptionId; set status, currentPeriodEnd, cancelledAt so 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.jsx and 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. Store shopifyChargeId when 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) with amountCents, creditsAddedCents, and add the same amount to shop.creditBalanceCents. Optionally store shopifyChargeId if returned in the callback.
  • Reconciliation: The Billing loader also fetches Shopify oneTimePurchases and inserts any missing rows keyed by shopify_charge_id only (section 8.6); do not match by amount and date to avoid treating two different same-day purchases as one.

Run:

Terminal window
npx prisma migrate dev --name add_shop_subscription_and_one_time_charges

6. 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 GraphQL node(id: AppSubscription/charge_id):
    • Update shop.plan = "paid", shop.subscriptionId, shop.subscriptionLineItemId, and add initial credits to creditBalanceCents (as today).
    • Upsert ShopSubscription: shopId, shopifySubscriptionId (from sub.id), planName (e.g. "Paid"), status (e.g. sub.status), currentPeriodEnd (from Shopify’s subscription currentPeriodEnd if available), startedAt = now.
  • Webhook app_subscriptions/update: On payload:
    • Find shop by shopDomain (from webhook).
    • Upsert ShopSubscription by shopId (and optionally shopifySubscriptionId from payload): set status, currentPeriodEnd, and cancelledAt when status is CANCELLED/DECLINED.
    • If status is CANCELLED or DECLINED, set shop.plan = "free" and clear shop.subscriptionId / subscriptionLineItemId (as today).

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 store shopifyChargeId if Shopify returns it in the callback.
    • Add creditsAddedCents to shop.creditBalanceCents.
  • 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.

On the Billing page (or a dedicated “Subscription” / “Billing history” section), display:

Recurring subscription (paid shops):

  • Plan name (from ShopSubscription.planName or PLANS[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”). If currentPeriodEnd is null, show ”—”.
  • Cancel subscription: A Cancel subscription button that calls Shopify’s appSubscriptionCancel mutation with the shop’s subscriptionId (GID). After cancellation, Shopify sends the app_subscriptions/update webhook with status CANCELLED; your webhook sets the shop to free and updates ShopSubscription. 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 ShopOneTimeCharge rows for this shop: Date, Amount (e.g. $10), Credits added, Status. Order by createdAt desc, 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.

  1. Loader: Call Shopify currentAppInstallation { activeSubscriptions { id name status currentPeriodEnd } } first.
  2. Plan: effectivePlan = activeSubscriptions.length > 0 ? "paid" : "free". Do not derive plan from shops.plan or shop_subscriptions for this screen.
  3. Sync DB to match Shopify:
    • When Shopify returns no active subscriptions: set shop.plan = "free", clear shop.subscriptionId and subscriptionLineItemId, and update ShopSubscription (if any) to status = "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": set shop.plan = "paid" and store the first subscription’s id in shop.subscriptionId.

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 pass activeSubscriptions to 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=cancel and subscriptionId=<sub.id>. The action uses formData.get("subscriptionId") when present; otherwise it falls back to shop.subscriptionId, then ShopSubscription.shopifySubscriptionId, then fetches from Shopify. Normalize the id to GID (gid://shopify/AppSubscription/{id} if not already) before calling appSubscriptionCancel(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=1 from the request URL and pass cancelSuccess: true to the UI.
  • UI: Show a success banner when cancelSuccess is true (e.g. “Subscription cancelled. You will keep access until the end of the current billing period.”). Optionally use useEffect + history.replaceState to remove the cancel_success query 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.
  • Status: If ShopSubscription.status is missing or "UNKNOWN", display as “Active” on the Billing page (since they are on the paid plan).
  • Next billing date: If ShopSubscription.currentPeriodEnd is 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 provides currentPeriodEnd (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.

When the cancel action runs, the subscription GID may be missing in the DB. Resolve it in this order:

  1. Form: formData.get("subscriptionId") (when cancelling from the “Your subscriptions” list).
  2. Shop: shop.subscriptionId.
  3. DB: ShopSubscription.shopifySubscriptionId for the shop.
  4. Shopify API: Call currentAppInstallation { activeSubscriptions { id } } and use the first subscription’s id.

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.

  1. Fetch from Shopify: Call currentAppInstallation { oneTimePurchases(first: 50) { nodes { id name status price { amount currencyCode } createdAt } } }.
  2. Duplicate check by shopify_charge_id only: For each Shopify purchase, skip if we already have a ShopOneTimeCharge row with shopifyChargeId equal to the purchase’s id. Do not match by amount and date (same amount on the same day can be two different purchases).
  3. Insert missing: For each purchase not found by shopifyChargeId, insert a row: shopId, shopifyChargeId = node.id, amountCents (from price.amount × 100), creditsAddedCents (same), status (map Shopify ACTIVE → COMPLETED, DECLINED/EXPIRED → DECLINED, else PENDING), createdAt = node.createdAt (preserve Shopify’s date).
  4. Re-fetch list: If any rows were inserted, re-query ShopOneTimeCharge (orderBy createdAt desc, take 30) and use that for the Billing history table.
  5. 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.
  • When the merchant returns from the buy-credits confirm flow, the app redirects to /app/billing?credits_added=1.
  • Loader: Read credits_added=1 from the request URL and pass creditsAdded: true to the UI.
  • UI: Show a success banner when creditsAdded is 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.


  • app/lib/plans.server.js created 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.jsx created; 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.jsx created; updates shop.plan to "paid", grants initial $10 to creditBalanceCents, and upserts ShopSubscription (shopifySubscriptionId, planName, status, currentPeriodEnd, startedAt) from Shopify subscription data.
  • appSubscriptionCreate mutation 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.jsx created; on CANCELLED/DECLINED downgrades shop to free and updates ShopSubscription (status, cancelledAt); on ACTIVE/update upserts ShopSubscription with currentPeriodEnd so “next billing date” stays correct.
  • app_subscriptions/update added to shopify.app.toml and deployed.
  • Prisma schema: Shop has subscriptionId, subscriptionLineItemId, creditBalanceCents; new models ShopSubscription and ShopOneTimeCharge with relations to Shop; migration run.
  • Recurring subscription in DB: Billing confirm and app_subscriptions/update webhook upsert ShopSubscription so status and next billing date are stored and displayable.
  • One-time charges in DB: Buy-credits confirm inserts (or updates) ShopOneTimeCharge with amount, credits added, status COMPLETED, and adds credits to shop.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 (calls appSubscriptionCancel); and a Billing history list of one-time credit purchases (date, amount, credits added, status) from ShopOneTimeCharge. 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 subscriptionId from the form.
  • Cancel flow: On successful cancel, action redirects to /app/billing?cancel_success=1 so the page reloads; plan shows Free and Cancel buttons disappear. Success banner shown when cancel_success=1; optionally clear the URL param after showing.
  • Display edge cases: Status UNKNOWN or empty shown as “Active”; when currentPeriodEnd is 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 before appSubscriptionCancel.
  • One-time charge reconciliation: On Billing load, fetch Shopify currentAppInstallation.oneTimePurchases(first: 50); for each purchase, if no ShopOneTimeCharge with that shopifyChargeId exists, insert a row (shopifyChargeId, amountCents, creditsAddedCents, status from Shopify, createdAt from Shopify). Use only shopify_charge_id for 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 passes creditsAdded: true and 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 if creditBalanceCents ≥ estimated/actual cost; deduct actual cost per reply from creditBalanceCents.
  • Add-on credit packs: $10, $20, $50, $100, $200; one-time charge → store in ShopOneTimeCharge and add amount to creditBalanceCents.
  • 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; lastLowBalanceAlertAt on Shop.
  • Optional: Plan definitions in DB (e.g. Plan table) for configurable pricing without code deploy.

Next: Option-A-Phase-6-Launch-and-Publishing-Guide.md