diff --git a/docs/ai/plans/stripe-plugin-plan.md b/docs/ai/plans/stripe-plugin-plan.md
new file mode 100644
index 000000000..4e51a486e
--- /dev/null
+++ b/docs/ai/plans/stripe-plugin-plan.md
@@ -0,0 +1,75 @@
+# Stripe Subscription Plugin Implementation Plan
+
+## Overview
+A SonicJS plugin that handles Stripe subscription lifecycle via webhooks and exposes subscription status to the rest of the system. Tracks: GitHub Issue #760.
+
+## Requirements
+- [x] Stripe webhook endpoint with signature verification
+- [x] Subscriptions database table
+- [x] Checkout session creation for authenticated users
+- [x] Subscription status API
+- [x] Hook integration for subscription lifecycle events
+- [x] Admin UI for subscription management
+- [x] `requireSubscription()` middleware
+
+## Technical Approach
+
+### Architecture
+Follows the existing SonicJS plugin pattern (PluginBuilder SDK). Modeled after the security-audit-plugin structure with separated routes, services, types, and components.
+
+### File Changes
+| File | Action | Description |
+|------|--------|-------------|
+| `src/plugins/core-plugins/stripe-plugin/index.ts` | Create | Plugin factory and exports |
+| `src/plugins/core-plugins/stripe-plugin/manifest.json` | Create | Plugin metadata |
+| `src/plugins/core-plugins/stripe-plugin/types.ts` | Create | TypeScript types |
+| `src/plugins/core-plugins/stripe-plugin/routes/api.ts` | Create | API routes (webhook, checkout, status) |
+| `src/plugins/core-plugins/stripe-plugin/routes/admin.ts` | Create | Admin dashboard routes |
+| `src/plugins/core-plugins/stripe-plugin/services/subscription-service.ts` | Create | DB operations for subscriptions |
+| `src/plugins/core-plugins/stripe-plugin/services/stripe-api.ts` | Create | Stripe API wrapper |
+| `src/plugins/core-plugins/stripe-plugin/middleware/require-subscription.ts` | Create | Subscription gate middleware |
+| `src/plugins/core-plugins/stripe-plugin/components/subscriptions-page.ts` | Create | Admin subscriptions list |
+| `src/plugins/core-plugins/index.ts` | Modify | Add stripe plugin exports |
+| `src/app.ts` | Modify | Register stripe plugin routes |
+
+### Database Changes
+New `subscriptions` table via D1 migration in bootstrap:
+- id, userId, stripeCustomerId, stripeSubscriptionId, stripePriceId
+- status, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd
+- createdAt, updatedAt
+
+### API Endpoints
+- `POST /api/stripe/webhook` — Stripe webhook (no auth, signature verified)
+- `POST /api/stripe/create-checkout-session` — Create checkout (auth required)
+- `GET /api/stripe/subscription` — Current user subscription (auth required)
+- `GET /admin/plugins/stripe` — Admin subscriptions dashboard (admin only)
+- `GET /api/stripe/subscriptions` — List all subscriptions (admin only)
+
+## Implementation Steps
+1. Create types and manifest
+2. Implement subscription service (DB layer)
+3. Implement Stripe API wrapper
+4. Implement webhook route with signature verification
+5. Implement API routes (checkout, status)
+6. Implement admin routes and dashboard component
+7. Implement requireSubscription() middleware
+8. Wire up plugin in index.ts, core-plugins/index.ts, app.ts
+9. Verify TypeScript compilation
+
+## Testing Strategy
+
+### Unit Tests
+- Webhook signature verification
+- Subscription service CRUD operations
+- Event type routing
+- Middleware subscription check logic
+
+### E2E Tests
+- Webhook endpoint responds correctly to signed/unsigned requests
+- Admin page renders subscription list
+- API returns subscription status for authenticated user
+
+## Risks & Considerations
+- Webhook must use raw body for signature verification (not parsed JSON)
+- Stripe SDK not available in CF Workers — use `fetch` directly against Stripe API
+- Must handle idempotent webhook delivery (Stripe retries)
diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts
index 21321900d..be3167ecd 100644
--- a/packages/core/src/app.ts
+++ b/packages/core/src/app.ts
@@ -41,6 +41,7 @@ import { aiSearchPlugin } from './plugins/core-plugins/ai-search-plugin'
import { createMagicLinkAuthPlugin } from './plugins/available/magic-link-auth'
import { securityAuditPlugin } from './plugins/core-plugins/security-audit-plugin'
import { securityAuditMiddleware } from './plugins/core-plugins/security-audit-plugin'
+import { stripePlugin } from './plugins/core-plugins/stripe-plugin'
import { pluginMenuMiddleware } from './middleware/plugin-menu'
import cachePlugin from './plugins/cache'
import { faviconSvg } from './assets/favicon'
@@ -261,6 +262,13 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp {
// Test cleanup routes (only for development/test environments)
app.route('/', testCleanupRoutes)
+ // Plugin routes - Stripe (subscriptions, webhook, checkout)
+ if (stripePlugin.routes && stripePlugin.routes.length > 0) {
+ for (const route of stripePlugin.routes) {
+ app.route(route.path, route.handler as any)
+ }
+ }
+
// Plugin routes - Email
if (emailPlugin.routes && emailPlugin.routes.length > 0) {
for (const route of emailPlugin.routes) {
diff --git a/packages/core/src/plugins/core-plugins/index.ts b/packages/core/src/plugins/core-plugins/index.ts
index 8fbb4fe61..a212e8757 100644
--- a/packages/core/src/plugins/core-plugins/index.ts
+++ b/packages/core/src/plugins/core-plugins/index.ts
@@ -32,6 +32,7 @@ export { securityAuditPlugin, createSecurityAuditPlugin } from './security-audit
export { SecurityAuditService, BruteForceDetector, securityAuditMiddleware } from './security-audit-plugin'
export { userProfilesPlugin, createUserProfilesPlugin, defineUserProfile, getUserProfileConfig } from './user-profiles'
export type { ProfileFieldDefinition, UserProfileConfig } from './user-profiles'
+export { stripePlugin, createStripePlugin, SubscriptionService, StripeAPI, requireSubscription } from './stripe-plugin'
// Core plugins list - now imported from auto-generated registry
export const CORE_PLUGIN_IDS = [
@@ -53,7 +54,8 @@ export const CORE_PLUGIN_IDS = [
'oauth-providers',
'global-variables',
'security-audit',
- 'user-profiles'
+ 'user-profiles',
+ 'stripe'
] as const
export type CorePluginNames = (typeof CORE_PLUGIN_IDS)[number]
diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/components/subscriptions-page.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/components/subscriptions-page.ts
new file mode 100644
index 000000000..2e490b56c
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/stripe-plugin/components/subscriptions-page.ts
@@ -0,0 +1,152 @@
+import type { Subscription, SubscriptionStats, SubscriptionStatus } from '../types'
+
+export function renderSubscriptionsPage(
+ subscriptions: (Subscription & { userEmail?: string })[],
+ stats: SubscriptionStats,
+ filters: { status?: string; page: number; totalPages: number }
+): string {
+ return `
+
+
+
+ ${statsCard('Total', stats.total, 'text-gray-700')}
+ ${statsCard('Active', stats.active, 'text-green-600')}
+ ${statsCard('Trialing', stats.trialing, 'text-blue-600')}
+ ${statsCard('Past Due', stats.pastDue, 'text-yellow-600')}
+ ${statsCard('Canceled', stats.canceled, 'text-red-600')}
+
+
+
+
+
+
+
+
+
+
+
+
+ | User |
+ Status |
+ Price ID |
+ Current Period |
+ Cancel at End |
+ Stripe |
+
+
+
+ ${subscriptions.length === 0
+ ? '| No subscriptions found |
'
+ : subscriptions.map(renderRow).join('')
+ }
+
+
+
+ ${renderPagination(filters.page, filters.totalPages, filters.status)}
+
+
+ `
+}
+
+function statsCard(label: string, value: number, colorClass: string): string {
+ return `
+
+ `
+}
+
+function statusOption(value: string, current?: string): string {
+ const selected = value === current ? 'selected' : ''
+ const label = value.replace('_', ' ').replace(/\b\w/g, c => c.toUpperCase())
+ return ``
+}
+
+function statusBadge(status: SubscriptionStatus): string {
+ const colors: Record = {
+ active: 'bg-green-100 text-green-800',
+ trialing: 'bg-blue-100 text-blue-800',
+ past_due: 'bg-yellow-100 text-yellow-800',
+ canceled: 'bg-red-100 text-red-800',
+ unpaid: 'bg-orange-100 text-orange-800',
+ paused: 'bg-gray-100 text-gray-800',
+ incomplete: 'bg-gray-100 text-gray-500',
+ incomplete_expired: 'bg-red-100 text-red-500'
+ }
+ const color = colors[status] || 'bg-gray-100 text-gray-800'
+ const label = status.replace('_', ' ')
+ return `${label}`
+}
+
+function formatDate(timestamp: number): string {
+ if (!timestamp) return '-'
+ return new Date(timestamp * 1000).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ })
+}
+
+function renderRow(sub: Subscription & { userEmail?: string }): string {
+ return `
+
+ |
+ ${sub.userEmail || sub.userId}
+ ${sub.stripeCustomerId}
+ |
+ ${statusBadge(sub.status)} |
+ ${sub.stripePriceId} |
+
+ ${formatDate(sub.currentPeriodStart)} - ${formatDate(sub.currentPeriodEnd)}
+ |
+
+ ${sub.cancelAtPeriodEnd
+ ? 'Yes'
+ : 'No'
+ }
+ |
+
+
+ View in Stripe
+
+ |
+
+ `
+}
+
+function renderPagination(page: number, totalPages: number, status?: string): string {
+ if (totalPages <= 1) return ''
+
+ const params = status ? `&status=${status}` : ''
+ return `
+
+
+ Page ${page} of ${totalPages}
+
+
+ ${page > 1
+ ? `
Previous`
+ : ''
+ }
+ ${page < totalPages
+ ? `
Next`
+ : ''
+ }
+
+
+ `
+}
diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/index.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/index.ts
new file mode 100644
index 000000000..6cdff7ed9
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/stripe-plugin/index.ts
@@ -0,0 +1,61 @@
+import { PluginBuilder } from '../../sdk/plugin-builder'
+import type { Plugin } from '../../types'
+import { stripeAdminRoutes } from './routes/admin'
+import { stripeApiRoutes } from './routes/api'
+
+export function createStripePlugin(): Plugin {
+ const builder = PluginBuilder.create({
+ name: 'stripe',
+ version: '1.0.0-beta.1',
+ description: 'Stripe subscription management with webhook handling, checkout sessions, and subscription gating'
+ })
+
+ builder.metadata({
+ author: { name: 'SonicJS Team' },
+ license: 'MIT'
+ })
+
+ // Admin dashboard — subscription management
+ builder.addRoute('/admin/plugins/stripe', stripeAdminRoutes as any, {
+ description: 'Stripe subscriptions admin dashboard',
+ requiresAuth: true,
+ priority: 50
+ })
+
+ // API routes — webhook, checkout, subscription status
+ builder.addRoute('/api/stripe', stripeApiRoutes as any, {
+ description: 'Stripe API endpoints (webhook, checkout, subscription)',
+ requiresAuth: false, // Webhook route handles its own auth via signature
+ priority: 50
+ })
+
+ // Admin menu item
+ builder.addMenuItem('Stripe', '/admin/plugins/stripe', {
+ icon: ``,
+ order: 75
+ })
+
+ // Lifecycle hooks
+ builder.lifecycle({
+ install: async () => {
+ console.log('[Stripe] Plugin installed')
+ },
+ activate: async () => {
+ console.log('[Stripe] Plugin activated')
+ },
+ deactivate: async () => {
+ console.log('[Stripe] Plugin deactivated')
+ },
+ uninstall: async () => {
+ console.log('[Stripe] Plugin uninstalled')
+ }
+ })
+
+ return builder.build()
+}
+
+export const stripePlugin = createStripePlugin()
+export { SubscriptionService } from './services/subscription-service'
+export { StripeAPI } from './services/stripe-api'
+export { requireSubscription } from './middleware/require-subscription'
+export default stripePlugin
diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/manifest.json b/packages/core/src/plugins/core-plugins/stripe-plugin/manifest.json
new file mode 100644
index 000000000..26b71b56e
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/stripe-plugin/manifest.json
@@ -0,0 +1,56 @@
+{
+ "id": "stripe",
+ "name": "Stripe Subscriptions",
+ "version": "1.0.0-beta.1",
+ "description": "Stripe subscription management with webhook handling, checkout sessions, and subscription gating",
+ "author": "SonicJS Team",
+ "category": "payments",
+ "icon": "credit-card",
+ "core": true,
+ "dependencies": [],
+ "settings": {
+ "schema": {
+ "stripeSecretKey": {
+ "type": "password",
+ "label": "Stripe Secret Key",
+ "description": "Your Stripe secret API key (sk_...)",
+ "required": true
+ },
+ "stripeWebhookSecret": {
+ "type": "password",
+ "label": "Webhook Signing Secret",
+ "description": "Stripe webhook endpoint signing secret (whsec_...)",
+ "required": true
+ },
+ "stripePriceId": {
+ "type": "text",
+ "label": "Default Price ID",
+ "description": "Default Stripe Price ID for checkout sessions (price_...)",
+ "required": false
+ },
+ "successUrl": {
+ "type": "text",
+ "label": "Checkout Success URL",
+ "description": "URL to redirect to after successful checkout",
+ "default": "/admin/dashboard"
+ },
+ "cancelUrl": {
+ "type": "text",
+ "label": "Checkout Cancel URL",
+ "description": "URL to redirect to if checkout is cancelled",
+ "default": "/admin/dashboard"
+ }
+ }
+ },
+ "permissions": {
+ "stripe:manage": "Manage Stripe settings and view all subscriptions",
+ "stripe:view": "View subscription status"
+ },
+ "hooks": {
+ "stripe:subscription.created": "Fired when a new subscription is created",
+ "stripe:subscription.updated": "Fired when a subscription is updated",
+ "stripe:subscription.deleted": "Fired when a subscription is cancelled/deleted",
+ "stripe:payment.succeeded": "Fired when a payment succeeds",
+ "stripe:payment.failed": "Fired when a payment fails"
+ }
+}
diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/middleware/require-subscription.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/middleware/require-subscription.ts
new file mode 100644
index 000000000..2eeba43cf
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/stripe-plugin/middleware/require-subscription.ts
@@ -0,0 +1,41 @@
+import type { Context, Next } from 'hono'
+import type { Bindings, Variables } from '../../../../app'
+import { SubscriptionService } from '../services/subscription-service'
+
+/**
+ * Middleware that gates access to users with an active or trialing subscription.
+ * Must be used after requireAuth() middleware.
+ *
+ * Usage:
+ * import { requireSubscription } from '../plugins/core-plugins/stripe-plugin'
+ * app.use('/premium/*', requireAuth(), requireSubscription())
+ */
+export function requireSubscription() {
+ return async (c: Context<{ Bindings: Bindings; Variables: Variables }>, next: Next) => {
+ const user = c.get('user')
+ if (!user) {
+ return c.json({ error: 'Authentication required' }, 401)
+ }
+
+ const db = c.env.DB
+ const subscriptionService = new SubscriptionService(db)
+
+ try {
+ await subscriptionService.ensureTable()
+ const subscription = await subscriptionService.getByUserId(user.userId)
+
+ if (!subscription || (subscription.status !== 'active' && subscription.status !== 'trialing')) {
+ return c.json({
+ error: 'Active subscription required',
+ subscription: subscription ? { status: subscription.status } : null
+ }, 403)
+ }
+
+ // Proceed with the request
+ return next()
+ } catch (error) {
+ console.error('[Stripe] Error checking subscription:', error)
+ return c.json({ error: 'Subscription check failed' }, 500)
+ }
+ }
+}
diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/routes/admin.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/routes/admin.ts
new file mode 100644
index 000000000..bf0874488
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/stripe-plugin/routes/admin.ts
@@ -0,0 +1,81 @@
+import { Hono } from 'hono'
+import { requireAuth } from '../../../../middleware'
+import { PluginService } from '../../../../services'
+import { SubscriptionService } from '../services/subscription-service'
+import { renderSubscriptionsPage } from '../components/subscriptions-page'
+import type { Bindings, Variables } from '../../../../app'
+import type { StripePluginSettings, SubscriptionStatus } from '../types'
+import { DEFAULT_SETTINGS } from '../types'
+
+const adminRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>()
+
+adminRoutes.use('*', requireAuth())
+
+// Check admin role
+adminRoutes.use('*', async (c, next) => {
+ const user = c.get('user')
+ if (user?.role !== 'admin') {
+ return c.text('Access denied', 403)
+ }
+ return next()
+})
+
+async function getSettings(db: any): Promise {
+ try {
+ const pluginService = new PluginService(db)
+ const plugin = await pluginService.getPlugin('stripe')
+ if (plugin?.settings) {
+ const settings = typeof plugin.settings === 'string' ? JSON.parse(plugin.settings) : plugin.settings
+ return { ...DEFAULT_SETTINGS, ...settings }
+ }
+ } catch { /* ignore */ }
+ return DEFAULT_SETTINGS
+}
+
+// Subscriptions dashboard
+adminRoutes.get('/', async (c) => {
+ const db = c.env.DB
+ const subscriptionService = new SubscriptionService(db)
+ await subscriptionService.ensureTable()
+
+ const page = parseInt(c.req.query('page') || '1')
+ const limit = 50
+ const statusFilter = c.req.query('status') as SubscriptionStatus | undefined
+
+ const [{ subscriptions, total }, stats] = await Promise.all([
+ subscriptionService.list({ status: statusFilter, page, limit }),
+ subscriptionService.getStats()
+ ])
+
+ const totalPages = Math.ceil(total / limit)
+
+ const html = renderSubscriptionsPage(subscriptions as any, stats, {
+ status: statusFilter,
+ page,
+ totalPages
+ })
+
+ return c.html(html)
+})
+
+// Save settings
+adminRoutes.post('/settings', async (c) => {
+ try {
+ const body = await c.req.json()
+ const db = c.env.DB
+
+ await db.prepare(`
+ UPDATE plugins
+ SET settings = ?,
+ updated_at = unixepoch()
+ WHERE id = 'stripe'
+ `).bind(JSON.stringify(body)).run()
+
+ return c.json({ success: true })
+ } catch (error) {
+ console.error('Error saving Stripe settings:', error)
+ return c.json({ success: false, error: 'Failed to save settings' }, 500)
+ }
+})
+
+export { adminRoutes as stripeAdminRoutes }
diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/routes/api.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/routes/api.ts
new file mode 100644
index 000000000..16e91e508
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/stripe-plugin/routes/api.ts
@@ -0,0 +1,283 @@
+import { Hono } from 'hono'
+import { requireAuth } from '../../../../middleware'
+import { PluginService } from '../../../../services'
+import { SubscriptionService } from '../services/subscription-service'
+import { StripeAPI } from '../services/stripe-api'
+import type { Bindings, Variables } from '../../../../app'
+import type {
+ StripePluginSettings,
+ StripeEvent,
+ StripeSubscriptionObject,
+ StripeCheckoutSession,
+ StripeInvoice,
+ SubscriptionStatus,
+ SubscriptionFilters
+} from '../types'
+import { DEFAULT_SETTINGS } from '../types'
+
+const apiRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>()
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+async function getSettings(db: any): Promise {
+ try {
+ const pluginService = new PluginService(db)
+ const plugin = await pluginService.getPlugin('stripe')
+ if (plugin?.settings) {
+ const settings = typeof plugin.settings === 'string' ? JSON.parse(plugin.settings) : plugin.settings
+ return { ...DEFAULT_SETTINGS, ...settings }
+ }
+ } catch { /* ignore */ }
+ return DEFAULT_SETTINGS
+}
+
+function mapStripeStatus(status: string): SubscriptionStatus {
+ const map: Record = {
+ active: 'active',
+ canceled: 'canceled',
+ past_due: 'past_due',
+ trialing: 'trialing',
+ unpaid: 'unpaid',
+ paused: 'paused',
+ incomplete: 'incomplete',
+ incomplete_expired: 'incomplete_expired'
+ }
+ return map[status] || 'incomplete'
+}
+
+// ============================================================================
+// Webhook — No auth, verified by Stripe signature
+// ============================================================================
+
+apiRoutes.post('/webhook', async (c) => {
+ const db = c.env.DB
+ const settings = await getSettings(db)
+
+ if (!settings.stripeWebhookSecret) {
+ return c.json({ error: 'Webhook secret not configured' }, 500)
+ }
+
+ // Must read raw body for signature verification
+ const rawBody = await c.req.text()
+ const sigHeader = c.req.header('stripe-signature') || ''
+
+ const stripeApi = new StripeAPI(settings.stripeSecretKey)
+ const isValid = await stripeApi.verifyWebhookSignature(rawBody, sigHeader, settings.stripeWebhookSecret)
+
+ if (!isValid) {
+ return c.json({ error: 'Invalid signature' }, 400)
+ }
+
+ const event: StripeEvent = JSON.parse(rawBody)
+ const subscriptionService = new SubscriptionService(db)
+ await subscriptionService.ensureTable()
+
+ try {
+ switch (event.type) {
+ case 'customer.subscription.created': {
+ const sub = event.data.object as unknown as StripeSubscriptionObject
+ const userId = sub.metadata?.sonicjs_user_id || await subscriptionService.getUserIdByStripeCustomer(sub.customer) || ''
+
+ await subscriptionService.create({
+ userId,
+ stripeCustomerId: sub.customer,
+ stripeSubscriptionId: sub.id,
+ stripePriceId: sub.items.data[0]?.price.id || '',
+ status: mapStripeStatus(sub.status),
+ currentPeriodStart: sub.current_period_start,
+ currentPeriodEnd: sub.current_period_end,
+ cancelAtPeriodEnd: sub.cancel_at_period_end
+ })
+
+ console.log(`[Stripe] Subscription created: ${sub.id}`)
+ break
+ }
+
+ case 'customer.subscription.updated': {
+ const sub = event.data.object as unknown as StripeSubscriptionObject
+ await subscriptionService.updateByStripeId(sub.id, {
+ status: mapStripeStatus(sub.status),
+ stripePriceId: sub.items.data[0]?.price.id || undefined,
+ currentPeriodStart: sub.current_period_start,
+ currentPeriodEnd: sub.current_period_end,
+ cancelAtPeriodEnd: sub.cancel_at_period_end
+ })
+
+ console.log(`[Stripe] Subscription updated: ${sub.id} -> ${sub.status}`)
+ break
+ }
+
+ case 'customer.subscription.deleted': {
+ const sub = event.data.object as unknown as StripeSubscriptionObject
+ await subscriptionService.updateByStripeId(sub.id, {
+ status: 'canceled'
+ })
+
+ console.log(`[Stripe] Subscription deleted: ${sub.id}`)
+ break
+ }
+
+ case 'checkout.session.completed': {
+ const session = event.data.object as unknown as StripeCheckoutSession
+ const userId = session.metadata?.sonicjs_user_id
+
+ // Link the Stripe customer to the user if we have a userId and subscription
+ if (userId && session.subscription) {
+ const existing = await subscriptionService.getByStripeSubscriptionId(session.subscription)
+ if (existing && !existing.userId) {
+ await subscriptionService.updateByStripeId(session.subscription, {
+ userId
+ } as any)
+ }
+ }
+
+ console.log(`[Stripe] Checkout completed: ${session.id}`)
+ break
+ }
+
+ case 'invoice.payment_succeeded': {
+ const invoice = event.data.object as unknown as StripeInvoice
+ if (invoice.subscription) {
+ await subscriptionService.updateByStripeId(invoice.subscription, {
+ status: 'active'
+ })
+ }
+ console.log(`[Stripe] Payment succeeded for invoice: ${invoice.id}`)
+ break
+ }
+
+ case 'invoice.payment_failed': {
+ const invoice = event.data.object as unknown as StripeInvoice
+ if (invoice.subscription) {
+ await subscriptionService.updateByStripeId(invoice.subscription, {
+ status: 'past_due'
+ })
+ }
+ console.log(`[Stripe] Payment failed for invoice: ${invoice.id}`)
+ break
+ }
+
+ default:
+ console.log(`[Stripe] Unhandled event type: ${event.type}`)
+ }
+ } catch (error) {
+ console.error(`[Stripe] Error processing webhook event ${event.type}:`, error)
+ return c.json({ error: 'Webhook processing failed' }, 500)
+ }
+
+ return c.json({ received: true })
+})
+
+// ============================================================================
+// Authenticated API Routes
+// ============================================================================
+
+// Create checkout session for current user
+apiRoutes.post('/create-checkout-session', requireAuth(), async (c) => {
+ const db = c.env.DB
+ const user = c.get('user')
+ if (!user) return c.json({ error: 'Unauthorized' }, 401)
+
+ const settings = await getSettings(db)
+ if (!settings.stripeSecretKey) {
+ return c.json({ error: 'Stripe not configured' }, 500)
+ }
+
+ const body = await c.req.json().catch(() => ({})) as { priceId?: string }
+ const priceId = body.priceId || settings.stripePriceId
+ if (!priceId) {
+ return c.json({ error: 'No price ID specified' }, 400)
+ }
+
+ const stripeApi = new StripeAPI(settings.stripeSecretKey)
+ const subscriptionService = new SubscriptionService(db)
+ await subscriptionService.ensureTable()
+
+ // Check if user already has a Stripe customer ID
+ const existingSub = await subscriptionService.getByUserId(user.userId)
+ let customerId = existingSub?.stripeCustomerId
+
+ if (!customerId) {
+ // Try to find existing customer by email, or create one
+ const existing = await stripeApi.findCustomerByEmail(user.email)
+ if (existing) {
+ customerId = existing.id
+ } else {
+ const customer = await stripeApi.createCustomer({
+ email: user.email,
+ metadata: { sonicjs_user_id: user.userId }
+ })
+ customerId = customer.id
+ }
+ }
+
+ const origin = new URL(c.req.url).origin
+ const session = await stripeApi.createCheckoutSession({
+ priceId,
+ customerId,
+ successUrl: `${origin}${settings.successUrl}?session_id={CHECKOUT_SESSION_ID}`,
+ cancelUrl: `${origin}${settings.cancelUrl}`,
+ metadata: { sonicjs_user_id: user.userId }
+ })
+
+ return c.json({ sessionId: session.id, url: session.url })
+})
+
+// Get current user's subscription
+apiRoutes.get('/subscription', requireAuth(), async (c) => {
+ const user = c.get('user')
+ if (!user) return c.json({ error: 'Unauthorized' }, 401)
+
+ const db = c.env.DB
+ const subscriptionService = new SubscriptionService(db)
+ await subscriptionService.ensureTable()
+
+ const subscription = await subscriptionService.getByUserId(user.userId)
+ if (!subscription) {
+ return c.json({ subscription: null })
+ }
+
+ return c.json({ subscription })
+})
+
+// ============================================================================
+// Admin API Routes
+// ============================================================================
+
+// List all subscriptions (admin only)
+apiRoutes.get('/subscriptions', requireAuth(), async (c) => {
+ const user = c.get('user')
+ if (user?.role !== 'admin') return c.json({ error: 'Access denied' }, 403)
+
+ const db = c.env.DB
+ const subscriptionService = new SubscriptionService(db)
+ await subscriptionService.ensureTable()
+
+ const filters: SubscriptionFilters = {
+ status: c.req.query('status') as SubscriptionStatus | undefined,
+ page: c.req.query('page') ? parseInt(c.req.query('page')!) : 1,
+ limit: c.req.query('limit') ? parseInt(c.req.query('limit')!) : 50,
+ sortBy: (c.req.query('sortBy') as any) || 'created_at',
+ sortOrder: (c.req.query('sortOrder') as any) || 'desc'
+ }
+
+ const result = await subscriptionService.list(filters)
+ return c.json(result)
+})
+
+// Get subscription stats (admin only)
+apiRoutes.get('/stats', requireAuth(), async (c) => {
+ const user = c.get('user')
+ if (user?.role !== 'admin') return c.json({ error: 'Access denied' }, 403)
+
+ const db = c.env.DB
+ const subscriptionService = new SubscriptionService(db)
+ await subscriptionService.ensureTable()
+
+ const stats = await subscriptionService.getStats()
+ return c.json(stats)
+})
+
+export { apiRoutes as stripeApiRoutes }
diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-api.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-api.ts
new file mode 100644
index 000000000..d62bbed62
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-api.ts
@@ -0,0 +1,141 @@
+import type { StripePluginSettings } from '../types'
+
+/**
+ * Lightweight Stripe API client using fetch (CF Workers compatible, no SDK needed)
+ */
+export class StripeAPI {
+ private baseUrl = 'https://api.stripe.com/v1'
+
+ constructor(private secretKey: string) {}
+
+ /**
+ * Verify a webhook signature
+ * Implements Stripe's v1 signature scheme using Web Crypto API
+ */
+ async verifyWebhookSignature(payload: string, sigHeader: string, secret: string): Promise {
+ const parts = sigHeader.split(',')
+ const timestamp = parts.find(p => p.startsWith('t='))?.split('=')[1]
+ const signatures = parts
+ .filter(p => p.startsWith('v1='))
+ .map(p => p.substring(3))
+
+ if (!timestamp || signatures.length === 0) return false
+
+ // Reject events older than 5 minutes (tolerance)
+ const now = Math.floor(Date.now() / 1000)
+ if (Math.abs(now - parseInt(timestamp)) > 300) return false
+
+ // Compute expected signature
+ const signedPayload = `${timestamp}.${payload}`
+ const encoder = new TextEncoder()
+ const key = await crypto.subtle.importKey(
+ 'raw',
+ encoder.encode(secret),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign']
+ )
+ const signatureBuffer = await crypto.subtle.sign('HMAC', key, encoder.encode(signedPayload))
+ const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
+ .map(b => b.toString(16).padStart(2, '0'))
+ .join('')
+
+ return signatures.some(sig => timingSafeEqual(sig, expectedSignature))
+ }
+
+ /**
+ * Create a Checkout Session
+ */
+ async createCheckoutSession(params: {
+ priceId: string
+ customerId?: string
+ customerEmail?: string
+ successUrl: string
+ cancelUrl: string
+ metadata?: Record
+ }): Promise<{ id: string; url: string }> {
+ const body = new URLSearchParams()
+ body.append('mode', 'subscription')
+ body.append('line_items[0][price]', params.priceId)
+ body.append('line_items[0][quantity]', '1')
+ body.append('success_url', params.successUrl)
+ body.append('cancel_url', params.cancelUrl)
+
+ if (params.customerId) {
+ body.append('customer', params.customerId)
+ } else if (params.customerEmail) {
+ body.append('customer_email', params.customerEmail)
+ }
+
+ if (params.metadata) {
+ for (const [key, value] of Object.entries(params.metadata)) {
+ body.append(`metadata[${key}]`, value)
+ }
+ }
+
+ const response = await this.request('POST', '/checkout/sessions', body)
+ return { id: response.id, url: response.url }
+ }
+
+ /**
+ * Retrieve a Stripe subscription
+ */
+ async getSubscription(subscriptionId: string): Promise {
+ return this.request('GET', `/subscriptions/${subscriptionId}`)
+ }
+
+ /**
+ * Create a Stripe customer
+ */
+ async createCustomer(params: { email: string; metadata?: Record }): Promise<{ id: string }> {
+ const body = new URLSearchParams()
+ body.append('email', params.email)
+ if (params.metadata) {
+ for (const [key, value] of Object.entries(params.metadata)) {
+ body.append(`metadata[${key}]`, value)
+ }
+ }
+ return this.request('POST', '/customers', body)
+ }
+
+ /**
+ * Search for a customer by email
+ */
+ async findCustomerByEmail(email: string): Promise<{ id: string } | null> {
+ const params = new URLSearchParams()
+ params.append('query', `email:'${email}'`)
+ params.append('limit', '1')
+ const result = await this.request('GET', `/customers/search?${params.toString()}`)
+ return result.data?.[0] || null
+ }
+
+ private async request(method: string, path: string, body?: URLSearchParams): Promise {
+ const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`
+ const response = await fetch(url, {
+ method,
+ headers: {
+ 'Authorization': `Bearer ${this.secretKey}`,
+ ...(body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {})
+ },
+ ...(body ? { body: body.toString() } : {})
+ })
+
+ const data = await response.json() as any
+ if (!response.ok) {
+ throw new Error(`Stripe API error: ${data.error?.message || response.statusText}`)
+ }
+ return data
+ }
+}
+
+/**
+ * Constant-time string comparison to prevent timing attacks on signature verification
+ */
+function timingSafeEqual(a: string, b: string): boolean {
+ if (a.length !== b.length) return false
+ let result = 0
+ for (let i = 0; i < a.length; i++) {
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i)
+ }
+ return result === 0
+}
diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/services/subscription-service.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/services/subscription-service.ts
new file mode 100644
index 000000000..2738ce958
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/stripe-plugin/services/subscription-service.ts
@@ -0,0 +1,246 @@
+import type { D1Database } from '@cloudflare/workers-types'
+import type {
+ Subscription,
+ SubscriptionInsert,
+ SubscriptionFilters,
+ SubscriptionStats,
+ SubscriptionStatus
+} from '../types'
+
+/**
+ * Manages subscription records in D1
+ */
+export class SubscriptionService {
+ constructor(private db: D1Database) {}
+
+ /**
+ * Ensure the subscriptions table exists
+ */
+ async ensureTable(): Promise {
+ await this.db.prepare(`
+ CREATE TABLE IF NOT EXISTS subscriptions (
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
+ user_id TEXT NOT NULL,
+ stripe_customer_id TEXT NOT NULL,
+ stripe_subscription_id TEXT NOT NULL UNIQUE,
+ stripe_price_id TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'incomplete',
+ current_period_start INTEGER NOT NULL DEFAULT 0,
+ current_period_end INTEGER NOT NULL DEFAULT 0,
+ cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
+ )
+ `).run()
+
+ // Indexes for common lookups
+ await this.db.prepare(`
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)
+ `).run()
+ await this.db.prepare(`
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_customer_id ON subscriptions(stripe_customer_id)
+ `).run()
+ await this.db.prepare(`
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id)
+ `).run()
+ await this.db.prepare(`
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status)
+ `).run()
+ }
+
+ /**
+ * Create a new subscription record
+ */
+ async create(data: SubscriptionInsert): Promise {
+ const result = await this.db.prepare(`
+ INSERT INTO subscriptions (user_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, status, current_period_start, current_period_end, cancel_at_period_end)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ RETURNING *
+ `).bind(
+ data.userId,
+ data.stripeCustomerId,
+ data.stripeSubscriptionId,
+ data.stripePriceId,
+ data.status,
+ data.currentPeriodStart,
+ data.currentPeriodEnd,
+ data.cancelAtPeriodEnd ? 1 : 0
+ ).first()
+
+ return this.mapRow(result as any)
+ }
+
+ /**
+ * Update a subscription by its Stripe subscription ID
+ */
+ async updateByStripeId(stripeSubscriptionId: string, data: Partial): Promise {
+ const sets: string[] = []
+ const values: any[] = []
+
+ if (data.status !== undefined) {
+ sets.push('status = ?')
+ values.push(data.status)
+ }
+ if (data.stripePriceId !== undefined) {
+ sets.push('stripe_price_id = ?')
+ values.push(data.stripePriceId)
+ }
+ if (data.currentPeriodStart !== undefined) {
+ sets.push('current_period_start = ?')
+ values.push(data.currentPeriodStart)
+ }
+ if (data.currentPeriodEnd !== undefined) {
+ sets.push('current_period_end = ?')
+ values.push(data.currentPeriodEnd)
+ }
+ if (data.cancelAtPeriodEnd !== undefined) {
+ sets.push('cancel_at_period_end = ?')
+ values.push(data.cancelAtPeriodEnd ? 1 : 0)
+ }
+
+ if (sets.length === 0) return this.getByStripeSubscriptionId(stripeSubscriptionId)
+
+ sets.push('updated_at = unixepoch()')
+ values.push(stripeSubscriptionId)
+
+ const result = await this.db.prepare(`
+ UPDATE subscriptions SET ${sets.join(', ')} WHERE stripe_subscription_id = ? RETURNING *
+ `).bind(...values).first()
+
+ return result ? this.mapRow(result as any) : null
+ }
+
+ /**
+ * Get subscription by Stripe subscription ID
+ */
+ async getByStripeSubscriptionId(stripeSubscriptionId: string): Promise {
+ const result = await this.db.prepare(
+ 'SELECT * FROM subscriptions WHERE stripe_subscription_id = ?'
+ ).bind(stripeSubscriptionId).first()
+ return result ? this.mapRow(result as any) : null
+ }
+
+ /**
+ * Get the active subscription for a user
+ */
+ async getByUserId(userId: string): Promise {
+ const result = await this.db.prepare(
+ "SELECT * FROM subscriptions WHERE user_id = ? ORDER BY CASE WHEN status = 'active' THEN 0 WHEN status = 'trialing' THEN 1 ELSE 2 END, updated_at DESC LIMIT 1"
+ ).bind(userId).first()
+ return result ? this.mapRow(result as any) : null
+ }
+
+ /**
+ * Get subscription by Stripe customer ID
+ */
+ async getByStripeCustomerId(stripeCustomerId: string): Promise {
+ const result = await this.db.prepare(
+ 'SELECT * FROM subscriptions WHERE stripe_customer_id = ? ORDER BY updated_at DESC LIMIT 1'
+ ).bind(stripeCustomerId).first()
+ return result ? this.mapRow(result as any) : null
+ }
+
+ /**
+ * Find the userId linked to a Stripe customer ID
+ */
+ async getUserIdByStripeCustomer(stripeCustomerId: string): Promise {
+ const result = await this.db.prepare(
+ 'SELECT user_id FROM subscriptions WHERE stripe_customer_id = ? LIMIT 1'
+ ).bind(stripeCustomerId).first() as { user_id: string } | null
+ return result?.user_id ?? null
+ }
+
+ /**
+ * List subscriptions with filters and pagination
+ */
+ async list(filters: SubscriptionFilters = {}): Promise<{ subscriptions: Subscription[]; total: number }> {
+ const where: string[] = []
+ const values: any[] = []
+
+ if (filters.status) {
+ where.push('status = ?')
+ values.push(filters.status)
+ }
+ if (filters.userId) {
+ where.push('user_id = ?')
+ values.push(filters.userId)
+ }
+ if (filters.stripeCustomerId) {
+ where.push('stripe_customer_id = ?')
+ values.push(filters.stripeCustomerId)
+ }
+
+ const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
+ const sortBy = filters.sortBy || 'created_at'
+ const sortOrder = filters.sortOrder || 'desc'
+ const limit = Math.min(filters.limit || 50, 100)
+ const page = filters.page || 1
+ const offset = (page - 1) * limit
+
+ // Get total count
+ const countResult = await this.db.prepare(
+ `SELECT COUNT(*) as count FROM subscriptions ${whereClause}`
+ ).bind(...values).first() as { count: number }
+
+ // Get paginated results
+ const results = await this.db.prepare(
+ `SELECT s.*, u.email as user_email FROM subscriptions s LEFT JOIN users u ON s.user_id = u.id ${whereClause} ORDER BY ${sortBy} ${sortOrder} LIMIT ? OFFSET ?`
+ ).bind(...values, limit, offset).all()
+
+ return {
+ subscriptions: (results.results || []).map((r: any) => this.mapRow(r)),
+ total: countResult?.count || 0
+ }
+ }
+
+ /**
+ * Get subscription stats
+ */
+ async getStats(): Promise {
+ const result = await this.db.prepare(`
+ SELECT
+ COUNT(*) as total,
+ SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active,
+ SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END) as canceled,
+ SUM(CASE WHEN status = 'past_due' THEN 1 ELSE 0 END) as past_due,
+ SUM(CASE WHEN status = 'trialing' THEN 1 ELSE 0 END) as trialing
+ FROM subscriptions
+ `).first() as any
+
+ return {
+ total: result?.total || 0,
+ active: result?.active || 0,
+ canceled: result?.canceled || 0,
+ pastDue: result?.past_due || 0,
+ trialing: result?.trialing || 0
+ }
+ }
+
+ /**
+ * Delete a subscription record by Stripe subscription ID
+ */
+ async deleteByStripeId(stripeSubscriptionId: string): Promise {
+ const result = await this.db.prepare(
+ 'DELETE FROM subscriptions WHERE stripe_subscription_id = ?'
+ ).bind(stripeSubscriptionId).run()
+ return (result.meta?.changes || 0) > 0
+ }
+
+ private mapRow(row: Record): Subscription {
+ return {
+ id: row.id,
+ userId: row.user_id,
+ stripeCustomerId: row.stripe_customer_id,
+ stripeSubscriptionId: row.stripe_subscription_id,
+ stripePriceId: row.stripe_price_id,
+ status: row.status as SubscriptionStatus,
+ currentPeriodStart: row.current_period_start,
+ currentPeriodEnd: row.current_period_end,
+ cancelAtPeriodEnd: !!row.cancel_at_period_end,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ // Attach email if joined
+ ...(row.user_email ? { userEmail: row.user_email } : {})
+ } as Subscription
+ }
+}
diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/types.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/types.ts
new file mode 100644
index 000000000..eb3d88fc1
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/stripe-plugin/types.ts
@@ -0,0 +1,123 @@
+// ============================================================================
+// Stripe Plugin Types
+// ============================================================================
+
+export type SubscriptionStatus =
+ | 'active'
+ | 'canceled'
+ | 'past_due'
+ | 'trialing'
+ | 'unpaid'
+ | 'paused'
+ | 'incomplete'
+ | 'incomplete_expired'
+
+export interface Subscription {
+ id: string
+ userId: string
+ stripeCustomerId: string
+ stripeSubscriptionId: string
+ stripePriceId: string
+ status: SubscriptionStatus
+ currentPeriodStart: number
+ currentPeriodEnd: number
+ cancelAtPeriodEnd: boolean
+ createdAt: number
+ updatedAt: number
+}
+
+export interface SubscriptionInsert {
+ userId: string
+ stripeCustomerId: string
+ stripeSubscriptionId: string
+ stripePriceId: string
+ status: SubscriptionStatus
+ currentPeriodStart: number
+ currentPeriodEnd: number
+ cancelAtPeriodEnd?: boolean
+}
+
+export interface SubscriptionFilters {
+ status?: SubscriptionStatus
+ userId?: string
+ stripeCustomerId?: string
+ page?: number
+ limit?: number
+ sortBy?: 'created_at' | 'updated_at' | 'status'
+ sortOrder?: 'asc' | 'desc'
+}
+
+export interface SubscriptionStats {
+ total: number
+ active: number
+ canceled: number
+ pastDue: number
+ trialing: number
+}
+
+export interface StripePluginSettings {
+ stripeSecretKey: string
+ stripeWebhookSecret: string
+ stripePriceId?: string
+ successUrl: string
+ cancelUrl: string
+}
+
+export const DEFAULT_SETTINGS: StripePluginSettings = {
+ stripeSecretKey: '',
+ stripeWebhookSecret: '',
+ stripePriceId: '',
+ successUrl: '/admin/dashboard',
+ cancelUrl: '/admin/dashboard'
+}
+
+// Stripe webhook event types we handle
+export type StripeWebhookEventType =
+ | 'customer.subscription.created'
+ | 'customer.subscription.updated'
+ | 'customer.subscription.deleted'
+ | 'checkout.session.completed'
+ | 'invoice.payment_succeeded'
+ | 'invoice.payment_failed'
+
+// Minimal Stripe types (we use fetch, not the SDK)
+export interface StripeEvent {
+ id: string
+ type: string
+ data: {
+ object: Record
+ }
+}
+
+export interface StripeSubscriptionObject {
+ id: string
+ customer: string
+ status: string
+ items: {
+ data: Array<{
+ price: {
+ id: string
+ }
+ }>
+ }
+ current_period_start: number
+ current_period_end: number
+ cancel_at_period_end: boolean
+ metadata?: Record
+}
+
+export interface StripeCheckoutSession {
+ id: string
+ customer: string
+ subscription: string
+ metadata?: Record
+}
+
+export interface StripeInvoice {
+ id: string
+ customer: string
+ subscription: string | null
+ status: string
+ amount_paid: number
+ currency: string
+}