From b9ede16613fb19543d96ebc8a9763ab5128997e4 Mon Sep 17 00:00:00 2001 From: Kazem Alghasi Date: Sat, 31 Oct 2026 19:18:58 +0330 Subject: [PATCH] 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. --- .gitignore | 5 + .../plugins/ifnex-bridge/ifnex-bridge.php | 27 ++- .../ifnex-bridge/includes/api-client.php | 3 - .../ifnex-bridge/includes/shortcodes.php | 3 - .../ifnex-bridge/includes/tracking-form.php | 3 - .../ifnex-bridge/includes/user-bridge.php | 11 +- 04_Laravel/.env.example | 19 ++ 04_Laravel/.phpunit.result.cache | 2 +- .../DiscountCodeUnavailableException.php | 17 ++ .../Api/CommitmentFormController.php | 9 +- .../Api/Customer/CustomerOrderController.php | 27 ++- .../Controllers/Api/PaymentController.php | 16 +- 04_Laravel/app/Models/DiscountCodeUsage.php | 38 +++ .../app/Services/DiscountCodeService.php | 135 +++++++++++ .../app/Services/PriceCalculatorService.php | 5 + ...0000_create_discount_code_usages_table.php | 40 ++++ .../Api/DiscountCodeControllerTest.php | 13 ++ .../Services/DiscountCodeServiceTest.php | 184 +++++++++++++++ .../Services/PriceCalculatorServiceTest.php | 221 +++++++++++++----- AGENT.md | 15 +- CLIENT_DELIVERY.md | 48 ++-- DEPLOYMENT_HANDOFF.md | 18 +- README-INSTALL.md | 110 +++++++++ 23 files changed, 861 insertions(+), 108 deletions(-) create mode 100644 04_Laravel/app/Exceptions/DiscountCodeUnavailableException.php create mode 100644 04_Laravel/app/Models/DiscountCodeUsage.php create mode 100644 04_Laravel/app/Services/DiscountCodeService.php create mode 100644 04_Laravel/database/migrations/2026_10_31_100000_create_discount_code_usages_table.php create mode 100644 04_Laravel/tests/Feature/Services/DiscountCodeServiceTest.php create mode 100644 README-INSTALL.md diff --git a/.gitignore b/.gitignore index 5213cc1..d167071 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php b/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php index c03f157..250f6b0 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php @@ -1,7 +1,4 @@ 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); } } diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/api-client.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/api-client.php index 681ec8e..bd94bb4 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/api-client.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/api-client.php @@ -1,7 +1,4 @@ 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', ]); diff --git a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php index 3c99f1a..1fa2ea3 100644 --- a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php +++ b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php @@ -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, diff --git a/04_Laravel/app/Http/Controllers/Api/PaymentController.php b/04_Laravel/app/Http/Controllers/Api/PaymentController.php index cf20280..cde7f55 100644 --- a/04_Laravel/app/Http/Controllers/Api/PaymentController.php +++ b/04_Laravel/app/Http/Controllers/Api/PaymentController.php @@ -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() diff --git a/04_Laravel/app/Models/DiscountCodeUsage.php b/04_Laravel/app/Models/DiscountCodeUsage.php new file mode 100644 index 0000000..707cb57 --- /dev/null +++ b/04_Laravel/app/Models/DiscountCodeUsage.php @@ -0,0 +1,38 @@ +belongsTo(DiscountCode::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function shipment(): BelongsTo + { + return $this->belongsTo(Shipment::class); + } +} diff --git a/04_Laravel/app/Services/DiscountCodeService.php b/04_Laravel/app/Services/DiscountCodeService.php new file mode 100644 index 0000000..8a4764f --- /dev/null +++ b/04_Laravel/app/Services/DiscountCodeService.php @@ -0,0 +1,135 @@ +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; + } + } +} diff --git a/04_Laravel/app/Services/PriceCalculatorService.php b/04_Laravel/app/Services/PriceCalculatorService.php index 64d6833..a5d2c91 100644 --- a/04_Laravel/app/Services/PriceCalculatorService.php +++ b/04_Laravel/app/Services/PriceCalculatorService.php @@ -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) { diff --git a/04_Laravel/database/migrations/2026_10_31_100000_create_discount_code_usages_table.php b/04_Laravel/database/migrations/2026_10_31_100000_create_discount_code_usages_table.php new file mode 100644 index 0000000..291dc56 --- /dev/null +++ b/04_Laravel/database/migrations/2026_10_31_100000_create_discount_code_usages_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/04_Laravel/tests/Feature/Api/DiscountCodeControllerTest.php b/04_Laravel/tests/Feature/Api/DiscountCodeControllerTest.php index f57d8c0..6961b83 100644 --- a/04_Laravel/tests/Feature/Api/DiscountCodeControllerTest.php +++ b/04_Laravel/tests/Feature/Api/DiscountCodeControllerTest.php @@ -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() { diff --git a/04_Laravel/tests/Feature/Services/DiscountCodeServiceTest.php b/04_Laravel/tests/Feature/Services/DiscountCodeServiceTest.php new file mode 100644 index 0000000..b816bf5 --- /dev/null +++ b/04_Laravel/tests/Feature/Services/DiscountCodeServiceTest.php @@ -0,0 +1,184 @@ +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) + ); + } +} diff --git a/04_Laravel/tests/Feature/Services/PriceCalculatorServiceTest.php b/04_Laravel/tests/Feature/Services/PriceCalculatorServiceTest.php index b4dff6f..5160daf 100644 --- a/04_Laravel/tests/Feature/Services/PriceCalculatorServiceTest.php +++ b/04_Laravel/tests/Feature/Services/PriceCalculatorServiceTest.php @@ -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']); } -} \ No newline at end of file + + #[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']); + } +} diff --git a/AGENT.md b/AGENT.md index 1bb33e7..18ce802 100644 --- a/AGENT.md +++ b/AGENT.md @@ -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) | diff --git a/CLIENT_DELIVERY.md b/CLIENT_DELIVERY.md index b2cbd8c..b558518 100644 --- a/CLIENT_DELIVERY.md +++ b/CLIENT_DELIVERY.md @@ -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 (آینده) diff --git a/DEPLOYMENT_HANDOFF.md b/DEPLOYMENT_HANDOFF.md index afab747..add2988 100644 --- a/DEPLOYMENT_HANDOFF.md +++ b/DEPLOYMENT_HANDOFF.md @@ -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 تست شد - [ ] بارگذاری فایل (آپلود تعهدنامه) کار می‌کند - [ ] بکاپ دیتابیس تنظیم شده است diff --git a/README-INSTALL.md b/README-INSTALL.md new file mode 100644 index 0000000..09d2e1a --- /dev/null +++ b/README-INSTALL.md @@ -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های وردپرس، تنظیم درگاه واقعی، لاگ روزانه، کش‌ها).