Skip to main content

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:
StateDescriptionTransition Trigger
incompleteInitial state before payment method is verified.Tenant Creation
incomplete_expiredPayment window expired without success. Terminal.Stripe payment window timeout
trialingActive trial period where all features are free.Successful Stripe Trial setup
activePayment is current and account is in good standing.Stripe invoice.payment_succeeded
past_duePayment failed, but access is still granted (Grace Period).Stripe invoice.payment_failed
unpaidPayment failed multiple times. Tiered access begins.Exhausted retry attempts
pausedSubscription manually paused.Manual pause action
canceledSubscription 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:
  1. Quota Checking: Every business action checks the current subscription tier (e.g., maxUsers, aiGenerationsPerMonth, maxAgentCreditsPerMonth, maxAgentRunsPerMonth).
  2. 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.
  3. 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.
  4. 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:
FunctionCloud Function CalledPurpose
fetchPaymentMethods()getPaymentMethodsList payment methods for an organization
createSetupIntentForPaymentMethod()createSetupIntentGet a SetupIntent clientSecret for Stripe Elements
removePaymentMethod()detachPaymentMethodRemove a payment method
setDefaultPaymentMethod()setDefaultPaymentMethodSet default payment method on customer + subscription
fetchInvoices()getInvoicesList invoices for an organization
downloadInvoice()(uses fetchInvoices)Get invoice PDF URL
fetchSubscriptionDetails()getSubscriptionDetailsGet current subscription from Stripe
updateSubscription()updateSubscriptionChange subscription tier
cancelSubscription()cancelSubscriptionCancel (at period end or immediately)
reactivateSubscription()reactivateSubscriptionUn-cancel a subscription scheduled for cancellation
fetchPaymentSettings()getPaymentSettingsGet billing email, address, tax ID
updatePaymentSettings()updatePaymentSettingsUpdate billing details
createCustomerPortalSession()createPortalSessionRedirect to Stripe Customer Portal
createCheckoutSession()createCheckoutSessionStart a Stripe Checkout flow
getSeatsUsage()getSeatsUsageGet current seat allocation and usage
updateSubscriptionSeats()updateSubscriptionSeatsChange seat count
calculateSeatChangeCost()calculateSeatChangeCostPreview 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:
FileExportsResponsibility
index.tscreateCheckoutSession, createPortalSession, getPaymentMethods, getInvoices, getSubscriptionDetails, stripeWebhookCore Stripe operations + webhook handler
paymentMethods.tsattachPaymentMethod, detachPaymentMethod, createSetupIntent, setDefaultPaymentMethodPayment method CRUD
subscriptionManagement.tsSubscription update/cancel/reactivate functionsSubscription lifecycle operations
subscriptionOperations.tsSeat management, cost calculationSeat-based billing operations
paymentSettings.tsgetPaymentSettings, updatePaymentSettingsBilling info management
stateMachine.tsvalidateAndLogTransition, getCurrentStatus, getTierForStatus, isValidTransitionSubscription state machine enforcement
idempotency.tsprocessEventIdempotently, isEventProcessed, markEventProcessedWebhook 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:
  1. Stripe -> Cloud Function: Stripe sends a webhook to the stripeWebhook HTTP function (an onRequest handler).
  2. Signature Verification: The raw request body + stripe-signature header are verified against STRIPE_WEBHOOK_SECRET.
  3. Idempotency Check: The processEventIdempotently() wrapper checks the stripe_events Firestore collection using the Stripe event.id. Duplicate events are skipped.
  4. 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.
  5. Handler Dispatch: The event is dispatched to the appropriate handler based on event.type.
  6. Firestore Update: The handler updates the organizations/{id} document with the new subscription state, period dates, seats, and tier.

Handled Webhook Events

Stripe EventHandlerEffect
invoice.payment_succeededhandleInvoicePaymentSucceededSet status to active, update period end, send receipt email
invoice.payment_failedhandleInvoicePaymentFailedSet status to past_due, send dunning email
customer.subscription.createdhandleSubscriptionCreatedStore stripeSubscriptionId, status, period dates
customer.subscription.updatedhandleSubscriptionUpdatedSync status, cancelAtPeriodEnd, period dates, seat counts; tier downgrade if applicable
customer.subscription.deletedhandleSubscriptionDeletedSet status to canceled, downgrade tier to Starter; send trial-expired email if applicable
customer.subscription.trial_will_endhandleTrialWillEndFlag trialEndingSoon, send trial-expiring email
checkout.session.completedhandleCheckoutCompletedStore stripeCustomerId and stripeSubscriptionId, set status to active
payment_method.attachedhandlePaymentMethodAttachedLog attachment (fresh data fetched on next list call)
payment_method.detachedhandlePaymentMethodDetachedLog detachment
customer.updatedhandleCustomerUpdatedSync customer email and address to legalEntity
charge.dispute.createdhandleDisputeCreatedRecord 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:
TabContent
SubscriptionPricing cards (Starter / Professional / Enterprise), billing cycle toggle, upgrade/downgrade, cancel/reactivate
Usage & LimitsAI generations, seat license, agent credits, agent runs — progress bars with period-end reset dates
ExtensionsModule add-on subscriptions via stripeModuleBilling service
Payment MethodsList, set default, remove payment methods; add via Stripe Portal
InvoicesInvoice history with status badges and PDF download links
Billing InfoLegal 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.