Comprehensive developer guidelines for payment system development. New Files: - docs/.../OQI-005-payments-stripe/DEVELOPER-GUIDELINES.md (900+ lines) - Complete reference for payment development - PCI-DSS compliance rules (DO's and DON'Ts) - Backend development guidelines - Frontend development guidelines - Testing guidelines (unit + E2E) - Common pitfalls and how to avoid them - Code review checklist - Deployment checklist - Troubleshooting guide - Examples and templates Sections: 1. Overview - Architecture summary, tech stack, compliance level 2. PCI-DSS Compliance Rules - What's allowed vs prohibited 3. Backend Development - File structure, endpoints, webhooks, database 4. Frontend Development - Stripe Elements, checkout flow, error handling 5. Testing Guidelines - Unit tests, E2E tests, component tests 6. Common Pitfalls - 5 common mistakes and how to avoid them 7. Code Review Checklist - Security, quality, Stripe integration 8. Deployment Checklist - Environment, security, testing, monitoring 9. Troubleshooting - Common issues and solutions 10. Examples & Templates - Complete flow examples Key Guidelines: ✅ DO's: - Use Payment Intents (server-side processing) - Use Stripe Elements (client-side tokenization) - Verify webhook signatures - Store only tokens/IDs (pm_xxx, pi_xxx) - Use HTTPS everywhere - Log payment events (without sensitive data) - Write E2E tests for PCI-DSS compliance ❌ DON'Ts: - Accept card data in backend - Store PAN, CVV, or expiry in database - Create native card inputs - Store card data in React state - Skip webhook signature verification - Use HTTP (only HTTPS) - Log sensitive data PCI-DSS Compliance: ✅ ALLOWED: - Store last 4 digits - Store card brand - Store Stripe tokens (pm_xxx, pi_xxx, cus_xxx) - Store customer name ❌ PROHIBITED: - Store full PAN (card number) - Store CVV/CVC - Store expiry date - Store PIN Common Pitfalls: 1. Accepting card data in backend → Block sensitive fields 2. Storing full PAN in database → Use tokens only 3. Native card inputs → Use Stripe CardElement 4. Not verifying webhook signatures → Use constructEvent 5. Logging sensitive data → Filter sensitive fields Code Examples: - Wallet deposit flow (complete end-to-end) - Subscription checkout (Stripe hosted) - Payment Intent creation (backend) - Stripe Elements integration (frontend) - Webhook signature verification - Database schema (safe vs prohibited) Testing Examples: - Unit tests (Stripe service mocked) - E2E tests (PCI-DSS compliance) - Component tests (CardElement rendering) - Integration tests (webhook handling) Deployment Checklist: - Environment variables configured - Stripe webhooks set up - SSL/TLS enabled - Security headers configured - Rate limiting enabled - All tests passing (45+ PCI-DSS tests) - Monitoring and alerts configured Target Audience: - Backend developers (Express.js, TypeScript) - Frontend developers (React, Stripe.js) - DevOps engineers (deployment, monitoring) - Code reviewers (security validation) - New team members (onboarding) Status: BLOCKER-002 (ST4.2) - Developer guidelines complete Task: #5 ST4.2.5 - Actualizar developer guidelines pagos Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
998 lines
25 KiB
Markdown
998 lines
25 KiB
Markdown
# Developer Guidelines: Payment System
|
|
|
|
**Epic:** OQI-005 - Payments & Stripe
|
|
**Blocker:** BLOCKER-002 (ST4.2.5)
|
|
**Version:** 1.0.0
|
|
**Last Updated:** 2026-01-26
|
|
**Status:** ✅ Complete
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Overview](#overview)
|
|
2. [PCI-DSS Compliance Rules](#pci-dss-compliance-rules)
|
|
3. [Backend Development](#backend-development)
|
|
4. [Frontend Development](#frontend-development)
|
|
5. [Testing Guidelines](#testing-guidelines)
|
|
6. [Common Pitfalls](#common-pitfalls)
|
|
7. [Code Review Checklist](#code-review-checklist)
|
|
8. [Deployment Checklist](#deployment-checklist)
|
|
9. [Troubleshooting](#troubleshooting)
|
|
10. [Examples & Templates](#examples--templates)
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
### Architecture Summary
|
|
|
|
```
|
|
Frontend (React) → Backend (Express.js) → Stripe API
|
|
↓ ↓ ↓
|
|
CardElement Payment Intent Processing
|
|
(Stripe.js) (server-side) (Stripe servers)
|
|
```
|
|
|
|
**Key Principle:** ⚠️ **Card data NEVER touches our servers**
|
|
|
|
### Technology Stack
|
|
|
|
- **Backend:** Express.js, TypeScript, Stripe Node.js SDK
|
|
- **Frontend:** React, TypeScript, @stripe/stripe-js, @stripe/react-stripe-js
|
|
- **Database:** PostgreSQL (NO card data stored)
|
|
- **Payment Processor:** Stripe (Level 1 PCI-DSS certified)
|
|
|
|
### Compliance Level
|
|
|
|
**PCI-DSS SAQ-A** (Self-Assessment Questionnaire A)
|
|
- Simplest compliance path (22 requirements vs 300+)
|
|
- Achieved by delegating ALL card processing to Stripe
|
|
- **Required:** NO card data on our systems
|
|
|
|
---
|
|
|
|
## PCI-DSS Compliance Rules
|
|
|
|
### ✅ ALLOWED
|
|
|
|
**Backend:**
|
|
```typescript
|
|
// ✅ Create Payment Intent (server-side)
|
|
const paymentIntent = await stripe.paymentIntents.create({
|
|
amount: 10000,
|
|
currency: 'usd',
|
|
metadata: { userId, transactionId },
|
|
});
|
|
|
|
// ✅ Store safe identifiers
|
|
await db.query(
|
|
'INSERT INTO transactions (payment_intent_id, amount) VALUES ($1, $2)',
|
|
[paymentIntent.id, 100] // ← Safe: token ID, not card data
|
|
);
|
|
|
|
// ✅ Store card metadata (last4, brand)
|
|
await db.query(
|
|
'UPDATE payment_methods SET card_last4 = $1, card_brand = $2',
|
|
['4242', 'visa'] // ← Safe: not full PAN
|
|
);
|
|
```
|
|
|
|
**Frontend:**
|
|
```typescript
|
|
// ✅ Use Stripe Elements (hosted iframe)
|
|
import { CardElement } from '@stripe/react-stripe-js';
|
|
|
|
<CardElement options={cardElementOptions} />
|
|
|
|
// ✅ Confirm payment with Stripe
|
|
const { error, paymentIntent } = await stripe.confirmCardPayment(
|
|
clientSecret,
|
|
{ payment_method: { card: cardElement } } // ← Card data goes to Stripe
|
|
);
|
|
```
|
|
|
|
### ❌ PROHIBITED
|
|
|
|
**NEVER DO THIS:**
|
|
|
|
```typescript
|
|
// ❌ PROHIBITED: Accept card data in backend
|
|
export async function createPayment(req, res) {
|
|
const { cardNumber, cvv, expiryDate } = req.body; // ← VIOLATION!
|
|
// ... DO NOT DO THIS
|
|
}
|
|
|
|
// ❌ PROHIBITED: Store card data in database
|
|
await db.query(
|
|
'INSERT INTO payment_methods (card_number, cvv) VALUES ($1, $2)',
|
|
['4242424242424242', '123'] // ← SEVERE VIOLATION!
|
|
);
|
|
|
|
// ❌ PROHIBITED: Create native card input
|
|
<input
|
|
type="text"
|
|
name="cardNumber" // ← VIOLATION!
|
|
placeholder="Card Number"
|
|
/>
|
|
|
|
// ❌ PROHIBITED: Store card data in React state
|
|
const [cardNumber, setCardNumber] = useState(''); // ← VIOLATION!
|
|
|
|
// ❌ PROHIBITED: Log sensitive data
|
|
console.log('Card:', cardNumber, cvv); // ← VIOLATION!
|
|
```
|
|
|
|
**Consequences:**
|
|
- 💰 **Fines:** Up to $500,000 per incident
|
|
- 🚫 **Loss of payment processing:** Stripe account terminated
|
|
- ⚖️ **Legal liability:** GDPR violations
|
|
- 📰 **Reputation damage:** Customer trust lost
|
|
|
|
### PCI-DSS Quick Reference
|
|
|
|
| Action | Allowed | Example |
|
|
|--------|---------|---------|
|
|
| Store full PAN (card number) | ❌ NO | 4242424242424242 |
|
|
| Store CVV/CVC | ❌ NO | 123 |
|
|
| Store expiry date | ❌ NO | 12/25 |
|
|
| Store PIN | ❌ NO | 1234 |
|
|
| Store last 4 digits | ✅ YES | 4242 |
|
|
| Store card brand | ✅ YES | visa |
|
|
| Store Stripe token | ✅ YES | pm_xxx, pi_xxx |
|
|
| Store customer name | ✅ YES | John Doe |
|
|
|
|
**Rule of Thumb:** If it can be used to make a fraudulent charge, **DON'T STORE IT**.
|
|
|
|
---
|
|
|
|
## Backend Development
|
|
|
|
### File Structure
|
|
|
|
```
|
|
src/modules/payments/
|
|
├── controllers/
|
|
│ └── payments.controller.ts # REST API endpoints
|
|
├── services/
|
|
│ ├── stripe.service.ts # Stripe API wrapper
|
|
│ ├── wallet.service.ts # Wallet management
|
|
│ └── subscription.service.ts # Subscription logic
|
|
├── types/
|
|
│ └── payments.types.ts # TypeScript types
|
|
└── payments.routes.ts # Express routes
|
|
```
|
|
|
|
### Creating a New Payment Endpoint
|
|
|
|
**Step 1: Define Route**
|
|
|
|
```typescript
|
|
// payments.routes.ts
|
|
router.post(
|
|
'/wallet/deposit',
|
|
requireAuth, // ← Always require auth
|
|
createDeposit // ← Controller function
|
|
);
|
|
```
|
|
|
|
**Step 2: Create Controller**
|
|
|
|
```typescript
|
|
// controllers/payments.controller.ts
|
|
export async function createDeposit(req, res, next) {
|
|
try {
|
|
const authReq = req as AuthenticatedRequest;
|
|
const { amount, currency } = req.body;
|
|
|
|
// ✅ Validation
|
|
if (!amount || amount <= 0) {
|
|
res.status(400).json({ error: 'Invalid amount' });
|
|
return;
|
|
}
|
|
|
|
// ❌ Block sensitive data (CRITICAL!)
|
|
const sensitiveFields = ['cardNumber', 'cvv', 'pan', 'expiryDate'];
|
|
if (sensitiveFields.some(field => req.body[field])) {
|
|
logger.warn('Sensitive data blocked', { userId: authReq.user.id });
|
|
res.status(400).json({ error: 'Card data not allowed' });
|
|
return;
|
|
}
|
|
|
|
// ✅ Call service
|
|
const result = await stripeService.createPaymentIntent({
|
|
userId: authReq.user.id,
|
|
amount,
|
|
currency,
|
|
});
|
|
|
|
res.json({ success: true, data: result });
|
|
} catch (error) {
|
|
next(error); // ← Always use error middleware
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Implement Service**
|
|
|
|
```typescript
|
|
// services/stripe.service.ts
|
|
export class StripeService {
|
|
async createPaymentIntent(params: {
|
|
userId: string;
|
|
amount: number;
|
|
currency: string;
|
|
}): Promise<{ clientSecret: string; paymentIntentId: string }> {
|
|
// ✅ Create Payment Intent (server-side)
|
|
const paymentIntent = await this.stripe.paymentIntents.create({
|
|
amount: Math.round(params.amount * 100), // Convert to cents
|
|
currency: params.currency.toLowerCase(),
|
|
metadata: {
|
|
userId: params.userId,
|
|
type: 'wallet_deposit',
|
|
},
|
|
});
|
|
|
|
// ✅ Store transaction in database (safe data only)
|
|
await db.query(
|
|
`INSERT INTO payments.transactions (
|
|
user_id, amount, currency, status, payment_intent_id
|
|
) VALUES ($1, $2, $3, $4, $5)`,
|
|
[params.userId, params.amount, params.currency, 'pending', paymentIntent.id]
|
|
);
|
|
|
|
// ✅ Return client secret (frontend will use this)
|
|
return {
|
|
clientSecret: paymentIntent.client_secret!,
|
|
paymentIntentId: paymentIntent.id,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Webhook Implementation
|
|
|
|
**Critical:** Webhooks MUST verify Stripe signature
|
|
|
|
```typescript
|
|
// controllers/payments.controller.ts
|
|
export async function handleStripeWebhook(req, res, next) {
|
|
const signature = req.headers['stripe-signature'] as string;
|
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
|
|
|
try {
|
|
// ✅ CRITICAL: Verify signature (prevents spoofing)
|
|
const event = stripe.webhooks.constructEvent(
|
|
req.body, // ← Must be raw body (not JSON parsed)
|
|
signature,
|
|
webhookSecret
|
|
);
|
|
|
|
logger.info('Webhook received', {
|
|
type: event.type,
|
|
id: event.id,
|
|
});
|
|
|
|
// Handle event types
|
|
switch (event.type) {
|
|
case 'payment_intent.succeeded':
|
|
await handlePaymentSucceeded(event.data.object);
|
|
break;
|
|
|
|
case 'payment_intent.payment_failed':
|
|
await handlePaymentFailed(event.data.object);
|
|
break;
|
|
|
|
case 'customer.subscription.created':
|
|
await handleSubscriptionCreated(event.data.object);
|
|
break;
|
|
|
|
default:
|
|
logger.warn('Unhandled webhook event', { type: event.type });
|
|
}
|
|
|
|
res.json({ received: true });
|
|
} catch (err) {
|
|
// ❌ Invalid signature - REJECT
|
|
logger.error('Webhook verification failed', { error: err });
|
|
res.status(400).json({ error: 'Invalid signature' });
|
|
}
|
|
}
|
|
```
|
|
|
|
**Important:** Webhook endpoint must use raw body parser:
|
|
|
|
```typescript
|
|
// app.ts
|
|
import { raw } from 'express';
|
|
|
|
app.post(
|
|
'/api/v1/payments/webhook',
|
|
raw({ type: 'application/json' }), // ← Raw body for signature verification
|
|
handleStripeWebhook
|
|
);
|
|
```
|
|
|
|
### Database Schema Guidelines
|
|
|
|
**✅ ALLOWED columns:**
|
|
```sql
|
|
CREATE TABLE payments.transactions (
|
|
id UUID PRIMARY KEY,
|
|
user_id UUID NOT NULL,
|
|
amount DECIMAL(10,2),
|
|
currency VARCHAR(3),
|
|
status VARCHAR(50),
|
|
payment_intent_id VARCHAR(255), -- ✅ Stripe token
|
|
stripe_customer_id VARCHAR(255), -- ✅ Stripe ID
|
|
created_at TIMESTAMPTZ
|
|
);
|
|
```
|
|
|
|
**❌ PROHIBITED columns:**
|
|
```sql
|
|
-- ❌ NEVER create these columns!
|
|
card_number VARCHAR(16) -- PAN
|
|
cvv VARCHAR(4) -- Security code
|
|
expiry_date DATE -- Expiration
|
|
card_holder_name VARCHAR(100) -- Sensitive
|
|
pin VARCHAR(4) -- PIN
|
|
```
|
|
|
|
---
|
|
|
|
## Frontend Development
|
|
|
|
### Stripe Elements Setup
|
|
|
|
**Step 1: Wrap App with Stripe Provider**
|
|
|
|
```typescript
|
|
// App.tsx
|
|
import { Elements } from '@stripe/react-stripe-js';
|
|
import { loadStripe } from '@stripe/stripe-js';
|
|
|
|
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY!);
|
|
|
|
function App() {
|
|
return (
|
|
<Elements stripe={stripePromise}>
|
|
<YourRoutes />
|
|
</Elements>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Create Payment Form**
|
|
|
|
```typescript
|
|
// DepositForm.tsx
|
|
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
|
|
|
function DepositForm() {
|
|
const stripe = useStripe();
|
|
const elements = useElements();
|
|
const [amount, setAmount] = useState(100);
|
|
const [processing, setProcessing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!stripe || !elements) {
|
|
return; // Stripe not loaded yet
|
|
}
|
|
|
|
setProcessing(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Step 1: Backend creates Payment Intent (NO card data sent)
|
|
const response = await apiClient.post('/api/v1/payments/wallet/deposit', {
|
|
amount, // ✅ Safe
|
|
currency: 'USD', // ✅ Safe
|
|
// ❌ NO cardNumber, cvv, expiryDate
|
|
});
|
|
|
|
const { clientSecret } = response.data.data;
|
|
|
|
// Step 2: Confirm payment with Stripe (card data goes to Stripe)
|
|
const cardElement = elements.getElement(CardElement);
|
|
|
|
const { error, paymentIntent } = await stripe.confirmCardPayment(
|
|
clientSecret,
|
|
{
|
|
payment_method: {
|
|
card: cardElement!, // ← Stripe iframe reference
|
|
},
|
|
}
|
|
);
|
|
|
|
if (error) {
|
|
setError(error.message || 'Payment failed');
|
|
} else if (paymentIntent.status === 'succeeded') {
|
|
// ✅ Success! (Backend webhook will update database)
|
|
onSuccess();
|
|
}
|
|
} catch (err) {
|
|
setError(err.message || 'An error occurred');
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<input
|
|
type="number"
|
|
value={amount}
|
|
onChange={(e) => setAmount(Number(e.target.value))}
|
|
min="1"
|
|
/>
|
|
|
|
{/* ✅ Stripe CardElement = iframe from stripe.com */}
|
|
<CardElement
|
|
options={{
|
|
style: {
|
|
base: {
|
|
fontSize: '16px',
|
|
color: '#424770',
|
|
'::placeholder': { color: '#aab7c4' },
|
|
},
|
|
invalid: { color: '#9e2146' },
|
|
},
|
|
}}
|
|
/>
|
|
|
|
{error && <div className="error">{error}</div>}
|
|
|
|
<button type="submit" disabled={processing || !stripe}>
|
|
{processing ? 'Processing...' : `Deposit $${amount}`}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Checkout Session Flow (Stripe Hosted)
|
|
|
|
For subscription payments, use Stripe Checkout (hosted page):
|
|
|
|
```typescript
|
|
// SubscriptionButton.tsx
|
|
async function handleSubscribe() {
|
|
// Backend creates Checkout Session
|
|
const response = await apiClient.post('/api/v1/payments/checkout', {
|
|
planId: 'plan_basic',
|
|
billingCycle: 'monthly',
|
|
successUrl: `${window.location.origin}/success`,
|
|
cancelUrl: `${window.location.origin}/cancel`,
|
|
});
|
|
|
|
const { url } = response.data.data;
|
|
|
|
// ✅ Redirect to Stripe hosted page
|
|
window.location.href = url; // ← User enters card on checkout.stripe.com
|
|
}
|
|
```
|
|
|
|
### Error Handling
|
|
|
|
```typescript
|
|
// Handle Stripe errors
|
|
const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {...});
|
|
|
|
if (error) {
|
|
// Stripe validation errors
|
|
switch (error.type) {
|
|
case 'card_error':
|
|
// Card was declined, invalid number, etc.
|
|
setError(error.message);
|
|
break;
|
|
|
|
case 'validation_error':
|
|
// Invalid parameters sent to Stripe
|
|
setError('Please check your input');
|
|
break;
|
|
|
|
case 'api_error':
|
|
// Stripe API issue
|
|
setError('Payment service unavailable');
|
|
break;
|
|
|
|
default:
|
|
setError('An unexpected error occurred');
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Guidelines
|
|
|
|
### Backend Tests
|
|
|
|
**Unit Tests:**
|
|
```typescript
|
|
// stripe.service.test.ts
|
|
describe('StripeService', () => {
|
|
it('should create Payment Intent', async () => {
|
|
const mockStripe = {
|
|
paymentIntents: {
|
|
create: jest.fn().mockResolvedValue({
|
|
id: 'pi_test_123',
|
|
client_secret: 'pi_test_123_secret_456',
|
|
}),
|
|
},
|
|
};
|
|
|
|
const service = new StripeService(mockStripe);
|
|
|
|
const result = await service.createPaymentIntent({
|
|
userId: 'user_123',
|
|
amount: 100,
|
|
currency: 'usd',
|
|
});
|
|
|
|
expect(result.clientSecret).toBe('pi_test_123_secret_456');
|
|
expect(mockStripe.paymentIntents.create).toHaveBeenCalledWith({
|
|
amount: 10000,
|
|
currency: 'usd',
|
|
metadata: expect.objectContaining({ userId: 'user_123' }),
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**E2E Tests (PCI-DSS Compliance):**
|
|
```typescript
|
|
// payments-pci-dss.test.ts
|
|
describe('PCI-DSS Compliance', () => {
|
|
it('should reject request with card data', async () => {
|
|
const response = await request(app)
|
|
.post('/api/v1/payments/wallet/deposit')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({
|
|
amount: 100,
|
|
cardNumber: '4242424242424242', // ← Attempt to send card data
|
|
})
|
|
.expect(400);
|
|
|
|
expect(response.body.error).toContain('Card data not allowed');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Frontend Tests
|
|
|
|
**Component Tests:**
|
|
```typescript
|
|
// DepositForm.test.tsx
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import { Elements } from '@stripe/react-stripe-js';
|
|
|
|
describe('DepositForm', () => {
|
|
it('should render Stripe CardElement', () => {
|
|
render(
|
|
<Elements stripe={mockStripe}>
|
|
<DepositForm />
|
|
</Elements>
|
|
);
|
|
|
|
// ✅ Stripe CardElement rendered
|
|
expect(screen.getByTestId('stripe-card-element')).toBeInTheDocument();
|
|
|
|
// ❌ NO native card inputs
|
|
expect(screen.queryByPlaceholderText(/card number/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should NOT store card data in state', () => {
|
|
const { container } = render(
|
|
<Elements stripe={mockStripe}>
|
|
<DepositForm />
|
|
</Elements>
|
|
);
|
|
|
|
const html = container.innerHTML;
|
|
|
|
// ❌ NO card data in DOM
|
|
expect(html).not.toContain('cardNumber');
|
|
expect(html).not.toContain('cvv');
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall 1: Accepting Card Data in Backend
|
|
|
|
**❌ WRONG:**
|
|
```typescript
|
|
export async function createPayment(req, res) {
|
|
const { cardNumber, cvv } = req.body; // ← VIOLATION!
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**✅ CORRECT:**
|
|
```typescript
|
|
export async function createPayment(req, res) {
|
|
const { amount, currency } = req.body;
|
|
|
|
// Block sensitive data
|
|
if (req.body.cardNumber || req.body.cvv) {
|
|
return res.status(400).json({ error: 'Card data not allowed' });
|
|
}
|
|
|
|
// Create Payment Intent (safe)
|
|
const paymentIntent = await stripe.paymentIntents.create({ amount, currency });
|
|
res.json({ clientSecret: paymentIntent.client_secret });
|
|
}
|
|
```
|
|
|
|
### Pitfall 2: Storing Full PAN in Database
|
|
|
|
**❌ WRONG:**
|
|
```sql
|
|
CREATE TABLE payment_methods (
|
|
card_number VARCHAR(16) -- ← SEVERE VIOLATION!
|
|
);
|
|
```
|
|
|
|
**✅ CORRECT:**
|
|
```sql
|
|
CREATE TABLE payment_methods (
|
|
stripe_payment_method_id VARCHAR(255), -- ✅ Token
|
|
card_last4 VARCHAR(4), -- ✅ Last 4 digits
|
|
card_brand VARCHAR(50) -- ✅ Metadata
|
|
);
|
|
```
|
|
|
|
### Pitfall 3: Native Card Inputs
|
|
|
|
**❌ WRONG:**
|
|
```tsx
|
|
<input type="text" name="cardNumber" /> {/* VIOLATION! */}
|
|
```
|
|
|
|
**✅ CORRECT:**
|
|
```tsx
|
|
import { CardElement } from '@stripe/react-stripe-js';
|
|
|
|
<CardElement /> {/* Stripe iframe */}
|
|
```
|
|
|
|
### Pitfall 4: Not Verifying Webhook Signatures
|
|
|
|
**❌ WRONG:**
|
|
```typescript
|
|
export async function handleWebhook(req, res) {
|
|
const event = req.body; // ← NO VERIFICATION! Insecure!
|
|
// Anyone can spoof this endpoint
|
|
}
|
|
```
|
|
|
|
**✅ CORRECT:**
|
|
```typescript
|
|
export async function handleWebhook(req, res) {
|
|
const signature = req.headers['stripe-signature'];
|
|
|
|
const event = stripe.webhooks.constructEvent(
|
|
req.body,
|
|
signature,
|
|
webhookSecret // ← VERIFIED signature
|
|
);
|
|
}
|
|
```
|
|
|
|
### Pitfall 5: Logging Sensitive Data
|
|
|
|
**❌ WRONG:**
|
|
```typescript
|
|
console.log('Payment:', {
|
|
cardNumber: req.body.cardNumber, // ← VIOLATION!
|
|
cvv: req.body.cvv,
|
|
});
|
|
```
|
|
|
|
**✅ CORRECT:**
|
|
```typescript
|
|
logger.info('Payment created', {
|
|
userId,
|
|
amount,
|
|
paymentIntentId, // ← Safe token
|
|
// NO card data
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Code Review Checklist
|
|
|
|
**Before approving any payment-related PR, verify:**
|
|
|
|
### Security
|
|
- [ ] ❌ NO card data accepted in API (cardNumber, cvv, expiryDate)
|
|
- [ ] ❌ NO card data stored in database (PAN, CVV, expiry)
|
|
- [ ] ✅ Only Stripe tokens/IDs stored (pm_xxx, pi_xxx, cus_xxx)
|
|
- [ ] ✅ Webhook signatures verified (constructEvent)
|
|
- [ ] ✅ HTTPS enforced (no HTTP endpoints)
|
|
- [ ] ✅ Input validation on all endpoints
|
|
- [ ] ✅ No sensitive data in logs
|
|
|
|
### Code Quality
|
|
- [ ] TypeScript types defined
|
|
- [ ] Error handling implemented
|
|
- [ ] Logging added (info, warn, error)
|
|
- [ ] Tests added/updated (unit + E2E)
|
|
- [ ] Documentation updated
|
|
|
|
### Stripe Integration
|
|
- [ ] Payment Intents used (not deprecated APIs)
|
|
- [ ] Stripe Elements used in frontend (not native inputs)
|
|
- [ ] Client Secret format validated (pi_xxx_secret_yyy)
|
|
- [ ] Metadata included in Stripe calls
|
|
|
|
### Database
|
|
- [ ] Parameterized queries (NO SQL injection)
|
|
- [ ] Transactions used for multi-step operations
|
|
- [ ] Indexes created for query performance
|
|
- [ ] Migrations tested
|
|
|
|
---
|
|
|
|
## Deployment Checklist
|
|
|
|
**Before deploying payment features to production:**
|
|
|
|
### Environment Variables
|
|
- [ ] `STRIPE_SECRET_KEY` set (sk_live_...)
|
|
- [ ] `STRIPE_PUBLISHABLE_KEY` set (pk_live_...)
|
|
- [ ] `STRIPE_WEBHOOK_SECRET` set (whsec_...)
|
|
- [ ] `DATABASE_URL` configured
|
|
- [ ] `JWT_SECRET` set (strong random key)
|
|
|
|
### Stripe Configuration
|
|
- [ ] Live mode API keys configured
|
|
- [ ] Webhooks configured (payment_intent.*, customer.subscription.*)
|
|
- [ ] Webhook endpoint HTTPS URL added
|
|
- [ ] Test webhooks with Stripe CLI
|
|
- [ ] Radar fraud detection enabled (recommended)
|
|
|
|
### Security
|
|
- [ ] SSL/TLS certificate valid (HTTPS)
|
|
- [ ] HSTS header enabled
|
|
- [ ] CSP header configured (allow stripe.com)
|
|
- [ ] Rate limiting enabled (60 req/min recommended)
|
|
- [ ] Firewall rules configured
|
|
- [ ] Database backups enabled
|
|
|
|
### Testing
|
|
- [ ] All E2E tests passing (45+ tests)
|
|
- [ ] Manual testing completed
|
|
- [ ] Test payment with real card (Stripe test mode)
|
|
- [ ] Webhook handling verified
|
|
- [ ] Error scenarios tested
|
|
|
|
### Monitoring
|
|
- [ ] Logging configured (info, warn, error)
|
|
- [ ] Error tracking enabled (Sentry, etc.)
|
|
- [ ] Payment metrics dashboard
|
|
- [ ] Alerts configured (failed payments, webhook errors)
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Common Issues
|
|
|
|
#### Issue 1: "No such payment_intent"
|
|
|
|
**Cause:** Invalid Payment Intent ID
|
|
|
|
**Solution:**
|
|
```typescript
|
|
// Verify Payment Intent ID format
|
|
if (!paymentIntentId.startsWith('pi_')) {
|
|
throw new Error('Invalid Payment Intent ID');
|
|
}
|
|
|
|
// Check Stripe dashboard for actual ID
|
|
```
|
|
|
|
#### Issue 2: "Webhook signature verification failed"
|
|
|
|
**Cause:** Invalid signature or wrong webhook secret
|
|
|
|
**Solution:**
|
|
```typescript
|
|
// 1. Verify webhook secret is correct
|
|
console.log('Webhook Secret:', process.env.STRIPE_WEBHOOK_SECRET);
|
|
|
|
// 2. Check raw body is used (not JSON parsed)
|
|
app.post('/webhook', raw({ type: 'application/json' }), handler);
|
|
|
|
// 3. Test with Stripe CLI
|
|
// stripe listen --forward-to localhost:3000/api/v1/payments/webhook
|
|
```
|
|
|
|
#### Issue 3: "CardElement not rendering"
|
|
|
|
**Cause:** Stripe not loaded or Elements context missing
|
|
|
|
**Solution:**
|
|
```typescript
|
|
// 1. Wrap with Elements provider
|
|
<Elements stripe={stripePromise}>
|
|
<DepositForm />
|
|
</Elements>
|
|
|
|
// 2. Check Stripe loaded
|
|
const stripe = useStripe();
|
|
if (!stripe) {
|
|
return <div>Loading...</div>;
|
|
}
|
|
```
|
|
|
|
#### Issue 4: "Payment stuck in 'pending'"
|
|
|
|
**Cause:** Webhook not received or failed
|
|
|
|
**Solution:**
|
|
```bash
|
|
# 1. Check webhook logs
|
|
stripe webhook logs --limit 10
|
|
|
|
# 2. Manually trigger webhook (for testing)
|
|
stripe trigger payment_intent.succeeded
|
|
|
|
# 3. Verify webhook endpoint is reachable
|
|
curl -X POST https://api.example.com/api/v1/payments/webhook
|
|
```
|
|
|
|
---
|
|
|
|
## Examples & Templates
|
|
|
|
### Example 1: Wallet Deposit Flow (Complete)
|
|
|
|
```typescript
|
|
// Backend: payments.controller.ts
|
|
export async function createDeposit(req, res) {
|
|
const { amount, currency } = req.body;
|
|
|
|
const paymentIntent = await stripe.paymentIntents.create({
|
|
amount: amount * 100,
|
|
currency,
|
|
metadata: { userId: req.user.id, type: 'deposit' },
|
|
});
|
|
|
|
await db.query(
|
|
'INSERT INTO transactions (user_id, amount, payment_intent_id, status) VALUES ($1, $2, $3, $4)',
|
|
[req.user.id, amount, paymentIntent.id, 'pending']
|
|
);
|
|
|
|
res.json({ clientSecret: paymentIntent.client_secret });
|
|
}
|
|
|
|
// Frontend: DepositForm.tsx
|
|
function DepositForm() {
|
|
const stripe = useStripe();
|
|
const elements = useElements();
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
|
|
const { clientSecret } = await fetch('/api/v1/payments/wallet/deposit', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ amount: 100, currency: 'USD' }),
|
|
}).then(r => r.json());
|
|
|
|
const { error } = await stripe.confirmCardPayment(clientSecret, {
|
|
payment_method: { card: elements.getElement(CardElement) },
|
|
});
|
|
|
|
if (!error) {
|
|
alert('Deposit successful!');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<CardElement />
|
|
<button type="submit">Deposit</button>
|
|
</form>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Example 2: Subscription Checkout (Stripe Hosted)
|
|
|
|
```typescript
|
|
// Backend: createCheckoutSession
|
|
export async function createCheckoutSession(req, res) {
|
|
const { planId, billingCycle } = req.body;
|
|
|
|
const session = await stripe.checkout.sessions.create({
|
|
mode: 'subscription',
|
|
customer_email: req.user.email,
|
|
line_items: [{
|
|
price: getPriceId(planId, billingCycle),
|
|
quantity: 1,
|
|
}],
|
|
success_url: `${req.body.successUrl}?session_id={CHECKOUT_SESSION_ID}`,
|
|
cancel_url: req.body.cancelUrl,
|
|
metadata: { userId: req.user.id, planId },
|
|
});
|
|
|
|
res.json({ sessionId: session.id, url: session.url });
|
|
}
|
|
|
|
// Frontend: SubscribeButton.tsx
|
|
async function handleSubscribe() {
|
|
const { url } = await fetch('/api/v1/payments/checkout', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
planId: 'basic',
|
|
billingCycle: 'monthly',
|
|
successUrl: window.location.origin + '/success',
|
|
cancelUrl: window.location.origin + '/cancel',
|
|
}),
|
|
}).then(r => r.json());
|
|
|
|
window.location.href = url; // Redirect to Stripe
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Quick Reference
|
|
|
|
### DO's ✅
|
|
|
|
- ✅ Use Payment Intents (server-side processing)
|
|
- ✅ Use Stripe Elements (client-side tokenization)
|
|
- ✅ Verify webhook signatures
|
|
- ✅ Store only tokens/IDs (pm_xxx, pi_xxx)
|
|
- ✅ Use HTTPS everywhere
|
|
- ✅ Log payment events (without sensitive data)
|
|
- ✅ Write E2E tests for PCI-DSS compliance
|
|
- ✅ Validate input on all endpoints
|
|
- ✅ Use parameterized SQL queries
|
|
|
|
### DON'Ts ❌
|
|
|
|
- ❌ Accept card data in backend
|
|
- ❌ Store PAN, CVV, or expiry in database
|
|
- ❌ Create native card inputs
|
|
- ❌ Store card data in React state
|
|
- ❌ Skip webhook signature verification
|
|
- ❌ Use HTTP (only HTTPS)
|
|
- ❌ Log sensitive data
|
|
- ❌ Concatenate SQL queries
|
|
|
|
---
|
|
|
|
## Support
|
|
|
|
**Questions or Issues?**
|
|
- Slack: #payments-support
|
|
- Email: payments-team@trading-platform.com
|
|
- Documentation: [ET-PAY-006: PCI-DSS Architecture](./especificaciones/ET-PAY-006-pci-dss-architecture.md)
|
|
|
|
**Security Concerns?**
|
|
- Email: security@trading-platform.com
|
|
- **DO NOT** discuss card data in public channels
|
|
|
|
---
|
|
|
|
**Version:** 1.0.0
|
|
**Last Updated:** 2026-01-26
|
|
**Next Review:** 2026-07-26
|
|
|
|
---
|
|
|
|
*These guidelines are mandatory for all developers working on payment features. Non-compliance may result in PCI-DSS violations and severe penalties.*
|