137 lines
4.8 KiB
PHP
137 lines
4.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Coupon;
|
|
|
|
use App\Models\Coupon;
|
|
use App\Models\CouponRedemption;
|
|
use App\Models\Customer;
|
|
use App\Models\Order;
|
|
use App\Models\Product;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class CouponService
|
|
{
|
|
public const MIN_PAYABLE = 1000;
|
|
|
|
/**
|
|
* @return array{coupon: Coupon, discount: int}
|
|
*/
|
|
public function preview(string $code, Customer $customer, Product $product, string $billingCycle, int $subTotal, string $context = Coupon::APPLIES_NEW): array
|
|
{
|
|
$coupon = Coupon::where('code', Coupon::normalizeCode($code))->first();
|
|
|
|
return $this->evaluate($coupon, $customer, $product, $billingCycle, $subTotal, $context);
|
|
}
|
|
|
|
/**
|
|
* @return array{coupon: Coupon, discount: int}
|
|
*/
|
|
public function lockAndApply(string $code, Customer $customer, Product $product, string $billingCycle, int $subTotal, string $context = Coupon::APPLIES_NEW): array
|
|
{
|
|
$coupon = Coupon::where('code', Coupon::normalizeCode($code))->lockForUpdate()->first();
|
|
|
|
return $this->evaluate($coupon, $customer, $product, $billingCycle, $subTotal, $context);
|
|
}
|
|
|
|
public function record(Coupon $coupon, Order $order, int $discount): CouponRedemption
|
|
{
|
|
return CouponRedemption::create([
|
|
'coupon_id' => $coupon->id,
|
|
'customer_id' => $order->customer_id,
|
|
'order_id' => $order->id,
|
|
'amount' => $discount,
|
|
]);
|
|
}
|
|
|
|
public function setReleased(Order $order, bool $released): void
|
|
{
|
|
CouponRedemption::where('order_id', $order->id)
|
|
->update(['released_at' => $released ? now() : null]);
|
|
}
|
|
|
|
/**
|
|
* @return array{coupon: Coupon, discount: int}
|
|
*/
|
|
private function evaluate(?Coupon $coupon, Customer $customer, Product $product, string $billingCycle, int $subTotal, string $context): array
|
|
{
|
|
if (! $coupon || ! $coupon->is_active) {
|
|
$this->fail('Mã giảm giá không hợp lệ.');
|
|
}
|
|
|
|
if ($coupon->starts_at?->isFuture()) {
|
|
$this->fail('Mã giảm giá chưa đến thời gian áp dụng.');
|
|
}
|
|
|
|
if ($coupon->ends_at?->isPast()) {
|
|
$this->fail('Mã giảm giá đã hết hạn.');
|
|
}
|
|
|
|
if (! in_array($coupon->applies_to, [$context, Coupon::APPLIES_ALL], true)) {
|
|
$this->fail($context === Coupon::APPLIES_NEW
|
|
? 'Mã giảm giá này không áp dụng cho đơn mới.'
|
|
: 'Mã giảm giá này chỉ áp dụng cho đơn mới.');
|
|
}
|
|
|
|
if (! $this->inScope($coupon, $product, $billingCycle)) {
|
|
$this->fail('Mã giảm giá không áp dụng cho sản phẩm hoặc chu kỳ thanh toán này.');
|
|
}
|
|
|
|
if ($subTotal < $coupon->min_order) {
|
|
$this->fail(sprintf('Đơn hàng tối thiểu %s VNĐ để áp dụng mã này.', number_format($coupon->min_order)));
|
|
}
|
|
|
|
$this->assertUsageLeft($coupon, $customer);
|
|
|
|
$discount = $this->amountFor($coupon, $subTotal);
|
|
if ($discount <= 0) {
|
|
$this->fail('Đơn hàng không đủ điều kiện áp dụng mã giảm giá.');
|
|
}
|
|
|
|
return ['coupon' => $coupon, 'discount' => $discount];
|
|
}
|
|
|
|
private function inScope(Coupon $coupon, Product $product, string $billingCycle): bool
|
|
{
|
|
return $this->matches($coupon->product_types, $product->product_type)
|
|
&& $this->matches($coupon->product_ids, $product->id)
|
|
&& $this->matches($coupon->billing_cycles, $billingCycle);
|
|
}
|
|
|
|
private function matches(?array $allowed, string|int|null $value): bool
|
|
{
|
|
return empty($allowed) || in_array($value, $allowed, false);
|
|
}
|
|
|
|
private function assertUsageLeft(Coupon $coupon, Customer $customer): void
|
|
{
|
|
$redemptions = $coupon->activeRedemptions();
|
|
|
|
if ($coupon->usage_limit !== null && (clone $redemptions)->count() >= $coupon->usage_limit) {
|
|
$this->fail('Mã giảm giá đã hết lượt sử dụng.');
|
|
}
|
|
|
|
if ($coupon->per_customer_limit !== null
|
|
&& (clone $redemptions)->where('customer_id', $customer->id)->count() >= $coupon->per_customer_limit) {
|
|
$this->fail('Bạn đã dùng hết số lần cho phép của mã giảm giá này.');
|
|
}
|
|
}
|
|
|
|
private function amountFor(Coupon $coupon, int $subTotal): int
|
|
{
|
|
$discount = $coupon->type === Coupon::TYPE_PERCENT
|
|
? intdiv($subTotal * $coupon->value, 100)
|
|
: $coupon->value;
|
|
|
|
if ($coupon->max_discount !== null) {
|
|
$discount = min($discount, $coupon->max_discount);
|
|
}
|
|
|
|
return min($discount, $subTotal - self::MIN_PAYABLE);
|
|
}
|
|
|
|
private function fail(string $message): never
|
|
{
|
|
throw ValidationException::withMessages(['coupon_code' => $message]);
|
|
}
|
|
}
|