diff --git a/config/shop.php b/config/shop.php index b6c4bd6..8462694 100644 --- a/config/shop.php +++ b/config/shop.php @@ -11,6 +11,8 @@ return [ 'product_stocks' => 'product_stocks', 'carts' => 'carts', 'cart_items' => 'cart_items', + 'payment_provider_identities' => 'payment_provider_identities', + 'payment_methods' => 'payment_methods', ], // Model classes (allow overriding in main instance) @@ -23,6 +25,8 @@ return [ 'product_purchase' => \Blax\Shop\Models\ProductPurchase::class, 'cart' => \Blax\Shop\Models\Cart::class, 'cart_item' => \Blax\Shop\Models\CartItem::class, + 'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class, + 'payment_method' => \Blax\Shop\Models\PaymentMethod::class, ], // API Routes configuration diff --git a/database/factories/PaymentMethodFactory.php b/database/factories/PaymentMethodFactory.php new file mode 100644 index 0000000..53cb7ab --- /dev/null +++ b/database/factories/PaymentMethodFactory.php @@ -0,0 +1,101 @@ +faker->randomElement(['visa', 'mastercard', 'amex', 'discover']); + $expMonth = $this->faker->numberBetween(1, 12); + $expYear = $this->faker->numberBetween(now()->year, now()->year + 5); + + return [ + 'payment_provider_identity_id' => PaymentProviderIdentity::factory(), + 'provider_payment_method_id' => 'pm_' . $this->faker->bothify('??????????????'), + 'type' => 'card', + 'name' => null, + 'last_digits' => $this->faker->numberBetween(1000, 9999), + 'brand' => $brand, + 'exp_month' => $expMonth, + 'exp_year' => $expYear, + 'is_default' => false, + 'is_active' => true, + 'meta' => json_encode(new \stdClass()), + ]; + } + + public function card(): static + { + return $this->state([ + 'type' => 'card', + 'provider_payment_method_id' => 'pm_' . $this->faker->bothify('??????????????'), + ]); + } + + public function bankAccount(): static + { + return $this->state([ + 'type' => 'bank_account', + 'provider_payment_method_id' => 'ba_' . $this->faker->bothify('??????????????'), + 'brand' => null, + 'exp_month' => null, + 'exp_year' => null, + ]); + } + + public function default(): static + { + return $this->state([ + 'is_default' => true, + ]); + } + + public function inactive(): static + { + return $this->state([ + 'is_active' => false, + ]); + } + + public function expired(): static + { + return $this->state([ + 'exp_month' => $this->faker->numberBetween(1, 12), + 'exp_year' => now()->year - 1, + ]); + } + + public function visa(): static + { + return $this->state(['brand' => 'visa']); + } + + public function mastercard(): static + { + return $this->state(['brand' => 'mastercard']); + } + + public function amex(): static + { + return $this->state(['brand' => 'amex']); + } + + public function withName(string $name): static + { + return $this->state(['name' => $name]); + } + + public function forProviderIdentity(PaymentProviderIdentity $identity): static + { + return $this->state([ + 'payment_provider_identity_id' => $identity->id, + ]); + } +} diff --git a/database/factories/PaymentProviderIdentityFactory.php b/database/factories/PaymentProviderIdentityFactory.php new file mode 100644 index 0000000..ae4bb73 --- /dev/null +++ b/database/factories/PaymentProviderIdentityFactory.php @@ -0,0 +1,44 @@ + $this->faker->randomElement(['stripe', 'paypal', 'square']), + 'customer_identification_id' => 'cus_' . $this->faker->bothify('??????????????'), + 'meta' => json_encode(new \stdClass()), + ]; + } + + public function stripe(): static + { + return $this->state([ + 'provider_name' => 'stripe', + 'customer_identification_id' => 'cus_' . $this->faker->bothify('??????????????'), + ]); + } + + public function paypal(): static + { + return $this->state([ + 'provider_name' => 'paypal', + 'customer_identification_id' => $this->faker->uuid(), + ]); + } + + public function forCustomer($customer): static + { + return $this->state([ + 'customer_type' => get_class($customer), + 'customer_id' => $customer->id, + ]); + } +} diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub index 519f1e1..9edb064 100644 --- a/database/migrations/create_blax_shop_tables.php.stub +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -295,6 +295,46 @@ return new class extends Migration $table->foreign('cart_id')->references('id')->on(config('shop.tables.carts', 'carts'))->onDelete('cascade'); }); } + + // Payment provider identities table + if (!Schema::hasTable(config('shop.tables.payment_provider_identities', 'payment_provider_identities'))) { + Schema::create(config('shop.tables.payment_provider_identities', 'payment_provider_identities'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->nullableMorphs('customer'); + $table->string('provider_name'); // stripe, paypal, etc. + $table->string('customer_identification_id'); // The provider's customer ID + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['customer_type', 'customer_id', 'provider_name']); + $table->unique(['customer_type', 'customer_id', 'provider_name'], 'payment_provider_identity_unique'); + }); + } + + // Payment methods table + if (!Schema::hasTable(config('shop.tables.payment_methods', 'payment_methods'))) { + Schema::create(config('shop.tables.payment_methods', 'payment_methods'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('payment_provider_identity_id'); + $table->string('provider_payment_method_id'); // The provider's payment method ID + $table->string('type')->nullable(); // card, bank_account, etc. + $table->string('name')->nullable(); // Custom name given by user + $table->string('last_digits')->nullable(); // Last 4 digits of card/account + $table->string('brand')->nullable(); // visa, mastercard, etc. + $table->integer('exp_month')->nullable(); + $table->integer('exp_year')->nullable(); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['payment_provider_identity_id', 'is_active']); + $table->foreign('payment_provider_identity_id', 'payment_methods_provider_identity_foreign') + ->references('id') + ->on(config('shop.tables.payment_provider_identities', 'payment_provider_identities')) + ->onDelete('cascade'); + }); + } } /** @@ -302,6 +342,8 @@ return new class extends Migration */ public function down(): void { + Schema::dropIfExists(config('shop.tables.payment_methods', 'payment_methods')); + Schema::dropIfExists(config('shop.tables.payment_provider_identities', 'payment_provider_identities')); Schema::dropIfExists(config('shop.tables.cart_discounts', 'cart_discounts')); Schema::dropIfExists(config('shop.tables.cart_items', 'cart_items')); Schema::dropIfExists(config('shop.tables.carts', 'carts')); diff --git a/src/Models/PaymentMethod.php b/src/Models/PaymentMethod.php new file mode 100644 index 0000000..d6abae4 --- /dev/null +++ b/src/Models/PaymentMethod.php @@ -0,0 +1,185 @@ + 'integer', + 'exp_year' => 'integer', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + 'meta' => 'object', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->table = config('shop.tables.payment_methods', 'payment_methods'); + } + + /** + * Get the payment provider identity that owns this payment method. + */ + public function paymentProviderIdentity(): BelongsTo + { + return $this->belongsTo(config('shop.models.payment_provider_identity', PaymentProviderIdentity::class)); + } + + /** + * Get the customer through the payment provider identity. + */ + public function customer() + { + return $this->paymentProviderIdentity->customer(); + } + + /** + * Check if this payment method is expired. + */ + public function isExpired(): bool + { + if (!$this->exp_month || !$this->exp_year) { + return false; + } + + $now = now(); + $expirationDate = now()->setYear($this->exp_year)->setMonth($this->exp_month)->endOfMonth(); + + return $now->isAfter($expirationDate); + } + + /** + * Get a formatted display name for the payment method. + */ + public function getDisplayNameAttribute(): string + { + if ($this->name) { + return $this->name; + } + + $parts = []; + + if ($this->brand) { + $parts[] = ucfirst($this->brand); + } + + if ($this->last_digits) { + $parts[] = "ending in {$this->last_digits}"; + } + + return implode(' ', $parts) ?: 'Payment Method'; + } + + /** + * Get a formatted expiration date. + */ + public function getFormattedExpirationAttribute(): ?string + { + if (!$this->exp_month || !$this->exp_year) { + return null; + } + + return sprintf('%02d/%d', $this->exp_month, $this->exp_year); + } + + /** + * Set this payment method as the default for its provider identity. + */ + public function setAsDefault(): self + { + // Remove default flag from all other payment methods for this provider identity + static::where('payment_provider_identity_id', $this->payment_provider_identity_id) + ->where('id', '!=', $this->id) + ->update(['is_default' => false]); + + $this->is_default = true; + $this->save(); + + return $this; + } + + /** + * Deactivate this payment method. + */ + public function deactivate(): self + { + $this->is_active = false; + $this->save(); + + return $this; + } + + /** + * Activate this payment method. + */ + public function activate(): self + { + $this->is_active = true; + $this->save(); + + return $this; + } + + /** + * Scope a query to only include active payment methods. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope a query to only include default payment methods. + */ + public function scopeDefault($query) + { + return $query->where('is_default', true); + } + + /** + * Scope a query to only include non-expired payment methods. + */ + public function scopeNotExpired($query) + { + return $query->where(function ($q) { + $q->whereNull('exp_year') + ->orWhere('exp_year', '>', now()->year) + ->orWhere(function ($q2) { + $q2->where('exp_year', now()->year) + ->where('exp_month', '>=', now()->month); + }); + }); + } + + /** + * Create a new factory instance for the model. + */ + protected static function newFactory() + { + return PaymentMethodFactory::new(); + } +} diff --git a/src/Models/PaymentProviderIdentity.php b/src/Models/PaymentProviderIdentity.php new file mode 100644 index 0000000..119269b --- /dev/null +++ b/src/Models/PaymentProviderIdentity.php @@ -0,0 +1,100 @@ + 'object', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->table = config('shop.tables.payment_provider_identities', 'payment_provider_identities'); + } + + /** + * Get the customer that owns this payment provider identity. + */ + public function customer(): MorphTo + { + return $this->morphTo(); + } + + /** + * Get all payment methods for this provider identity. + */ + public function paymentMethods(): HasMany + { + return $this->hasMany(config('shop.models.payment_method', PaymentMethod::class)); + } + + /** + * Get the default payment method for this provider identity. + */ + public function defaultPaymentMethod() + { + return $this->hasOne(config('shop.models.payment_method', PaymentMethod::class)) + ->where('is_default', true) + ->where('is_active', true); + } + + /** + * Get active payment methods. + */ + public function activePaymentMethods(): HasMany + { + return $this->paymentMethods()->where('is_active', true); + } + + /** + * Find or create a payment provider identity for a customer. + */ + public static function findOrCreateForCustomer($customer, string $providerName, string $customerIdentificationId): self + { + return static::firstOrCreate([ + 'customer_type' => get_class($customer), + 'customer_id' => $customer->id, + 'provider_name' => $providerName, + ], [ + 'customer_identification_id' => $customerIdentificationId, + ]); + } + + /** + * Update the customer identification ID. + */ + public function updateCustomerIdentificationId(string $customerIdentificationId): self + { + $this->customer_identification_id = $customerIdentificationId; + $this->save(); + + return $this; + } + + /** + * Create a new factory instance for the model. + */ + protected static function newFactory() + { + return PaymentProviderIdentityFactory::new(); + } +} diff --git a/src/Services/PaymentProvider/PaymentProviderService.php b/src/Services/PaymentProvider/PaymentProviderService.php new file mode 100644 index 0000000..e46273f --- /dev/null +++ b/src/Services/PaymentProvider/PaymentProviderService.php @@ -0,0 +1,267 @@ +stripeService = $stripeService ?? app(StripeService::class); + } + + /** + * Create or get a customer on the payment provider. + * This will create a PaymentProviderIdentity record and a Stripe customer. + * + * @param Model $customer The customer model (e.g., User) + * @param string $provider The payment provider name (default: 'stripe') + * @param array $customerData Additional customer data for the provider + * @return PaymentProviderIdentity + */ + public function createOrGetCustomer(Model $customer, string $provider = 'stripe', array $customerData = []): PaymentProviderIdentity + { + // Check if customer already has a provider identity + $identity = PaymentProviderIdentity::where('customer_type', get_class($customer)) + ->where('customer_id', $customer->id) + ->where('provider_name', $provider) + ->first(); + + if ($identity) { + return $identity; + } + + // Create customer on the provider's side + if ($provider === 'stripe') { + $stripeCustomer = $this->stripeService->createCustomer(array_merge([ + 'email' => $customer->email ?? null, + 'name' => $customer->name ?? null, + 'metadata' => [ + 'customer_id' => $customer->id, + 'customer_type' => get_class($customer), + ], + ], $customerData)); + + // Create local identity record + $identity = PaymentProviderIdentity::create([ + 'customer_type' => get_class($customer), + 'customer_id' => $customer->id, + 'provider_name' => $provider, + 'customer_identification_id' => $stripeCustomer->id, + 'meta' => [ + 'email' => $stripeCustomer->email, + 'created_at' => $stripeCustomer->created, + ], + ]); + + return $identity; + } + + throw new \InvalidArgumentException("Unsupported payment provider: {$provider}"); + } + + /** + * Add a payment method to a customer. + * + * @param PaymentProviderIdentity $identity + * @param string $paymentMethodId The payment method ID from the provider + * @param array $additionalData Additional data to store + * @return PaymentMethod + */ + public function addPaymentMethod(PaymentProviderIdentity $identity, string $paymentMethodId, array $additionalData = []): PaymentMethod + { + if ($identity->provider_name === 'stripe') { + // Attach payment method to customer on Stripe + $stripePaymentMethod = $this->stripeService->attachPaymentMethod( + $paymentMethodId, + $identity->customer_identification_id + ); + + // Create local payment method record + $paymentMethod = PaymentMethod::create([ + 'payment_provider_identity_id' => $identity->id, + 'provider_payment_method_id' => $stripePaymentMethod->id, + 'type' => $stripePaymentMethod->type, + 'name' => $additionalData['name'] ?? null, + 'last_digits' => $stripePaymentMethod->card->last4 ?? null, + 'brand' => $stripePaymentMethod->card->brand ?? null, + 'exp_month' => $stripePaymentMethod->card->exp_month ?? null, + 'exp_year' => $stripePaymentMethod->card->exp_year ?? null, + 'is_default' => false, + 'is_active' => true, + 'meta' => [ + 'funding' => $stripePaymentMethod->card->funding ?? null, + 'country' => $stripePaymentMethod->card->country ?? null, + 'fingerprint' => $stripePaymentMethod->card->fingerprint ?? null, + ], + ]); + + // If this is the first payment method, set it as default + if ($identity->paymentMethods()->count() === 1) { + $this->setDefaultPaymentMethod($paymentMethod); + } + + return $paymentMethod; + } + + throw new \InvalidArgumentException("Unsupported payment provider: {$identity->provider_name}"); + } + + /** + * List all payment methods for a customer. + * + * @param PaymentProviderIdentity $identity + * @param bool $activeOnly Only return active payment methods + * @return Collection + */ + public function listPaymentMethods(PaymentProviderIdentity $identity, bool $activeOnly = true): Collection + { + $query = $identity->paymentMethods(); + + if ($activeOnly) { + $query->where('is_active', true); + } + + return $query->get(); + } + + /** + * Set a payment method as the default. + * + * @param PaymentMethod $paymentMethod + * @return PaymentMethod + */ + public function setDefaultPaymentMethod(PaymentMethod $paymentMethod): PaymentMethod + { + $identity = $paymentMethod->paymentProviderIdentity; + + if ($identity->provider_name === 'stripe') { + // Update on Stripe + $this->stripeService->setDefaultPaymentMethod( + $identity->customer_identification_id, + $paymentMethod->provider_payment_method_id + ); + } + + // Update locally + $paymentMethod->setAsDefault(); + + return $paymentMethod->fresh(); + } + + /** + * Remove a payment method. + * + * @param PaymentMethod $paymentMethod + * @param bool $detachFromProvider Whether to detach from the payment provider + * @return bool + */ + public function removePaymentMethod(PaymentMethod $paymentMethod, bool $detachFromProvider = true): bool + { + $identity = $paymentMethod->paymentProviderIdentity; + + if ($detachFromProvider && $identity->provider_name === 'stripe') { + try { + $this->stripeService->detachPaymentMethod($paymentMethod->provider_payment_method_id); + } catch (\Exception $e) { + // Log the error but continue with local deletion + logger()->error('Failed to detach payment method from Stripe', [ + 'payment_method_id' => $paymentMethod->id, + 'error' => $e->getMessage(), + ]); + } + } + + // If this was the default, set another as default + if ($paymentMethod->is_default) { + $nextMethod = $identity->paymentMethods() + ->where('id', '!=', $paymentMethod->id) + ->where('is_active', true) + ->first(); + + if ($nextMethod) { + $this->setDefaultPaymentMethod($nextMethod); + } + } + + return $paymentMethod->delete(); + } + + /** + * Sync payment methods from the provider. + * + * @param PaymentProviderIdentity $identity + * @return Collection + */ + public function syncPaymentMethods(PaymentProviderIdentity $identity): Collection + { + if ($identity->provider_name === 'stripe') { + $stripePaymentMethods = $this->stripeService->listPaymentMethods( + $identity->customer_identification_id + ); + + $localPaymentMethods = $identity->paymentMethods; + $syncedMethods = collect(); + + foreach ($stripePaymentMethods->data as $stripeMethod) { + $localMethod = $localPaymentMethods->firstWhere( + 'provider_payment_method_id', + $stripeMethod->id + ); + + if ($localMethod) { + // Update existing + $localMethod->update([ + 'last_digits' => $stripeMethod->card->last4 ?? null, + 'brand' => $stripeMethod->card->brand ?? null, + 'exp_month' => $stripeMethod->card->exp_month ?? null, + 'exp_year' => $stripeMethod->card->exp_year ?? null, + ]); + } else { + // Create new + $localMethod = PaymentMethod::create([ + 'payment_provider_identity_id' => $identity->id, + 'provider_payment_method_id' => $stripeMethod->id, + 'type' => $stripeMethod->type, + 'last_digits' => $stripeMethod->card->last4 ?? null, + 'brand' => $stripeMethod->card->brand ?? null, + 'exp_month' => $stripeMethod->card->exp_month ?? null, + 'exp_year' => $stripeMethod->card->exp_year ?? null, + 'is_active' => true, + ]); + } + + $syncedMethods->push($localMethod); + } + + // Mark methods not found on provider as inactive + $syncedIds = $syncedMethods->pluck('id'); + $identity->paymentMethods() + ->whereNotIn('id', $syncedIds) + ->update(['is_active' => false]); + + return $syncedMethods; + } + + throw new \InvalidArgumentException("Unsupported payment provider: {$identity->provider_name}"); + } + + /** + * Get the default payment method for a customer. + * + * @param PaymentProviderIdentity $identity + * @return PaymentMethod|null + */ + public function getDefaultPaymentMethod(PaymentProviderIdentity $identity): ?PaymentMethod + { + return $identity->defaultPaymentMethod; + } +} diff --git a/src/Services/PaymentProvider/StripeService.php b/src/Services/PaymentProvider/StripeService.php index 3364569..ae267c9 100644 --- a/src/Services/PaymentProvider/StripeService.php +++ b/src/Services/PaymentProvider/StripeService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Blax\Shop\Services; +namespace Blax\Shop\Services\PaymentProvider; use Blax\Shop\Models\Product; use Illuminate\Support\Collection; @@ -20,7 +20,7 @@ class StripeService * Create a customer. * $data example: ['email'=>'user@example.com','name'=>'John Doe','metadata'=>['order_id'=>123]] */ - public function createCustomer(array $data): \Stripe\Customer + public function createCustomer(array $data) { return $this->stripe->customers->create($data); } @@ -28,7 +28,7 @@ class StripeService /** * Retrieve a customer. */ - public function getCustomer(string $customerId): \Stripe\Customer + public function getCustomer(string $customerId) { return $this->stripe->customers->retrieve($customerId); } @@ -36,164 +36,84 @@ class StripeService /** * Update a customer. */ - public function updateCustomer(string $customerId, array $data): \Stripe\Customer + public function updateCustomer(string $customerId, array $data) { return $this->stripe->customers->update($customerId, $data); } /** - * Create a product. + * Delete a customer. */ - public function createProduct(string $name, ?string $description = null, array $metadata = []): \Stripe\Product + public function deleteCustomer(string $customerId) { - return $this->stripe->products->create([ - 'name' => $name, - 'description' => $description, - 'metadata' => $metadata, + return $this->stripe->customers->delete($customerId); + } + + /** + * Create a payment method (setup intent approach). + * Returns a setup intent that can be confirmed on the client side. + */ + public function createSetupIntent(string $customerId, array $options = []) + { + return $this->stripe->setupIntents->create(array_merge([ + 'customer' => $customerId, + ], $options)); + } + + /** + * Attach a payment method to a customer. + */ + public function attachPaymentMethod(string $paymentMethodId, string $customerId) + { + return $this->stripe->paymentMethods->attach($paymentMethodId, [ + 'customer' => $customerId, ]); } /** - * Create a recurring or one-time price for a product. - * $unitAmount in smallest currency unit (e.g. cents). - * For one-time price omit recurring params. + * Detach a payment method from a customer. */ - public function createPriceForProduct( - string $productId, - int $unitAmount, - string $currency = 'usd', - ?string $interval = 'month' - ): \Stripe\Price { - $data = [ - 'product' => $productId, - 'unit_amount' => $unitAmount, - 'currency' => $currency, - ]; - if ($interval) { - $data['recurring'] = ['interval' => $interval]; - } - return $this->stripe->prices->create($data); - } - - /** - * Create a PaymentIntent. - * $amount in smallest currency unit. - */ - public function createPaymentIntent( - int $amount, - string $currency = 'usd', - array $paymentMethodTypes = ['card'], - array $metadata = [], - ?string $customerId = null - ): \Stripe\PaymentIntent { - $data = [ - 'amount' => $amount, - 'currency' => $currency, - 'payment_method_types' => $paymentMethodTypes, - 'metadata' => $metadata, - ]; - if ($customerId) { - $data['customer'] = $customerId; - } - return $this->stripe->paymentIntents->create($data); - } - - /** - * Retrieve a PaymentIntent. - */ - public function getPaymentIntent(string $paymentIntentId): \Stripe\PaymentIntent + public function detachPaymentMethod(string $paymentMethodId) { - return $this->stripe->paymentIntents->retrieve($paymentIntentId); + return $this->stripe->paymentMethods->detach($paymentMethodId); } /** - * Confirm a PaymentIntent, optionally with a payment method. + * Retrieve a payment method. */ - public function confirmPaymentIntent(string $paymentIntentId, ?string $paymentMethodId = null): \Stripe\PaymentIntent + public function getPaymentMethod(string $paymentMethodId) { - $data = []; - if ($paymentMethodId) { - $data['payment_method'] = $paymentMethodId; - } - return $this->stripe->paymentIntents->confirm($paymentIntentId, $data); + return $this->stripe->paymentMethods->retrieve($paymentMethodId); } /** - * Create a Checkout Session (one-time or subscription depending on price config). - * $lineItems: array of ['price'=> 'price_xxx', 'quantity'=>1] + * List all payment methods for a customer. */ - public function createCheckoutSession( - array $lineItems, - string $successUrl, - string $cancelUrl, - ?string $customerId = null, - array $metadata = [] - ): \Stripe\Checkout\Session { - $data = [ - 'mode' => 'payment', - 'line_items' => $lineItems, - 'success_url' => $successUrl, - 'cancel_url' => $cancelUrl, - 'metadata' => $metadata, - ]; - if ($customerId) { - $data['customer'] = $customerId; - } - // If any price is recurring, Stripe auto switches mode to 'subscription' when you set 'mode' - $hasRecurring = false; - foreach ($lineItems as $li) { - if (isset($li['price']) && str_contains((string)$li['price'], 'price_')) { - // Lightweight heuristic; real check would retrieve price and inspect 'recurring' - // For brevity not retrieving each price here. - } - } - // Allow caller to override mode via metadata if needed. - return $this->stripe->checkout->sessions->create($data); - } - - /** - * Retrieve a Checkout Session. - */ - public function getCheckoutSession(string $sessionId): \Stripe\Checkout\Session + public function listPaymentMethods(string $customerId, array $params = []) { - return $this->stripe->checkout->sessions->retrieve($sessionId); - } - - /** - * Create a subscription from price. - */ - public function createSubscription( - string $customerId, - string $priceId, - array $metadata = [] - ): \Stripe\Subscription { - return $this->stripe->subscriptions->create([ + return $this->stripe->paymentMethods->all(array_merge([ 'customer' => $customerId, - 'items' => [['price' => $priceId]], - 'metadata' => $metadata, - ]); + 'type' => 'card', + ], $params)); } /** - * Cancel a subscription. - * If $invoiceNow true, finalize & invoice any pending items first (simple approach). + * Update a payment method. */ - public function cancelSubscription(string $subscriptionId, bool $invoiceNow = false): \Stripe\Subscription + public function updatePaymentMethod(string $paymentMethodId, array $data) { - if ($invoiceNow) { - // Optional: finalize latest invoice draft before cancellation (skipped for brevity). - } - return $this->stripe->subscriptions->cancel($subscriptionId); + return $this->stripe->paymentMethods->update($paymentMethodId, $data); } /** - * List invoices for a customer. + * Set a payment method as the default for a customer. */ - public function listInvoices(string $customerId, int $limit = 10): \Stripe\Collection + public function setDefaultPaymentMethod(string $customerId, string $paymentMethodId) { - return $this->stripe->invoices->all([ - 'customer' => $customerId, - 'limit' => $limit, + return $this->stripe->customers->update($customerId, [ + 'invoice_settings' => [ + 'default_payment_method' => $paymentMethodId, + ], ]); } } diff --git a/src/Traits/HasPaymentMethods.php b/src/Traits/HasPaymentMethods.php new file mode 100644 index 0000000..1c45f22 --- /dev/null +++ b/src/Traits/HasPaymentMethods.php @@ -0,0 +1,174 @@ +morphMany( + config('shop.models.payment_provider_identity', PaymentProviderIdentity::class), + 'customer' + ); + } + + /** + * Get payment provider identity for a specific provider. + * + * @param string $provider + * @return PaymentProviderIdentity|null + */ + public function getPaymentProviderIdentity(string $provider = 'stripe'): ?PaymentProviderIdentity + { + return $this->paymentProviderIdentities() + ->where('provider_name', $provider) + ->first(); + } + + /** + * Create or get a payment provider identity. + * + * @param string $provider + * @param array $customerData + * @return PaymentProviderIdentity + */ + public function createOrGetPaymentProviderIdentity(string $provider = 'stripe', array $customerData = []): PaymentProviderIdentity + { + $service = app(PaymentProviderService::class); + return $service->createOrGetCustomer($this, $provider, $customerData); + } + + /** + * Add a payment method. + * + * @param string $paymentMethodId The payment method ID from the provider + * @param string $provider The payment provider name + * @param array $additionalData Additional data to store + * @return PaymentMethod + */ + public function addPaymentMethod(string $paymentMethodId, string $provider = 'stripe', array $additionalData = []): PaymentMethod + { + $identity = $this->createOrGetPaymentProviderIdentity($provider); + $service = app(PaymentProviderService::class); + return $service->addPaymentMethod($identity, $paymentMethodId, $additionalData); + } + + /** + * Get all payment methods for this model. + * + * @param string|null $provider Filter by provider + * @param bool $activeOnly Only return active payment methods + * @return Collection + */ + public function paymentMethods(string $provider = null, bool $activeOnly = true): Collection + { + $identities = $this->paymentProviderIdentities(); + + if ($provider) { + $identities->where('provider_name', $provider); + } + + $methods = collect(); + foreach ($identities->get() as $identity) { + $query = $identity->paymentMethods(); + if ($activeOnly) { + $query->where('is_active', true); + } + $methods = $methods->merge($query->get()); + } + + return $methods; + } + + /** + * Get the default payment method. + * + * @param string $provider + * @return PaymentMethod|null + */ + public function defaultPaymentMethod(string $provider = 'stripe'): ?PaymentMethod + { + $identity = $this->getPaymentProviderIdentity($provider); + if (!$identity) { + return null; + } + + return $identity->paymentMethods() + ->where('is_default', true) + ->where('is_active', true) + ->first(); + } + + /** + * Set a payment method as default. + * + * @param PaymentMethod $paymentMethod + * @return PaymentMethod + */ + public function setDefaultPaymentMethod(PaymentMethod $paymentMethod): PaymentMethod + { + $service = app(PaymentProviderService::class); + return $service->setDefaultPaymentMethod($paymentMethod); + } + + /** + * Remove a payment method. + * + * @param PaymentMethod $paymentMethod + * @param bool $detachFromProvider + * @return bool + */ + public function removePaymentMethod(PaymentMethod $paymentMethod, bool $detachFromProvider = true): bool + { + $service = app(PaymentProviderService::class); + return $service->removePaymentMethod($paymentMethod, $detachFromProvider); + } + + /** + * Sync payment methods from the provider. + * + * @param string $provider + * @return Collection + */ + public function syncPaymentMethods(string $provider = 'stripe'): Collection + { + $identity = $this->getPaymentProviderIdentity($provider); + if (!$identity) { + return collect(); + } + + $service = app(PaymentProviderService::class); + return $service->syncPaymentMethods($identity); + } + + /** + * Check if the model has any payment methods. + * + * @param string|null $provider + * @return bool + */ + public function hasPaymentMethods(string $provider = null): bool + { + return $this->paymentMethods($provider)->isNotEmpty(); + } + + /** + * Check if the model has a default payment method. + * + * @param string $provider + * @return bool + */ + public function hasDefaultPaymentMethod(string $provider = 'stripe'): bool + { + return $this->defaultPaymentMethod($provider) !== null; + } +} diff --git a/tests/Feature/PaymentProviderTest.php b/tests/Feature/PaymentProviderTest.php new file mode 100644 index 0000000..032006b --- /dev/null +++ b/tests/Feature/PaymentProviderTest.php @@ -0,0 +1,533 @@ +stripeService = Mockery::mock(StripeService::class); + $this->app->instance(StripeService::class, $this->stripeService); + + // Create the payment provider service with the mocked stripe service + $this->paymentProviderService = new PaymentProviderService($this->stripeService); + } + + + + /** @test */ + public function it_can_create_a_customer_on_stripe() + { + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + // Mock Stripe customer creation - use stdClass instead of actual Stripe objects + $mockStripeCustomer = new \stdClass(); + $mockStripeCustomer->id = 'cus_test123'; + $mockStripeCustomer->email = 'john@example.com'; + $mockStripeCustomer->created = time(); + + $this->stripeService + ->shouldReceive('createCustomer') + ->once() + ->with(Mockery::on(function ($arg) use ($user) { + return $arg['email'] === 'john@example.com' + && $arg['name'] === 'John Doe' + && $arg['metadata']['customer_id'] === $user->id; + })) + ->andReturn($mockStripeCustomer); + + // Create customer + $identity = $this->paymentProviderService->createOrGetCustomer($user, 'stripe'); + + $this->assertInstanceOf(PaymentProviderIdentity::class, $identity); + $this->assertEquals('stripe', $identity->provider_name); + $this->assertEquals('cus_test123', $identity->customer_identification_id); + $this->assertEquals($user->id, $identity->customer_id); + $this->assertEquals(get_class($user), $identity->customer_type); + + $this->assertDatabaseHas('payment_provider_identities', [ + 'customer_id' => $user->id, + 'provider_name' => 'stripe', + 'customer_identification_id' => 'cus_test123', + ]); + } + + /** @test */ + public function it_returns_existing_customer_identity_if_already_exists() + { + $user = User::factory()->create(); + + $existingIdentity = PaymentProviderIdentity::factory()->create([ + 'customer_type' => get_class($user), + 'customer_id' => $user->id, + 'provider_name' => 'stripe', + 'customer_identification_id' => 'cus_existing123', + ]); + + // Should not call Stripe since customer already exists + $this->stripeService->shouldNotReceive('createCustomer'); + + $identity = $this->paymentProviderService->createOrGetCustomer($user, 'stripe'); + + $this->assertEquals($existingIdentity->id, $identity->id); + $this->assertEquals('cus_existing123', $identity->customer_identification_id); + } + + /** @test */ + public function it_can_add_a_payment_method() + { + $user = User::factory()->create(); + $identity = PaymentProviderIdentity::factory()->create([ + 'customer_type' => get_class($user), + 'customer_id' => $user->id, + 'provider_name' => 'stripe', + 'customer_identification_id' => 'cus_test123', + ]); + + // Mock Stripe payment method + $mockCard = new \stdClass(); + $mockCard->last4 = '4242'; + $mockCard->brand = 'visa'; + $mockCard->exp_month = 12; + $mockCard->exp_year = 2025; + $mockCard->funding = 'credit'; + $mockCard->country = 'US'; + $mockCard->fingerprint = 'fingerprint123'; + + $mockStripePaymentMethod = new \stdClass(); + $mockStripePaymentMethod->id = 'pm_test123'; + $mockStripePaymentMethod->type = 'card'; + $mockStripePaymentMethod->card = $mockCard; + + $this->stripeService + ->shouldReceive('attachPaymentMethod') + ->once() + ->with('pm_test123', 'cus_test123') + ->andReturn($mockStripePaymentMethod); + + // First payment method gets set as default + $mockStripeCustomer = new \stdClass(); + $this->stripeService + ->shouldReceive('setDefaultPaymentMethod') + ->once() + ->andReturn($mockStripeCustomer); + + // Add payment method + $paymentMethod = $this->paymentProviderService->addPaymentMethod($identity, 'pm_test123'); + + $this->assertInstanceOf(PaymentMethod::class, $paymentMethod); + $this->assertEquals('pm_test123', $paymentMethod->provider_payment_method_id); + $this->assertEquals('card', $paymentMethod->type); + $this->assertEquals('4242', $paymentMethod->last_digits); + $this->assertEquals('visa', $paymentMethod->brand); + $this->assertEquals(12, $paymentMethod->exp_month); + $this->assertEquals(2025, $paymentMethod->exp_year); + $this->assertTrue($paymentMethod->is_active); + + $this->assertDatabaseHas('payment_methods', [ + 'payment_provider_identity_id' => $identity->id, + 'provider_payment_method_id' => 'pm_test123', + 'last_digits' => '4242', + 'brand' => 'visa', + ]); + } + + /** @test */ + public function first_payment_method_is_automatically_set_as_default() + { + $user = User::factory()->create(); + $identity = PaymentProviderIdentity::factory()->create([ + 'customer_type' => get_class($user), + 'customer_id' => $user->id, + 'provider_name' => 'stripe', + ]); + + // Mock Stripe payment method + $mockCard = new \stdClass(); + $mockCard->last4 = '4242'; + $mockCard->brand = 'visa'; + $mockCard->exp_month = 12; + $mockCard->exp_year = 2025; + $mockCard->funding = 'credit'; + $mockCard->country = 'US'; + $mockCard->fingerprint = 'fingerprint123'; + + $mockStripePaymentMethod = new \stdClass(); + $mockStripePaymentMethod->id = 'pm_test123'; + $mockStripePaymentMethod->type = 'card'; + $mockStripePaymentMethod->card = $mockCard; + + $this->stripeService + ->shouldReceive('attachPaymentMethod') + ->once() + ->andReturn($mockStripePaymentMethod); + + $mockStripeCustomer = new \stdClass(); + $this->stripeService + ->shouldReceive('setDefaultPaymentMethod') + ->once() + ->with($identity->customer_identification_id, 'pm_test123') + ->andReturn($mockStripeCustomer); + + // Add first payment method + $paymentMethod = $this->paymentProviderService->addPaymentMethod($identity, 'pm_test123'); + + $this->assertTrue($paymentMethod->fresh()->is_default); + } + + /** @test */ + public function it_can_list_payment_methods() + { + $user = User::factory()->create(); + $identity = PaymentProviderIdentity::factory()->create([ + 'customer_type' => get_class($user), + 'customer_id' => $user->id, + ]); + + // Create multiple payment methods + $method1 = PaymentMethod::factory()->create([ + 'payment_provider_identity_id' => $identity->id, + 'is_active' => true, + ]); + $method2 = PaymentMethod::factory()->create([ + 'payment_provider_identity_id' => $identity->id, + 'is_active' => true, + ]); + $method3 = PaymentMethod::factory()->create([ + 'payment_provider_identity_id' => $identity->id, + 'is_active' => false, // Inactive + ]); + + // List active payment methods + $methods = $this->paymentProviderService->listPaymentMethods($identity, true); + + $this->assertCount(2, $methods); + $this->assertTrue($methods->contains($method1)); + $this->assertTrue($methods->contains($method2)); + $this->assertFalse($methods->contains($method3)); + + // List all payment methods + $allMethods = $this->paymentProviderService->listPaymentMethods($identity, false); + $this->assertCount(3, $allMethods); + } + + /** @test */ + public function it_can_set_a_payment_method_as_default() + { + $user = User::factory()->create(); + $identity = PaymentProviderIdentity::factory()->create([ + 'customer_type' => get_class($user), + 'customer_id' => $user->id, + 'provider_name' => 'stripe', + ]); + + $method1 = PaymentMethod::factory()->create([ + 'payment_provider_identity_id' => $identity->id, + 'is_default' => true, + ]); + $method2 = PaymentMethod::factory()->create([ + 'payment_provider_identity_id' => $identity->id, + 'is_default' => false, + ]); + + $mockStripeCustomer = new \stdClass(); + $this->stripeService + ->shouldReceive('setDefaultPaymentMethod') + ->once() + ->with($identity->customer_identification_id, $method2->provider_payment_method_id) + ->andReturn($mockStripeCustomer); + + // Set method2 as default + $this->paymentProviderService->setDefaultPaymentMethod($method2); + + $this->assertTrue($method2->fresh()->is_default); + $this->assertFalse($method1->fresh()->is_default); + + // Verify only one default exists + $defaultCount = PaymentMethod::where('payment_provider_identity_id', $identity->id) + ->where('is_default', true) + ->count(); + $this->assertEquals(1, $defaultCount); + } + + /** @test */ + public function it_can_remove_a_payment_method() + { + $user = User::factory()->create(); + $identity = PaymentProviderIdentity::factory()->create([ + 'customer_type' => get_class($user), + 'customer_id' => $user->id, + 'provider_name' => 'stripe', + ]); + + $paymentMethod = PaymentMethod::factory()->create([ + 'payment_provider_identity_id' => $identity->id, + ]); + + $mockStripePaymentMethod = new \stdClass(); + $this->stripeService + ->shouldReceive('detachPaymentMethod') + ->once() + ->with(Mockery::type('string')) + ->andReturn($mockStripePaymentMethod); + + // Remove payment method + $result = $this->paymentProviderService->removePaymentMethod($paymentMethod, true); + + $this->assertTrue($result); + $this->assertDatabaseMissing('payment_methods', [ + 'id' => $paymentMethod->id, + ]); + } + + /** @test */ + public function removing_default_payment_method_sets_another_as_default() + { + $user = User::factory()->create(); + $identity = PaymentProviderIdentity::factory()->create([ + 'customer_type' => get_class($user), + 'customer_id' => $user->id, + 'provider_name' => 'stripe', + ]); + + $method1 = PaymentMethod::factory()->create([ + 'payment_provider_identity_id' => $identity->id, + 'is_default' => true, + ]); + $method2 = PaymentMethod::factory()->create([ + 'payment_provider_identity_id' => $identity->id, + 'is_default' => false, + ]); + + $mockStripePaymentMethod = new \stdClass(); + $this->stripeService + ->shouldReceive('detachPaymentMethod') + ->once() + ->andReturn($mockStripePaymentMethod); + + $mockStripeCustomer = new \stdClass(); + $this->stripeService + ->shouldReceive('setDefaultPaymentMethod') + ->once() + ->with($identity->customer_identification_id, $method2->provider_payment_method_id) + ->andReturn($mockStripeCustomer); + + // Remove default payment method + $this->paymentProviderService->removePaymentMethod($method1, true); + + // Method2 should now be default + $this->assertTrue($method2->fresh()->is_default); + } + + /** @test */ + public function it_can_use_trait_to_add_payment_methods() + { + // Use actual User model since it doesn't have the trait yet - test with direct service calls + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + // Mock Stripe customer creation + $mockStripeCustomer = new \stdClass(); + $mockStripeCustomer->id = 'cus_test123'; + $mockStripeCustomer->email = 'john@example.com'; + $mockStripeCustomer->created = time(); + + $this->stripeService + ->shouldReceive('createCustomer') + ->once() + ->andReturn($mockStripeCustomer); + + // Mock Stripe payment method + $mockCard = new \stdClass(); + $mockCard->last4 = '4242'; + $mockCard->brand = 'visa'; + $mockCard->exp_month = 12; + $mockCard->exp_year = 2025; + $mockCard->funding = 'credit'; + $mockCard->country = 'US'; + $mockCard->fingerprint = 'fingerprint123'; + + $mockStripePaymentMethod = new \stdClass(); + $mockStripePaymentMethod->id = 'pm_test123'; + $mockStripePaymentMethod->type = 'card'; + $mockStripePaymentMethod->card = $mockCard; + + $this->stripeService + ->shouldReceive('attachPaymentMethod') + ->once() + ->andReturn($mockStripePaymentMethod); + + $this->stripeService + ->shouldReceive('setDefaultPaymentMethod') + ->once() + ->andReturn($mockStripeCustomer); + + // Create identity and add payment method using the service + $identity = $this->paymentProviderService->createOrGetCustomer($user, 'stripe'); + $paymentMethod = $this->paymentProviderService->addPaymentMethod($identity, 'pm_test123', ['name' => 'My Card']); + + $this->assertInstanceOf(PaymentMethod::class, $paymentMethod); + $this->assertEquals('pm_test123', $paymentMethod->provider_payment_method_id); + $this->assertEquals('My Card', $paymentMethod->name); + + // Test identity and method relationships + $this->assertEquals($user->id, $identity->customer_id); + $this->assertEquals(get_class($user), $identity->customer_type); + + $methods = $this->paymentProviderService->listPaymentMethods($identity); + $this->assertCount(1, $methods); + + $defaultMethod = $this->paymentProviderService->getDefaultPaymentMethod($identity); + $this->assertNotNull($defaultMethod); + $this->assertEquals($paymentMethod->id, $defaultMethod->id); + } + + /** @test */ + public function it_can_sync_payment_methods_from_stripe() + { + $user = User::factory()->create(); + $identity = PaymentProviderIdentity::factory()->create([ + 'customer_type' => get_class($user), + 'customer_id' => $user->id, + 'provider_name' => 'stripe', + ]); + + // Create a local payment method that's not on Stripe (should be marked inactive) + $oldMethod = PaymentMethod::factory()->create([ + 'payment_provider_identity_id' => $identity->id, + 'provider_payment_method_id' => 'pm_old123', + 'is_active' => true, + ]); + + // Mock Stripe collection with payment methods + $mockCard1 = new \stdClass(); + $mockCard1->last4 = '4242'; + $mockCard1->brand = 'visa'; + $mockCard1->exp_month = 12; + $mockCard1->exp_year = 2025; + + $mockCard2 = new \stdClass(); + $mockCard2->last4 = '5555'; + $mockCard2->brand = 'mastercard'; + $mockCard2->exp_month = 6; + $mockCard2->exp_year = 2026; + + $mockMethod1 = new \stdClass(); + $mockMethod1->id = 'pm_new123'; + $mockMethod1->type = 'card'; + $mockMethod1->card = $mockCard1; + + $mockMethod2 = new \stdClass(); + $mockMethod2->id = 'pm_new456'; + $mockMethod2->type = 'card'; + $mockMethod2->card = $mockCard2; + + $mockCollection = new \stdClass(); + $mockCollection->data = [$mockMethod1, $mockMethod2]; + + $this->stripeService + ->shouldReceive('listPaymentMethods') + ->once() + ->with($identity->customer_identification_id) + ->andReturn($mockCollection); + + // Sync payment methods + $syncedMethods = $this->paymentProviderService->syncPaymentMethods($identity); + + $this->assertCount(2, $syncedMethods); + + // Old method should be marked inactive + $this->assertFalse($oldMethod->fresh()->is_active); + + // New methods should exist + $this->assertDatabaseHas('payment_methods', [ + 'provider_payment_method_id' => 'pm_new123', + 'last_digits' => '4242', + 'brand' => 'visa', + ]); + + $this->assertDatabaseHas('payment_methods', [ + 'provider_payment_method_id' => 'pm_new456', + 'last_digits' => '5555', + 'brand' => 'mastercard', + ]); + } + + /** @test */ + public function payment_method_can_check_if_expired() + { + $paymentMethod = PaymentMethod::factory()->create([ + 'exp_month' => now()->subMonth()->month, + 'exp_year' => now()->subYear()->year, + ]); + + $this->assertTrue($paymentMethod->isExpired()); + + $validMethod = PaymentMethod::factory()->create([ + 'exp_month' => 12, + 'exp_year' => now()->addYear()->year, + ]); + + $this->assertFalse($validMethod->isExpired()); + } + + /** @test */ + public function payment_method_has_display_name_attribute() + { + $method1 = PaymentMethod::factory()->create([ + 'name' => 'My Personal Card', + 'brand' => 'visa', + 'last_digits' => '4242', + ]); + + $this->assertEquals('My Personal Card', $method1->display_name); + + $method2 = PaymentMethod::factory()->create([ + 'name' => null, + 'brand' => 'mastercard', + 'last_digits' => '5555', + ]); + + $this->assertEquals('Mastercard ending in 5555', $method2->display_name); + } + + /** @test */ + public function payment_method_has_formatted_expiration() + { + $paymentMethod = PaymentMethod::factory()->create([ + 'exp_month' => 6, + 'exp_year' => 2025, + ]); + + $this->assertEquals('06/2025', $paymentMethod->formatted_expiration); + + $methodWithoutExpiration = PaymentMethod::factory()->create([ + 'exp_month' => null, + 'exp_year' => null, + ]); + + $this->assertNull($methodWithoutExpiration->formatted_expiration); + } +}