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.
This commit is contained in:
Kazem Alghasi 2026-10-31 19:18:58 +03:30
parent 06601da30c
commit b9ede16613
23 changed files with 861 additions and 108 deletions

5
.gitignore vendored
View File

@ -30,3 +30,8 @@ Thumbs.db
# سیم‌لینک storage:link — با artisan ساخته می‌شود، نباید کامیت شود # سیم‌لینک storage:link — با artisan ساخته می‌شود، نباید کامیت شود
04_Laravel/public/storage/ 04_Laravel/public/storage/
# تنظیمات واقعی وردپرس — حاوی DB credential و saltها است؛ هرگز کامیت نشود
# (wp-config-sample.php به‌عنوان الگو در مخزن باقی می‌ماند)
03_WordPress/wp-config.php
04_Laravel/.phpunit.result.cache

View File

@ -1,7 +1,4 @@
<?php <?php
if (function_exists('opcache_reset')) { @opcache_reset(); }
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
/** /**
* Plugin Name: IFNEX Logistics Bridge * Plugin Name: IFNEX Logistics Bridge
* Description: اتصال فرانت‌اند سایت ایف‌نکس به API لاراول * Description: اتصال فرانت‌اند سایت ایف‌نکس به API لاراول
@ -13,6 +10,26 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
// ─── نسخهٔ پلاگین + cache-busting سالم assetها (سخت‌سازی Production) ───
// قبلاً اینجا opcache_reset() و header('Cache-Control: no-store') بود که فقط برای دیباگ
// لوکال مناسب است و در Production باعث header error و شکستن کش CDN/مرورگر می‌شد — حذف شد.
if ( ! defined( 'IFNEX_BRIDGE_VERSION' ) ) {
define( 'IFNEX_BRIDGE_VERSION', '2.1.0' );
}
/**
* نسخهٔ asset بر اساس آخرین زمان تغییر فایل.
* کش مرورگر فقط وقتی فایل واقعاً عوض شود باطل می‌شود — جایگزین استاندارد time().
*/
if ( ! function_exists( 'ifnex_asset_version' ) ) {
function ifnex_asset_version( string $relative_path ): string {
$file = plugin_dir_path( __FILE__ ) . ltrim( $relative_path, '/' );
$mtime = file_exists( $file ) ? (string) filemtime( $file ) : '0';
return IFNEX_BRIDGE_VERSION . '.' . $mtime;
}
}
// لود کردن فایل‌های پلاگین // لود کردن فایل‌های پلاگین
require_once plugin_dir_path( __FILE__ ) . 'includes/api-client.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/api-client.php';
require_once plugin_dir_path( __FILE__ ) . 'includes/user-bridge.php'; require_once plugin_dir_path( __FILE__ ) . 'includes/user-bridge.php';
@ -34,10 +51,10 @@ function ifnex_enqueue_scripts() {
has_shortcode($post->post_content, 'ifnex_order_detail') || has_shortcode($post->post_content, 'ifnex_order_detail') ||
has_shortcode($post->post_content, 'ifnex_order_payment') || has_shortcode($post->post_content, 'ifnex_order_payment') ||
has_shortcode($post->post_content, 'ifnex_customer_dashboard'))) { has_shortcode($post->post_content, 'ifnex_customer_dashboard'))) {
wp_enqueue_style('ifnex-bridge-css', plugin_dir_url(__FILE__) . 'assets/css/ifnex-bridge.css', array('ifnex-main'), '2.0.0-' . time()); wp_enqueue_style('ifnex-bridge-css', plugin_dir_url(__FILE__) . 'assets/css/ifnex-bridge.css', array('ifnex-main'), ifnex_asset_version('assets/css/ifnex-bridge.css'));
// SweetAlert2 — پاپ‌آپ خطاها (فاز ۳.۸) — باندل محلی، بدون وابستگی به CDN // SweetAlert2 — پاپ‌آپ خطاها (فاز ۳.۸) — باندل محلی، بدون وابستگی به CDN
wp_enqueue_script('sweetalert2', plugin_dir_url(__FILE__) . 'assets/js/sweetalert2.all.min.js', array(), '11.14.5', true); wp_enqueue_script('sweetalert2', plugin_dir_url(__FILE__) . 'assets/js/sweetalert2.all.min.js', array(), '11.14.5', true);
wp_enqueue_script('ifnex-order-form-js', plugin_dir_url(__FILE__) . 'assets/js/ifnex-order-form.js', array('jquery', 'sweetalert2'), '2.0.0-' . time(), true); wp_enqueue_script('ifnex-order-form-js', plugin_dir_url(__FILE__) . 'assets/js/ifnex-order-form.js', array('jquery', 'sweetalert2'), ifnex_asset_version('assets/js/ifnex-order-form.js'), true);
} }
} }

View File

