From 46c826cfb95bf0bbc48714272613c8e72d98676d Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sat, 29 Nov 2025 17:57:01 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 18 + README.md | 3 + agents/payment-integration/AGENT.md | 61 ++ commands/stripe-setup.md | 931 +++++++++++++++++++++ commands/subscription-flow.md | 1193 +++++++++++++++++++++++++++ commands/subscription-manage.md | 386 +++++++++ commands/webhook-setup.md | 295 +++++++ plugin.lock.json | 77 ++ skills/billing-automation/SKILL.md | 559 +++++++++++++ skills/paypal-integration/SKILL.md | 467 +++++++++++ skills/pci-compliance/SKILL.md | 466 +++++++++++ skills/stripe-integration/SKILL.md | 442 ++++++++++ 12 files changed, 4898 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 agents/payment-integration/AGENT.md create mode 100644 commands/stripe-setup.md create mode 100644 commands/subscription-flow.md create mode 100644 commands/subscription-manage.md create mode 100644 commands/webhook-setup.md create mode 100644 plugin.lock.json create mode 100644 skills/billing-automation/SKILL.md create mode 100644 skills/paypal-integration/SKILL.md create mode 100644 skills/pci-compliance/SKILL.md create mode 100644 skills/stripe-integration/SKILL.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..82cf89e --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "specweave-payments", + "description": "Payment processing integration for Stripe, PayPal, and billing automation. Includes checkout flows, subscription lifecycle management, PCI DSS compliance guidance, and recurring billing. Focus on production-ready payment systems.", + "version": "0.22.14", + "author": { + "name": "SpecWeave Team", + "url": "https://spec-weave.com" + }, + "skills": [ + "./skills" + ], + "agents": [ + "./agents" + ], + "commands": [ + "./commands" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9faaeb4 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# specweave-payments + +Payment processing integration for Stripe, PayPal, and billing automation. Includes checkout flows, subscription lifecycle management, PCI DSS compliance guidance, and recurring billing. Focus on production-ready payment systems. diff --git a/agents/payment-integration/AGENT.md b/agents/payment-integration/AGENT.md new file mode 100644 index 0000000..1eec2d1 --- /dev/null +++ b/agents/payment-integration/AGENT.md @@ -0,0 +1,61 @@ +--- +name: payment-integration +description: Integrate Stripe, PayPal, and payment processors. Handles checkout flows, subscriptions, webhooks, and PCI compliance. Use PROACTIVELY when implementing payments, billing, or subscription features. +model: claude-haiku-4-5-20251001 +model_preference: haiku +cost_profile: execution +fallback_behavior: flexible +--- + +You are a payment integration specialist focused on secure, reliable payment processing. + +## 🚀 How to Invoke This Agent + +**Subagent Type**: `specweave-payments:payment-integration:payment-integration` + +**Usage Example**: + +```typescript +Task({ + subagent_type: "specweave-payments:payment-integration:payment-integration", + prompt: "Implement Stripe payment integration with checkout flow, webhook handling, and subscription billing", + model: "haiku" // optional: haiku, sonnet, opus +}); +``` + +**Naming Convention**: `{plugin}:{directory}:{yaml-name-or-directory-name}` +- **Plugin**: specweave-payments +- **Directory**: payment-integration +- **Agent Name**: payment-integration + +**When to Use**: +- You're implementing payment processing with Stripe or PayPal +- You need to build checkout flows and payment forms +- You want to set up recurring billing and subscriptions +- You need to handle payment webhooks and events +- You want to ensure PCI compliance and security best practices + +## Focus Areas +- Stripe/PayPal/Square API integration +- Checkout flows and payment forms +- Subscription billing and recurring payments +- Webhook handling for payment events +- PCI compliance and security best practices +- Payment error handling and retry logic + +## Approach +1. Security first - never log sensitive card data +2. Implement idempotency for all payment operations +3. Handle all edge cases (failed payments, disputes, refunds) +4. Test mode first, with clear migration path to production +5. Comprehensive webhook handling for async events + +## Output +- Payment integration code with error handling +- Webhook endpoint implementations +- Database schema for payment records +- Security checklist (PCI compliance points) +- Test payment scenarios and edge cases +- Environment variable configuration + +Always use official SDKs. Include both server-side and client-side code where needed. diff --git a/commands/stripe-setup.md b/commands/stripe-setup.md new file mode 100644 index 0000000..16aad82 --- /dev/null +++ b/commands/stripe-setup.md @@ -0,0 +1,931 @@ +# /specweave-payments:stripe-setup + +Complete Stripe integration setup guide with production-ready code templates, security best practices, and testing workflows. + +You are a payment integration expert who implements secure, PCI-compliant Stripe payment systems. + +## Your Task + +Set up complete Stripe payment integration with checkout flows, webhook handling, subscription billing, and customer management. + +### 1. Environment Setup + +**Install Dependencies**: + +```bash +# Node.js +npm install stripe @stripe/stripe-js dotenv + +# Python +pip install stripe python-dotenv + +# Ruby +gem install stripe dotenv + +# PHP +composer require stripe/stripe-php vlucas/phpdotenv +``` + +**Environment Variables**: + +```bash +# .env (NEVER commit this file!) +# Get keys from https://dashboard.stripe.com/apikeys + +# Test mode (development) +STRIPE_PUBLISHABLE_KEY=pk_test_51... +STRIPE_SECRET_KEY=sk_test_51... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Live mode (production) +# STRIPE_PUBLISHABLE_KEY=pk_live_51... +# STRIPE_SECRET_KEY=sk_live_51... +# STRIPE_WEBHOOK_SECRET=whsec_... + +# App configuration +STRIPE_SUCCESS_URL=https://yourdomain.com/success +STRIPE_CANCEL_URL=https://yourdomain.com/cancel +STRIPE_CURRENCY=usd +``` + +### 2. Backend Setup (Node.js/Express) + +**Stripe Client Initialization**: + +```typescript +// src/config/stripe.ts +import Stripe from 'stripe'; +import dotenv from 'dotenv'; + +dotenv.config(); + +if (!process.env.STRIPE_SECRET_KEY) { + throw new Error('STRIPE_SECRET_KEY is not set in environment variables'); +} + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2023-10-16', + typescript: true, + maxNetworkRetries: 2, + timeout: 10000, // 10 seconds +}); + +export const STRIPE_CONFIG = { + publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!, + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, + successUrl: process.env.STRIPE_SUCCESS_URL || 'http://localhost:3000/success', + cancelUrl: process.env.STRIPE_CANCEL_URL || 'http://localhost:3000/cancel', + currency: process.env.STRIPE_CURRENCY || 'usd', +}; +``` + +**Payment Service**: + +```typescript +// src/services/payment.service.ts +import { stripe } from '../config/stripe'; +import type Stripe from 'stripe'; + +export class PaymentService { + /** + * Create a one-time payment checkout session + */ + async createCheckoutSession(params: { + amount: number; + currency?: string; + customerId?: string; + metadata?: Record; + }): Promise { + try { + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: params.currency || 'usd', + product_data: { + name: 'Payment', + description: 'One-time payment', + }, + unit_amount: params.amount, // Amount in cents + }, + quantity: 1, + }, + ], + mode: 'payment', + success_url: `${process.env.STRIPE_SUCCESS_URL}?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: process.env.STRIPE_CANCEL_URL, + customer: params.customerId, + metadata: params.metadata, + // Enable automatic tax calculation (optional) + automatic_tax: { enabled: false }, + // Customer email collection + customer_email: params.customerId ? undefined : '', + }); + + return session; + } catch (error) { + console.error('Failed to create checkout session:', error); + throw new Error('Payment session creation failed'); + } + } + + /** + * Create a payment intent for custom checkout UI + */ + async createPaymentIntent(params: { + amount: number; + currency?: string; + customerId?: string; + paymentMethodTypes?: string[]; + metadata?: Record; + }): Promise { + try { + const paymentIntent = await stripe.paymentIntents.create({ + amount: params.amount, + currency: params.currency || 'usd', + customer: params.customerId, + payment_method_types: params.paymentMethodTypes || ['card'], + metadata: params.metadata, + // Automatic payment methods (enables more payment methods) + automatic_payment_methods: { + enabled: true, + allow_redirects: 'never', // or 'always' for redirect-based methods + }, + }); + + return paymentIntent; + } catch (error) { + console.error('Failed to create payment intent:', error); + throw new Error('Payment intent creation failed'); + } + } + + /** + * Retrieve a payment intent + */ + async getPaymentIntent(paymentIntentId: string): Promise { + try { + return await stripe.paymentIntents.retrieve(paymentIntentId); + } catch (error) { + console.error('Failed to retrieve payment intent:', error); + throw new Error('Payment intent retrieval failed'); + } + } + + /** + * Confirm a payment intent (server-side confirmation) + */ + async confirmPaymentIntent( + paymentIntentId: string, + paymentMethodId: string + ): Promise { + try { + return await stripe.paymentIntents.confirm(paymentIntentId, { + payment_method: paymentMethodId, + }); + } catch (error) { + console.error('Failed to confirm payment intent:', error); + throw new Error('Payment confirmation failed'); + } + } + + /** + * Create or update a customer + */ + async createCustomer(params: { + email: string; + name?: string; + phone?: string; + metadata?: Record; + paymentMethodId?: string; + }): Promise { + try { + const customer = await stripe.customers.create({ + email: params.email, + name: params.name, + phone: params.phone, + metadata: params.metadata, + payment_method: params.paymentMethodId, + invoice_settings: params.paymentMethodId + ? { + default_payment_method: params.paymentMethodId, + } + : undefined, + }); + + return customer; + } catch (error) { + console.error('Failed to create customer:', error); + throw new Error('Customer creation failed'); + } + } + + /** + * Attach a payment method to a customer + */ + async attachPaymentMethod( + paymentMethodId: string, + customerId: string, + setAsDefault = true + ): Promise { + try { + // Attach payment method + const paymentMethod = await stripe.paymentMethods.attach(paymentMethodId, { + customer: customerId, + }); + + // Set as default if requested + if (setAsDefault) { + await stripe.customers.update(customerId, { + invoice_settings: { + default_payment_method: paymentMethodId, + }, + }); + } + + return paymentMethod; + } catch (error) { + console.error('Failed to attach payment method:', error); + throw new Error('Payment method attachment failed'); + } + } + + /** + * List customer payment methods + */ + async listPaymentMethods(customerId: string): Promise { + try { + const paymentMethods = await stripe.paymentMethods.list({ + customer: customerId, + type: 'card', + }); + + return paymentMethods.data; + } catch (error) { + console.error('Failed to list payment methods:', error); + throw new Error('Payment method listing failed'); + } + } + + /** + * Create a refund + */ + async createRefund(params: { + paymentIntentId?: string; + chargeId?: string; + amount?: number; // Partial refund amount in cents + reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer'; + metadata?: Record; + }): Promise { + try { + const refund = await stripe.refunds.create({ + payment_intent: params.paymentIntentId, + charge: params.chargeId, + amount: params.amount, + reason: params.reason, + metadata: params.metadata, + }); + + return refund; + } catch (error) { + console.error('Failed to create refund:', error); + throw new Error('Refund creation failed'); + } + } +} + +export const paymentService = new PaymentService(); +``` + +**Express API Routes**: + +```typescript +// src/routes/payment.routes.ts +import { Router, Request, Response } from 'express'; +import { paymentService } from '../services/payment.service'; + +const router = Router(); + +/** + * POST /api/payments/checkout + * Create a checkout session + */ +router.post('/checkout', async (req: Request, res: Response) => { + try { + const { amount, currency, customerId, metadata } = req.body; + + // Validate amount + if (!amount || amount <= 0) { + return res.status(400).json({ error: 'Invalid amount' }); + } + + const session = await paymentService.createCheckoutSession({ + amount, + currency, + customerId, + metadata, + }); + + res.json({ sessionId: session.id, url: session.url }); + } catch (error) { + console.error('Checkout error:', error); + res.status(500).json({ error: 'Failed to create checkout session' }); + } +}); + +/** + * POST /api/payments/intent + * Create a payment intent for custom UI + */ +router.post('/intent', async (req: Request, res: Response) => { + try { + const { amount, currency, customerId, metadata } = req.body; + + if (!amount || amount <= 0) { + return res.status(400).json({ error: 'Invalid amount' }); + } + + const paymentIntent = await paymentService.createPaymentIntent({ + amount, + currency, + customerId, + metadata, + }); + + res.json({ clientSecret: paymentIntent.client_secret }); + } catch (error) { + console.error('Payment intent error:', error); + res.status(500).json({ error: 'Failed to create payment intent' }); + } +}); + +/** + * POST /api/payments/customers + * Create a customer + */ +router.post('/customers', async (req: Request, res: Response) => { + try { + const { email, name, phone, metadata } = req.body; + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); + } + + const customer = await paymentService.createCustomer({ + email, + name, + phone, + metadata, + }); + + res.json({ customerId: customer.id }); + } catch (error) { + console.error('Customer creation error:', error); + res.status(500).json({ error: 'Failed to create customer' }); + } +}); + +/** + * POST /api/payments/refunds + * Create a refund + */ +router.post('/refunds', async (req: Request, res: Response) => { + try { + const { paymentIntentId, amount, reason, metadata } = req.body; + + if (!paymentIntentId) { + return res.status(400).json({ error: 'Payment Intent ID is required' }); + } + + const refund = await paymentService.createRefund({ + paymentIntentId, + amount, + reason, + metadata, + }); + + res.json({ refundId: refund.id, status: refund.status }); + } catch (error) { + console.error('Refund error:', error); + res.status(500).json({ error: 'Failed to create refund' }); + } +}); + +/** + * GET /api/payments/config + * Get public Stripe configuration + */ +router.get('/config', (req: Request, res: Response) => { + res.json({ + publishableKey: process.env.STRIPE_PUBLISHABLE_KEY, + }); +}); + +export default router; +``` + +### 3. Frontend Setup (React) + +**Stripe Provider**: + +```typescript +// src/providers/StripeProvider.tsx +import React from 'react'; +import { Elements } from '@stripe/react-stripe-js'; +import { loadStripe, Stripe } from '@stripe/stripe-js'; + +// Load Stripe.js outside of component to avoid recreating the instance +let stripePromise: Promise; + +const getStripe = () => { + if (!stripePromise) { + const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || ''; + stripePromise = loadStripe(publishableKey); + } + return stripePromise; +}; + +interface StripeProviderProps { + children: React.ReactNode; +} + +export const StripeProvider: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; +``` + +**Payment Form Component**: + +```typescript +// src/components/PaymentForm.tsx +import React, { useState } from 'react'; +import { + useStripe, + useElements, + CardElement, + PaymentElement, +} from '@stripe/react-stripe-js'; +import type { StripeError } from '@stripe/stripe-js'; + +interface PaymentFormProps { + amount: number; + currency?: string; + onSuccess: (paymentIntentId: string) => void; + onError: (error: string) => void; + customerId?: string; + metadata?: Record; +} + +export const PaymentForm: React.FC = ({ + amount, + currency = 'usd', + onSuccess, + onError, + customerId, + metadata, +}) => { + const stripe = useStripe(); + const elements = useElements(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + // Stripe.js hasn't loaded yet + return; + } + + setLoading(true); + setError(null); + + try { + // Create payment intent on backend + const response = await fetch('/api/payments/intent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount, + currency, + customerId, + metadata, + }), + }); + + const { clientSecret } = await response.json(); + + // Confirm payment with Stripe.js + const { error: stripeError, paymentIntent } = await stripe.confirmCardPayment( + clientSecret, + { + payment_method: { + card: elements.getElement(CardElement)!, + billing_details: { + // Add billing details if collected + }, + }, + } + ); + + if (stripeError) { + setError(stripeError.message || 'Payment failed'); + onError(stripeError.message || 'Payment failed'); + } else if (paymentIntent && paymentIntent.status === 'succeeded') { + onSuccess(paymentIntent.id); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Payment failed'; + setError(errorMessage); + onError(errorMessage); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+ ); +}; +``` + +**Checkout Session Flow**: + +```typescript +// src/components/CheckoutButton.tsx +import React, { useState } from 'react'; +import { loadStripe } from '@stripe/stripe-js'; + +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); + +interface CheckoutButtonProps { + amount: number; + currency?: string; + buttonText?: string; +} + +export const CheckoutButton: React.FC = ({ + amount, + currency = 'usd', + buttonText = 'Checkout', +}) => { + const [loading, setLoading] = useState(false); + + const handleCheckout = async () => { + setLoading(true); + + try { + // Create checkout session + const response = await fetch('/api/payments/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount, currency }), + }); + + const { sessionId } = await response.json(); + + // Redirect to Stripe Checkout + const stripe = await stripePromise; + if (stripe) { + const { error } = await stripe.redirectToCheckout({ sessionId }); + if (error) { + console.error('Checkout error:', error); + } + } + } catch (error) { + console.error('Checkout error:', error); + } finally { + setLoading(false); + } + }; + + return ( + + ); +}; +``` + +### 4. Testing + +**Test Cards**: + +```typescript +// Test card numbers for different scenarios +export const TEST_CARDS = { + // Success + VISA_SUCCESS: '4242424242424242', + VISA_DEBIT: '4000056655665556', + MASTERCARD: '5555555555554444', + + // Authentication required + THREE_D_SECURE: '4000002500003155', + + // Failure scenarios + CARD_DECLINED: '4000000000000002', + INSUFFICIENT_FUNDS: '4000000000009995', + LOST_CARD: '4000000000009987', + STOLEN_CARD: '4000000000009979', + EXPIRED_CARD: '4000000000000069', + INCORRECT_CVC: '4000000000000127', + PROCESSING_ERROR: '4000000000000119', + + // Special cases + DISPUTE: '4000000000000259', + FRAUD: '4100000000000019', +}; + +// Any future expiry date (e.g., 12/34) +// Any 3-digit CVC +// Any postal code +``` + +**Integration Test**: + +```typescript +// tests/integration/payment.test.ts +import { paymentService } from '../../src/services/payment.service'; +import Stripe from 'stripe'; + +describe('Payment Service Integration', () => { + describe('Payment Intent', () => { + it('should create a payment intent', async () => { + const paymentIntent = await paymentService.createPaymentIntent({ + amount: 1000, + currency: 'usd', + }); + + expect(paymentIntent).toBeDefined(); + expect(paymentIntent.amount).toBe(1000); + expect(paymentIntent.currency).toBe('usd'); + expect(paymentIntent.status).toBe('requires_payment_method'); + }); + + it('should confirm payment intent with test card', async () => { + // Create payment intent + const paymentIntent = await paymentService.createPaymentIntent({ + amount: 1000, + currency: 'usd', + }); + + // Create test payment method + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + const paymentMethod = await stripe.paymentMethods.create({ + type: 'card', + card: { + number: '4242424242424242', + exp_month: 12, + exp_year: 2034, + cvc: '123', + }, + }); + + // Confirm payment + const confirmed = await paymentService.confirmPaymentIntent( + paymentIntent.id, + paymentMethod.id + ); + + expect(confirmed.status).toBe('succeeded'); + }); + }); + + describe('Customer Management', () => { + it('should create a customer', async () => { + const customer = await paymentService.createCustomer({ + email: 'test@example.com', + name: 'Test User', + }); + + expect(customer).toBeDefined(); + expect(customer.email).toBe('test@example.com'); + }); + + it('should attach payment method to customer', async () => { + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + + // Create customer + const customer = await paymentService.createCustomer({ + email: 'test@example.com', + }); + + // Create payment method + const paymentMethod = await stripe.paymentMethods.create({ + type: 'card', + card: { + number: '4242424242424242', + exp_month: 12, + exp_year: 2034, + cvc: '123', + }, + }); + + // Attach payment method + const attached = await paymentService.attachPaymentMethod( + paymentMethod.id, + customer.id + ); + + expect(attached.customer).toBe(customer.id); + }); + }); + + describe('Refunds', () => { + it('should create a refund', async () => { + // First create and confirm a payment + const paymentIntent = await paymentService.createPaymentIntent({ + amount: 1000, + currency: 'usd', + }); + + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + const paymentMethod = await stripe.paymentMethods.create({ + type: 'card', + card: { + number: '4242424242424242', + exp_month: 12, + exp_year: 2034, + cvc: '123', + }, + }); + + await paymentService.confirmPaymentIntent(paymentIntent.id, paymentMethod.id); + + // Create refund + const refund = await paymentService.createRefund({ + paymentIntentId: paymentIntent.id, + reason: 'requested_by_customer', + }); + + expect(refund).toBeDefined(); + expect(refund.status).toBe('succeeded'); + }); + }); +}); +``` + +### 5. Security Checklist + +**Backend Security**: +- [ ] NEVER log full card numbers or CVV +- [ ] Use HTTPS only (enforce TLS 1.2+) +- [ ] Validate webhook signatures +- [ ] Implement rate limiting on payment endpoints +- [ ] Store Stripe IDs, not card details +- [ ] Use environment variables for keys +- [ ] Implement idempotency keys for retries +- [ ] Sanitize user inputs +- [ ] Enable CSRF protection +- [ ] Use secure session management + +**Frontend Security**: +- [ ] Use Stripe.js (never raw card inputs) +- [ ] Load Stripe.js from CDN (integrity check) +- [ ] Never send card data to your server +- [ ] Implement CSP headers +- [ ] Use HTTPS only +- [ ] Clear sensitive data from memory +- [ ] Disable autocomplete on card fields +- [ ] Implement proper error handling + +**Monitoring**: +- [ ] Log all payment attempts +- [ ] Monitor failed payment rates +- [ ] Set up alerts for unusual activity +- [ ] Track refund rates +- [ ] Monitor webhook delivery +- [ ] Implement fraud detection + +### 6. Production Deployment + +**Pre-launch Checklist**: + +1. **Update API Keys**: + - Switch from test keys (`sk_test_`, `pk_test_`) to live keys + - Update webhook endpoint with live webhook secret + - Test with live mode in Stripe Dashboard + +2. **Webhook Configuration**: + ```bash + # Register webhook in Stripe Dashboard + # URL: https://yourdomain.com/api/webhooks/stripe + # Events: payment_intent.succeeded, payment_intent.payment_failed, + # customer.subscription.*, charge.refunded + ``` + +3. **Enable Radar** (fraud detection): + - Configure Radar rules in Stripe Dashboard + - Enable 3D Secure for high-risk payments + - Set up risk score thresholds + +4. **Tax Configuration**: + - Enable Stripe Tax if needed + - Configure tax rates by location + - Set up tax reporting + +5. **Business Verification**: + - Complete business verification in Stripe + - Add business information + - Verify bank account for payouts + +6. **Monitoring**: + - Set up Sentry or similar for error tracking + - Configure log aggregation (Datadog, Splunk) + - Set up uptime monitoring for webhook endpoint + - Create alerts for failed payments + +## Output Deliverables + +When you complete this setup, provide: + +1. **Configured Files**: + - `.env` template with all required variables + - Backend service with payment methods + - API routes with error handling + - Frontend components (PaymentForm, CheckoutButton) + +2. **Documentation**: + - API endpoint documentation + - Testing guide with test cards + - Deployment checklist + - Security audit report + +3. **Testing**: + - Integration tests for payment flows + - Test scenarios for edge cases + - Webhook handling tests + +4. **Deployment**: + - Environment-specific configurations + - Database migration scripts (if storing payment records) + - Monitoring setup guide + +## Resources + +- **Stripe Documentation**: https://stripe.com/docs +- **Stripe.js Reference**: https://stripe.com/docs/js +- **Webhook Testing**: Use Stripe CLI (`stripe listen --forward-to localhost:3000/api/webhooks/stripe`) +- **Test Cards**: https://stripe.com/docs/testing + +## Best Practices + +1. **Always use Stripe.js** for card collection (PCI compliance) +2. **Verify webhooks** with signature validation +3. **Handle errors gracefully** with user-friendly messages +4. **Test thoroughly** with all test cards before production +5. **Monitor payment success rates** and investigate drops +6. **Implement retry logic** for API failures +7. **Use metadata** to link payments to your database records +8. **Never expose secret keys** in frontend code +9. **Implement idempotency** for payment operations +10. **Keep Stripe.js updated** to latest version + +Start with test mode, verify all flows work correctly, then switch to live mode with the same code. diff --git a/commands/subscription-flow.md b/commands/subscription-flow.md new file mode 100644 index 0000000..8d048b6 --- /dev/null +++ b/commands/subscription-flow.md @@ -0,0 +1,1193 @@ +# /specweave-payments:subscription-flow + +Complete subscription billing implementation guide with pricing tiers, trials, upgrades/downgrades, and lifecycle management. + +You are a subscription billing expert who designs and implements SaaS recurring revenue systems. + +## Your Task + +Implement complete subscription billing with multiple tiers, trial periods, proration, cancellation handling, and customer portal. + +### 1. Subscription Architecture + +**Subscription Components**: + +``` +Product (e.g., "Pro Plan") + ├─ Price (Monthly: $29) + ├─ Price (Yearly: $290, 16% discount) + └─ Features (API access, 10 users, Priority support) + +Customer + ├─ Subscription (Active) + │ ├─ Items (Price ID, Quantity) + │ ├─ Current Period (Start/End) + │ └─ Payment Method (Card) + └─ Invoices (History) +``` + +**Subscription States**: +``` +trialing → active → past_due → canceled + ↓ + paused → resumed + ↓ + incomplete → incomplete_expired +``` + +### 2. Product and Pricing Setup + +**Define Pricing Tiers**: + +```typescript +// src/config/subscription-plans.ts +export interface SubscriptionPlan { + id: string; + name: string; + description: string; + features: string[]; + stripePriceIds: { + monthly: string; + yearly: string; + }; + prices: { + monthly: number; // in cents + yearly: number; + }; + limits: { + users?: number; + apiCalls?: number; + storage?: number; // in GB + }; + popular?: boolean; +} + +export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [ + { + id: 'free', + name: 'Free', + description: 'Perfect for trying out our service', + features: [ + 'Up to 2 users', + '1,000 API calls/month', + '1 GB storage', + 'Community support', + ], + stripePriceIds: { + monthly: '', // No Stripe price for free tier + yearly: '', + }, + prices: { + monthly: 0, + yearly: 0, + }, + limits: { + users: 2, + apiCalls: 1000, + storage: 1, + }, + }, + { + id: 'starter', + name: 'Starter', + description: 'Great for small teams getting started', + features: [ + 'Up to 5 users', + '10,000 API calls/month', + '10 GB storage', + 'Email support', + 'Basic analytics', + ], + stripePriceIds: { + monthly: 'price_starter_monthly_xxx', + yearly: 'price_starter_yearly_xxx', + }, + prices: { + monthly: 2900, // $29 + yearly: 29000, // $290 (16% discount) + }, + limits: { + users: 5, + apiCalls: 10000, + storage: 10, + }, + }, + { + id: 'pro', + name: 'Pro', + description: 'For growing teams with advanced needs', + features: [ + 'Up to 20 users', + '100,000 API calls/month', + '100 GB storage', + 'Priority support', + 'Advanced analytics', + 'Custom integrations', + ], + stripePriceIds: { + monthly: 'price_pro_monthly_xxx', + yearly: 'price_pro_yearly_xxx', + }, + prices: { + monthly: 9900, // $99 + yearly: 99000, // $990 (16% discount) + }, + limits: { + users: 20, + apiCalls: 100000, + storage: 100, + }, + popular: true, + }, + { + id: 'enterprise', + name: 'Enterprise', + description: 'Custom solutions for large organizations', + features: [ + 'Unlimited users', + 'Unlimited API calls', + 'Unlimited storage', + 'Dedicated support', + 'SLA guarantees', + 'Custom contracts', + 'On-premise deployment', + ], + stripePriceIds: { + monthly: 'price_enterprise_monthly_xxx', + yearly: 'price_enterprise_yearly_xxx', + }, + prices: { + monthly: 49900, // $499 + yearly: 499000, // $4,990 (16% discount) + }, + limits: { + users: undefined, // unlimited + apiCalls: undefined, + storage: undefined, + }, + }, +]; + +export function getPlanById(planId: string): SubscriptionPlan | undefined { + return SUBSCRIPTION_PLANS.find((plan) => plan.id === planId); +} + +export function getPlanByPriceId(priceId: string): SubscriptionPlan | undefined { + return SUBSCRIPTION_PLANS.find( + (plan) => + plan.stripePriceIds.monthly === priceId || + plan.stripePriceIds.yearly === priceId + ); +} +``` + +### 3. Subscription Service + +**Subscription Management**: + +```typescript +// src/services/subscription.service.ts +import { stripe } from '../config/stripe'; +import type Stripe from 'stripe'; +import { getPlanById } from '../config/subscription-plans'; + +export class SubscriptionService { + /** + * Create a subscription with trial period + */ + async createSubscription(params: { + customerId: string; + priceId: string; + trialDays?: number; + quantity?: number; + couponId?: string; + metadata?: Record; + }): Promise { + try { + const subscriptionParams: Stripe.SubscriptionCreateParams = { + customer: params.customerId, + items: [ + { + price: params.priceId, + quantity: params.quantity || 1, + }, + ], + payment_behavior: 'default_incomplete', + payment_settings: { + save_default_payment_method: 'on_subscription', + }, + expand: ['latest_invoice.payment_intent'], + metadata: params.metadata, + }; + + // Add trial period if specified + if (params.trialDays && params.trialDays > 0) { + subscriptionParams.trial_period_days = params.trialDays; + } + + // Add coupon if specified + if (params.couponId) { + subscriptionParams.coupon = params.couponId; + } + + const subscription = await stripe.subscriptions.create(subscriptionParams); + + return subscription; + } catch (error) { + console.error('Failed to create subscription:', error); + throw new Error('Subscription creation failed'); + } + } + + /** + * Create subscription with checkout session + */ + async createSubscriptionCheckout(params: { + customerId?: string; + customerEmail?: string; + priceId: string; + trialDays?: number; + successUrl: string; + cancelUrl: string; + metadata?: Record; + }): Promise { + try { + const sessionParams: Stripe.Checkout.SessionCreateParams = { + mode: 'subscription', + line_items: [ + { + price: params.priceId, + quantity: 1, + }, + ], + success_url: params.successUrl, + cancel_url: params.cancelUrl, + metadata: params.metadata, + }; + + // Customer reference + if (params.customerId) { + sessionParams.customer = params.customerId; + } else if (params.customerEmail) { + sessionParams.customer_email = params.customerEmail; + } + + // Trial period + if (params.trialDays && params.trialDays > 0) { + sessionParams.subscription_data = { + trial_period_days: params.trialDays, + }; + } + + const session = await stripe.checkout.sessions.create(sessionParams); + + return session; + } catch (error) { + console.error('Failed to create subscription checkout:', error); + throw new Error('Checkout creation failed'); + } + } + + /** + * Retrieve subscription details + */ + async getSubscription(subscriptionId: string): Promise { + try { + return await stripe.subscriptions.retrieve(subscriptionId, { + expand: ['customer', 'default_payment_method', 'latest_invoice'], + }); + } catch (error) { + console.error('Failed to retrieve subscription:', error); + throw new Error('Subscription retrieval failed'); + } + } + + /** + * Update subscription (upgrade/downgrade) + */ + async updateSubscription(params: { + subscriptionId: string; + newPriceId: string; + prorationBehavior?: 'create_prorations' | 'none' | 'always_invoice'; + quantity?: number; + }): Promise { + try { + // Get current subscription + const subscription = await stripe.subscriptions.retrieve(params.subscriptionId); + + // Update subscription + const updated = await stripe.subscriptions.update(params.subscriptionId, { + items: [ + { + id: subscription.items.data[0].id, + price: params.newPriceId, + quantity: params.quantity, + }, + ], + proration_behavior: params.prorationBehavior || 'create_prorations', + }); + + return updated; + } catch (error) { + console.error('Failed to update subscription:', error); + throw new Error('Subscription update failed'); + } + } + + /** + * Cancel subscription (immediate or at period end) + */ + async cancelSubscription(params: { + subscriptionId: string; + immediately?: boolean; + cancellationReason?: string; + }): Promise { + try { + if (params.immediately) { + // Cancel immediately + return await stripe.subscriptions.cancel(params.subscriptionId, { + cancellation_details: { + comment: params.cancellationReason, + }, + }); + } else { + // Cancel at period end + return await stripe.subscriptions.update(params.subscriptionId, { + cancel_at_period_end: true, + cancellation_details: { + comment: params.cancellationReason, + }, + }); + } + } catch (error) { + console.error('Failed to cancel subscription:', error); + throw new Error('Subscription cancellation failed'); + } + } + + /** + * Resume a canceled subscription + */ + async resumeSubscription(subscriptionId: string): Promise { + try { + return await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: false, + }); + } catch (error) { + console.error('Failed to resume subscription:', error); + throw new Error('Subscription resume failed'); + } + } + + /** + * Pause subscription + */ + async pauseSubscription(params: { + subscriptionId: string; + resumeAt?: number; // Unix timestamp + }): Promise { + try { + return await stripe.subscriptions.update(params.subscriptionId, { + pause_collection: { + behavior: 'void', + resumes_at: params.resumeAt, + }, + }); + } catch (error) { + console.error('Failed to pause subscription:', error); + throw new Error('Subscription pause failed'); + } + } + + /** + * Resume paused subscription + */ + async unpauseSubscription(subscriptionId: string): Promise { + try { + return await stripe.subscriptions.update(subscriptionId, { + pause_collection: null as any, + }); + } catch (error) { + console.error('Failed to unpause subscription:', error); + throw new Error('Subscription unpause failed'); + } + } + + /** + * List customer subscriptions + */ + async listCustomerSubscriptions( + customerId: string + ): Promise { + try { + const subscriptions = await stripe.subscriptions.list({ + customer: customerId, + status: 'all', + expand: ['data.default_payment_method'], + }); + + return subscriptions.data; + } catch (error) { + console.error('Failed to list subscriptions:', error); + throw new Error('Subscription listing failed'); + } + } + + /** + * Get upcoming invoice (preview charges) + */ + async getUpcomingInvoice(params: { + customerId: string; + subscriptionId: string; + newPriceId?: string; + }): Promise { + try { + const invoiceParams: Stripe.InvoiceRetrieveUpcomingParams = { + customer: params.customerId, + subscription: params.subscriptionId, + }; + + // Preview plan change + if (params.newPriceId) { + const subscription = await stripe.subscriptions.retrieve(params.subscriptionId); + invoiceParams.subscription_items = [ + { + id: subscription.items.data[0].id, + price: params.newPriceId, + }, + ]; + } + + return await stripe.invoices.retrieveUpcoming(invoiceParams); + } catch (error) { + console.error('Failed to retrieve upcoming invoice:', error); + throw new Error('Invoice preview failed'); + } + } + + /** + * Create customer portal session + */ + async createPortalSession(params: { + customerId: string; + returnUrl: string; + }): Promise { + try { + return await stripe.billingPortal.sessions.create({ + customer: params.customerId, + return_url: params.returnUrl, + }); + } catch (error) { + console.error('Failed to create portal session:', error); + throw new Error('Portal session creation failed'); + } + } + + /** + * Apply coupon to subscription + */ + async applyCoupon( + subscriptionId: string, + couponId: string + ): Promise { + try { + return await stripe.subscriptions.update(subscriptionId, { + coupon: couponId, + }); + } catch (error) { + console.error('Failed to apply coupon:', error); + throw new Error('Coupon application failed'); + } + } + + /** + * Remove coupon from subscription + */ + async removeCoupon(subscriptionId: string): Promise { + try { + return await stripe.subscriptions.update(subscriptionId, { + coupon: '', + }); + } catch (error) { + console.error('Failed to remove coupon:', error); + throw new Error('Coupon removal failed'); + } + } +} + +export const subscriptionService = new SubscriptionService(); +``` + +### 4. API Routes + +**Subscription Endpoints**: + +```typescript +// src/routes/subscription.routes.ts +import { Router, Request, Response } from 'express'; +import { subscriptionService } from '../services/subscription.service'; + +const router = Router(); + +/** + * POST /api/subscriptions + * Create a subscription + */ +router.post('/', async (req: Request, res: Response) => { + try { + const { customerId, priceId, trialDays, quantity, couponId, metadata } = req.body; + + if (!customerId || !priceId) { + return res.status(400).json({ error: 'Customer ID and Price ID required' }); + } + + const subscription = await subscriptionService.createSubscription({ + customerId, + priceId, + trialDays, + quantity, + couponId, + metadata, + }); + + res.json({ + subscriptionId: subscription.id, + status: subscription.status, + clientSecret: (subscription.latest_invoice as any)?.payment_intent + ?.client_secret, + }); + } catch (error) { + console.error('Subscription creation error:', error); + res.status(500).json({ error: 'Failed to create subscription' }); + } +}); + +/** + * POST /api/subscriptions/checkout + * Create subscription checkout session + */ +router.post('/checkout', async (req: Request, res: Response) => { + try { + const { customerId, customerEmail, priceId, trialDays, metadata } = req.body; + + if (!priceId) { + return res.status(400).json({ error: 'Price ID required' }); + } + + const session = await subscriptionService.createSubscriptionCheckout({ + customerId, + customerEmail, + priceId, + trialDays, + successUrl: `${req.headers.origin}/subscription/success?session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${req.headers.origin}/subscription/cancel`, + metadata, + }); + + res.json({ sessionId: session.id, url: session.url }); + } catch (error) { + console.error('Checkout creation error:', error); + res.status(500).json({ error: 'Failed to create checkout' }); + } +}); + +/** + * GET /api/subscriptions/:id + * Get subscription details + */ +router.get('/:id', async (req: Request, res: Response) => { + try { + const subscription = await subscriptionService.getSubscription(req.params.id); + res.json(subscription); + } catch (error) { + console.error('Subscription retrieval error:', error); + res.status(500).json({ error: 'Failed to retrieve subscription' }); + } +}); + +/** + * PATCH /api/subscriptions/:id + * Update subscription (upgrade/downgrade) + */ +router.patch('/:id', async (req: Request, res: Response) => { + try { + const { newPriceId, quantity, prorationBehavior } = req.body; + + if (!newPriceId) { + return res.status(400).json({ error: 'New price ID required' }); + } + + const subscription = await subscriptionService.updateSubscription({ + subscriptionId: req.params.id, + newPriceId, + quantity, + prorationBehavior, + }); + + res.json(subscription); + } catch (error) { + console.error('Subscription update error:', error); + res.status(500).json({ error: 'Failed to update subscription' }); + } +}); + +/** + * DELETE /api/subscriptions/:id + * Cancel subscription + */ +router.delete('/:id', async (req: Request, res: Response) => { + try { + const { immediately, reason } = req.body; + + const subscription = await subscriptionService.cancelSubscription({ + subscriptionId: req.params.id, + immediately, + cancellationReason: reason, + }); + + res.json(subscription); + } catch (error) { + console.error('Subscription cancellation error:', error); + res.status(500).json({ error: 'Failed to cancel subscription' }); + } +}); + +/** + * POST /api/subscriptions/:id/resume + * Resume canceled subscription + */ +router.post('/:id/resume', async (req: Request, res: Response) => { + try { + const subscription = await subscriptionService.resumeSubscription(req.params.id); + res.json(subscription); + } catch (error) { + console.error('Subscription resume error:', error); + res.status(500).json({ error: 'Failed to resume subscription' }); + } +}); + +/** + * POST /api/subscriptions/:id/pause + * Pause subscription + */ +router.post('/:id/pause', async (req: Request, res: Response) => { + try { + const { resumeAt } = req.body; + + const subscription = await subscriptionService.pauseSubscription({ + subscriptionId: req.params.id, + resumeAt, + }); + + res.json(subscription); + } catch (error) { + console.error('Subscription pause error:', error); + res.status(500).json({ error: 'Failed to pause subscription' }); + } +}); + +/** + * POST /api/subscriptions/:id/unpause + * Resume paused subscription + */ +router.post('/:id/unpause', async (req: Request, res: Response) => { + try { + const subscription = await subscriptionService.unpauseSubscription(req.params.id); + res.json(subscription); + } catch (error) { + console.error('Subscription unpause error:', error); + res.status(500).json({ error: 'Failed to unpause subscription' }); + } +}); + +/** + * GET /api/subscriptions/customer/:customerId + * List customer subscriptions + */ +router.get('/customer/:customerId', async (req: Request, res: Response) => { + try { + const subscriptions = await subscriptionService.listCustomerSubscriptions( + req.params.customerId + ); + res.json(subscriptions); + } catch (error) { + console.error('Subscription listing error:', error); + res.status(500).json({ error: 'Failed to list subscriptions' }); + } +}); + +/** + * GET /api/subscriptions/:id/upcoming-invoice + * Preview upcoming invoice + */ +router.get('/:id/upcoming-invoice', async (req: Request, res: Response) => { + try { + const { customerId, newPriceId } = req.query; + + if (!customerId) { + return res.status(400).json({ error: 'Customer ID required' }); + } + + const invoice = await subscriptionService.getUpcomingInvoice({ + customerId: customerId as string, + subscriptionId: req.params.id, + newPriceId: newPriceId as string, + }); + + res.json(invoice); + } catch (error) { + console.error('Invoice preview error:', error); + res.status(500).json({ error: 'Failed to preview invoice' }); + } +}); + +/** + * POST /api/subscriptions/portal + * Create customer portal session + */ +router.post('/portal', async (req: Request, res: Response) => { + try { + const { customerId } = req.body; + + if (!customerId) { + return res.status(400).json({ error: 'Customer ID required' }); + } + + const session = await subscriptionService.createPortalSession({ + customerId, + returnUrl: `${req.headers.origin}/account`, + }); + + res.json({ url: session.url }); + } catch (error) { + console.error('Portal session error:', error); + res.status(500).json({ error: 'Failed to create portal session' }); + } +}); + +/** + * POST /api/subscriptions/:id/coupon + * Apply coupon to subscription + */ +router.post('/:id/coupon', async (req: Request, res: Response) => { + try { + const { couponId } = req.body; + + if (!couponId) { + return res.status(400).json({ error: 'Coupon ID required' }); + } + + const subscription = await subscriptionService.applyCoupon( + req.params.id, + couponId + ); + + res.json(subscription); + } catch (error) { + console.error('Coupon application error:', error); + res.status(500).json({ error: 'Failed to apply coupon' }); + } +}); + +/** + * DELETE /api/subscriptions/:id/coupon + * Remove coupon from subscription + */ +router.delete('/:id/coupon', async (req: Request, res: Response) => { + try { + const subscription = await subscriptionService.removeCoupon(req.params.id); + res.json(subscription); + } catch (error) { + console.error('Coupon removal error:', error); + res.status(500).json({ error: 'Failed to remove coupon' }); + } +}); + +export default router; +``` + +### 5. Frontend Components + +**Pricing Table**: + +```typescript +// src/components/PricingTable.tsx +import React from 'react'; +import { SUBSCRIPTION_PLANS } from '../config/subscription-plans'; + +interface PricingTableProps { + billingCycle: 'monthly' | 'yearly'; + onSelectPlan: (planId: string, priceId: string) => void; +} + +export const PricingTable: React.FC = ({ + billingCycle, + onSelectPlan, +}) => { + return ( +
+ {SUBSCRIPTION_PLANS.map((plan) => ( +
+ {plan.popular && ( + + Popular + + )} + +

{plan.name}

+

{plan.description}

+ +
+ + ${plan.prices[billingCycle] / 100} + + /{billingCycle === 'yearly' ? 'year' : 'month'} + {billingCycle === 'yearly' && plan.prices.yearly > 0 && ( +

+ Save ${(plan.prices.monthly * 12 - plan.prices.yearly) / 100}/year +

+ )} +
+ +
    + {plan.features.map((feature, index) => ( +
  • + + + + {feature} +
  • + ))} +
+ + +
+ ))} +
+ ); +}; +``` + +**Subscription Management**: + +```typescript +// src/components/SubscriptionManager.tsx +import React, { useState, useEffect } from 'react'; +import type Stripe from 'stripe'; + +interface SubscriptionManagerProps { + customerId: string; +} + +export const SubscriptionManager: React.FC = ({ + customerId, +}) => { + const [subscriptions, setSubscriptions] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadSubscriptions(); + }, [customerId]); + + const loadSubscriptions = async () => { + try { + const response = await fetch(`/api/subscriptions/customer/${customerId}`); + const data = await response.json(); + setSubscriptions(data); + } catch (error) { + console.error('Failed to load subscriptions:', error); + } finally { + setLoading(false); + } + }; + + const handleCancelSubscription = async (subscriptionId: string) => { + if (!confirm('Are you sure you want to cancel this subscription?')) { + return; + } + + try { + await fetch(`/api/subscriptions/${subscriptionId}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ immediately: false }), + }); + + await loadSubscriptions(); + alert('Subscription will be canceled at the end of the billing period'); + } catch (error) { + console.error('Failed to cancel subscription:', error); + alert('Failed to cancel subscription'); + } + }; + + const handleResumeSubscription = async (subscriptionId: string) => { + try { + await fetch(`/api/subscriptions/${subscriptionId}/resume`, { + method: 'POST', + }); + + await loadSubscriptions(); + alert('Subscription resumed successfully'); + } catch (error) { + console.error('Failed to resume subscription:', error); + alert('Failed to resume subscription'); + } + }; + + const handleManageBilling = async () => { + try { + const response = await fetch('/api/subscriptions/portal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customerId }), + }); + + const { url } = await response.json(); + window.location.href = url; + } catch (error) { + console.error('Failed to open billing portal:', error); + alert('Failed to open billing portal'); + } + }; + + if (loading) { + return
Loading subscriptions...
; + } + + return ( +
+
+

Your Subscriptions

+ +
+ +
+ {subscriptions.map((subscription) => ( +
+
+
+

+ {subscription.items.data[0].price.product as string} +

+

+ ${subscription.items.data[0].price.unit_amount! / 100}/ + {subscription.items.data[0].price.recurring?.interval} +

+

+ Status:{' '} + + {subscription.status} + +

+ {subscription.cancel_at_period_end && ( +

+ Cancels on{' '} + {new Date(subscription.current_period_end * 1000).toLocaleDateString()} +

+ )} +
+ +
+ {subscription.cancel_at_period_end ? ( + + ) : ( + + )} +
+
+
+ ))} + + {subscriptions.length === 0 && ( +

+ You don't have any active subscriptions +

+ )} +
+
+ ); +}; +``` + +### 6. Webhook Handling + +**Subscription Events**: + +```typescript +// src/webhooks/subscription.webhook.ts +import type Stripe from 'stripe'; + +export async function handleSubscriptionCreated( + subscription: Stripe.Subscription +): Promise { + console.log('Subscription created:', subscription.id); + + // Update database + // await db.subscriptions.create({ + // stripeSubscriptionId: subscription.id, + // customerId: subscription.customer, + // status: subscription.status, + // currentPeriodEnd: new Date(subscription.current_period_end * 1000), + // }); + + // Send welcome email +} + +export async function handleSubscriptionUpdated( + subscription: Stripe.Subscription +): Promise { + console.log('Subscription updated:', subscription.id); + + // Update database + // await db.subscriptions.update({ + // where: { stripeSubscriptionId: subscription.id }, + // data: { + // status: subscription.status, + // currentPeriodEnd: new Date(subscription.current_period_end * 1000), + // }, + // }); + + // Handle status changes + if (subscription.status === 'past_due') { + // Send payment failed email + } +} + +export async function handleSubscriptionDeleted( + subscription: Stripe.Subscription +): Promise { + console.log('Subscription deleted:', subscription.id); + + // Update database + // await db.subscriptions.update({ + // where: { stripeSubscriptionId: subscription.id }, + // data: { + // status: 'canceled', + // canceledAt: new Date(), + // }, + // }); + + // Revoke access + // Send cancellation confirmation email +} + +export async function handleInvoicePaymentSucceeded( + invoice: Stripe.Invoice +): Promise { + console.log('Invoice payment succeeded:', invoice.id); + + // Record payment + // Send receipt +} + +export async function handleInvoicePaymentFailed( + invoice: Stripe.Invoice +): Promise { + console.log('Invoice payment failed:', invoice.id); + + // Send payment failed notification + // Implement dunning management +} +``` + +## Output Deliverables + +When you complete this implementation, provide: + +1. **Configuration**: + - Subscription plans with pricing tiers + - Stripe product and price IDs + - Trial period settings + +2. **Backend Services**: + - Subscription service with all operations + - API routes for subscription management + - Webhook handlers for subscription events + +3. **Frontend Components**: + - Pricing table with plan comparison + - Subscription management dashboard + - Plan upgrade/downgrade UI + +4. **Documentation**: + - Subscription lifecycle diagram + - Upgrade/downgrade flow + - Proration explanation + - Cancellation policy + +5. **Testing**: + - Subscription creation tests + - Plan change tests + - Cancellation tests + - Trial period tests + +## Best Practices + +1. **Always use proration** for mid-cycle changes +2. **Implement trials** to reduce friction +3. **Allow cancellation at period end** (not immediately) +4. **Use customer portal** for self-service +5. **Send clear email notifications** for all subscription events +6. **Handle failed payments gracefully** with retry logic +7. **Preview charges** before plan changes +8. **Track subscription metrics** (MRR, churn, LTV) +9. **Offer annual discounts** to improve retention +10. **Make downgrades easy** to reduce immediate cancellations + +Subscriptions are the foundation of SaaS revenue. Implement them robustly with clear communication and excellent UX. diff --git a/commands/subscription-manage.md b/commands/subscription-manage.md new file mode 100644 index 0000000..75f5700 --- /dev/null +++ b/commands/subscription-manage.md @@ -0,0 +1,386 @@ +# Subscription Management + +Generate complete subscription management system. + +## Task + +You are a subscription billing expert. Generate production-ready subscription management with billing, upgrades, and cancellations. + +### Steps: + +1. **Ask for Requirements**: + - Pricing tiers (Basic, Pro, Enterprise) + - Billing interval (monthly, annual) + - Features per tier + - Trial period + +2. **Generate Pricing Configuration**: + +```typescript +// config/pricing.ts +export const PRICING_PLANS = { + basic: { + id: 'basic', + name: 'Basic', + description: 'For individuals and small teams', + prices: { + monthly: { + amount: 9, + stripePriceId: 'price_basic_monthly', + }, + annual: { + amount: 90, + stripePriceId: 'price_basic_annual', + savings: 18, // 2 months free + }, + }, + features: [ + '10 projects', + '5 GB storage', + 'Basic support', + ], + limits: { + projects: 10, + storage: 5 * 1024 * 1024 * 1024, // 5 GB in bytes + apiCallsPerMonth: 10000, + }, + }, + pro: { + id: 'pro', + name: 'Pro', + description: 'For growing teams', + prices: { + monthly: { + amount: 29, + stripePriceId: 'price_pro_monthly', + }, + annual: { + amount: 290, + stripePriceId: 'price_pro_annual', + savings: 58, + }, + }, + features: [ + 'Unlimited projects', + '50 GB storage', + 'Priority support', + 'Advanced analytics', + ], + limits: { + projects: Infinity, + storage: 50 * 1024 * 1024 * 1024, + apiCallsPerMonth: 100000, + }, + }, + enterprise: { + id: 'enterprise', + name: 'Enterprise', + description: 'For large organizations', + prices: { + monthly: { + amount: 99, + stripePriceId: 'price_enterprise_monthly', + }, + annual: { + amount: 990, + stripePriceId: 'price_enterprise_annual', + savings: 198, + }, + }, + features: [ + 'Unlimited everything', + '1 TB storage', + '24/7 dedicated support', + 'Custom integrations', + 'SLA guarantee', + ], + limits: { + projects: Infinity, + storage: 1024 * 1024 * 1024 * 1024, + apiCallsPerMonth: Infinity, + }, + }, +}; +``` + +3. **Generate Subscription Service**: + +```typescript +// services/subscription.service.ts +import Stripe from 'stripe'; +import { PRICING_PLANS } from '../config/pricing'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + +export class SubscriptionService { + // Create subscription + async create(userId: string, planId: string, interval: 'monthly' | 'annual') { + const user = await db.users.findUnique({ where: { id: userId } }); + const plan = PRICING_PLANS[planId]; + + if (!plan) throw new Error('Invalid plan'); + + // Create Stripe customer if doesn't exist + let customerId = user.stripeCustomerId; + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { userId }, + }); + customerId = customer.id; + await db.users.update({ + where: { id: userId }, + data: { stripeCustomerId: customerId }, + }); + } + + // Create subscription + const subscription = await stripe.subscriptions.create({ + customer: customerId, + items: [{ price: plan.prices[interval].stripePriceId }], + trial_period_days: 14, // 14-day trial + payment_behavior: 'default_incomplete', + payment_settings: { + save_default_payment_method: 'on_subscription', + }, + expand: ['latest_invoice.payment_intent'], + }); + + // Save to database + await db.subscriptions.create({ + data: { + userId, + stripeSubscriptionId: subscription.id, + stripePriceId: plan.prices[interval].stripePriceId, + status: subscription.status, + planId, + interval, + currentPeriodStart: new Date(subscription.current_period_start * 1000), + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + trialEnd: subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : null, + }, + }); + + return { + subscriptionId: subscription.id, + clientSecret: subscription.latest_invoice.payment_intent.client_secret, + }; + } + + // Upgrade/downgrade subscription + async changePlan(userId: string, newPlanId: string, newInterval: 'monthly' | 'annual') { + const subscription = await db.subscriptions.findFirst({ + where: { userId, status: 'active' }, + }); + + if (!subscription) throw new Error('No active subscription'); + + const newPlan = PRICING_PLANS[newPlanId]; + const newPriceId = newPlan.prices[newInterval].stripePriceId; + + // Update Stripe subscription + const stripeSubscription = await stripe.subscriptions.retrieve( + subscription.stripeSubscriptionId + ); + + const updatedSubscription = await stripe.subscriptions.update( + subscription.stripeSubscriptionId, + { + items: [ + { + id: stripeSubscription.items.data[0].id, + price: newPriceId, + }, + ], + proration_behavior: 'always_invoice', // Prorate charges + } + ); + + // Update database + await db.subscriptions.update({ + where: { id: subscription.id }, + data: { + stripePriceId: newPriceId, + planId: newPlanId, + interval: newInterval, + }, + }); + + return updatedSubscription; + } + + // Cancel subscription + async cancel(userId: string, cancelAtPeriodEnd = true) { + const subscription = await db.subscriptions.findFirst({ + where: { userId, status: 'active' }, + }); + + if (!subscription) throw new Error('No active subscription'); + + if (cancelAtPeriodEnd) { + // Cancel at end of billing period + await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + cancel_at_period_end: true, + }); + + await db.subscriptions.update({ + where: { id: subscription.id }, + data: { cancelAtPeriodEnd: true }, + }); + } else { + // Cancel immediately + await stripe.subscriptions.cancel(subscription.stripeSubscriptionId); + + await db.subscriptions.update({ + where: { id: subscription.id }, + data: { status: 'canceled', canceledAt: new Date() }, + }); + } + } + + // Resume canceled subscription + async resume(userId: string) { + const subscription = await db.subscriptions.findFirst({ + where: { userId, cancelAtPeriodEnd: true }, + }); + + if (!subscription) throw new Error('No subscription to resume'); + + await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + cancel_at_period_end: false, + }); + + await db.subscriptions.update({ + where: { id: subscription.id }, + data: { cancelAtPeriodEnd: false }, + }); + } + + // Get subscription status + async getStatus(userId: string) { + const subscription = await db.subscriptions.findFirst({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + + if (!subscription) return null; + + const plan = PRICING_PLANS[subscription.planId]; + return { + ...subscription, + plan, + isActive: subscription.status === 'active', + isTrialing: subscription.status === 'trialing', + isCanceling: subscription.cancelAtPeriodEnd, + }; + } + + // Check feature access + async canAccess(userId: string, feature: string, value?: number) { + const status = await this.getStatus(userId); + + if (!status || !status.isActive) return false; + + const limits = status.plan.limits; + + // Check specific limits + switch (feature) { + case 'projects': + const projectCount = await db.projects.count({ where: { userId } }); + return projectCount < limits.projects; + + case 'storage': + const storageUsed = await this.getStorageUsage(userId); + return storageUsed < limits.storage; + + case 'api_calls': + const apiCalls = await this.getApiCallsThisMonth(userId); + return apiCalls < limits.apiCallsPerMonth; + + default: + return false; + } + } +} +``` + +4. **Generate Usage Tracking**: + +```typescript +// Track API usage for metered billing +export class UsageTracker { + async recordApiCall(userId: string) { + const subscription = await db.subscriptions.findFirst({ + where: { userId, status: 'active' }, + }); + + if (!subscription) return; + + // Increment usage + await db.usageRecords.create({ + data: { + subscriptionId: subscription.id, + type: 'api_call', + quantity: 1, + timestamp: new Date(), + }, + }); + + // Optional: Report to Stripe for metered billing + if (subscription.meteringEnabled) { + await stripe.subscriptionItems.createUsageRecord( + subscription.stripeSubscriptionItemId, + { + quantity: 1, + timestamp: Math.floor(Date.now() / 1000), + } + ); + } + } + + async getUsage(userId: string, period: 'month' | 'all' = 'month') { + const subscription = await db.subscriptions.findFirst({ + where: { userId }, + }); + + if (!subscription) return null; + + const startDate = + period === 'month' + ? new Date(new Date().setDate(1)) // Start of month + : undefined; + + const usage = await db.usageRecords.groupBy({ + by: ['type'], + where: { + subscriptionId: subscription.id, + timestamp: startDate ? { gte: startDate } : undefined, + }, + _sum: { + quantity: true, + }, + }); + + return usage; + } +} +``` + +### Best Practices Included: + +- Trial periods +- Proration on plan changes +- Cancel at period end vs immediate +- Usage tracking for metered billing +- Feature gating based on plan +- Subscription resumption +- Clear pricing configuration + +### Example Usage: + +``` +User: "Set up subscription with Basic, Pro, Enterprise tiers" +Result: Complete subscription system with billing, upgrades, trials +``` diff --git a/commands/webhook-setup.md b/commands/webhook-setup.md new file mode 100644 index 0000000..a9a2132 --- /dev/null +++ b/commands/webhook-setup.md @@ -0,0 +1,295 @@ +# Payment Webhook Configuration + +Generate secure webhook handlers for payment providers. + +## Task + +You are a payment webhook security expert. Generate secure, production-ready webhook handlers. + +### Steps: + +1. **Ask for Provider**: + - Stripe + - PayPal + - Square + - Custom payment gateway + +2. **Generate Webhook Endpoint** (Stripe): + +```typescript +import crypto from 'crypto'; +import express from 'express'; +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); +const app = express(); + +// CRITICAL: Use raw body for webhook signature verification +app.post( + '/api/webhooks/stripe', + express.raw({ type: 'application/json' }), + async (req, res) => { + const sig = req.headers['stripe-signature']; + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; + + let event; + + try { + // Verify webhook signature + event = stripe.webhooks.constructEvent( + req.body, + sig!, + webhookSecret + ); + } catch (err) { + console.error(`Webhook signature verification failed: ${err.message}`); + return res.status(400).send(`Webhook Error: ${err.message}`); + } + + // Handle event idempotently + const eventId = event.id; + const existingEvent = await db.webhookEvents.findUnique({ + where: { stripeEventId: eventId }, + }); + + if (existingEvent) { + console.log(`Duplicate webhook event: ${eventId}`); + return res.status(200).json({ received: true }); + } + + // Store event (prevents duplicate processing) + await db.webhookEvents.create({ + data: { + stripeEventId: eventId, + type: event.type, + processedAt: new Date(), + }, + }); + + // Process event in background to return 200 quickly + processWebhookEvent(event).catch((error) => { + console.error(`Failed to process webhook: ${error.message}`); + // Alert ops team + }); + + res.status(200).json({ received: true }); + } +); + +async function processWebhookEvent(event: Stripe.Event) { + switch (event.type) { + case 'checkout.session.completed': + await handleCheckoutComplete(event.data.object); + break; + + case 'customer.subscription.created': + case 'customer.subscription.updated': + await handleSubscriptionChange(event.data.object); + break; + + case 'customer.subscription.deleted': + await handleSubscriptionCanceled(event.data.object); + break; + + case 'invoice.paid': + await handleInvoicePaid(event.data.object); + break; + + case 'invoice.payment_failed': + await handleInvoicePaymentFailed(event.data.object); + break; + + case 'payment_intent.succeeded': + await handlePaymentSuccess(event.data.object); + break; + + case 'payment_intent.payment_failed': + await handlePaymentFailed(event.data.object); + break; + + case 'charge.dispute.created': + await handleDisputeCreated(event.data.object); + break; + + case 'customer.created': + await handleCustomerCreated(event.data.object); + break; + + default: + console.log(`Unhandled event type: ${event.type}`); + } +} +``` + +3. **Generate PayPal Webhook**: + +```typescript +import crypto from 'crypto'; + +app.post('/api/webhooks/paypal', express.json(), async (req, res) => { + const webhookId = process.env.PAYPAL_WEBHOOK_ID!; + const webhookEvent = req.body; + + // Verify PayPal webhook signature + const isValid = await verifyPayPalWebhook(req, webhookId); + + if (!isValid) { + return res.status(400).send('Invalid webhook signature'); + } + + const eventType = webhookEvent.event_type; + + switch (eventType) { + case 'PAYMENT.CAPTURE.COMPLETED': + await handlePayPalPaymentCompleted(webhookEvent.resource); + break; + + case 'BILLING.SUBSCRIPTION.CREATED': + await handlePayPalSubscriptionCreated(webhookEvent.resource); + break; + + case 'BILLING.SUBSCRIPTION.CANCELLED': + await handlePayPalSubscriptionCancelled(webhookEvent.resource); + break; + + default: + console.log(`Unhandled PayPal event: ${eventType}`); + } + + res.status(200).json({ received: true }); +}); + +async function verifyPayPalWebhook(req, webhookId) { + const transmissionId = req.headers['paypal-transmission-id']; + const timestamp = req.headers['paypal-transmission-time']; + const signature = req.headers['paypal-transmission-sig']; + const certUrl = req.headers['paypal-cert-url']; + + const response = await fetch( + `https://api.paypal.com/v1/notifications/verify-webhook-signature`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${await getPayPalAccessToken()}`, + }, + body: JSON.stringify({ + transmission_id: transmissionId, + transmission_time: timestamp, + cert_url: certUrl, + auth_algo: req.headers['paypal-auth-algo'], + transmission_sig: signature, + webhook_id: webhookId, + webhook_event: req.body, + }), + } + ); + + const data = await response.json(); + return data.verification_status === 'SUCCESS'; +} +``` + +4. **Generate Webhook Testing Script**: + +```typescript +// test-webhook.ts +import { exec } from 'child_process'; +import util from 'util'; + +const execPromise = util.promisify(exec); + +async function testWebhook() { + // Install Stripe CLI: brew install stripe/stripe-cli/stripe + + // Listen to webhooks + const { stdout } = await execPromise('stripe listen --forward-to localhost:3000/api/webhooks/stripe'); + console.log(stdout); + + // Trigger test events + await execPromise('stripe trigger payment_intent.succeeded'); + await execPromise('stripe trigger customer.subscription.created'); + await execPromise('stripe trigger invoice.payment_failed'); +} + +testWebhook(); +``` + +5. **Generate Monitoring & Alerting**: + +```typescript +// Monitor webhook failures +async function monitorWebhooks() { + const failedEvents = await db.webhookEvents.findMany({ + where: { + processed: false, + createdAt: { + lt: new Date(Date.now() - 5 * 60 * 1000), // 5 minutes ago + }, + }, + }); + + if (failedEvents.length > 0) { + await sendAlert({ + type: 'webhook_failure', + count: failedEvents.length, + events: failedEvents.map((e) => e.type), + }); + } +} + +// Retry failed webhooks +async function retryFailedWebhooks() { + const failedEvents = await db.webhookEvents.findMany({ + where: { processed: false }, + take: 10, + }); + + for (const event of failedEvents) { + try { + await processWebhookEvent(event.data); + await db.webhookEvents.update({ + where: { id: event.id }, + data: { processed: true, processedAt: new Date() }, + }); + } catch (error) { + console.error(`Retry failed for event ${event.id}: ${error.message}`); + } + } +} +``` + +6. **Generate Webhook Schema** (Database): + +```sql +CREATE TABLE webhook_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stripe_event_id VARCHAR(255) UNIQUE NOT NULL, + type VARCHAR(100) NOT NULL, + data JSONB NOT NULL, + processed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW(), + processed_at TIMESTAMP +); + +CREATE INDEX idx_webhook_events_type ON webhook_events(type); +CREATE INDEX idx_webhook_events_processed ON webhook_events(processed); +``` + +### Security Best Practices: + +- ✅ Verify webhook signatures (prevent spoofing) +- ✅ Use raw request body for signature validation +- ✅ Idempotency (track event IDs, prevent duplicate processing) +- ✅ Return 200 immediately (process in background) +- ✅ Retry logic for failures +- ✅ Monitoring and alerting +- ✅ HTTPS only (secure transmission) +- ✅ IP whitelisting (optional but recommended) + +### Example Usage: + +``` +User: "Set up secure Stripe webhook handler" +Result: Complete webhook endpoint with signature verification, idempotency, and monitoring +``` diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..962f781 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,77 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:anton-abyzov/specweave:plugins/specweave-payments", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "74ff4ef1842f904cc4ce74a8a7260a4b1d345033", + "treeHash": "90ff82e9531aa182ea37ac8bece0e5ccbc71564b9c519ffc0a52f4c39b48ad3f", + "generatedAt": "2025-11-28T10:13:53.304035Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "specweave-payments", + "description": "Payment processing integration for Stripe, PayPal, and billing automation. Includes checkout flows, subscription lifecycle management, PCI DSS compliance guidance, and recurring billing. Focus on production-ready payment systems.", + "version": "0.22.14" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "e8e8f19307f83e5dd609e9656899fd8ab8b466f3303f23a3043d162d1e49013c" + }, + { + "path": "agents/payment-integration/AGENT.md", + "sha256": "020245ca834a42196f6b0dde85cb688cefe2496766da3920cfc883a124e58bee" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "017c97bf830c1c16c68541d1ae3acb50e00f358a82d741b25dfabdc01ced480c" + }, + { + "path": "commands/webhook-setup.md", + "sha256": "7a5486eca995fa3c6f24362045c6dda1c515dd0afecb1d23d658705feab4a6bf" + }, + { + "path": "commands/subscription-manage.md", + "sha256": "96968e02e733befb7839b6119c7180120609fa819370d07cb58b78782b0f0036" + }, + { + "path": "commands/stripe-setup.md", + "sha256": "93d4fffbd3fb7f53107474c05d9c43891da31f37aacebd012c7fc66a4c1e956a" + }, + { + "path": "commands/subscription-flow.md", + "sha256": "fdab7bbbc1b8dc5ae334d667043aafdf5582f75be5e07eb946d9ca5fc3867672" + }, + { + "path": "skills/stripe-integration/SKILL.md", + "sha256": "c424fb2edf330858cc28b6416abdc09eb1233a0cff98231fa917fba401c3fe6b" + }, + { + "path": "skills/billing-automation/SKILL.md", + "sha256": "d8cfd13532ef4573539de5b3404c5cb794e9988c96f54c158de37da91ff91caf" + }, + { + "path": "skills/paypal-integration/SKILL.md", + "sha256": "88802f7ef05d07ff15f8e8e0d2a37714599dc7629aac1d21e4ecba06cdbd6ce0" + }, + { + "path": "skills/pci-compliance/SKILL.md", + "sha256": "9c50102176f3b0108358299aa7de9168aec84ab1c1fb88b7beaf12ef58e78ee4" + } + ], + "dirSha256": "90ff82e9531aa182ea37ac8bece0e5ccbc71564b9c519ffc0a52f4c39b48ad3f" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/billing-automation/SKILL.md b/skills/billing-automation/SKILL.md new file mode 100644 index 0000000..8c65b4a --- /dev/null +++ b/skills/billing-automation/SKILL.md @@ -0,0 +1,559 @@ +--- +name: billing-automation +description: Build automated billing systems for recurring payments, invoicing, subscription lifecycle, and dunning management. Use when implementing subscription billing, automating invoicing, or managing recurring payment systems. +--- + +# Billing Automation + +Master automated billing systems including recurring billing, invoice generation, dunning management, proration, and tax calculation. + +## When to Use This Skill + +- Implementing SaaS subscription billing +- Automating invoice generation and delivery +- Managing failed payment recovery (dunning) +- Calculating prorated charges for plan changes +- Handling sales tax, VAT, and GST +- Processing usage-based billing +- Managing billing cycles and renewals + +## Core Concepts + +### 1. Billing Cycles +**Common Intervals:** +- Monthly (most common for SaaS) +- Annual (discounted long-term) +- Quarterly +- Weekly +- Custom (usage-based, per-seat) + +### 2. Subscription States +``` +trial → active → past_due → canceled + → paused → resumed +``` + +### 3. Dunning Management +Automated process to recover failed payments through: +- Retry schedules +- Customer notifications +- Grace periods +- Account restrictions + +### 4. Proration +Adjusting charges when: +- Upgrading/downgrading mid-cycle +- Adding/removing seats +- Changing billing frequency + +## Quick Start + +```python +from billing import BillingEngine, Subscription + +# Initialize billing engine +billing = BillingEngine() + +# Create subscription +subscription = billing.create_subscription( + customer_id="cus_123", + plan_id="plan_pro_monthly", + billing_cycle_anchor=datetime.now(), + trial_days=14 +) + +# Process billing cycle +billing.process_billing_cycle(subscription.id) +``` + +## Subscription Lifecycle Management + +```python +from datetime import datetime, timedelta +from enum import Enum + +class SubscriptionStatus(Enum): + TRIAL = "trial" + ACTIVE = "active" + PAST_DUE = "past_due" + CANCELED = "canceled" + PAUSED = "paused" + +class Subscription: + def __init__(self, customer_id, plan, billing_cycle_day=None): + self.id = generate_id() + self.customer_id = customer_id + self.plan = plan + self.status = SubscriptionStatus.TRIAL + self.current_period_start = datetime.now() + self.current_period_end = self.current_period_start + timedelta(days=plan.trial_days or 30) + self.billing_cycle_day = billing_cycle_day or self.current_period_start.day + self.trial_end = datetime.now() + timedelta(days=plan.trial_days) if plan.trial_days else None + + def start_trial(self, trial_days): + """Start trial period.""" + self.status = SubscriptionStatus.TRIAL + self.trial_end = datetime.now() + timedelta(days=trial_days) + self.current_period_end = self.trial_end + + def activate(self): + """Activate subscription after trial or immediately.""" + self.status = SubscriptionStatus.ACTIVE + self.current_period_start = datetime.now() + self.current_period_end = self.calculate_next_billing_date() + + def mark_past_due(self): + """Mark subscription as past due after failed payment.""" + self.status = SubscriptionStatus.PAST_DUE + # Trigger dunning workflow + + def cancel(self, at_period_end=True): + """Cancel subscription.""" + if at_period_end: + self.cancel_at_period_end = True + # Will cancel when current period ends + else: + self.status = SubscriptionStatus.CANCELED + self.canceled_at = datetime.now() + + def calculate_next_billing_date(self): + """Calculate next billing date based on interval.""" + if self.plan.interval == 'month': + return self.current_period_start + timedelta(days=30) + elif self.plan.interval == 'year': + return self.current_period_start + timedelta(days=365) + elif self.plan.interval == 'week': + return self.current_period_start + timedelta(days=7) +``` + +## Billing Cycle Processing + +```python +class BillingEngine: + def process_billing_cycle(self, subscription_id): + """Process billing for a subscription.""" + subscription = self.get_subscription(subscription_id) + + # Check if billing is due + if datetime.now() < subscription.current_period_end: + return + + # Generate invoice + invoice = self.generate_invoice(subscription) + + # Attempt payment + payment_result = self.charge_customer( + subscription.customer_id, + invoice.total + ) + + if payment_result.success: + # Payment successful + invoice.mark_paid() + subscription.advance_billing_period() + self.send_invoice(invoice) + else: + # Payment failed + subscription.mark_past_due() + self.start_dunning_process(subscription, invoice) + + def generate_invoice(self, subscription): + """Generate invoice for billing period.""" + invoice = Invoice( + customer_id=subscription.customer_id, + subscription_id=subscription.id, + period_start=subscription.current_period_start, + period_end=subscription.current_period_end + ) + + # Add subscription line item + invoice.add_line_item( + description=subscription.plan.name, + amount=subscription.plan.amount, + quantity=subscription.quantity or 1 + ) + + # Add usage-based charges if applicable + if subscription.has_usage_billing: + usage_charges = self.calculate_usage_charges(subscription) + invoice.add_line_item( + description="Usage charges", + amount=usage_charges + ) + + # Calculate tax + tax = self.calculate_tax(invoice.subtotal, subscription.customer) + invoice.tax = tax + + invoice.finalize() + return invoice + + def charge_customer(self, customer_id, amount): + """Charge customer using saved payment method.""" + customer = self.get_customer(customer_id) + + try: + # Charge using payment processor + charge = stripe.Charge.create( + customer=customer.stripe_id, + amount=int(amount * 100), # Convert to cents + currency='usd' + ) + + return PaymentResult(success=True, transaction_id=charge.id) + except stripe.error.CardError as e: + return PaymentResult(success=False, error=str(e)) +``` + +## Dunning Management + +```python +class DunningManager: + """Manage failed payment recovery.""" + + def __init__(self): + self.retry_schedule = [ + {'days': 3, 'email_template': 'payment_failed_first'}, + {'days': 7, 'email_template': 'payment_failed_reminder'}, + {'days': 14, 'email_template': 'payment_failed_final'} + ] + + def start_dunning_process(self, subscription, invoice): + """Start dunning process for failed payment.""" + dunning_attempt = DunningAttempt( + subscription_id=subscription.id, + invoice_id=invoice.id, + attempt_number=1, + next_retry=datetime.now() + timedelta(days=3) + ) + + # Send initial failure notification + self.send_dunning_email(subscription, 'payment_failed_first') + + # Schedule retries + self.schedule_retries(dunning_attempt) + + def retry_payment(self, dunning_attempt): + """Retry failed payment.""" + subscription = self.get_subscription(dunning_attempt.subscription_id) + invoice = self.get_invoice(dunning_attempt.invoice_id) + + # Attempt payment again + result = self.charge_customer(subscription.customer_id, invoice.total) + + if result.success: + # Payment succeeded + invoice.mark_paid() + subscription.status = SubscriptionStatus.ACTIVE + self.send_dunning_email(subscription, 'payment_recovered') + dunning_attempt.mark_resolved() + else: + # Still failing + dunning_attempt.attempt_number += 1 + + if dunning_attempt.attempt_number < len(self.retry_schedule): + # Schedule next retry + next_retry_config = self.retry_schedule[dunning_attempt.attempt_number] + dunning_attempt.next_retry = datetime.now() + timedelta(days=next_retry_config['days']) + self.send_dunning_email(subscription, next_retry_config['email_template']) + else: + # Exhausted retries, cancel subscription + subscription.cancel(at_period_end=False) + self.send_dunning_email(subscription, 'subscription_canceled') + + def send_dunning_email(self, subscription, template): + """Send dunning notification to customer.""" + customer = self.get_customer(subscription.customer_id) + + email_content = self.render_template(template, { + 'customer_name': customer.name, + 'amount_due': subscription.plan.amount, + 'update_payment_url': f"https://app.example.com/billing" + }) + + send_email( + to=customer.email, + subject=email_content['subject'], + body=email_content['body'] + ) +``` + +## Proration + +```python +class ProrationCalculator: + """Calculate prorated charges for plan changes.""" + + @staticmethod + def calculate_proration(old_plan, new_plan, period_start, period_end, change_date): + """Calculate proration for plan change.""" + # Days in current period + total_days = (period_end - period_start).days + + # Days used on old plan + days_used = (change_date - period_start).days + + # Days remaining on new plan + days_remaining = (period_end - change_date).days + + # Calculate prorated amounts + unused_amount = (old_plan.amount / total_days) * days_remaining + new_plan_amount = (new_plan.amount / total_days) * days_remaining + + # Net charge/credit + proration = new_plan_amount - unused_amount + + return { + 'old_plan_credit': -unused_amount, + 'new_plan_charge': new_plan_amount, + 'net_proration': proration, + 'days_used': days_used, + 'days_remaining': days_remaining + } + + @staticmethod + def calculate_seat_proration(current_seats, new_seats, price_per_seat, period_start, period_end, change_date): + """Calculate proration for seat changes.""" + total_days = (period_end - period_start).days + days_remaining = (period_end - change_date).days + + # Additional seats charge + additional_seats = new_seats - current_seats + prorated_amount = (additional_seats * price_per_seat / total_days) * days_remaining + + return { + 'additional_seats': additional_seats, + 'prorated_charge': max(0, prorated_amount), # No refund for removing seats mid-cycle + 'effective_date': change_date + } +``` + +## Tax Calculation + +```python +class TaxCalculator: + """Calculate sales tax, VAT, GST.""" + + def __init__(self): + # Tax rates by region + self.tax_rates = { + 'US_CA': 0.0725, # California sales tax + 'US_NY': 0.04, # New York sales tax + 'GB': 0.20, # UK VAT + 'DE': 0.19, # Germany VAT + 'FR': 0.20, # France VAT + 'AU': 0.10, # Australia GST + } + + def calculate_tax(self, amount, customer): + """Calculate applicable tax.""" + # Determine tax jurisdiction + jurisdiction = self.get_tax_jurisdiction(customer) + + if not jurisdiction: + return 0 + + # Get tax rate + tax_rate = self.tax_rates.get(jurisdiction, 0) + + # Calculate tax + tax = amount * tax_rate + + return { + 'tax_amount': tax, + 'tax_rate': tax_rate, + 'jurisdiction': jurisdiction, + 'tax_type': self.get_tax_type(jurisdiction) + } + + def get_tax_jurisdiction(self, customer): + """Determine tax jurisdiction based on customer location.""" + if customer.country == 'US': + # US: Tax based on customer state + return f"US_{customer.state}" + elif customer.country in ['GB', 'DE', 'FR']: + # EU: VAT + return customer.country + elif customer.country == 'AU': + # Australia: GST + return 'AU' + else: + return None + + def get_tax_type(self, jurisdiction): + """Get type of tax for jurisdiction.""" + if jurisdiction.startswith('US_'): + return 'Sales Tax' + elif jurisdiction in ['GB', 'DE', 'FR']: + return 'VAT' + elif jurisdiction == 'AU': + return 'GST' + return 'Tax' + + def validate_vat_number(self, vat_number, country): + """Validate EU VAT number.""" + # Use VIES API for validation + # Returns True if valid, False otherwise + pass +``` + +## Invoice Generation + +```python +class Invoice: + def __init__(self, customer_id, subscription_id=None): + self.id = generate_invoice_number() + self.customer_id = customer_id + self.subscription_id = subscription_id + self.status = 'draft' + self.line_items = [] + self.subtotal = 0 + self.tax = 0 + self.total = 0 + self.created_at = datetime.now() + + def add_line_item(self, description, amount, quantity=1): + """Add line item to invoice.""" + line_item = { + 'description': description, + 'unit_amount': amount, + 'quantity': quantity, + 'total': amount * quantity + } + self.line_items.append(line_item) + self.subtotal += line_item['total'] + + def finalize(self): + """Finalize invoice and calculate total.""" + self.total = self.subtotal + self.tax + self.status = 'open' + self.finalized_at = datetime.now() + + def mark_paid(self): + """Mark invoice as paid.""" + self.status = 'paid' + self.paid_at = datetime.now() + + def to_pdf(self): + """Generate PDF invoice.""" + from reportlab.pdfgen import canvas + + # Generate PDF + # Include: company info, customer info, line items, tax, total + pass + + def to_html(self): + """Generate HTML invoice.""" + template = """ + + + Invoice #{invoice_number} + +

