Billing System Implementation
Overview
This document outlines the technical design for implementing a comprehensive billing system in the Construction Code Expert application. The system will track user balances, manage payment transactions, and provide cost transparency for long-running AI tasks.
Background
The Construction Code Expert application currently tracks costs for long-running tasks (Issue #200) but lacks a billing mechanism to monetize the service. Users need visibility into their account balance, transaction history, and the ability to top-up their accounts. The implementation will integrate with Stripe for payment processing while maintaining a local cache in Firestore for performance and reliability.
Requirements
Functional Requirements
-
User Balance Management
- Track available balance per user
- Display balance in top app bar
- Grant $300 complimentary credits for new users
- Deduct costs from balance when executing long-running tasks
-
Payment Processing
- Integration with Stripe for payment processing
- Support for balance top-up transactions
- Transaction history and receipts
-
Billing Dashboard
- Dedicated billing page with transaction history
- Expense history grouped by projects
- Payment information management
- Cost breakdown for completed tasks
-
Cost Control
- Prevent task execution when insufficient balance
- Real-time balance updates after task completion
- Cost estimation before task execution
Non-Functional Requirements
-
Security
- Minimal PII storage (rely on Firebase Auth and Stripe)
- Secure API endpoints with proper authentication
- Audit trail for all financial transactions
-
Reliability
- Backup and disaster recovery procedures
- Consistent state between Stripe and local database
- Graceful handling of payment failures
-
Performance
- Fast balance lookups for UI display
- Efficient transaction history queries
- Minimal impact on existing task execution flow
Architecture Overview
High-Level Components
Data Architecture
The billing system will maintain data across three primary storage systems:
- Stripe - Source of truth for payment data
- Firestore - Local cache and operational data
- Firebase Auth - User identity and basic profile
Detailed Design
1. Protocol Buffer Schemas
1.1 User Profile Schema
// billing.proto
syntax = "proto3";
package org.codetricks.construction.code.assistant.billing;
import "google/api/annotations.proto";
import "google/type/money.proto";
import "google/protobuf/timestamp.proto";
// User billing profile with balance and subscription information
message UserBillingProfile {
string user_email = 1;
google.type.Money available_balance = 2;
SubscriptionTier subscription_tier = 3;
google.type.Money complimentary_credits = 4;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp updated_at = 6;
// Stripe customer information
string stripe_customer_id = 7;
// Usage statistics
BillingUsageStats usage_stats = 8;
}
// Subscription tier enumeration
enum SubscriptionTier {
UNSPECIFIED = 0; // Default/unknown value
TRIAL = 1;
BASIC = 2;
PROFESSIONAL = 3;
ENTERPRISE = 4;
}
// Usage statistics for the billing profile
message BillingUsageStats {
google.type.Money total_spent = 1;
int32 total_tasks_executed = 2;
google.protobuf.Timestamp last_task_date = 3;
google.type.Money monthly_spend = 4;
}
1.2 Transaction Schema
// Transaction record for balance changes
message BillingTransaction {
string transaction_id = 1;
string user_email = 2;
TransactionType type = 3;
google.type.Money amount = 4;
google.type.Money balance_before = 5;
google.type.Money balance_after = 6;
string description = 7;
google.protobuf.Timestamp created_at = 8;
// Related entity information
oneof related_entity {
string task_id = 9;
string stripe_payment_intent_id = 10;
}
// Transaction metadata
TransactionMetadata metadata = 11;
}
// Transaction type enumeration
enum TransactionType {
UNSPECIFIED = 0; // Default/unknown value
CREDIT = 1; // Balance top-up
DEBIT = 2; // Task cost deduction
REFUND = 3; // Payment refund
ADJUSTMENT = 4; // Manual adjustment
COMPLIMENTARY = 5; // Free credits
}
// Base transaction metadata
message TransactionMetadata {
string source = 1; // e.g., "stripe", "admin", "task_execution"
// Specific metadata based on transaction type
oneof specific_metadata {
TaskCompletionTransactionMetadata task_completion = 2;
// Future: StripePaymentTransactionMetadata stripe_payment = 3;
// Future: AdminAdjustmentTransactionMetadata admin_adjustment = 4;
}
}
// Metadata specific to task completion debit transactions
message TaskCompletionTransactionMetadata {
string project_id = 1; // Project where task was executed
string task_type = 2; // e.g., "plan-ingestion", "code-applicability"
string file_id = 3; // File that was processed
int32 page_id = 4; // Page number that was processed
}
1.3 Payment Request Schema
// Request to top up user balance
message TopUpBalanceRequest {
google.type.Money amount = 1;
string payment_method_id = 2; // Stripe payment method ID
string return_url = 3; // For 3D Secure redirects
}
// Response for balance top-up
message TopUpBalanceResponse {
bool success = 1;
string message = 2;
string stripe_payment_intent_id = 3;
string client_secret = 4; // For frontend payment confirmation
BillingTransaction transaction = 5;
}
// Request to get project expense history (project owner only)
message GetProjectExpenseHistoryRequest {
string project_id = 1;
int32 limit = 2; // Optional: limit number of results
string page_token = 3; // Optional: for pagination
}
// Response with project expense history
message GetProjectExpenseHistoryResponse {
repeated ProjectExpenseEntry expenses = 1;
string next_page_token = 2; // For pagination
google.type.Money total_project_cost = 3;
}
// Individual expense entry for a project
message ProjectExpenseEntry {
string user_email = 1; // User who incurred the expense
BillingTransaction transaction = 2; // The actual transaction
TaskCompletionTransactionMetadata task_details = 3; // Task-specific details
}
2. gRPC Service Interface
// Billing service for user balance and payment management
service BillingService {
// Get user's billing profile and current balance
rpc GetUserBillingProfile(GetUserBillingProfileRequest) returns (GetUserBillingProfileResponse) {
option (google.api.http) = {
get: "/v1/billing/profile"
};
}
// Top up user balance via Stripe
rpc TopUpBalance(TopUpBalanceRequest) returns (TopUpBalanceResponse) {
option (google.api.http) = {
post: "/v1/billing/top-up"
body: "*"
};
}
// Get transaction history for the user
rpc GetTransactionHistory(GetTransactionHistoryRequest) returns (GetTransactionHistoryResponse) {
option (google.api.http) = {
get: "/v1/billing/transactions"
};
}
// Get expense history grouped by projects (user's own expenses only)
rpc GetExpenseHistory(GetExpenseHistoryRequest) returns (GetExpenseHistoryResponse) {
option (google.api.http) = {
get: "/v1/billing/expenses"
};
}
// Get project expense history (project owner can see all users' expenses)
rpc GetProjectExpenseHistory(GetProjectExpenseHistoryRequest) returns (GetProjectExpenseHistoryResponse) {
option (google.api.http) = {
get: "/v1/billing/projects/{project_id}/expenses"
};
}
// Check if user has sufficient balance for a task
rpc CheckSufficientBalance(CheckSufficientBalanceRequest) returns (CheckSufficientBalanceResponse) {
option (google.api.http) = {
post: "/v1/billing/check-balance"
body: "*"
};
}
// Deduct cost from user balance (internal service call)
rpc DeductTaskCost(DeductTaskCostRequest) returns (DeductTaskCostResponse);
}
3. Firestore Schema Design
3.1 Collections Structure
/billing_profiles/{user_email}
- available_balance: number
- subscription_tier: string
- complimentary_credits: number
- stripe_customer_id: string
- created_at: timestamp
- updated_at: timestamp
- usage_stats: object
/billing_transactions/{transaction_id}
- user_email: string
- type: string
- amount: number
- currency: string
- balance_before: number
- balance_after: number
- description: string
- created_at: timestamp
- task_id?: string
- stripe_payment_intent_id?: string
- project_id?: string
- task_type?: string
/billing_cache/{user_email}
- last_stripe_sync: timestamp
- balance_cache: number
- pending_transactions: array
3.2 Security Rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Billing profiles: Users can only access their own profile
match /billing_profiles/{userEmail} {
allow read, write: if request.auth != null &&
request.auth.token.email == userEmail;
}
// Billing transactions: Users can read their own transactions
// Project owners can read project-related transactions for their projects
match /billing_transactions/{transactionId} {
allow read: if request.auth != null && (
// Users can read their own transactions
resource.data.user_email == request.auth.token.email ||
// Project owners can read project transactions (checked via custom claims)
(resource.data.metadata.task_completion.project_id != null &&
request.auth.token.projects[resource.data.metadata.task_completion.project_id] == 'OWNER')
);
allow write: if false; // Only backend can write transactions
}
// Billing cache: Users can only read their own cache
match /billing_cache/{userEmail} {
allow read: if request.auth != null &&
request.auth.token.email == userEmail;
allow write: if false; // Only backend can write cache
}
}
}
4. Backend Implementation
4.1 BillingService Implementation
@Service
public class BillingServiceImpl extends BillingServiceGrpc.BillingServiceImplBase {
private final StripeService stripeService;
private final FirestoreService firestoreService;
private final TaskServiceImpl taskService;
@Override
public void getUserBillingProfile(GetUserBillingProfileRequest request,
StreamObserver<GetUserBillingProfileResponse> responseObserver) {
try {
String userEmail = AuthenticationUtils.getCurrentUserEmail();
UserBillingProfile profile = getBillingProfile(userEmail);
GetUserBillingProfileResponse response = GetUserBillingProfileResponse.newBuilder()
.setProfile(profile)
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) {
responseObserver.onError(e);
}
}
@Override
public void topUpBalance(TopUpBalanceRequest request,
StreamObserver<TopUpBalanceResponse> responseObserver) {
try {
String userEmail = AuthenticationUtils.getCurrentUserEmail();
// Create Stripe payment intent
PaymentIntent paymentIntent = stripeService.createPaymentIntent(
request.getAmount(), userEmail, request.getPaymentMethodId());
// Create pending transaction
BillingTransaction transaction = createPendingTransaction(
userEmail, request.getAmount(), paymentIntent.getId());
TopUpBalanceResponse response = TopUpBalanceResponse.newBuilder()
.setSuccess(true)
.setStripePaymentIntentId(paymentIntent.getId())
.setClientSecret(paymentIntent.getClientSecret())
.setTransaction(transaction)
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) {
responseObserver.onError(e);
}
}
@Override
public void getProjectExpenseHistory(GetProjectExpenseHistoryRequest request,
StreamObserver<GetProjectExpenseHistoryResponse> responseObserver) {
try {
String userEmail = AuthenticationUtils.getCurrentUserEmail();
String projectId = request.getProjectId();
// Verify user is project owner
CheckProjectPermissionRequest permissionCheck = CheckProjectPermissionRequest.newBuilder()
.setArchitecturalProjectId(projectId)
.setRequiredRole(UserRole.OWNER)
.build();
CheckProjectPermissionResponse permissionResponse = rbacService.checkProjectPermission(permissionCheck);
if (!permissionResponse.getHasPermission()) {
throw new PermissionDeniedException("Only project owners can view project expense history");
}
// Get all expense transactions for this project
List<ProjectExpenseEntry> expenses = getProjectExpenses(projectId, request.getLimit(), request.getPageToken());
Money totalCost = calculateTotalProjectCost(projectId);
GetProjectExpenseHistoryResponse response = GetProjectExpenseHistoryResponse.newBuilder()
.addAllExpenses(expenses)
.setTotalProjectCost(totalCost)
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) {
responseObserver.onError(e);
}
}
@Override
public void deductTaskCost(DeductTaskCostRequest request,
StreamObserver<DeductTaskCostResponse> responseObserver) {
try {
String userEmail = request.getUserEmail();
Money costAmount = request.getCostAmount();
// Check sufficient balance
UserBillingProfile profile = getBillingProfile(userEmail);
if (!hasSufficientBalance(profile, costAmount)) {
throw new InsufficientBalanceException("Insufficient balance for task execution");
}
// Deduct from balance atomically
BillingTransaction transaction = deductBalanceAtomically(
userEmail, costAmount, request.getTaskId(), request.getProjectId());
DeductTaskCostResponse response = DeductTaskCostResponse.newBuilder()
.setSuccess(true)
.setTransaction(transaction)
.setNewBalance(transaction.getBalanceAfter())
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) {
responseObserver.onError(e);
}
}
}
4.2 Stripe Integration
@Service
public class StripeService {
@Value("${stripe.secret.key}")
private String stripeSecretKey;
public PaymentIntent createPaymentIntent(Money amount, String userEmail, String paymentMethodId) {
Stripe.apiKey = stripeSecretKey;
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(convertToStripeAmount(amount))
.setCurrency(amount.getCurrencyCode().toLowerCase())
.setPaymentMethod(paymentMethodId)
.setConfirmationMethod(PaymentIntentCreateParams.ConfirmationMethod.MANUAL)
.setConfirm(true)
.putMetadata("user_email", userEmail)
.putMetadata("purpose", "balance_top_up")
.build();
return PaymentIntent.create(params);
}
public void handleWebhook(String payload, String sigHeader) {
Event event = Webhook.constructEvent(payload, sigHeader, webhookSecret);
switch (event.getType()) {
case "payment_intent.succeeded":
handlePaymentSuccess(event);
break;
case "payment_intent.payment_failed":
handlePaymentFailure(event);
break;
}
}
}
4.3 Task Integration
Modify the existing TaskServiceImpl to check balance before task execution:
public String createTask(String taskType, String projectId, String userEmail,
Money estimatedCost, Runnable backgroundTask) {
// Check sufficient balance before creating task
if (estimatedCost != null) {
CheckSufficientBalanceRequest balanceCheck = CheckSufficientBalanceRequest.newBuilder()
.setUserEmail(userEmail)
.setEstimatedCost(estimatedCost)
.build();
if (!billingService.checkSufficientBalance(balanceCheck).getHasSufficientBalance()) {
throw new InsufficientBalanceException("Insufficient balance to execute task");
}
}
// Create task as normal
String taskId = createTaskInternal(taskType, projectId, userEmail, backgroundTask);
// Wrap background task to deduct cost on completion
if (estimatedCost != null) {
Runnable wrappedTask = () -> {
try {
backgroundTask.run();
// Get actual cost from completed task
Task completedTask = getTask(taskId);
Money actualCost = calculateActualCost(completedTask);
// Deduct actual cost from balance
billingService.deductTaskCost(DeductTaskCostRequest.newBuilder()
.setUserEmail(userEmail)
.setCostAmount(actualCost)
.setTaskId(taskId)
.setProjectId(projectId)
.build());
} catch (Exception e) {
logger.error("Failed to deduct task cost", e);
}
};
executorService.submit(wrappedTask);
} else {
executorService.submit(backgroundTask);
}
return taskId;
}
5. Frontend Implementation
5.1 Balance Display in Top App Bar
Add balance display to the existing top app bar components:
// shared/billing.service.ts
@Injectable({
providedIn: 'root'
})
export class BillingService {
private balanceSubject = new BehaviorSubject<number>(0);
balance$ = this.balanceSubject.asObservable();
constructor(private grpcWrapper: GrpcWrapperService) {
this.loadUserBalance();
}
async loadUserBalance(): Promise<void> {
try {
const response = await this.grpcWrapper.getUserBillingProfile({});
this.balanceSubject.next(response.profile.availableBalance.units);
} catch (error) {
console.error('Failed to load user balance:', error);
}
}
async topUpBalance(amount: number, paymentMethodId: string): Promise<any> {
const request = {
amount: { currencyCode: 'USD', units: amount },
paymentMethodId: paymentMethodId,
returnUrl: window.location.origin + '/billing'
};
return this.grpcWrapper.topUpBalance(request);
}
}
Update top app bar components to display balance:
// components/shared/base-top-app-bar.component.ts
export class BaseTopAppBarComponent {
@Input() user$!: Observable<User | null>;
balance$ = this.billingService.balance$;
constructor(protected billingService: BillingService) {}
}
<!-- base-top-app-bar.component.html -->
<div class="user-profile">
<div class="balance-display">
<mat-icon>account_balance_wallet</mat-icon>
<span class="balance-amount">${{ balance$ | async | number:'1.2-2' }}</span>
</div>
<button mat-icon-button [matMenuTriggerFor]="userMenu" class="profile-button">
<!-- existing profile content -->
</button>
</div>
5.2 Billing Page Component
// components/billing/billing.component.ts
@Component({
selector: 'app-billing',
templateUrl: './billing.component.html',
styleUrls: ['./billing.component.css']
})
export class BillingComponent implements OnInit {
billingProfile$ = new BehaviorSubject<UserBillingProfile | null>(null);
transactions$ = new BehaviorSubject<BillingTransaction[]>([]);
expenses$ = new BehaviorSubject<ExpenseHistory[]>([]);
loading = true;
constructor(
private billingService: BillingService,
private dialog: MatDialog
) {}
async ngOnInit() {
await this.loadBillingData();
this.loading = false;
}
async loadBillingData() {
try {
const [profile, transactions, expenses] = await Promise.all([
this.billingService.getUserBillingProfile(),
this.billingService.getTransactionHistory(),
this.billingService.getExpenseHistory()
]);
this.billingProfile$.next(profile);
this.transactions$.next(transactions);
this.expenses$.next(expenses);
} catch (error) {
console.error('Failed to load billing data:', error);
}
}
openTopUpDialog() {
const dialogRef = this.dialog.open(TopUpDialogComponent, {
width: '500px'
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.loadBillingData();
}
});
}
}
5.3 Routing Configuration
Add billing route to the application:
// app.routes.ts
export const routes: Routes = [
// ... existing routes
{
path: 'billing',
loadComponent: () => import('./components/billing/billing.component').then(m => m.BillingComponent),
canActivate: [authGuard]
},
// ... other routes
];
6. Security Considerations
6.1 Authentication & Authorization
Personal Billing Data:
- All billing endpoints require authenticated user
- Users can only access their own billing profile and personal transaction history
- Users can only perform balance top-ups on their own accounts
Project-Related Expense Data:
- Project owners can view all expense transactions for projects they own (including other users' expenses)
- Non-project owners can only view their own expense transactions, even within projects they have access to
- Project expense queries must validate project ownership through RBAC service
Administrative Access:
- Admin endpoints for billing adjustments require admin role
- Global billing analytics require admin role
- Stripe webhook endpoints use signature verification
Access Control Matrix:
| User Role | Own Profile | Own Transactions | Project Expenses (Owner) | Project Expenses (Member) |
|---|---|---|---|---|
| User | ✅ Read/Write | ✅ Read | ❌ No Access | ❌ No Access |
| Project Owner | ✅ Read/Write | ✅ Read | ✅ Read All Users | ❌ No Access |
| Admin | ✅ All Users | ✅ All Users | ✅ All Projects | ✅ All Projects |
6.2 Data Protection
- Minimal PII storage in local database
- Sensitive payment data stored only in Stripe
- Firestore security rules enforce user isolation
- All financial operations logged for audit
6.3 Balance Protection
- Atomic balance updates using Firestore transactions
- Pre-flight balance checks before task execution
- Concurrent access protection with optimistic locking
- Balance reconciliation with Stripe
7. Testing Strategy
7.1 Unit Tests
- BillingService methods with mocked dependencies
- Firestore transaction atomicity
- Balance calculation accuracy
- Stripe integration error handling
7.2 Integration Tests
- End-to-end payment flow with Stripe test mode
- Task execution with balance deduction
- Webhook processing and balance updates
- Frontend billing page functionality
7.3 Load Testing
- Concurrent balance updates
- High-frequency transaction processing
- Stripe API rate limiting
- Firestore query performance
8. Deployment Strategy
8.1 Phase 1: Mock Implementation
- Deploy billing proto definitions
- Implement BillingService with mock Stripe integration
- Add balance display to frontend
- Create basic billing page
8.2 Phase 2: Stripe Integration
- Configure Stripe webhook endpoints
- Implement real payment processing
- Add comprehensive error handling
- Deploy to test environment
8.3 Phase 3: Production Rollout
- Configure production Stripe account
- Set up monitoring and alerting
- Implement backup and recovery procedures
- Gradual rollout to users
9. Monitoring and Observability
9.1 Metrics
- Balance top-up success/failure rates
- Task execution cost distribution
- Average user balance
- Payment processing latency
9.2 Alerts
- Failed payment processing
- Balance reconciliation discrepancies
- Stripe webhook failures
- Insufficient balance task rejections
9.3 Logging
- All financial transactions
- Balance updates with before/after values
- Stripe API interactions
- Task cost calculations
10. Future Considerations
10.1 Subscription Model
- Recurring billing for subscription tiers
- Usage-based pricing tiers
- Enterprise billing features
- Bulk payment discounts
10.2 Cost Optimization
- Task cost prediction models
- Batch processing discounts
- Cached content cost reductions
- Resource usage optimization
10.3 Compliance
- PCI DSS compliance for payment data (Payment Card Industry Data Security Standard)
- Who: Any organization that stores, processes, or transmits credit card information
- Our scope: Limited since Stripe handles card data, but we must secure any cardholder data we touch
- Requirements: Secure network, protect cardholder data, maintain vulnerability management program
- GDPR compliance for user data (General Data Protection Regulation)
- Who: Any organization processing personal data of EU residents
- Our scope: User emails, billing profiles, transaction history for EU users
- Requirements: Data minimization, right to erasure, data portability, consent management
- Financial regulation compliance
- Who: Organizations handling financial transactions (varies by jurisdiction)
- Potential requirements: Anti-money laundering (AML), Know Your Customer (KYC), financial reporting
- Audit trail requirements
- Purpose: Regulatory compliance, fraud prevention, dispute resolution
- Scope: All financial transactions, balance changes, administrative actions
Conclusion
This billing system design provides a comprehensive foundation for monetizing the Construction Code Expert service. The architecture balances security, performance, and user experience while maintaining flexibility for future enhancements. The phased implementation approach allows for gradual rollout and risk mitigation.
The design leverages existing infrastructure (Firestore, gRPC, Angular) while introducing new components (Stripe integration, billing UI) in a cohesive manner. The separation of concerns between payment processing (Stripe) and operational data (Firestore) ensures both reliability and performance.