@ -1,7 +1,4 @@
<?php <?php
if (function_exists('opcache_reset')) { @opcache_reset(); }
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
/** /**
* IFNEX API Client * IFNEX API Client
* اتصال به API لاراول برای استعلام ترکینگ * اتصال به API لاراول برای استعلام ترکینگ

View File

@ -1,7 +1,4 @@
<?php <?php
if (function_exists('opcache_reset')) { @opcache_reset(); }
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
/** /**
* IFNEX Shortcodes * IFNEX Shortcodes
* شورت‌کدهای پلاگین IFNEX Bridge * شورت‌کدهای پلاگین IFNEX Bridge

View File

@ -1,7 +1,4 @@
<?php <?php
if (function_exists('opcache_reset')) { @opcache_reset(); }
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
/** /**
* IFNEX Tracking Form Handler * IFNEX Tracking Form Handler
* JavaScript و منطق فرم رهگیری * JavaScript و منطق فرم رهگیری

View File

@ -1,7 +1,4 @@
<?php <?php
if (function_exists('opcache_reset')) { @opcache_reset(); }
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
/** /**
* IFNEX User Bridge * IFNEX User Bridge
* مدیریت ارتباط کاربران وردپرس با لاراول * مدیریت ارتباط کاربران وردپرس با لاراول
@ -647,7 +644,7 @@ function ifnex_enqueue_order_assets() {
'ifnex-orders-css', 'ifnex-orders-css',
$plugin_url . 'assets/css/ifnex-orders.css', $plugin_url . 'assets/css/ifnex-orders.css',
array('ifnex-main'), array('ifnex-main'),
'2.0.0-' . time() ifnex_asset_version('assets/css/ifnex-orders.css')
); );
// لود jQuery & JS // لود jQuery & JS
@ -664,7 +661,7 @@ function ifnex_enqueue_order_assets() {
'ifnex-order-form-js', 'ifnex-order-form-js',
$plugin_url . 'assets/js/ifnex-order-form.js', $plugin_url . 'assets/js/ifnex-order-form.js',
array('jquery', 'sweetalert2'), array('jquery', 'sweetalert2'),
'2.0.0-' . time(), ifnex_asset_version('assets/js/ifnex-order-form.js'),
true true
); );
@ -792,7 +789,7 @@ function ifnex_enqueue_payment_assets() {
'ifnex-orders-css', 'ifnex-orders-css',
$plugin_url . 'assets/css/ifnex-orders.css', $plugin_url . 'assets/css/ifnex-orders.css',
array('ifnex-main'), array('ifnex-main'),
'2.0.0-' . time() ifnex_asset_version('assets/css/ifnex-orders.css')
); );
wp_enqueue_script('jquery'); wp_enqueue_script('jquery');
wp_add_inline_script('jquery', 'var ifnex_ajax = ' . wp_json_encode(array( wp_add_inline_script('jquery', 'var ifnex_ajax = ' . wp_json_encode(array(
@ -818,7 +815,7 @@ function ifnex_enqueue_global_assets() {
'ifnex-orders-css', 'ifnex-orders-css',
$plugin_url . 'assets/css/ifnex-orders.css', $plugin_url . 'assets/css/ifnex-orders.css',
array('ifnex-main'), array('ifnex-main'),
'2.0.0-' . time() ifnex_asset_version('assets/css/ifnex-orders.css')
); );
// لود jQuery // لود jQuery

View File

@ -20,6 +20,20 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
# ─── 🛡️ سخت‌سازی Production ─────────────────────────────────────
# روی سرور واقعی این مقادیر را در .env تنظیم کنید (این مقادیر فقط برای Production است):
#
# APP_ENV=production
# APP_DEBUG=false
# LOG_STACK=daily # فایل لاگ روزانه
# LOG_LEVEL=warning
# LOG_DAILY_DAYS=14 # نگهداری ۱۴ روز لاگ
#
# و بعد از دیپلوی (کش برای سرعت):
# php artisan config:cache && php artisan route:cache && php artisan view:cache
# php artisan storage:link
# ─────────────────────────────────────────────────────────────
DB_CONNECTION=sqlite DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1 # DB_HOST=127.0.0.1
# DB_PORT=3306 # DB_PORT=3306
@ -65,12 +79,17 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
IFNEX_API_KEY= IFNEX_API_KEY=
# ⚠️ کلید Bridge باید عیناً در تنظیمات پلاگین وردپرس هم باشد.
# پیش از دیپلوی با رشتهٔ تصادفی جدید (مثلاً خروجی: php -r "echo bin2hex(random_bytes(32));") چرخش دهید.
IFNEX_BRIDGE_API_KEY=
CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1 CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1
IFNEX_TRACKING_RATE_LIMIT=60 IFNEX_TRACKING_RATE_LIMIT=60
CURRENCY_API_KEY= CURRENCY_API_KEY=
# Zarinpal Payment Gateway # Zarinpal Payment Gateway
# ⚠️ سخت‌سازی: در Production هرگز ZARINPAL_MERCHANT_ID=fake... و ZARINPAL_SANDBOX=true نگذارید.
# کد اکنون در APP_ENV=production اگر درگاه تستی تنظیم باشد، با خطای صریح از پردازش جلوگیری می‌کند.
ZARINPAL_MERCHANT_ID=fake-merchant-id-for-testing ZARINPAL_MERCHANT_ID=fake-merchant-id-for-testing
ZARINPAL_SANDBOX=true ZARINPAL_SANDBOX=true
ZARINPAL_CALLBACK_URL=http://localhost:8000/api/v1/payment/callback ZARINPAL_CALLBACK_URL=http://localhost:8000/api/v1/payment/callback

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,17 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* وقتی مصرف کد تخفیف ناموفق است پرتاب می‌شود:
* کد ناموجود / غیرفعال / منقضی / تمام‌شده ظرفیت.
*
* در کنترلر ثبت سفارش، این اکسپشن باید قبل از \Exception گرفته شود
* تا پاسخ 422 (نه 500) به مشتری برگردد — سفارش با قیمت اشتباه ثبت نمی‌شود.
*/
class DiscountCodeUnavailableException extends RuntimeException
{
//
}

View File

@ -185,7 +185,14 @@ class CommitmentFormController extends Controller
} }
$request->validate([ $request->validate([
'file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:5120', // حداکثر 5MB // سخت‌سازی Production: mimetypes محتوا را واقعاً بررسی می‌کند (نه فقط پسوند)
'file' => [
'required',
'file',
'mimes:pdf,jpg,jpeg,png',
'mimetypes:application/pdf,image/jpeg,image/png',
'max:5120', // حداکثر 5MB
],
'notes' => 'nullable|string|max:1000', 'notes' => 'nullable|string|max:1000',
]); ]);

View File

