Files
portal-api/app/Services/Coupon/CouponService.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]);
}
}