Implement critical security hardening and resolve a P0 bug in the discount code consumption flow. This update transitions the system from a development-heavy configuration to a production-ready state. Key changes: - Fix P0 bug where `consumeDiscountCode()` was missing, breaking order registration; replaced with a robust `DiscountCodeService` using `discount_code_usages` for audit trails and idempotency. - Implement strict production guards for the Zarinpal payment gateway to prevent accidental mock processing in live environments. - Refactor WordPress bridge asset management to use `filemtime` based versioning instead of `time()` to prevent unnecessary cache busting. - Remove debug-only hacks (`opcache_reset`, `header` cache control) from the WordPress plugin. - Enhance file upload security by enforcing strict `mimetypes` validation. - Update `.gitignore` to exclude sensitive WordPress configuration files and local PHPUnit caches. - Add comprehensive documentation for production deployment, including security checklists and key rotation procedures. - Update test suite to reflect new architecture and ensure 100% coverage of the discount service.
136 lines
5.4 KiB
PHP
136 lines
5.4 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Exceptions\DiscountCodeUnavailableException;
|
||
use App\Models\DiscountCode;
|
||
use App\Models\DiscountCodeUsage;
|
||
use App\Models\Shipment;
|
||
use App\Models\User;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
/**
|
||
* سرویس مصرف کد تخفیف (سختسازی Production — فاز ۳.۸.۱)
|
||
*
|
||
* قواعد:
|
||
* ۱. مصرف کد فقط در لحظهٔ ثبت سفارش (store) انجام میشود؛
|
||
* در ارسال مجدد (resubmit) مصرف دوباره رخ نمیدهد.
|
||
* ۲. با lockForUpdate + شرط atomic روی used_count، race condition
|
||
* بین دو ثبت همزمان بسته میشود (ظرفیت هرگز منفی/بیش از حد نمیشود).
|
||
* ۳. هر مصرف موفق یک رکورد Audit در discount_code_usages ثبت میکند.
|
||
* ۴. اگر کد در محاسبهٔ قیمت اعمال شده ولی در مصرف شکست بخورد،
|
||
* اکسپشن پرتاب میشود تا سفارش با قیمت اشتباه ثبت نشود (حمایت مالی).
|
||
*/
|
||
class DiscountCodeService
|
||
{
|
||
/**
|
||
* مصرف یک کد تخفیف برای سفارش دادهشده.
|
||
*
|
||
* @param string|null $code کد تخفیف (null یعنی مشتری کد نداده — بدون خطا)
|
||
* @param User $user کاربر ثبتکنندهٔ سفارش
|
||
* @param Shipment $shipment سفارش تازهساختهشده (داخل همان transaction)
|
||
* @param float $discountedAmount مبلغ تخفیفی که در محاسبهٔ قیمت اعمال شد (ریال)
|
||
*
|
||
* @return bool true یعنی مصرف شد (یا قبلاً برای همین سفارش مصرف شده بود — Idempotent)
|
||
*
|
||
* @throws DiscountCodeUnavailableException وقتی کد معتبر نیست یا ظرفیتش تمام شده
|
||
*/
|
||
public function consumeForOrder(
|
||
?string $code,
|
||
User $user,
|
||
Shipment $shipment,
|
||
float $discountedAmount = 0.0
|
||
): bool {
|
||
$code = trim((string) $code);
|
||
|
||
// مشتری اصلاً کد نداده — چیزی برای مصرف نیست
|
||
if ($code === '') {
|
||
return false;
|
||
}
|
||
|
||
// جستجوی کد با قفل ردیفی (داخل transaction فراخوان باید باشد)
|
||
$discount = DiscountCode::where('code', $code)
|
||
->where('is_active', true)
|
||
->where(function ($q) {
|
||
$q->whereNull('expires_at')
|
||
->orWhere('expires_at', '>', now());
|
||
})
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
if (!$discount) {
|
||
throw new DiscountCodeUnavailableException(
|
||
'کد تخفیف معتبر نیست یا منقضی شده است. سفارش بدون تخفیف مجدداً ثبت شود.'
|
||
);
|
||
}
|
||
|
||
// Idempotency: اگر همین کد قبلاً برای همین سفارش مصرف شده، دوباره مصرف نکن
|
||
$alreadyUsed = DiscountCodeUsage::where('discount_code_id', $discount->id)
|
||
->where('shipment_id', $shipment->id)
|
||
->exists();
|
||
|
||
if ($alreadyUsed) {
|
||
return true;
|
||
}
|
||
|
||
// افزایش atomic با گارد ظرفیت — حتی اگر دو درخواست همزمان برسند
|
||
// فقط یکی موفق میشود وقتی ظرفیت تکمصرف است
|
||
$updated = DiscountCode::where('id', $discount->id)
|
||
->where(function ($q) {
|
||
$q->whereNull('usage_limit')
|
||
->orWhereColumn('used_count', '<', 'usage_limit');
|
||
})
|
||
->increment('used_count');
|
||
|
||
if (!$updated) {
|
||
throw new DiscountCodeUnavailableException(
|
||
'ظرفیت استفاده از این کد تخفیف به پایان رسیده است. سفارش بدون تخفیف مجدداً ثبت شود.'
|
||
);
|
||
}
|
||
|
||
// رکورد Audit
|
||
DiscountCodeUsage::create([
|
||
'discount_code_id' => $discount->id,
|
||
'user_id' => $user->id,
|
||
'shipment_id' => $shipment->id,
|
||
'discounted_amount' => round($discountedAmount, 2),
|
||
]);
|
||
|
||
Log::info('Discount code consumed', [
|
||
'code' => $discount->code,
|
||
'discount_code_id' => $discount->id,
|
||
'user_id' => $user->id,
|
||
'shipment_id' => $shipment->id,
|
||
'discounted_amount' => round($discountedAmount, 2),
|
||
'used_count' => $discount->used_count + 1,
|
||
]);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* مصرف ایمن — خطا را لاگ میکند و بهجای پرتاب، false برمیگرداند.
|
||
* برای سناریوهایی که شکست مصرف نباید ثبت سفارش را قطع کند.
|
||
*/
|
||
public function tryConsumeForOrder(
|
||
?string $code,
|
||
User $user,
|
||
Shipment $shipment,
|
||
float $discountedAmount = 0.0
|
||
): bool {
|
||
try {
|
||
return $this->consumeForOrder($code, $user, $shipment, $discountedAmount);
|
||
} catch (DiscountCodeUnavailableException $e) {
|
||
Log::warning('Discount code consumption failed', [
|
||
'code' => $code,
|
||
'user_id' => $user->id,
|
||
'shipment_id' => $shipment->id,
|
||
'reason' => $e->getMessage(),
|
||
]);
|
||
|
||
return false;
|
||
}
|
||
}
|
||
}
|