Skip to main content

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

PlanPriceVisitors/monthFeatures
Free$0500Basic protection, limited analytics
Starter$9/mo5,000All protections, bot detection, analytics
Pro$29/mo25,000+ Spy detection, phishing scans, IP blocking
Enterprise$99/mo100,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
});
}