Billing System
Store Shield uses Shopify App Billing with visitor-based usage limits. This document covers the billing architecture.
Overview
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Merchant │────▶│ Shopify Billing │────▶│ Store Shield │
│ (Subscribe) │ │ API │ │ (Activate) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Plan Tiers
| Plan | Price | Visitors/month | Features |
|---|---|---|---|
| Free | $0 | 500 | Basic protection, limited analytics |
| Starter | $9/mo | 5,000 | All protections, bot detection, analytics |
| Pro | $29/mo | 25,000 | + Spy detection, phishing scans, IP blocking |
| Enterprise | $99/mo | 100,000 | + Marketplace monitoring, takedowns, priority support |
Plan Configuration
// app/db/billing-plans.ts
export const PLANS = {
free: {
id: 'free',
name: 'Free',
price: 0,
visitorLimit: 500,
features: {
basicProtection: true,
botDetection: false,
spyDetection: false,
ipBlocking: false,
phishingScans: false,
marketplaceMonitoring: false,
takedowns: false,
analytics: 'basic', // 7 days
alertsEmail: true,
alertsWebhook: false,
prioritySupport: false
}
},
starter: {
id: 'starter',
name: 'Starter',
price: 9,
visitorLimit: 5000,
features: {
basicProtection: true,
botDetection: true,
spyDetection: false,
ipBlocking: false,
phishingScans: false,
marketplaceMonitoring: false,
takedowns: false,
analytics: 'standard', // 30 days
alertsEmail: true,
alertsWebhook: false,
prioritySupport: false
}
},
pro: {
id: 'pro',
name: 'Pro',
price: 29,
visitorLimit: 25000,
features: {
basicProtection: true,
botDetection: true,
spyDetection: true,
ipBlocking: true,
phishingScans: true,
marketplaceMonitoring: false,
takedowns: false,
analytics: 'advanced', // 90 days
alertsEmail: true,
alertsWebhook: true,
prioritySupport: false
}
},
enterprise: {
id: 'enterprise',
name: 'Enterprise',
price: 99,
visitorLimit: 100000,
features: {
basicProtection: true,
botDetection: true,
spyDetection: true,
ipBlocking: true,
phishingScans: true,
marketplaceMonitoring: true,
takedowns: true,
analytics: 'unlimited',
alertsEmail: true,
alertsWebhook: true,
prioritySupport: true
}
}
} as const;
Shopify Subscription Flow
Creating a Subscription
// app/graphQl/billing.ts
export async function createSubscription(
admin: AdminApiClient,
plan: Plan,
returnUrl: string
) {
const response = await admin.graphql(`
mutation AppSubscriptionCreate($name: String!, $returnUrl: URL!, $lineItems: [AppSubscriptionLineItemInput!]!) {
appSubscriptionCreate(
name: $name
returnUrl: $returnUrl
lineItems: $lineItems
test: ${process.env.NODE_ENV !== 'production'}
) {
appSubscription {
id
status
}
confirmationUrl
userErrors {
field
message
}
}
}
`, {
variables: {
name: `Store Shield ${plan.name}`,
returnUrl,
lineItems: [{
plan: {
appRecurringPricingDetails: {
price: { amount: plan.price, currencyCode: 'USD' },
interval: 'EVERY_30_DAYS'
}
}
}]
}
});
return response.json();
}
Handling Subscription Callback
// app/routes/billing.callback.tsx
export async function loader({ request }: LoaderArgs) {
const { admin, session } = await authenticate.admin(request);
const url = new URL(request.url);
const chargeId = url.searchParams.get('charge_id');
// Verify the subscription
const subscription = await verifySubscription(admin, chargeId);
if (subscription.status === 'ACTIVE') {
// Update merchant settings
await prisma.merchantSettings.update({
where: { shop: session.shop },
data: {
plan: subscription.plan,
billingStatus: 'active',
subscriptionId: subscription.id,
currentPeriodStart: new Date(),
currentPeriodEnd: addDays(new Date(), 30)
}
});
}
return redirect('/app');
}
Subscription Update Webhook
// app/routes/webhooks.app.subscriptions_update.tsx
export async function action({ request }: ActionArgs) {
const { payload, shop } = await authenticate.webhook(request);
await prisma.merchantSettings.update({
where: { shop },
data: {
billingStatus: payload.app_subscription.status.toLowerCase(),
plan: mapShopifyPlanToOurPlan(payload.app_subscription.name)
}
});
return new Response('OK');
}
Visitor Counting
Unique visitors are tracked in D1 for billing purposes.
Tracking Unique Visitors
// session-init-handler.js
export async function handleSessionInit(shop, data, env) {
const { visitorId } = data;
const today = new Date().toISOString().split('T')[0];
// Track daily unique (for billing)
await env.DB.prepare(`
INSERT OR IGNORE INTO DailyUniqueVisitors (shop, date, visitor_id)
VALUES (?, ?, ?)
`).bind(shop, today, visitorId).run();
// ... rest of session init
}
Counting for Billing Period
// billing-handler.js
export async function getVisitorCount(shop, startDate, endDate, env) {
const result = await env.DB.prepare(`
SELECT COUNT(DISTINCT visitor_id) as count
FROM DailyUniqueVisitors
WHERE shop = ?
AND date >= ?
AND date <= ?
`).bind(shop, startDate, endDate).first();
return result?.count || 0;
}
API Endpoint
// app/db/visitor-usage.ts
export async function getVisitorUsage(shop: string) {
const settings = await prisma.merchantSettings.findUnique({
where: { shop },
select: {
currentPeriodStart: true,
currentPeriodEnd: true,
plan: true
}
});
const startDate = settings.currentPeriodStart.toISOString().split('T')[0];
const endDate = settings.currentPeriodEnd.toISOString().split('T')[0];
// Fetch from D1 via worker
const response = await fetch(
`${EVENTS_WORKER_URL}/api/billing/visitor-count?shop=${shop}&start=${startDate}&end=${endDate}`
);
const { count } = await response.json();
const limit = PLANS[settings.plan].visitorLimit;
return {
current: count,
limit,
percentage: Math.round((count / limit) * 100),
isOverLimit: count > limit
};
}
Usage Limits
Enforcement
When visitors exceed limits, we degrade gracefully:
// app/db/billing-plans.ts
export function getAvailableFeatures(plan: string, visitorUsage: number) {
const planConfig = PLANS[plan];
const limit = planConfig.visitorLimit;
if (visitorUsage <= limit) {
return planConfig.features;
}
// Over limit - degrade to free features
return {
...PLANS.free.features,
// Keep basic protection working
basicProtection: true
};
}
Usage Alerts
export async function checkUsageAlerts(shop: string) {
const usage = await getVisitorUsage(shop);
const settings = await prisma.merchantSettings.findUnique({
where: { shop }
});
// Alert at 80% and 100%
if (usage.percentage >= 80 && !settings.alert80Sent) {
await sendUsageAlert(shop, '80%');
await prisma.merchantSettings.update({
where: { shop },
data: { alert80Sent: true }
});
}
if (usage.percentage >= 100 && !settings.alert100Sent) {
await sendUsageAlert(shop, '100%');
await prisma.merchantSettings.update({
where: { shop },
data: { alert100Sent: true }
});
}
}
Trial Period
export async function startTrial(shop: string, plan: string) {
await prisma.merchantSettings.update({
where: { shop },
data: {
plan,
billingStatus: 'trial',
trialStartedAt: new Date(),
trialEndsAt: addDays(new Date(), 7),
currentPeriodStart: new Date(),
currentPeriodEnd: addDays(new Date(), 7)
}
});
}
export async function isTrialExpired(shop: string): Promise<boolean> {
const settings = await prisma.merchantSettings.findUnique({
where: { shop }
});
if (settings.billingStatus !== 'trial') return false;
return new Date() > settings.trialEndsAt;
}
Cancellation
// app/routes/webhooks.app.uninstalled.tsx
export async function action({ request }: ActionArgs) {
const { shop } = await authenticate.webhook(request);
// Mark as cancelled
await prisma.merchantSettings.update({
where: { shop },
data: {
billingStatus: 'cancelled',
cancelledAt: new Date()
}
});
// Clean up (optional - retain data for reinstall)
// await cleanupShopData(shop);
return new Response('OK');
}
Dashboard UI
The billing page shows current plan, usage, and upgrade options:
// app/routes/app.billing.tsx
export async function loader({ request }: LoaderArgs) {
const { session } = await authenticate.admin(request);
const [settings, usage] = await Promise.all([
prisma.merchantSettings.findUnique({ where: { shop: session.shop } }),
getVisitorUsage(session.shop)
]);
return json({
currentPlan: settings.plan,
billingStatus: settings.billingStatus,
usage,
plans: PLANS
});
}