# 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'; // ✅ 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 // ❌ 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 ( ); } ``` **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(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 (
setAmount(Number(e.target.value))} min="1" /> {/* ✅ Stripe CardElement = iframe from stripe.com */} {error &&
{error}
} ); } ``` ### 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( ); // ✅ 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( ); 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 {/* VIOLATION! */} ``` **✅ CORRECT:** ```tsx import { CardElement } from '@stripe/react-stripe-js'; {/* 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 // 2. Check Stripe loaded const stripe = useStripe(); if (!stripe) { return
Loading...
; } ``` #### 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 (
); } ``` ### 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.*