diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index d6eeb52c..6f3a831f 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -10,7 +10,10 @@ use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\UpdateProjectRequest; use App\Http\Resources\ProjectResource; use App\Jobs\SyncSupplierProjectJob; +use App\Models\PricingTier; use App\Models\Project; +use App\Models\Tenant; +use App\Services\Billing\BalancePreflightService; use App\Services\Project\ProjectService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -117,7 +120,35 @@ class ProjectController extends Controller /** POST /api/projects */ public function store(StoreProjectRequest $request): JsonResponse { - $project = $this->projects->create($request->user()->tenant, $request->validated()); + $validated = $request->validated(); + $tenant = $request->user()->tenant; + $forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false); + unset($validated['force_save_blocked']); + + // Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные. + $existingLimit = (int) Project::where('tenant_id', $tenant->id) + ->where('is_active', true) + ->whereNull('preflight_blocked_at') + ->sum('daily_limit_target'); + $wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target']; + + $preflight = $this->runPreflight($tenant, $wouldBeRequired); + + if (! $preflight['passes'] && ! $forceSaveBlocked) { + return response()->json([ + 'error' => 'balance_insufficient', + 'current_balance_rub' => (string) $tenant->balance_rub, + 'current_capacity_leads' => $preflight['capacity_leads'], + 'would_be_required_leads' => $wouldBeRequired, + 'deficit_leads' => $preflight['deficit_leads'], + ], 409); + } + + if (! $preflight['passes'] && $forceSaveBlocked) { + $validated['preflight_blocked_at'] = now(); + } + + $project = $this->projects->create($tenant, $validated); return response()->json(['data' => new ProjectResource($project)], 201); } @@ -126,11 +157,70 @@ class ProjectController extends Controller public function update(UpdateProjectRequest $request, int $id): JsonResponse { $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); - $updated = $this->projects->update($project, $request->validated()); + $validated = $request->validated(); + $tenant = $request->user()->tenant; + $forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false); + unset($validated['force_save_blocked']); + + // Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО + // проекта + лимиты остальных активных не-blocked. + if (array_key_exists('daily_limit_target', $validated)) { + $existingLimit = (int) Project::where('tenant_id', $tenant->id) + ->where('id', '!=', $project->id) + ->where('is_active', true) + ->whereNull('preflight_blocked_at') + ->sum('daily_limit_target'); + $wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target']; + + $preflight = $this->runPreflight($tenant, $wouldBeRequired); + + if (! $preflight['passes'] && ! $forceSaveBlocked) { + return response()->json([ + 'error' => 'balance_insufficient', + 'current_balance_rub' => (string) $tenant->balance_rub, + 'current_capacity_leads' => $preflight['capacity_leads'], + 'would_be_required_leads' => $wouldBeRequired, + 'deficit_leads' => $preflight['deficit_leads'], + ], 409); + } + + if (! $preflight['passes'] && $forceSaveBlocked) { + $validated['preflight_blocked_at'] = now(); + } + } + + $updated = $this->projects->update($project, $validated); return response()->json(['data' => new ProjectResource($updated)]); } + /** + * @return array{passes: bool, capacity_leads: int, deficit_leads: int} + */ + private function runPreflight(Tenant $tenant, int $requiredLeads): array + { + $tiers = PricingTier::query()->where('is_active', true)->get(); + + // Safe fallback: без активных pricing_tiers биллинг не настроен — + // преfflight не имеет смысла, пропускаем (legacy-окружения / тесты). + if ($tiers->isEmpty()) { + return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0]; + } + + $result = (new BalancePreflightService)->evaluate( + balanceRub: (string) $tenant->balance_rub, + deliveredInMonth: (int) $tenant->delivered_in_month, + requiredLeads: $requiredLeads, + tiers: $tiers, + ); + + return [ + 'passes' => $result->passes, + 'capacity_leads' => $result->capacityLeads, + 'deficit_leads' => $result->deficitLeads, + ]; + } + /** GET /api/projects/{id} */ public function show(Request $request, int $id): JsonResponse { diff --git a/app/app/Http/Requests/StoreProjectRequest.php b/app/app/Http/Requests/StoreProjectRequest.php index 632297bf..92f6ba01 100644 --- a/app/app/Http/Requests/StoreProjectRequest.php +++ b/app/app/Http/Requests/StoreProjectRequest.php @@ -28,6 +28,9 @@ class StoreProjectRequest extends FormRequest 'regions' => ['present', 'array'], 'regions.*' => ['integer', 'between:1,89'], 'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'], + // Spec C §3.4: при перегрузке преfflight UI шлёт force_save_blocked=true → + // проект создаётся с preflight_blocked_at=now() вместо ответа 409. + 'force_save_blocked' => ['sometimes', 'boolean'], ]; if ($signalType === 'site') { diff --git a/app/app/Http/Requests/UpdateProjectRequest.php b/app/app/Http/Requests/UpdateProjectRequest.php index 052c643f..dc094347 100644 --- a/app/app/Http/Requests/UpdateProjectRequest.php +++ b/app/app/Http/Requests/UpdateProjectRequest.php @@ -28,6 +28,9 @@ class UpdateProjectRequest extends FormRequest 'sms_senders' => ['sometimes', 'array', 'min:1'], 'sms_senders.*' => ['string', 'max:11'], 'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'], + // Spec C §3.4: при перегрузке преfflight UI шлёт force_save_blocked=true → + // проект помечается preflight_blocked_at=now() вместо ответа 409. + 'force_save_blocked' => ['sometimes', 'boolean'], ]; // 18.05.2026 UX: редактирование источника (signal_identifier) для site/call. diff --git a/app/tests/Feature/Billing/ProjectPreflightTest.php b/app/tests/Feature/Billing/ProjectPreflightTest.php new file mode 100644 index 00000000..5a813455 --- /dev/null +++ b/app/tests/Feature/Billing/ProjectPreflightTest.php @@ -0,0 +1,107 @@ +create([ + 'tier_no' => 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 5000, // 50₽/лид — capacity = balance/50 + 'is_active' => true, + 'effective_from' => now(), + ]); +}); + +it('returns 409 when new project would overload balance', function () { + // 1000₽ / 50₽ = 20 лидов capacity; запрашиваем daily_limit_target=30 → дефицит 10. + $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'Перегрузка', + 'signal_type' => 'site', + 'signal_identifier' => 'overload.ru', + 'daily_limit_target' => 30, + 'regions' => [], + 'delivery_days_mask' => 127, + ]); + + $response->assertStatus(409); + $response->assertJsonPath('error', 'balance_insufficient'); + $response->assertJsonPath('deficit_leads', 10); + $response->assertJsonPath('current_capacity_leads', 20); + $response->assertJsonPath('would_be_required_leads', 30); + expect(Project::where('signal_identifier', 'overload.ru')->exists())->toBeFalse(); +}); + +it('creates blocked project when force_save_blocked=true', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'Заблокированный', + 'signal_type' => 'site', + 'signal_identifier' => 'force-blocked.ru', + 'daily_limit_target' => 30, + 'regions' => [], + 'delivery_days_mask' => 127, + 'force_save_blocked' => true, + ]); + + $response->assertCreated(); + $project = Project::where('signal_identifier', 'force-blocked.ru')->first(); + expect($project)->not->toBeNull(); + expect($project->preflight_blocked_at)->not->toBeNull(); +}); + +it('creates normally when within balance', function () { + // 2000₽ / 50₽ = 40 лидов capacity; daily_limit_target=30 — passes. + $tenant = Tenant::factory()->create(['balance_rub' => '2000.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'Норма', + 'signal_type' => 'site', + 'signal_identifier' => 'ok-balance.ru', + 'daily_limit_target' => 30, + 'regions' => [], + 'delivery_days_mask' => 127, + ]); + + $response->assertCreated(); + $project = Project::where('signal_identifier', 'ok-balance.ru')->first(); + expect($project->preflight_blocked_at)->toBeNull(); +}); + +it('returns 409 on update when increased limit overloads balance', function () { + // существующий проект на 15 лидов, всё ок (capacity 20). + $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'daily_limit_target' => 15, + ]); + + // UPDATE до 30 → suma 30 > capacity 20 → 409. + $response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'daily_limit_target' => 30, + ]); + + $response->assertStatus(409); + $response->assertJsonPath('error', 'balance_insufficient'); + expect($project->fresh()->daily_limit_target)->toBe(15); // не изменилось +});