Register New Paid Subscription

Description

This feature enables users to subscribe to a new paid service plan, such as the Basic or Premium Plan. The process involves creating a Stripe customer if one doesn't exist, setting up the subscription in both the local database and on Stripe, and handling the payment flow via Stripe Checkout. The system relies on Stripe webhooks, specifically checkout.session.completed, to securely and reliably activate the subscription upon successful payment, ensuring data consistency and preventing race conditions.

This feature is crucial for the platform's monetization, providing a seamless and secure way for users to upgrade to paid tiers and access premium features.

Prerequisites:

  • User is authenticated and has decided to subscribe to a paid plan.
  • The group associated with the user does not have a conflicting active subscription.
  • The user has the necessary permissions within their group to manage billing.

Process Flow Diagram

---
config:
  theme: base
  layout: dagre
  flowchart:
    curve: linear
    htmlLabels: true
  themeVariables:
    edgeLabelBackground: "transparent"
---
flowchart TD
    %% == NODES DEFINITION ==
    Client[User]

    %% Layer components
    subgraph ApiControllerLayer["API Controller Layer"]
        SubscriptionController[SubscriptionController]
        WebhookController[WebhookController]
    end

    subgraph ApiServiceLayer["API Service Layer"]
        SubscriptionService(SubscriptionService)
    end

    subgraph BusinessServices["Business Logic Services"]
        StripeService(StripeService)
    end

    subgraph DatabaseLayer["Database Layer"]
        UsersDB[(users)]
        SubscriptionDB[(subscriptions)]
        HistoryDB[(subscription_histories)]
        WebhookEventsDB[(stripe_webhook_events)]
    end

    subgraph ExternalServices["External Services"]
        StripeAPI((Stripe API))
        StripeCheckout((Stripe Checkout Page))
    end

    %% == STEPS DEFINITION ==
    %% Subscription Request Flow
    Step1["<div style='text-align: center'><span style='display: inline-block; background-color: #6699cc !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>1</span><p style='margin-top: 8px'>Request Paid Plan</p></div>"]
    Step2["<div style='text-align: center'><span style='display: inline-block; background-color: #6699cc !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>2</span><p style='margin-top: 8px'>Check/Create Stripe Customer</p></div>"]
    Step3["<div style='text-align: center'><span style='display: inline-block; background-color: #6699cc !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>3</span><p style='margin-top: 8px'>Create Unpaid Subscription</p></div>"]
    Step4["<div style='text-align: center'><span style='display: inline-block; background-color: #6699cc !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>4</span><p style='margin-top: 8px'>Create Stripe Checkout Session</p></div>"]
    Step5["<div style='text-align: center'><span style='display: inline-block; background-color: #6699cc !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>5</span><p style='margin-top: 8px'>Return Checkout URL</p></div>"]
    Step6["<div style='text-align: center'><span style='display: inline-block; background-color: #99cc66 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>6</span><p style='margin-top: 8px'>Complete Payment</p></div>"]

    %% Webhook Flow
    StepW1["<div style='text-align: center'><span style='display: inline-block; background-color: #ff9966 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W1</span><p style='margin-top: 8px'>Send checkout.session.completed</p></div>"]
    StepW2["<div style='text-align: center'><span style='display: inline-block; background-color: #ff9966 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W2</span><p style='margin-top: 8px'>Verify & Log Webhook</p></div>"]
    StepW3["<div style='text-align: center'><span style='display: inline-block; background-color: #ff9966 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W3</span><p style='margin-top: 8px'>Process Session Event</p></div>"]
    StepW4["<div style='text-align: center'><span style='display: inline-block; background-color: #ff9966 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W4</span><p style='margin-top: 8px'>Activate Subscription</p></div>"]

    %% == CONNECTIONS ==
    %% Subscription Request Flow
    Client --- Step1 --> SubscriptionController
    SubscriptionController --- Step2 --> StripeService
    StripeService --> StripeAPI
    StripeService --> UsersDB
    SubscriptionController --- Step3 --> SubscriptionService
    SubscriptionService --> SubscriptionDB
    SubscriptionService --> HistoryDB
    SubscriptionController --- Step4 --> StripeService
    SubscriptionController --- Step5 --> Client
    Client --- Step6 --> StripeCheckout
    
    %% Webhook Flow
    StripeAPI --- StepW1 --> WebhookController
    WebhookController --- StepW2 --> WebhookEventsDB
    WebhookController --- StepW3 --> SubscriptionService
    SubscriptionService --- StepW4
    StepW4 --> SubscriptionDB
    StepW4 --> HistoryDB

    %% == STYLING ==
    style Client fill:#e6f3ff,stroke:#0066cc,stroke-width:2px
    style ApiControllerLayer fill:#e6f3ff,stroke:#0066cc,stroke-width:2px
    style ApiServiceLayer fill:#f0f8e6,stroke:#339933,stroke-width:2px
    style BusinessServices fill:#f5f0ff,stroke:#9966cc,stroke-width:2px
    style DatabaseLayer fill:#ffe6cc,stroke:#ff9900,stroke-width:2px
    style ExternalServices fill:#fcd9d9,stroke:#cc3333,stroke-width:2px
    style Step1 fill:transparent,stroke:transparent,stroke-width:1px
    style Step2 fill:transparent,stroke:transparent,stroke-width:1px
    style Step3 fill:transparent,stroke:transparent,stroke-width:1px
    style Step4 fill:transparent,stroke:transparent,stroke-width:1px
    style Step5 fill:transparent,stroke:transparent,stroke-width:1px
    style Step6 fill:transparent,stroke:transparent,stroke-width:1px
    style StepW1 fill:transparent,stroke:transparent,stroke-width:1px
    style StepW2 fill:transparent,stroke:transparent,stroke-width:1px
    style StepW3 fill:transparent,stroke:transparent,stroke-width:1px
    style StepW4 fill:transparent,stroke:transparent,stroke-width:1px

