Skip to content

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 free and appifire-ai-chat-plan-1; upgrade/downgrade via Shopify’s plan picker (not appSubscriptionCreate).
  • One-time credit packs: Billing API appPurchaseOneTimeCreate — see Credits wallet: one-time & subscription.
  • Balance field: production uses shops.credit_balance (USD decimal), not creditBalanceCents.

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.


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


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. 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) and appifire-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.jsx action intent=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.jsx and the billing loader sync shops.plan via resolveSubscriptionState() / syncShopPlanFromSubscriptionState() in app/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 activeSubscription when SHOPIFY_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.

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

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 ”—”.
  • Change plan (primary): Change plan opens Shopify’s hosted plan picker (pick Free to downgrade). Implemented as intent=change_plan on Billing.
  • Cancel subscription (legacy): appSubscriptionCancel may 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 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: 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:

  1. plan_handle query param after plan picker (if present).
  2. Partner API activeSubscription (optional, env-configured).
  3. Admin API activeSubscriptions with line-item recurring price and name/handle classification via classifyAdminSubscription().

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


Shopify App Pricing (current):

  • SHOPIFY_PLAN_HANDLES, mapPlanHandleToInternal()app/lib/plans.server.js
  • Plan picker redirect, resolveSubscriptionState, syncShopPlanFromSubscriptionState, merchantHasPaidPlanapp/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, legacy charge_id) — app/routes/app.billing.confirm.jsx
  • Paid detection by handle / price > 0 (not subscription count) — billing loader + webhook
  • Buy credits gated on paid planapp/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_added banners
  • Limits: free reply cap; paid wallet + soft balance
  • Optional: Plan definitions in DB
  • Real low-balance email delivery (stub today; see billing-remaining-tasks)

Next: launch-and-publishing-guide.md