192 lines
7.6 KiB
PHP
192 lines
7.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\PricingTier;
|
|
use App\Models\SaasAdminAuditLog;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* SaaS-admin CRUD для pricing_tiers (7-ступенчатый тариф).
|
|
*
|
|
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §6.1.
|
|
* Без auth-middleware на MVP (паритет с другими /api/admin/*; gated на Б-1).
|
|
* Audit trail в saas_admin_audit_log (action='pricing_tiers.create_scheduled' /
|
|
* 'pricing_tiers.delete_scheduled').
|
|
*
|
|
* Конструкция audit-log:
|
|
* - schema.saas_admin_audit_log требует NOT NULL admin_user_id и ip_address;
|
|
* на MVP admin_user_id берётся из request param (как в
|
|
* AdminSystemSettingsController), либо создаётся системный стаб
|
|
* `system-pricing@liderra.local`. ip_address — $request->ip() или '127.0.0.1'.
|
|
* - payload_after хранит {effective_from, tiers} для воспроизводимости.
|
|
*/
|
|
final class AdminPricingTiersController extends Controller
|
|
{
|
|
/** GET /api/admin/pricing-tiers */
|
|
public function index(): JsonResponse
|
|
{
|
|
$today = Carbon::now('Europe/Moscow')->toDateString();
|
|
|
|
$active = PricingTier::query()->where('is_active', true)
|
|
->where('effective_from', '<=', $today)
|
|
->orderBy('tier_no')->orderBy('effective_from', 'desc')
|
|
->get()
|
|
->groupBy('tier_no')
|
|
->map(fn ($g) => $g->first())
|
|
->values();
|
|
|
|
$scheduled = PricingTier::query()->where('is_active', true)
|
|
->where('effective_from', '>', $today)
|
|
->orderBy('effective_from')->orderBy('tier_no')
|
|
->get()
|
|
->groupBy(fn (PricingTier $t) => $t->effective_from->toDateString());
|
|
|
|
return response()->json([
|
|
'data' => [
|
|
'active' => $active,
|
|
'scheduled' => $scheduled,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/** POST /api/admin/pricing-tiers */
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
$todayMsk = Carbon::now('Europe/Moscow')->toDateString();
|
|
|
|
$request->validate([
|
|
'tiers' => ['required', 'array', 'size:7'],
|
|
'tiers.*.tier_no' => ['required', 'integer', 'between:1,7'],
|
|
'tiers.*.leads_in_tier' => ['nullable', 'integer', 'min:1'],
|
|
'tiers.*.price_rub' => ['required', 'numeric', 'min:0'],
|
|
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],
|
|
]);
|
|
|
|
/** @var array<int, array{tier_no:int, leads_in_tier:?int, price_rub:string|float}> $tiers */
|
|
$tiers = $request->input('tiers');
|
|
|
|
$tierNos = array_column($tiers, 'tier_no');
|
|
if (count(array_unique($tierNos)) !== 7) {
|
|
abort(422, 'tier_no must be unique 1..7');
|
|
}
|
|
if (array_diff([1, 2, 3, 4, 5, 6, 7], $tierNos) !== []) {
|
|
abort(422, 'all 7 tier_no values required');
|
|
}
|
|
|
|
$tier7 = collect($tiers)->firstWhere('tier_no', 7);
|
|
if ($tier7['leads_in_tier'] !== null) {
|
|
abort(422, 'tier_no=7 leads_in_tier must be null');
|
|
}
|
|
|
|
foreach ($tiers as $tier) {
|
|
if ($tier['tier_no'] !== 7 && ($tier['leads_in_tier'] === null || $tier['leads_in_tier'] < 1)) {
|
|
abort(422, "tier_no={$tier['tier_no']} leads_in_tier must be >= 1");
|
|
}
|
|
}
|
|
|
|
$effectiveFrom = $request->input('effective_from')
|
|
?? Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
|
|
$adminUserId = $this->resolveAdminUserId($request);
|
|
|
|
DB::transaction(function () use ($tiers, $effectiveFrom, $adminUserId, $request): void {
|
|
foreach ($tiers as $tier) {
|
|
PricingTier::create([
|
|
'tier_no' => $tier['tier_no'],
|
|
'leads_in_tier' => $tier['leads_in_tier'],
|
|
'price_per_lead_kopecks' => (int) round(((float) $tier['price_rub']) * 100),
|
|
'is_active' => true,
|
|
'effective_from' => $effectiveFrom,
|
|
]);
|
|
}
|
|
|
|
SaasAdminAuditLog::create([
|
|
'admin_user_id' => $adminUserId,
|
|
'action' => 'pricing_tiers.create_scheduled',
|
|
'target_type' => 'pricing_tiers',
|
|
'target_id' => null,
|
|
'payload_before' => null,
|
|
'payload_after' => ['effective_from' => $effectiveFrom, 'tiers' => $tiers],
|
|
'reason' => 'Scheduled pricing-tier update via admin UI.',
|
|
'ip_address' => $request->ip() ?? '127.0.0.1',
|
|
'user_agent' => $request->userAgent(),
|
|
]);
|
|
});
|
|
|
|
return response()->json(['effective_from' => $effectiveFrom], Response::HTTP_CREATED);
|
|
}
|
|
|
|
/** DELETE /api/admin/pricing-tiers/scheduled/{effective_from} */
|
|
public function deleteScheduled(Request $request, string $effectiveFrom): JsonResponse
|
|
{
|
|
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $effectiveFrom)) {
|
|
abort(400, 'invalid date format');
|
|
}
|
|
|
|
$todayMsk = Carbon::now('Europe/Moscow')->toDateString();
|
|
if ($effectiveFrom <= $todayMsk) {
|
|
abort(409, 'cannot delete past or active set');
|
|
}
|
|
|
|
$adminUserId = $this->resolveAdminUserId($request);
|
|
|
|
DB::transaction(function () use ($effectiveFrom, $adminUserId, $request): void {
|
|
$deleted = PricingTier::where('effective_from', $effectiveFrom)->delete();
|
|
|
|
SaasAdminAuditLog::create([
|
|
'admin_user_id' => $adminUserId,
|
|
'action' => 'pricing_tiers.delete_scheduled',
|
|
'target_type' => 'pricing_tiers',
|
|
'target_id' => null,
|
|
'payload_before' => ['effective_from' => $effectiveFrom],
|
|
'payload_after' => ['rows_deleted' => $deleted],
|
|
'reason' => 'Cancelled scheduled pricing-tier set via admin UI.',
|
|
'ip_address' => $request->ip() ?? '127.0.0.1',
|
|
'user_agent' => $request->userAgent(),
|
|
]);
|
|
});
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
/**
|
|
* Резолвит admin_user_id: из request input (на MVP) либо создаёт
|
|
* системный стаб-аккаунт `system-pricing@liderra.local`, чтобы соблюсти
|
|
* NOT NULL constraint + FK на saas_admin_users.
|
|
*/
|
|
private function resolveAdminUserId(Request $request): int
|
|
{
|
|
$requested = $request->input('admin_user_id');
|
|
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
|
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
|
if ($existing !== null) {
|
|
return (int) $existing;
|
|
}
|
|
}
|
|
|
|
$existingId = DB::table('saas_admin_users')
|
|
->where('email', 'system-pricing@liderra.local')
|
|
->value('id');
|
|
if ($existingId !== null) {
|
|
return (int) $existingId;
|
|
}
|
|
|
|
return (int) DB::table('saas_admin_users')->insertGetId([
|
|
'email' => 'system-pricing@liderra.local',
|
|
'full_name' => 'System Pricing Bot',
|
|
'password_hash' => '$2y$04$system-stub-not-loginable',
|
|
'role' => 'super_admin',
|
|
'is_active' => false,
|
|
'sso_provider' => 'local',
|
|
'is_break_glass' => false,
|
|
]);
|
|
}
|
|
}
|