feat(billing): wallet/transactions/invoices read API (E3)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BalanceTransactionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_TRIAL_BONUS = 'trial_bonus';
|
||||
|
||||
public const TYPE_TOPUP = 'topup';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Тарифный план SaaS-портала (каталог tariff_plans).
|
||||
*
|
||||
* Сидится из db/schema.sql (4 стартовых плана: start/basic/pro/enterprise).
|
||||
* Read-mostly: редактируется только админкой SaaS. Tenant ссылается через
|
||||
* tenants.current_tariff_id (см. Tenant::tariff()).
|
||||
*
|
||||
* Источник: db/schema.sql §20.2.1, table `tariff_plans`.
|
||||
*
|
||||
* @mixin IdeHelperTariffPlan
|
||||
*/
|
||||
class TariffPlan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'billing_model',
|
||||
'price_per_lead',
|
||||
'price_monthly',
|
||||
'included_leads',
|
||||
'limits',
|
||||
'features',
|
||||
'trial_bonus_leads',
|
||||
'is_active',
|
||||
'is_public',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price_per_lead' => '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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<TariffPlan, $this> */
|
||||
public function tariff(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TariffPlan::class, 'current_tariff_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<BalanceTransaction>
|
||||
*/
|
||||
class BalanceTransactionFactory extends Factory
|
||||
{
|
||||
protected $model = BalanceTransaction::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,12 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/Project/ProjectService.php
|
||||
|
||||
-
|
||||
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
|
||||
identifier: method.childReturnType
|
||||
count: 1
|
||||
path: database/factories/BalanceTransactionFactory.php
|
||||
|
||||
-
|
||||
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Project, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Project\>\:\: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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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);
|
||||
});
|
||||
Reference in New Issue
Block a user