• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

conedevelopment / bazar / 20312400741

17 Dec 2025 05:57PM UTC coverage: 64.395% (+0.9%) from 63.48%
20312400741

Pull #235

github

web-flow
Merge 705e249c7 into 090ff8496
Pull Request #235: Coupons & Discounts

204 of 260 new or added lines in 23 files covered. (78.46%)

8 existing lines in 1 file now uncovered.

1096 of 1702 relevant lines covered (64.39%)

18.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.55
/src/Traits/AsOrder.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Cone\Bazar\Traits;
6

7
use Cone\Bazar\Bazar;
8
use Cone\Bazar\Enums\Currency;
9
use Cone\Bazar\Interfaces\Inventoryable;
10
use Cone\Bazar\Interfaces\LineItem;
11
use Cone\Bazar\Interfaces\Taxable;
12
use Cone\Bazar\Models\AppliedCoupon;
13
use Cone\Bazar\Models\Coupon;
14
use Cone\Bazar\Models\Item;
15
use Cone\Bazar\Models\Shipping;
16
use Cone\Bazar\Support\Facades\Shipping as ShippingManager;
17
use Cone\Root\Interfaces\Models\User;
18
use Illuminate\Database\Eloquent\Casts\Attribute;
19
use Illuminate\Database\Eloquent\ModelNotFoundException;
20
use Illuminate\Database\Eloquent\Relations\BelongsTo;
21
use Illuminate\Database\Eloquent\Relations\MorphMany;
22
use Illuminate\Database\Eloquent\Relations\MorphOne;
23
use Illuminate\Database\Eloquent\Relations\MorphToMany;
24
use Illuminate\Database\Eloquent\SoftDeletes;
25
use Illuminate\Support\Arr;
26
use Illuminate\Support\Collection;
27
use Illuminate\Support\Facades\App;
28
use Illuminate\Support\Number;
29
use Throwable;
30

