feat(billing): topup ledger service + POST /api/billing/topup stub (E1)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Биллинг тенанта — кошелёк, транзакции, счета, пополнение (audit E1/E3).
|
||||
*
|
||||
* Все эндпоинты под middleware [auth:sanctum, tenant] (RLS-контекст).
|
||||
* Отдельно от TenantChargesController (lead_charges ledger) и
|
||||
* AdminBillingController (SaaS-уровневые агрегаты).
|
||||
*
|
||||
* E1: POST /api/billing/topup — MVP-stub пополнения (без платёжного шлюза).
|
||||
* E3: GET wallet/transactions/invoices — данные для BillingView Overview.
|
||||
*/
|
||||
class BillingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BillingTopupService $topupService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/billing/topup — пополнить рублёвый баланс.
|
||||
*
|
||||
* MVP-stub: кредитует баланс немедленно (без ЮKassa — реальная оплата
|
||||
* post-Б-1). Записывает append-only строку balance_transactions(topup).
|
||||
*/
|
||||
public function topup(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
|
||||
/**
|
||||
* Сервис пополнения рублёвого баланса тенанта (audit E1).
|
||||
*
|
||||
* MVP-stub: кредитует tenants.balance_rub немедленно и пишет строку
|
||||
* balance_transactions(type='topup'). Реальная оплата через платёжный
|
||||
* шлюз — post-Б-1 (требует реквизитов ООО), здесь НЕ интегрирована.
|
||||
*
|
||||
* Контракт: вызывается ВНУТРИ транзакции (middleware `tenant` оборачивает
|
||||
* HTTP-запрос в DB-транзакцию). lockForUpdate на строке tenant защищает от
|
||||
* lost-update при конкурентных topup/charge.
|
||||
*
|
||||
* balance_transactions защищена hash-chain триггером (BEFORE INSERT
|
||||
* audit_chain_hash) — log_hash заполняется автоматически. UPDATE/DELETE
|
||||
* на таблице запрещены триггером audit_block_mutation, поэтому каждое
|
||||
* пополнение — отдельная append-only строка; существующие не меняются.
|
||||
*/
|
||||
final class BillingTopupService
|
||||
{
|
||||
/**
|
||||
* Пополнить рублёвый баланс тенанта.
|
||||
*
|
||||
* @param int $tenantId ID тенанта.
|
||||
* @param string $amountRub Сумма пополнения, DECIMAL-строка («100.00»).
|
||||
* @param int|null $userId Кто инициировал (NULL — системное).
|
||||
* @return BalanceTransaction Созданная append-only строка ledger'а.
|
||||
*/
|
||||
public function topup(int $tenantId, string $amountRub, ?int $userId): BalanceTransaction
|
||||
{
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
test('topup кредитует balance_rub и пишет append-only строку topup', function () {
|
||||
$tenant = Tenant::factory()->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();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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);
|
||||
});
|
||||
Reference in New Issue
Block a user