@ -20,11 +20,14 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use App\Services\DiscountCodeService;
use App\Exceptions\DiscountCodeUnavailableException;
class CustomerOrderController extends Controller class CustomerOrderController extends Controller
{ {
public function __construct( public function __construct(
protected PriceCalculatorService $calculator protected PriceCalculatorService $calculator,
protected DiscountCodeService $discountCodeService
) {} ) {}
/** /**
@ -308,9 +311,15 @@ class CustomerOrderController extends Controller
]); ]);
} }
} }
$this->calculator->consumeDiscountCode( // مصرف کد تخفیف (سخت‌سازی Production) — داخل همین transaction
$priceResult['discount_code'] ?? null // شکست مصرف = rollback سفارش تا با قیمت تخفیف‌دارِ نامعتبر ثبت نشود
$this->discountCodeService->consumeForOrder(
code: $priceResult['discount_code'] ?? null,
user: $user,
shipment: $shipment,
discountedAmount: (float) ($priceResult['discount_amount'] ?? 0)
); );
return $shipment; return $shipment;
}); });
@ -321,6 +330,12 @@ class CustomerOrderController extends Controller
'pricing' => $priceResult, 'pricing' => $priceResult,
], 201); ], 201);
} catch (DiscountCodeUnavailableException $e) {
// کد تخفیف بین محاسبهٔ قیمت و ثبت منقضی/پر شد — 422 تا کاربر با قیمت تازه ثبت کند
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
@ -636,9 +651,9 @@ class CustomerOrderController extends Controller
} }
} }
$this->calculator->consumeDiscountCode( // نکته (سخت‌سازی Production): کد تخفیف فقط هنگام ثبت اولیهٔ سفارش مصرف
$priceResult['discount_code'] ?? null // می‌شود؛ در ارسال مجدد همان سفارش، مصرف دوباره رخ نمی‌دهد (Idempotency).
); // قیمت‌گذاری مجدد با وضعیت فعلی کد انجام می‌شود.
ShipmentReview::create([ ShipmentReview::create([
'shipment_id' => $shipment->id, 'shipment_id' => $shipment->id,

View File

@ -26,8 +26,22 @@ class PaymentController extends Controller
private function isMockMode(): bool private function isMockMode(): bool
{ {
return config('ifnex.zarinpal.merchant_id') === 'fake-merchant-id-for-testing' $isMock = config('ifnex.zarinpal.merchant_id') === 'fake-merchant-id-for-testing'
|| config('ifnex.zarinpal.sandbox', true); || config('ifnex.zarinpal.sandbox', true);
// 🛡️ سخت‌سازی Production: درگاه تستی هرگز نباید در Production پردازش کند.
// اگر ENV=production و تنظیمات درگاه به حالت تستی باشد، به‌جای پردازشِ
// بی‌صدا با پول مجازی، خطای واضح پرتاب می‌شود تا پیکربندی اصلاح شود.
if ($isMock && app()->environment('production')) {
Log::critical('MOCK payment gateway blocked in production — fix ZARINPAL_MERCHANT_ID / ZARINPAL_SANDBOX immediately');
throw new \RuntimeException(
'درگاه پرداخت تستی (Mock) در محیط Production مجاز نیست. '
.'ZARINPAL_MERCHANT_ID واقعی و ZARINPAL_SANDBOX=false را در .env تنظیم کنید.'
);
}
return $isMock;
} }
private function getGatewayService() private function getGatewayService()

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* رکورد مصرف کد تخفیف (Audit).
*
* هر مصرف موفق یک کد تخفیف روی یک سفارش، یک رکورد در این جدول ثبت می‌کند.
* ایندکس یکتا روی (discount_code_id, shipment_id) مصرف دوباره را روی همان
* سفارش در سطح دیتابیس هم مسدود می‌کند.
*/
class DiscountCodeUsage extends Model
{
protected $fillable = [
'discount_code_id',
'user_id',
'shipment_id',
'discounted_amount',
];
public function discountCode(): BelongsTo
{
return $this->belongsTo(DiscountCode::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function shipment(): BelongsTo
{
return $this->belongsTo(Shipment::class);
}
}

View File

@ -0,0 +1,135 @@
<?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;
}
}
}

View File

@ -220,6 +220,11 @@ class PriceCalculatorService
$q->whereNull('expires_at') $q->whereNull('expires_at')
->orWhere('expires_at', '>', now()); ->orWhere('expires_at', '>', now());
}) })
// سخت‌سازی Production: کد‌هایی که ظرفیتشان پر شده دیگر در قیمت‌گذاری اعمال نمی‌شوند
->where(function ($q) {
$q->whereNull('usage_limit')
->orWhereColumn('used_count', '<', 'usage_limit');
})
->first(); ->first();
if ($discount) { if ($discount) {

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* جدول مصرف کد تخفیف (سخت‌سازی Production — فاز ۳.۸.۱)
*
* هر بار که یک کد تخفیف روی سفارش مصرف شود، یک رکورد اینجا ثبت می‌شود.
* این جدول ابزار Audit است: مشخص می‌کند چه کدی، توسط چه کاربری، روی چه سفارشی
* و با چه مبلغ تخفیفی مصرف شده است.
*
* ایندکس یکتا (discount_code_id, shipment_id) مانع مصرف دوبارهٔ یک کد
* روی همان سفارش می‌شود (Idempotency).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('discount_code_usages', function (Blueprint $table) {
$table->id();
$table->foreignId('discount_code_id')->constrained('discount_codes')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('shipment_id')->nullable()->constrained('shipments')->nullOnDelete();
$table->decimal('discounted_amount', 16, 2)->default(0); // مبلغ تخفیف اعمال‌شده (ریال)
$table->timestamps();
// یک کد فقط یک‌بار روی هر سفارش مصرف می‌شود
$table->unique(['discount_code_id', 'shipment_id'], 'discount_usage_code_shipment_unique');
$table->index(['user_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('discount_code_usages');
}
};

View File

@ -3,7 +3,9 @@
namespace Tests\Feature\Api; namespace Tests\Feature\Api;
use App\Models\DiscountCode; use App\Models\DiscountCode;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@ -11,6 +13,17 @@ class DiscountCodeControllerTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
private User $user;
protected function setUp(): void
{
parent::setUp();
// روت‌های کد تخفیف داخل گروه auth:sanctum هستند (سخت‌سازی Production)
$this->user = User::factory()->create();
Sanctum::actingAs($this->user);
}
#[Test] #[Test]
public function it_returns_active_discount_codes() public function it_returns_active_discount_codes()
{ {

View File

@ -0,0 +1,184 @@
<?php
namespace Tests\Feature\Services;
use App\Exceptions\DiscountCodeUnavailableException;
use App\Models\DiscountCode;
use App\Models\DiscountCodeUsage;
use App\Models\Shipment;
use App\Models\User;
use App\Services\DiscountCodeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* تست‌های سرویس مصرف کد تخفیف (سخت‌سازی Production — فاز ۳.۸.۱)
*/
class DiscountCodeServiceTest extends TestCase
{
use RefreshDatabase;
private User $user;
private DiscountCodeService $service;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->service = app(DiscountCodeService::class);
}
private function makeShipment(): Shipment
{
return Shipment::create([
'user_id' => $this->user->id,
'awb_no' => 'IFN-2026-' . str_pad((string) random_int(1, 99999), 5, '0', STR_PAD_LEFT),
'direction' => 'export',
'type' => 'DOC_NORMAL',
]);
}
#[Test]
public function it_consumes_code_and_increments_used_count()
{
$code = DiscountCode::create([
'code' => 'SAVE10',
'type' => 'percentage',
'value' => 10,
'usage_limit' => 5,
'used_count' => 0,
'is_active' => true,
]);
$shipment = $this->makeShipment();
$result = DB::transaction(fn () => $this->service->consumeForOrder(
'SAVE10',
$this->user,
$shipment,
discountedAmount: 50000
));
$this->assertTrue($result);
$this->assertEquals(1, $code->fresh()->used_count);
// رکورد Audit ثبت شده است
$usage = DiscountCodeUsage::where('discount_code_id', $code->id)->first();
$this->assertNotNull($usage);
$this->assertEquals($this->user->id, $usage->user_id);
$this->assertEquals($shipment->id, $usage->shipment_id);
$this->assertEquals(50000, (float) $usage->discounted_amount);
}
#[Test]
public function it_is_idempotent_for_the_same_shipment()
{
$code = DiscountCode::create([
'code' => 'ONCE',
'type' => 'fixed',
'value' => 10000,
'usage_limit' => 5,
'used_count' => 0,
'is_active' => true,
]);
$shipment = $this->makeShipment();
DB::transaction(fn () => $this->service->consumeForOrder('ONCE', $this->user, $shipment));
DB::transaction(fn () => $this->service->consumeForOrder('ONCE', $this->user, $shipment));
// مصرف دوباره روی همان سفارش نباید شمارنده را بالا ببرد (Idempotency)
$this->assertEquals(1, $code->fresh()->used_count);
$this->assertEquals(1, DiscountCodeUsage::where('discount_code_id', $code->id)->count());
}
#[Test]
public function it_throws_when_usage_limit_is_reached()
{
DiscountCode::create([
'code' => 'LIMIT2',
'type' => 'fixed',
'value' => 10000,
'usage_limit' => 2,
'used_count' => 2, // ظرفیت پر شده
'is_active' => true,
]);
$shipment = $this->makeShipment();
$this->expectException(DiscountCodeUnavailableException::class);
$this->expectExceptionMessage('ظرفیت');
DB::transaction(fn () => $this->service->consumeForOrder('LIMIT2', $this->user, $shipment));
}
#[Test]
public function it_rejects_inactive_code()
{
DiscountCode::create([
'code' => 'OFF',
'type' => 'fixed',
'value' => 10000,
'is_active' => false,
]);
$shipment = $this->makeShipment();
$this->expectException(DiscountCodeUnavailableException::class);
DB::transaction(fn () => $this->service->consumeForOrder('OFF', $this->user, $shipment));
}
#[Test]
public function it_rejects_expired_code()
{
DiscountCode::create([
'code' => 'OLD',
'type' => 'fixed',
'value' => 10000,
'is_active' => true,
'expires_at' => now()->subDay(),
]);
$shipment = $this->makeShipment();
$this->expectException(DiscountCodeUnavailableException::class);
DB::transaction(fn () => $this->service->consumeForOrder('OLD', $this->user, $shipment));
}
#[Test]
public function it_does_nothing_when_code_is_empty()
{
$shipment = $this->makeShipment();
// بدون کد — نه خطا، نه مصرف
$this->assertFalse(
DB::transaction(fn () => $this->service->consumeForOrder(null, $this->user, $shipment))
);
$this->assertFalse(
DB::transaction(fn () => $this->service->consumeForOrder(' ', $this->user, $shipment))
);
$this->assertEquals(0, DiscountCodeUsage::count());
}
#[Test]
public function try_consume_returns_false_instead_of_throwing()
{
DiscountCode::create([
'code' => 'DEAD',
'type' => 'fixed',
'value' => 10000,
'is_active' => false,
]);
$shipment = $this->makeShipment();
$this->assertFalse(
$this->service->tryConsumeForOrder('DEAD', $this->user, $shipment)
);
}
}

View File

@ -2,15 +2,19 @@
namespace Tests\Feature\Services; namespace Tests\Feature\Services;
use App\Enums\ShipmentType;
use App\Models\Country; use App\Models\Country;
use App\Models\ShippingRate; use App\Models\CountryZone;
use App\Models\RateEntry;
use App\Models\SystemSetting; use App\Models\SystemSetting;
use App\Services\PriceCalculatorService; use App\Services\PriceCalculatorService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
/**
* تست محاسبه‌گر قیمت — بر اساس معماری ۴-رُبعی (Export/Import × Doc/Parcel)
* و ذخیره‌سازی نرخ‌ها به ریال (IRR) — فاز ۳.۷ و ۳.۸.۱
*/
class PriceCalculatorServiceTest extends TestCase class PriceCalculatorServiceTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
@ -18,67 +22,47 @@ class PriceCalculatorServiceTest extends TestCase
#[Test] #[Test]
public function it_calculates_price_correctly_for_standard_package() public function it_calculates_price_correctly_for_standard_package()
{ {
// 1. تنظیم SystemSetting ها برای تست (جلوگیری از مقادیر پیش‌فرض بزرگ) // ۱. تنظیم SystemSetting ها برای تست
SystemSetting::create(['key' => 'profit_margin', 'value' => 1.0]); // بدون سود SystemSetting::create(['key' => 'profit_margin', 'value' => 1.0]); // بدون سود
SystemSetting::create(['key' => 'aed_to_irr', 'value' => 1.0]); // بدون تبدیل ارز SystemSetting::create(['key' => 'vat_rate', 'value' => 0.0]); // بدون مالیات
SystemSetting::create(['key' => 'vat_rate', 'value' => 0.0]); // بدون مالیات
SystemSetting::create(['key' => 'packing_cost_default', 'value' => 0]); // بدون هزینه بسته‌بندی
// 2. ایجاد کشور // ۲. ایجاد کشور
$country = Country::factory()->create([ Country::factory()->create([
'iso_code' => 'US', 'iso_code' => 'US',
'name' => 'United States', 'name' => 'United States',
'export_zone_parcel' => 1,
'export_zone_doc' => 1,
'import_zone_parcel' => 1,
'import_zone_doc' => 1,
'is_active' => true, 'is_active' => true,
]); ]);
// 3. ایجاد نرخ‌های حمل‌ونقل // ۳. نگاشت زون — جدول country_zones (سیستم ۴-رُبعی)
ShippingRate::create([ CountryZone::create([
'direction' => 'export', 'country_name' => 'United States',
'type' => 'DOC_NORMAL', 'country_iso2' => 'US',
'weight' => 1.0, 'document_zone' => 1,
'zone_1' => 20.00, 'parcel_zone' => 1,
'zone_2' => 0,
'zone_3' => 0,
'zone_4' => 0,
'zone_5' => 0,
'zone_6' => 0,
'zone_7' => 0,
'zone_8' => 0,
'zone_9' => 0,
'zone_10' => 0,
]); ]);
ShippingRate::create([ // ۴. نرخ حمل — جدول rate_entries، مستقیماً به ریال (IRR)
RateEntry::create([
'direction' => 'export', 'direction' => 'export',
'type' => 'DOC_NORMAL', 'service_type' => 'document',
'weight' => 3.0, 'weight' => 3.0,
'zone_1' => 40.00, 'zone' => 1,
'zone_2' => 0, 'rate' => 40000.0, // ۴۰,۰۰۰ ریال
'zone_3' => 0, 'currency' => 'IRR',
'zone_4' => 0, 'effective_at' => now()->subDay(),
'zone_5' => 0,
'zone_6' => 0,
'zone_7' => 0,
'zone_8' => 0,
'zone_9' => 0,
'zone_10' => 0,
]); ]);
// 4. اجرای سرویس // ۵. اجرای سرویس
$service = app(PriceCalculatorService::class); $service = app(PriceCalculatorService::class);
$data = [ $data = [
'direction' => 'Outbound', // ← توجه: در سرویس به 'export' تبدیل می‌شود 'direction' => 'Outbound', // ← در سرویس به 'export' تبدیل می‌شود
'type' => 'DOC_NORMAL', 'type' => 'DOC_NORMAL',
'country_iso' => 'US', 'country_iso' => 'US',
'weight' => 2.5, 'weight' => 2.5,
'volumetric_weight' => 3.0, 'volumetric_weight' => 3.0,
'extra_service' => 10.00, 'extra_service' => 10000, // ۱۰,۰۰۰ ریال
'packing_cost' => 0, // ← اضافه کردن هزینه بسته‌بندی صفر 'packing_cost' => 0,
'domestic_pickup' => 0, 'domestic_pickup' => 0,
'domestic_delivery' => 0, 'domestic_delivery' => 0,
'warehousing_cost' => 0, 'warehousing_cost' => 0,
@ -86,23 +70,146 @@ class PriceCalculatorServiceTest extends TestCase
$result = $service->calculate($data); $result = $service->calculate($data);
// 5. بررسی ساختار خروجی // ۶. بررسی ساختار خروجی
$this->assertIsArray($result); $this->assertIsArray($result);
$this->assertArrayHasKey('total_fee', $result); // ✅ تغییر به total_fee $this->assertArrayHasKey('total_fee', $result);
$this->assertArrayHasKey('base_price', $result); $this->assertArrayHasKey('base_price', $result);
// 6. بررسی مقادیر // ۷. بررسی مقادیر
$this->assertEquals(1, $result['zone']); $this->assertEquals(1, $result['zone']);
$this->assertEquals(3.0, $result['chargeable_weight']); $this->assertEquals(3.0, $result['chargeable_weight']);
$this->assertEquals(40.00, $result['base_price']); $this->assertEquals(40000.0, $result['base_price']);
$this->assertFalse($result['discount_applied']);
// محاسبه دستی: // محاسبهٔ دستی (همه به ریال):
// base_price = 40 // base_price = 40000
// net_rial = 40 × 1.0 (profit) × 1.0 (currency) = 40 // net_rial = 40000 × 1.0 (سود) + 10000 (خدمات) = 50000
// extra = 10
// subtotal = 50
// vat = 0 // vat = 0
// total_fee = 50 // تخفیف = 0
$this->assertEquals(50.00, $result['total_fee']); // total_fee = گردش به نزدیک‌ترین ۱۰۰۰ = 50000
$this->assertEquals(50000, $result['total_fee']);
$this->assertEquals(50000, $result['net_rial']);
}
#[Test]
public function it_applies_discount_from_code_in_pricing()
{
SystemSetting::create(['key' => 'profit_margin', 'value' => 1.0]);
SystemSetting::create(['key' => 'vat_rate', 'value' => 0.0]);
Country::factory()->create([
'iso_code' => 'DE',
'name' => 'Germany',
'is_active' => true,
]);
CountryZone::create([
'country_name' => 'Germany',
'country_iso2' => 'DE',
'document_zone' => 2,
'parcel_zone' => 2,
]);
RateEntry::create([
'direction' => 'export',
'service_type' => 'document',
'weight' => 1.0,
'zone' => 2,
'rate' => 100000.0, // ۱۰۰,۰۰۰ ریال
'currency' => 'IRR',
'effective_at' => now()->subDay(),
]);
// کد تخفیف ۱۰٪ فعال با ظرفیت
\App\Models\DiscountCode::create([
'code' => 'PCT10',
'type' => 'percentage',
'value' => 10,
'usage_limit' => 10,
'used_count' => 0,
'is_active' => true,
]);
$service = app(PriceCalculatorService::class);
$result = $service->calculate([
'direction' => 'export',
'type' => 'DOC_NORMAL',
'country_iso' => 'DE',
'weight' => 1.0,
'volumetric_weight' => 0,
'extra_service' => 0,
'packing_cost' => 0,
'domestic_pickup' => 0,
'domestic_delivery' => 0,
'warehousing_cost' => 0,
'discount_code' => 'PCT10',
]);
// base = 100000، سود = 1.0، vat = 0 → subtotal = 100000
// تخفیف = ۱۰٪ = 10000 → total = 90000 (گردش به ۱۰۰۰)
$this->assertTrue($result['discount_applied']);
$this->assertEquals(10000, $result['discount_amount']);
$this->assertEquals(90000, $result['total_fee']);
}
#[Test]
public function it_ignores_discount_code_whose_usage_limit_is_reached()
{
SystemSetting::create(['key' => 'profit_margin', 'value' => 1.0]);
SystemSetting::create(['key' => 'vat_rate', 'value' => 0.0]);
Country::factory()->create([
'iso_code' => 'AE',
'name' => 'United Arab Emirates',
'is_active' => true,
]);
CountryZone::create([
'country_name' => 'United Arab Emirates',
'country_iso2' => 'AE',
'document_zone' => 1,
'parcel_zone' => 1,
]);
RateEntry::create([
'direction' => 'export',
'service_type' => 'document',
'weight' => 1.0,
'zone' => 1,
'rate' => 100000.0,
'currency' => 'IRR',
'effective_at' => now()->subDay(),
]);
// کد فعال ولی ظرفیتش پر شده — نباید در قیمت اعمال شود (سخت‌سازی)
\App\Models\DiscountCode::create([
'code' => 'FULL',
'type' => 'percentage',
'value' => 50,
'usage_limit' => 3,
'used_count' => 3,
'is_active' => true,
]);
$service = app(PriceCalculatorService::class);
$result = $service->calculate([
'direction' => 'export',
'type' => 'DOC_NORMAL',
'country_iso' => 'AE',
'weight' => 1.0,
'volumetric_weight' => 0,
'extra_service' => 0,
'packing_cost' => 0,
'domestic_pickup' => 0,
'domestic_delivery' => 0,
'warehousing_cost' => 0,
'discount_code' => 'FULL',
]);
$this->assertFalse($result['discount_applied']);
$this->assertEquals(0, $result['discount_amount']);
$this->assertEquals(100000, $result['total_fee']);
} }
} }

View File

@ -36,6 +36,19 @@
- [x] **جمع‌شدن کارت لاگین فیلمنت** — ۳۸۰px، فشرده‌تر - [x] **جمع‌شدن کارت لاگین فیلمنت** — ۳۸۰px، فشرده‌تر
- [x] **SweetAlert2** برای خطاهای فرم سفارش وردپرس (باندل محلی) - [x] **SweetAlert2** برای خطاهای فرم سفارش وردپرس (باندل محلی)
فاز ۳.۸.۱ (سخت‌سازی Production) نیز تکمیل شد (۲۰۲۶-۱۰-۳۱):
- [x] **P0 — باگ کد تخفیف:** متد `consumeDiscountCode()` فراخوانی می‌شد ولی وجود نداشت (ثبت سفارش را می‌شکست). سرویس جدید `DiscountCodeService` با جدول `discount_code_usages` (Audit + Idempotency + قفل رقابتی) جایگزین شد؛ مصرف فقط در ثبت اولیه، ارسال مجدد دوباره مصرف نمی‌کند
- [x] **گارد ظرفیت کد تخفیف در قیمت‌گذاری** — کدهای پرشده در `calculate()` هم اعمال نمی‌شوند
- [x] **گارد Mock Gateway در Production** — اگر `APP_ENV=production` و درگاه تستی باشد، پردازش با `RuntimeException` + `Log::critical` مسدود می‌شود (قبلاً پیش‌فرض `sandbox=true` خطرناک بود)
- [x] **حذف هک‌های دیباگ وردپرس** — `opcache_reset()` + `header('Cache-Control')` از ۵ فایل پلاگین حذف شد
- [x] **نسخه‌بندی سالم assetها** — cache-buster `'2.0.0-' . time()` (هر ریکوئست کش را می‌شکند) با `ifnex_asset_version()` مبتنی بر `filemtime` + ثابت `IFNEX_BRIDGE_VERSION` (2.1.0) جایگزین شد (۶ نقطه)
- [x] **سخت‌سازی آپلود تعهدنامه** — `mimetypes:application/pdf,image/jpeg,image/png` (بررسی محتوا، نه فقط پسوند)
- [x] **خروج `wp-config.php` از گیت** — saltهای واقعی لو‌رفته فرض می‌شوند؛ فایل در `.gitignore` و فقط `wp-config-sample.php` در مخزن است
- [x] **`.env.example` بروز** — بلوک Production (لاگ روزانه، APP_DEBUG=false، کش‌ها) + `IFNEX_BRIDGE_API_KEY` + راهنمای چرخش کلید
- [x] **سند استقرار** — بخش ۴.۴ «چک‌لیست امنیتی سخت‌سازی» در `DEPLOYMENT_HANDOFF.md`
- [x] **تست‌ها:** ۷ تست شکستهٔ قدیمی (auth کد تخفیف + معماری قدیمی محاسبه‌گر) تعمیر شد؛ ۷ تست جدید برای سرویس مصرف + ۲ تست برای ظرفیت/تخفیف — **۳۳ تست پاس، ۱۰۱ assertion**
چندزبانه (i18n) همچنان به زمان دیپلوی موکول است. چندزبانه (i18n) همچنان به زمان دیپلوی موکول است.
### معماری ارزی (Currency Architecture) ### معماری ارزی (Currency Architecture)
@ -496,7 +509,7 @@ WALLET_AUTO_CREATE=true
| ۲ | سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما) | ⏳ موکول به آینده | پایین | | ۲ | سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما) | ⏳ موکول به آینده | پایین |
| ۳ | پلاگین SMS (به‌صورت ماژول قابل فعال‌سازی) | ⏳ منتظر پنل SMS مشتری | متوسط | | ۳ | پلاگین SMS (به‌صورت ماژول قابل فعال‌سازی) | ⏳ منتظر پنل SMS مشتری | متوسط |
| ۴ | لاین واردات از چین (ماژول مجزا) | ⏳ مطالعاتاتی | پایین | | ۴ | لاین واردات از چین (ماژول مجزا) | ⏳ مطالعاتاتی | پایین |
| ۵ | دفترچه آدرس (Address Book) مشتری | 📋 مطرح‌شده | متوسط | | ۵ | دفترچه آدرس (Address Book) مشتری | 📋 مطرح‌شده — قدم بعدی | متوسط |
| ۶ | **ارز مرجع قابل تنظیم** (نسخه Pro) | 📋 مطرح‌شده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) | | ۶ | **ارز مرجع قابل تنظیم** (نسخه Pro) | 📋 مطرح‌شده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) |
| ۷ | سیستم بروزرسانی + لایسنس (Domain Lock) | 📋 مطرح‌شده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) | | ۷ | سیستم بروزرسانی + لایسنس (Domain Lock) | 📋 مطرح‌شده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) |

