From adabcf15a4e175de0d6fadd77b49a6c518d22f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 16 May 2026 13:17:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20G4=20backend=20=E2=80=94=20billi?= =?UTF-8?q?ng=20tenant=20actions=20(status/refund/tariff)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../Api/AdminBillingController.php | 165 ++++++++++++++++++ .../Concerns/ResolvesAdminUserId.php | 47 +++++ app/phpstan-baseline.neon | 18 ++ app/routes/web.php | 9 + app/tests/Feature/AdminBillingActionsTest.php | 117 +++++++++++++ 5 files changed, 356 insertions(+) create mode 100644 app/app/Http/Controllers/Concerns/ResolvesAdminUserId.php create mode 100644 app/tests/Feature/AdminBillingActionsTest.php diff --git a/app/app/Http/Controllers/Api/AdminBillingController.php b/app/app/Http/Controllers/Api/AdminBillingController.php index 87cc54e3..0168272e 100644 --- a/app/app/Http/Controllers/Api/AdminBillingController.php +++ b/app/app/Http/Controllers/Api/AdminBillingController.php @@ -4,7 +4,10 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; +use App\Http\Controllers\Concerns\ResolvesAdminUserId; use App\Http\Controllers\Controller; +use App\Models\BalanceTransaction; +use App\Models\SaasAdminAuditLog; use Carbon\CarbonImmutable; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -19,6 +22,168 @@ use Illuminate\Support\Facades\DB; */ class AdminBillingController extends Controller { + use ResolvesAdminUserId; + + /** GET /api/admin/billing/tariff-plans — список планов для диалога смены тарифа. */ + public function tariffPlans(): JsonResponse + { + $plans = DB::table('tariff_plans') + ->select(['id', 'name', 'price_monthly']) + ->orderBy('price_monthly') + ->get() + ->map(fn ($p) => [ + 'id' => (int) $p->id, + 'name' => $p->name, + 'price_monthly' => (string) $p->price_monthly, + ]); + + return response()->json(['plans' => $plans]); + } + + /** PATCH /api/admin/billing/tenants/{id}/status — приостановить/разблокировать тенанта. */ + public function updateStatus(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'status' => ['required', 'in:active,suspended'], + 'reason' => ['required', 'string', 'min:10', 'max:1000'], + ]); + + $tenant = $this->findActiveTenant($id); + $adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot'); + + DB::transaction(function () use ($tenant, $validated, $adminUserId, $request): void { + DB::table('tenants')->where('id', $tenant->id)->update([ + 'status' => $validated['status'], + 'updated_at' => now(), + ]); + + SaasAdminAuditLog::create([ + 'admin_user_id' => $adminUserId, + 'action' => $validated['status'] === 'suspended' ? 'tenant.suspend' : 'tenant.activate', + 'target_type' => 'tenant', + 'target_id' => $tenant->id, + 'target_tenant_id' => $tenant->id, + 'payload_before' => ['status' => $tenant->status], + 'payload_after' => ['status' => $validated['status']], + 'reason' => $validated['reason'], + 'ip_address' => $request->ip() ?? '127.0.0.1', + 'user_agent' => $request->userAgent(), + ]); + }); + + return response()->json(['id' => $tenant->id, 'status' => $validated['status']]); + } + + /** POST /api/admin/billing/tenants/{id}/refund — возврат средств: списание с баланса + ledger-запись. */ + public function refund(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'amount_rub' => ['required', 'numeric', 'gt:0'], + 'reason' => ['required', 'string', 'min:10', 'max:1000'], + ]); + + $tenant = $this->findActiveTenant($id); + + $amount = number_format((float) $validated['amount_rub'], 2, '.', ''); + $balance = (string) $tenant->balance_rub; + if (bccomp($amount, $balance, 2) === 1) { + abort(422, 'refund amount exceeds tenant balance'); + } + $newBalance = bcsub($balance, $amount, 2); + $adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot'); + + $transactionId = DB::transaction(function () use ($tenant, $amount, $newBalance, $validated, $adminUserId, $request): int { + DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id); + + DB::table('tenants')->where('id', $tenant->id)->update([ + 'balance_rub' => $newBalance, + 'updated_at' => now(), + ]); + + $tx = BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_REFUND, + 'amount_rub' => '-'.$amount, + 'amount_leads' => 0, + 'balance_rub_after' => $newBalance, + 'description' => $validated['reason'], + 'admin_user_id' => $adminUserId, + 'created_at' => now(), + ]); + + SaasAdminAuditLog::create([ + 'admin_user_id' => $adminUserId, + 'action' => 'tenant.refund', + 'target_type' => 'tenant', + 'target_id' => $tenant->id, + 'target_tenant_id' => $tenant->id, + 'payload_before' => ['balance_rub' => (string) $tenant->balance_rub], + 'payload_after' => ['balance_rub' => $newBalance, 'amount_rub' => $amount, 'transaction_id' => $tx->id], + 'reason' => $validated['reason'], + 'ip_address' => $request->ip() ?? '127.0.0.1', + 'user_agent' => $request->userAgent(), + ]); + + return (int) $tx->id; + }); + + return response()->json([ + 'id' => $tenant->id, + 'balance_rub' => $newBalance, + 'transaction_id' => $transactionId, + ]); + } + + /** PATCH /api/admin/billing/tenants/{id}/tariff — сменить тарифный план тенанта. */ + public function changeTariff(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'tariff_id' => ['required', 'integer', 'exists:tariff_plans,id'], + 'reason' => ['required', 'string', 'min:10', 'max:1000'], + ]); + + $tenant = $this->findActiveTenant($id); + $tariff = DB::table('tariff_plans')->where('id', $validated['tariff_id'])->first(); + $adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot'); + + DB::transaction(function () use ($tenant, $tariff, $validated, $adminUserId, $request): void { + DB::table('tenants')->where('id', $tenant->id)->update([ + 'current_tariff_id' => $tariff->id, + 'updated_at' => now(), + ]); + + SaasAdminAuditLog::create([ + 'admin_user_id' => $adminUserId, + 'action' => 'tenant.change_tariff', + 'target_type' => 'tenant', + 'target_id' => $tenant->id, + 'target_tenant_id' => $tenant->id, + 'payload_before' => ['current_tariff_id' => $tenant->current_tariff_id], + 'payload_after' => ['current_tariff_id' => (int) $tariff->id], + 'reason' => $validated['reason'], + 'ip_address' => $request->ip() ?? '127.0.0.1', + 'user_agent' => $request->userAgent(), + ]); + }); + + return response()->json([ + 'id' => $tenant->id, + 'tariff_id' => (int) $tariff->id, + 'tariff_name' => $tariff->name, + ]); + } + + /** Возвращает не-удалённого тенанта либо abort(404). */ + private function findActiveTenant(int $id): object + { + $tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')->first(); + if ($tenant === null) { + abort(404, 'tenant not found'); + } + + return $tenant; + } + /** GET /api/admin/billing?search= */ public function index(Request $request): JsonResponse { diff --git a/app/app/Http/Controllers/Concerns/ResolvesAdminUserId.php b/app/app/Http/Controllers/Concerns/ResolvesAdminUserId.php new file mode 100644 index 00000000..0cb71676 --- /dev/null +++ b/app/app/Http/Controllers/Concerns/ResolvesAdminUserId.php @@ -0,0 +1,47 @@ +input('admin_user_id'); + if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) { + $existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id'); + if ($existing !== null) { + return (int) $existing; + } + } + + $existingId = DB::table('saas_admin_users')->where('email', $stubEmail)->value('id'); + if ($existingId !== null) { + return (int) $existingId; + } + + return (int) DB::table('saas_admin_users')->insertGetId([ + 'email' => $stubEmail, + 'full_name' => $stubName, + 'password_hash' => '$2y$04$system-stub-not-loginable', + 'role' => 'super_admin', + 'is_active' => false, + 'sso_provider' => 'local', + 'is_break_glass' => false, + ]); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index a65e59c6..e13a1dd5 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -180,6 +180,24 @@ parameters: count: 3 path: tests/Feature/Admin/AdminSuppliersControllerTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/AdminBillingActionsTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#' + identifier: method.notFound + count: 7 + path: tests/Feature/AdminBillingActionsTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/AdminBillingActionsTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound diff --git a/app/routes/web.php b/app/routes/web.php index 1b9a5f8a..e3366d40 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -98,6 +98,15 @@ Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTena // SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц. Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index'); +// Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа. +Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans'); +Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus') + ->where('id', '[0-9]+'); +Route::post('/api/admin/billing/tenants/{id}/refund', 'App\Http\Controllers\Api\AdminBillingController@refund') + ->where('id', '[0-9]+'); +Route::patch('/api/admin/billing/tenants/{id}/tariff', 'App\Http\Controllers\Api\AdminBillingController@changeTariff') + ->where('id', '[0-9]+'); + // SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView. Route::get('/api/admin/incidents', 'App\Http\Controllers\Api\AdminIncidentsController@index'); diff --git a/app/tests/Feature/AdminBillingActionsTest.php b/app/tests/Feature/AdminBillingActionsTest.php new file mode 100644 index 00000000..49a640df --- /dev/null +++ b/app/tests/Feature/AdminBillingActionsTest.php @@ -0,0 +1,117 @@ +insertGetId(array_merge([ + 'subdomain' => 'bt-'.bin2hex(random_bytes(4)), + 'organization_name' => 'Billing Test Co', + 'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local', + 'webhook_token' => bin2hex(random_bytes(16)), + 'status' => 'active', + 'balance_rub' => '5000.00', + 'is_trial' => false, + 'created_at' => now(), + ], $overrides)); +} + +test('GET tariff-plans возвращает список планов', function () { + $r = $this->getJson('/api/admin/billing/tariff-plans'); + $r->assertOk(); + expect($r->json('plans'))->toBeArray(); +}); + +test('PATCH status suspended меняет статус + пишет audit-log', function () { + $id = makeBillingTenant(['status' => 'active']); + $r = $this->patchJson("/api/admin/billing/tenants/{$id}/status", [ + 'status' => 'suspended', + 'reason' => 'Просрочка оплаты более 30 дней.', + ]); + $r->assertOk(); + expect($r->json('status'))->toBe('suspended'); + expect(DB::table('tenants')->where('id', $id)->value('status'))->toBe('suspended'); + expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.suspend')->where('target_id', $id)->exists())->toBeTrue(); +}); + +test('PATCH status active разблокирует', function () { + $id = makeBillingTenant(['status' => 'suspended']); + $this->patchJson("/api/admin/billing/tenants/{$id}/status", [ + 'status' => 'active', 'reason' => 'Оплата получена, блокировка снята.', + ])->assertOk(); + expect(DB::table('tenants')->where('id', $id)->value('status'))->toBe('active'); +}); + +test('PATCH status reason короче 10 символов → 422', function () { + $id = makeBillingTenant(); + $this->patchJson("/api/admin/billing/tenants/{$id}/status", ['status' => 'suspended', 'reason' => 'мало']) + ->assertStatus(422); +}); + +test('PATCH status несуществующий тенант → 404', function () { + $this->patchJson('/api/admin/billing/tenants/99999999/status', [ + 'status' => 'suspended', 'reason' => 'Любое основание длиной более десяти.', + ])->assertStatus(404); +}); + +test('PATCH status soft-deleted тенант → 404', function () { + $id = makeBillingTenant(['deleted_at' => now()]); + $this->patchJson("/api/admin/billing/tenants/{$id}/status", [ + 'status' => 'suspended', 'reason' => 'Любое основание длиной более десяти.', + ])->assertStatus(404); +}); + +test('POST refund списывает с баланса + создаёт balance_transactions refund', function () { + $id = makeBillingTenant(['balance_rub' => '5000.00']); + $r = $this->postJson("/api/admin/billing/tenants/{$id}/refund", [ + 'amount_rub' => 1500, 'reason' => 'Возврат по обращению клиента №42.', + ]); + $r->assertOk(); + expect($r->json('balance_rub'))->toBe('3500.00'); + expect(DB::table('tenants')->where('id', $id)->value('balance_rub'))->toBe('3500.00'); + $tx = BalanceTransaction::where('tenant_id', $id)->where('type', 'refund')->first(); + expect($tx)->not->toBeNull(); + expect((string) $tx->amount_rub)->toBe('-1500.00'); + expect((string) $tx->balance_rub_after)->toBe('3500.00'); + expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.refund')->where('target_id', $id)->exists())->toBeTrue(); +}); + +test('POST refund больше баланса → 422, баланс не меняется', function () { + $id = makeBillingTenant(['balance_rub' => '1000.00']); + $this->postJson("/api/admin/billing/tenants/{$id}/refund", [ + 'amount_rub' => 5000, 'reason' => 'Возврат по обращению клиента №7.', + ])->assertStatus(422); + expect(DB::table('tenants')->where('id', $id)->value('balance_rub'))->toBe('1000.00'); + expect(BalanceTransaction::where('tenant_id', $id)->count())->toBe(0); +}); + +test('POST refund неположительная сумма → 422', function () { + $id = makeBillingTenant(); + $this->postJson("/api/admin/billing/tenants/{$id}/refund", ['amount_rub' => 0, 'reason' => 'Основание длиннее десяти символов.']) + ->assertStatus(422); +}); + +test('PATCH tariff меняет current_tariff_id + audit-log', function () { + $id = makeBillingTenant(); + $tariffId = (int) DB::table('tariff_plans')->orderBy('id')->value('id'); + expect($tariffId)->toBeGreaterThan(0); + $r = $this->patchJson("/api/admin/billing/tenants/{$id}/tariff", [ + 'tariff_id' => $tariffId, 'reason' => 'Переход на тариф по договорённости с клиентом.', + ]); + $r->assertOk(); + expect((int) DB::table('tenants')->where('id', $id)->value('current_tariff_id'))->toBe($tariffId); + expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.change_tariff')->where('target_id', $id)->exists())->toBeTrue(); +}); + +test('PATCH tariff несуществующий tariff_id → 422', function () { + $id = makeBillingTenant(); + $this->patchJson("/api/admin/billing/tenants/{$id}/tariff", [ + 'tariff_id' => 88888888, 'reason' => 'Основание длиннее десяти символов.', + ])->assertStatus(422); +});