AI payment process & payment provider files
This commit is contained in:
parent
856686e292
commit
929e87bc28
|
|
@ -11,6 +11,8 @@ return [
|
||||||
'product_stocks' => 'product_stocks',
|
'product_stocks' => 'product_stocks',
|
||||||
'carts' => 'carts',
|
'carts' => 'carts',
|
||||||
'cart_items' => 'cart_items',
|
'cart_items' => 'cart_items',
|
||||||
|
'payment_provider_identities' => 'payment_provider_identities',
|
||||||
|
'payment_methods' => 'payment_methods',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Model classes (allow overriding in main instance)
|
// Model classes (allow overriding in main instance)
|
||||||
|
|
@ -23,6 +25,8 @@ return [
|
||||||
'product_purchase' => \Blax\Shop\Models\ProductPurchase::class,
|
'product_purchase' => \Blax\Shop\Models\ProductPurchase::class,
|
||||||
'cart' => \Blax\Shop\Models\Cart::class,
|
'cart' => \Blax\Shop\Models\Cart::class,
|
||||||
'cart_item' => \Blax\Shop\Models\CartItem::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
|
// API Routes configuration
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Database\Factories;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\PaymentMethod;
|
||||||
|
use Blax\Shop\Models\PaymentProviderIdentity;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class PaymentMethodFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = PaymentMethod::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$brand = $this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Database\Factories;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\PaymentProviderIdentity;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class PaymentProviderIdentityFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = PaymentProviderIdentity::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider_name' => $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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -295,6 +295,46 @@ return new class extends Migration
|
||||||
$table->foreign('cart_id')->references('id')->on(config('shop.tables.carts', 'carts'))->onDelete('cascade');
|
$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
|
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_discounts', 'cart_discounts'));
|
||||||
Schema::dropIfExists(config('shop.tables.cart_items', 'cart_items'));
|
Schema::dropIfExists(config('shop.tables.cart_items', 'cart_items'));
|
||||||
Schema::dropIfExists(config('shop.tables.carts', 'carts'));
|
Schema::dropIfExists(config('shop.tables.carts', 'carts'));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
|
use Blax\Shop\Database\Factories\PaymentMethodFactory;
|
||||||
|
use Blax\Workkit\Traits\HasMeta;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PaymentMethod extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, HasUuids, HasMeta;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'payment_provider_identity_id',
|
||||||
|
'provider_payment_method_id',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'last_digits',
|
||||||
|
'brand',
|
||||||
|
'exp_month',
|
||||||
|
'exp_year',
|
||||||
|
'is_default',
|
||||||
|
'is_active',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'exp_month' => '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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
|
use Blax\Shop\Database\Factories\PaymentProviderIdentityFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
class PaymentProviderIdentity extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'customer_type',
|
||||||
|
'customer_id',
|
||||||
|
'provider_name',
|
||||||
|
'customer_identification_id',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'meta' => '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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,267 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Services\PaymentProvider;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\PaymentMethod;
|
||||||
|
use Blax\Shop\Models\PaymentProviderIdentity;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class PaymentProviderService
|
||||||
|
{
|
||||||
|
protected StripeService $stripeService;
|
||||||
|
|
||||||
|
public function __construct(StripeService $stripeService = null)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Services;
|
namespace Blax\Shop\Services\PaymentProvider;
|
||||||
|
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
@ -20,7 +20,7 @@ class StripeService
|
||||||
* Create a customer.
|
* Create a customer.
|
||||||
* $data example: ['email'=>'user@example.com','name'=>'John Doe','metadata'=>['order_id'=>123]]
|
* $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);
|
return $this->stripe->customers->create($data);
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ class StripeService
|
||||||
/**
|
/**
|
||||||
* Retrieve a customer.
|
* Retrieve a customer.
|
||||||
*/
|
*/
|
||||||
public function getCustomer(string $customerId): \Stripe\Customer
|
public function getCustomer(string $customerId)
|
||||||
{
|
{
|
||||||
return $this->stripe->customers->retrieve($customerId);
|
return $this->stripe->customers->retrieve($customerId);
|
||||||
}
|
}
|
||||||
|
|
@ -36,164 +36,84 @@ class StripeService
|
||||||
/**
|
/**
|
||||||
* Update a customer.
|
* 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);
|
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([
|
return $this->stripe->customers->delete($customerId);
|
||||||
'name' => $name,
|
}
|
||||||
'description' => $description,
|
|
||||||
'metadata' => $metadata,
|
/**
|
||||||
|
* 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.
|
* Detach a payment method from a customer.
|
||||||
* $unitAmount in smallest currency unit (e.g. cents).
|
|
||||||
* For one-time price omit recurring params.
|
|
||||||
*/
|
*/
|
||||||
public function createPriceForProduct(
|
public function detachPaymentMethod(string $paymentMethodId)
|
||||||
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
|
|
||||||
{
|
{
|
||||||
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 = [];
|
return $this->stripe->paymentMethods->retrieve($paymentMethodId);
|
||||||
if ($paymentMethodId) {
|
|
||||||
$data['payment_method'] = $paymentMethodId;
|
|
||||||
}
|
|
||||||
return $this->stripe->paymentIntents->confirm($paymentIntentId, $data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Checkout Session (one-time or subscription depending on price config).
|
* List all payment methods for a customer.
|
||||||
* $lineItems: array of ['price'=> 'price_xxx', 'quantity'=>1]
|
|
||||||
*/
|
*/
|
||||||
public function createCheckoutSession(
|
public function listPaymentMethods(string $customerId, array $params = [])
|
||||||
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
|
|
||||||
{
|
{
|
||||||
return $this->stripe->checkout->sessions->retrieve($sessionId);
|
return $this->stripe->paymentMethods->all(array_merge([
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a subscription from price.
|
|
||||||
*/
|
|
||||||
public function createSubscription(
|
|
||||||
string $customerId,
|
|
||||||
string $priceId,
|
|
||||||
array $metadata = []
|
|
||||||
): \Stripe\Subscription {
|
|
||||||
return $this->stripe->subscriptions->create([
|
|
||||||
'customer' => $customerId,
|
'customer' => $customerId,
|
||||||
'items' => [['price' => $priceId]],
|
'type' => 'card',
|
||||||
'metadata' => $metadata,
|
], $params));
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel a subscription.
|
* Update a payment method.
|
||||||
* If $invoiceNow true, finalize & invoice any pending items first (simple approach).
|
|
||||||
*/
|
*/
|
||||||
public function cancelSubscription(string $subscriptionId, bool $invoiceNow = false): \Stripe\Subscription
|
public function updatePaymentMethod(string $paymentMethodId, array $data)
|
||||||
{
|
{
|
||||||
if ($invoiceNow) {
|
return $this->stripe->paymentMethods->update($paymentMethodId, $data);
|
||||||
// Optional: finalize latest invoice draft before cancellation (skipped for brevity).
|
|
||||||
}
|
|
||||||
return $this->stripe->subscriptions->cancel($subscriptionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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([
|
return $this->stripe->customers->update($customerId, [
|
||||||
'customer' => $customerId,
|
'invoice_settings' => [
|
||||||
'limit' => $limit,
|
'default_payment_method' => $paymentMethodId,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\PaymentMethod;
|
||||||
|
use Blax\Shop\Models\PaymentProviderIdentity;
|
||||||
|
use Blax\Shop\Services\PaymentProvider\PaymentProviderService;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
trait HasPaymentMethods
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get all payment provider identities for this model.
|
||||||
|
*/
|
||||||
|
public function paymentProviderIdentities(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,533 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\PaymentMethod;
|
||||||
|
use Blax\Shop\Models\PaymentProviderIdentity;
|
||||||
|
use Blax\Shop\Services\PaymentProvider\PaymentProviderService;
|
||||||
|
use Blax\Shop\Services\PaymentProvider\StripeService;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
class PaymentProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected StripeService $stripeService;
|
||||||
|
protected PaymentProviderService $paymentProviderService;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Mock the Stripe service
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue