AI payment process & payment provider files

This commit is contained in:
a6a2f5842 2025-11-26 11:09:52 +01:00
parent 856686e292
commit 929e87bc28
10 changed files with 1497 additions and 127 deletions

View File

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

View File

@ -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,
]);
}
}

View File

@ -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,
]);
}
}

View File

@ -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'));

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

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

View File

@ -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,
],
]); ]);
} }
} }

View File

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

View File

@ -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);
}
}