Files
gh-hirefrank-hirefrank-mark…/commands/es-billing-setup.md
2025-11-29 18:45:50 +08:00

15 KiB

description
description
Interactive Polar.sh billing integration wizard. Sets up products, webhooks, database schema, and subscription middleware for Cloudflare Workers.

Billing Setup Command

<command_purpose> Guide developers through complete Polar.sh billing integration with automated code generation, database migrations, and MCP-driven product configuration. </command_purpose>

Introduction

Senior Payments Integration Engineer with expertise in Polar.sh, Cloudflare Workers, and subscription management

This command will:

  • Query Polar MCP for existing products/subscriptions
  • Generate webhook handler with signature verification
  • Create D1 database schema for customers/subscriptions
  • Generate subscription middleware for protected routes
  • Configure environment variables
  • Validate setup via Polar MCP

Prerequisites

- Cloudflare Workers project (Tanstack Start or Hono) - Polar.sh account: https://polar.sh - D1 database configured in wrangler.toml (or will create) - Polar Access Token (will guide through obtaining)

Main Tasks

1. Check Polar Account Setup

First, verify user has Polar account and products created. Use Polar MCP to check for existing products.

Immediate Actions:

<task_list>

  • Check if Polar MCP is available
  • Prompt user for Polar Access Token (if not in env)
  • Query Polar MCP for existing products
  • If no products found, guide to Polar dashboard
  • Display available products and let user select which to integrate

</task_list>

Check Polar Products:

// Query MCP for products
const products = await mcp.polar.listProducts();

if (products.length === 0) {
  console.log("⚠️  No products found in your Polar account");
  console.log("📋 Next steps:");
  console.log("1. Go to https://polar.sh/dashboard");
  console.log("2. Create your products (Pro, Enterprise, etc.)");
  console.log("3. Run this command again");
  process.exit(0);
}

// Display products
console.log("✅ Found Polar products:");
products.forEach((p, i) => {
  console.log(`${i + 1}. ${p.name} - $${p.prices[0].amount / 100}/${p.prices[0].interval}`);
  console.log(`   ID: ${p.id}`);
});

2. Generate Webhook Handler

Create comprehensive webhook handler with signature verification and all critical event handlers.

Generate File: app/routes/api/webhooks/polar.ts (Tanstack Start) or src/webhooks/polar.ts (Hono)

// Generated webhook handler
import { Polar } from '@polar-sh/sdk';

export interface Env {
  POLAR_ACCESS_TOKEN: string;
  POLAR_WEBHOOK_SECRET: string;
  DB: D1Database;
}

