From 4e4ebc567a32b1d881a67b098f53679f9e92d025 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 06:46:52 +0300 Subject: [PATCH] feat(billing): topup ledger service + POST /api/billing/topup stub (E1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BillingTopupService кредитует tenants.balance_rub (bcmath) и пишет append-only строку balance_transactions(type='topup'). BillingController + route POST /api/billing/topup под [auth:sanctum, tenant]. MVP-stub: без платёжного шлюза (ЮKassa — post-Б-1). Sprint 2 Plan C, audit E1 (backend). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controllers/Api/BillingController.php | 60 ++++++++++++++++ .../Services/Billing/BillingTopupService.php | 59 ++++++++++++++++ app/phpstan-baseline.neon | 30 ++++++++ app/routes/web.php | 6 ++ .../Billing/BillingTopupServiceTest.php | 54 +++++++++++++++ .../Feature/Billing/TopupControllerTest.php | 68 +++++++++++++++++++ 6 files changed, 277 insertions(+) create mode 100644 app/app/Http/Controllers/Api/BillingController.php create mode 100644 app/app/Services/Billing/BillingTopupService.php create mode 100644 app/tests/Feature/Billing/BillingTopupServiceTest.php create mode 100644 app/tests/Feature/Billing/TopupControllerTest.php diff --git a/app/app/Http/Controllers/Api/BillingController.php b/app/app/Http/Controllers/Api/BillingController.php new file mode 100644 index 00000000..3fec0564 --- /dev/null +++ b/app/app/Http/Controllers/Api/BillingController.php @@ -0,0 +1,60 @@ +validate([ + 'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'], + ]); + + /** @var User $user */ + $user = $request->user(); + + // Нормализуем в DECIMAL-строку scale 2 для bcmath (НЕ float). + $amountRub = bcadd((string) $validated['amount_rub'], '0', 2); + + $tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id); + + return response()->json([ + 'transaction' => [ + 'id' => $tx->id, + 'type' => $tx->type, + 'amount_rub' => $tx->amount_rub, + 'balance_rub_after' => $tx->balance_rub_after, + 'created_at' => $tx->created_at, + ], + 'balance_rub' => $tx->balance_rub_after, + ], 201); + } +} diff --git a/app/app/Services/Billing/BillingTopupService.php b/app/app/Services/Billing/BillingTopupService.php new file mode 100644 index 00000000..5683961e --- /dev/null +++ b/app/app/Services/Billing/BillingTopupService.php @@ -0,0 +1,59 @@ +lockForUpdate()->findOrFail($tenantId); + + // bcadd — DECIMAL-точность, НЕ PHP float (паттерн LedgerService). + $newBalanceRub = bcadd((string) $tenant->balance_rub, $amountRub, 2); + + $tenant->balance_rub = $newBalanceRub; + $tenant->save(); + + return BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_TOPUP, + 'amount_rub' => $amountRub, + 'amount_leads' => 0, + 'balance_rub_after' => $newBalanceRub, + 'balance_leads_after' => (int) $tenant->balance_leads, + 'description' => 'Пополнение баланса', + 'user_id' => $userId, + 'created_at' => now(), + ]); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index d970b190..e71edca3 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -564,6 +564,36 @@ parameters: count: 1 path: tests/Feature/Billing/TenantChargesControllerTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/Billing/TopupControllerTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/Billing/TopupControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Billing/TopupControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Billing/TopupControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 7 + path: tests/Feature/Billing/TopupControllerTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' identifier: method.notFound diff --git a/app/routes/web.php b/app/routes/web.php index 5bf72bf7..44db414f 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -125,6 +125,12 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->g Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export'); }); +// Биллинг тенанта: пополнение/кошелёк/транзакции/счета (audit E1/E3). +// RLS на balance_transactions / saas_invoices требует tenant middleware. +Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(function () { + Route::post('/topup', 'App\Http\Controllers\Api\BillingController@topup'); +}); + // API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware. Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(function () { Route::get('/', 'App\Http\Controllers\Api\ApiKeyController@index'); diff --git a/app/tests/Feature/Billing/BillingTopupServiceTest.php b/app/tests/Feature/Billing/BillingTopupServiceTest.php new file mode 100644 index 00000000..2440656c --- /dev/null +++ b/app/tests/Feature/Billing/BillingTopupServiceTest.php @@ -0,0 +1,54 @@ +create(['balance_rub' => '500.00', 'balance_leads' => 7]); + + $tx = app(BillingTopupService::class)->topup($tenant->id, '250.00', null); + + expect($tx->type)->toBe('topup') + ->and($tx->amount_rub)->toBe('250.00') + ->and($tx->amount_leads)->toBe(0) + ->and($tx->balance_rub_after)->toBe('750.00') + ->and($tx->balance_leads_after)->toBe(7); + + expect((string) $tenant->fresh()->balance_rub)->toBe('750.00'); +}); + +test('topup использует bcmath — нет float-дрейфа на 0.1 + 0.2', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '0.10']); + + app(BillingTopupService::class)->topup($tenant->id, '0.20', null); + + expect((string) $tenant->fresh()->balance_rub)->toBe('0.30'); +}); + +test('topup фиксирует user_id инициатора', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $tx = app(BillingTopupService::class)->topup($tenant->id, '100.00', $user->id); + + expect($tx->user_id)->toBe($user->id); +}); + +test('topup-строка получает log_hash через append-only hash-chain триггер', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + + $tx = app(BillingTopupService::class)->topup($tenant->id, '100.00', null); + + $hasHash = DB::table('balance_transactions') + ->where('id', $tx->id) + ->whereNotNull('log_hash') + ->exists(); + expect($hasHash)->toBeTrue(); +}); diff --git a/app/tests/Feature/Billing/TopupControllerTest.php b/app/tests/Feature/Billing/TopupControllerTest.php new file mode 100644 index 00000000..832734aa --- /dev/null +++ b/app/tests/Feature/Billing/TopupControllerTest.php @@ -0,0 +1,68 @@ +tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'balance_leads' => 12]); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +test('POST /api/billing/topup кредитует баланс и возвращает 201', function () { + $response = $this->postJson('/api/billing/topup', ['amount_rub' => 250]); + + $response->assertStatus(201) + ->assertJsonPath('balance_rub', '750.00') + ->assertJsonPath('transaction.type', 'topup') + ->assertJsonPath('transaction.amount_rub', '250.00'); + + expect((string) $this->tenant->fresh()->balance_rub)->toBe('750.00'); +}); + +test('POST /api/billing/topup пишет строку balance_transactions с user_id', function () { + $this->postJson('/api/billing/topup', ['amount_rub' => 100])->assertStatus(201); + + $this->assertDatabaseHas('balance_transactions', [ + 'tenant_id' => $this->tenant->id, + 'user_id' => $this->user->id, + 'type' => 'topup', + 'amount_rub' => '100.00', + ]); +}); + +test('POST /api/billing/topup использует bcmath-точность', function () { + $this->tenant->update(['balance_rub' => '0.10']); + + $this->postJson('/api/billing/topup', ['amount_rub' => 100.20])->assertStatus(201); + + expect((string) $this->tenant->fresh()->balance_rub)->toBe('100.30'); +}); + +test('POST /api/billing/topup отклоняет сумму ниже минимума 100 ₽', function () { + $this->postJson('/api/billing/topup', ['amount_rub' => 50]) + ->assertStatus(422) + ->assertJsonValidationErrors('amount_rub'); +}); + +test('POST /api/billing/topup отклоняет отсутствующую сумму', function () { + $this->postJson('/api/billing/topup', []) + ->assertStatus(422) + ->assertJsonValidationErrors('amount_rub'); +}); + +test('POST /api/billing/topup отклоняет более 2 знаков после запятой', function () { + $this->postJson('/api/billing/topup', ['amount_rub' => 100.123]) + ->assertStatus(422) + ->assertJsonValidationErrors('amount_rub'); +}); + +test('POST /api/billing/topup без auth: 401', function () { + auth()->logout(); + $this->postJson('/api/billing/topup', ['amount_rub' => 100])->assertStatus(401); +});