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')} +
+ + +
+
+ + +
+
+ + +
+ + + + + + + + + + + + + ${subscriptions.length === 0 + ? '' + : subscriptions.map(renderRow).join('') + } + +
UserStatusPrice IDCurrent PeriodCancel at EndStripe
No subscriptions found
+ + ${renderPagination(filters.page, filters.totalPages, filters.status)} +
+
+ ` +} + +function statsCard(label: string, value: number, colorClass: string): string { + return ` +
+
${label}
+
${value}
+
+ ` +} + +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 +}