ifnex/04_Laravel/app/Services/DiscountCodeService.php
Kazem Alghasi b9ede16613 refactor(core): harden production environment and fix discount logic
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.
2026-10-31 19:18:58 +03:30

136 lines
5.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}
}