BFI cart conversion product purchase, R cart dates

This commit is contained in:
Fabian @ Blax Software 2025-12-19 09:53:44 +01:00
parent c5b78071e7
commit f6c60f3d79
7 changed files with 219 additions and 100 deletions

View File

@ -263,8 +263,8 @@ return new class extends Migration
$table->timestamp('last_activity_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('converted_at')->nullable();
$table->timestamp('from_date')->nullable(); // Default start date for booking items
$table->timestamp('until_date')->nullable(); // Default end date for booking items
$table->timestamp('from')->nullable(); // Default start date for booking items
$table->timestamp('until')->nullable(); // Default end date for booking items
$table->json('meta')->nullable();
$table->timestamps();
$table->softDeletes();

View File

@ -122,7 +122,7 @@ class StripeWebhookController
'converted_at' => now(),
]);
// Update associated purchases
// Update associated purchases and claim stocks
$this->updatePurchasesForSession($cart, $session);
Log::info('Cart converted via Stripe checkout', [
@ -165,15 +165,20 @@ class StripeWebhookController
// Update purchases with this charge ID if they exist
$purchases = ProductPurchase::where('charge_id', $charge->id)->get();
foreach ($purchases as $purchase) {
$updateData = [
'status' => PurchaseStatus::COMPLETED,
];
if ($purchase->status !== PurchaseStatus::COMPLETED) {
$updateData = [
'status' => PurchaseStatus::COMPLETED,
];
if (in_array('amount_paid', $purchase->getFillable())) {
$updateData['amount_paid'] = $charge->amount / 100;
if (in_array('amount_paid', $purchase->getFillable())) {
$updateData['amount_paid'] = $charge->amount / 100;
}
$purchase->update($updateData);
// Claim stock if not already claimed
$this->claimStockForPurchase($purchase);
}
$purchase->update($updateData);
}
}
@ -209,15 +214,20 @@ class StripeWebhookController
// Update purchases with this payment intent
$purchases = ProductPurchase::where('charge_id', $paymentIntent->id)->get();
foreach ($purchases as $purchase) {
$updateData = [
'status' => PurchaseStatus::COMPLETED,
];
if ($purchase->status !== PurchaseStatus::COMPLETED) {
$updateData = [
'status' => PurchaseStatus::COMPLETED,
];
if (in_array('amount_paid', $purchase->getFillable())) {
$updateData['amount_paid'] = $paymentIntent->amount / 100;
if (in_array('amount_paid', $purchase->getFillable())) {
$updateData['amount_paid'] = $paymentIntent->amount / 100;
}
$purchase->update($updateData);
// Claim stock if not already claimed
$this->claimStockForPurchase($purchase);
}
$purchase->update($updateData);
}
}
@ -244,7 +254,8 @@ class StripeWebhookController
*/
protected function updatePurchasesForSession(Cart $cart, $session)
{
$purchases = $cart->items()->with('purchase')->get()->pluck('purchase')->filter();
// Get all purchases for this cart
$purchases = ProductPurchase::where('cart_id', $cart->id)->get();
foreach ($purchases as $purchase) {
if (!$purchase) {
@ -255,16 +266,87 @@ class StripeWebhookController
'status' => PurchaseStatus::COMPLETED,
];
// Only update columns that exist in the model
// Update charge_id if it exists in fillable
if (in_array('charge_id', $purchase->getFillable())) {
$updateData['charge_id'] = $session->payment_intent;
}
// Update amount_paid if it exists in fillable
if (in_array('amount_paid', $purchase->getFillable())) {
$updateData['amount_paid'] = $session->amount_total / 100; // Convert from cents
// Use the purchase's amount since it was already set correctly
$updateData['amount_paid'] = $purchase->amount;
}
$purchase->update($updateData);
// Claim stock after successful payment
$this->claimStockForPurchase($purchase);
}
}
/**
* Claim stock for a purchase (used after successful payment)
*/
protected function claimStockForPurchase(ProductPurchase $purchase)
{
$product = $purchase->purchasable;
if (!($product instanceof \Blax\Shop\Models\Product)) {
return;
}
// Skip if product doesn't manage stock
if (!$product->manage_stock && !$product->isPool()) {
return;
}
// Determine if we need to claim stock with timespan (from/until)
$hasTimespan = $purchase->from && $purchase->until;
try {
if ($product->isPool()) {
// For pool products: claim from single items (they manage their own stock)
// Only claim if there's a timespan (booking dates)
if ($hasTimespan) {
$product->claimPoolStock(
$purchase->quantity,
$purchase,
$purchase->from,
$purchase->until,
"Purchase #{$purchase->id} completed"
);
}
// If no timespan, pool products don't claim stock
// (single items would be simple products that don't need claiming)
} elseif ($product->isBooking()) {
// For booking products: claim stock for the timespan
if ($hasTimespan) {
$product->claimStock(
$purchase->quantity,
$purchase,
$purchase->from,
$purchase->until,
"Purchase #{$purchase->id} completed"
);
} else {
Log::warning('Booking product without timespan', [
'purchase_id' => $purchase->id,
'product_id' => $product->id,
]);
}
} else {
// For simple/consumable products (like shampoo bottle):
// Decrease stock immediately (no timespan needed)
if ($product->manage_stock) {
$product->decreaseStock($purchase->quantity);
}
}
} catch (\Exception $e) {
Log::error('Failed to claim/decrease stock for purchase', [
'purchase_id' => $purchase->id,
'product_id' => $product->id,
'product_type' => $product->type->value ?? 'unknown',
'error' => $e->getMessage(),
]);
}
}
}

View File

@ -5,6 +5,7 @@ namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable;
use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Exceptions\InvalidDateRangeException;
use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException;
use Blax\Shop\Services\CartService;
@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Facades\DB;
class Cart extends Model
{
@ -32,8 +34,8 @@ class Cart extends Model
'expires_at',
'converted_at',
'meta',
'from_date',
'until_date',
'from',
'until',
];
protected $casts = [
@ -42,8 +44,8 @@ class Cart extends Model
'converted_at' => 'datetime',
'last_activity_at' => 'datetime',
'meta' => 'object',
'from_date' => 'datetime',
'until_date' => 'datetime',
'from' => 'datetime',
'until' => 'datetime',
];
protected $appends = [
@ -236,8 +238,8 @@ class Cart extends Model
// Update cart with from/until
$this->update([
'from_date' => $from,
'until_date' => $until,
'from' => $from,
'until' => $until,
]);
// Update cart items with from/until
@ -264,15 +266,15 @@ class Cart extends Model
$from = Carbon::parse($from);
}
if ($this->until_date && $from >= $this->until_date) {
if ($this->until && $from >= $this->until) {
throw new InvalidDateRangeException();
}
if ($validateAvailability && $this->until_date) {
$this->validateDateAvailability($from, $this->until_date);
if ($validateAvailability && $this->until) {
$this->validateDateAvailability($from, $this->until);
}
$this->update(['from_date' => $from]);
$this->update(['from' => $from]);
return $this->fresh();
}
@ -293,15 +295,15 @@ class Cart extends Model
$until = Carbon::parse($until);
}
if ($this->from_date && $this->from_date >= $until) {
if ($this->from && $this->from >= $until) {
throw new InvalidDateRangeException();
}
if ($validateAvailability && $this->from_date) {
$this->validateDateAvailability($this->from_date, $until);
if ($validateAvailability && $this->from) {
$this->validateDateAvailability($this->from, $until);
}
$this->update(['until_date' => $until]);
$this->update(['until' => $until]);
return $this->fresh();
}
@ -315,27 +317,27 @@ class Cart extends Model
*/
public function applyDatesToItems(bool $validateAvailability = true): self
{
if (!$this->from_date || !$this->until_date) {
if (!$this->from || !$this->until) {
return $this;
}
foreach ($this->items as $item) {
// Only apply to items without dates that are booking products
// Only apply to booking items that don't already have dates set
if ($item->is_booking && (!$item->from || !$item->until)) {
if ($validateAvailability) {
$product = $item->purchasable;
if ($product && !$product->isAvailableForBooking($this->from_date, $this->until_date, $item->quantity)) {
if ($product && !$product->isAvailableForBooking($this->from, $this->until, $item->quantity)) {
throw new NotEnoughAvailableInTimespanException(
productName: $product->name ?? 'Product',
requested: $item->quantity,
available: 0, // Could calculate actual available amount
from: $this->from_date,
until: $this->until_date
from: $this->from,
until: $this->until
);
}
}
$item->updateDates($this->from_date, $this->until_date);
$item->updateDates($this->from, $this->until);
}
}
@ -844,7 +846,7 @@ class Cart extends Model
public function checkout(): static
{
return \DB::transaction(function () {
return DB::transaction(function () {
// Lock the cart to prevent concurrent checkouts
$this->lockForUpdate();
@ -949,6 +951,7 @@ class Cart extends Model
*
* This method:
* - Validates the cart (doesn't convert it)
* - Creates ProductPurchase records for each cart item (with PENDING status)
* - Uses dynamic price_data for each cart item (no pre-created Stripe prices needed)
* - Creates line items with descriptions including booking dates
* - Returns the Stripe checkout session
@ -971,6 +974,40 @@ class Cart extends Model
// Validate cart before proceeding (doesn't convert it)
$this->validateForCheckout();
// Create ProductPurchase records for each cart item
DB::transaction(function () {
foreach ($this->items as $item) {
// Skip if purchase already exists
if ($item->purchase_id) {
continue;
}
$product = $item->purchasable;
$from = $item->from;
$until = $item->until;
// Create purchase record with PENDING status
$purchase = ProductPurchase::create([
'cart_id' => $this->id,
'price_id' => $item->price_id,
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'purchaser_id' => $this->customer_id,
'purchaser_type' => $this->customer_type,
'quantity' => $item->quantity,
'amount' => $item->subtotal,
'amount_paid' => 0,
'status' => PurchaseStatus::PENDING,
'from' => $from,
'until' => $until,
'meta' => $item->meta,
]);
// Link purchase to cart item
$item->update(['purchase_id' => $purchase->id]);
}
});
$lineItems = [];
foreach ($this->items as $item) {

View File

@ -342,7 +342,7 @@ class CartItem extends Model
/**
* Get the effective 'from' date for this cart item.
* Returns the item's specific date if set, otherwise falls back to the cart's from_date.
* Returns the item's specific date if set, otherwise falls back to the cart's from.
*
* @return \Carbon\Carbon|null
*/
@ -352,12 +352,12 @@ class CartItem extends Model
return $this->from;
}
return $this->cart?->from_date;
return $this->cart?->from;
}
/**
* Get the effective 'until' date for this cart item.
* Returns the item's specific date if set, otherwise falls back to the cart's until_date.
* Returns the item's specific date if set, otherwise falls back to the cart's until.
*
* @return \Carbon\Carbon|null
*/
@ -367,7 +367,7 @@ class CartItem extends Model
return $this->until;
}
return $this->cart?->until_date;
return $this->cart?->until;
}
/**

View File

@ -24,8 +24,8 @@ class CartDateManagementTest extends TestCase
$cart->setDates($from, $until, validateAvailability: false);
$cart->refresh();
$this->assertEquals($from->toDateTimeString(), $cart->from_date->toDateTimeString());
$this->assertEquals($until->toDateTimeString(), $cart->until_date->toDateTimeString());
$this->assertEquals($from->toDateTimeString(), $cart->from->toDateTimeString());
$this->assertEquals($until->toDateTimeString(), $cart->until->toDateTimeString());
}
/** @test */
@ -48,7 +48,7 @@ class CartDateManagementTest extends TestCase
$cart->setFromDate($from, validateAvailability: false);
$cart->refresh();
$this->assertEquals($from->toDateTimeString(), $cart->from_date->toDateTimeString());
$this->assertEquals($from->toDateTimeString(), $cart->from->toDateTimeString());
}
/** @test */
@ -60,14 +60,14 @@ class CartDateManagementTest extends TestCase
$cart->setUntilDate($until, validateAvailability: false);
$cart->refresh();
$this->assertEquals($until->toDateTimeString(), $cart->until_date->toDateTimeString());
$this->assertEquals($until->toDateTimeString(), $cart->until->toDateTimeString());
}
/** @test */
public function it_throws_exception_when_setting_from_date_after_existing_until_date()
{
$cart = Cart::factory()->create([
'until_date' => Carbon::now()->addDays(2),
'until' => Carbon::now()->addDays(2),
]);
$this->expectException(InvalidDateRangeException::class);
@ -78,7 +78,7 @@ class CartDateManagementTest extends TestCase
public function it_throws_exception_when_setting_until_date_before_existing_from_date()
{
$cart = Cart::factory()->create([
'from_date' => Carbon::now()->addDays(3),
'from' => Carbon::now()->addDays(3),
]);
$this->expectException(InvalidDateRangeException::class);
@ -102,8 +102,8 @@ class CartDateManagementTest extends TestCase
]);
$cart = Cart::factory()->create([
'from_date' => Carbon::now()->addDays(1),
'until_date' => Carbon::now()->addDays(3),
'from' => Carbon::now()->addDays(1),
'until' => Carbon::now()->addDays(3),
]);
$itemFromDate = Carbon::now()->addDays(5);
@ -136,8 +136,8 @@ class CartDateManagementTest extends TestCase
$cartUntilDate = Carbon::now()->addDays(3);
$cart = Cart::factory()->create([
'from_date' => $cartFromDate,
'until_date' => $cartUntilDate,
'from' => $cartFromDate,
'until' => $cartUntilDate,
]);
$item = $cart->addToCart($product, 1);
@ -187,8 +187,8 @@ class CartDateManagementTest extends TestCase
]);
$cart = Cart::factory()->create([
'from_date' => Carbon::now()->addDays(1),
'until_date' => Carbon::now()->addDays(3),
'from' => Carbon::now()->addDays(1),
'until' => Carbon::now()->addDays(3),
]);
$item = $cart->addToCart($product, 1);
@ -283,8 +283,8 @@ class CartDateManagementTest extends TestCase
]);
$cart = Cart::factory()->create([
'from_date' => Carbon::now()->addDays(1),
'until_date' => Carbon::now()->addDays(3),
'from' => Carbon::now()->addDays(1),
'until' => Carbon::now()->addDays(3),
]);
$item = $cart->addToCart($product, 1);
@ -389,8 +389,8 @@ class CartDateManagementTest extends TestCase
]);
$cart = Cart::factory()->create([
'from_date' => Carbon::now()->addDays(1),
'until_date' => Carbon::now()->addDays(3),
'from' => Carbon::now()->addDays(1),
'until' => Carbon::now()->addDays(3),
]);
// Add item that would exceed available stock
@ -428,7 +428,7 @@ class CartDateManagementTest extends TestCase
validateAvailability: false
);
$this->assertNotNull($cart->from_date);
$this->assertNotNull($cart->until_date);
$this->assertNotNull($cart->from);
$this->assertNotNull($cart->until);
}
}

