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

conedevelopment / bazar / 20260491864

16 Dec 2025 07:51AM UTC coverage: 61.975% (-1.5%) from 63.48%
20260491864

Pull #235

github

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

95 of 169 new or added lines in 18 files covered. (56.21%)

3 existing lines in 2 files now uncovered.

1004 of 1620 relevant lines covered (61.98%)

14.91 hits per line

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

75.21
/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\Relations\BelongsTo;
20
use Illuminate\Database\Eloquent\Relations\MorphMany;
21
use Illuminate\Database\Eloquent\Relations\MorphOne;
22
use Illuminate\Database\Eloquent\Relations\MorphToMany;
23
use Illuminate\Database\Eloquent\SoftDeletes;
24
use Illuminate\Support\Arr;
25
use Illuminate\Support\Collection;
26
use Illuminate\Support\Facades\App;
27
use Illuminate\Support\Number;
28
use Throwable;
29

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

365
            return $item;
14✔
366
        }
367

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

370
        return $stored;
1✔
371
    }
372

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

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

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

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

406
        try {
NEW
407
            $coupon->apply($this);
×
NEW
408
        } catch (Throwable $exception) {
×
NEW
409
            $this->coupons()->detach([$coupon->getKey()]);
×
410
        }
411
    }
412

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

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

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

NEW
436
        return round($value * 100, 2);
×
437
    }
438

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

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

NEW
456
        return $this->getDiscount();
×
457
    }
458
}
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