Spaces:
Sleeping
Sleeping
File size: 4,913 Bytes
10dc6f2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | <?php
namespace App\Services;
use App\Models\Event;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\PromoCode;
use App\Models\TicketTier;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class OrderService
{
const FEE_RATE = 0.03; // 3% service fee
const TAX_RATE = 0.11; // 11% PPN
const EXPIRY_MINUTES = 15;
/**
* Create a pending order with race-condition-safe stock deduction.
*
* @param User $user
* @param Event $event
* @param array $items [['tier_id' => int, 'qty' => int], ...]
* @param string|null $promoCode
* @return Order
*
* @throws \Exception
*/
public function createOrder(User $user, Event $event, array $items, ?string $promoCode = null): Order
{
return DB::transaction(function () use ($user, $event, $items, $promoCode) {
$subtotal = 0;
$orderItems = [];
foreach ($items as $item) {
// Lock the tier row to prevent oversell
$tier = TicketTier::where('id', $item['tier_id'])
->where('event_id', $event->id)
->lockForUpdate()
->firstOrFail();
if (!$tier->isOnSale()) {
throw new \Exception("Tiket \"{$tier->name}\" tidak tersedia untuk dijual saat ini.");
}
$qty = (int) $item['qty'];
if ($qty > $tier->max_per_order) {
throw new \Exception("Maksimal {$tier->max_per_order} tiket per order untuk \"{$tier->name}\".");
}
if ($tier->availableStock() < $qty) {
throw new \Exception("Kuota tiket \"{$tier->name}\" tidak mencukupi. Sisa: {$tier->availableStock()}.");
}
// Deduct stock
$tier->increment('sold_count', $qty);
$lineTotal = $tier->price * $qty;
$subtotal += $lineTotal;
$orderItems[] = [
'tier' => $tier,
'qty' => $qty,
'unit_price' => $tier->price,
'total' => $lineTotal,
];
}
// Calculate fees
$fee = round($subtotal * self::FEE_RATE, 2);
$tax = round($subtotal * self::TAX_RATE, 2);
$discount = 0;
// Apply promo code
if ($promoCode) {
$promo = PromoCode::where('code', $promoCode)->first();
if ($promo && $promo->isValid()) {
$discount = $promo->applyDiscount($subtotal);
$promo->increment('used_count');
}
}
$total = max(0, $subtotal + $fee + $tax - $discount);
// Create order
$order = Order::create([
'user_id' => $user->id,
'event_id' => $event->id,
'subtotal' => $subtotal,
'fee' => $fee,
'tax' => $tax,
'discount' => $discount,
'total' => $total,
'status' => 'pending',
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES),
]);
// Create order items
foreach ($orderItems as $oi) {
OrderItem::create([
'order_id' => $order->id,
'ticket_tier_id' => $oi['tier']->id,
'qty' => $oi['qty'],
'unit_price' => $oi['unit_price'],
'total' => $oi['total'],
]);
}
return $order->load('items.ticketTier', 'event');
});
}
/**
* Expire a pending order and restore stock.
*/
public function expireOrder(Order $order): void
{
if (!$order->isPending()) return;
DB::transaction(function () use ($order) {
$order->update(['status' => 'expired']);
foreach ($order->items as $item) {
$item->ticketTier->decrement('sold_count', $item->qty);
}
});
}
/**
* Cancel/refund an order if the event's refund policy allows.
*/
public function cancelOrder(Order $order): bool
{
if (!$order->isPaid()) return false;
// Check refund deadline: must be at least 24h before event start
$event = $order->event;
if ($event->start_at->subDay()->isPast()) return false;
DB::transaction(function () use ($order) {
$order->update(['status' => 'refunded']);
foreach ($order->items as $item) {
$item->ticketTier->decrement('sold_count', $item->qty);
$item->attendees()->delete();
}
});
return true;
}
}
|