Skip to content

How Purchase Linking Works

Understanding how Stripe purchases are linked to Craft CMS users in the ICoTA Members plugin.

Overview

The plugin links Stripe purchases to Craft users automatically when webhook events are received from Stripe. The linkUserPurchases() Twig function is NOT used in the normal flow - it was a temporary debugging tool.

Automatic Purchase Linking

The Normal Flow

When a successful payment occurs, the process is:

1. User completes payment in Stripe
2. Stripe sends webhook: payment_intent.succeeded
3. Plugin receives webhook event
4. Plugin creates PurchaseRecord
5. Plugin AUTOMATICALLY links to Craft user
6. Plugin creates MembershipRecord

How Users Are Found

Location: src/Plugin.php:239-279 in handlePaymentIntentSucceeded()

The plugin uses a two-step lookup strategy:

Step 1: Find by Stripe Customer ID

php
// Line 243-250 in Plugin.php
if (!empty($paymentIntentData->customer)) {
    $user = $this->findUserByStripeCustomerId($paymentIntentData->customer);
    if ($user) {
        Craft::info("Linked purchase to user {$user->id} ({$user->email})");
    }
}

How it works:

  1. Get Stripe customer ID from payment intent (e.g., cus_abc123)
  2. Query Stripe plugin to get customer details
  3. Extract email address from Stripe customer
  4. Find Craft user by email match
  5. Link purchase to user

Code path: Plugin.php:501-551 - findUserByStripeCustomerId()

Step 2: Fallback to Billing Email

php
// Line 253-277 in Plugin.php
if (!$user) {
    // Extract email from charges billing_details
    if (isset($paymentIntentData->charges->data)) {
        foreach ($paymentIntentData->charges->data as $charge) {
            if (isset($charge->billing_details->email)) {
                $billingEmail = $charge->billing_details->email;
                break;
            }
        }
    }

    // Find user by billing email
    if ($billingEmail) {
        $user = User::find()->email($billingEmail)->one();
    }
}

How it works:

  1. If Step 1 fails, extract billing email from charge details
  2. Find Craft user directly by email
  3. Link purchase to user

Final Assignment

php
// Line 279 in Plugin.php
$purchase->userId = $user ? $user->id : null;
  • If user found: userId is set
  • If user NOT found: userId is null (orphaned purchase)

When Purchases Can't Be Linked

Purchases become "orphaned" (no userId) when:

  1. User doesn't exist in Craft yet

    • Payment made before user account created
    • Email mismatch between Stripe and Craft
  2. Email can't be extracted

    • Stripe customer has no email
    • Billing details missing from charge
  3. Stripe plugin issues

    • Plugin not installed/enabled
    • API connection problems

Retroactive Linking

For purchases that were created without a user link, there's a proper method in the service layer:

Using PurchaseService::linkPurchasesToUser()

Location: src/services/PurchaseService.php:234-256

php
$purchaseService = Plugin::getInstance()->purchases;
$user = User::find()->email('user@example.com')->one();

// Links all purchases for this user's Stripe customers
$linkedCount = $purchaseService->linkPurchasesToUser($user);

How it works:

  1. Gets all Stripe customer IDs associated with the user's email
  2. Finds all purchases with those customer IDs where userId is null
  3. Updates each purchase to set userId to the user's ID
  4. Returns count of purchases linked

When to use:

  • After importing legacy users
  • After manually creating user accounts
  • To fix orphaned purchases

Example Console Command

You could create a console command to bulk-link purchases:

php
// In a console controller
public function actionLinkPurchases(): int
{
    $users = User::find()->all();
    $totalLinked = 0;

    foreach ($users as $user) {
        $linkedCount = Plugin::getInstance()->purchases->linkPurchasesToUser($user);
        if ($linkedCount > 0) {
            $this->stdout("Linked {$linkedCount} purchases to {$user->email}\n");
            $totalLinked += $linkedCount;
        }
    }

    $this->stdout("Total purchases linked: {$totalLinked}\n");
    return ExitCode::OK;
}

Retroactive Linking from PHP/Console

From PHP/Console:

php
$purchaseService = Plugin::getInstance()->purchases;
$linkedCount = $purchaseService->linkPurchasesToUser($user);

From Twig (if needed):

