Stripe Integration Plan
Issue: #202 Add Billing Page, #216 Integrate with Stripe
Status: Ready for Implementation
Last Updated: 2025-11-03
Table of Contents
- Overview
- Architecture Decision: Credit Burndown Model
- When Stripe Integration Happens
- Implementation Phases
- Development Environment Setup
- Backend Implementation
- Frontend Implementation
- Testing Strategy
- Production Deployment
- Monitoring and Operations
Overview
This document outlines the plan for integrating Stripe payment processing into the Construction Code Expert billing system. The integration will enable real payment processing while maintaining the existing credit balance tracking architecture.
Goals
- ✅ Enable real payment processing for balance top-ups
- ✅ Maintain current credit balance tracking in Firestore
- ✅ Keep Stripe integration simple and focused on payments only
- ✅ Support development, test, and production environments
- ✅ Ensure secure handling of payment credentials
Non-Goals
- ❌ Reporting every task execution to Stripe (too complex, unnecessary)
- ❌ Stripe-managed subscriptions (future phase)
- ❌ Stripe-hosted billing portal (using our own UI)
Architecture Decision: Credit Burndown Model
Stripe Pricing Models Comparison
Stripe offers multiple pricing models. We've chosen the Credit Burndown model:
| Model | How It Works | Stripe Involvement | Best For |
|---|---|---|---|
| Credit Burndown ✅ | Pre-purchase credits, spend down locally | Payment only | Our use case |
| Usage-Based Metering | Report usage to Stripe, bill periodically | Every usage event | SaaS with predictable usage |
| Subscriptions | Recurring charges | Monthly billing | Fixed-price plans |
Why Credit Burndown:
- Simplicity - Stripe only processes payments, not usage tracking
- Performance - No API calls during task execution (which happens frequently)
- User Control - Users top up when they want, not auto-billed
- Cost - Fewer Stripe API calls = lower fees
- Privacy - Usage details stay in our system, not Stripe's
Reference Documentation
When Stripe Integration Happens
✅ Stripe API Calls - Balance Top-Up Flow
When: User manually adds funds to their account
Stripe APIs Used:
PaymentIntent.create()- Create payment intentWebhook.constructEvent()- Verify webhook signatureCustomer.search()/Customer.create()- Manage customer records
Frequency: Low (user-initiated, maybe once per week/month)
❌ NO Stripe API Calls - Balance Consumption Flow
When: Every time a user runs a task (frequent)
Stripe APIs Used: NONE - All local to our system
Frequency: High (every task execution, potentially 100s per day per user)
Why No Stripe:
- ⚡ Performance - No network latency from Stripe API
- 💰 Cost - No API fees for balance checks/deductions
- 🔒 Privacy - Usage patterns stay internal
- 📊 Simplicity - Single source of truth (Firestore)
Implementation Phases
Phase 1: Development Environment (Week 1) ⭐ START HERE
Objective: Set up Stripe test mode for development
Tasks:
- Create Stripe account (test mode)
- Configure environment variables
- Update
BillingServiceImplto use real Stripe - Test payment flow in Stripe test mode
- Set up webhook endpoint locally
Deliverables:
- Stripe test account configured
- Local webhook testing working
- End-to-end payment flow tested
Phase 2: Frontend Payment UI (Week 2)
Objective: Build payment method collection and confirmation UI
Tasks:
- Install Stripe.js in Angular app
- Create payment method input component
- Implement 3D Secure / SCA handling
- Add payment confirmation dialog
- Handle payment failures gracefully
Deliverables:
- Payment method input UI
- Stripe Elements integration
- Error handling and retry logic
Phase 3: Test Environment (Week 3)
Objective: Deploy to test environment with Stripe test keys
Tasks:
- Configure Stripe test keys in Secret Manager
- Deploy backend with Stripe integration
- Set up webhook endpoint in Cloud Run
- Configure Stripe webhook in Dashboard
- End-to-end testing
Deliverables:
- Test environment with live Stripe test mode
- Webhook processing verified
- Test transactions successful
Phase 4: Production Deployment (Week 4)
Objective: Go live with real Stripe account
Tasks:
- Complete Stripe account verification
- Configure production API keys in Secret Manager
- Set up production webhook endpoint
- Gradual rollout to users
- Monitor for issues
Deliverables:
- Production Stripe integration
- Real payments processing
- Monitoring and alerting active
Development Environment Setup
Step 1: Create Stripe Account
-
Sign up for Stripe:
- Go to https://dashboard.stripe.com/register
- Use your work email
- Complete account setup
-
Enable Test Mode:
- In Stripe Dashboard, toggle to "Test mode" (top right)
- All API keys will have
_test_prefix
Step 2: Get API Keys
Navigate to Developers → API keys in Stripe Dashboard:
# Test mode keys (safe to use in development)
STRIPE_PUBLISHABLE_KEY=pk_test_51xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_SECRET_KEY=sk_test_51xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Important:
- ✅ Test keys start with
pk_test_andsk_test_ - ✅ Test mode = no real charges, fake credit cards work
- ❌ NEVER commit secret keys to git (use Secret Manager - see Step 5)
- ❌ NEVER use production keys in development
- ✅ Frontend publishable keys (pk_*) can be committed (they're meant to be public)
- ❌ Backend secret keys (sk_*) must use Secret Manager (even test keys)
Step 3: Install Stripe CLI (Optional but Recommended)
The Stripe CLI helps with local webhook testing:
# macOS
brew install stripe/stripe-cli/stripe
# Linux
wget https://github.com/stripe/stripe-cli/releases/latest/download/stripe_linux_x86_64.tar.gz
tar -xvf stripe_linux_x86_64.tar.gz
sudo mv stripe /usr/local/bin/
# Verify installation
stripe --version
# Login to your Stripe account
stripe login
Step 4: Configure Environment Variables
🔐 IMPORTANT: Security Best Practice
Backend secret keys (sk_test_, sk_live_) should NEVER be in vars.yaml or setvars.sh - even for test environments.
Use Google Secret Manager for ALL backend secrets (see Step 5 below). Only configuration flags go in vars.yaml.
Backend Configuration (vars.yaml):
Update env/dev/gcp/cloud-run/grpc/vars.yaml:
# Existing vars
GCP_PROJECT_ID: "construction-code-expert-dev"
# ... other vars ...
# Stripe Configuration (Issue #216)
# Enable/disable Stripe integration - actual keys come from Secret Manager
STRIPE_ENABLED: "false" # Set to "true" when ready to test with real Stripe
# NOTE: Do NOT put STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET here!
# These are loaded from Secret Manager at deployment (see Step 5)
Why not put test keys in vars.yaml?
- ✅
vars.yamlis committed to git → secrets shouldn't be - ✅ Practice the same pattern in dev, test, and prod
- ✅ Easy key rotation without code changes
- ✅ Audit trail of who accessed secrets
- ✅ Prevents accidental exposure in public repos
Frontend Configuration (setvars.sh):
The publishable key CAN go in setvars.sh since it's meant to be public:
Update env/dev/firebase/m3/setvars.sh:
# ... existing vars ...
# Stripe Configuration (Issue #216)
# Publishable key is safe to commit (client-side, domain-restricted)
export STRIPE_PUBLISHABLE_KEY=pk_test_51xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Next: Proceed to Step 5 to properly configure backend secrets in Secret Manager.
Step 5: Store Secrets in Google Secret Manager (Required for All Environments)
Use Secret Manager for dev, test, and production - this is the proper way to handle backend secrets:
# Set environment
ENV=dev # or 'test', 'prod'
GCP_PROJECT_ID="construction-code-expert-${ENV}"
# Create secrets in Google Secret Manager
# Note: --data-file=- means "read from stdin" (via the pipe)
# This is more secure than writing secrets to a temporary file
# The secret goes directly from memory to Secret Manager without touching disk
echo -n "sk_test_51xxxxx" | gcloud secrets create stripe-secret-key \
--data-file=- \
--project=${GCP_PROJECT_ID}
echo -n "whsec_xxxxx" | gcloud secrets create stripe-webhook-secret \
--data-file=- \
--project=${GCP_PROJECT_ID}
# Grant Cloud Run service account access
SERVICE_ACCOUNT="construction-code-expert-backend@${GCP_PROJECT_ID}.iam.gserviceaccount.com"
gcloud secrets add-iam-policy-binding stripe-secret-key \
--member="serviceAccount:${SERVICE_ACCOUNT}" \
--role="roles/secretmanager.secretAccessor" \
--project=${GCP_PROJECT_ID}
gcloud secrets add-iam-policy-binding stripe-webhook-secret \
--member="serviceAccount:${SERVICE_ACCOUNT}" \
--role="roles/secretmanager.secretAccessor" \
--project=${GCP_PROJECT_ID}
# Grant AI Agent service account access (for local testing)
# The AI agent runs from the 'test' project but may need to access secrets in other environments
AI_AGENT_SA="ai-swe-agent@construction-code-expert-test.iam.gserviceaccount.com"
gcloud secrets add-iam-policy-binding stripe-secret-key \
--member="serviceAccount:${AI_AGENT_SA}" \
--role="roles/secretmanager.secretAccessor" \
--project=${GCP_PROJECT_ID}
gcloud secrets add-iam-policy-binding stripe-webhook-secret \
--member="serviceAccount:${AI_AGENT_SA}" \
--role="roles/secretmanager.secretAccessor" \
--project=${GCP_PROJECT_ID} 2>/dev/null || \
echo "⚠️ stripe-webhook-secret doesn't exist yet - will grant access after creation"
Quick Setup Script:
Alternatively, use the provided script to grant permissions:
# Grant AI agent access to Stripe secrets in dev environment
./cli/sdlc/setup-stripe-secret-permissions.sh dev
# Grant AI agent access to Stripe secrets in test environment
./cli/sdlc/setup-stripe-secret-permissions.sh test
The script:
- ✅ Checks if secrets exist
- ✅ Grants access to ai-swe-agent service account
- ✅ Verifies access by reading secrets (impersonating the service account)
- ✅ Provides helpful error messages
⚠️ IMPORTANT: How to Get the Webhook Secret
The webhook secret (whsec_xxx) is NOT in your API keys section. You get it when you create a webhook endpoint:
For Local Development (Stripe CLI):
# Run the Stripe CLI to forward webhooks to your local server
stripe listen --forward-to localhost:8080/v1/billing/webhook
# Output will show:
# > Ready! Your webhook signing secret is whsec_1234567890abcdef...
#
# Copy this secret and store it in Secret Manager:
echo -n "whsec_1234567890abcdef..." | gcloud secrets create stripe-webhook-secret \
--data-file=- \
--project=${GCP_PROJECT_ID}
For Deployed Environments (Cloud Run):
You need to create a webhook endpoint in Stripe Dashboard first:
- Deploy your backend (initial deployment without webhook secret is OK)
- Note your webhook URL:
https://construction-code-expert-esp2-${ENV}-xxx.a.run.app/v1/billing/webhook - Go to Stripe Dashboard → Developers → Webhooks (https://dashboard.stripe.com/webhooks)
- Click "Add endpoint"
- Enter your webhook URL
- Click "Select events" and choose:
- ✅
payment_intent.succeeded - ✅
payment_intent.payment_failed - ✅
charge.dispute.created(optional)
- ✅
- Click "Add endpoint"
- On the endpoint details page, you'll see "Signing secret"
- Click "Reveal" to see the secret (starts with
whsec_) - Copy and store in Secret Manager:
echo -n "whsec_xxxxxxxxxxxxxxxxxxxxx" | gcloud secrets create stripe-webhook-secret \
--data-file=- \
--project=${GCP_PROJECT_ID}
- Redeploy your backend to mount the webhook secret
Order of Operations:
- ✅ Get API keys → Store in Secret Manager
- ✅ Deploy backend (webhooks will fail initially - that's OK)
- ✅ Create webhook endpoint in Stripe → Get webhook secret
- ✅ Store webhook secret in Secret Manager
- ✅ Redeploy backend with webhook secret mounted → Webhooks now work
Step 6: Update Cloud Run Configuration
When deploying to Cloud Run, mount secrets as environment variables:
gcloud run deploy construction-code-expert-grpc \
--source=. \
--platform=managed \
--region=us-central1 \
--allow-unauthenticated \
--set-env-vars-file=env/${ENV}/gcp/cloud-run/grpc/vars.yaml \
--set-secrets="STRIPE_SECRET_KEY=stripe-secret-key:latest,STRIPE_WEBHOOK_SECRET=stripe-webhook-secret:latest" \
--project=${GCP_PROJECT_ID}
Step 7: Set Up Local Webhook Forwarding (For Local Development)
For local development, use Stripe CLI to forward webhooks to your local machine:
# Forward Stripe webhooks to your local server
stripe listen --forward-to localhost:8080/v1/billing/webhook
# This will output something like:
# > Ready! Your webhook signing secret is whsec_1234567890abcdef...
# > 2025-11-03 12:34:56 --> payment_intent.succeeded [evt_xxx]
What this does:
- ✅ Forwards webhook events from Stripe to your local server (even though it's not publicly accessible)
- ✅ Provides a temporary webhook signing secret (only valid while CLI is running)
- ✅ Shows real webhook signatures for testing
- ✅ Logs all events in the terminal for debugging
- ✅ Allows manual event triggering:
stripe trigger payment_intent.succeeded
Important Notes:
- 🔄 The
whsec_xxxsecret generated bystripe listenis temporary - it changes each time you run the command - 🔄 This is only for local development - deployed environments use endpoint-specific secrets (from Step 5)
- 💡 You can use this secret in your local environment variables while testing
- 💡 Leave the CLI running in a terminal window while you develop
Step 8: Test Stripe Configuration
Create a simple test script to verify Stripe connectivity:
// Test in BillingServiceImpl or create a CLI tool
public void testStripeConnection() {
try {
String secretKey = System.getenv("STRIPE_SECRET_KEY");
if (secretKey == null || !secretKey.startsWith("sk_test_")) {
logger.warning("Stripe secret key not configured or not in test mode");
return;
}
StripeService stripe = new StripeService(secretKey, null, false);
String customerId = stripe.getOrCreateStripeCustomer("test@example.com");
logger.info("✅ Stripe connection successful! Customer ID: " + customerId);
} catch (Exception e) {
logger.severe("❌ Stripe connection failed: " + e.getMessage());
}
}
Step 9: Test Payment Flow
Use Stripe's test card numbers:
Card Number: 4242 4242 4242 4242
Expiry: Any future date
CVC: Any 3 digits
ZIP: Any 5 digits
Other test cards:
- 4000 0027 6000 3184 (requires 3D Secure)
- 4000 0000 0000 9995 (card declined)
- 4000 0000 0000 0069 (charge fails)
Environment Setup Checklist
- Stripe account created and verified
- Test mode API keys obtained
- Stripe CLI installed and authenticated
- Backend secrets stored in Google Secret Manager (dev, test, AND prod)
-
STRIPE_ENABLEDflag added tovars.yaml(config only, no secrets) - Frontend publishable key added to
setvars.sh(safe to commit) - Cloud Run deployment configured to mount secrets from Secret Manager
- Local webhook forwarding working with Stripe CLI
- Test payment successful with test card
- Verified no secrets in
vars.yaml(only config flags)
Backend Implementation
Step 1: Update StripeService Configuration
Currently, StripeService is initialized in mock mode. Update BillingServiceImpl to use environment-based configuration:
File: src/main/java/org/codetricks/construction/code/assistant/billing/BillingServiceImpl.java
private void initializeDependencies() {
initializeFirestore();
// Initialize Stripe service based on environment
String stripeSecretKey = System.getenv("STRIPE_SECRET_KEY");
String stripeWebhookSecret = System.getenv("STRIPE_WEBHOOK_SECRET");
String stripeEnabledStr = System.getenv("STRIPE_ENABLED");
boolean stripeEnabled = "true".equalsIgnoreCase(stripeEnabledStr);
if (stripeEnabled && stripeSecretKey != null && !stripeSecretKey.isEmpty()) {
logger.info("Initializing Stripe service in LIVE mode");
this.stripeService = new StripeService(stripeSecretKey, stripeWebhookSecret, false);
} else {
logger.info("Initializing Stripe service in MOCK mode");
this.stripeService = new StripeService();
}
}
Step 2: Implement Webhook Endpoint
What are Stripe Webhooks?
Webhooks are HTTP callbacks (server-to-server notifications) that Stripe sends to your application when specific events occur. Think of them as "reverse API calls" - instead of you calling Stripe's API, Stripe calls your API to notify you of events.
Why webhooks are essential:
In an asynchronous payment flow, certain events happen on Stripe's servers that you need to know about:
Without webhooks:
User pays → You create PaymentIntent → User confirms → ??? (You don't know if it succeeded!)
❌ You'd have to constantly poll Stripe to check payment status
With webhooks:
User pays → You create PaymentIntent → User confirms → Stripe sends webhook → You update balance
✅ Stripe instantly notifies you when payment succeeds or fails
Common webhook events:
| Event | When it fires | What you should do |
|---|---|---|
payment_intent.succeeded | Payment completed successfully | Update user balance, create transaction record |
payment_intent.payment_failed | Payment failed (card declined, etc.) | Notify user, clean up pending transaction |
charge.dispute.created | User disputes a charge | Flag transaction for review, notify admin |
customer.subscription.updated | Subscription changed | Update user's subscription tier (future) |
charge.refunded | Charge was refunded | Deduct from user balance, create refund transaction |
How webhooks work in our payment flow:
Why asynchronous?
- Payment processing takes time (3D Secure, fraud checks, bank authorization)
- Network issues can delay confirmation
- Some payment methods require manual review
- Webhook ensures you get notified even if user closes browser
Security: Webhook Signature Verification
Anyone could POST fake data to your webhook URL, so Stripe signs each webhook:
// Stripe sends this header with each webhook
Stripe-Signature: t=1614556800,v1=abc123def456...,v0=xyz789...
// Components of the signature:
// t = timestamp (Unix epoch) when Stripe generated the signature
// v1 = signature computed using HMAC-SHA256 (current scheme)
// v0 = signature using older scheme (deprecated, may be included for backwards compatibility)
// Example breakdown:
// t=1614556800 → Timestamp: March 1, 2021 00:00:00 UTC
// v1=abc123def456... → HMAC-SHA256 signature of: t + "." + payload
// using your webhook secret as the key
// You MUST verify the signature before trusting the payload
Event event = Webhook.constructEvent(
payload, // Raw request body (must be the exact bytes received!)
sigHeader, // Stripe-Signature header
webhookSecret // Your webhook signing secret from Stripe Dashboard
);
// If signature is invalid, this throws an exception
// This also verifies the timestamp isn't too old (prevents replay attacks)
How signature verification works:
-
Stripe constructs the signed payload:
signed_payload = timestamp + "." + raw_request_body -
Stripe computes the signature using HMAC-SHA256:
signature = HMAC-SHA256(signed_payload, webhook_secret) -
Stripe sends the header:
Stripe-Signature: t=1614556800,v1=computed_signature -
Your server verifies:
- Extracts timestamp (
t) and signature (v1) from header - Recomputes the signature using the same method
- Compares computed signature with received signature
- Checks timestamp isn't too old (default: 5 minutes tolerance)
- If all checks pass → webhook is authentic ✅
- If any check fails → reject the request ❌
- Extracts timestamp (
Why this is critical:
Without signature verification:
┌─────────────────┐
│ Evil Actor 🦹 │
└────────┬────────┘
│ POST /v1/billing/webhook
│ {"type": "payment_intent.succeeded", "amount": 1000000}
▼
┌─────────────────┐
│ Your Server │ ← Accepts fake webhook!
│ Adds $10k to │ User didn't actually pay! 💸
│ user balance │
└─────────────────┘
With signature verification:
┌─────────────────┐
│ Evil Actor 🦹 │
└────────┬────────┘
│ POST /v1/billing/webhook
│ {"type": "payment_intent.succeeded", "amount": 1000000}
│ Stripe-Signature: t=123,v1=FAKE_SIGNATURE
▼
┌─────────────────┐
│ Your Server │ ← Signature doesn't match!
│ Verifies sig │ ← Rejects the request ✋
│ ❌ REJECTED │
└─────────────────┘
Replay attack prevention:
The timestamp (t) prevents attackers from capturing a valid webhook and replaying it later:
// Stripe's Webhook.constructEvent() automatically checks:
long currentTime = System.currentTimeMillis() / 1000;
long webhookTime = extractTimestamp(header); // The 't' value
if (Math.abs(currentTime - webhookTime) > 300) { // 5 minutes
throw new SignatureVerificationException("Timestamp too old");
}
// Prevents replay attacks using captured old webhooks
Multiple signature versions:
Stripe may include multiple signature schemes for backwards compatibility:
v1- Current HMAC-SHA256 scheme (always verify this)v0- Deprecated older scheme (can ignore)
The Webhook.constructEvent() method handles all of this automatically!
Webhook reliability:
Stripe webhooks are designed to be reliable:
- ✅ Retries: Stripe retries failed webhooks automatically (with exponential backoff)
- ✅ Ordering: Webhooks may arrive out of order - use event timestamps
- ✅ Idempotency: Same event may be sent multiple times - handle duplicates
- ✅ Monitoring: Stripe Dashboard shows webhook delivery status
Best practices:
- ✅ Always verify signature - Reject requests with invalid signatures
- ✅ Return 200 quickly - Process webhook in background if needed (< 5 seconds)
- ✅ Handle duplicates - Check if event was already processed
- ✅ Log all webhooks - For debugging and audit trail
- ✅ Test webhook handling - Use Stripe CLI or Dashboard to trigger test events
In our implementation:
The webhook endpoint will:
- Receive POST from Stripe with payment event
- Verify the webhook signature for security
- Extract event type (
payment_intent.succeeded, etc.) - Update user's balance in Firestore atomically
- Update transaction status from PENDING to COMPLETED
- Return 200 OK to Stripe
Now let's implement the webhook endpoint:
Add a new gRPC method or REST endpoint for Stripe webhooks:
Option A: Add to billing.proto (Recommended)
// Webhook request from Stripe
message StripeWebhookRequest {
string payload = 1; // Raw webhook JSON payload
string signature = 2; // Stripe-Signature header
}
// Webhook response
message StripeWebhookResponse {
bool success = 1;
string message = 2;
}
service BillingService {
// ... existing RPCs ...
// Handle Stripe webhook events
rpc HandleStripeWebhook(StripeWebhookRequest) returns (StripeWebhookResponse) {
option (google.api.http) = {
post: "/v1/billing/webhook"
body: "*"
};
}
}
Option B: Add REST Controller (Simpler for webhooks)
Create src/main/java/org/codetricks/construction/code/assistant/billing/StripeWebhookController.java:
package org.codetricks.construction.code.assistant.billing;
import com.google.common.io.ByteStreams;
import java.io.IOException;
import java.util.logging.Logger;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* Servlet for handling Stripe webhook events.
*
* This endpoint receives webhook events from Stripe for payment confirmations,
* failures, disputes, etc.
*/
public class StripeWebhookController extends HttpServlet {
private static final Logger logger = Logger.getLogger(StripeWebhookController.class.getName());
private final StripeService stripeService;
private final BillingServiceImpl billingService;
public StripeWebhookController() {
this.stripeService = new StripeService(); // Get from DI in real impl
this.billingService = new BillingServiceImpl(); // Get from DI in real impl
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws IOException {
try {
// Read raw request body
String payload = new String(ByteStreams.toByteArray(request.getInputStream()));
String sigHeader = request.getHeader("Stripe-Signature");
if (sigHeader == null || sigHeader.isEmpty()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Missing Stripe-Signature header");
return;
}
// Verify and process webhook
stripeService.handleWebhook(payload, sigHeader);
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write("{\"success\": true}");
} catch (Exception e) {
logger.severe("Webhook processing failed: " + e.getMessage());
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.getWriter().write("{\"success\": false, \"error\": \"" + e.getMessage() + "\"}");
}
}
}
Step 3: Enhance StripeService Webhook Handling
Update StripeService to properly handle webhook events and update Firestore:
File: src/main/java/org/codetricks/construction/code/assistant/billing/StripeService.java
/** Handles successful payment webhook events. */
private void handlePaymentSuccess(Event event) {
try {
PaymentIntent paymentIntent = (PaymentIntent) event.getDataObjectDeserializer()
.getObject()
.orElseThrow(() -> new RuntimeException("Unable to deserialize PaymentIntent"));
String userEmail = paymentIntent.getMetadata().get("user_email");
String amountStr = paymentIntent.getMetadata().get("amount");
if (userEmail == null || amountStr == null) {
logger.warning("Missing metadata in PaymentIntent: " + paymentIntent.getId());
return;
}
// Parse amount from metadata
Money amount = parseMoneyFromString(amountStr);
logger.info("Payment succeeded for user: " + userEmail +
", amount: " + amount.getUnits() + " " + amount.getCurrencyCode() +
", PaymentIntent: " + paymentIntent.getId());
// Update user balance in Firestore
// This would call BillingServiceImpl.completeTopUp() or similar
// For now, log the event - actual implementation depends on callback pattern
} catch (Exception e) {
logger.severe("Failed to handle payment success: " + e.getMessage());
}
}
/** Handles failed payment webhook events. */
private void handlePaymentFailure(Event event) {
try {
PaymentIntent paymentIntent = (PaymentIntent) event.getDataObjectDeserializer()
.getObject()
.orElseThrow(() -> new RuntimeException("Unable to deserialize PaymentIntent"));
String userEmail = paymentIntent.getMetadata().get("user_email");
logger.warning("Payment failed for user: " + userEmail +
", PaymentIntent: " + paymentIntent.getId() +
", Reason: " + paymentIntent.getLastPaymentError());
// TODO: Notify user of payment failure
// TODO: Clean up pending transaction in Firestore
} catch (Exception e) {
logger.severe("Failed to handle payment failure: " + e.getMessage());
}
}
/** Helper to parse Money from string format "100.00 USD" */
private Money parseMoneyFromString(String amountStr) {
String[] parts = amountStr.split(" ");
double amount = Double.parseDouble(parts[0]);
String currency = parts[1];
return Money.newBuilder()
.setUnits((long) amount)
.setNanos((int) ((amount - (long) amount) * 1_000_000_000))
.setCurrencyCode(currency)
.build();
}
Step 4: Update TopUpBalance Implementation
Enhance the topUpBalance method to properly create PaymentIntent with metadata:
@Override
public void topUpBalance(
TopUpBalanceRequest request,
StreamObserver<TopUpBalanceResponse> responseObserver) {
try {
String userEmail = RpcAuthContext.getInstance().getUserEmail();
Money amount = request.getAmount();
String paymentMethodId = request.getPaymentMethodId();
logger.info("Processing balance top-up for user: " + userEmail +
", amount: " + amount.getUnits() + " " + amount.getCurrencyCode());
// Create Stripe payment intent
StripeService.PaymentIntentResult paymentResult =
stripeService.createPaymentIntent(amount, userEmail, paymentMethodId);
// Create pending transaction in Firestore
BillingTransaction pendingTransaction = createPendingTopUpTransaction(
userEmail, amount, paymentResult.getId());
// Return response with client secret for frontend confirmation
TopUpBalanceResponse response = TopUpBalanceResponse.newBuilder()
.setSuccess(true)
.setMessage("Payment intent created")
.setStripePaymentIntentId(paymentResult.getId())
.setClientSecret(paymentResult.getClientSecret())
.setTransaction(pendingTransaction)
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) {
logger.severe("Failed to process top-up: " + e.getMessage());
responseObserver.onError(
Status.INTERNAL
.withDescription("Failed to process payment: " + e.getMessage())
.asRuntimeException());
}
}
/** Creates a pending top-up transaction that will be completed on webhook */
private BillingTransaction createPendingTopUpTransaction(
String userEmail, Money amount, String paymentIntentId)
throws ExecutionException, InterruptedException {
// Get current balance
UserBillingProfile profile = getBillingProfile(userEmail);
Money currentBalance = profile.getAvailableBalance();
// Create transaction record
String transactionId = "txn_" + UUID.randomUUID().toString();
Instant now = Instant.now();
BillingTransaction transaction = BillingTransaction.newBuilder()
.setTransactionId(transactionId)
.setUserEmail(userEmail)
.setType(TransactionType.CREDIT)
.setAmount(amount)
.setBalanceBefore(currentBalance)
.setBalanceAfter(currentBalance) // Not updated yet - pending
.setDescription("Balance top-up (pending)")
.setCreatedAt(Timestamp.newBuilder()
.setSeconds(now.getEpochSecond())
.setNanos(now.getNano())
.build())
.setStripePaymentIntentId(paymentIntentId)
.setMetadata(TransactionMetadata.newBuilder()
.setSource("stripe")
.setNotes("Pending payment confirmation")
.build())
.build();
// Save to Firestore with status=PENDING
Map<String, Object> data = FirestoreProtoConverter.convertToFirestore(transaction);
data.put("status", "PENDING"); // Add status field
firestore.collection(BILLING_TRANSACTIONS_COLLECTION)
.document(transactionId)
.set(data)
.get();
logger.info("Created pending transaction: " + transactionId);
return transaction;
}
Step 5: Implement Transaction Completion on Webhook
Add method to complete pending transaction when webhook confirms payment:
/**
* Completes a pending top-up transaction after webhook confirmation.
* Updates the transaction status and adds balance atomically.
*/
public void completeTopUpTransaction(String paymentIntentId, String userEmail, Money amount)
throws ExecutionException, InterruptedException {
logger.info("Completing top-up transaction for PaymentIntent: " + paymentIntentId);
// Find pending transaction
DocumentSnapshot pendingTxn = firestore.collection(BILLING_TRANSACTIONS_COLLECTION)
.whereEqualTo("stripe_payment_intent_id", paymentIntentId)
.whereEqualTo("status", "PENDING")
.get()
.get()
.getDocuments()
.stream()
.findFirst()
.orElseThrow(() -> new RuntimeException("Pending transaction not found"));
String transactionId = pendingTxn.getId();
// Update balance and transaction atomically
firestore.runTransaction(transaction -> {
DocumentReference profileDoc = firestore.collection(BILLING_PROFILES_COLLECTION)
.document(userEmail);
DocumentReference txnDoc = firestore.collection(BILLING_TRANSACTIONS_COLLECTION)
.document(transactionId);
// Read current profile
DocumentSnapshot profileSnapshot = transaction.get(profileDoc).get();
Map<String, Object> profileData = profileSnapshot.getData();
UserBillingProfile profile = FirestoreProtoConverter.convertFromFirestore(
UserBillingProfile.class, profileData);
// Calculate new balance
Money oldBalance = profile.getAvailableBalance();
Money newBalance = addMoney(oldBalance, amount);
// Update profile balance
Map<String, Object> updates = new HashMap<>();
updates.put("available_balance.units", newBalance.getUnits());
updates.put("available_balance.nanos", newBalance.getNanos());
updates.put("updated_at", FieldValue.serverTimestamp());
transaction.update(profileDoc, updates);
// Update transaction to COMPLETED
Map<String, Object> txnUpdates = new HashMap<>();
txnUpdates.put("status", "COMPLETED");
txnUpdates.put("balance_after.units", newBalance.getUnits());
txnUpdates.put("balance_after.nanos", newBalance.getNanos());
txnUpdates.put("description", "Balance top-up (completed)");
txnUpdates.put("metadata.notes", "Payment confirmed via webhook");
transaction.update(txnDoc, txnUpdates);
logger.info("Completed top-up: " + userEmail +
" - Old: " + oldBalance.getUnits() +
" + " + amount.getUnits() +
" = New: " + newBalance.getUnits());
return null;
}).get();
}
/** Helper to add two Money values */
private Money addMoney(Money a, Money b) {
if (!a.getCurrencyCode().equals(b.getCurrencyCode())) {
throw new IllegalArgumentException("Currency mismatch");
}
long totalNanos = a.getNanos() + b.getNanos();
long totalUnits = a.getUnits() + b.getUnits() + (totalNanos / 1_000_000_000);
int remainingNanos = (int) (totalNanos % 1_000_000_000);
return Money.newBuilder()
.setCurrencyCode(a.getCurrencyCode())
.setUnits(totalUnits)
.setNanos(remainingNanos)
.build();
}
Backend Implementation Checklist
- Environment variable loading implemented
- StripeService switches between mock/live mode
- Webhook endpoint created and registered
- Webhook signature verification working
- Payment success handler updates Firestore
- Payment failure handler notifies user
- Atomic transaction updates implemented
- Error handling and logging comprehensive
- Unit tests for Stripe integration
- Integration tests with Stripe test mode
Frontend Implementation
Step 1: Install Stripe.js
File: web-ng-m3/package.json
{
"dependencies": {
"@stripe/stripe-js": "^2.4.0"
}
}
cd web-ng-m3
npm install @stripe/stripe-js
Step 2: Add Stripe Publishable Key to Environment
File: web-ng-m3/scripts/set-env.js
Update the environment generation to include Stripe publishable key:
function generateEnvironmentFile(env) {
const targetEnv = env || 'dev';
const isProd = targetEnv === 'prod' || targetEnv === 'demo';
// ... existing code ...
const content = `// This file is dynamically generated by set-env.js
export const environment = {
production: ${isProd},
node_env: '${process.env.NODE_ENV || 'development'}',
CODEPROOF_API_SERVER: '${envVars.CODEPROOF_API_SERVER || ''}',
CODEPROOF_WS_SERVER: '${envVars.CODEPROOF_WS_SERVER || ''}',
googleMapsApiKey: '${envVars.GOOGLE_MAPS_API_KEY || ''}',
stripePublishableKey: '${envVars.STRIPE_PUBLISHABLE_KEY || ''}',
firebase: ${JSON.stringify(firebaseConfig, null, 2)}
};
`;
// ... rest of function ...
}
File: env/dev/firebase/m3/setvars.sh
# ... existing vars ...
# Stripe Configuration (Issue #216)
# Publishable key is safe to commit - it's meant to be public (client-side)
# and is restricted to specific domains in Stripe Dashboard
export STRIPE_PUBLISHABLE_KEY=pk_test_51xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Understanding 3D Secure and SCA
Before implementing the payment flow, it's important to understand 3D Secure and SCA (Strong Customer Authentication), as they are critical security requirements for online payments.
What is 3D Secure?
3D Secure (3DS) is an authentication protocol that adds an extra security layer to online card transactions by requiring customers to verify their identity with their bank before completing a payment.
The "3 Domains" are:
- Issuer Domain - The bank that issued the customer's card
- Acquirer Domain - The merchant's bank (in our case, Stripe)
- Interoperability Domain - The payment network infrastructure (Visa, Mastercard, etc.)
Common brand names:
- Visa: "Verified by Visa"
- Mastercard: "Mastercard SecureCode"
- American Express: "SafeKey"
User experience: When 3D Secure is required, users will see:
- A popup window from their bank
- Request for PIN, password, or biometric authentication
- SMS verification code
- Mobile app authentication (push notification)
What is SCA (Strong Customer Authentication)?
SCA is a European regulatory requirement (part of the PSD2 directive) that mandates two-factor authentication for most electronic payments in the European Economic Area (EEA).
Requirements - Two of these three factors:
- Knowledge - Something the user knows (password, PIN)
- Possession - Something the user has (phone, card, security token)
- Inherence - Something the user is (fingerprint, face recognition)
Valid combinations:
- ✅ Password + SMS code (knowledge + possession)
- ✅ Card number + fingerprint (possession + inherence)
- ✅ PIN + mobile app confirmation (knowledge + possession)
- ❌ Card number alone (only one factor - insufficient)
When SCA is required:
- ✅ Payments from European Economic Area (EEA) cards
- ✅ Transactions over €30 (typically)
- ✅ High-risk transactions flagged by fraud detection
- ❌ Low-value transactions under €30 (may be exempt)
- ❌ Recurring payments (after initial authentication)
- ❌ Trusted merchants with low fraud rates (may be exempt)
Regional Requirements
| Region | 3D Secure / SCA | Enforcement |
|---|---|---|
| Europe (EEA) | Mandatory | Required by law since September 2019 |
| United Kingdom | Mandatory | Required (post-Brexit compliance) |
| United States | Optional | Increasingly adopted for fraud prevention |
| Canada | Optional | Growing adoption |
| Australia | Optional | Recommended by banks |
| Asia-Pacific | Varies | Many countries adopting |
How Stripe Handles 3D Secure
Good news: Stripe.js automatically handles most of the complexity!
Stripe's automatic handling:
- ✅ Detects when 3D Secure authentication is required
- ✅ Displays the authentication popup or redirect
- ✅ Communicates with the cardholder's bank
- ✅ Handles all authentication flows (SMS, biometric, app-based)
- ✅ Returns the authentication result
- ✅ Works with Apple Pay, Google Pay, and other payment methods
What you need to do:
- Use
stripe.confirmCardPayment()- it handles 3D Secure automatically - Handle different payment states (
requires_action,succeeded,failed) - Show appropriate loading states during authentication
- Provide clear error messages if authentication fails
Payment flow with 3D Secure:
Testing 3D Secure
Stripe provides special test cards for testing 3D Secure flows:
// Test card numbers for different scenarios
// ✅ Success - No 3D Secure authentication required
// (Like most US cards)
4242 4242 4242 4242
// ✅ Success - 3D Secure authentication required
// (Like European cards with SCA)
4000 0027 6000 3184
// In test mode, you'll see a popup to "Complete" or "Fail" authentication
// ✅ Success - 3D Secure 2 with biometric/mobile flow
4000 0025 0000 3155
// ❌ Declined - 3D Secure authentication fails
4000 0082 6000 3178
// User fails or cancels the authentication
// ❌ Declined - Card does not support 3D Secure
4000 0000 0000 3220
// For all test cards:
// Expiry: Any future date (e.g., 12/25)
// CVC: Any 3 digits (e.g., 123)
// ZIP: Any 5 digits (e.g., 12345)
Test flow example with 3D Secure card:
- Enter card
4000 0027 6000 3184 - Click "Add $50"
- Stripe.js shows authentication popup
- In test mode: Click "Complete authentication" button
- Payment succeeds and balance is updated
- If you click "Fail authentication" → Payment is declined
Implementation Considerations
Performance:
- 3D Secure adds 5-30 seconds to payment time
- Users must complete authentication within time limit (usually 5 minutes)
- Some users may abandon due to extra friction
User Experience:
- Always show loading spinner during authentication
- Display clear message: "Please complete authentication with your bank"
- Handle popup blockers gracefully
- Provide fallback: "Try a different card" if authentication fails
- Don't retry automatically - let user decide
Error Handling:
const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: cardElement }
});
if (error) {
switch (error.type) {
case 'card_error':
// Card was declined or authentication failed
showError('Payment declined. Please check your card details.');
break;
case 'validation_error':
// Invalid parameters
showError('Invalid payment information.');
break;
default:
// Network error, API error, etc.
showError('Payment processing failed. Please try again.');
}
} else if (paymentIntent.status === 'requires_action') {
// This shouldn't happen - Stripe.js handles it automatically
// But good to log for debugging
console.warn('Payment requires additional action:', paymentIntent);
} else if (paymentIntent.status === 'succeeded') {
// ✅ Payment successful!
showSuccess('Payment completed successfully!');
}
Best Practices:
- ✅ Always implement 3D Secure support (not optional for global payments)
- ✅ Test with European test cards (
4000 0027 6000 3184) - ✅ Show clear loading states and messages
- ✅ Handle all authentication outcomes (success, failure, timeout)
- ✅ Don't customize the authentication UI - let Stripe handle it
- ✅ Log authentication failures for monitoring
- ❌ Don't retry failed authentications automatically
- ❌ Don't bypass 3D Secure checks
Compliance:
- European regulations require SCA for most online payments
- Stripe automatically applies SCA when required
- You don't need to explicitly enable it - it's built into the payment flow
- Stripe handles exemptions (low-value, trusted merchant, etc.)
In Your Implementation
The code in our payment dialog (Step 4 below) already handles 3D Secure correctly:
// This single line handles all 3D Secure complexity!
const { error: confirmError } = await this.stripeService.confirmCardPayment(
topUpResponse.clientSecret,
this.cardElement
);
Stripe.js will:
- Check if 3D Secure is required for this card/amount/region
- If required, show the authentication challenge
- Wait for user to complete authentication
- Return success or failure
- All automatically with no extra code needed!
References:
Step 3: Create Stripe Service
File: web-ng-m3/src/app/shared/stripe.service.ts
import { Injectable } from '@angular/core';
import { loadStripe, Stripe, StripeElements, StripeCardElement } from '@stripe/stripe-js';
import { environment } from '../environments/environment';
@Injectable({
providedIn: 'root'
})
export class StripeService {
private stripePromise: Promise<Stripe | null>;
private stripe: Stripe | null = null;
constructor() {
const key = environment.stripePublishableKey;
if (!key) {
console.warn('Stripe publishable key not configured');
this.stripePromise = Promise.resolve(null);
} else {
this.stripePromise = loadStripe(key);
}
}
async getStripe(): Promise<Stripe | null> {
if (!this.stripe) {
this.stripe = await this.stripePromise;
}
return this.stripe;
}
/**
* Creates Stripe Elements for payment method collection
*/
async createElements(): Promise<StripeElements | null> {
const stripe = await this.getStripe();
if (!stripe) return null;
return stripe.elements();
}
/**
* Confirms a payment with the provided client secret
*/
async confirmCardPayment(
clientSecret: string,
cardElement: StripeCardElement
): Promise<any> {
const stripe = await this.getStripe();
if (!stripe) {
throw new Error('Stripe not initialized');
}
return stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: cardElement
}
});
}
/**
* Creates a payment method from card element
*/
async createPaymentMethod(cardElement: StripeCardElement): Promise<any> {
const stripe = await this.getStripe();
if (!stripe) {
throw new Error('Stripe not initialized');
}
return stripe.createPaymentMethod({
type: 'card',
card: cardElement
});
}
}
Step 4: Create Payment Dialog Component
File: web-ng-m3/src/app/components/billing/top-up-dialog/top-up-dialog.component.ts
import { Component, OnInit, ViewChild, ElementRef, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { StripeService } from '../../../shared/stripe.service';
import { BillingService } from '../../../shared/billing.service';
import { StripeCardElement, StripeElements } from '@stripe/stripe-js';
@Component({
selector: 'app-top-up-dialog',
templateUrl: './top-up-dialog.component.html',
styleUrls: ['./top-up-dialog.component.css']
})
export class TopUpDialogComponent implements OnInit {
@ViewChild('cardElement', { static: true }) cardElementRef!: ElementRef;
amount: number = 50;
processing: boolean = false;
error: string = '';
private cardElement: StripeCardElement | null = null;
private elements: StripeElements | null = null;
constructor(
private dialogRef: MatDialogRef<TopUpDialogComponent>,
private stripeService: StripeService,
private billingService: BillingService,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
async ngOnInit() {
await this.initializeStripeElements();
}
private async initializeStripeElements() {
try {
this.elements = await this.stripeService.createElements();
if (!this.elements) {
this.error = 'Payment system not available';
return;
}
// Create card element
this.cardElement = this.elements.create('card', {
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': {
color: '#aab7c4',
},
},
invalid: {
color: '#9e2146',
},
},
});
// Mount to DOM
this.cardElement.mount(this.cardElementRef.nativeElement);
// Handle validation errors
this.cardElement.on('change', (event) => {
if (event.error) {
this.error = event.error.message;
} else {
this.error = '';
}
});
} catch (error) {
console.error('Failed to initialize Stripe Elements:', error);
this.error = 'Failed to initialize payment form';
}
}
async submitPayment() {
if (!this.cardElement) {
this.error = 'Payment form not initialized';
return;
}
if (this.amount < 10) {
this.error = 'Minimum top-up amount is $10';
return;
}
if (this.amount > 10000) {
this.error = 'Maximum top-up amount is $10,000';
return;
}
this.processing = true;
this.error = '';
try {
// Step 1: Create payment method
const { paymentMethod, error: pmError } = await this.stripeService.createPaymentMethod(
this.cardElement
);
if (pmError) {
throw new Error(pmError.message);
}
// Step 2: Create payment intent via backend
const topUpResponse = await this.billingService.topUpBalance(
this.amount,
paymentMethod.id
);
if (!topUpResponse.success) {
throw new Error(topUpResponse.message || 'Payment failed');
}
// Step 3: Confirm payment (handles 3D Secure if needed)
const { error: confirmError } = await this.stripeService.confirmCardPayment(
topUpResponse.clientSecret,
this.cardElement
);
if (confirmError) {
throw new Error(confirmError.message);
}
// Success!
console.log('Payment successful:', topUpResponse);
this.dialogRef.close({ success: true, amount: this.amount });
} catch (error: any) {
console.error('Payment failed:', error);
this.error = error.message || 'Payment failed. Please try again.';
this.processing = false;
}
}
cancel() {
this.dialogRef.close({ success: false });
}
}
File: web-ng-m3/src/app/components/billing/top-up-dialog/top-up-dialog.component.html
<h2 mat-dialog-title>Add Funds to Your Account</h2>
<mat-dialog-content>
<div class="top-up-form">
<!-- Amount selection -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Amount (USD)</mat-label>
<input
matInput
type="number"
[(ngModel)]="amount"
min="10"
max="10000"
step="10"
[disabled]="processing">
<span matPrefix>$ </span>
</mat-form-field>
<!-- Quick amount buttons -->
<div class="quick-amounts">
<button
mat-stroked-button
*ngFor="let preset of [25, 50, 100, 200, 500]"
(click)="amount = preset"
[disabled]="processing">
${{preset}}
</button>
</div>
<!-- Stripe card element -->
<div class="card-element-wrapper">
<label class="card-label">Card Information</label>
<div #cardElement class="card-element"></div>
</div>
<!-- Error message -->
<mat-error *ngIf="error" class="error-message">
{{ error }}
</mat-error>
<!-- Payment info -->
<div class="payment-info">
<mat-icon>lock</mat-icon>
<span>Secure payment processed by Stripe</span>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()" [disabled]="processing">Cancel</button>
<button
mat-raised-button
color="primary"
(click)="submitPayment()"
[disabled]="processing || !amount">
<span *ngIf="!processing">Add ${{ amount }}</span>
<span *ngIf="processing">
<mat-spinner diameter="20" class="inline-spinner"></mat-spinner>
Processing...
</span>
</button>
</mat-dialog-actions>
File: web-ng-m3/src/app/components/billing/top-up-dialog/top-up-dialog.component.css
.top-up-form {
min-width: 400px;
padding: 16px 0;
}
.full-width {
width: 100%;
}
.quick-amounts {
display: flex;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.quick-amounts button {
flex: 1;
min-width: 70px;
}
.card-element-wrapper {
margin-bottom: 16px;
}
.card-label {
display: block;
font-size: 14px;
color: rgba(0, 0, 0, 0.6);
margin-bottom: 8px;
}
.card-element {
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 4px;
background: white;
}
.card-element:focus-within {
border-color: #3f51b5;
border-width: 2px;
padding: 11px; /* Compensate for thicker border */
}
.error-message {
margin-top: 8px;
margin-bottom: 16px;
}
.payment-info {
display: flex;
align-items: center;
gap: 8px;
color: rgba(0, 0, 0, 0.6);
font-size: 12px;
margin-top: 16px;
}
.payment-info mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
.inline-spinner {
display: inline-block;
margin-right: 8px;
}
Step 5: Update BillingService to Handle Top-Up
File: web-ng-m3/src/app/shared/billing.service.ts
async topUpBalance(amount: number, paymentMethodId: string): Promise<TopUpResponse> {
try {
console.log('[BillingService] Starting top-up:', { amount, paymentMethodId });
const response = await this.billingGrpcService.topUpBalance({
amount: {
currencyCode: 'USD',
units: Math.floor(amount),
nanos: Math.round((amount % 1) * 1_000_000_000)
},
paymentMethodId: paymentMethodId,
returnUrl: window.location.origin + '/billing'
});
console.log('[BillingService] Top-up response:', response);
// Refresh balance after successful top-up
if (response.success) {
await this.loadUserBillingProfile();
}
return response;
} catch (error) {
console.error('[BillingService] Top-up failed:', error);
throw error;
}
}
Step 6: Integrate into Billing Component
File: web-ng-m3/src/app/components/billing/billing.component.ts
openTopUpDialog() {
const dialogRef = this.dialog.open(TopUpDialogComponent, {
width: '500px',
disableClose: false
});
dialogRef.afterClosed().subscribe(result => {
if (result && result.success) {
console.log('Top-up successful:', result.amount);
// Show success message
this.snackBar.open(
`Successfully added $${result.amount} to your balance!`,
'Close',
{ duration: 5000 }
);
// Reload billing data
this.loadBillingData();
}
});
}
Frontend Implementation Checklist
- Stripe.js package installed
- Stripe publishable key in environment config
- StripeService created and tested
- Payment dialog component created
- Stripe Elements mounted and styled
- Payment method creation working
- Payment confirmation with 3D Secure
- Error handling for all payment states
- Success/failure UI feedback
- Integration with BillingService
- UI tests for payment flow
Testing Strategy
Test Mode vs. Production Mode
| Aspect | Test Mode | Production Mode |
|---|---|---|
| API Keys | pk_test_... / sk_test_... | pk_live_... / sk_live_... |
| Charges | No real money | Real charges |
| Cards | Test cards only | Real cards |
| Webhooks | Test events | Live events |
| Dashboard | Separate "Test" view | "Live" view |
Stripe Test Cards
// Success - Standard card
4242 4242 4242 4242
// Success - 3D Secure authentication required
4000 0027 6000 3184
// Declined - Generic decline
4000 0000 0000 0002
// Declined - Insufficient funds
4000 0000 0000 9995
// Failed - Charge fails
4000 0000 0000 0069
// Expired - Card expired
4000 0000 0000 0069 (with past expiry date)
Unit Tests
Backend (Java + JUnit 5):
// src/test/java/org/codetricks/construction/code/assistant/billing/StripeServiceTest.java
class StripeServiceTest {
@Test
void testCreatePaymentIntent_MockMode() {
StripeService stripe = new StripeService(); // Mock mode
Money amount = Money.newBuilder()
.setCurrencyCode("USD")
.setUnits(50)
.build();
StripeService.PaymentIntentResult result =
stripe.createPaymentIntent(amount, "test@example.com", "pm_test_xxx");
assertNotNull(result);
assertTrue(result.getId().startsWith("pi_mock_"));
assertNotNull(result.getClientSecret());
}
@Test
void testWebhookVerification_InvalidSignature() {
StripeService stripe = new StripeService("sk_test_xxx", "whsec_xxx", false);
assertThrows(RuntimeException.class, () -> {
stripe.handleWebhook("{\"type\": \"test\"}", "invalid_signature");
});
}
}
Frontend (Jasmine):
// web-ng-m3/src/app/shared/stripe.service.spec.ts
describe('StripeService', () => {
let service: StripeService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(StripeService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should load Stripe.js', async () => {
const stripe = await service.getStripe();
expect(stripe).toBeTruthy();
});
it('should create Stripe Elements', async () => {
const elements = await service.createElements();
expect(elements).toBeTruthy();
});
});
Integration Tests
End-to-End Payment Flow:
# Test checklist (manual or automated)
1. [ ] Load billing page - balance displays
2. [ ] Click "Add Funds" - dialog opens
3. [ ] Enter amount - validation works
4. [ ] Enter test card 4242... - Stripe accepts
5. [ ] Click "Add $50" - payment processes
6. [ ] Webhook fires - balance updates in Firestore
7. [ ] UI refreshes - new balance shown
8. [ ] Transaction appears in history
Test with Stripe CLI:
# Listen for webhooks locally
stripe listen --forward-to localhost:8080/v1/billing/webhook
# Trigger test events manually
stripe trigger payment_intent.succeeded
# Create test payment
stripe payment_intents create \
--amount=5000 \
--currency=usd \
--payment-method=pm_card_visa \
--confirm=true
Load Testing
Test concurrent payment processing:
# Use Apache Bench or similar tool
ab -n 100 -c 10 -T 'application/json' \
-H 'Authorization: Bearer xxx' \
-p payment-request.json \
https://construction-code-expert-esp2-test-xxx.a.run.app/v1/billing/top-up
Security Testing
- Test with invalid Stripe keys - fails gracefully
- Test webhook with invalid signature - rejected
- Test with expired test cards - proper error message
- Test with different 3D Secure scenarios
- Test concurrent balance updates - no race conditions
- Test SQL injection in metadata fields - sanitized
- Test XSS in error messages - escaped properly
Testing Checklist
- Unit tests pass (backend and frontend)
- Integration tests pass with test mode
- Test cards work correctly
- 3D Secure authentication works
- Webhook signature verification works
- Payment failures handled gracefully
- Concurrent payments don't corrupt balance
- Error messages user-friendly
- Logs don't expose sensitive data
- Load testing shows acceptable performance
Production Deployment
Pre-Deployment Checklist
Before deploying to production:
Stripe Account
- Stripe account fully verified (business info, bank account)
- Production API keys generated
- Webhook endpoints configured for production URL
- Production webhook secrets obtained
- Payment methods enabled (cards, ACH, etc.)
- Test mode thoroughly tested
- Dispute handling process documented
Security
- All secrets stored in Google Secret Manager (not in code)
- Cloud Run service account has minimal required permissions
- Firestore security rules updated for billing collections
- API endpoints require authentication
- Webhook endpoint validates Stripe signatures
- Logging sanitized (no card numbers, API keys, etc.)
- HTTPS enforced for all connections
Infrastructure
- Secret Manager secrets created for production
- Cloud Run environment variables configured
- Webhook URL publicly accessible
- Firestore indexes created for transaction queries
- Monitoring dashboards configured
- Alerts set up for payment failures
- Backup strategy for Firestore data
Legal/Compliance
- Terms of Service updated with payment terms
- Privacy Policy updated for payment data handling
- Refund policy documented
- PCI DSS compliance reviewed (Stripe handles most)
- Data retention policy for transaction records
Deployment Steps
Step 1: Create Production Stripe Account
-
Activate Live Mode:
- Log into Stripe Dashboard
- Click "Activate your account"
- Complete business verification
- Add bank account for payouts
-
Get Production API Keys:
# Navigate to Developers → API keys
# Copy live keys (they start with pk_live_ and sk_live_)
STRIPE_SECRET_KEY=sk_live_51xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_PUBLISHABLE_KEY=pk_live_51xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Step 2: Configure Production Secrets
# Set environment
ENV=prod
GCP_PROJECT_ID="construction-code-expert-prod"
# Store production Stripe keys in Secret Manager
echo -n "sk_live_51xxxxx" | gcloud secrets create stripe-secret-key \
--data-file=- \
--project=${GCP_PROJECT_ID}
echo -n "whsec_xxxxx" | gcloud secrets create stripe-webhook-secret \
--data-file=- \
--project=${GCP_PROJECT_ID}
# Grant access to Cloud Run service account
SERVICE_ACCOUNT="construction-code-expert-backend@${GCP_PROJECT_ID}.iam.gserviceaccount.com"
for SECRET in stripe-secret-key stripe-webhook-secret; do
gcloud secrets add-iam-policy-binding ${SECRET} \
--member="serviceAccount:${SERVICE_ACCOUNT}" \
--role="roles/secretmanager.secretAccessor" \
--project=${GCP_PROJECT_ID}
done
Step 3: Update Production Environment
File: env/prod/gcp/cloud-run/grpc/vars.yaml
# Existing vars
GCP_PROJECT_ID: "construction-code-expert-prod"
# ... other vars ...
# Stripe Configuration (loaded from Secret Manager)
STRIPE_ENABLED: "true" # Enable Stripe in production
File: env/prod/firebase/m3/setvars.sh
# Frontend Stripe publishable key
export STRIPE_PUBLISHABLE_KEY=pk_live_51xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Step 4: Configure Stripe Webhook
-
Get Production Webhook URL:
# After deploying backend
WEBHOOK_URL="https://construction-code-expert-esp2-prod-xxx.a.run.app/v1/billing/webhook"
echo "Webhook URL: ${WEBHOOK_URL}" -
Add Webhook in Stripe Dashboard:
- Go to Developers → Webhooks
- Click "Add endpoint"
- Enter webhook URL
- Select events:
payment_intent.succeededpayment_intent.payment_failedcharge.dispute.created(optional)customer.subscription.updated(future)
- Save and copy webhook signing secret
- Update Secret Manager with new webhook secret
-
Test Webhook:
# Send test event from Stripe Dashboard
# Check Cloud Run logs for successful receipt
Step 5: Deploy Backend
cd /workspaces/construction-code-expert
# Build and compile
export JAVA_HOME=/usr/lib/jvm/temurin-23-jdk-arm64
mvn clean compile -DskipTests
# Deploy to Cloud Run with secrets
ENV=prod
gcloud run deploy construction-code-expert-grpc \
--source=. \
--platform=managed \
--region=us-central1 \
--allow-unauthenticated \
--set-env-vars-file=env/${ENV}/gcp/cloud-run/grpc/vars.yaml \
--set-secrets="STRIPE_SECRET_KEY=stripe-secret-key:latest,STRIPE_WEBHOOK_SECRET=stripe-webhook-secret:latest" \
--project=construction-code-expert-prod \
--service-account=construction-code-expert-backend@construction-code-expert-prod.iam.gserviceaccount.com
Step 6: Deploy Frontend
cd web-ng-m3
# Generate environment config with production Stripe key
npm run set-env:prod
# Build production bundle
npm run build
# Deploy to Firebase Hosting
firebase deploy --only hosting:prod --project construction-code-expert-prod
Step 7: Smoke Test Production
Critical Tests:
-
Load Billing Page:
# Visit: https://app.example.com/billing
# Verify: Balance loads, no errors in console -
Test Top-Up (Small Amount):
# Use a real card with $5
# Verify:
# - Payment processes
# - Webhook fires
# - Balance updates
# - Transaction appears -
Test Task Execution:
# Run a small task that costs ~$0.50
# Verify:
# - Balance deducted correctly
# - Transaction recorded
# - No Stripe API calls (check logs) -
Test Error Handling:
# Try payment with:
# - Declined card (4000 0000 0000 0002 in test, real decline in prod)
# - Expired card
# - Insufficient funds
# Verify: User-friendly error messages
Step 8: Monitor Initial Transactions
# Watch Cloud Run logs
gcloud logging read \
"resource.type=cloud_run_revision AND resource.labels.service_name=construction-code-expert-grpc" \
--project=construction-code-expert-prod \
--limit=100 \
--format=json | grep -i stripe
# Watch Firestore billing_transactions collection
# Via Firebase Console or gcloud firestore
# Check Stripe Dashboard
# - Payments → Recent payments
# - Webhooks → Recent deliveries
Rollback Plan
If issues arise:
# Option 1: Disable Stripe, keep billing UI
gcloud run services update construction-code-expert-grpc \
--update-env-vars STRIPE_ENABLED=false \
--project=construction-code-expert-prod
# Option 2: Rollback to previous revision
gcloud run services update-traffic construction-code-expert-grpc \
--to-revisions=PREVIOUS=100 \
--project=construction-code-expert-prod
# Option 3: Hide billing UI temporarily
# Deploy frontend with billing page disabled
Post-Deployment Checklist
- First production payment successful
- Webhook events processing correctly
- Balance updates atomic and consistent
- Transaction history accurate
- No errors in logs
- Monitoring dashboards green
- Alert rules not firing
- User feedback positive
- Stripe Dashboard shows correct data
- Bank account receiving payouts
Monitoring and Operations
Key Metrics to Monitor
Payment Metrics
# Payment success rate (target: >95%)
payment_success_rate = successful_payments / total_payment_attempts
# Average payment amount
avg_payment_amount = sum(payment_amounts) / count(payments)
# Payment processing time (target: <5 seconds)
payment_latency_p95 = 95th percentile of payment processing time
# Failed payment rate (alert if >5%)
failed_payment_rate = failed_payments / total_payment_attempts
Webhook Metrics
# Webhook delivery success rate (target: 100%)
webhook_success_rate = successful_webhooks / total_webhooks
# Webhook processing time (target: <1 second)
webhook_latency_p95 = 95th percentile of webhook processing time
# Webhook retry count (should be 0 for healthy system)
webhook_retries = count of Stripe webhook retries
Balance Metrics
# Balance reconciliation errors (target: 0)
balance_discrepancies = count of balance != sum(transactions)
# Concurrent update conflicts (should be low)
concurrent_update_conflicts = count of transaction retry attempts
# Negative balance incidents (target: 0)
negative_balance_count = count of users with balance < 0
Cloud Monitoring Dashboards
Dashboard 1: Payment Health
# Create via gcloud or Cloud Console
displayName: "Billing - Payment Health"
widgets:
- title: "Payment Success Rate (24h)"
scorecard:
query: |
resource.type="cloud_run_revision"
AND log.message:"Payment succeeded"
- title: "Payment Failures (24h)"
scorecard:
query: |
resource.type="cloud_run_revision"
AND (log.message:"Payment failed" OR severity="ERROR")
- title: "Average Payment Amount"
timeSeries:
query: |
SELECT AVG(amount)
FROM billing_transactions
WHERE type='CREDIT' AND status='COMPLETED'
- title: "Payment Processing Time (p95)"
timeSeries:
query: |
SELECT PERCENTILE_CONT(latency, 0.95)
FROM payment_metrics
Dashboard 2: Webhook Monitoring
displayName: "Billing - Webhook Processing"
widgets:
- title: "Webhook Events Received"
timeSeries:
query: |
resource.type="cloud_run_revision"
AND log.message:"Processing Stripe webhook"
- title: "Webhook Processing Errors"
timeSeries:
query: |
resource.type="cloud_run_revision"
AND log.message:"Webhook processing failed"
- title: "Webhook Signature Failures"
scorecard:
query: |
resource.type="cloud_run_revision"
AND log.message:"Invalid webhook signature"
Alerts
Critical Alerts (Page on-call):
# Alert 1: High payment failure rate
displayName: "Billing - High Payment Failure Rate"
conditions:
- conditionThreshold:
filter: |
resource.type="cloud_run_revision"
AND log.message:"Payment failed"
aggregations:
- alignmentPeriod: 300s # 5 minutes
perSeriesAligner: ALIGN_RATE
comparison: COMPARISON_GT
thresholdValue: 0.1 # 10% failure rate
duration: 300s
notificationChannels:
- "projects/construction-code-expert-prod/notificationChannels/pagerduty"
# Alert 2: Webhook processing failures
displayName: "Billing - Webhook Processing Failures"
conditions:
- conditionThreshold:
filter: |
resource.type="cloud_run_revision"
AND log.message:"Webhook processing failed"
aggregations:
- alignmentPeriod: 60s
perSeriesAligner: ALIGN_RATE
comparison: COMPARISON_GT
thresholdValue: 0.05 # Any failures
duration: 60s
# Alert 3: Balance reconciliation errors
displayName: "Billing - Balance Reconciliation Errors"
conditions:
- conditionThreshold:
filter: |
resource.type="cloud_run_revision"
AND log.message:"Balance reconciliation failed"
aggregations:
- alignmentPeriod: 60s
perSeriesAligner: ALIGN_RATE
comparison: COMPARISON_GT
thresholdValue: 0 # Any errors
duration: 60s
Warning Alerts (Email/Slack):
# Alert 4: Elevated payment processing time
displayName: "Billing - Slow Payment Processing"
conditions:
- conditionThreshold:
filter: |
resource.type="cloud_run_revision"
AND metric.type="billing/payment_latency"
aggregations:
- alignmentPeriod: 300s
crossSeriesReducer: REDUCE_PERCENTILE_95
comparison: COMPARISON_GT
thresholdValue: 5000 # 5 seconds
duration: 600s # 10 minutes
notificationChannels:
- "projects/construction-code-expert-prod/notificationChannels/slack"
Log-Based Metrics
Create custom metrics from logs:
# Metric 1: Payment amount distribution
gcloud logging metrics create payment_amount \
--description="Payment amount in USD" \
--value-extractor='EXTRACT(jsonPayload.amount)' \
--log-filter='resource.type="cloud_run_revision"
AND jsonPayload.message="Payment succeeded"'
# Metric 2: Payment processing latency
gcloud logging metrics create payment_latency \
--description="Payment processing latency in ms" \
--value-extractor='EXTRACT(jsonPayload.latency_ms)' \
--log-filter='resource.type="cloud_run_revision"
AND jsonPayload.message="Payment completed"'
# Metric 3: Webhook processing success
gcloud logging metrics create webhook_success_count \
--description="Count of successful webhook processing" \
--log-filter='resource.type="cloud_run_revision"
AND jsonPayload.message="Webhook processed successfully"'
Operational Runbooks
Runbook 1: High Payment Failure Rate
Symptoms: Alert fires, >10% of payments failing
Investigation:
- Check Stripe Dashboard → Payments → Filter by "Failed"
- Identify common failure reasons:
- Card declined (user issue)
- Insufficient funds (user issue)
- Authentication required (3D Secure issue)
- Network error (Stripe/our issue)
- Check Cloud Run logs for errors
- Verify Stripe API status: https://status.stripe.com
Resolution:
- If Stripe outage → Wait for resolution, notify users
- If code issue → Rollback to previous version
- If user issue → Improve error messages, documentation
Runbook 2: Webhook Processing Failures
Symptoms: Webhooks not processing, balance not updating
Investigation:
- Check Stripe Dashboard → Developers → Webhooks → View attempts
- Check webhook signature secret is correct
- Check Cloud Run logs for webhook errors
- Verify webhook endpoint is accessible
Resolution:
# Test webhook endpoint manually
curl -X POST https://your-app.run.app/v1/billing/webhook \
-H "Stripe-Signature: test" \
-d '{"type": "payment_intent.succeeded"}'
# If endpoint not accessible, check Cloud Run deployment
gcloud run services describe construction-code-expert-grpc \
--region=us-central1 \
--project=construction-code-expert-prod
# If signature mismatch, update secret
gcloud secrets versions add stripe-webhook-secret \
--data-file=- \
--project=construction-code-expert-prod
Runbook 3: Balance Reconciliation Error
Symptoms: User balance doesn't match transaction sum
Investigation:
- Query Firestore for user's transactions
- Calculate expected balance:
initial + sum(credits) - sum(debits) - Compare with actual balance in profile
- Check for concurrent update conflicts in logs
- Check for incomplete transactions (PENDING status)
Resolution:
// Run balance reconciliation script
// src/main/java/org/codetricks/construction/code/assistant/billing/ReconcileBalance.java
public void reconcileUserBalance(String userEmail) {
// 1. Get all transactions
List<BillingTransaction> transactions = getAllTransactions(userEmail);
// 2. Calculate correct balance
Money calculatedBalance = calculateBalance(transactions);
// 3. Get current profile balance
UserBillingProfile profile = getBillingProfile(userEmail);
Money currentBalance = profile.getAvailableBalance();
// 4. If mismatch, create adjustment transaction
if (!balancesEqual(calculatedBalance, currentBalance)) {
Money difference = subtractMoney(calculatedBalance, currentBalance);
createAdjustmentTransaction(userEmail, difference,
"Balance reconciliation: calculated vs actual mismatch");
}
}
Daily Operations Checklist
Every Morning:
- Review payment success rate (should be >95%)
- Check for failed webhooks (should be 0)
- Review Stripe Dashboard for disputes
- Check balance reconciliation errors (should be 0)
- Verify no critical alerts fired overnight
Every Week:
- Review payment volume trends
- Check average payment amount
- Review failed payment reasons
- Update test mode with latest production scenarios
- Review and archive old transaction logs
Every Month:
- Reconcile Stripe payouts with revenue
- Review and optimize Firestore indexes
- Update Stripe webhook endpoint if needed
- Review and update pricing if needed
- Backup Firestore billing data
Support Procedures
User Reports Payment Issue:
-
Gather Information:
- User email
- Timestamp of payment attempt
- Amount attempted
- Error message shown
- Payment method type (last 4 digits if available)
-
Investigate:
# Search Cloud Run logs
gcloud logging read \
"resource.type=cloud_run_revision
AND jsonPayload.user_email=\"user@example.com\"
AND timestamp>=\"2025-11-01T00:00:00Z\"" \
--project=construction-code-expert-prod \
--limit=50
# Search Stripe Dashboard
# Payments → Search by email or amount -
Common Issues:
- Card declined → Ask user to contact bank or use different card
- 3D Secure failed → Guide user through authentication
- Network error → Retry payment
- Invalid card → Check card details are correct
-
If We Caused the Issue:
- Apologize to user
- Manually credit their account if payment went through but balance didn't update
- Create incident report and fix root cause
Manual Balance Adjustment:
// Only use in emergency situations with approval
// Document reason in transaction metadata
BillingTransaction adjustment = BillingTransaction.newBuilder()
.setTransactionId("txn_manual_" + UUID.randomUUID())
.setUserEmail("user@example.com")
.setType(TransactionType.ADJUSTMENT)
.setAmount(Money.newBuilder().setCurrencyCode("USD").setUnits(50).build())
.setDescription("Manual credit - payment processing error")
.setMetadata(TransactionMetadata.newBuilder()
.setSource("admin")
.setNotes("Ticket #1234 - payment went through but webhook failed")
.build())
.build();
// Create transaction and update balance atomically
createAdjustmentTransaction(adjustment);
Appendix
Useful Links
Stripe Documentation:
Google Cloud:
Project Documentation:
Glossary
- PaymentIntent: Stripe object representing intent to collect payment
- PaymentMethod: Stripe object representing payment source (card, bank)
- Customer: Stripe object representing a customer
- Webhook: HTTP callback for asynchronous events
- 3D Secure / SCA: Strong Customer Authentication for European cards
- Credit Burndown: Pricing model where users pre-purchase credits
- Mock Mode: Testing mode that simulates Stripe without real API calls
Changelog
| Date | Version | Changes | Author |
|---|---|---|---|
| 2025-11-03 | 1.0 | Initial Stripe integration plan | AI Assistant |
Summary
This integration plan provides a comprehensive roadmap for implementing Stripe payment processing in the Construction Code Expert billing system. The approach:
✅ Keeps it simple - Stripe only handles payments, not usage tracking ✅ Follows best practices - Secure credential management, proper error handling ✅ Provides clear phases - Development → Testing → Production ✅ Includes monitoring - Dashboards, alerts, runbooks ✅ Supports operations - Daily checklists, support procedures
Next Steps:
- Create Stripe test account
- Configure development environment variables
- Update
BillingServiceImplto use real Stripe - Test payment flow end-to-end
- Proceed through remaining phases