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

@@ -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