Use Cases

Case 1: Paid Plan Registration Request

Description

A user selects a paid plan and initiates the registration process. The system creates the necessary records locally and generates a Stripe Checkout session, redirecting the user to Stripe to complete the payment.

Sequence Diagram

sequenceDiagram
    participant Client
    participant SubscriptionController as SubController
    participant SubscriptionService as SubService
    participant StripeService
    participant StripeAPI as Stripe API
    participant DB as Database

    Note over Client, DB: Paid Plan Registration Request Flow
    
    Client->>SubController: POST /api/v1/general/subscription/register
    Note over Client, SubController: Send package_plan_id
    
    rect rgb(255, 255, 200)
    Note right of SubController: Check/Create Stripe Customer
    SubController->>StripeService: getOrCreateCustomer(user)
    StripeService->>DB: Find user.payment_provider_customer_id
    alt No Customer ID
        StripeService->>StripeAPI: customers.create()
        StripeAPI-->>StripeService: customer object
        StripeService->>DB: Update user with customer_id
    end
    end
    
    rect rgb(200, 255, 255)
    Note right of SubController: Create Local Subscription
    SubController->>SubService: createSubscription(user, group, plan)
    SubService->>DB: Begin Transaction
    SubService->>DB: Create subscriptions record (status: unpaid)
    SubService->>DB: Create subscription_histories record (type: new_contract, payment_status: pending)
    SubService->>DB: Commit Transaction
    end
    
    rect rgb(255, 230, 200)
    Note right of SubController: Create Stripe Checkout Session
    SubController->>StripeService: createCheckoutSession(customer_id, price_id, subscription_slug)
    StripeService->>StripeAPI: checkout.sessions.create()
    StripeAPI-->>StripeService: checkout_session object with URL
    StripeService-->>SubController: checkout_url
    end
    
    SubController-->>Client: 200 OK (checkout_url)

Steps

Step 1: Request Registration

  • The user selects a paid plan and sends a request to the backend with the package_plan_id.

Step 2: Check or Create Stripe Customer

  • The system checks if the user already has a payment_provider_customer_id.
  • If not, it creates a new customer on Stripe and saves the ID to the users table.

Step 3: Create Initial Records

  • Within a database transaction, the system creates initial records to track the subscription intent. A record in the subscriptions table is created with its subscription.status set to unpaid.
  • A corresponding record in subscription_histories is also created with subscription_histories.type set to new_contract and the subscription_histories.payment_status set to pending. This pre-records the user's intent to subscribe, which will be finalized upon successful payment.

Step 4: Create Stripe Checkout Session

  • The system requests a new checkout session from Stripe, passing the customer ID, price ID, and the local subscription's unique slug in the metadata.

Step 5: Return Checkout URL

  • The backend returns the Stripe Checkout URL to the client. The client then redirects the user to this URL to complete the payment.

Case 2: Successful Payment Webhook Processing

Description

After the user successfully completes the payment on Stripe, Stripe sends a checkout.session.completed webhook. The system listens for this event to activate the user's subscription. The invoice.paid event is ignored to prevent duplicate processing.

Sequence Diagram

sequenceDiagram
    participant StripeWebhook as Stripe API
    participant WebhookController
    participant SubscriptionService as SubService
    participant SubscriptionSessionHandler as Handler
    participant DB as Database

    Note over StripeWebhook, DB: Webhook Flow for Successful Payment

    StripeWebhook->>WebhookController: POST /api/v1/admin/stripe/webhook (checkout.session.completed)
    
    rect rgb(255, 255, 200)
    Note right of WebhookController: Verify & Log Event
    WebhookController->>DB: Check if event was already processed
    WebhookController->>DB: Create stripe_webhook_events record (status: processing)
    end
    
    WebhookController->>SubService: subscriptionSessionCompleted(session_data)
    SubService->>Handler: handleSessionCompleted(session_data)
    
    rect rgb(200, 255, 255)
    Note right of Handler: Retrieve Data & Activate
    Handler->>DB: Find unpaid subscription via slug from webhook metadata
    
    alt Subscription found and is unpaid
        Handler->>DB: Begin Transaction with Lock
        Handler->>DB: Update subscriptions (status: active, provider_id, deadline_at)
        Handler->>DB: Update subscription_histories (payment_status: paid, paid_at, invoice_id)
        Handler->>DB: Commit Transaction
        Handler->>SubService: Return success
    else Subscription not found or already active
        Handler->>SubService: Log error and return
        end
    end
    
    SubService-->>WebhookController: Success
    WebhookController->>DB: Update stripe_webhook_events (status: completed)
    WebhookController-->>StripeWebhook: 200 OK

Steps

Step 1: Receive Webhook

  • Stripe sends a checkout.session.completed event to the webhook endpoint.

Step 2: Verify and Log Event

  • The WebhookController verifies the webhook signature.
  • It checks the stripe_webhook_events table to prevent processing the same event twice.
  • A new event record is created with a processing status.

Step 3: Process Event

  • The controller delegates the event to the SubscriptionService, which in turn calls the SubscriptionSessionHandler.

Step 4: Activate Subscription

  • The SubscriptionSessionHandler finds the local subscription record using the subscription_slug from the webhook metadata and verifies its status is unpaid.
  • Within a locked database transaction, it activates the subscription. It updates the main subscription.status to active and populates essential information like the payment_provider_subscription_id and deadline_at from the webhook data.
  • It then finds the related pending history record and updates its subscription_histories.payment_status to paid, and populates billing details like invoice_id and paid_at.
  • Finally, the webhook event status is updated to completed in the stripe_webhook_events table.

Case 3: Failed Payment Webhook Processing

Description

If a recurring payment fails for an active subscription, Stripe sends an invoice.payment_failed webhook. The system updates the subscription status and history to reflect the failure and handles retry logic.

Sequence Diagram

sequenceDiagram
    participant StripeWebhook
    participant WebhookController
    participant SubscriptionService as SubService
    participant SubscriptionInvoiceHandler as Handler
    participant DB as Database

    Note over StripeWebhook, DB: Webhook Flow for Failed Recurring Payment

    StripeWebhook->>WebhookController: POST /api/v1/admin/stripe/webhook (invoice.payment_failed)
    
    WebhookController->>SubService: handleInvoicePaymentFailed(invoice_data)
    SubService->>Handler: handleInvoicePaymentFailed(invoice_data)
    
    Handler->>DB: Find subscription by provider_id
    
    alt Subscription is active
        Handler->>DB: Find latest subscription_history
        alt Latest history is 'paid' (First failure for this period)
            Handler->>DB: Create new subscription_history (status: inactive, payment_status: failed, payment_attempt: 1)
        else Latest history is 'failed' (Retry failure)
            Handler->>DB: Update subscription_history (payment_attempt + 1)
        end
    else Subscription is past_due or canceled
        Note over Handler: No action taken
    end

