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:
@@ -30,6 +30,7 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
|
||||
'email',
|
||||
'password',
|
||||
'level',
|
||||
'polar_cust_id',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -786,54 +786,87 @@ class PolarProvider implements PaymentProviderContract
|
||||
// Helper methods
|
||||
protected function getOrCreateCustomer(User $user): array
|
||||
{
|
||||
// First, try to find existing customer by external_id
|
||||
try {
|
||||
$response = $this->makeAuthenticatedRequest('GET', '/customers', [
|
||||
'external_id' => (string) $user->id,
|
||||
'limit' => 1,
|
||||
// NEW 1:1 BINDING LOGIC: Use polar_cust_id for secure user binding
|
||||
|
||||
// Check if user already has a Polar customer ID stored
|
||||
if ($user->polar_cust_id) {
|
||||
Log::info('User has existing Polar customer ID, using it', [
|
||||
'user_id' => $user->id,
|
||||
'polar_cust_id' => $user->polar_cust_id,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
if (! empty($data['items'])) {
|
||||
Log::info('Found existing Polar customer by external_id', [
|
||||
try {
|
||||
$response = $this->makeAuthenticatedRequest('GET', '/customers/'.$user->polar_cust_id);
|
||||
|
||||
if ($response->successful()) {
|
||||
$customer = $response->json();
|
||||
Log::info('Successfully retrieved existing Polar customer', [
|
||||
'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) {
|
||||
Log::warning('Failed to retrieve stored Polar customer, will create new one', [
|
||||
'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]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::info('Customer not found by external_id, will create new one', [
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
$response = $this->makeAuthenticatedRequest('GET', '/customers', [
|
||||
'email' => $user->email,
|
||||
'limit' => 1,
|
||||
'limit' => 10,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
if (! empty($data['items'])) {
|
||||
$customer = $data['items'][0]; // Take the first match
|
||||
Log::info('Found existing Polar customer by email', [
|
||||
'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) {
|
||||
Log::info('Customer not found by email, will create new one', [
|
||||
Log::info('No existing Polar customer found by email', [
|
||||
'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
|
||||
$customerData = [
|
||||
'email' => $user->email,
|
||||
@@ -864,7 +897,38 @@ class PolarProvider implements PaymentProviderContract
|
||||
'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');
|
||||
}
|
||||
|
||||
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,
|
||||
'customer_id' => $customer['id'],
|
||||
'external_id' => $customer['external_id'],
|
||||
]);
|
||||
|
||||
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
|
||||
{
|
||||
// Look for existing product by plan metadata
|
||||
|
||||
Reference in New Issue
Block a user