export async function handlePolarWebhook(
  request: Request,
  env: Env
): Promise<Response> {
  // 1. Verify webhook signature
  const signature = request.headers.get('polar-signature');
  if (!signature) {
    return new Response('Missing signature', { status: 401 });
  }

  const body = await request.text();
  const polar = new Polar({ accessToken: env.POLAR_ACCESS_TOKEN });

  let event;
  try {
    event = polar.webhooks.verify(body, signature, env.POLAR_WEBHOOK_SECRET);
  } catch (err) {
    console.error('Webhook verification failed:', err);
    return new Response('Invalid signature', { status: 401 });
  }

  // 2. Log event for debugging
  await env.DB.prepare(
    \`INSERT INTO webhook_events (id, type, data, created_at)
     VALUES (?, ?, ?, ?)\`
  ).bind(
    crypto.randomUUID(),
    event.type,
    JSON.stringify(event.data),
    new Date().toISOString()
  ).run();

  // 3. Handle event types
  try {
    switch (event.type) {
      case 'checkout.completed':
        await handleCheckoutCompleted(event.data, env);
        break;

      case 'subscription.created':
        await handleSubscriptionCreated(event.data, env);
        break;

      case 'subscription.updated':
        await handleSubscriptionUpdated(event.data, env);
        break;

      case 'subscription.canceled':
        await handleSubscriptionCanceled(event.data, env);
        break;

      case 'subscription.past_due':
        await handleSubscriptionPastDue(event.data, env);
        break;

      default:
        console.log('Unhandled event type:', event.type);
    }

    return new Response('OK', { status: 200 });
  } catch (err) {
    console.error('Webhook processing error:', err);
    return new Response('Processing failed', { status: 500 });
  }
}

// Event handlers
async function handleCheckoutCompleted(data: any, env: Env) {
  const { customer_id, product_id, metadata } = data;

  await env.DB.prepare(
    \`UPDATE users
     SET polar_customer_id = ?,
         product_id = ?,
         subscription_status = 'active',
         updated_at = ?
     WHERE id = ?\`
  ).bind(customer_id, product_id, new Date().toISOString(), metadata.user_id).run();
}

async function handleSubscriptionCreated(data: any, env: Env) {
  const { id, customer_id, product_id, status, current_period_end } = data;

  await env.DB.prepare(
    \`INSERT INTO subscriptions (id, polar_customer_id, product_id, status, current_period_end, created_at)
     VALUES (?, ?, ?, ?, ?, ?)\`
  ).bind(id, customer_id, product_id, status, current_period_end, new Date().toISOString()).run();
}

async function handleSubscriptionUpdated(data: any, env: Env) {
  const { id, status, product_id, current_period_end } = data;

  await env.DB.prepare(
    \`UPDATE subscriptions
     SET status = ?, product_id = ?, current_period_end = ?, updated_at = ?
     WHERE id = ?\`
  ).bind(status, product_id, current_period_end, new Date().toISOString(), id).run();
}

async function handleSubscriptionCanceled(data: any, env: Env) {
  const { id } = data;

  await env.DB.prepare(
    \`UPDATE subscriptions
     SET status = 'canceled', canceled_at = ?, updated_at = ?
     WHERE id = ?\`
  ).bind(new Date().toISOString(), new Date().toISOString(), id).run();
}

async function handleSubscriptionPastDue(data: any, env: Env) {
  const { id } = data;

  await env.DB.prepare(
    \`UPDATE subscriptions
     SET status = 'past_due', updated_at = ?
     WHERE id = ?\`
  ).bind(new Date().toISOString(), id).run();

  // TODO: Send payment failure notification
  console.log('Subscription past due:', id);
}

// App-specific export
export default defineEventHandler(async (event) => {
  return await handlePolarWebhook(
    event.node.req,
    event.context.cloudflare.env
  );
});

3. Generate Database Migration

Create D1 schema for users, subscriptions, and webhook event logging.

Generate File: migrations/0001_polar_billing.sql

-- Users table (add Polar fields)
CREATE TABLE IF NOT EXISTS users (
  id TEXT PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  polar_customer_id TEXT UNIQUE,
  product_id TEXT,
  subscription_status TEXT, -- 'active', 'canceled', 'past_due', NULL
  current_period_end TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

-- Subscriptions table (detailed tracking)
CREATE TABLE subscriptions (
  id TEXT PRIMARY KEY, -- Polar subscription ID
  polar_customer_id TEXT NOT NULL,
  product_id TEXT NOT NULL,
  price_id TEXT NOT NULL,
  status TEXT NOT NULL, -- 'active', 'canceled', 'past_due', 'trialing'
  current_period_start TEXT,
  current_period_end TEXT,
  canceled_at TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,

  FOREIGN KEY (polar_customer_id) REFERENCES users(polar_customer_id)
);

-- Webhook events log (debugging/auditing)
CREATE TABLE webhook_events (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,
  data TEXT NOT NULL, -- JSON blob
  created_at TEXT NOT NULL
);

-- Indexes for performance
CREATE INDEX idx_users_polar_customer ON users(polar_customer_id);
CREATE INDEX idx_users_subscription_status ON users(subscription_status);
CREATE INDEX idx_subscriptions_customer ON subscriptions(polar_customer_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE INDEX idx_webhook_events_type ON webhook_events(type);
CREATE INDEX idx_webhook_events_created ON webhook_events(created_at);

Run Migration:

wrangler d1 migrations apply DB --local
wrangler d1 migrations apply DB --remote

4. Generate Subscription Middleware

Create middleware to check subscription status on protected routes.

Generate File: app/middleware/subscription.ts (Tanstack Start) or src/middleware/subscription.ts (Hono)

// Subscription check middleware
export async function requireActiveSubscription(
  request: Request,
  env: Env,
  ctx?: ExecutionContext
) {
  // Get user ID from session (assumes auth is already set up)
  const userId = await getUserIdFromSession(request, env);

  if (!userId) {
    return new Response('Unauthorized', { status: 401 });
  }

  // Check subscription status
  const user = await env.DB.prepare(
    \`SELECT subscription_status, current_period_end, product_id
     FROM users
     WHERE id = ?\`
  ).bind(userId).first();

  if (!user) {
    return new Response('User not found', { status: 404 });
  }

  // Check if subscription is active
  if (user.subscription_status !== 'active') {
    return new Response(JSON.stringify({
      error: 'subscription_required',
      message: 'Active subscription required to access this feature',
      upgrade_url: '/pricing'
    }), {
      status: 403,
      headers: { 'Content-Type': 'application/json' }
    });
  }

  // Check if subscription hasn't expired
  if (user.current_period_end) {
    const periodEnd = new Date(user.current_period_end);
    if (periodEnd < new Date()) {
      return new Response(JSON.stringify({
        error: 'subscription_expired',
        message: 'Your subscription has expired',
        renew_url: '/pricing'
      }), {
        status: 403,
        headers: { 'Content-Type': 'application/json' }
      });
    }
  }

  // Subscription is valid, continue
  return null;
}

// Helper to get user ID from session
async function getUserIdFromSession(request: Request, env: Env): Promise<string | null> {
  // TODO: Implement based on your auth setup
  // const session = await getUserSession(event);
  // return session?.user?.id || null;

  // For better-auth:
  // const session = await auth.api.getSession({ headers: request.headers });
  // return session?.user?.id || null;

  return null; // Placeholder
}

Usage Example:

// Protected API route
export default defineEventHandler(async (event) => {
  // Check subscription
  const subscriptionCheck = await requireActiveSubscription(
    event.node.req,
    event.context.cloudflare.env
  );

  if (subscriptionCheck) {
    return subscriptionCheck; // Return 403 if no subscription
  }

  // User has active subscription, proceed
  return {
    message: 'Premium feature accessed',
    data: '...'
  };
});

5. Configure Environment Variables

Update wrangler.toml and create .dev.vars template.

Update: wrangler.toml

# Add Polar webhook secret (public, not sensitive)
[vars]
POLAR_WEBHOOK_SECRET = "whsec_..."  # Get from Polar dashboard

# D1 database (if not already configured)
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "..."  # Get from: wrangler d1 create my-app-db

Create: .dev.vars (local development)

# Polar Access Token (sensitive - DO NOT COMMIT)
POLAR_ACCESS_TOKEN=polar_at_xxxxxxxxxxxxx

# Get this from: https://polar.sh/dashboard/settings/api

Production Setup:

# Set secret in Cloudflare Workers
wrangler secret put POLAR_ACCESS_TOKEN
# Paste: polar_at_xxxxxxxxxxxxx

6. Configure Polar Webhook Endpoint

User needs to configure webhook endpoint in Polar dashboard.

Instructions for User:

## Configure Polar Webhook

1. Go to https://polar.sh/dashboard/settings/webhooks
2. Click "Add Webhook Endpoint"
3. Enter your webhook URL:
   - Development: http://localhost:3000/api/webhooks/polar
   - Production: https://yourdomain.com/api/webhooks/polar
4. Select events to send:
   ✅ checkout.completed
   ✅ subscription.created
   ✅ subscription.updated
   ✅ subscription.canceled
   ✅ subscription.past_due
5. Copy the "Webhook Secret" (whsec_...)
6. Add to wrangler.toml: POLAR_WEBHOOK_SECRET = "whsec_..."
7. Click "Create Endpoint"
8. Test with "Send Test Event" button

7. Validate Setup

Use Polar MCP to verify configuration is correct.

Validation Checklist:

// Run validation checks
const validation = {
  polarAccount: await mcp.polar.verifySetup(),
  products: await mcp.polar.listProducts(),
  webhookEvents: await mcp.polar.getWebhookEvents(),
  database: await checkDatabaseSchema(env),
  environment: await checkEnvironmentVars(env),
  webhookEndpoint: await checkWebhookHandler()
};

console.log("🔍 Polar.sh Integration Validation\n");

// 1. Polar Account
console.log("✅ Polar Account:", validation.polarAccount.status);
console.log(`   Found ${validation.products.length} products`);

// 2. Database Schema
if (validation.database.users && validation.database.subscriptions) {
  console.log("✅ Database Schema: Complete");
} else {
  console.log("❌ Database Schema: Missing tables");
  console.log("   Run: wrangler d1 migrations apply DB");
}

// 3. Environment Variables
if (validation.environment.POLAR_ACCESS_TOKEN && validation.environment.POLAR_WEBHOOK_SECRET) {
  console.log("✅ Environment Variables: Configured");
} else {
  console.log("❌ Environment Variables: Missing");
  if (!validation.environment.POLAR_ACCESS_TOKEN) {
    console.log("   Missing: POLAR_ACCESS_TOKEN");
  }
  if (!validation.environment.POLAR_WEBHOOK_SECRET) {
    console.log("   Missing: POLAR_WEBHOOK_SECRET");
  }
}

// 4. Webhook Handler
if (validation.webhookEndpoint.exists) {
  console.log("✅ Webhook Handler: Exists");
} else {
  console.log("❌ Webhook Handler: Not found");
}

console.log("\n📋 Next Steps:");
console.log("1. Configure webhook in Polar dashboard");
console.log("2. Test webhook with Polar's 'Send Test Event'");
console.log("3. Implement subscription checks on protected routes");
console.log("4. Deploy to production with: /es-deploy");

Success Criteria

Billing setup complete when:

  • Polar products queried successfully via MCP
  • Webhook handler generated with signature verification
  • Database schema created (users, subscriptions, webhook_events)
  • Subscription middleware generated
  • Environment variables configured
  • Validation passes all checks
  • User guided through Polar dashboard configuration

Output Summary

Files Created:

  • server/api/webhooks/polar.ts (or src/webhooks/polar.ts)
  • server/middleware/subscription.ts (or src/middleware/subscription.ts)
  • migrations/0001_polar_billing.sql
  • .dev.vars (template)

Files Updated:

  • wrangler.toml (added Polar vars and D1 binding)

Next Actions:

  1. Run database migration
  2. Configure webhook in Polar dashboard
  3. Test webhook with Polar simulator
  4. Add subscription checks to protected routes
  5. Deploy with /es-deploy

Notes

  • Always use Polar MCP for real-time product data
  • Test webhooks locally with Polar's test event feature
  • Store POLAR_ACCESS_TOKEN as Cloudflare secret (not in wrangler.toml)
  • Webhook endpoint must be publicly accessible (use ngrok for local testing)
  • See agents/integrations/polar-billing-specialist for detailed implementation guidance