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 MembershipRecordHow 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
// 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:
- Get Stripe customer ID from payment intent (e.g.,
cus_abc123) - Query Stripe plugin to get customer details
- Extract email address from Stripe customer
- Find Craft user by email match
- Link purchase to user
Code path: Plugin.php:501-551 - findUserByStripeCustomerId()
Step 2: Fallback to Billing Email
// 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:
- If Step 1 fails, extract billing email from charge details
- Find Craft user directly by email
- Link purchase to user
Final Assignment
// Line 279 in Plugin.php
$purchase->userId = $user ? $user->id : null;- If user found:
userIdis set - If user NOT found:
userIdis null (orphaned purchase)
When Purchases Can't Be Linked
Purchases become "orphaned" (no userId) when:
User doesn't exist in Craft yet
- Payment made before user account created
- Email mismatch between Stripe and Craft
Email can't be extracted
- Stripe customer has no email
- Billing details missing from charge
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
$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:
- Gets all Stripe customer IDs associated with the user's email
- Finds all purchases with those customer IDs where
userIdis null - Updates each purchase to set
userIdto the user's ID - 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:
// 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:
$purchaseService = Plugin::getInstance()->purchases;
$linkedCount = $purchaseService->linkPurchasesToUser($user);From Twig (if needed):
{# Get current purchase count #}
{% set purchases = getUserPurchases(currentUser) %}
{% set purchaseCount = purchases|length %}Troubleshooting Purchase Linking
Check if Purchases Are Linked
// Find orphaned purchases
$orphanedPurchases = PurchaseRecord::find()
->where(['userId' => null])
->all();
foreach ($orphanedPurchases as $purchase) {
echo "Purchase {$purchase->id} - Customer: {$purchase->stripeCustomerId}\n";
}Manual Linking
// 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
// 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();
}
}
}Verifying Purchase Links
Check User's Purchases
{# 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
// 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
{# 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:
// 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:
// 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:
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
- ✅ Automatic linking happens in
handlePaymentIntentSucceeded()webhook handler - ✅ Two-step lookup: Stripe customer ID → email, then billing email fallback
- ✅ 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