View File

@ -51,10 +51,10 @@ class CartDateStringParsingTest extends TestCase
{
$cart = $this->cart->setDates('2025-12-20', '2025-12-25', false);
$this->assertNotNull($cart->from_date);
$this->assertNotNull($cart->until_date);
$this->assertEquals('2025-12-20', $cart->from_date->format('Y-m-d'));
$this->assertEquals('2025-12-25', $cart->until_date->format('Y-m-d'));
$this->assertNotNull($cart->from);
$this->assertNotNull($cart->until);
$this->assertEquals('2025-12-20', $cart->from->format('Y-m-d'));
$this->assertEquals('2025-12-25', $cart->until->format('Y-m-d'));
}
/** @test */
@ -65,8 +65,8 @@ class CartDateStringParsingTest extends TestCase
$cart = $this->cart->setDates($from, $until, false);
$this->assertEquals('2025-12-20', $cart->from_date->format('Y-m-d'));
$this->assertEquals('2025-12-25', $cart->until_date->format('Y-m-d'));
$this->assertEquals('2025-12-20', $cart->from->format('Y-m-d'));
$this->assertEquals('2025-12-25', $cart->until->format('Y-m-d'));
}
/** @test */
@ -74,18 +74,18 @@ class CartDateStringParsingTest extends TestCase
{
$cart = $this->cart->setFromDate('2025-12-20', false);
$this->assertNotNull($cart->from_date);
$this->assertEquals('2025-12-20', $cart->from_date->format('Y-m-d'));
$this->assertNotNull($cart->from);
$this->assertEquals('2025-12-20', $cart->from->format('Y-m-d'));
}
/** @test */
public function cart_set_until_date_accepts_string()
{
$this->cart->update(['from_date' => Carbon::parse('2025-12-20')]);
$this->cart->update(['from' => Carbon::parse('2025-12-20')]);
$cart = $this->cart->setUntilDate('2025-12-25', false);
$this->assertNotNull($cart->until_date);
$this->assertEquals('2025-12-25', $cart->until_date->format('Y-m-d'));
$this->assertNotNull($cart->until);
$this->assertEquals('2025-12-25', $cart->until->format('Y-m-d'));
}
/** @test */
@ -107,8 +107,8 @@ class CartDateStringParsingTest extends TestCase
$cart = $cart->setDates($from, $until, false);
$this->assertNotNull($cart->from_date, "Failed to parse: $from");
$this->assertNotNull($cart->until_date, "Failed to parse: $until");
$this->assertNotNull($cart->from, "Failed to parse: $from");
$this->assertNotNull($cart->until, "Failed to parse: $until");
}
}

