A traits, concerns, services

This commit is contained in:
a6a2f5842 2025-11-26 00:05:46 +01:00
parent 82ee18b0f1
commit 856686e292
9 changed files with 398 additions and 238 deletions

View File

@ -21,7 +21,8 @@
"php": ">=8.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/database": "^10.0|^11.0|^12.0",
"blax-software/laravel-workkit": "dev-master|*"
"blax-software/laravel-workkit": "dev-master|*",
"stripe/stripe-php": "^19.0"
},
"require-dev": {
"orchestra/testbench": "^8.0|^9.0",

View File

@ -62,12 +62,6 @@ return [
'prefix' => 'shop:',
],
// Pagination
'pagination' => [
'per_page' => 20,
'max_per_page' => 100,
],
// Cart configuration
'cart' => [
'expire_after_days' => 30,
@ -81,4 +75,5 @@ return [
'wrap_response' => true,
'response_key' => 'data',
],
];

View File

@ -38,14 +38,14 @@ Update `config/shop.php`:
### Sync Individual Product
```php
use Blax\Shop\Services\ShopStripeService;
use Blax\Shop\Services\StripeService;
use Stripe\Product as StripeProduct;
// Get Stripe product
$stripeProduct = StripeProduct::retrieve('prod_xxxxx');
// Sync to local database
$product = ShopStripeService::syncProductDown($stripeProduct);
$product = StripeService::syncProductDown($stripeProduct);
// This creates/updates a Product with:
// - stripe_product_id
@ -61,7 +61,7 @@ $product = ShopStripeService::syncProductDown($stripeProduct);
```php
// Sync all prices for a product
ShopStripeService::syncProductPricesDown($product);
StripeService::syncProductPricesDown($product);
// This creates/updates ProductPrice records with:
// - stripe_price_id
@ -464,7 +464,7 @@ class StripeWebhookController extends Controller
protected function handleProductUpdate($stripeProduct)
{
ShopStripeService::syncProductDown($stripeProduct);
StripeService::syncProductDown($stripeProduct);
}
protected function handlePriceUpdate($stripePrice)
@ -505,7 +505,7 @@ use Stripe\Product as StripeProduct;
Stripe::setApiKey(config('services.stripe.secret'));
Product::whereNotNull('stripe_product_id')->each(function ($product) {
ShopStripeService::syncProductPricesDown($product);
StripeService::syncProductPricesDown($product);
});
```

View File

@ -0,0 +1,10 @@
<?php
namespace Blax\Shop\Contracts;
interface Chargable
{
public function getDefaultPaymentMethod(): ?string;
public function paymentMethods(): array;
}

View File

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Services;
use Blax\Shop\Models\Product;
use Illuminate\Support\Collection;
class StripeService
{
public $stripe;
public function __construct()
{
$this->stripe = new \Stripe\StripeClient(config('shop.payment.stripe.secret_key'));
}
/**
* Create a customer.
* $data example: ['email'=>'user@example.com','name'=>'John Doe','metadata'=>['order_id'=>123]]
*/
public function createCustomer(array $data): \Stripe\Customer
{
return $this->stripe->customers->create($data);
}
/**
* Retrieve a customer.
*/
public function getCustomer(string $customerId): \Stripe\Customer
{
return $this->stripe->customers->retrieve($customerId);
}
/**
* Update a customer.
*/
public function updateCustomer(string $customerId, array $data): \Stripe\Customer
{
return $this->stripe->customers->update($customerId, $data);
}
/**
* Create a product.
*/
public function createProduct(string $name, ?string $description = null, array $metadata = []): \Stripe\Product
{
return $this->stripe->products->create([
'name' => $name,
'description' => $description,
'metadata' => $metadata,
]);
}
/**
* Create a recurring or one-time price for a product.
* $unitAmount in smallest currency unit (e.g. cents).
* For one-time price omit recurring params.
*/
public function createPriceForProduct(
string $productId,
int $unitAmount,
string $currency = 'usd',
?string $interval = 'month'
): \Stripe\Price {
$data = [
'product' => $productId,
'unit_amount' => $unitAmount,
'currency' => $currency,
];
if ($interval) {
$data['recurring'] = ['interval' => $interval];
}
return $this->stripe->prices->create($data);
}
/**
* Create a PaymentIntent.
* $amount in smallest currency unit.
*/
public function createPaymentIntent(
int $amount,
string $currency = 'usd',
array $paymentMethodTypes = ['card'],
array $metadata = [],
?string $customerId = null
): \Stripe\PaymentIntent {
$data = [
'amount' => $amount,
'currency' => $currency,
'payment_method_types' => $paymentMethodTypes,
'metadata' => $metadata,
];
if ($customerId) {
$data['customer'] = $customerId;
}
return $this->stripe->paymentIntents->create($data);
}
/**
* Retrieve a PaymentIntent.
*/
public function getPaymentIntent(string $paymentIntentId): \Stripe\PaymentIntent
{
return $this->stripe->paymentIntents->retrieve($paymentIntentId);
}
/**
* Confirm a PaymentIntent, optionally with a payment method.
*/
public function confirmPaymentIntent(string $paymentIntentId, ?string $paymentMethodId = null): \Stripe\PaymentIntent
{
$data = [];
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).
* $lineItems: array of ['price'=> 'price_xxx', 'quantity'=>1]
*/
public function createCheckoutSession(
array $lineItems,
string $successUrl,
string $cancelUrl,
?string $customerId = null,
array $metadata = []
): \Stripe\Checkout\Session {
$data = [
'mode' => 'payment',
'line_items' => $lineItems,
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
'metadata' => $metadata,
];
if ($customerId) {
$data['customer'] = $customerId;
}
// If any price is recurring, Stripe auto switches mode to 'subscription' when you set 'mode'
$hasRecurring = false;
foreach ($lineItems as $li) {
if (isset($li['price']) && str_contains((string)$li['price'], 'price_')) {
// Lightweight heuristic; real check would retrieve price and inspect 'recurring'
// For brevity not retrieving each price here.
}
}
// Allow caller to override mode via metadata if needed.
return $this->stripe->checkout->sessions->create($data);
}
/**
* Retrieve a Checkout Session.
*/
public function getCheckoutSession(string $sessionId): \Stripe\Checkout\Session
{
return $this->stripe->checkout->sessions->retrieve($sessionId);
}
/**
* Create a subscription from price.
*/
public function createSubscription(
string $customerId,
string $priceId,
array $metadata = []
): \Stripe\Subscription {
return $this->stripe->subscriptions->create([
'customer' => $customerId,
'items' => [['price' => $priceId]],
'metadata' => $metadata,
]);
}
/**
* Cancel a subscription.
* If $invoiceNow true, finalize & invoice any pending items first (simple approach).
*/
public function cancelSubscription(string $subscriptionId, bool $invoiceNow = false): \Stripe\Subscription
{
if ($invoiceNow) {
// Optional: finalize latest invoice draft before cancellation (skipped for brevity).
}
return $this->stripe->subscriptions->cancel($subscriptionId);
}
/**
* List invoices for a customer.
*/
public function listInvoices(string $customerId, int $limit = 10): \Stripe\Collection
{
return $this->stripe->invoices->all([
'customer' => $customerId,
'limit' => $limit,
]);
}
}

View File

@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Services;
use Blax\Shop\Models\Product;
use Illuminate\Support\Collection;
class ShopStripeService
{
public static function syncProductDown(\Stripe\Product $stripeProduct)
{
$product = Product::updateOrCreate(
['stripe_product_id' => $stripeProduct->id],
[
'slug' => str()->slug($stripeProduct->name),
'type' => $stripeProduct->type,
'virtual' => $stripeProduct->type === 'service',
'status' => $stripeProduct->active ? 'published' : 'draft',
]
);
$product->setLocalized('name', $stripeProduct->name);
if (isset($stripeProduct->marketing_features)) {
$product->setLocalized(
'features',
collect($stripeProduct->marketing_features)->map(fn($i) => $i->name)->toArray(),
);
}
$product->save();
// Sync prices
self::syncProductPricesDown($product);
if (app()->runningInConsole()) {
echo "\n";
}
return $product;
}
public static function syncProductPricesDown(Product $product)
{
self::getProductPrices($product->stripe_product_id)->each(function ($stripePrice) use ($product) {
if ($stripePrice->product !== $product->stripe_product_id) {
return;
}
$price = $product->prices()->updateOrCreate(
['stripe_price_id' => $stripePrice->id],
[
'name' => $stripePrice->nickname,
'type' => $stripePrice->type,
'price' => $stripePrice->unit_amount,
'currency' => $stripePrice->currency,
'billing_scheme' => $stripePrice->billing_scheme,
'interval' => $stripePrice->recurring ? $stripePrice->recurring->interval : null,
'interval_count' => $stripePrice->recurring ? $stripePrice->recurring->interval_count : null,
'trial_period_days' => $stripePrice->recurring ? $stripePrice->recurring->trial_period_days : null,
'is_default' => false,
]
);
if (app()->runningInConsole()) {
echo " - Synced price {$price->id} ({$stripePrice->id})\n";
}
});
}
}

169
src/Traits/HasCart.php Normal file
View File

@ -0,0 +1,169 @@
<?php
namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait HasCart
{
public function cart(): MorphMany
{
return $this->morphMany(
config('shop.models.cart', \Blax\Shop\Models\Cart::class),
'customer'
);
}
/**
* Get cart items (purchases with status 'cart')
*/
public function cartItems(): HasMany
{
return $this->cart()->latest()->firstOrCreate()->items();
}
/**
* Get or create the current cart for the entity
*
* @return Cart
*/
public function currentCart()
{
return $this->cart()
->whereNull('converted_at')
->latest()
->firstOrCreate();
}
/**
* Add product to cart
*
* @param Product|ProductPrice $product_or_price
* @param int $quantity
* @param array $options
* @return CartItem
* @throws \Exception
*/
public function addToCart(Product|ProductPrice $product_or_price, int $quantity = 1, array $parameters = []): CartItem
{
if ($product_or_price instanceof ProductPrice){
$product = $product_or_price->purchasable;
if ($product instanceof Product) {
$product->reserveStock($quantity);
}
}
if ($product_or_price instanceof Product) {
$product_or_price->reserveStock($quantity);
$default_prices = $product_or_price->defaultPrice()->count();
if ($default_prices === 0) {
throw new NotPurchasable("Product has no default price");
}
if ($default_prices > 1) {
throw new MultiplePurchaseOptions("Product has multiple default prices, please specify a price to add to cart");
}
}
return $this->currentCart()->addToCart(
$product_or_price,
$quantity,
$parameters
);
}
/**
* Update cart item quantity
*
* @param CartItem $cartItem
* @param int $quantity
* @return CartItem
* @throws \Exception
*/
public function updateCartQuantity(CartItem $cartItem, int $quantity): CartItem
{
$product = $cartItem->purchasable;
// Validate stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
throw new \Exception("Insufficient stock available");
}
$meta = (array) $cartItem->meta;
$cartItem->update([
'quantity' => $quantity,
]);
return $cartItem->fresh();
}
/**
* Remove item from cart
*
* @param CartItem $cartItem
* @return bool
* @throws \Exception
*/
public function removeFromCart(CartItem $cartItem): bool
{
return $cartItem->forceDelete();
}
/**
* Clear all cart items
*
* @param string|null $cartId (deprecated - not used)
* @return int Number of items removed
*/
public function clearCart(?string $cartId = null): int
{
return $this->cartItems()->delete();
}
/**
* Get cart total
*
* @param string|null $cartId (deprecated - not used)
* @return float
*/
public function getCartTotal(?string $cartId = null): float
{
return $this->cartItems()->get()->sum(function ($item) {
return ($item->purchasable->getCurrentPrice() ?? 0) * $item->quantity;
});
}
/**
* Get cart items count
*
* @param string|null $cartId (deprecated - not used)
* @return int
*/
public function getCartItemsCount(?string $cartId = null): int
{
return $this->cartItems()->sum('quantity') ?? 0;
}
/**
* Get or generate current cart ID
*
* @return string
*/
protected function getCurrentCartId(): string
{
// Override this method if you need custom cart ID logic
return 'cart_' . $this->getKey();
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Blax\Shop\Traits;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait HasChargingOptions
{
//
}

View File

@ -15,13 +15,7 @@ use Illuminate\Support\Collection;
trait HasShoppingCapabilities
{
public function cart(): MorphMany
{
return $this->morphMany(
config('shop.models.cart', \Blax\Shop\Models\Cart::class),
'customer'
);
}
use HasChargingOptions, HasCart;
/**
* Get all purchases made by this entity
@ -35,14 +29,6 @@ trait HasShoppingCapabilities
);
}
/**
* Get cart items (purchases with status 'cart')
*/
public function cartItems(): HasMany
{
return $this->cart()->latest()->firstOrCreate()->items();
}
/**
* Get completed purchases
*/
@ -145,133 +131,6 @@ trait HasShoppingCapabilities
return $purchase;
}
/**
* Get or create the current cart for the entity
*
* @return Cart
*/
public function currentCart()
{
return $this->cart()
->whereNull('converted_at')
->latest()
->firstOrCreate();
}
/**
* Add product to cart
*
* @param Product|ProductPrice $product_or_price
* @param int $quantity
* @param array $options
* @return CartItem
* @throws \Exception
*/
public function addToCart(Product|ProductPrice $product_or_price, int $quantity = 1, array $parameters = []): CartItem
{
if ($product_or_price instanceof ProductPrice){
$product = $product_or_price->purchasable;
if ($product instanceof Product) {
$product->reserveStock($quantity);
}
}
if ($product_or_price instanceof Product) {
$product_or_price->reserveStock($quantity);
$default_prices = $product_or_price->defaultPrice()->count();
if ($default_prices === 0) {
throw new NotPurchasable("Product has no default price");
}
if ($default_prices > 1) {
throw new MultiplePurchaseOptions("Product has multiple default prices, please specify a price to add to cart");
}
}
return $this->currentCart()->addToCart(
$product_or_price,
$quantity,
$parameters
);
}
/**
* Update cart item quantity
*
* @param CartItem $cartItem
* @param int $quantity
* @return CartItem
* @throws \Exception
*/
public function updateCartQuantity(CartItem $cartItem, int $quantity): CartItem
{
$product = $cartItem->purchasable;
// Validate stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
throw new \Exception("Insufficient stock available");
}
$meta = (array) $cartItem->meta;
$cartItem->update([
'quantity' => $quantity,
]);
return $cartItem->fresh();
}
/**
* Remove item from cart
*
* @param CartItem $cartItem
* @return bool
* @throws \Exception
*/
public function removeFromCart(CartItem $cartItem): bool
{
return $cartItem->forceDelete();
}
/**
* Clear all cart items
*
* @param string|null $cartId (deprecated - not used)
* @return int Number of items removed
*/
public function clearCart(?string $cartId = null): int
{
return $this->cartItems()->delete();
}
/**
* Get cart total
*
* @param string|null $cartId (deprecated - not used)
* @return float
*/
public function getCartTotal(?string $cartId = null): float
{
return $this->cartItems()->get()->sum(function ($item) {
return ($item->purchasable->getCurrentPrice() ?? 0) * $item->quantity;
});
}
/**
* Get cart items count
*
* @param string|null $cartId (deprecated - not used)
* @return int
*/
public function getCartItemsCount(?string $cartId = null): int
{
return $this->cartItems()->sum('quantity') ?? 0;
}
/**
* Checkout cart - convert cart items to completed purchases
*
@ -422,15 +281,4 @@ trait HasShoppingCapabilities
return $product->getCurrentPrice();
}
/**
* Get or generate current cart ID
*
* @return string
*/
protected function getCurrentCartId(): string
{
// Override this method if you need custom cart ID logic
return 'cart_' . $this->getKey();
}
}