feat(billing): renewals, coupons, VAT numbering, billing cycles and invoice payment tracking
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
ea9a53f742
commit
25f23d541e
@@ -0,0 +1,136 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user