Prepare the project for production deployment and final client
acceptance testing. Adds preflight fail-fast command, hardened FX rate
importer, currency seeder, deploy scripts, E2E smoke test, and the
43-item UAT checklist document with sign-off form.
Key changes:
- Phase 3.9.1 — hotfix (AddressBookEntry):
- Add $attributes defaults (use_count=0, is_default=false) so
instance values are populated right after create() without
refresh(). Resolves the red test
`it_increments_use_count_and_sets_last_used_at`.
- Phase 3.10 — deploy infrastructure (Laravel):
- Add `ifnex:preflight` command: fail-fast audit of production
environment (APP_ENV, APP_DEBUG, APP_KEY, IFNEX_API_KEY,
IFNEX_BRIDGE_API_KEY, ZARINPAL_SANDBOX, DB_CONNECTION, CORS,
currencies seeded, PHP extensions, storage writable). Exits 1
if any check fails; supports --skip-db for pre-migrate runs.
- Add `ifnex:import-fx-rates` command: replacement for
`ifnex:update-rates`. Fetches USD/AED from Exir.io + EUR/CNY
from FreeCurrencyAPI. Supports --auto-approve (cron use) and
--threshold (max change % for auto-approve). Logs critical on
HTTP failure. URL configurable via EXIR_API_URL env.
- Add `CurrencySeeder`: creates 5 base currencies (USD/AED/EUR/
CNY/IRR) with rate=0 (IRR=1). Idempotent — does not overwrite
real rates already set by admin or cron.
- Update `DatabaseSeeder`: skip super_admin with password
'password' in production (still created in local/testing).
- Fix `bootstrap/app.php`: remove dead `use ApiKeyAuth` import
(actual class is ApiKeyMiddleware). Latent fatal if api_key
alias was used.
- Add `config/ifnex.php` exir block + preflight config.
- Harden `.env.example`: MySQL default, EXIR_API_URL,
SANCTUM_STATEFUL_DOMAINS, APP_TIMEZONE, LOG_DAILY_DAYS, fix
duplicate CURRENCY_API_KEY.
- Phase 3.10 — deploy scripts (Linux):
- Add `scripts/deploy.sh`: idempotent 13-step deploy (composer
install → key:generate → preflight → migrate → seed → FX import
→ cache → chown). Fail-fast with set -e.
- Add `scripts/cron-exir-rates.sh`: daily cron wrapper for
`ifnex:import-fx-rates --source=all --auto-approve`.
- Phase 3.10.1 — preflight hotfix:
- Make expected DB connection configurable
(ifnex.preflight.expected_db_connection) instead of hardcoded
'mysql'. Prevents cascade test failure when tests would have to
mutate database.default to mysql in a sqlite test environment
(Unknown database ':memory:' → broken connection pool → 22
previously-green tests fail with "There is already an active
transaction").
- Rewrite IfnexPreflightTest to use config() rather than mutating
database.default.
- Phase 3.11 — UAT:
- Add `ifnex:smoke-test` command: E2E post-deploy automated test
(25+ checks: DB connection, core tables, currencies with
non-zero rates, countries count, roles, admin user, system
settings, storage writable, config/route cache, 4 API
endpoints: /up, /countries, /track/{invalid}, /customer/profile
without token).
- Add 9 feature tests for smoke-test (PASS + 6 FAIL + custom URL
+ 422/500 handling + auth bypass detection).
- Add `UAT-3.11.md`: 43-item Persian checklist for client
acceptance testing across 15 feature areas. Includes sign-off
form for both parties (client + VernaSoft).
- Update `CLIENT_DELIVERY.md` to record phases 3.10 and 3.11.
Tests:
- Total: 51 → 82 tests, 150 → 220+ assertions, all green.
- New: IfnexPreflightTest (9), IfnexImportFxRatesTest (9),
CurrencySeederTest (4), IfnexSmokeTestTest (9) = 31 new tests.
Documentation:
- DEPLOYMENT_HANDOFF.md §2.4: add CurrencySeeder + import-fx-rates.
- DEPLOYMENT_HANDOFF.md §2.8: update cron to use cron-exir-rates.sh.
- UAT-3.11.md: new 43-item client acceptance checklist + sign-off.
- CLIENT_DELIVERY.md: record phases 3.10 and 3.11 completion.
336 lines
13 KiB
PHP
336 lines
13 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\Currency;
|
||
use App\Models\ExchangeRateHistory;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Throwable;
|
||
|
||
/**
|
||
* IfnexImportFxRates — فاز ۳.۱۰
|
||
*
|
||
* جایگزین و نسخهٔ ارتقایافتهٔ `ifnex:update-rates`. تفاوتها:
|
||
* - پشتیبانی از EUR و CNY (علاوه بر USD و AED) از طریق freecurrencyapi.com
|
||
* - پرچم `--auto-approve` برای استفادهٔ cron: نرخهای sub-threshold خودکار
|
||
* تأیید و در جدول currencies نوشته میشوند (نه فقط در صف pending).
|
||
* - URL اکسیر از env خوانده میشود (نه hardcode).
|
||
* - در صورت شکست HTTP، Log::critical صدا زده میشود.
|
||
* - اگه جدول currencies خالی باشد، خطای واضح میدهد (نه سکوت).
|
||
*
|
||
* نمونهٔ استفاده:
|
||
* php artisan ifnex:import-fx-rates
|
||
* php artisan ifnex:import-fx-rates --source=exir
|
||
* php artisan ifnex:import-fx-rates --source=exir --auto-approve
|
||
* php artisan ifnex:import-fx-rates --source=all --auto-approve # برای cron
|
||
*
|
||
* برای cron روزانه (DEPLOYMENT_HANDOFF.md §۲.۸):
|
||
* 0 2 * * * cd /var/www/system.ifnex.ir && php artisan ifnex:import-fx-rates --source=all --auto-approve
|
||
*/
|
||
class IfnexImportFxRates extends Command
|
||
{
|
||
protected $signature = 'ifnex:import-fx-rates
|
||
{--source=exir : exir|freecurrencyapi|all}
|
||
{--auto-approve : نرخهای زیر آستانه را خودکار تأیید و در currencies بنویس}
|
||
{--threshold=5 : درصد حداکثر تغییر مجاز برای auto-approve (پیشفرض ۵٪)}';
|
||
|
||
protected $description = 'دریافت نرخ ارز از Exir.io (USD/AED) و/یا FreeCurrencyAPI (EUR/CNY) و ذخیره در دیتابیس';
|
||
|
||
/** آستانهٔ percent تغییر برای ثبت رکورد جدید (٪) */
|
||
private const CHANGE_THRESHOLD_PCT = 0.5;
|
||
|
||
/** کدهایی که از Exir.io دریافت شدند — تا FreeCurrencyAPI آنها را overwrite نکند */
|
||
private array $ratesFromExir = [];
|
||
|
||
public function handle(): int
|
||
{
|
||
$source = (string) $this->option('source');
|
||
$autoApprove = (bool) $this->option('auto-approve');
|
||
$threshold = (float) $this->option('threshold');
|
||
|
||
if (! in_array($source, ['exir', 'freecurrencyapi', 'all'], true)) {
|
||
$this->error("مقدار --source نامعتبر است. مجاز: exir, freecurrencyapi, all");
|
||
|
||
return Command::INVALID;
|
||
}
|
||
|
||
$this->warnIfCurrenciesMissing();
|
||
|
||
$rates = [];
|
||
|
||
if ($source === 'exir' || $source === 'all') {
|
||
$exirRates = $this->fetchFromExir();
|
||
$rates = array_merge($rates, $exirRates);
|
||
// ثبت کدهایی که از Exir آمدند تا FreeCurrencyAPI آنها را overwrite نکند
|
||
foreach (array_keys($exirRates) as $code) {
|
||
$this->ratesFromExir[$code] = true;
|
||
}
|
||
}
|
||
|
||
if ($source === 'freecurrencyapi' || $source === 'all') {
|
||
$rates = array_merge($rates, $this->fetchFromFreeCurrencyApi());
|
||
}
|
||
|
||
if (empty($rates)) {
|
||
$this->error('❌ هیچ نرخی دریافت نشد. بررسی لاگ برای جزئیات.');
|
||
Log::critical('ifnex:import-fx-rates — هیچ نرخی دریافت نشد.', [
|
||
'source' => $source,
|
||
]);
|
||
|
||
return Command::FAILURE;
|
||
}
|
||
|
||
$created = 0;
|
||
$approved = 0;
|
||
|
||
foreach ($rates as $code => $rate) {
|
||
[$c, $a] = $this->upsertRate($code, $rate, $autoApprove, $threshold);
|
||
$created += $c;
|
||
$approved += $a;
|
||
}
|
||
|
||
$this->newLine();
|
||
$this->info("✅ دریافت نرخ انجام شد. کل ارزها: " . count($rates));
|
||
$this->line(" رکورد جدید در exchange_rate_history: {$created}");
|
||
$this->line(" نرخهای تأییدشده در currencies: {$approved}");
|
||
|
||
if (! $autoApprove) {
|
||
$this->newLine();
|
||
$this->warn('ℹ️ نرخها بهصورت pending ثبت شدند. برای اعمال فوری:');
|
||
$this->line(' php artisan ifnex:import-fx-rates --source=all --auto-approve');
|
||
$this->line(' یا از پنل ادمین ← تاریخچه نرخ ارز ← تأیید.');
|
||
}
|
||
|
||
return Command::SUCCESS;
|
||
}
|
||
|
||
// ─── Exir.io ────────────────────────────────────────────
|
||
|
||
private function fetchFromExir(): array
|
||
{
|
||
$url = (string) (config('ifnex.exir.api_url') ?? 'https://api.exir.io/v2/minicharts');
|
||
$timeout = (int) (config('ifnex.exir.timeout') ?? 15);
|
||
|
||
$this->line("→ در حال دریافت از Exir.io: {$url}");
|
||
|
||
try {
|
||
$response = Http::timeout($timeout)->get($url);
|
||
} catch (Throwable $e) {
|
||
$this->error("خطا در اتصال به Exir.io: {$e->getMessage()}");
|
||
Log::critical('ifnex:import-fx-rates — Exir.io unreachable', [
|
||
'url' => $url,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
|
||
return [];
|
||
}
|
||
|
||
if ($response->failed()) {
|
||
$this->error("Exir.io پاسخ ناموفق داد: HTTP {$response->status()}");
|
||
Log::critical('ifnex:import-fx-rates — Exir.io HTTP failure', [
|
||
'url' => $url,
|
||
'status' => $response->status(),
|
||
]);
|
||
|
||
return [];
|
||
}
|
||
|
||
$data = $response->json();
|
||
$rates = [];
|
||
|
||
// نگاشت جفتارزهای اکسیر به کدهای استاندارد ما
|
||
$symbolMap = [
|
||
'usdt-rls' => 'USD', // 1 دلار = X ریال (قیمت مستقیم ریالی)
|
||
'usdt-aed' => 'AED', // 1 درهم = X تتر → باید در نرخ دلار ضرب شود
|
||
];
|
||
|
||
foreach ($data as $symbol => $info) {
|
||
if (! isset($symbolMap[$symbol])) {
|
||
continue;
|
||
}
|
||
|
||
$code = $symbolMap[$symbol];
|
||
$price = (float) ($info['price'] ?? 0);
|
||
|
||
if ($price <= 0) {
|
||
continue;
|
||
}
|
||
|
||
if ($code === 'USD') {
|
||
$rates['USD'] = round($price, 2);
|
||
}
|
||
|
||
// برای درهم: قیمت بر اساس تتر است؛ باید در نرخ دلار ضرب شود تا ریالی شود
|
||
if ($code === 'AED' && isset($rates['USD'])) {
|
||
$rates['AED'] = round($price * $rates['USD'], 2);
|
||
}
|
||
}
|
||
|
||
$this->line(' Exir.io دریافت شد: ' . json_encode($rates, JSON_UNESCAPED_UNICODE));
|
||
|
||
return $rates;
|
||
}
|
||
|
||
// ─── FreeCurrencyAPI (EUR/CNY) ──────────────────────────
|
||
|
||
private function fetchFromFreeCurrencyApi(): array
|
||
{
|
||
$apiKey = (string) config('ifnex.currency.api_key');
|
||
|
||
if ($apiKey === '' || $apiKey === 'your_api_key_here') {
|
||
$this->warn(' CURRENCY_API_KEY تنظیم نشده — پرش از منبع freecurrencyapi.');
|
||
$this->line(' برای دریافت EUR/CNY لازم است: https://app.freecurrencyapi.com/');
|
||
|
||
return [];
|
||
}
|
||
|
||
$url = (string) config('ifnex.currency.api_url');
|
||
$base = (string) config('ifnex.currency.base_currency', 'USD');
|
||
|
||
$fullUrl = "{$url}?apikey={$apiKey}&base_currency={$base}¤cies=IRR,EUR,CNY";
|
||
|
||
$this->line("→ در حال دریافت از FreeCurrencyAPI: {$url}");
|
||
|
||
try {
|
||
$response = Http::timeout(15)->get($fullUrl);
|
||
} catch (Throwable $e) {
|
||
$this->error("خطا در اتصال به FreeCurrencyAPI: {$e->getMessage()}");
|
||
Log::critical('ifnex:import-fx-rates — FreeCurrencyAPI unreachable', [
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
|
||
return [];
|
||
}
|
||
|
||
if ($response->failed()) {
|
||
$this->error("FreeCurrencyAPI پاسخ ناموفق داد: HTTP {$response->status()}");
|
||
Log::critical('ifnex:import-fx-rates — FreeCurrencyAPI HTTP failure', [
|
||
'status' => $response->status(),
|
||
]);
|
||
|
||
return [];
|
||
}
|
||
|
||
$data = $response->json('data', []);
|
||
$rates = [];
|
||
|
||
// تبدیل نسبت دلار به ریال: free_currency_api نرخ 1USD = X IRR میدهد
|
||
if (isset($data['IRR']) && $data['IRR'] > 0) {
|
||
$usdToIrr = (float) $data['IRR'];
|
||
|
||
// USD را overwrite نکن اگر از Exir آمده — Exir در ایران دقیقتر است.
|
||
// فقط اگه Exir نداشتیم، از اینجا پر کن.
|
||
if (! isset($this->ratesFromExir['USD'])) {
|
||
$rates['USD'] = round($usdToIrr, 2);
|
||
}
|
||
|
||
// EUR = usd_to_eur × usd_to_irr
|
||
if (isset($data['EUR']) && $data['EUR'] > 0) {
|
||
$rates['EUR'] = round(((float) $data['EUR']) * $usdToIrr, 2);
|
||
}
|
||
|
||
// CNY = usd_to_cny × usd_to_irr
|
||
if (isset($data['CNY']) && $data['CNY'] > 0) {
|
||
$rates['CNY'] = round(((float) $data['CNY']) * $usdToIrr, 2);
|
||
}
|
||
}
|
||
|
||
$this->line(' FreeCurrencyAPI دریافت شد: ' . json_encode($rates, JSON_UNESCAPED_UNICODE));
|
||
|
||
return $rates;
|
||
}
|
||
|
||
// ─── Upsert نرخ ─────────────────────────────────────────
|
||
|
||
/**
|
||
* @return array{0:int,1:int} [records_created_in_history, records_approved_in_currencies]
|
||
*/
|
||
private function upsertRate(string $code, float $newRate, bool $autoApprove, float $threshold): array
|
||
{
|
||
$code = strtoupper($code);
|
||
$currency = Currency::where('code', $code)->first();
|
||
|
||
if (! $currency) {
|
||
$this->warn(" ارز {$code} در جدول currencies وجود ندارد — پرش.");
|
||
Log::warning("ifnex:import-fx-rates — currency {$code} not found in table.");
|
||
return [0, 0];
|
||
}
|
||
|
||
$oldRate = (float) $currency->rate_to_rial;
|
||
|
||
// اگر نرخ صفر است (اولین بار)، بدون بررسی threshold بنویس
|
||
if ($oldRate <= 0) {
|
||
return $this->applyApproved($currency, $oldRate, $newRate, $code, source: $this->currentSource($code));
|
||
}
|
||
|
||
$changePct = abs($newRate - $oldRate) / $oldRate * 100;
|
||
|
||
// اگر تغییر قابلتوجه نیست (>0.5٪)، نادیده بگیر
|
||
if ($changePct < self::CHANGE_THRESHOLD_PCT) {
|
||
$this->line(" {$code}: تغییر {$changePct}٪ < آستانه — پرش.");
|
||
return [0, 0];
|
||
}
|
||
|
||
// اگر تغییر بیش از threshold auto-approve است، حتی با auto-approve فقط pending بساز
|
||
if ($autoApprove && $changePct <= $threshold) {
|
||
return $this->applyApproved($currency, $oldRate, $newRate, $code, source: $this->currentSource($code));
|
||
}
|
||
|
||
// در غیر این صورت، فقط رکورد pending بساز
|
||
ExchangeRateHistory::create([
|
||
'currency_from' => $code,
|
||
'currency_to' => 'IRR',
|
||
'old_rate' => $oldRate,
|
||
'new_rate' => $newRate,
|
||
'change_percent' => round((($newRate - $oldRate) / $oldRate) * 100, 4),
|
||
'change_source' => 'api',
|
||
'api_source' => $this->currentSource($code),
|
||
'status' => 'pending',
|
||
]);
|
||
|
||
$this->line(" {$code}: رکورد pending ساخته شد (تغییر {$changePct}٪).");
|
||
|
||
return [1, 0];
|
||
}
|
||
|
||
private function applyApproved(Currency $currency, float $oldRate, float $newRate, string $code, string $source): array
|
||
{
|
||
$currency->update(['rate_to_rial' => $newRate]);
|
||
|
||
ExchangeRateHistory::create([
|
||
'currency_from' => $code,
|
||
'currency_to' => 'IRR',
|
||
'old_rate' => $oldRate,
|
||
'new_rate' => $newRate,
|
||
'change_percent' => $oldRate > 0 ? round((($newRate - $oldRate) / $oldRate) * 100, 4) : null,
|
||
'change_source' => 'api',
|
||
'api_source' => $source,
|
||
'approved_at' => now(),
|
||
'status' => 'approved',
|
||
]);
|
||
|
||
$this->line(" {$code}: نرخ در currencies نوشته شد (auto-approved، منبع {$source}).");
|
||
|
||
return [1, 1];
|
||
}
|
||
|
||
private function currentSource(string $code): string
|
||
{
|
||
return in_array($code, ['USD', 'AED'], true) ? 'exir.io' : 'freecurrencyapi.com';
|
||
}
|
||
|
||
// ─── هشدار در صورت خالی بودن جدول currencies ────────────
|
||
|
||
private function warnIfCurrenciesMissing(): void
|
||
{
|
||
$count = Currency::count();
|
||
if ($count === 0) {
|
||
$this->warn('⚠️ جدول currencies خالی است! ابتدا اجرا کنید:');
|
||
$this->line(' php artisan db:seed --class=CurrencySeeder --force');
|
||
$this->newLine();
|
||
}
|
||
}
|
||
}
|