twig
{# Get current purchase count #}
{% set purchases = getUserPurchases(currentUser) %}
{% set purchaseCount = purchases|length %}

Troubleshooting Purchase Linking

Check if Purchases Are Linked

php
// Find orphaned purchases
$orphanedPurchases = PurchaseRecord::find()
    ->where(['userId' => null])
    ->all();

foreach ($orphanedPurchases as $purchase) {
    echo "Purchase {$purchase->id} - Customer: {$purchase->stripeCustomerId}\n";
}

Manual Linking

php
// Link specific purchase to user
$purchase = PurchaseRecord::findOne(['id' => 123]);
$user = User::find()->email('user@example.com')->one();

if ($purchase && $user) {
    $purchase->userId = $user->id;
    if ($purchase->save()) {
        echo "Linked purchase {$purchase->id} to user {$user->id}\n";
    }
}

Bulk Linking by Email Match

php
// Find purchases where customer email matches Craft user
$stripePlugin = Craft::$app->plugins->getPlugin('stripe');
$users = User::find()->all();

foreach ($users as $user) {
    // Get Stripe customers for this email
    $customers = $stripePlugin->customers->getCustomersByEmail($user->email);

    foreach ($customers as $customer) {
        $customerId = $customer->id; // or ->stripeId depending on structure

        // Find purchases for this customer
        $purchases = PurchaseRecord::find()
            ->where(['stripeCustomerId' => $customerId, 'userId' => null])
            ->all();

        foreach ($purchases as $purchase) {
            $purchase->userId = $user->id;
            $purchase->save();
        }
    }
}

Check User's Purchases

twig
{# In Twig template #}
{% set purchases = getUserPurchases(currentUser) %}

{% if purchases|length > 0 %}
  <p>You have {{ purchases|length }} purchase(s)</p>
{% else %}
  <p>No purchases found</p>
{% endif %}

Check Purchase Has User

php
// In PHP
$purchase = PurchaseRecord::findOne(['id' => 123]);

if ($purchase->userId) {
    $user = $purchase->getUser()->one();
    echo "Purchase linked to: {$user->email}";
} else {
    echo "Purchase not linked to any user";
}

Best Practices

1. Ensure Users Exist Before Payment

twig
{# Create account before checkout #}
{% if not currentUser %}
  <div class="signup-prompt">
    <p>Please create an account before purchasing</p>
    <a href="/register">Sign Up</a>
  </div>
{% else %}
  {# Show Stripe checkout #}
  {{ craft.stripe.checkout(...) }}
{% endif %}

2. Use Stripe Customer Creation Hook

The plugin automatically ensures customers are created:

php
// Plugin.php:122-175 - Before checkout session
Event::on(Checkout::class, Checkout::EVENT_BEFORE_START_CHECKOUT_SESSION,
    function(CheckoutSessionEvent $event) {
        // Ensures customer exists or is created
        if (isset($event->params['customer_email']) && !isset($event->params['customer'])) {
            // Plugin checks for existing customer or forces creation
        }
    }
);

3. Use customer.created Webhook

The plugin automatically creates Craft users when Stripe customers are created:

php
// Plugin.php:447-496 - Handle customer.created webhook
private function handleCustomerCreated(StripeEvent $event): void
{
    $customerData = $event->stripeEvent->data->object;
    $customerEmail = $customerData->email;

    // Check if user exists, create if not
    if (!User::find()->email($customerEmail)->exists()) {
        $user = new User();
        $user->email = $customerEmail;
        $user->username = $customerEmail;
        // ... save user and send activation email
    }
}

4. Monitor Orphaned Purchases

Create a console command to check for orphaned purchases:

php
public function actionCheckOrphaned(): int
{
    $orphaned = PurchaseRecord::find()
        ->where(['userId' => null])
        ->all();

    $this->stdout(count($orphaned) . " orphaned purchase(s) found\n");

    foreach ($orphaned as $purchase) {
        $this->stdout("  ID: {$purchase->id}, Customer: {$purchase->stripeCustomerId}\n");
    }

    return ExitCode::OK;
}

Architecture Diagram

┌─────────────────────┐
│   Stripe Payment    │
│    Completed        │
└──────────┬──────────┘

           │ webhook: payment_intent.succeeded

┌─────────────────────┐
│  Plugin Webhook     │
│     Handler         │
└──────────┬──────────┘


┌─────────────────────┐
│ Create Purchase     │
│    Record           │
└──────────┬──────────┘


    ┌──────────────┐
    │ Find User    │
    │   by:        │
    │ 1. Customer  │◄─── Queries Stripe API
    │    ID        │     for customer email
    │ 2. Billing   │◄─── Extracts from charge
    │    Email     │     billing details
    └──────┬───────┘

      Found?
      ┌────┴────┐
      │         │
     Yes       No
      │         │
      ▼         ▼
  Set userId  userId = null
      │         │
      └────┬────┘


┌─────────────────────┐
│  Save Purchase      │
│    Record           │
└──────────┬──────────┘


┌─────────────────────┐
│ Create Membership   │
│    Record           │
└─────────────────────┘

Summary

How It Actually Works

  1. Automatic linking happens in handlePaymentIntentSucceeded() webhook handler
  2. Two-step lookup: Stripe customer ID → email, then billing email fallback
  3. Service method exists: PurchaseService::linkPurchasesToUser() for retroactive linking

Key Takeaways

  • Normal operation: Purchases are linked automatically via webhooks
  • Retroactive linking: Use PurchaseService::linkPurchasesToUser($user) from PHP
  • Prevention: Ensure users exist before they make purchases

ICoTA Members Plugin Documentation