Skip to main content

Stripe Integration Plan

Issue: #202 Add Billing Page, #216 Integrate with Stripe

Status: Ready for Implementation

Last Updated: 2025-11-03


Table of Contents

  1. Overview
  2. Architecture Decision: Credit Burndown Model
  3. When Stripe Integration Happens
  4. Implementation Phases
  5. Development Environment Setup
  6. Backend Implementation
  7. Frontend Implementation
  8. Testing Strategy
  9. Production Deployment
  10. 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:

ModelHow It WorksStripe InvolvementBest For
Credit BurndownPre-purchase credits, spend down locallyPayment onlyOur use case
Usage-Based MeteringReport usage to Stripe, bill periodicallyEvery usage eventSaaS with predictable usage
SubscriptionsRecurring chargesMonthly billingFixed-price plans

Why Credit Burndown:

  1. Simplicity - Stripe only processes payments, not usage tracking
  2. Performance - No API calls during task execution (which happens frequently)
  3. User Control - Users top up when they want, not auto-billed
  4. Cost - Fewer Stripe API calls = lower fees
  5. 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 intent
  • Webhook.constructEvent() - Verify webhook signature
  • Customer.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:

  1. Create Stripe account (test mode)
  2. Configure environment variables
  3. Update BillingServiceImpl to use real Stripe
  4. Test payment flow in Stripe test mode
  5. 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:

  1. Install Stripe.js in Angular app
  2. Create payment method input component
  3. Implement 3D Secure / SCA handling
  4. Add payment confirmation dialog
  5. 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:

  1. Configure Stripe test keys in Secret Manager
  2. Deploy backend with Stripe integration
  3. Set up webhook endpoint in Cloud Run
  4. Configure Stripe webhook in Dashboard
  5. 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:

  1. Complete Stripe account verification
  2. Configure production API keys in Secret Manager
  3. Set up production webhook endpoint
  4. Gradual rollout to users
  5. Monitor for issues

Deliverables:

  • Production Stripe integration
  • Real payments processing
  • Monitoring and alerting active

Development Environment Setup

