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

conedevelopment / bazar / 20303456369

17 Dec 2025 12:52PM UTC coverage: 64.206% (+0.7%) from 63.48%
20303456369

Pull #235

github

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

176 of 240 new or added lines in 23 files covered. (73.33%)

3 existing lines in 2 files now uncovered.

1087 of 1693 relevant lines covered (64.21%)

16.93 hits per line

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

81.15
/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 {
63✔
39
            if (! in_array(SoftDeletes::class, class_uses_recursive($model)) || $model->forceDeleting) {
×
40
                $model->items->each->delete();
×
41
                $model->shipping->delete();
×
42
            }
43
        });
63✔
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');
38✔
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')
23✔
78
            ->using(AppliedCoupon::getProxiedClass())
23✔
79
            ->withPivot('value')
23✔
80
            ->withTimestamps();
23✔
81
    }
82

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

366
            return $item;
14✔
367
        }
368

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

371
        return $stored;
1✔
372
    }
373

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

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

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

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

408
            $coupon->apply($this);
1✔
409
        } catch (ModelNotFoundException $exception) {
1✔
410
            //
NEW
411
        } catch (Throwable $exception) {
×
NEW
412
            $this->coupons()->detach([$coupon->getKey()]);
×
413
        }
414
    }
415

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

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

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

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

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

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

459
        return $this->getDiscount();
14✔
460
    }
461
}
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