Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/ai/plans/stripe-plugin-plan.md
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions packages/core/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/plugins/core-plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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]
Original file line number Diff line number Diff line change
@@ -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 `
<div class="space-y-6">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
${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')}
</div>

<!-- Filters -->
<div class="bg-white rounded-lg shadow p-4">
<form method="GET" class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700">Status:</label>
<select name="status" class="border rounded px-3 py-1.5 text-sm" onchange="this.form.submit()">
<option value="">All</option>
${statusOption('active', filters.status)}
${statusOption('trialing', filters.status)}
${statusOption('past_due', filters.status)}
${statusOption('canceled', filters.status)}
${statusOption('unpaid', filters.status)}
${statusOption('paused', filters.status)}
</select>
</form>
</div>

<!-- Subscriptions Table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Price ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Current Period</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Cancel at End</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Stripe</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
${subscriptions.length === 0
? '<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500">No subscriptions found</td></tr>'
: subscriptions.map(renderRow).join('')
}
</tbody>
</table>

${renderPagination(filters.page, filters.totalPages, filters.status)}
</div>
</div>
`
}

function statsCard(label: string, value: number, colorClass: string): string {
return `
<div class="bg-white rounded-lg shadow p-4">
<div class="text-sm font-medium text-gray-500">${label}</div>
<div class="text-2xl font-bold ${colorClass}">${value}</div>
</div>
`
}

function statusOption(value: string, current?: string): string {
const selected = value === current ? 'selected' : ''
const label = value.replace('_', ' ').replace(/\b\w/g, c => c.toUpperCase())
return `<option value="${value}" ${selected}>${label}</option>`
}

function statusBadge(status: SubscriptionStatus): string {
const colors: Record<string, string> = {
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 `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${color}">${label}</span>`
}

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 `
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">${sub.userEmail || sub.userId}</div>
<div class="text-xs text-gray-500">${sub.stripeCustomerId}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">${statusBadge(sub.status)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${sub.stripePriceId}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${formatDate(sub.currentPeriodStart)} - ${formatDate(sub.currentPeriodEnd)}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
${sub.cancelAtPeriodEnd
? '<span class="text-yellow-600 font-medium">Yes</span>'
: '<span class="text-gray-400">No</span>'
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<a href="https://dashboard.stripe.com/subscriptions/${sub.stripeSubscriptionId}"
target="_blank" rel="noopener noreferrer"
class="text-indigo-600 hover:text-indigo-800">
View in Stripe
</a>
</td>
</tr>
`
}

function renderPagination(page: number, totalPages: number, status?: string): string {
if (totalPages <= 1) return ''

const params = status ? `&status=${status}` : ''
return `
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="text-sm text-gray-700">
Page ${page} of ${totalPages}
</div>
<div class="flex gap-2">
${page > 1
? `<a href="?page=${page - 1}${params}" class="px-3 py-1 border rounded text-sm hover:bg-gray-50">Previous</a>`
: ''
}
${page < totalPages
? `<a href="?page=${page + 1}${params}" class="px-3 py-1 border rounded text-sm hover:bg-gray-50">Next</a>`
: ''
}
</div>
</div>
`
}
61 changes: 61 additions & 0 deletions packages/core/src/plugins/core-plugins/stripe-plugin/index.ts
Original file line number Diff line number Diff line change
@@ -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: `<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg>`,
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
56 changes: 56 additions & 0 deletions packages/core/src/plugins/core-plugins/stripe-plugin/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading