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
userstable.
Step 3: Create Initial Records
- Within a database transaction, the system creates initial records to track the subscription intent. A record in the
subscriptionstable is created with itssubscription.statusset tounpaid. - A corresponding record in
subscription_historiesis also created withsubscription_histories.typeset tonew_contractand thesubscription_histories.payment_statusset topending. 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.completedevent to the webhook endpoint.
Step 2: Verify and Log Event
- The
WebhookControllerverifies the webhook signature. - It checks the
stripe_webhook_eventstable to prevent processing the same event twice. - A new event record is created with a
processingstatus.
Step 3: Process Event
- The controller delegates the event to the
SubscriptionService, which in turn calls theSubscriptionSessionHandler.
Step 4: Activate Subscription
- The
SubscriptionSessionHandlerfinds the local subscription record using thesubscription_slugfrom the webhook metadata and verifies its status isunpaid. - Within a locked database transaction, it activates the subscription. It updates the main
subscription.statustoactiveand populates essential information like thepayment_provider_subscription_idanddeadline_atfrom the webhook data. - It then finds the related
pendinghistory record and updates itssubscription_histories.payment_statustopaid, and populates billing details likeinvoice_idandpaid_at. - Finally, the webhook event status is updated to
completedin thestripe_webhook_eventstable.
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_failedevent for a recurring billing attempt.
Step 2: Process Event
- The
WebhookControllerdelegates to theSubscriptionService, which calls theSubscriptionInvoiceHandler.
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_historiesrecord withpayment_status: failed. - If this is a subsequent failure, it increments the
payment_attemptcounter on the existing failed history record. - Stripe will automatically handle the subscription status transition to
past_dueorcanceledafter enough failed retries. The system listens for those separate webhooks (customer.subscription.updated,customer.subscription.deleted) to update the mainsubscriptionstable 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
errorcolumn of thestripe_webhook_eventstable.
-
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.completedevent as the single source of truth for activating a new subscription. The subsequentinvoice.paidevent (withbilling_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_failedwebhooks to track these attempts andcustomer.subscription.updated/deletedto handle the final state change.