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',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
'level',
|
'level',
|
||||||
|
'polar_cust_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($response->successful()) {
|
try {
|
||||||
$data = $response->json();
|
$response = $this->makeAuthenticatedRequest('GET', '/customers/'.$user->polar_cust_id);
|
||||||
if (! empty($data['items'])) {
|
|
||||||
Log::info('Found existing Polar customer by external_id', [
|
if ($response->successful()) {
|
||||||
|
$customer = $response->json();
|
||||||
|
Log::info('Successfully retrieved existing Polar customer', [
|
||||||
'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) {
|
||||||
|
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 {
|
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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user