Steps

Step 1: Receive Webhook

  • Stripe sends an invoice.payment_failed event for a recurring billing attempt.

Step 2: Process Event

  • The WebhookController delegates to the SubscriptionService, which calls the SubscriptionInvoiceHandler.

Step 3: Update Subscription History

  • The handler finds the relevant subscription.
  • If this is the first failure for a billing cycle, it creates a new subscription_histories record with payment_status: failed.
  • If this is a subsequent failure, it increments the payment_attempt counter on the existing failed history record.
  • Stripe will automatically handle the subscription status transition to past_due or canceled after enough failed retries. The system listens for those separate webhooks (customer.subscription.updated, customer.subscription.deleted) to update the main subscriptions table status.

Related Database Structure

erDiagram
    users {
        bigint id PK
        string name "User's full name"
        string email "User's email address (unique)"
        string payment_provider_customer_id "Customer ID from Stripe (nullable)"
        timestamp created_at
        timestamp updated_at
    }
    subscriptions {
        bigint id PK
        string slug "Unique identifier for linking with Stripe metadata"
        bigint user_id FK
        bigint group_id FK
        bigint package_id FK
        bigint package_plan_id FK
        string status "unpaid, active, past_due, canceled"
        string payment_provider_subscription_id "Stripe subscription ID"
        timestamp first_register_at "Timestamp of initial registration"
        timestamp deadline_at "Current billing period end"
        timestamp cancelled_at "Timestamp of cancellation"
        timestamp created_at
        timestamp updated_at
    }
    subscription_history {
        bigint id PK
        bigint subscription_id FK
        string status "active, inactive"
        string payment_status "pending, paid, failed"
        string type "new, renewal, change"
        string invoice_id "Stripe invoice ID"
        string payment_intent_id "Stripe payment intent ID"
        timestamp started_at "Period start timestamp"
        timestamp expires_at "Period end timestamp"
        timestamp paid_at "Payment success timestamp"
        int payment_attempt "Number of failed payment attempts for this cycle"
        timestamp created_at
        timestamp updated_at
    }
    stripe_webhook_events {
        bigint id PK
        string stripe_event_id "Stripe event ID to prevent duplicates"
        string event_type
        enum status "pending, processing, completed, failed"
        text error "Error message if processing fails"
        timestamp created_at
        timestamp updated_at
    }
    users ||--o{ subscriptions : has
    subscriptions ||--o{ subscription_history : has

Related API Endpoints

Method Endpoint Controller Description
POST /api/v1/general/subscription/register SubscriptionController@register Initiates a new paid subscription and returns a Stripe Checkout URL.
POST /api/v1/admin/stripe/webhook WebhookController@handleWebhook Listens for and processes all incoming events from Stripe.

Error Handling

  • Log:

    • All registration, payment, and webhook processing failures are logged to the application log.
    • Stripe API errors are logged with detailed context.
    • Webhook processing errors are stored in the error column of the stripe_webhook_events table.
  • Error Detail:

Status Code Error Message Description
400 Invalid subscription request. Input validation failed (e.g., missing package_plan_id).
403 User is not authorized. The user does not have permission to manage billing for the group.
409 Active subscription already exists. The group already has an active subscription.
500 Stripe API error: {message} An error occurred while communicating with the Stripe API.
500 Database error: {message} A database operation failed during the process.

Additional Notes

  • Webhook Handling Strategy: To prevent race conditions and duplicate processing, the system uses the checkout.session.completed event as the single source of truth for activating a new subscription. The subsequent invoice.paid event (with billing_reason: subscription_create) is intentionally ignored for this initial registration flow. This ensures that the subscription fulfillment logic is executed only once, in a reliable and atomic manner.
  • Transaction Safety: All database operations that modify subscription state are wrapped in database transactions to ensure data consistency. The webhook handler for successful payments also uses database-level locking to prevent race conditions from multiple simultaneous webhook deliveries.
  • Payment Retries: For recurring payments, retry logic is managed by Stripe's "Smart Retries" feature. The system listens for invoice.payment_failed webhooks to track these attempts and customer.subscription.updated / deleted to handle the final state change.