From 040d25423d1d4dcc89b39282beee2292c5e77e49 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 07:08:09 +0300 Subject: [PATCH] feat(billing): wallet/transactions/invoices read API (E3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/billing/wallet (баланс + тариф + runway), /transactions (пагинированный balance_transactions с фильтром type), /invoices (saas_invoices, real-but-empty до Б-1). TariffPlan модель + Tenant::tariff() relation + BalanceTransactionFactory. Sprint 2 Plan C, audit E3 (backend). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controllers/Api/BillingController.php | 125 +++++++++++++ app/app/Models/BalanceTransaction.php | 5 + app/app/Models/TariffPlan.php | 54 ++++++ app/app/Models/Tenant.php | 7 + .../factories/BalanceTransactionFactory.php | 34 ++++ app/phpstan-baseline.neon | 30 ++++ app/routes/web.php | 3 + .../Billing/BillingOverviewControllerTest.php | 165 ++++++++++++++++++ 8 files changed, 423 insertions(+) create mode 100644 app/app/Models/TariffPlan.php create mode 100644 app/database/factories/BalanceTransactionFactory.php create mode 100644 app/tests/Feature/Billing/BillingOverviewControllerTest.php diff --git a/app/app/Http/Controllers/Api/BillingController.php b/app/app/Http/Controllers/Api/BillingController.php index 3fec0564..9efa63a9 100644 --- a/app/app/Http/Controllers/Api/BillingController.php +++ b/app/app/Http/Controllers/Api/BillingController.php @@ -5,10 +5,13 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\BalanceTransaction; +use App\Models\Tenant; use App\Models\User; use App\Services\Billing\BillingTopupService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; /** * Биллинг тенанта — кошелёк, транзакции, счета, пополнение (audit E1/E3). @@ -57,4 +60,126 @@ class BillingController extends Controller 'balance_rub' => $tx->balance_rub_after, ], 201); } + + /** + * GET /api/billing/wallet — балансы тенанта + текущий тариф + runway. + */ + public function wallet(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + /** @var Tenant $tenant */ + $tenant = Tenant::query()->with('tariff')->findOrFail($user->tenant_id); + + return response()->json([ + 'balance_rub' => $tenant->balance_rub, + 'balance_leads' => $tenant->balance_leads, + 'runway_days' => $this->runwayDays($tenant), + 'tariff' => $tenant->tariff === null ? null : [ + 'code' => $tenant->tariff->code, + 'name' => $tenant->tariff->name, + 'price_monthly' => $tenant->tariff->price_monthly, + 'billing_model' => $tenant->tariff->billing_model, + 'features' => $tenant->tariff->features ?? [], + ], + ]); + } + + /** + * GET /api/billing/transactions?type=topup|lead_charge|refund&page=N + * — пагинированная история balance_transactions тенанта (20/страница). + */ + public function transactions(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + $tenantId = (int) $user->tenant_id; + + // Явный tenant_id фильтр — defense-in-depth поверх RLS (тесты идут + // под superuser BYPASSRLS; паттерн TenantChargesController). + $query = BalanceTransaction::query() + ->where('tenant_id', $tenantId) + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc'); + + $type = $request->query('type'); + if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'refund'], true)) { + $query->where('type', $type); + } + + $page = $query->paginate(20); + + return response()->json([ + 'data' => array_map(static fn (BalanceTransaction $tx): array => [ + 'id' => $tx->id, + 'code' => 'TX-'.$tx->id, + 'type' => $tx->type, + 'description' => $tx->description, + 'amount_rub' => $tx->amount_rub, + 'amount_leads' => $tx->amount_leads, + 'balance_rub_after' => $tx->balance_rub_after, + 'created_at' => $tx->created_at, + ], $page->items()), + 'meta' => [ + 'current_page' => $page->currentPage(), + 'last_page' => $page->lastPage(), + 'total' => $page->total(), + 'per_page' => $page->perPage(), + ], + ]); + } + + /** + * GET /api/billing/invoices — счета тенанта (saas_invoices). + * + * Real-but-empty на MVP: saas_invoices.legal_entity_id NOT NULL требует + * зарегистрированного юр-лица (блокируется Б-1). Read-only выборка через + * DB::table — без Eloquent-модели (паттерн AdminBillingController). + */ + public function invoices(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + $tenantId = (int) $user->tenant_id; + + $rows = DB::table('saas_invoices') + ->where('tenant_id', $tenantId) + ->orderBy('issued_at', 'desc') + ->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'pdf_path']); + + return response()->json([ + 'data' => $rows->map(static fn (\stdClass $r): array => [ + 'id' => $r->id, + 'invoice_number' => $r->invoice_number, + 'amount_total' => $r->amount_total, + 'status' => $r->status, + 'issued_at' => $r->issued_at, + 'has_pdf' => $r->pdf_path !== null, + ])->all(), + ]); + } + + /** + * Прогноз «на сколько дней хватит баланса» — оценочный UX-показатель. + * + * = balance_rub / (рублёвые списания за 30 дней / 30). NULL, если списаний + * не было. Float здесь допустим: грубая оценка для шапки, НЕ мутация + * баланса (мутации баланса — строго bcmath, см. BillingTopupService). + */ + private function runwayDays(Tenant $tenant): ?int + { + $spent = abs((float) DB::table('balance_transactions') + ->where('tenant_id', $tenant->id) + ->where('type', BalanceTransaction::TYPE_LEAD_CHARGE) + ->where('created_at', '>=', now()->subDays(30)) + ->sum('amount_rub')); + + if ($spent <= 0.0) { + return null; + } + + $perDay = $spent / 30.0; + + return (int) floor((float) $tenant->balance_rub / $perDay); + } } diff --git a/app/app/Models/BalanceTransaction.php b/app/app/Models/BalanceTransaction.php index d79f6487..ec67c18c 100644 --- a/app/app/Models/BalanceTransaction.php +++ b/app/app/Models/BalanceTransaction.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Models; +use Database\Factories\BalanceTransactionFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -19,6 +21,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; */ class BalanceTransaction extends Model { + /** @use HasFactory */ + use HasFactory; + public const TYPE_TRIAL_BONUS = 'trial_bonus'; public const TYPE_TOPUP = 'topup'; diff --git a/app/app/Models/TariffPlan.php b/app/app/Models/TariffPlan.php new file mode 100644 index 00000000..2a979d97 --- /dev/null +++ b/app/app/Models/TariffPlan.php @@ -0,0 +1,54 @@ + 'decimal:2', + 'price_monthly' => 'decimal:2', + 'included_leads' => 'integer', + 'limits' => 'array', + 'features' => 'array', + 'trial_bonus_leads' => 'integer', + 'is_active' => 'boolean', + 'is_public' => 'boolean', + 'sort_order' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } +} diff --git a/app/app/Models/Tenant.php b/app/app/Models/Tenant.php index 855a4e9f..bd933023 100644 --- a/app/app/Models/Tenant.php +++ b/app/app/Models/Tenant.php @@ -7,6 +7,7 @@ namespace App\Models; use Database\Factories\TenantFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -80,4 +81,10 @@ class Tenant extends Model { return $this->hasMany(Project::class); } + + /** @return BelongsTo */ + public function tariff(): BelongsTo + { + return $this->belongsTo(TariffPlan::class, 'current_tariff_id'); + } } diff --git a/app/database/factories/BalanceTransactionFactory.php b/app/database/factories/BalanceTransactionFactory.php new file mode 100644 index 00000000..769fe84b --- /dev/null +++ b/app/database/factories/BalanceTransactionFactory.php @@ -0,0 +1,34 @@ + + */ +class BalanceTransactionFactory extends Factory +{ + protected $model = BalanceTransaction::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'type' => BalanceTransaction::TYPE_TOPUP, + 'amount_rub' => '100.00', + 'amount_leads' => 0, + 'balance_rub_after' => '100.00', + 'balance_leads_after' => 0, + 'description' => 'Тестовая транзакция', + 'created_at' => now(), + ]; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index b18d49f0..f01d8393 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -102,6 +102,12 @@ parameters: count: 1 path: app/Services/Project/ProjectService.php + - + message: '#^Return type \(array\\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/BalanceTransactionFactory.php + - message: '#^Return type \(array\\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' identifier: method.childReturnType @@ -510,6 +516,30 @@ parameters: count: 6 path: tests/Feature/Auth/UpdateProfileTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 10 + path: tests/Feature/Billing/BillingOverviewControllerTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Billing/BillingOverviewControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Billing/BillingOverviewControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 17 + path: tests/Feature/Billing/BillingOverviewControllerTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#' identifier: property.notFound diff --git a/app/routes/web.php b/app/routes/web.php index 44db414f..e4b6ad66 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -129,6 +129,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->g // 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'); + Route::get('/wallet', 'App\Http\Controllers\Api\BillingController@wallet'); + Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions'); + Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices'); }); // API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware. diff --git a/app/tests/Feature/Billing/BillingOverviewControllerTest.php b/app/tests/Feature/Billing/BillingOverviewControllerTest.php new file mode 100644 index 00000000..e89a6fc4 --- /dev/null +++ b/app/tests/Feature/Billing/BillingOverviewControllerTest.php @@ -0,0 +1,165 @@ +tenant = Tenant::factory()->create([ + 'balance_rub' => '14250.00', + 'balance_leads' => 285, + ]); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +// ---- wallet ---- + +test('GET /api/billing/wallet возвращает баланс тенанта', function () { + $this->getJson('/api/billing/wallet') + ->assertOk() + ->assertJsonPath('balance_rub', '14250.00') + ->assertJsonPath('balance_leads', 285); +}); + +test('GET /api/billing/wallet возвращает тариф, если он назначен', function () { + $tariffId = DB::table('tariff_plans')->where('code', 'pro')->value('id'); + $this->tenant->update(['current_tariff_id' => $tariffId]); + + $response = $this->getJson('/api/billing/wallet'); + + $response->assertOk() + ->assertJsonPath('tariff.code', 'pro') + ->assertJsonPath('tariff.name', 'Про'); + expect($response->json('tariff.features'))->toBeArray(); +}); + +test('GET /api/billing/wallet возвращает tariff=null без назначенного тарифа', function () { + $this->getJson('/api/billing/wallet') + ->assertOk() + ->assertJsonPath('tariff', null); +}); + +test('GET /api/billing/wallet: runway_days = null без списаний', function () { + $this->getJson('/api/billing/wallet') + ->assertOk() + ->assertJsonPath('runway_days', null); +}); + +test('GET /api/billing/wallet: runway_days рассчитан при наличии списаний', function () { + BalanceTransaction::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'type' => 'lead_charge', + 'amount_rub' => '-3000.00', + 'created_at' => now()->subDays(10), + ]); + + // 3000 ₽ / 30 дн = 100 ₽/день; баланс 14250 → floor(142.5) = 142. + expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(142); +}); + +test('GET /api/billing/wallet без auth: 401', function () { + auth()->logout(); + $this->getJson('/api/billing/wallet')->assertStatus(401); +}); + +// ---- transactions ---- + +test('GET /api/billing/transactions возвращает транзакции тенанта', function () { + BalanceTransaction::factory()->count(3)->create(['tenant_id' => $this->tenant->id]); + + $response = $this->getJson('/api/billing/transactions'); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(3); + expect($response->json('meta.total'))->toBe(3); + expect($response->json('data.0'))->toHaveKeys(['id', 'code', 'type', 'amount_rub', 'created_at']); +}); + +test('GET /api/billing/transactions изолирован по тенанту', function () { + BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id]); + $other = Tenant::factory()->create(); + BalanceTransaction::factory()->create(['tenant_id' => $other->id]); + + expect($this->getJson('/api/billing/transactions')->json('data'))->toHaveCount(1); +}); + +test('GET /api/billing/transactions фильтрует по type', function () { + BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'topup']); + BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'lead_charge', 'amount_rub' => '-50.00']); + BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'refund', 'amount_rub' => '10.00']); + + $this->getJson('/api/billing/transactions?type=topup')->assertJsonCount(1, 'data'); + $this->getJson('/api/billing/transactions?type=lead_charge')->assertJsonCount(1, 'data'); + $this->getJson('/api/billing/transactions?type=refund')->assertJsonCount(1, 'data'); +}); + +test('GET /api/billing/transactions: пагинация 20/страница', function () { + BalanceTransaction::factory()->count(25)->create(['tenant_id' => $this->tenant->id]); + + expect($this->getJson('/api/billing/transactions?page=1')->json('data'))->toHaveCount(20); + expect($this->getJson('/api/billing/transactions?page=2')->json('data'))->toHaveCount(5); +}); + +test('GET /api/billing/transactions без auth: 401', function () { + auth()->logout(); + $this->getJson('/api/billing/transactions')->assertStatus(401); +}); + +// ---- invoices ---- + +test('GET /api/billing/invoices возвращает пустой список без счетов', function () { + $this->getJson('/api/billing/invoices') + ->assertOk() + ->assertJsonCount(0, 'data'); +}); + +test('GET /api/billing/invoices возвращает счета тенанта и изолирует чужие', function () { + $leId = DB::table('legal_entities')->insertGetId([ + 'code' => 'ooo_test_'.uniqid(), + 'name' => 'ООО Тест', + 'legal_form' => 'OOO', + 'inn' => '7700000000', + 'created_at' => now(), + ]); + DB::table('saas_invoices')->insert([ + 'tenant_id' => $this->tenant->id, + 'legal_entity_id' => $leId, + 'invoice_number' => 'СЧ-2026-00001', + 'payer_type' => 'legal', + 'amount_net' => '990.00', + 'amount_total' => '990.00', + 'status' => 'issued', + 'issued_at' => now(), + 'expires_at' => now()->addDays(5), + ]); + $other = Tenant::factory()->create(); + DB::table('saas_invoices')->insert([ + 'tenant_id' => $other->id, + 'legal_entity_id' => $leId, + 'invoice_number' => 'СЧ-2026-00002', + 'payer_type' => 'legal', + 'amount_net' => '500.00', + 'amount_total' => '500.00', + 'status' => 'issued', + 'issued_at' => now(), + 'expires_at' => now()->addDays(5), + ]); + + $response = $this->getJson('/api/billing/invoices'); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(1); + expect($response->json('data.0.invoice_number'))->toBe('СЧ-2026-00001'); +}); + +test('GET /api/billing/invoices без auth: 401', function () { + auth()->logout(); + $this->getJson('/api/billing/invoices')->assertStatus(401); +});