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 ساخته می‌شود، نباید کامیت شود
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
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
* Description: اتصال فرانت‌اند سایت ایف‌نکس به API لاراول
@ -13,6 +10,26 @@ if ( ! defined( 'ABSPATH' ) ) {
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/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_payment') ||
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
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
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
* اتصال به API لاراول برای استعلام ترکینگ

View File

@ -1,7 +1,4 @@
<?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 Bridge

View File

@ -1,7 +1,4 @@
<?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
* JavaScript و منطق فرم رهگیری

View File

@ -1,7 +1,4 @@
<?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
* مدیریت ارتباط کاربران وردپرس با لاراول
@ -647,7 +644,7 @@ function ifnex_enqueue_order_assets() {
'ifnex-orders-css',
$plugin_url . 'assets/css/ifnex-orders.css',
array('ifnex-main'),
'2.0.0-' . time()
ifnex_asset_version('assets/css/ifnex-orders.css')
);
// لود jQuery & JS
@ -664,7 +661,7 @@ function ifnex_enqueue_order_assets() {
'ifnex-order-form-js',
$plugin_url . 'assets/js/ifnex-order-form.js',
array('jquery', 'sweetalert2'),
'2.0.0-' . time(),
ifnex_asset_version('assets/js/ifnex-order-form.js'),
true
);
@ -792,7 +789,7 @@ function ifnex_enqueue_payment_assets() {
'ifnex-orders-css',
$plugin_url . 'assets/css/ifnex-orders.css',
array('ifnex-main'),
'2.0.0-' . time()
ifnex_asset_version('assets/css/ifnex-orders.css')
);
wp_enqueue_script('jquery');
wp_add_inline_script('jquery', 'var ifnex_ajax = ' . wp_json_encode(array(
@ -818,7 +815,7 @@ function ifnex_enqueue_global_assets() {
'ifnex-orders-css',
$plugin_url . 'assets/css/ifnex-orders.css',
array('ifnex-main'),
'2.0.0-' . time()
ifnex_asset_version('assets/css/ifnex-orders.css')
);
// لود jQuery

View File

@ -20,6 +20,20 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
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_HOST=127.0.0.1
# DB_PORT=3306
@ -65,12 +79,17 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
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
IFNEX_TRACKING_RATE_LIMIT=60
CURRENCY_API_KEY=
# Zarinpal Payment Gateway
# ⚠️ سخت‌سازی: در Production هرگز ZARINPAL_MERCHANT_ID=fake... و ZARINPAL_SANDBOX=true نگذارید.
# کد اکنون در APP_ENV=production اگر درگاه تستی تنظیم باشد، با خطای صریح از پردازش جلوگیری می‌کند.
ZARINPAL_MERCHANT_ID=fake-merchant-id-for-testing
ZARINPAL_SANDBOX=true
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([
'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',
]);

View File

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

View File

@ -26,8 +26,22 @@ class PaymentController extends Controller
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);
// 🛡️ سخت‌سازی 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()

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')
->orWhere('expires_at', '>', now());
})
// سخت‌سازی Production: کد‌هایی که ظرفیتشان پر شده دیگر در قیمت‌گذاری اعمال نمی‌شوند
->where(function ($q) {
$q->whereNull('usage_limit')
->orWhereColumn('used_count', '<', 'usage_limit');
})
->first();
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;
use App\Models\DiscountCode;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -11,6 +13,17 @@ class DiscountCodeControllerTest extends TestCase
{
use RefreshDatabase;
private User $user;
protected function setUp(): void
{
parent::setUp();
// روت‌های کد تخفیف داخل گروه auth:sanctum هستند (سخت‌سازی Production)
$this->user = User::factory()->create();
Sanctum::actingAs($this->user);
}
#[Test]
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;
use App\Enums\ShipmentType;
use App\Models\Country;
use App\Models\ShippingRate;
use App\Models\CountryZone;
use App\Models\RateEntry;
use App\Models\SystemSetting;
use App\Services\PriceCalculatorService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* تست محاسبه‌گر قیمت — بر اساس معماری ۴-رُبعی (Export/Import × Doc/Parcel)
* و ذخیره‌سازی نرخ‌ها به ریال (IRR) — فاز ۳.۷ و ۳.۸.۱
*/
class PriceCalculatorServiceTest extends TestCase
{
use RefreshDatabase;
@ -18,67 +22,47 @@ class PriceCalculatorServiceTest extends TestCase
#[Test]
public function it_calculates_price_correctly_for_standard_package()
{
// 1. تنظیم SystemSetting ها برای تست (جلوگیری از مقادیر پیش‌فرض بزرگ)
// ۱. تنظیم SystemSetting ها برای تست
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' => 'packing_cost_default', 'value' => 0]); // بدون هزینه بسته‌بندی
SystemSetting::create(['key' => 'vat_rate', 'value' => 0.0]); // بدون مالیات
// 2. ایجاد کشور
$country = Country::factory()->create([
// ۲. ایجاد کشور
Country::factory()->create([
'iso_code' => 'US',
'name' => 'United States',
'export_zone_parcel' => 1,
'export_zone_doc' => 1,
'import_zone_parcel' => 1,
'import_zone_doc' => 1,
'is_active' => true,
]);
// 3. ایجاد نرخ‌های حمل‌ونقل
ShippingRate::create([
'direction' => 'export',
'type' => 'DOC_NORMAL',
'weight' => 1.0,
'zone_1' => 20.00,
'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,
// ۳. نگاشت زون — جدول country_zones (سیستم ۴-رُبعی)
CountryZone::create([
'country_name' => 'United States',
'country_iso2' => 'US',
'document_zone' => 1,
'parcel_zone' => 1,
]);
ShippingRate::create([
// ۴. نرخ حمل — جدول rate_entries، مستقیماً به ریال (IRR)
RateEntry::create([
'direction' => 'export',
'type' => 'DOC_NORMAL',
'service_type' => 'document',
'weight' => 3.0,
'zone_1' => 40.00,
'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,
'zone' => 1,
'rate' => 40000.0, // ۴۰,۰۰۰ ریال
'currency' => 'IRR',
'effective_at' => now()->subDay(),
]);
// 4. اجرای سرویس
// ۵. اجرای سرویس
$service = app(PriceCalculatorService::class);
$data = [
'direction' => 'Outbound', // ← توجه: در سرویس به 'export' تبدیل می‌شود
'direction' => 'Outbound', // ← در سرویس به 'export' تبدیل می‌شود
'type' => 'DOC_NORMAL',
'country_iso' => 'US',
'weight' => 2.5,
'volumetric_weight' => 3.0,
'extra_service' => 10.00,
'packing_cost' => 0, // ← اضافه کردن هزینه بسته‌بندی صفر
'extra_service' => 10000, // ۱۰,۰۰۰ ریال
'packing_cost' => 0,
'domestic_pickup' => 0,
'domestic_delivery' => 0,
'warehousing_cost' => 0,
@ -86,23 +70,146 @@ class PriceCalculatorServiceTest extends TestCase
$result = $service->calculate($data);
// 5. بررسی ساختار خروجی
// ۶. بررسی ساختار خروجی
$this->assertIsArray($result);
$this->assertArrayHasKey('total_fee', $result); // ✅ تغییر به total_fee
$this->assertArrayHasKey('total_fee', $result);
$this->assertArrayHasKey('base_price', $result);
// 6. بررسی مقادیر
// ۷. بررسی مقادیر
$this->assertEquals(1, $result['zone']);
$this->assertEquals(3.0, $result['chargeable_weight']);
$this->assertEquals(40.00, $result['base_price']);
// محاسبه دستی:
// base_price = 40
// net_rial = 40 × 1.0 (profit) × 1.0 (currency) = 40
// extra = 10
// subtotal = 50
$this->assertEquals(40000.0, $result['base_price']);
$this->assertFalse($result['discount_applied']);
// محاسبهٔ دستی (همه به ریال):
// base_price = 40000
// net_rial = 40000 × 1.0 (سود) + 10000 (خدمات) = 50000
// vat = 0
// total_fee = 50
$this->assertEquals(50.00, $result['total_fee']);
// تخفیف = 0
// 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] **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) همچنان به زمان دیپلوی موکول است.
### معماری ارزی (Currency Architecture)
@ -496,7 +509,7 @@ WALLET_AUTO_CREATE=true
| ۲ | سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما) | ⏳ موکول به آینده | پایین |
| ۳ | پلاگین SMS (به‌صورت ماژول قابل فعال‌سازی) | ⏳ منتظر پنل SMS مشتری | متوسط |
| ۴ | لاین واردات از چین (ماژول مجزا) | ⏳ مطالعاتاتی | پایین |
| ۵ | دفترچه آدرس (Address Book) مشتری | 📋 مطرح‌شده | متوسط |
| ۵ | دفترچه آدرس (Address Book) مشتری | 📋 مطرح‌شده — قدم بعدی | متوسط |
| ۶ | **ارز مرجع قابل تنظیم** (نسخه Pro) | 📋 مطرح‌شده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) |
| ۷ | سیستم بروزرسانی + لایسنس (Domain Lock) | 📋 مطرح‌شده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) |

View File

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

View File

@ -267,6 +267,19 @@ UPDATE wp_postmeta
هر دو دامنه به گواهی 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)
- [ ] `IFNEX_API_KEY` و `IFNEX_BRIDGE_API_KEY` با رشتهٔ تصادفی جایگزین شدند (مقدار `change-this-secret-key` باقی نمانده)
- [ ] `php artisan key:generate` اجرا شد
- [ ] `php artisan migrate` اجرا شد
- [ ] `php artisan migrate` اجرا شد (شامل مایگریشن جدید `discount_code_usages` — سخت‌سازی فاز ۳.۸.۱)
- [ ] تست کد تخفیف: ثبت سفارش با کد فعال → `used_count` افزایش یافت؛ سفارش دوم با کد تک‌مصرف → خطای 422
- [ ] seedها اجرا شدند (`CountriesTableSeeder`، `SystemSettingSeeder`، `RoleAndPermissionSeeder`)
- [ ] جدول `countries` پر است (۱۹۲ کشور)
- [ ] حساب ادمین با نقش `super_admin` ساخته شد
@ -308,7 +322,7 @@ UPDATE wp_postmeta
- [ ] وردپرس به API لاراول وصل می‌شود (تست: لاگین مشتری)
- [ ] خطای CORS داده نمی‌شود
- [ ] صفحهٔ `/tracking/` با یک شمارهٔ AWB نتیجه برمی‌گرداند
- [ ] پرداخت درگاه کار می‌کند (`ZARINPAL_MERCHANT_ID` تنظیم شده و `ZARINPAL_SANDBOX=false`)
- [ ] پرداخت درگاه کار می‌کند (`ZARINPAL_MERCHANT_ID` تنظیم شده و `ZARINPAL_SANDBOX=false`) — توجه: از فاز ۳.۸.۱ کد در Production با درگاه تستی اصلاً اجرا نمی‌شود (گارد سخت‌سازی)
- [ ] ارسال پیامک 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های وردپرس، تنظیم درگاه واقعی، لاگ روزانه، کش‌ها).