Documentation Index
Fetch the complete documentation index at: https://grantmaster.dev/llms.txt
Use this file to discover all available pages before exploring further.
Subscription Lifecycle and State Machine
last_updated: 2026-02-22
This document details the internal domain logic for managing tenant subscriptions and billing states. GrantMaster uses a strict state machine to handle the transition from a new user to a paying customer.
🏗️ The Subscription State Machine
A tenant’s subscription (organizations/{organizationId} — field subscription) moves through the following states, synchronized with Stripe events:
| State | Description | Transition Trigger |
|---|
incomplete | Initial state before payment method is verified. | Tenant Creation |
incomplete_expired | Payment window expired without success. Terminal. | Stripe payment window timeout |
trialing | Active trial period where all features are free. | Successful Stripe Trial setup |
active | Payment is current and account is in good standing. | Stripe invoice.payment_succeeded |
past_due | Payment failed, but access is still granted (Grace Period). | Stripe invoice.payment_failed |
unpaid | Payment failed multiple times. Tiered access begins. | Exhausted retry attempts |
paused | Subscription manually paused. | Manual pause action |
canceled | Subscription ended. Data is preserved but access is revoked. | Stripe customer.subscription.deleted |
State Transition Diagram
The state machine is enforced server-side in functions/src/stripe/stateMachine.ts. Invalid transitions are logged to the subscription_transition_logs Firestore collection for audit but do not block webhook processing.
🧬 Domain Logic: Access Control
The BillingService, SubscriptionService, and tier-limit configuration (src/config/entitlements.ts) resolve the final access level for a tenant:
- Quota Checking: Every business action checks the current subscription tier (e.g.,
maxUsers, aiGenerationsPerMonth, maxAgentCreditsPerMonth, maxAgentRunsPerMonth).
- Grace Periods: For
past_due organizations, we grant a grace period for SuperAdmins to intervene or for the user to update billing. The SubscriptionStatusBanner component surfaces payment-failure warnings with a direct “Update Payment Method” call to action.
- Tier Downgrade: If a subscription is canceled or enters
unpaid/incomplete_expired, the tenant is moved to the Starter (free) tier automatically. If they exceed the Starter tier limits (e.g., have 50 users but now only 3 allowed), the UI enters Read-Only / Lock Mode for all non-admin users.
- Seat Management: Seat counts are tracked per organization (
subscription.seats.included, seats.purchased, seats.total, seats.used, seats.available) and synchronized via the customer.subscription.updated webhook.
🔗 Stripe Integration Architecture
Client-Side: stripePaymentService.ts
Note: The former stripeService.ts has been removed. All client-side Stripe operations are now handled by src/features/billing/services/stripePaymentService.ts.
The frontend service communicates with Stripe exclusively through Firebase Cloud Functions via httpsCallable. It never touches Stripe secret keys. The service provides:
| Function | Cloud Function Called | Purpose |
|---|
fetchPaymentMethods() | getPaymentMethods | List payment methods for an organization |
createSetupIntentForPaymentMethod() | createSetupIntent | Get a SetupIntent clientSecret for Stripe Elements |
removePaymentMethod() | detachPaymentMethod | Remove a payment method |
setDefaultPaymentMethod() | setDefaultPaymentMethod | Set default payment method on customer + subscription |
fetchInvoices() | getInvoices | List invoices for an organization |
downloadInvoice() | (uses fetchInvoices) | Get invoice PDF URL |
fetchSubscriptionDetails() | getSubscriptionDetails | Get current subscription from Stripe |
updateSubscription() | updateSubscription | Change subscription tier |
cancelSubscription() | cancelSubscription | Cancel (at period end or immediately) |
reactivateSubscription() | reactivateSubscription | Un-cancel a subscription scheduled for cancellation |
fetchPaymentSettings() | getPaymentSettings | Get billing email, address, tax ID |
updatePaymentSettings() | updatePaymentSettings | Update billing details |
createCustomerPortalSession() | createPortalSession | Redirect to Stripe Customer Portal |
createCheckoutSession() | createCheckoutSession | Start a Stripe Checkout flow |
getSeatsUsage() | getSeatsUsage | Get current seat allocation and usage |
updateSubscriptionSeats() | updateSubscriptionSeats | Change seat count |
calculateSeatChangeCost() | calculateSeatChangeCost | Preview cost of seat changes |
The Stripe.js frontend SDK is loaded for Stripe Elements (card collection iframes) via the getStripe() helper using the publishable key from VITE_STRIPE_PUBLISHABLE_KEY.
Server-Side: Cloud Functions (functions/src/stripe/)
The Stripe Cloud Functions are split across several files:
| File | Exports | Responsibility |
|---|
index.ts | createCheckoutSession, createPortalSession, getPaymentMethods, getInvoices, getSubscriptionDetails, stripeWebhook | Core Stripe operations + webhook handler |
paymentMethods.ts | attachPaymentMethod, detachPaymentMethod, createSetupIntent, setDefaultPaymentMethod | Payment method CRUD |
subscriptionManagement.ts | Subscription update/cancel/reactivate functions | Subscription lifecycle operations |
subscriptionOperations.ts | Seat management, cost calculation | Seat-based billing operations |
paymentSettings.ts | getPaymentSettings, updatePaymentSettings | Billing info management |
stateMachine.ts | validateAndLogTransition, getCurrentStatus, getTierForStatus, isValidTransition | Subscription state machine enforcement |
idempotency.ts | processEventIdempotently, isEventProcessed, markEventProcessed | Webhook deduplication |
All Cloud Functions enforce:
- App Check verification — requests must originate from a verified app
- Firebase Authentication — the caller must be signed in
- Organization membership — the caller must belong to the target organization
- Billing permission (payment methods) — the caller must have
Admin or Super Admin role
Webhook Flow
We use Webhooks as the source of truth for subscription changes:
- Stripe -> Cloud Function: Stripe sends a webhook to the
stripeWebhook HTTP function (an onRequest handler).
- Signature Verification: The raw request body +
stripe-signature header are verified against STRIPE_WEBHOOK_SECRET.
- Idempotency Check: The
processEventIdempotently() wrapper checks the stripe_events Firestore collection using the Stripe event.id. Duplicate events are skipped.
- State Machine Validation: For status-changing events,
validateAndLogTransition() checks the transition is valid. Invalid transitions are logged to subscription_transition_logs but do not block processing.
- Handler Dispatch: The event is dispatched to the appropriate handler based on
event.type.
- Firestore Update: The handler updates the
organizations/{id} document with the new subscription state, period dates, seats, and tier.
Handled Webhook Events
| Stripe Event | Handler | Effect |
|---|
invoice.payment_succeeded | handleInvoicePaymentSucceeded | Set status to active, update period end, send receipt email |
invoice.payment_failed | handleInvoicePaymentFailed | Set status to past_due, send dunning email |
customer.subscription.created | handleSubscriptionCreated | Store stripeSubscriptionId, status, period dates |
customer.subscription.updated | handleSubscriptionUpdated | Sync status, cancelAtPeriodEnd, period dates, seat counts; tier downgrade if applicable |
customer.subscription.deleted | handleSubscriptionDeleted | Set status to canceled, downgrade tier to Starter; send trial-expired email if applicable |
customer.subscription.trial_will_end | handleTrialWillEnd | Flag trialEndingSoon, send trial-expiring email |
checkout.session.completed | handleCheckoutCompleted | Store stripeCustomerId and stripeSubscriptionId, set status to active |
payment_method.attached | handlePaymentMethodAttached | Log attachment (fresh data fetched on next list call) |
payment_method.detached | handlePaymentMethodDetached | Log detachment |
customer.updated | handleCustomerUpdated | Sync customer email and address to legalEntity |
charge.dispute.created | handleDisputeCreated | Record dispute in organizations/{id}/disputes subcollection |
Payment Method Management Flow
User clicks "Add Payment Method"
→ Frontend calls createSetupIntentForPaymentMethod(orgId)
→ Cloud Function createSetupIntent creates a Stripe SetupIntent
→ Returns clientSecret
→ Frontend renders Stripe Payment Element with clientSecret
→ User enters card details in Stripe iframe (card data never touches our code)
→ Stripe confirms SetupIntent, attaches payment method to customer
→ payment_method.attached webhook fires
→ User can then set as default or remove via setDefaultPaymentMethod / detachPaymentMethod
Alternatively, users can manage payment methods through the Stripe Customer Portal via createCustomerPortalSession().
🖥️ Billing UI Component Structure
The billing page (src/features/billing/components/Billing.tsx) is a single component with tabbed navigation:
| Tab | Content |
|---|
| Subscription | Pricing cards (Starter / Professional / Enterprise), billing cycle toggle, upgrade/downgrade, cancel/reactivate |
| Usage & Limits | AI generations, seat license, agent credits, agent runs — progress bars with period-end reset dates |
| Extensions | Module add-on subscriptions via stripeModuleBilling service |
| Payment Methods | List, set default, remove payment methods; add via Stripe Portal |
| Invoices | Invoice history with status badges and PDF download links |
| Billing Info | Legal entity details (org name, KVK, RSIN, address); link to Settings for editing |
The SubscriptionStatusBanner sub-component renders contextual alerts for past_due, trialing, cancellation-scheduled, and canceled states.
Public exports from src/features/billing/index.ts:
billingService, stripePaymentService, subscriptionService, usageAlerts, usageTracking, stripeModuleBilling
Billing component
🛡️ Billing Integrity
- Idempotency: Every webhook handler uses the Stripe
event.id stored in the stripe_events Firestore collection. Duplicate events return { processed: false, duplicate: true }. Processed event records auto-expire after 30 days via cleanupOldEvents().
- State Machine Audit: Invalid state transitions are recorded in
subscription_transition_logs with the from/to status and context. The webhook still proceeds (non-blocking) to avoid rejecting legitimate Stripe events.
- Transient Error Handling: On webhook processing failure, the function returns HTTP 500 so Stripe retries with exponential backoff (up to 3 days).
- Billing Emails: Payment receipts, dunning notices, trial-expiring, and trial-expired emails are sent asynchronously (non-blocking) via helpers in
functions/src/email/billingEmails.ts.