31
trait AsOrder
32
{
33
    /**
34
     * Boot the trait.
35
     */
36
    public static function bootAsOrder(): void
37
    {
38
        static::deleting(static function (self $model): void {
66✔
39
            if (! in_array(SoftDeletes::class, class_uses_recursive($model)) || $model->forceDeleting) {
×
40
                $model->items->each->delete();
×
41
                $model->shipping->delete();
×
42
            }
43
        });
66✔
44
    }
45

46
    /**
47
     * Get the user for the model.
48
     */
49
    public function user(): BelongsTo
50
    {
51
        return $this->belongsTo(get_class(App::make(User::class)));
17✔
52
    }
53

54
    /**
55
     * Get the items for the model.
56
     */
57
    public function items(): MorphMany
58
    {
59
        return $this->morphMany(Item::getProxiedClass(), 'checkoutable');
41✔
60
    }
61

62
    /**
63
     * Get the shipping for the model.
64
     */
65
    public function shipping(): MorphOne
66
    {
67
        return $this->morphOne(Shipping::getProxiedClass(), 'shippable')->withDefault([
24✔
68
            'driver' => ShippingManager::getDefaultDriver(),
24✔
69
        ]);
24✔
70
    }
71

72
    /**
73
     * Get the coupons for the model.
74
     */
75
    public function coupons(): MorphToMany
76
    {
77
        return $this->morphToMany(Coupon::getProxiedClass(), 'couponable', 'bazar_couponables')
39✔
78
            ->as('coupon')
39✔
79
            ->using(AppliedCoupon::getProxiedClass())
39✔
80
            ->withPivot(['value'])
39✔
81
            ->withTimestamps();
39✔
82
    }
83

84
    /**
85
     * Get the items.
86
     */
87
    public function getItems(): Collection
88
    {
89
        return $this->items;
38✔
90
    }
91

92
    /**
93
     * Get the line items.
94
     */
95
    public function getLineItems(): Collection
96
    {
97
        return $this->getItems()->filter->isLineItem();
38✔
98
    }
99

100
    /**
101
     * Get the fees.
102
     */
103
    public function getFees(): Collection
104
    {
105
        return $this->getItems()->filter->isFee();
×
106
    }
107

108
    /**
109
     * Get the taxables.
110
     */
111
    public function getTaxables(): Collection
112
    {
113
        return $this->getItems()->when($this->needsShipping(), function (Collection $items): Collection {
16✔
114
            return $items->merge([$this->shipping]);
16✔
115
        });
16✔
116
    }
117

118
    /**
119
     * Get the total attribute.
120
     *
121
     * @return \Illuminate\Database\Eloquent\Casts\Attribute<float, never>
122
     */
123
    protected function total(): Attribute
124
    {
125
        return new Attribute(
6✔
126
            get: fn (): float => $this->getTotal()
6✔
127
        );
6✔
128
    }
129

130
    /**
131
     * Get the formatted total attribute.
132
     *
133
     * @return \Illuminate\Database\Eloquent\Casts\Attribute<string, never>
134
     */
135
    protected function formattedTotal(): Attribute
136
    {
137
        return new Attribute(
3✔
138
            get: fn (): string => $this->getFormattedTotal()
3✔
139
        );
3✔
140
    }
141

142
    /**
143
     * Get the subtotal attribute.
144
     *
145
     * @return \Illuminate\Database\Eloquent\Casts\Attribute<float, never>
146
     */
147
    protected function subtotal(): Attribute
148
    {
149
        return new Attribute(
5✔
150
            get: fn (): float => $this->getSubtotal()
5✔
151
        );
5✔
152
    }
153

154
    /**
155
     * Get the formatted subtotal attribute.
156
     *
157
     * @return \Illuminate\Database\Eloquent\Casts\Attribute<string, never>
158
     */
159
    protected function formattedSubtotal(): Attribute
160
    {
161
        return new Attribute(
3✔
162
            get: fn (): string => $this->getFormattedSubtotal()
3✔
163
        );
3✔
164
    }
165

166
    /**
167
     * Get the tax attribute.
168
     *
169
     * @return \Illuminate\Database\Eloquent\Casts\Attribute<float, never>
170
     */
171
    protected function tax(): Attribute
172
    {
173
        return new Attribute(
4✔
174
            get: fn (): float => $this->getTax()
4✔
175
        );
4✔
176
    }
177

178
    /**
179
     * Get the formatted tax attribute.
180
     *
181
     * @return \Illuminate\Database\Eloquent\Casts\Attribute<string, never>
182
     */
183
    protected function formattedTax(): Attribute
184
    {
185
        return new Attribute(
3✔
186
            get: fn (): string => $this->getFormattedTax()
3✔
187
        );
3✔
188
    }
189

190
    /**
191
     * Get the discount attribute.
192
     *
193
     * @return \Illuminate\Database\Eloquent\Casts\Attribute<float, never>
194
     */
195
    protected function discount(): Attribute
196
    {
197
        return new Attribute(
3✔
198
            get: fn (): float => $this->getDiscount()
3✔
199
        );
3✔
200
    }
201

202
    /**
203
     * Get the formatted discount attribute.
204
     *
205
     * @return \Illuminate\Database\Eloquent\Casts\Attribute<string, never>
206
     */
207
    protected function formattedDiscount(): Attribute
208
    {
209
        return new Attribute(
3✔
210
            get: fn (): string => $this->getFormattedDiscount()
3✔
211
        );
3✔
212
    }
213

214
    /**
215
     * Determine if the model needs shipping.
216
     */
217
    public function needsShipping(): bool
218
    {
219
        return $this->items->some(static function (Item $item): bool {
21✔
220
            return ! $item->isFee()
21✔
221
                && $item->buyable instanceof Inventoryable
21✔
222
                && $item->buyable->isPhysical();
21✔
223
        });
21✔
224
    }
225

226
    /**
227
     * Get the currency.
228
     */
229
    public function getCurrency(): Currency
230
    {
231
        return $this->currency ?: Bazar::getCurrency();
19✔
232
    }
233

234
    /**
235
     * Get the checkoutable model's total.
236
     */
237
    public function getTotal(): float
238
    {
239
        $value = $this->items->sum(static function (Item $item): float {
9✔
240
            return $item->getTotal();
9✔
241
        });
9✔
242

243
        $value += $this->needsShipping() ? $this->shipping->getTotal() : 0;
9✔
244

245
        $value -= $this->getDiscount();
9✔
246

247
        return round(max($value, 0), 2);
9✔
248
    }
249

250
    /**
251
     * Get the formatted total.
252
     */
253
    public function getFormattedTotal(): string
254
    {
255
        return $this->getCurrency()->format($this->getTotal());
3✔
256
    }
257

258
    /**
259
     * Get the checkoutable model's subtotal.
260
     */
261
    public function getSubtotal(): float
262
    {
263
        $value = $this->getLineItems()->sum(static function (LineItem $item): float {
38✔
264
            return $item->getSubtotal();
38✔
265
        });
38✔
266

267
        return round($value < 0 ? 0 : $value, 2);
38✔
268
    }
269

270
    /**
271
     * Get the formatted subtotal.
272
     */
273
    public function getFormattedSubtotal(): string
274
    {
275
        return $this->getCurrency()->format($this->getSubtotal());
3✔
276
    }
277

278
    /**
279
     * Get the checkoutable model's fee total.
280
     */
281
    public function getFeeTotal(): float
282
    {
283
        $value = $this->getFees()->sum(static function (LineItem $item): float {
×
284
            return $item->getSubtotal();
×
285
        });
×
286

287
        return round($value < 0 ? 0 : $value, 2);
×
288
    }
289

290
    /**
291
     * Get the formatted fee total.
292
     */
293
    public function getFormattedFeeTotal(): string
294
    {
NEW
295
        return $this->getCurrency()->format($this->getFeeTotal());
×
296
    }
297

298
    /**
299
     * Get the tax.
300
     */
301
    public function getTax(): float
302
    {
303
        $value = $this->getTaxables()->sum(static function (Taxable $item): float {
4✔
304
            return $item->getTaxTotal();
4✔
305
        });
4✔
306

307
        return round($value, 2);
4✔
308
    }
309

310
    /**
311
     * Get the formatted tax.
312
     */
313
    public function getFormattedTax(): string
314
    {
315
        return $this->getCurrency()->format($this->getTax());
3✔
316
    }
317

318
    /**
319
     * Calculate the tax.
320
     */
321
    public function calculateTax(): float
322
    {
323
        $value = $this->getTaxables()->each(static function (Taxable $item): void {
14✔
324
            $item->calculateTaxes();
14✔
325
        })->sum(static function (Taxable $item): float {
14✔
326
            return $item->getTaxTotal();
14✔
327
        });
14✔
328

329
        return round($value, 2);
14✔
330
    }
331

332
    /**
333
     * Find an item by its attributes or make a new instance.
334
     */
335
    public function findItem(array $attributes): ?Item
336
    {
337

338
        $attributes = array_merge(['properties' => null], $attributes, [
14✔
339
            'checkoutable_id' => $this->getKey(),
14✔
340
            'checkoutable_type' => static::class,
14✔
341
        ]);
14✔
342

343
        return $this->items->first(static function (Item $item) use ($attributes): bool {
14✔
344
            return empty(array_diff(
14✔
345
                array_filter(Arr::dot($attributes)),
14✔
346
                array_filter(Arr::dot(array_merge(['properties' => null], $item->withoutRelations()->toArray())))
14✔
347
            ));
14✔
348
        });
14✔
349
    }
350

351
    /**
352
     * Merge the given item into the collection.
353
     */
354
    public function mergeItem(Item $item): Item
355
    {
356
        $stored = $this->findItem(
14✔
357
            $item->only(['properties', 'buyable_id', 'buyable_type'])
14✔
358
        );
14✔
359

360
        if (is_null($stored)) {
14✔
361
            $item->checkoutable()->associate($this);
14✔
362

363
            $item->setRelation('checkoutable', $this->withoutRelations());
14✔
364

365
            $this->items->push($item);
14✔
366

367
            return $item;
14✔
368
        }
369

370
        $stored->quantity += $item->quantity;
1✔
371

372
        return $stored;
1✔
373
    }
374

375
    /**
376
     * Sync the items.
377
     */
378
    public function syncItems(): void
379
    {
380
        $this->items->each(static function (Item $item): void {
×
381
            if ($item->isLineItem() && ! is_null($item->checkoutable)) {
×
NEW
382
                $data = $item->buyable->toItem($item->checkoutable, $item->only('properties'))->only(['price']);
×
383

384
                $item->fill($data)->save();
×
385
                $item->calculateTaxes();
×
386
            }
387
        });
×
388
    }
389

390
    /**
391
     * Determine whether the checkoutable models needs payment.
392
     */
393
    public function needsPayment(): bool
394
    {
NEW
395
        return $this->getTotal() > 0;
×
396
    }
397

398
    /**
399
     * Apply a coupon to the checkoutable model.
400
     */
401
    public function applyCoupon(string|Coupon $coupon): void
402
    {
403
        try {
404
            $coupon = match (true) {
38✔
405
                is_string($coupon) => Coupon::query()->code($coupon)->available()->firstOrFail(),
39✔
406
                default => $coupon,
37✔
407
            };
38✔
408

409
            $coupon->apply($this);
38✔
410
        } catch (ModelNotFoundException $exception) {
7✔
411
            //
412
        } catch (Throwable $exception) {
6✔
413
            $this->coupons()->detach([$coupon->getKey()]);
6✔
414
        }
415
    }
416

417
    /**
418
     * Get the discount.
419
     */
420
    public function getDiscount(): float
421
    {
422
        return $this->coupons->sum('coupon.value');
23✔
423
    }
424

425
    /**
426
     * Get the formatted discount.
427
     */
428
    public function getFormattedDiscount(): string
429
    {
430
        return $this->getCurrency()->format($this->getDiscount());
3✔
431
    }
432

433
    /**
434
     * Get the discount rate.
435
     */
436
    public function getDiscountRate(): float
437
    {
NEW
438
        $value = $this->getSubtotal() > 0 ? $this->getDiscount() / $this->getSubtotal() : 0;
×
439

NEW
440
        return round($value * 100, 2);
×
441
    }
442

443
    /**
444
     * Get the formatted discount rate.
445
     */
446
    public function getFormattedDiscountRate(): string
447
    {
NEW
448
        return Number::percentage($this->getDiscountRate());
×
449
    }
450

451
    /**
452
     * Calculate the discount.
453
     */
454
    public function calculateDiscount(): float
455
    {
456
        $this->coupons->each(function (Coupon $coupon): void {
14✔
457
            $this->applyCoupon($coupon);
6✔
458
        });
14✔
459

460
        return $this->getDiscount();
14✔
461
    }
462
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc