96 lines
3.8 KiB
PHP
96 lines
3.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\BalanceTransaction;
|
||
use App\Models\PricingTier;
|
||
use App\Models\Tenant;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Идемпотентная одноразовая миграция: balance_leads → balance_rub по цене ступени 1.
|
||
*
|
||
* Запускается ОДИН РАЗ в проде после деплоя Billing v2 Spec A Phase A. Повторный
|
||
* запуск — no-op (тенанты с balance_leads=0 уже не обрабатываются).
|
||
*
|
||
* Per-tenant атомарность: lockForUpdate(Tenant) внутри DB::transaction.
|
||
*
|
||
* Spec: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §4.4
|
||
* Plan: docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md Task A.11
|
||
*/
|
||
final class BillingMigrateLeadsToRubCommand extends Command
|
||
{
|
||
protected $signature = 'billing:migrate-leads-to-rub';
|
||
|
||
protected $description = 'Convert legacy balance_leads to balance_rub at tier 1 price (idempotent, run once in prod)';
|
||
|
||
public function handle(): int
|
||
{
|
||
$tier1 = PricingTier::query()
|
||
->where('is_active', true)
|
||
->where('tier_no', 1)
|
||
->where('effective_from', '<=', Carbon::now('Europe/Moscow')->toDateString())
|
||
->orderBy('effective_from', 'desc')
|
||
->first();
|
||
|
||
if ($tier1 === null) {
|
||
$this->error('No active tier 1 found. Aborting.');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$count = 0;
|
||
|
||
Tenant::query()
|
||
->where('balance_leads', '>', 0)
|
||
->chunkById(100, function ($tenants) use ($tier1, &$count): void {
|
||
foreach ($tenants as $tenant) {
|
||
DB::transaction(function () use ($tenant, $tier1, &$count): void {
|
||
/** @var Tenant|null $locked */
|
||
$locked = Tenant::query()
|
||
->whereKey($tenant->id)
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
if ($locked === null || (int) $locked->balance_leads <= 0) {
|
||
return; // idempotency — already migrated or zero
|
||
}
|
||
|
||
$migratedLeads = (int) $locked->balance_leads;
|
||
$migratedKopecks = bcmul((string) $migratedLeads, (string) $tier1->price_per_lead_kopecks, 0);
|
||
$migratedRub = bcdiv((string) $migratedKopecks, '100', 2);
|
||
$newBalanceRub = bcadd((string) $locked->balance_rub, $migratedRub, 2);
|
||
|
||
DB::table('tenants')
|
||
->where('id', $locked->id)
|
||
->update([
|
||
'balance_rub' => $newBalanceRub,
|
||
'balance_leads' => 0,
|
||
]);
|
||
|
||
BalanceTransaction::create([
|
||
'tenant_id' => $locked->id,
|
||
'type' => BalanceTransaction::TYPE_MIGRATION,
|
||
'amount_leads' => -$migratedLeads,
|
||
'amount_rub' => $migratedRub,
|
||
'balance_leads_after' => 0,
|
||
'balance_rub_after' => $newBalanceRub,
|
||
'description' => 'Конвертация предоплаченных лидов в ₽ (миграция модели биллинга Spec A)',
|
||
'created_at' => now(),
|
||
]);
|
||
|
||
$count++;
|
||
});
|
||
}
|
||
});
|
||
|
||
$this->info("Migrated {$count} tenant(s).");
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
}
|