Invoice #{invoice_number}

+

Date: {date}

+

Bill To:

+

{customer_name}
{customer_address}

+ + + {line_items} +
DescriptionQuantityAmount
+

Subtotal: ${subtotal}

+

Tax: ${tax}

+

Total: ${total}

+ + + """ + + return template.format( + invoice_number=self.id, + date=self.created_at.strftime('%Y-%m-%d'), + customer_name=self.customer.name, + customer_address=self.customer.address, + line_items=self.render_line_items(), + subtotal=self.subtotal, + tax=self.tax, + total=self.total + ) +``` + +## Usage-Based Billing + +```python +class UsageBillingEngine: + """Track and bill for usage.""" + + def track_usage(self, customer_id, metric, quantity): + """Track usage event.""" + UsageRecord.create( + customer_id=customer_id, + metric=metric, + quantity=quantity, + timestamp=datetime.now() + ) + + def calculate_usage_charges(self, subscription, period_start, period_end): + """Calculate charges for usage in billing period.""" + usage_records = UsageRecord.get_for_period( + subscription.customer_id, + period_start, + period_end + ) + + total_usage = sum(record.quantity for record in usage_records) + + # Tiered pricing + if subscription.plan.pricing_model == 'tiered': + charge = self.calculate_tiered_pricing(total_usage, subscription.plan.tiers) + # Per-unit pricing + elif subscription.plan.pricing_model == 'per_unit': + charge = total_usage * subscription.plan.unit_price + # Volume pricing + elif subscription.plan.pricing_model == 'volume': + charge = self.calculate_volume_pricing(total_usage, subscription.plan.tiers) + + return charge + + def calculate_tiered_pricing(self, total_usage, tiers): + """Calculate cost using tiered pricing.""" + charge = 0 + remaining = total_usage + + for tier in sorted(tiers, key=lambda x: x['up_to']): + tier_usage = min(remaining, tier['up_to'] - tier['from']) + charge += tier_usage * tier['unit_price'] + remaining -= tier_usage + + if remaining <= 0: + break + + return charge +``` + +## Resources + +- **references/billing-cycles.md**: Billing cycle management +- **references/dunning-management.md**: Failed payment recovery +- **references/proration.md**: Prorated charge calculations +- **references/tax-calculation.md**: Tax/VAT/GST handling +- **references/invoice-lifecycle.md**: Invoice state management +- **assets/billing-state-machine.yaml**: Billing workflow +- **assets/invoice-template.html**: Invoice templates +- **assets/dunning-policy.yaml**: Dunning configuration + +## Best Practices + +1. **Automate Everything**: Minimize manual intervention +2. **Clear Communication**: Notify customers of billing events +3. **Flexible Retry Logic**: Balance recovery with customer experience +4. **Accurate Proration**: Fair calculation for plan changes +5. **Tax Compliance**: Calculate correct tax for jurisdiction +6. **Audit Trail**: Log all billing events +7. **Graceful Degradation**: Handle edge cases without breaking + +## Common Pitfalls + +- **Incorrect Proration**: Not accounting for partial periods +- **Missing Tax**: Forgetting to add tax to invoices +- **Aggressive Dunning**: Canceling too quickly +- **No Notifications**: Not informing customers of failures +- **Hardcoded Cycles**: Not supporting custom billing dates diff --git a/skills/paypal-integration/SKILL.md b/skills/paypal-integration/SKILL.md new file mode 100644 index 0000000..a649c66 --- /dev/null +++ b/skills/paypal-integration/SKILL.md @@ -0,0 +1,467 @@ +--- +name: paypal-integration +description: Integrate PayPal payment processing with support for express checkout, subscriptions, and refund management. Use when implementing PayPal payments, processing online transactions, or building e-commerce checkout flows. +--- + +# PayPal Integration + +Master PayPal payment integration including Express Checkout, IPN handling, recurring billing, and refund workflows. + +## When to Use This Skill + +- Integrating PayPal as a payment option +- Implementing express checkout flows +- Setting up recurring billing with PayPal +- Processing refunds and payment disputes +- Handling PayPal webhooks (IPN) +- Supporting international payments +- Implementing PayPal subscriptions + +## Core Concepts + +### 1. Payment Products +**PayPal Checkout** +- One-time payments +- Express checkout experience +- Guest and PayPal account payments + +**PayPal Subscriptions** +- Recurring billing +- Subscription plans +- Automatic renewals + +**PayPal Payouts** +- Send money to multiple recipients +- Marketplace and platform payments + +### 2. Integration Methods +**Client-Side (JavaScript SDK)** +- Smart Payment Buttons +- Hosted payment flow +- Minimal backend code + +**Server-Side (REST API)** +- Full control over payment flow +- Custom checkout UI +- Advanced features + +### 3. IPN (Instant Payment Notification) +- Webhook-like payment notifications +- Asynchronous payment updates +- Verification required + +## Quick Start + +```javascript +// Frontend - PayPal Smart Buttons +
+ + + +``` + +```python +# Backend - Verify and capture order +from paypalrestsdk import Payment +import paypalrestsdk + +paypalrestsdk.configure({ + "mode": "sandbox", # or "live" + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET" +}) + +def capture_paypal_order(order_id): + """Capture a PayPal order.""" + payment = Payment.find(order_id) + + if payment.execute({"payer_id": payment.payer.payer_info.payer_id}): + # Payment successful + return { + 'status': 'success', + 'transaction_id': payment.id, + 'amount': payment.transactions[0].amount.total + } + else: + # Payment failed + return { + 'status': 'failed', + 'error': payment.error + } +``` + +## Express Checkout Implementation + +### Server-Side Order Creation +```python +import requests +import json + +class PayPalClient: + def __init__(self, client_id, client_secret, mode='sandbox'): + self.client_id = client_id + self.client_secret = client_secret + self.base_url = 'https://api-m.sandbox.paypal.com' if mode == 'sandbox' else 'https://api-m.paypal.com' + self.access_token = self.get_access_token() + + def get_access_token(self): + """Get OAuth access token.""" + url = f"{self.base_url}/v1/oauth2/token" + headers = {"Accept": "application/json", "Accept-Language": "en_US"} + + response = requests.post( + url, + headers=headers, + data={"grant_type": "client_credentials"}, + auth=(self.client_id, self.client_secret) + ) + + return response.json()['access_token'] + + def create_order(self, amount, currency='USD'): + """Create a PayPal order.""" + url = f"{self.base_url}/v2/checkout/orders" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.access_token}" + } + + payload = { + "intent": "CAPTURE", + "purchase_units": [{ + "amount": { + "currency_code": currency, + "value": str(amount) + } + }] + } + + response = requests.post(url, headers=headers, json=payload) + return response.json() + + def capture_order(self, order_id): + """Capture payment for an order.""" + url = f"{self.base_url}/v2/checkout/orders/{order_id}/capture" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.access_token}" + } + + response = requests.post(url, headers=headers) + return response.json() + + def get_order_details(self, order_id): + """Get order details.""" + url = f"{self.base_url}/v2/checkout/orders/{order_id}" + headers = { + "Authorization": f"Bearer {self.access_token}" + } + + response = requests.get(url, headers=headers) + return response.json() +``` + +## IPN (Instant Payment Notification) Handling + +### IPN Verification and Processing +```python +from flask import Flask, request +import requests +from urllib.parse import parse_qs + +app = Flask(__name__) + +@app.route('/ipn', methods=['POST']) +def handle_ipn(): + """Handle PayPal IPN notifications.""" + # Get IPN message + ipn_data = request.form.to_dict() + + # Verify IPN with PayPal + if not verify_ipn(ipn_data): + return 'IPN verification failed', 400 + + # Process IPN based on transaction type + payment_status = ipn_data.get('payment_status') + txn_type = ipn_data.get('txn_type') + + if payment_status == 'Completed': + handle_payment_completed(ipn_data) + elif payment_status == 'Refunded': + handle_refund(ipn_data) + elif payment_status == 'Reversed': + handle_chargeback(ipn_data) + + return 'IPN processed', 200 + +def verify_ipn(ipn_data): + """Verify IPN message authenticity.""" + # Add 'cmd' parameter + verify_data = ipn_data.copy() + verify_data['cmd'] = '_notify-validate' + + # Send back to PayPal for verification + paypal_url = 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr' # or production URL + + response = requests.post(paypal_url, data=verify_data) + + return response.text == 'VERIFIED' + +def handle_payment_completed(ipn_data): + """Process completed payment.""" + txn_id = ipn_data.get('txn_id') + payer_email = ipn_data.get('payer_email') + mc_gross = ipn_data.get('mc_gross') + item_name = ipn_data.get('item_name') + + # Check if already processed (prevent duplicates) + if is_transaction_processed(txn_id): + return + + # Update database + # Send confirmation email + # Fulfill order + print(f"Payment completed: {txn_id}, Amount: ${mc_gross}") + +def handle_refund(ipn_data): + """Handle refund.""" + parent_txn_id = ipn_data.get('parent_txn_id') + mc_gross = ipn_data.get('mc_gross') + + # Process refund in your system + print(f"Refund processed: {parent_txn_id}, Amount: ${mc_gross}") + +def handle_chargeback(ipn_data): + """Handle payment reversal/chargeback.""" + txn_id = ipn_data.get('txn_id') + reason_code = ipn_data.get('reason_code') + + # Handle chargeback + print(f"Chargeback: {txn_id}, Reason: {reason_code}") +``` + +## Subscription/Recurring Billing + +### Create Subscription Plan +```python +def create_subscription_plan(name, amount, interval='MONTH'): + """Create a subscription plan.""" + client = PayPalClient(CLIENT_ID, CLIENT_SECRET) + + url = f"{client.base_url}/v1/billing/plans" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {client.access_token}" + } + + payload = { + "product_id": "PRODUCT_ID", # Create product first + "name": name, + "billing_cycles": [{ + "frequency": { + "interval_unit": interval, + "interval_count": 1 + }, + "tenure_type": "REGULAR", + "sequence": 1, + "total_cycles": 0, # Infinite + "pricing_scheme": { + "fixed_price": { + "value": str(amount), + "currency_code": "USD" + } + } + }], + "payment_preferences": { + "auto_bill_outstanding": True, + "setup_fee": { + "value": "0", + "currency_code": "USD" + }, + "setup_fee_failure_action": "CONTINUE", + "payment_failure_threshold": 3 + } + } + + response = requests.post(url, headers=headers, json=payload) + return response.json() + +def create_subscription(plan_id, subscriber_email): + """Create a subscription for a customer.""" + client = PayPalClient(CLIENT_ID, CLIENT_SECRET) + + url = f"{client.base_url}/v1/billing/subscriptions" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {client.access_token}" + } + + payload = { + "plan_id": plan_id, + "subscriber": { + "email_address": subscriber_email + }, + "application_context": { + "return_url": "https://yourdomain.com/subscription/success", + "cancel_url": "https://yourdomain.com/subscription/cancel" + } + } + + response = requests.post(url, headers=headers, json=payload) + subscription = response.json() + + # Get approval URL + for link in subscription.get('links', []): + if link['rel'] == 'approve': + return { + 'subscription_id': subscription['id'], + 'approval_url': link['href'] + } +``` + +## Refund Workflows + +```python +def create_refund(capture_id, amount=None, note=None): + """Create a refund for a captured payment.""" + client = PayPalClient(CLIENT_ID, CLIENT_SECRET) + + url = f"{client.base_url}/v2/payments/captures/{capture_id}/refund" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {client.access_token}" + } + + payload = {} + if amount: + payload["amount"] = { + "value": str(amount), + "currency_code": "USD" + } + + if note: + payload["note_to_payer"] = note + + response = requests.post(url, headers=headers, json=payload) + return response.json() + +def get_refund_details(refund_id): + """Get refund details.""" + client = PayPalClient(CLIENT_ID, CLIENT_SECRET) + + url = f"{client.base_url}/v2/payments/refunds/{refund_id}" + headers = { + "Authorization": f"Bearer {client.access_token}" + } + + response = requests.get(url, headers=headers) + return response.json() +``` + +## Error Handling + +```python +class PayPalError(Exception): + """Custom PayPal error.""" + pass + +def handle_paypal_api_call(api_function): + """Wrapper for PayPal API calls with error handling.""" + try: + result = api_function() + return result + except requests.exceptions.RequestException as e: + # Network error + raise PayPalError(f"Network error: {str(e)}") + except Exception as e: + # Other errors + raise PayPalError(f"PayPal API error: {str(e)}") + +# Usage +try: + order = handle_paypal_api_call(lambda: client.create_order(25.00)) +except PayPalError as e: + # Handle error appropriately + log_error(e) +``` + +## Testing + +```python +# Use sandbox credentials +SANDBOX_CLIENT_ID = "..." +SANDBOX_SECRET = "..." + +# Test accounts +# Create test buyer and seller accounts at developer.paypal.com + +def test_payment_flow(): + """Test complete payment flow.""" + client = PayPalClient(SANDBOX_CLIENT_ID, SANDBOX_SECRET, mode='sandbox') + + # Create order + order = client.create_order(10.00) + assert 'id' in order + + # Get approval URL + approval_url = next((link['href'] for link in order['links'] if link['rel'] == 'approve'), None) + assert approval_url is not None + + # After approval (manual step with test account) + # Capture order + # captured = client.capture_order(order['id']) + # assert captured['status'] == 'COMPLETED' +``` + +## Resources + +- **references/express-checkout.md**: Express Checkout implementation guide +- **references/ipn-handling.md**: IPN verification and processing +- **references/refund-workflows.md**: Refund handling patterns +- **references/billing-agreements.md**: Recurring billing setup +- **assets/paypal-client.py**: Production PayPal client +- **assets/ipn-processor.py**: IPN webhook processor +- **assets/recurring-billing.py**: Subscription management + +## Best Practices + +1. **Always Verify IPN**: Never trust IPN without verification +2. **Idempotent Processing**: Handle duplicate IPN notifications +3. **Error Handling**: Implement robust error handling +4. **Logging**: Log all transactions and errors +5. **Test Thoroughly**: Use sandbox extensively +6. **Webhook Backup**: Don't rely solely on client-side callbacks +7. **Currency Handling**: Always specify currency explicitly + +## Common Pitfalls + +- **Not Verifying IPN**: Accepting IPN without verification +- **Duplicate Processing**: Not checking for duplicate transactions +- **Wrong Environment**: Mixing sandbox and production URLs/credentials +- **Missing Webhooks**: Not handling all payment states +- **Hardcoded Values**: Not making configurable for different environments diff --git a/skills/pci-compliance/SKILL.md b/skills/pci-compliance/SKILL.md new file mode 100644 index 0000000..a4662b2 --- /dev/null +++ b/skills/pci-compliance/SKILL.md @@ -0,0 +1,466 @@ +--- +name: pci-compliance +description: Implement PCI DSS compliance requirements for secure handling of payment card data and payment systems. Use when securing payment processing, achieving PCI compliance, or implementing payment card security measures. +--- + +# PCI Compliance + +Master PCI DSS (Payment Card Industry Data Security Standard) compliance for secure payment processing and handling of cardholder data. + +## When to Use This Skill + +- Building payment processing systems +- Handling credit card information +- Implementing secure payment flows +- Conducting PCI compliance audits +- Reducing PCI compliance scope +- Implementing tokenization and encryption +- Preparing for PCI DSS assessments + +## PCI DSS Requirements (12 Core Requirements) + +### Build and Maintain Secure Network +1. Install and maintain firewall configuration +2. Don't use vendor-supplied defaults for passwords + +### Protect Cardholder Data +3. Protect stored cardholder data +4. Encrypt transmission of cardholder data across public networks + +### Maintain Vulnerability Management +5. Protect systems against malware +6. Develop and maintain secure systems and applications + +### Implement Strong Access Control +7. Restrict access to cardholder data by business need-to-know +8. Identify and authenticate access to system components +9. Restrict physical access to cardholder data + +### Monitor and Test Networks +10. Track and monitor all access to network resources and cardholder data +11. Regularly test security systems and processes + +### Maintain Information Security Policy +12. Maintain a policy that addresses information security + +## Compliance Levels + +**Level 1**: > 6 million transactions/year (annual ROC required) +**Level 2**: 1-6 million transactions/year (annual SAQ) +**Level 3**: 20,000-1 million e-commerce transactions/year +**Level 4**: < 20,000 e-commerce or < 1 million total transactions + +## Data Minimization (Never Store) + +```python +# NEVER STORE THESE +PROHIBITED_DATA = { + 'full_track_data': 'Magnetic stripe data', + 'cvv': 'Card verification code/value', + 'pin': 'PIN or PIN block' +} + +# CAN STORE (if encrypted) +ALLOWED_DATA = { + 'pan': 'Primary Account Number (card number)', + 'cardholder_name': 'Name on card', + 'expiration_date': 'Card expiration', + 'service_code': 'Service code' +} + +class PaymentData: + """Safe payment data handling.""" + + def __init__(self): + self.prohibited_fields = ['cvv', 'cvv2', 'cvc', 'pin'] + + def sanitize_log(self, data): + """Remove sensitive data from logs.""" + sanitized = data.copy() + + # Mask PAN + if 'card_number' in sanitized: + card = sanitized['card_number'] + sanitized['card_number'] = f"{card[:6]}{'*' * (len(card) - 10)}{card[-4:]}" + + # Remove prohibited data + for field in self.prohibited_fields: + sanitized.pop(field, None) + + return sanitized + + def validate_no_prohibited_storage(self, data): + """Ensure no prohibited data is being stored.""" + for field in self.prohibited_fields: + if field in data: + raise SecurityError(f"Attempting to store prohibited field: {field}") +``` + +## Tokenization + +### Using Payment Processor Tokens +```python +import stripe + +class TokenizedPayment: + """Handle payments using tokens (no card data on server).""" + + @staticmethod + def create_payment_method_token(card_details): + """Create token from card details (client-side only).""" + # THIS SHOULD ONLY BE DONE CLIENT-SIDE WITH STRIPE.JS + # NEVER send card details to your server + + """ + // Frontend JavaScript + const stripe = Stripe('pk_...'); + + const {token, error} = await stripe.createToken({ + card: { + number: '4242424242424242', + exp_month: 12, + exp_year: 2024, + cvc: '123' + } + }); + + // Send token.id to server (NOT card details) + """ + pass + + @staticmethod + def charge_with_token(token_id, amount): + """Charge using token (server-side).""" + # Your server only sees the token, never the card number + stripe.api_key = "sk_..." + + charge = stripe.Charge.create( + amount=amount, + currency="usd", + source=token_id, # Token instead of card details + description="Payment" + ) + + return charge + + @staticmethod + def store_payment_method(customer_id, payment_method_token): + """Store payment method as token for future use.""" + stripe.Customer.modify( + customer_id, + source=payment_method_token + ) + + # Store only customer_id and payment_method_id in your database + # NEVER store actual card details + return { + 'customer_id': customer_id, + 'has_payment_method': True + # DO NOT store: card number, CVV, etc. + } +``` + +### Custom Tokenization (Advanced) +```python +import secrets +from cryptography.fernet import Fernet + +class TokenVault: + """Secure token vault for card data (if you must store it).""" + + def __init__(self, encryption_key): + self.cipher = Fernet(encryption_key) + self.vault = {} # In production: use encrypted database + + def tokenize(self, card_data): + """Convert card data to token.""" + # Generate secure random token + token = secrets.token_urlsafe(32) + + # Encrypt card data + encrypted = self.cipher.encrypt(json.dumps(card_data).encode()) + + # Store token -> encrypted data mapping + self.vault[token] = encrypted + + return token + + def detokenize(self, token): + """Retrieve card data from token.""" + encrypted = self.vault.get(token) + if not encrypted: + raise ValueError("Token not found") + + # Decrypt + decrypted = self.cipher.decrypt(encrypted) + return json.loads(decrypted.decode()) + + def delete_token(self, token): + """Remove token from vault.""" + self.vault.pop(token, None) +``` + +## Encryption + +### Data at Rest +```python +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +import os + +class EncryptedStorage: + """Encrypt data at rest using AES-256-GCM.""" + + def __init__(self, encryption_key): + """Initialize with 256-bit key.""" + self.key = encryption_key # Must be 32 bytes + + def encrypt(self, plaintext): + """Encrypt data.""" + # Generate random nonce + nonce = os.urandom(12) + + # Encrypt + aesgcm = AESGCM(self.key) + ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None) + + # Return nonce + ciphertext + return nonce + ciphertext + + def decrypt(self, encrypted_data): + """Decrypt data.""" + # Extract nonce and ciphertext + nonce = encrypted_data[:12] + ciphertext = encrypted_data[12:] + + # Decrypt + aesgcm = AESGCM(self.key) + plaintext = aesgcm.decrypt(nonce, ciphertext, None) + + return plaintext.decode() + +# Usage +storage = EncryptedStorage(os.urandom(32)) +encrypted_pan = storage.encrypt("4242424242424242") +# Store encrypted_pan in database +``` + +### Data in Transit +```python +# Always use TLS 1.2 or higher +# Flask/Django example +app.config['SESSION_COOKIE_SECURE'] = True # HTTPS only +app.config['SESSION_COOKIE_HTTPONLY'] = True +app.config['SESSION_COOKIE_SAMESITE'] = 'Strict' + +# Enforce HTTPS +from flask_talisman import Talisman +Talisman(app, force_https=True) +``` + +## Access Control + +```python +from functools import wraps +from flask import session + +def require_pci_access(f): + """Decorator to restrict access to cardholder data.""" + @wraps(f) + def decorated_function(*args, **kwargs): + user = session.get('user') + + # Check if user has PCI access role + if not user or 'pci_access' not in user.get('roles', []): + return {'error': 'Unauthorized access to cardholder data'}, 403 + + # Log access attempt + audit_log( + user=user['id'], + action='access_cardholder_data', + resource=f.__name__ + ) + + return f(*args, **kwargs) + + return decorated_function + +@app.route('/api/payment-methods') +@require_pci_access +def get_payment_methods(): + """Retrieve payment methods (restricted access).""" + # Only accessible to users with pci_access role + pass +``` + +## Audit Logging + +```python +import logging +from datetime import datetime + +class PCIAuditLogger: + """PCI-compliant audit logging.""" + + def __init__(self): + self.logger = logging.getLogger('pci_audit') + # Configure to write to secure, append-only log + + def log_access(self, user_id, resource, action, result): + """Log access to cardholder data.""" + entry = { + 'timestamp': datetime.utcnow().isoformat(), + 'user_id': user_id, + 'resource': resource, + 'action': action, + 'result': result, + 'ip_address': request.remote_addr + } + + self.logger.info(json.dumps(entry)) + + def log_authentication(self, user_id, success, method): + """Log authentication attempt.""" + entry = { + 'timestamp': datetime.utcnow().isoformat(), + 'user_id': user_id, + 'event': 'authentication', + 'success': success, + 'method': method, + 'ip_address': request.remote_addr + } + + self.logger.info(json.dumps(entry)) + +# Usage +audit = PCIAuditLogger() +audit.log_access(user_id=123, resource='payment_methods', action='read', result='success') +``` + +## Security Best Practices + +### Input Validation +```python +import re + +def validate_card_number(card_number): + """Validate card number format (Luhn algorithm).""" + # Remove spaces and dashes + card_number = re.sub(r'[\s-]', '', card_number) + + # Check if all digits + if not card_number.isdigit(): + return False + + # Luhn algorithm + def luhn_checksum(card_num): + def digits_of(n): + return [int(d) for d in str(n)] + + digits = digits_of(card_num) + odd_digits = digits[-1::-2] + even_digits = digits[-2::-2] + checksum = sum(odd_digits) + for d in even_digits: + checksum += sum(digits_of(d * 2)) + return checksum % 10 + + return luhn_checksum(card_number) == 0 + +def sanitize_input(user_input): + """Sanitize user input to prevent injection.""" + # Remove special characters + # Validate against expected format + # Escape for database queries + pass +``` + +## PCI DSS SAQ (Self-Assessment Questionnaire) + +### SAQ A (Least Requirements) +- E-commerce using hosted payment page +- No card data on your systems +- ~20 questions + +### SAQ A-EP +- E-commerce with embedded payment form +- Uses JavaScript to handle card data +- ~180 questions + +### SAQ D (Most Requirements) +- Store, process, or transmit card data +- Full PCI DSS requirements +- ~300 questions + +## Compliance Checklist + +```python +PCI_COMPLIANCE_CHECKLIST = { + 'network_security': [ + 'Firewall configured and maintained', + 'No vendor default passwords', + 'Network segmentation implemented' + ], + 'data_protection': [ + 'No storage of CVV, track data, or PIN', + 'PAN encrypted when stored', + 'PAN masked when displayed', + 'Encryption keys properly managed' + ], + 'vulnerability_management': [ + 'Anti-virus installed and updated', + 'Secure development practices', + 'Regular security patches', + 'Vulnerability scanning performed' + ], + 'access_control': [ + 'Access restricted by role', + 'Unique IDs for all users', + 'Multi-factor authentication', + 'Physical security measures' + ], + 'monitoring': [ + 'Audit logs enabled', + 'Log review process', + 'File integrity monitoring', + 'Regular security testing' + ], + 'policy': [ + 'Security policy documented', + 'Risk assessment performed', + 'Security awareness training', + 'Incident response plan' + ] +} +``` + +## Resources + +- **references/data-minimization.md**: Never store prohibited data +- **references/tokenization.md**: Tokenization strategies +- **references/encryption.md**: Encryption requirements +- **references/access-control.md**: Role-based access +- **references/audit-logging.md**: Comprehensive logging +- **assets/pci-compliance-checklist.md**: Complete checklist +- **assets/encrypted-storage.py**: Encryption utilities +- **scripts/audit-payment-system.sh**: Compliance audit script + +## Common Violations + +1. **Storing CVV**: Never store card verification codes +2. **Unencrypted PAN**: Card numbers must be encrypted at rest +3. **Weak Encryption**: Use AES-256 or equivalent +4. **No Access Controls**: Restrict who can access cardholder data +5. **Missing Audit Logs**: Must log all access to payment data +6. **Insecure Transmission**: Always use TLS 1.2+ +7. **Default Passwords**: Change all default credentials +8. **No Security Testing**: Regular penetration testing required + +## Reducing PCI Scope + +1. **Use Hosted Payments**: Stripe Checkout, PayPal, etc. +2. **Tokenization**: Replace card data with tokens +3. **Network Segmentation**: Isolate cardholder data environment +4. **Outsource**: Use PCI-compliant payment processors +5. **No Storage**: Never store full card details + +By minimizing systems that touch card data, you reduce compliance burden significantly. diff --git a/skills/stripe-integration/SKILL.md b/skills/stripe-integration/SKILL.md new file mode 100644 index 0000000..7b2d7ab --- /dev/null +++ b/skills/stripe-integration/SKILL.md @@ -0,0 +1,442 @@ +--- +name: stripe-integration +description: Implement Stripe payment processing for robust, PCI-compliant payment flows including checkout, subscriptions, and webhooks. Use when integrating Stripe payments, building subscription systems, or implementing secure checkout flows. +--- + +# Stripe Integration + +Master Stripe payment processing integration for robust, PCI-compliant payment flows including checkout, subscriptions, webhooks, and refunds. + +## When to Use This Skill + +- Implementing payment processing in web/mobile applications +- Setting up subscription billing systems +- Handling one-time payments and recurring charges +- Processing refunds and disputes +- Managing customer payment methods +- Implementing SCA (Strong Customer Authentication) for European payments +- Building marketplace payment flows with Stripe Connect + +## Core Concepts + +### 1. Payment Flows +**Checkout Session (Hosted)** +- Stripe-hosted payment page +- Minimal PCI compliance burden +- Fastest implementation +- Supports one-time and recurring payments + +**Payment Intents (Custom UI)** +- Full control over payment UI +- Requires Stripe.js for PCI compliance +- More complex implementation +- Better customization options + +**Setup Intents (Save Payment Methods)** +- Collect payment method without charging +- Used for subscriptions and future payments +- Requires customer confirmation + +### 2. Webhooks +**Critical Events:** +- `payment_intent.succeeded`: Payment completed +- `payment_intent.payment_failed`: Payment failed +- `customer.subscription.updated`: Subscription changed +- `customer.subscription.deleted`: Subscription canceled +- `charge.refunded`: Refund processed +- `invoice.payment_succeeded`: Subscription payment successful + +### 3. Subscriptions +**Components:** +- **Product**: What you're selling +- **Price**: How much and how often +- **Subscription**: Customer's recurring payment +- **Invoice**: Generated for each billing cycle + +### 4. Customer Management +- Create and manage customer records +- Store multiple payment methods +- Track customer metadata +- Manage billing details + +## Quick Start + +```python +import stripe + +stripe.api_key = "sk_test_..." + +# Create a checkout session +session = stripe.checkout.Session.create( + payment_method_types=['card'], + line_items=[{ + 'price_data': { + 'currency': 'usd', + 'product_data': { + 'name': 'Premium Subscription', + }, + 'unit_amount': 2000, # $20.00 + 'recurring': { + 'interval': 'month', + }, + }, + 'quantity': 1, + }], + mode='subscription', + success_url='https://yourdomain.com/success?session_id={CHECKOUT_SESSION_ID}', + cancel_url='https://yourdomain.com/cancel', +) + +# Redirect user to session.url +print(session.url) +``` + +## Payment Implementation Patterns + +### Pattern 1: One-Time Payment (Hosted Checkout) +```python +def create_checkout_session(amount, currency='usd'): + """Create a one-time payment checkout session.""" + try: + session = stripe.checkout.Session.create( + payment_method_types=['card'], + line_items=[{ + 'price_data': { + 'currency': currency, + 'product_data': { + 'name': 'Purchase', + 'images': ['https://example.com/product.jpg'], + }, + 'unit_amount': amount, # Amount in cents + }, + 'quantity': 1, + }], + mode='payment', + success_url='https://yourdomain.com/success?session_id={CHECKOUT_SESSION_ID}', + cancel_url='https://yourdomain.com/cancel', + metadata={ + 'order_id': 'order_123', + 'user_id': 'user_456' + } + ) + return session + except stripe.error.StripeError as e: + # Handle error + print(f"Stripe error: {e.user_message}") + raise +``` + +### Pattern 2: Custom Payment Intent Flow +```python +def create_payment_intent(amount, currency='usd', customer_id=None): + """Create a payment intent for custom checkout UI.""" + intent = stripe.PaymentIntent.create( + amount=amount, + currency=currency, + customer=customer_id, + automatic_payment_methods={ + 'enabled': True, + }, + metadata={ + 'integration_check': 'accept_a_payment' + } + ) + return intent.client_secret # Send to frontend + +# Frontend (JavaScript) +""" +const stripe = Stripe('pk_test_...'); +const elements = stripe.elements(); +const cardElement = elements.create('card'); +cardElement.mount('#card-element'); + +const {error, paymentIntent} = await stripe.confirmCardPayment( + clientSecret, + { + payment_method: { + card: cardElement, + billing_details: { + name: 'Customer Name' + } + } + } +); + +if (error) { + // Handle error +} else if (paymentIntent.status === 'succeeded') { + // Payment successful +} +""" +``` + +### Pattern 3: Subscription Creation +```python +def create_subscription(customer_id, price_id): + """Create a subscription for a customer.""" + try: + subscription = stripe.Subscription.create( + customer=customer_id, + items=[{'price': price_id}], + payment_behavior='default_incomplete', + payment_settings={'save_default_payment_method': 'on_subscription'}, + expand=['latest_invoice.payment_intent'], + ) + + return { + 'subscription_id': subscription.id, + 'client_secret': subscription.latest_invoice.payment_intent.client_secret + } + except stripe.error.StripeError as e: + print(f"Subscription creation failed: {e}") + raise +``` + +### Pattern 4: Customer Portal +```python +def create_customer_portal_session(customer_id): + """Create a portal session for customers to manage subscriptions.""" + session = stripe.billing_portal.Session.create( + customer=customer_id, + return_url='https://yourdomain.com/account', + ) + return session.url # Redirect customer here +``` + +## Webhook Handling + +### Secure Webhook Endpoint +```python +from flask import Flask, request +import stripe + +app = Flask(__name__) + +endpoint_secret = 'whsec_...' + +@app.route('/webhook', methods=['POST']) +def webhook(): + payload = request.data + sig_header = request.headers.get('Stripe-Signature') + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, endpoint_secret + ) + except ValueError: + # Invalid payload + return 'Invalid payload', 400 + except stripe.error.SignatureVerificationError: + # Invalid signature + return 'Invalid signature', 400 + + # Handle the event + if event['type'] == 'payment_intent.succeeded': + payment_intent = event['data']['object'] + handle_successful_payment(payment_intent) + elif event['type'] == 'payment_intent.payment_failed': + payment_intent = event['data']['object'] + handle_failed_payment(payment_intent) + elif event['type'] == 'customer.subscription.deleted': + subscription = event['data']['object'] + handle_subscription_canceled(subscription) + + return 'Success', 200 + +def handle_successful_payment(payment_intent): + """Process successful payment.""" + customer_id = payment_intent.get('customer') + amount = payment_intent['amount'] + metadata = payment_intent.get('metadata', {}) + + # Update your database + # Send confirmation email + # Fulfill order + print(f"Payment succeeded: {payment_intent['id']}") + +def handle_failed_payment(payment_intent): + """Handle failed payment.""" + error = payment_intent.get('last_payment_error', {}) + print(f"Payment failed: {error.get('message')}") + # Notify customer + # Update order status + +def handle_subscription_canceled(subscription): + """Handle subscription cancellation.""" + customer_id = subscription['customer'] + # Update user access + # Send cancellation email + print(f"Subscription canceled: {subscription['id']}") +``` + +### Webhook Best Practices +```python +import hashlib +import hmac + +def verify_webhook_signature(payload, signature, secret): + """Manually verify webhook signature.""" + expected_sig = hmac.new( + secret.encode('utf-8'), + payload, + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(signature, expected_sig) + +def handle_webhook_idempotently(event_id, handler): + """Ensure webhook is processed exactly once.""" + # Check if event already processed + if is_event_processed(event_id): + return + + # Process event + try: + handler() + mark_event_processed(event_id) + except Exception as e: + log_error(e) + # Stripe will retry failed webhooks + raise +``` + +## Customer Management + +```python +def create_customer(email, name, payment_method_id=None): + """Create a Stripe customer.""" + customer = stripe.Customer.create( + email=email, + name=name, + payment_method=payment_method_id, + invoice_settings={ + 'default_payment_method': payment_method_id + } if payment_method_id else None, + metadata={ + 'user_id': '12345' + } + ) + return customer + +def attach_payment_method(customer_id, payment_method_id): + """Attach a payment method to a customer.""" + stripe.PaymentMethod.attach( + payment_method_id, + customer=customer_id + ) + + # Set as default + stripe.Customer.modify( + customer_id, + invoice_settings={ + 'default_payment_method': payment_method_id + } + ) + +def list_customer_payment_methods(customer_id): + """List all payment methods for a customer.""" + payment_methods = stripe.PaymentMethod.list( + customer=customer_id, + type='card' + ) + return payment_methods.data +``` + +## Refund Handling + +```python +def create_refund(payment_intent_id, amount=None, reason=None): + """Create a refund.""" + refund_params = { + 'payment_intent': payment_intent_id + } + + if amount: + refund_params['amount'] = amount # Partial refund + + if reason: + refund_params['reason'] = reason # 'duplicate', 'fraudulent', 'requested_by_customer' + + refund = stripe.Refund.create(**refund_params) + return refund + +def handle_dispute(charge_id, evidence): + """Update dispute with evidence.""" + stripe.Dispute.modify( + charge_id, + evidence={ + 'customer_name': evidence.get('customer_name'), + 'customer_email_address': evidence.get('customer_email'), + 'shipping_documentation': evidence.get('shipping_proof'), + 'customer_communication': evidence.get('communication'), + } + ) +``` + +## Testing + +```python +# Use test mode keys +stripe.api_key = "sk_test_..." + +# Test card numbers +TEST_CARDS = { + 'success': '4242424242424242', + 'declined': '4000000000000002', + '3d_secure': '4000002500003155', + 'insufficient_funds': '4000000000009995' +} + +def test_payment_flow(): + """Test complete payment flow.""" + # Create test customer + customer = stripe.Customer.create( + email="test@example.com" + ) + + # Create payment intent + intent = stripe.PaymentIntent.create( + amount=1000, + currency='usd', + customer=customer.id, + payment_method_types=['card'] + ) + + # Confirm with test card + confirmed = stripe.PaymentIntent.confirm( + intent.id, + payment_method='pm_card_visa' # Test payment method + ) + + assert confirmed.status == 'succeeded' +``` + +## Resources + +- **references/checkout-flows.md**: Detailed checkout implementation +- **references/webhook-handling.md**: Webhook security and processing +- **references/subscription-management.md**: Subscription lifecycle +- **references/customer-management.md**: Customer and payment method handling +- **references/invoice-generation.md**: Invoicing and billing +- **assets/stripe-client.py**: Production-ready Stripe client wrapper +- **assets/webhook-handler.py**: Complete webhook processor +- **assets/checkout-config.json**: Checkout configuration templates + +## Best Practices + +1. **Always Use Webhooks**: Don't rely solely on client-side confirmation +2. **Idempotency**: Handle webhook events idempotently +3. **Error Handling**: Gracefully handle all Stripe errors +4. **Test Mode**: Thoroughly test with test keys before production +5. **Metadata**: Use metadata to link Stripe objects to your database +6. **Monitoring**: Track payment success rates and errors +7. **PCI Compliance**: Never handle raw card data on your server +8. **SCA Ready**: Implement 3D Secure for European payments + +## Common Pitfalls + +- **Not Verifying Webhooks**: Always verify webhook signatures +- **Missing Webhook Events**: Handle all relevant webhook events +- **Hardcoded Amounts**: Use cents/smallest currency unit +- **No Retry Logic**: Implement retries for API calls +- **Ignoring Test Mode**: Test all edge cases with test cards