View File

@ -16,15 +16,15 @@ class HtmlDateTimeCastTest extends TestCase
$cart = Cart::factory()->create();
$date = Carbon::parse('2025-12-25 14:30:00');
$cart->from_date = $date;
$cart->from = $date;
$cart->save();
// Reload from database
$cart->refresh();
// Should return Carbon instance
$this->assertInstanceOf(Carbon::class, $cart->from_date);
$this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s'));
$this->assertInstanceOf(Carbon::class, $cart->from);
$this->assertEquals('2025-12-25 14:30:00', $cart->from->format('Y-m-d H:i:s'));
}
/** @test */
@ -33,13 +33,13 @@ class HtmlDateTimeCastTest extends TestCase
$cart = Cart::factory()->create();
$date = new \DateTime('2025-12-25 14:30:00');
$cart->from_date = $date;
$cart->from = $date;
$cart->save();
$cart->refresh();
$this->assertInstanceOf(Carbon::class, $cart->from_date);
$this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s'));
$this->assertInstanceOf(Carbon::class, $cart->from);
$this->assertEquals('2025-12-25 14:30:00', $cart->from->format('Y-m-d H:i:s'));
}
/** @test */
@ -48,13 +48,13 @@ class HtmlDateTimeCastTest extends TestCase
$cart = Cart::factory()->create();
// Standard datetime string
$cart->from_date = '2025-12-25 14:30:00';
$cart->from = '2025-12-25 14:30:00';
$cart->save();
$cart->refresh();
$this->assertInstanceOf(Carbon::class, $cart->from_date);
$this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s'));
$this->assertInstanceOf(Carbon::class, $cart->from);
$this->assertEquals('2025-12-25 14:30:00', $cart->from->format('Y-m-d H:i:s'));
}
/** @test */
@ -63,26 +63,26 @@ class HtmlDateTimeCastTest extends TestCase
$cart = Cart::factory()->create();
// HTML5 datetime-local format (YYYY-MM-DDTHH:MM)
$cart->from_date = '2025-12-25T14:30';
$cart->from = '2025-12-25T14:30';
$cart->save();
$cart->refresh();
$this->assertInstanceOf(Carbon::class, $cart->from_date);
$this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s'));
$this->assertInstanceOf(Carbon::class, $cart->from);
$this->assertEquals('2025-12-25 14:30:00', $cart->from->format('Y-m-d H:i:s'));
}
/** @test */
public function it_can_format_for_html5_input()
{
$cart = Cart::factory()->create();
$cart->from_date = Carbon::parse('2025-12-25 14:30:00');
$cart->from = Carbon::parse('2025-12-25 14:30:00');
$cart->save();
$cart->refresh();
// Can format for HTML5 datetime-local input
$htmlFormat = $cart->from_date->format('Y-m-d\TH:i');
$htmlFormat = $cart->from->format('Y-m-d\TH:i');
$this->assertEquals('2025-12-25T14:30', $htmlFormat);
}
@ -90,12 +90,12 @@ class HtmlDateTimeCastTest extends TestCase
public function it_handles_null_values()
{
$cart = Cart::factory()->create();
$cart->from_date = null;
$cart->from = null;
$cart->save();
$cart->refresh();
$this->assertNull($cart->from_date);
$this->assertNull($cart->from);
}
/** @test */
@ -128,28 +128,28 @@ class HtmlDateTimeCastTest extends TestCase
$cart = Cart::factory()->create();
$timestamp = Carbon::parse('2025-12-25 14:30:00')->timestamp;
$cart->from_date = $timestamp;
$cart->from = $timestamp;
$cart->save();
$cart->refresh();
$this->assertInstanceOf(Carbon::class, $cart->from_date);
$this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s'));
$this->assertInstanceOf(Carbon::class, $cart->from);
$this->assertEquals('2025-12-25 14:30:00', $cart->from->format('Y-m-d H:i:s'));
}
/** @test */
public function it_maintains_carbon_methods()
{
$cart = Cart::factory()->create();
$cart->from_date = Carbon::parse('2025-12-25 14:30:00');
$cart->from = Carbon::parse('2025-12-25 14:30:00');
$cart->save();
$cart->refresh();
// All Carbon methods should be available
$this->assertTrue($cart->from_date->isAfter(Carbon::parse('2025-12-24')));
$this->assertTrue($cart->from_date->isBefore(Carbon::parse('2025-12-26')));
$this->assertEquals('December', $cart->from_date->format('F'));
$this->assertEquals('2025-12-25', $cart->from_date->toDateString());
$this->assertTrue($cart->from->isAfter(Carbon::parse('2025-12-24')));
$this->assertTrue($cart->from->isBefore(Carbon::parse('2025-12-26')));
$this->assertEquals('December', $cart->from->format('F'));
$this->assertEquals('2025-12-25', $cart->from->toDateString());
}
}