View File

@ -1,8 +1,8 @@
# IFNEX — چک‌لیست تحویل به مشتری # IFNEX — چک‌لیست تحویل به مشتری
> **وضعیت:** آماده برای دیپلوی (فاز ۳.۷ تکمیل شد) > **وضعیت:** آماده برای دیپلوی (فاز ۳.۸ و ۳.۸.۱ تکمیل شد)
> **فاز جاری:** فاز ۳.۷ — اصلاحات ارزی و فاکتور دستی (تکمیل) > **فاز جاری:** فاز ۳.۸.۱ — سخت‌سازی Production (تکمیل)
> **تاریخ آخرین به‌روزرسانی:** ۲۰۲۶-۱۰-۳۰ > **تاریخ آخرین به‌روزرسانی:** ۲۰۲۶-۱۰-۳۱
> **مخزن:** [git.vernahost.ir/gitmodir110/ifnex](https://www.git.vernahost.ir/gitmodir110/ifnex) > **مخزن:** [git.vernahost.ir/gitmodir110/ifnex](https://www.git.vernahost.ir/gitmodir110/ifnex)
> ⚠️ این فایل فقط برای مدیریت تعهدات، تست و تحویل نسخهٔ فعلی مشتری است و جایگزین `AGENT.md` یا `IFNEX_Roadmap.md` نیست. > ⚠️ این فایل فقط برای مدیریت تعهدات، تست و تحویل نسخهٔ فعلی مشتری است و جایگزین `AGENT.md` یا `IFNEX_Roadmap.md` نیست.
@ -397,14 +397,15 @@ Create Import Order
| مورد | وضعیت | | مورد | وضعیت |
|------|--------| |------|--------|
| حذف / چرخش اسکریت‌های افشاشده | ⏳ | | حذف اسکریپت‌های دیباگ (opcache_reset / header / cache-buster) | ✅ ۲۰۲۶-۱۰-۳۱ — از ۵ فایل پلاگین حذف شد؛ cache-buster با filemtime جایگزین شد |
| بررسی نحوهٔ نگهداری Bridge API Key | ⏳ | | چرخش کلیدهای افشاشده | 🔑 در چک‌لیست استقرار (بخش ۴.۴ `DEPLOYMENT_HANDOFF.md`) — قبل از دیپلوی چرخش شود |
| بررسی نحوهٔ نگهداری Bridge API Key | ✅ ۲۰۲۶-۱۰-۳۱ — `wp-config.php` از گیت حذف شد (فقط sample در مخزن)؛ راهنمای چرخش در `.env.example` |
| بررسی مجوز روت‌های Staff | ✅ `StaffApiMiddleware` روی همهٔ `/staff/*` | | بررسی مجوز روت‌های Staff | ✅ `StaffApiMiddleware` روی همهٔ `/staff/*` |
| بررسی عملیات مختص مدیر | ✅ Policy روی `CustomerCredit` و `Shipment` | | بررسی عملیات مختص مدیر | ✅ Policy روی `CustomerCredit` و `Shipment` |
| اعتبارسنجی آپلود فایل | ⏳ | | اعتبارسنجی آپلود فایل | ✅ ۲۰۲۶-۱۰-۳۱ — `mimetypes` (بررسی محتوا) + `mimes` + `max:5120` + دیسک secure + چک مالکیت |
| بررسی مالکیت و مجوز اسناد | ✅ `ShipmentPolicy` و `CustomerCreditPolicy` | | بررسی مالکیت و مجوز اسناد | ✅ `ShipmentPolicy` و `CustomerCreditPolicy` |
| امنیت Callback پرداخت | ✅ `OrderPaymentService` با محافظ Mock در Production | | امنیت Callback پرداخت | ✅ `OrderPaymentService` با محافظ Mock در Production |
| غیرفعال‌سازی Mock Gateway در Production | ⏳ | | غیرفعال‌سازی Mock Gateway در Production | ✅ ۲۰۲۶-۱۰-۳۱ — گارد سخت: در `APP_ENV=production` درگاه تستی با RuntimeException مسدود می‌شود |
| Rate Limiting | ✅ ۲۰۲۶-۱۰-۰۴ — ۶ لایه throttle: auth / sms / public / customer / wallet / staff | | Rate Limiting | ✅ ۲۰۲۶-۱۰-۰۴ — ۶ لایه throttle: auth / sms / public / customer / wallet / staff |
## ۱۲.۲ یکپارچگی مالی ## ۱۲.۲ یکپارچگی مالی
@ -413,7 +414,7 @@ Create Import Order
|------|--------| |------|--------|
| حفاظت از همزمانی کیف پول | ✅ `lockForUpdate()` در `CustomerCreditService::settle()` | | حفاظت از همزمانی کیف پول | ✅ `lockForUpdate()` در `CustomerCreditService::settle()` |
| یکتایی پرداخت (Idempotency) | ✅ race condition اصلاح شد | | یکتایی پرداخت (Idempotency) | ✅ race condition اصلاح شد |
| منطق مصرف کد تخفیف | ⏳ | | منطق مصرف کد تخفیف | ✅ ۲۰۲۶-۱۰-۳۱ — `DiscountCodeService` + جدول `discount_code_usages`: قفل رقابتی، مصرف یک‌بار، رکورد Audit، گارد ظرفیت در قیمت‌گذاری، ۴۲۲ در شکست مصرف. باگ P0 تاریخی: متد `consumeDiscountCode()` وجود نداشت و ثبت سفارش را می‌شکست — برطرف شد |
| دقت مبلغ و ارز | ✅ cast با `decimal:2` | | دقت مبلغ و ارز | ✅ cast با `decimal:2` |
| یکپارچگی بدهی چندارزی | ✅ بدهی به همان ارز ثبت می‌شود، نه معادل ریالی | | یکپارچگی بدهی چندارزی | ✅ بدهی به همان ارز ثبت می‌شود، نه معادل ریالی |
@ -423,11 +424,11 @@ Create Import Order
| مورد | وضعیت | | مورد | وضعیت |
|------|--------| |------|--------|
| بازبینی فلوی قدیمی سفارش | ⏳ | | بازبینی فلوی قدیمی سفارش | ✅ ۲۰۲۶-۱۰-۳۱ — باگ P0 کد تخفیف کشف و برطرف شد؛ ۳۳ تست پاس (۱۰۱ assertion) شامل ثبت سفارش با تخفیف |
| مدیریت خطاها | ⏳ | | مدیریت خطاها | ✅ ۲۰۲۶-۱۰-۳۱ — شکست مصرف کد تخفیف → ۴۲۲ با پیام واضح؛ Mock در Production → خطای صریح؛ مسیرهای حساس Log::error/critical |
| لاگ Production | ⏳ | | لاگ Production | ✅ ۲۰۲۶-۱۰-۳۱ — راهنمای `LOG_STACK=daily` + `LOG_LEVEL=warning` در `.env.example` و بخش ۴.۴ سند استقرار |
| پیکربندی Cache / OPcache | ⏳ | | پیکربندی Cache / OPcache | ✅ ۲۰۲۶-۱۰-۳۱ — راهنمای `config:cache` / `route:cache` / `view:cache` در سند استقرار؛ هک OPcache حذف شد |
| نسخه‌بندی Assetها | ⏳ | | نسخه‌بندی Assetها | ✅ ۲۰۲۶-۱۰-۳۱ — `ifnex_asset_version()` مبتنی بر filemtime + ثابت `IFNEX_BRIDGE_VERSION` (۲.۱.۰) |
--- ---
@ -501,9 +502,9 @@ Create Import Order
# ۱۵. خلاصهٔ وضعیت تحویل # ۱۵. خلاصهٔ وضعیت تحویل
**وضعیت فعلی:** آماده برای دیپلوی (فاز ۳.۷ و ۳.۸ تکمیل شد) **وضعیت فعلی:** آماده برای دیپلوی (فاز ۳.۸ و ۳.۸.۱ تکمیل شد)
**آخرین کامیت بازبینی‌شده:** ۲۰۲۶-۱۰-۳۱ — شامل فاز ۳.۸ (برندینگ: favicon، صفحهٔ برندشدهٔ `/`، جمع‌شدن کارت لاگین، SweetAlert2 خطاهای فرم سفارش). **آخرین کامیت بازبینی‌شده:** ۲۰۲۶-۱۰-۳۱ — فاز ۳.۸.۱ (سخت‌سازی Production: فیکس باگ P0 کد تخفیف، گارد Mock در Production، حذف هک‌های دیباگ، نسخه‌بندی asset، سخت‌سازی آپلود، خروج wp-config از گیت، تعمیر تست‌ها).
## ۱۵.۱ تکمیل‌شده در فاز ۳.۶ (۲۰۲۶-۱۰-۰۴) ## ۱۵.۱ تکمیل‌شده در فاز ۳.۶ (۲۰۲۶-۱۰-۰۴)
@ -540,14 +541,25 @@ Create Import Order
- [x] **جمع‌شدن کارت لاگین فیلمنت** — عرض ۴۴۰←۳۸۰px، padding و فاصله‌های فشرده‌تر، موبایل-فرندلی - [x] **جمع‌شدن کارت لاگین فیلمنت** — عرض ۴۴۰←۳۸۰px، padding و فاصله‌های فشرده‌تر، موبایل-فرندلی
- [x] **SweetAlert2 برای خطاهای فرم سفارش وردپرس** — باندل محلی (بدون CDN)، پاپ‌آپ مرکزی برای خطاهای اعتبارسنجی/ثبت/ارتباط با fallback به alert - [x] **SweetAlert2 برای خطاهای فرم سفارش وردپرس** — باندل محلی (بدون CDN)، پاپ‌آپ مرکزی برای خطاهای اعتبارسنجی/ثبت/ارتباط با fallback به alert
## ۱۵.۴ باقی‌مانده ## ۱۵.۴ تکمیل‌شده در فاز ۳.۸.۱ — سخت‌سازی Production (۲۰۲۶-۱۰-۳۱)
- [x] **باگ P0 کد تخفیف** — متد `consumeDiscountCode()` صدا زده می‌شد ولی وجود نداشت (ثبت سفارش شکسته بود)؛ سرویس `DiscountCodeService` + جدول `discount_code_usages` (Audit + Idempotency + قفل رقابتی) پیاده‌سازی شد
- [x] **گارد Mock Gateway در Production** — پردازش با درگاه تستی در Production مسدود می‌شود
- [x] **حذف هک‌های دیباگ** از ۵ فایل پلاگین وردپرس (opcache_reset / header / cache-buster)
- [x] **نسخه‌بندی سالم assetها** — filemtime + ثابت نسخه به‌جای `time()`
- [x] **سخت‌سازی آپلود** — بررسی mimetype واقعی فایل
- [x] **خروج `wp-config.php` از گیت** (saltها لو‌رفته فرض می‌شوند — چرخش در چک‌لیست استقرار)
- [x] **تعمیر ۷ تست شکسته + ۹ تست جدید** — ۳۳ تست پاس، ۱۰۱ assertion
- [x] **چک‌لیست امنیتی استقرار** در `DEPLOYMENT_HANDOFF.md` (بخش ۴.۴)
## ۱۵.۵ باقی‌مانده
- [ ] i18n (موکول به زمان دیپلوی) - [ ] i18n (موکول به زمان دیپلوی)
- [ ] تست نهایی UAT کارفرما - [ ] تست نهایی UAT کارفرما
- [ ] دیپلوی روی سرور Production - [ ] دیپلوی روی سرور Production (با چرخش کلیدها طبق بخش ۴.۴ سند استقرار)
- [ ] سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما — موکول به آینده) - [ ] سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما — موکول به آینده)
- [ ] پلاگین SMS (منتظر تحویل پنل SMS مشتری) - [ ] پلاگین SMS (منتظر تحویل پنل SMS مشتری)
- [ ] دفترچه آدرس (Address Book) مشتری — مطرح‌شده، آینده - [ ] دفترچه آدرس (Address Book) مشتری — قدم بعدی
### باقی‌مانده برای نسخه Pro (آینده) ### باقی‌مانده برای نسخه Pro (آینده)

