![]() Server : Apache/2 System : Linux server-15-235-50-60 5.15.0-164-generic #174-Ubuntu SMP Fri Nov 14 20:25:16 UTC 2025 x86_64 User : gositeme ( 1004) PHP Version : 8.2.29 Disable Function : exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname Directory : /home/gositeme/.cursor-server/data/User/History/-3f6b0cce/ |
# Subscription Payment Flow Audit
**Date:** 2024-12-19
**Scope:** Complete subscription payment flow from user click to database recording
**Status:** ✅ Track purchases confirmed working A-Z
---
## Flow Overview
### 1. User Initiates Subscription (`subscribe.php`)
**Location:** `/subscribe.php` (lines 399-498)
**Process:**
1. User selects plan and clicks "Subscribe Now"
2. POST request with `create_subscription=1`
3. Validates price ID is configured (not placeholder)
4. Creates Stripe Checkout Session with:
- `mode: 'subscription'`
- `line_items[0][price]`: Plan's Stripe price ID
- `success_url`: `subscription_success.php?session_id={CHECKOUT_SESSION_ID}`
- `cancel_url`: `subscribe.php?plan={plan}&canceled=1`
- `metadata[user_id]`: Current user ID
- `metadata[plan]`: Plan key (essential, starter, pro, premium, enterprise)
5. Redirects user to Stripe Checkout
**✅ Strengths:**
- Validates price ID before creating session
- Includes user_id and plan in metadata
- Proper error handling with try/catch
- Logs errors for debugging
**⚠️ Issues Found:**
1. **Missing: Pre-subscription Validation**
- No check if user already has an active subscription
- User could create multiple subscriptions simultaneously
- **Recommendation:** Add check before creating checkout session:
```php
$existing_subscription = hasActiveSubscription($_SESSION['user_id']);
if ($existing_subscription && in_array($existing_subscription['status'], ['active', 'trialing'])) {
// Redirect to manage subscription or show error
}
```
2. **Missing: Customer ID Reuse**
- Code checks for `stripe_customer_id` but doesn't enforce reuse
- Could create duplicate Stripe customers
- **Current:** Only adds customer if exists, otherwise uses `customer_email`
- **Status:** Acceptable (Stripe handles deduplication)
---
### 2. Stripe Checkout Payment
**Process:**
- User completes payment on Stripe's hosted checkout
- Stripe processes payment
- User redirected to `success_url` with `session_id`
**✅ Strengths:**
- Uses Stripe's secure hosted checkout
- No PCI compliance concerns
---
### 3. Immediate Database Recording (`subscription_success.php`)
**Location:** `/subscription_success.php` (lines 32-184)
**Process:**
1. Receives `session_id` from query parameter
2. Fetches checkout session from Stripe API
3. Extracts subscription ID from session
4. Fetches subscription details from Stripe
5. Determines plan name from:
- Session metadata (`plan`)
- Subscription price ID (fallback)
6. **Immediately records in database:**
- Updates `users.stripe_customer_id`
- Inserts/updates `user_subscriptions` (ON DUPLICATE KEY UPDATE)
- Updates `users.plan`
- Creates `monthly_track_usage` record
**✅ Strengths:**
- **Primary recording mechanism** - doesn't wait for webhook
- Uses transactions for atomicity
- ON DUPLICATE KEY UPDATE prevents duplicates
- Multiple fallbacks for plan detection
- Graceful error handling (webhook will sync if this fails)
**⚠️ Issues Found:**
1. **Race Condition with Webhook**
- Both `subscription_success.php` and webhook can run simultaneously
- **Mitigation:** ON DUPLICATE KEY UPDATE handles this gracefully
- **Status:** ✅ Handled correctly
2. **Missing: Idempotency Check**
- No check if subscription already recorded before inserting
- **Impact:** Low (ON DUPLICATE KEY UPDATE handles it)
- **Recommendation:** Add check to avoid unnecessary DB writes:
```php
$check_stmt = $pdo->prepare("SELECT id FROM user_subscriptions WHERE stripe_subscription_id = ?");
$check_stmt->execute([$subscription['id']]);
if ($check_stmt->fetch()) {
// Already exists, skip insert
}
```
3. **Plan Detection Logic**
- Falls back to 'essential' if plan not found
- **Issue:** Could assign wrong plan if price ID not in config
- **Recommendation:** Log warning and alert admin if plan not found
---
### 4. Webhook Backup Recording (`webhooks/stripe.php`)
**Location:** `/webhooks/stripe.php`
**Events Handled:**
- `checkout.session.completed` (lines 160-190)
- `customer.subscription.created` (lines 135-138, function 1253-1405)
- `customer.subscription.updated` (lines 140-143, function 1407-1632)
- `customer.subscription.deleted` (lines 145-148, function 1634-1705)
**Process:**
1. Verifies webhook signature
2. Handles `checkout.session.completed`:
- Fetches subscription from Stripe
- Calls `handleSubscriptionCreated()`
3. Handles `customer.subscription.created`:
- Gets user by Stripe customer ID
- Falls back to metadata user_id
- Determines plan from price ID or metadata
- Records subscription with ON DUPLICATE KEY UPDATE
- Creates monthly_track_usage record
- Handles grace period conversion
**✅ Strengths:**
- **Backup mechanism** - ensures subscription recorded even if user doesn't complete redirect
- Signature verification for security
- Multiple fallbacks for user identification
- Grace period conversion handled
- Comprehensive logging
**⚠️ Issues Found:**
1. **User Identification Fallback**
- Tries `stripe_customer_id` first, then metadata
- **Issue:** If customer ID doesn't match, could fail
- **Status:** ✅ Has fallback to metadata
2. **Plan Detection in Webhook**
- Similar logic to `subscription_success.php`
- **Issue:** Could have different plan detection logic
- **Recommendation:** Extract plan detection to shared function
3. **Multiple Active Subscriptions**
- `handleSubscriptionCreated` doesn't check for existing active subscriptions
- **Issue:** User could have multiple active subscriptions
- **Current:** `subscribe.php` switch_plan logic cancels old subscriptions (lines 163-193)
- **Status:** ✅ Handled in plan switching, but not in initial creation
---
### 5. Subscription Updates (`customer.subscription.updated`)
**Location:** `/webhooks/stripe.php` (function `handleSubscriptionUpdated`, lines 1407-1632)
**Process:**
1. Gets existing subscription record
2. Detects plan changes
3. Updates subscription status, plan, periods
4. Updates user plan
5. Handles billing period renewals (resets track usage)
6. Handles plan changes (updates track limit)
7. Handles downgrades (caps tracks_created to new limit)
8. Grace period conversion on upgrades
**✅ Strengths:**
- Comprehensive update handling
- Detects plan changes and billing renewals
- Security: Caps tracks_created on downgrade
- Grace period conversion
- Detailed logging
**⚠️ Issues Found:**
1. **Period Change Detection**
- Compares period_start timestamps
- **Issue:** Timezone or format differences could cause false positives
- **Status:** ✅ Handles string/numeric conversion
2. **Downgrade Security**
- Caps `tracks_created` to new limit
- **Status:** ✅ Correctly implemented
---
## Critical Issues Summary
### 🔴 High Priority
1. **Missing Pre-subscription Check**
- **File:** `subscribe.php`
- **Issue:** No validation that user doesn't already have active subscription
- **Risk:** User could create multiple subscriptions
- **Fix:** Add check before creating checkout session
### 🟡 Medium Priority
2. **Plan Detection Consistency**
- **Files:** `subscription_success.php`, `webhooks/stripe.php`
- **Issue:** Plan detection logic duplicated
- **Risk:** Inconsistency if one file updated but not the other
- **Fix:** Extract to shared function
3. **Multiple Active Subscriptions**
- **File:** `webhooks/stripe.php` (`handleSubscriptionCreated`)
- **Issue:** Doesn't check for existing active subscriptions
- **Risk:** User could have multiple active subscriptions
- **Fix:** Add check and cancel old subscriptions
### 🟢 Low Priority
4. **Idempotency Optimization**
- **File:** `subscription_success.php`
- **Issue:** No check before insert (relies on ON DUPLICATE KEY)
- **Risk:** Unnecessary DB writes
- **Fix:** Add existence check before insert
5. **Plan Not Found Handling**
- **Files:** `subscription_success.php`, `webhooks/stripe.php`
- **Issue:** Falls back to 'essential' silently
- **Risk:** Wrong plan assigned
- **Fix:** Log warning and alert admin
---
## Recommendations
### Immediate Actions
1. **Add Pre-subscription Validation**
```php
// In subscribe.php, before creating checkout session
$existing_subscription = hasActiveSubscription($_SESSION['user_id']);
if ($existing_subscription && in_array($existing_subscription['status'], ['active', 'trialing'])) {
$error_message = t('subscribe.already_subscribed');
// Show error or redirect to manage_subscription.php
}
```
2. **Check for Multiple Active Subscriptions in Webhook**
```php
// In handleSubscriptionCreated
$existing_active = $pdo->prepare("
SELECT id, stripe_subscription_id
FROM user_subscriptions
WHERE user_id = ? AND status IN ('active', 'trialing')
ORDER BY created_at DESC
");
$existing_active->execute([$user_id]);
$all_active = $existing_active->fetchAll();
if (count($all_active) > 0) {
// Cancel old subscriptions (keep only newest)
// Similar to subscribe.php switch_plan logic
}
```
### Code Improvements
3. **Extract Plan Detection to Shared Function**
```php
// In utils/subscription_helpers.php
function detectPlanFromPriceId($price_id) {
$plans_config = require __DIR__ . '/../config/subscription_plans.php';
foreach ($plans_config as $plan_key => $plan_data) {
if (isset($plan_data['stripe_price_id']) && $plan_data['stripe_price_id'] === $price_id) {
return $plan_key;
}
}
error_log("WARNING: Plan not found for price_id: {$price_id}");
return 'essential'; // Default fallback
}
```
4. **Add Idempotency Check**
```php
// In subscription_success.php, before insert
$check_stmt = $pdo->prepare("SELECT id FROM user_subscriptions WHERE stripe_subscription_id = ?");
$check_stmt->execute([$subscription['id']]);
if ($check_stmt->fetch()) {
// Already exists, just update
$subscription_recorded = true;
} else {
// Insert new
}
```
---
## Testing Checklist
- [ ] New subscription creation
- [ ] Subscription with existing active subscription (should block)
- [ ] Webhook arrives before redirect (should still work)
- [ ] Webhook arrives after redirect (should not duplicate)
- [ ] Plan upgrade (switch_plan)
- [ ] Plan downgrade (should cap tracks_created)
- [ ] Billing period renewal (should reset usage)
- [ ] Subscription cancellation
- [ ] Multiple checkout sessions created simultaneously
- [ ] Invalid price ID handling
- [ ] Missing metadata handling
---
## Conclusion
**Overall Assessment:** ✅ **Good** - The flow is well-designed with proper fallbacks and error handling. The primary issues are:
1. Missing validation to prevent multiple active subscriptions
2. Some code duplication that could be refactored
3. Minor optimizations for idempotency
**Critical Path:** ✅ **Working** - The core flow (subscribe → payment → record) works correctly with proper fallbacks.
**Recommendation:** Implement the high-priority fixes (pre-subscription check and multiple subscription handling) to prevent edge cases, but the current implementation is production-ready for normal use cases.