Step 1: Create Stripe Account

  1. Sign up for Stripe:

  2. 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_ and sk_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)

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.yaml is 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:

  1. Deploy your backend (initial deployment without webhook secret is OK)
  2. Note your webhook URL: https://construction-code-expert-esp2-${ENV}-xxx.a.run.app/v1/billing/webhook
  3. Go to Stripe Dashboard → Developers → Webhooks (https://dashboard.stripe.com/webhooks)
  4. Click "Add endpoint"
  5. Enter your webhook URL
  6. Click "Select events" and choose:
    • payment_intent.succeeded
    • payment_intent.payment_failed
    • charge.dispute.created (optional)
  7. Click "Add endpoint"
  8. On the endpoint details page, you'll see "Signing secret"
  9. Click "Reveal" to see the secret (starts with whsec_)
  10. Copy and store in Secret Manager:
echo -n "whsec_xxxxxxxxxxxxxxxxxxxxx" | gcloud secrets create stripe-webhook-secret \
--data-file=- \
--project=${GCP_PROJECT_ID}
  1. Redeploy your backend to mount the webhook secret

Order of Operations:

  1. ✅ Get API keys → Store in Secret Manager
  2. ✅ Deploy backend (webhooks will fail initially - that's OK)
  3. ✅ Create webhook endpoint in Stripe → Get webhook secret
  4. ✅ Store webhook secret in Secret Manager
  5. ✅ 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_xxx secret generated by stripe listen is 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_ENABLED flag added to vars.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:

EventWhen it firesWhat you should do
payment_intent.succeededPayment completed successfullyUpdate user balance, create transaction record
payment_intent.payment_failedPayment failed (card declined, etc.)Notify user, clean up pending transaction
charge.dispute.createdUser disputes a chargeFlag transaction for review, notify admin
customer.subscription.updatedSubscription changedUpdate user's subscription tier (future)
charge.refundedCharge was refundedDeduct 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:

  1. Stripe constructs the signed payload:

    signed_payload = timestamp + "." + raw_request_body
  2. Stripe computes the signature using HMAC-SHA256:

    signature = HMAC-SHA256(signed_payload, webhook_secret)
  3. Stripe sends the header:

    Stripe-Signature: t=1614556800,v1=computed_signature
  4. 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 ❌

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:

  1. Always verify signature - Reject requests with invalid signatures
  2. Return 200 quickly - Process webhook in background if needed (< 5 seconds)
  3. Handle duplicates - Check if event was already processed
  4. Log all webhooks - For debugging and audit trail
  5. Test webhook handling - Use Stripe CLI or Dashboard to trigger test events

In our implementation:

The webhook endpoint will:

  1. Receive POST from Stripe with payment event
  2. Verify the webhook signature for security
  3. Extract event type (payment_intent.succeeded, etc.)
  4. Update user's balance in Firestore atomically
  5. Update transaction status from PENDING to COMPLETED
  6. 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:

  1. Issuer Domain - The bank that issued the customer's card
  2. Acquirer Domain - The merchant's bank (in our case, Stripe)
  3. 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:

  1. Knowledge - Something the user knows (password, PIN)
  2. Possession - Something the user has (phone, card, security token)
  3. 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

Region3D Secure / SCAEnforcement
Europe (EEA)MandatoryRequired by law since September 2019
United KingdomMandatoryRequired (post-Brexit compliance)
United StatesOptionalIncreasingly adopted for fraud prevention
CanadaOptionalGrowing adoption
AustraliaOptionalRecommended by banks
Asia-PacificVariesMany 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:

  1. Use stripe.confirmCardPayment() - it handles 3D Secure automatically
  2. Handle different payment states (requires_action, succeeded, failed)
  3. Show appropriate loading states during authentication
  4. 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:

  1. Enter card 4000 0027 6000 3184
  2. Click "Add $50"
  3. Stripe.js shows authentication popup
  4. In test mode: Click "Complete authentication" button
  5. Payment succeeds and balance is updated
  6. 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:

  1. ✅ Always implement 3D Secure support (not optional for global payments)
  2. ✅ Test with European test cards (4000 0027 6000 3184)
  3. ✅ Show clear loading states and messages
  4. ✅ Handle all authentication outcomes (success, failure, timeout)
  5. ✅ Don't customize the authentication UI - let Stripe handle it
  6. ✅ Log authentication failures for monitoring
  7. ❌ Don't retry failed authentications automatically
  8. ❌ 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:

  1. Check if 3D Secure is required for this card/amount/region
  2. If required, show the authentication challenge
  3. Wait for user to complete authentication
  4. Return success or failure
  5. 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>$&nbsp;</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

AspectTest ModeProduction Mode
API Keyspk_test_... / sk_test_...pk_live_... / sk_live_...
ChargesNo real moneyReal charges
CardsTest cards onlyReal cards
WebhooksTest eventsLive events
DashboardSeparate "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

  1. Activate Live Mode:

    • Log into Stripe Dashboard
    • Click "Activate your account"
    • Complete business verification
    • Add bank account for payouts
  2. 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

  1. 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}"
  2. Add Webhook in Stripe Dashboard:

    • Go to Developers → Webhooks
    • Click "Add endpoint"
    • Enter webhook URL
    • Select events:
      • payment_intent.succeeded
      • payment_intent.payment_failed
      • charge.dispute.created (optional)
      • customer.subscription.updated (future)
    • Save and copy webhook signing secret
    • Update Secret Manager with new webhook secret
  3. 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:

  1. Load Billing Page:

    # Visit: https://app.example.com/billing
    # Verify: Balance loads, no errors in console
  2. Test Top-Up (Small Amount):

    # Use a real card with $5
    # Verify:
    # - Payment processes
    # - Webhook fires
    # - Balance updates
    # - Transaction appears
  3. Test Task Execution:

    # Run a small task that costs ~$0.50
    # Verify:
    # - Balance deducted correctly
    # - Transaction recorded
    # - No Stripe API calls (check logs)
  4. 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:

  1. Check Stripe Dashboard → Payments → Filter by "Failed"
  2. Identify common failure reasons:
    • Card declined (user issue)
    • Insufficient funds (user issue)
    • Authentication required (3D Secure issue)
    • Network error (Stripe/our issue)
  3. Check Cloud Run logs for errors
  4. 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:

  1. Check Stripe Dashboard → Developers → Webhooks → View attempts
  2. Check webhook signature secret is correct
  3. Check Cloud Run logs for webhook errors
  4. 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:

  1. Query Firestore for user's transactions
  2. Calculate expected balance: initial + sum(credits) - sum(debits)
  3. Compare with actual balance in profile
  4. Check for concurrent update conflicts in logs
  5. 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:

  1. Gather Information:

    • User email
    • Timestamp of payment attempt
    • Amount attempted
    • Error message shown
    • Payment method type (last 4 digits if available)
  2. 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
  3. 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
  4. 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

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

DateVersionChangesAuthor
2025-11-031.0Initial Stripe integration planAI 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:

  1. Create Stripe test account
  2. Configure development environment variables
  3. Update BillingServiceImpl to use real Stripe
  4. Test payment flow end-to-end
  5. Proceed through remaining phases