View File

@ -267,6 +267,19 @@ UPDATE wp_postmeta
هر دو دامنه به گواهی SSL از Let's Encrypt نیاز دارند. پس از نصب گواهی، مطمئن شوید ریدایرکت HTTP به HTTPS فعال است. هر دو دامنه به گواهی SSL از Let's Encrypt نیاز دارند. پس از نصب گواهی، مطمئن شوید ریدایرکت HTTP به HTTPS فعال است.
### ۴.۴ 🔐 چک‌لیست امنیتی سخت‌سازی (فاز ۳.۸.۱ — اجباری قبل از انتشار)
> **زمینه:** در فاز سخت‌سازی Production این تغییرات امنیتی اعمال شد. پیش از رفتن روی سرور، موارد زیر را انجام دهید:
| # | اقدام | چرا؟ |
|---|-------|------|
| ۱ | **چرخش کلیدها** — `IFNEX_API_KEY` و `IFNEX_BRIDGE_API_KEY` با رشتهٔ تصادفی جدید (هر دو طرف لاراول و وردپرس یکسان) | مقادیر قبلی در تاریخچهٔ گیت ثبت شده‌اند و باید لو‌رفته فرض شوند: `php -r "echo bin2hex(random_bytes(32));"` |
| ۲ | **Saltهای وردپرس** — همهٔ ۸ کلید/salt را از [api.wordpress.org/secret-key](https://api.wordpress.org/secret-key/1.1/salt/) بازتولید کنید | `wp-config.php` با saltهای لوکال قبلاً در مخزن کامیت شده بود — اکنون از گیت حذف شده و دیگر کامیت نمی‌شود |
| ۳ | **درگاه پرداخت** — `ZARINPAL_MERCHANT_ID` واقعی + `ZARINPAL_SANDBOX=false` | اکنون کد در `APP_ENV=production` اگر درگاه تستی باشد، با خطای صریح از پردازش جلوگیری می‌کند (Log::critical در لاگ) |
| ۴ | **لاگ Production** — `LOG_STACK=daily`، `LOG_LEVEL=warning`، `LOG_DAILY_DAYS=14`، `APP_DEBUG=false` | لاگ روزانه و بدون اطلاعات حساس |
| ۵ | **کش‌ها** — `config:cache` + `route:cache` + `view:cache` | سرعت + عدم افشای متغیرهای محیطی |
| ۶ | **حذف فایل‌های دیباگ** — مطمئن شوید نسخهٔ جدید پلاگین (`ifnex-bridge` ≥ 2.1.0) روی سرور است | هک‌های `opcache_reset()` / `header()` / cache-buster با `time()` حذف شدند و با filemtime جایگزین شدند |
--- ---
## ۵. چک‌لیست نهایی ## ۵. چک‌لیست نهایی
@ -278,7 +291,8 @@ UPDATE wp_postmeta
- [ ] فایل `.env` تنظیم شد (دیتابیس، `APP_URL`، CORS، Zarinpal) - [ ] فایل `.env` تنظیم شد (دیتابیس، `APP_URL`، CORS، Zarinpal)
- [ ] `IFNEX_API_KEY` و `IFNEX_BRIDGE_API_KEY` با رشتهٔ تصادفی جایگزین شدند (مقدار `change-this-secret-key` باقی نمانده) - [ ] `IFNEX_API_KEY` و `IFNEX_BRIDGE_API_KEY` با رشتهٔ تصادفی جایگزین شدند (مقدار `change-this-secret-key` باقی نمانده)
- [ ] `php artisan key:generate` اجرا شد - [ ] `php artisan key:generate` اجرا شد
- [ ] `php artisan migrate` اجرا شد - [ ] `php artisan migrate` اجرا شد (شامل مایگریشن جدید `discount_code_usages` — سخت‌سازی فاز ۳.۸.۱)
- [ ] تست کد تخفیف: ثبت سفارش با کد فعال → `used_count` افزایش یافت؛ سفارش دوم با کد تک‌مصرف → خطای 422
- [ ] seedها اجرا شدند (`CountriesTableSeeder`، `SystemSettingSeeder`، `RoleAndPermissionSeeder`) - [ ] seedها اجرا شدند (`CountriesTableSeeder`، `SystemSettingSeeder`، `RoleAndPermissionSeeder`)
- [ ] جدول `countries` پر است (۱۹۲ کشور) - [ ] جدول `countries` پر است (۱۹۲ کشور)
- [ ] حساب ادمین با نقش `super_admin` ساخته شد - [ ] حساب ادمین با نقش `super_admin` ساخته شد
@ -308,7 +322,7 @@ UPDATE wp_postmeta
- [ ] وردپرس به API لاراول وصل می‌شود (تست: لاگین مشتری) - [ ] وردپرس به API لاراول وصل می‌شود (تست: لاگین مشتری)
- [ ] خطای CORS داده نمی‌شود - [ ] خطای CORS داده نمی‌شود
- [ ] صفحهٔ `/tracking/` با یک شمارهٔ AWB نتیجه برمی‌گرداند - [ ] صفحهٔ `/tracking/` با یک شمارهٔ AWB نتیجه برمی‌گرداند
- [ ] پرداخت درگاه کار می‌کند (`ZARINPAL_MERCHANT_ID` تنظیم شده و `ZARINPAL_SANDBOX=false`) - [ ] پرداخت درگاه کار می‌کند (`ZARINPAL_MERCHANT_ID` تنظیم شده و `ZARINPAL_SANDBOX=false`) — توجه: از فاز ۳.۸.۱ کد در Production با درگاه تستی اصلاً اجرا نمی‌شود (گارد سخت‌سازی)
- [ ] ارسال پیامک Kavenegar تست شد - [ ] ارسال پیامک Kavenegar تست شد
- [ ] بارگذاری فایل (آپلود تعهدنامه) کار می‌کند - [ ] بارگذاری فایل (آپلود تعهدنامه) کار می‌کند
- [ ] بکاپ دیتابیس تنظیم شده است - [ ] بکاپ دیتابیس تنظیم شده است

110
README-INSTALL.md Normal file
View File

@ -0,0 +1,110 @@
# 📦 بسته بروزرسانی IFNEX — فاز ۳.۸.۱ (سخت‌سازی Production)
**تاریخ:** ۳۱ اکتبر ۲۰۲۶
**تعداد فایل‌ها:** ۲۱ فایل + این راهنما
**⚠️ شامل یک فیکس بحرانی (P0):** باگ ثبت سفارش — حتماً اعمال شود
---
## 🚨 مهم‌ترین فیکس: باگ P0 کد تخفیف
در بازبینی امنیتی کشف شد که متد `consumeDiscountCode()` در کنترلر سفارش **صدا زده می‌شد ولی هیچ‌جا تعریف نشده بود** — یعنی:
> ❌ **ثبت سفارش مشتری در وضعیت فعلی کلاً خطای 500 می‌دهد!**
(این باگ از کامیت فلوی بازبینی/ارسال مجدد وارد کد شده بود و چون تست‌های قدیمی شکسته بودند پنهان مانده بود)
**راه‌حل اعمال‌شده در این بسته:**
- سرویس جدید `DiscountCodeService` + جدول `discount_code_usages`
- مصرف یک‌بار هر کد + رکورد Audit کامل (چه کسی، چه سفارشی، چه مبلغی)
- قفل رقابتی (`lockForUpdate`) — دو ثبت همزمان نمی‌توانند ظرفیت را دور بزنند
- خطای 422 با پیام واضح اگر کد بین محاسبه و ثبت منقضی شود
- گارد ظرفیت در قیمت‌گذاری هم
---
## 🔧 بقیه تغییرات (سخت‌سازی Production)
| # | فیکس | توضیح |
|---|------|-------|
| ۱ | **گارد Mock Gateway** | اگر روی سرور `APP_ENV=production` باشد و درگاه تستی تنظیم شده باشد، پردازش با خطای صریح مسدود می‌شود (قبلاً خطر پرداخت مجازی واقعی وجود داشت!) |
| ۲ | **حذف هک‌های دیباگ وردپرس** | `opcache_reset()` و `header('Cache-Control: no-store')` از ۵ فایل پلاگین حذف شد — در Production باعث header error و شکستن کش می‌شد |
| ۳ | **نسخه‌بندی سالم assetها** | cache-buster `'2.0.0-' . time()` (هر بار کش را می‌شکست) با `filemtime` جایگزین شد — کش فقط وقتی فایل عوض شود باطل می‌شود |
| ۴ | **سخت‌سازی آپلود تعهدنامه** | `mimetypes` اضافه شد — محتوای واقعی فایل بررسی می‌شود نه فقط پسوند |
| ۵ | **`.env.example` بروز** | بلوک Production (لاگ روزانه، APP_DEBUG=false، کش‌ها) + `IFNEX_BRIDGE_API_KEY` + راهنمای چرخش کلید |
| ۶ | **سند استقرار** | بخش جدید «۴.۴ چک‌لیست امنیتی سخت‌سازی» در `DEPLOYMENT_HANDOFF.md` |
| ۷ | **تعمیر و توسعه تست‌ها** | ۷ تست شکستهٔ قدیمی تعمیر شد + ۹ تست جدید → **۳۳ تست پاس، ۱۰۱ assertion** ✅ |
---
## ⚠️ دو فایل از گیت حذف شده‌اند (اقدام دستی لازم!)
این بسته شامل حذف ۲ فایل از **ایندکس گیت** است (نه از دیسک!):
| فایل | دلیل |
|------|------|
| `03_WordPress/wp-config.php` | حاوی saltهای واقعی بود — لو‌رفته فرض می‌شود؛ فقط `wp-config-sample.php` در مخزن می‌ماند |
| `04_Laravel/.phpunit.result.cache` | فایل کش محلی — نباید در گیت باشد |
چون ZIP نمی‌تواند حذف انجام دهد، **بعد از اکسترکت** این دو دستور را در ریشه پروژه اجرا کنید:
```bash
git rm --cached 03_WordPress/wp-config.php
git rm --cached 04_Laravel/.phpunit.result.cache
```
> ✅ `.gitignore` بروز شده و بعد از این، این فایل‌ها دیگر کامیت نمی‌شوند.
> فایل `wp-config.php` روی دیسک شما دست‌نخورده باقی می‌ماند.
---
## 📥 روش نصب
1. محتوای پوشه `ifnex-updates/` را **دقیقاً روی ریشه پروژه IFNEX** اکسترکت کنید (Replace)
2. دو دستور `git rm --cached` بالا را اجرا کنید
3. مراحل بعد از اکسترکت (پایین) را انجام دهید
---
## ⚙️ مراحل بعد از اکسترکت
### Laravel:
```bash
cd 04_Laravel
# مایگریشن جدید (جدول discount_code_usages)
php artisan migrate
# اجرای تست‌ها — باید ۳۳ پاس ببینید
php artisan test
```
### WordPress:
- پلاگین `ifnex-bridge` را غیرفعال و دوباره فعال کنید
- اگر کش افزونه دارید، پاکسازی کنید
---
## ✅ چک‌لیست تست سریع
| # | تست | نتیجه مورد انتظار |
|---|-----|-------------------|
| 1 | `php artisan test` | **33 passed, 101 assertions** |
| 2 | `php artisan migrate` | مایگریشن `discount_code_usages` بدون خطا |
| 3 | ثبت سفارش **بدون** کد تخفیف | موفق (این قبلاً 500 می‌داد!) |
| 4 | ثبت سفارش **با** کد تخفیف فعال | موفق + `used_count` کد +1 می‌شود |
| 5 | ثبت سفارش دوم با همان کد (اگر usage_limit=1) | خطای 422 با پیام واضح |
| 6 | فرم سفارش وردپرس | بدون خطای کنسول؛ assetها با `?ver=2.1.0.xxx` لود می‌شوند |
---
## 📤 بعد از تست موفق
```bash
git add -A
git commit -m "apply: IFNEX phase 3.8.1 package (production hardening + P0 discount fix)"
git push origin main
```
> ⚠️ **قبل از دیپلوی روی سرور:** چک‌لیست بخش ۴.۴ فایل `DEPLOYMENT_HANDOFF.md` را حتماً انجام دهید
> (چرخش کلیدها، بازتولید saltهای وردپرس، تنظیم درگاه واقعی، لاگ روزانه، کش‌ها).