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