Initial commit
This commit is contained in:
931
commands/stripe-setup.md
Normal file
931
commands/stripe-setup.md
Normal file
@@ -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<string, string>;
|
||||
}): Promise<Stripe.Checkout.Session> {
|
||||
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<string, string>;
|
||||
}): Promise<Stripe.PaymentIntent> {
|
||||
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<Stripe.PaymentIntent> {
|
||||
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<Stripe.PaymentIntent> {
|
||||
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<string, string>;
|
||||
paymentMethodId?: string;
|
||||
}): Promise<Stripe.Customer> {
|
||||
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<Stripe.PaymentMethod> {
|
||||
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<Stripe.PaymentMethod[]> {
|
||||
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<string, string>;
|
||||
}): Promise<Stripe.Refund> {
|
||||
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<Stripe | null>;
|
||||
|
||||
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<StripeProviderProps> = ({ children }) => {
|
||||
return (
|
||||
<Elements stripe={getStripe()}>
|
||||
{children}
|
||||
</Elements>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**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<string, string>;
|
||||
}
|
||||
|
||||
export const PaymentForm: React.FC<PaymentFormProps> = ({
|
||||
amount,
|
||||
currency = 'usd',
|
||||
onSuccess,
|
||||
onError,
|
||||
customerId,
|
||||
metadata,
|
||||
}) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<form onSubmit={handleSubmit} className="max-w-md mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Card Details
|
||||
</label>
|
||||
<div className="border border-gray-300 rounded-lg p-3">
|
||||
<CardElement
|
||||
options={{
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#424770',
|
||||
'::placeholder': {
|
||||
color: '#aab7c4',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#9e2146',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || loading}
|
||||
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Processing...' : `Pay $${(amount / 100).toFixed(2)}`}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**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<CheckoutButtonProps> = ({
|
||||
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 (
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white py-2 px-6 rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Loading...' : buttonText}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 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.
|
||||
1193
commands/subscription-flow.md
Normal file
1193
commands/subscription-flow.md
Normal file
File diff suppressed because it is too large
Load Diff
386
commands/subscription-manage.md
Normal file
386
commands/subscription-manage.md
Normal file
@@ -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
|
||||
```
|
||||
295
commands/webhook-setup.md
Normal file
295
commands/webhook-setup.md
Normal file
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user