From 856686e29207e5ab79dbfa1e98499c5107208ad5 Mon Sep 17 00:00:00 2001 From: a6a2f5842 Date: Wed, 26 Nov 2025 00:05:46 +0100 Subject: [PATCH] A traits, concerns, services --- composer.json | 5 +- config/shop.php | 7 +- docs/02-stripe.md | 10 +- src/Contracts/Chargable.php | 10 + .../PaymentProvider/StripeService.php | 199 ++++++++++++++++++ src/Services/ShopStripeService.php | 72 ------- src/Traits/HasCart.php | 169 +++++++++++++++ src/Traits/HasChargingOptions.php | 10 + src/Traits/HasShoppingCapabilities.php | 154 +------------- 9 files changed, 398 insertions(+), 238 deletions(-) create mode 100644 src/Contracts/Chargable.php create mode 100644 src/Services/PaymentProvider/StripeService.php delete mode 100644 src/Services/ShopStripeService.php create mode 100644 src/Traits/HasCart.php create mode 100644 src/Traits/HasChargingOptions.php diff --git a/composer.json b/composer.json index 3346ccc..7db7fc5 100644 --- a/composer.json +++ b/composer.json @@ -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", @@ -56,4 +57,4 @@ }, "minimum-stability": "dev", "prefer-stable": true -} \ No newline at end of file +} diff --git a/config/shop.php b/config/shop.php index 043b2ec..b6c4bd6 100644 --- a/config/shop.php +++ b/config/shop.php @@ -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', ], + ]; diff --git a/docs/02-stripe.md b/docs/02-stripe.md index 87abf4d..6603bff 100644 --- a/docs/02-stripe.md +++ b/docs/02-stripe.md @@ -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); }); ``` diff --git a/src/Contracts/Chargable.php b/src/Contracts/Chargable.php new file mode 100644 index 0000000..f254641 --- /dev/null +++ b/src/Contracts/Chargable.php @@ -0,0 +1,10 @@ +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, + ]); + } +} diff --git a/src/Services/ShopStripeService.php b/src/Services/ShopStripeService.php deleted file mode 100644 index 4eac661..0000000 --- a/src/Services/ShopStripeService.php +++ /dev/null @@ -1,72 +0,0 @@ - $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"; - } - }); - } -} diff --git a/src/Traits/HasCart.php b/src/Traits/HasCart.php new file mode 100644 index 0000000..1ffcd01 --- /dev/null +++ b/src/Traits/HasCart.php @@ -0,0 +1,169 @@ +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(); + } +} \ No newline at end of file diff --git a/src/Traits/HasChargingOptions.php b/src/Traits/HasChargingOptions.php new file mode 100644 index 0000000..7488089 --- /dev/null +++ b/src/Traits/HasChargingOptions.php @@ -0,0 +1,10 @@ +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(); - } }