feat(payment): implement secure 1:1 Polar customer binding system

- Add polar_cust_id column to users table for direct customer mapping
  - Rewrite PolarProvider customer logic to use stored customer IDs
  - Eliminate email-based customer lookup to prevent cross-user contamination
  - Implement self-healing mechanism for invalid customer IDs
  - Maintain external_id binding for Polar's reference system
  - Add comprehensive logging for customer lookup operations
This commit is contained in:
idevakk
2025-12-04 13:05:47 -08:00
parent cd8d6f1165
commit 34183dc3cb
3 changed files with 120 additions and 59 deletions

View File

@@ -30,6 +30,7 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
'email', 'email',
'password', 'password',
'level', 'level',
'polar_cust_id',
]; ];
/** /**

View File

@@ -786,54 +786,87 @@ class PolarProvider implements PaymentProviderContract
// Helper methods // Helper methods
protected function getOrCreateCustomer(User $user): array protected function getOrCreateCustomer(User $user): array
{ {
// First, try to find existing customer by external_id // NEW 1:1 BINDING LOGIC: Use polar_cust_id for secure user binding
try {
$response = $this->makeAuthenticatedRequest('GET', '/customers', [ // Check if user already has a Polar customer ID stored
'external_id' => (string) $user->id, if ($user->polar_cust_id) {
'limit' => 1, Log::info('User has existing Polar customer ID, using it', [
'user_id' => $user->id,
'polar_cust_id' => $user->polar_cust_id,
]); ]);
try {
$response = $this->makeAuthenticatedRequest('GET', '/customers/'.$user->polar_cust_id);
if ($response->successful()) { if ($response->successful()) {
$data = $response->json(); $customer = $response->json();
if (! empty($data['items'])) { Log::info('Successfully retrieved existing Polar customer', [
Log::info('Found existing Polar customer by external_id', [
'user_id' => $user->id, 'user_id' => $user->id,
'customer_id' => $data['items'][0]['id'], 'customer_id' => $customer['id'],
]); ]);
return $data['items'][0]; return $customer;
} } else {
Log::warning('Stored Polar customer ID not found, will create new one', [
'user_id' => $user->id,
'polar_cust_id' => $user->polar_cust_id,
'status_code' => $response->status(),
]);
// Clear the invalid ID and continue to create new customer
$user->update(['polar_cust_id' => null]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
Log::info('Customer not found by external_id, will create new one', [ Log::warning('Failed to retrieve stored Polar customer, will create new one', [
'user_id' => $user->id, 'user_id' => $user->id,
'polar_cust_id' => $user->polar_cust_id,
'error' => $e->getMessage(),
]); ]);
// Clear the invalid ID and continue to create new customer
$user->update(['polar_cust_id' => null]);
}
} }
// Try to find by email as fallback // No stored Polar customer ID, search by email to find existing customer
Log::info('No stored Polar customer ID, searching by email', [
'user_id' => $user->id,
'email' => $user->email,
]);
try { try {
$response = $this->makeAuthenticatedRequest('GET', '/customers', [ $response = $this->makeAuthenticatedRequest('GET', '/customers', [
'email' => $user->email, 'email' => $user->email,
'limit' => 1, 'limit' => 10,
]); ]);
if ($response->successful()) { if ($response->successful()) {
$data = $response->json(); $data = $response->json();
if (! empty($data['items'])) { if (! empty($data['items'])) {
$customer = $data['items'][0]; // Take the first match
Log::info('Found existing Polar customer by email', [ Log::info('Found existing Polar customer by email', [
'user_id' => $user->id, 'user_id' => $user->id,
'customer_id' => $data['items'][0]['id'], 'customer_id' => $customer['id'],
'customer_email' => $customer['email'],
]); ]);
return $data['items'][0]; // Store the Polar customer ID for future use
$user->update(['polar_cust_id' => $customer['id']]);
return $customer;
} }
} }
} catch (\Exception $e) { } catch (\Exception $e) {
Log::info('Customer not found by email, will create new one', [ Log::info('No existing Polar customer found by email', [
'user_id' => $user->id, 'user_id' => $user->id,
'error' => $e->getMessage(),
]); ]);
} }
// No existing customer found, create new one
Log::info('Creating new Polar customer for user', [
'user_id' => $user->id,
'email' => $user->email,
]);
// Create new customer // Create new customer
$customerData = [ $customerData = [
'email' => $user->email, 'email' => $user->email,
@@ -864,7 +897,38 @@ class PolarProvider implements PaymentProviderContract
'email' => $user->email, 'email' => $user->email,
]); ]);
return $this->findExistingCustomer($user); // With the new 1:1 binding system, this shouldn't happen often
// But if it does, we'll handle it by searching by email again
Log::warning('Customer creation conflict, searching by email as fallback', [
'user_id' => $user->id,
'email' => $user->email,
]);
// Fallback: search by email one more time
try {
$response = $this->makeAuthenticatedRequest('GET', '/customers', [
'email' => $user->email,
'limit' => 10,
]);
if ($response->successful()) {
$data = $response->json();
if (! empty($data['items'])) {
$customer = $data['items'][0];
// Store the found customer ID
$user->update(['polar_cust_id' => $customer['id']]);
return $customer;
}
}
} catch (\Exception $e) {
Log::error('Fallback email search also failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
}
throw new \Exception('Unable to create or find Polar customer account. Please contact support.');
} }
} }
} }
@@ -884,51 +948,18 @@ class PolarProvider implements PaymentProviderContract
throw new \Exception('Invalid response from Polar API: missing customer ID'); throw new \Exception('Invalid response from Polar API: missing customer ID');
} }
Log::info('Created new Polar customer', [ // Store the new Polar customer ID for 1:1 binding
$user->update(['polar_cust_id' => $customer['id']]);
Log::info('Created new Polar customer and stored ID for 1:1 binding', [
'user_id' => $user->id, 'user_id' => $user->id,
'customer_id' => $customer['id'], 'customer_id' => $customer['id'],
'external_id' => $customer['external_id'],
]); ]);
return $customer; return $customer;
} }
protected function findExistingCustomer(User $user): array
{
// Try multiple approaches to find the customer
$attempts = [
fn () => $this->makeAuthenticatedRequest('GET', '/customers', ['external_id' => (string) $user->id, 'limit' => 100]),
fn () => $this->makeAuthenticatedRequest('GET', '/customers', ['email' => $user->email, 'limit' => 100]),
fn () => $this->makeAuthenticatedRequest('GET', '/customers', ['limit' => 1000]),
];
foreach ($attempts as $attempt) {
try {
$response = $attempt();
if ($response->successful()) {
$data = $response->json();
foreach ($data['items'] ?? [] as $customer) {
if (($customer['email'] && strtolower($customer['email']) === strtolower($user->email)) ||
($customer['external_id'] && (string) $customer['external_id'] === (string) $user->id)) {
Log::info('Found existing Polar customer', [
'user_id' => $user->id,
'customer_id' => $customer['id'],
]);
return $customer;
}
}
}
} catch (\Exception $e) {
Log::warning('Customer lookup attempt failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
}
}
throw new \Exception('Customer exists in Polar but could not be retrieved after multiple lookup attempts');
}
protected function getOrCreatePrice(Plan $plan): string protected function getOrCreatePrice(Plan $plan): string
{ {
// Look for existing product by plan metadata // Look for existing product by plan metadata

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Add polar_cust_id column after 'id' for 1:1 Polar customer binding
$table->string('polar_cust_id')->nullable()->after('id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('polar_cust_id');
});
}
};