Files
portal/app/app/Http/Controllers/Api/AdminBillingController.php
T

340 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\SaasAdminAuditLog;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin billing-overview для AdminBillingView. Aggregates пополнения и
* списания за текущий календарный месяц по `balance_transactions`.
*
* На MVP без auth-middleware (saas-admin SSO ⏸ Б-1).
*/
class AdminBillingController extends Controller
{
use ResolvesAdminUserId;
/** GET /api/admin/billing/tariff-plans — список планов для диалога смены тарифа. */
public function tariffPlans(): JsonResponse
{
$plans = DB::table('tariff_plans')
->select(['id', 'name', 'price_monthly'])
->orderBy('price_monthly')
->get()
->map(fn ($p) => [
'id' => (int) $p->id,
'name' => $p->name,
'price_monthly' => (string) $p->price_monthly,
]);
return response()->json(['plans' => $plans]);
}
/** PATCH /api/admin/billing/tenants/{id}/status — приостановить/разблокировать тенанта. */
public function updateStatus(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'status' => ['required', 'in:active,suspended'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
]);
$tenant = $this->findActiveTenant($id);
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
DB::transaction(function () use ($tenant, $validated, $adminUserId, $request): void {
// tenants / saas_admin_audit_log не под RLS — SET LOCAL не нужен (ср. refund()).
DB::table('tenants')->where('id', $tenant->id)->update([
'status' => $validated['status'],
'updated_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => $validated['status'] === 'suspended' ? 'tenant.suspend' : 'tenant.activate',
'target_type' => 'tenant',
'target_id' => $tenant->id,
'target_tenant_id' => $tenant->id,
'payload_before' => ['status' => $tenant->status],
'payload_after' => ['status' => $validated['status']],
'reason' => $validated['reason'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
});
return response()->json(['id' => $tenant->id, 'status' => $validated['status']]);
}
/** POST /api/admin/billing/tenants/{id}/refund — возврат средств: списание с баланса + ledger-запись. */
public function refund(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'gt:0'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
]);
$this->findActiveTenant($id); // ранний 404; авторитетный баланс перечитывается под локом ниже
$amount = number_format((float) $validated['amount_rub'], 2, '.', '');
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
/** @var array{transaction_id:int, balance_rub:string} $result */
$result = DB::transaction(function () use ($id, $amount, $validated, $adminUserId, $request): array {
DB::statement('SET LOCAL app.current_tenant_id = '.$id);
// Баланс — money-колонка: перечитываем под row-lock внутри транзакции,
// защита от lost-update (конвенция LedgerService — lockForUpdate на tenants).
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')
->lockForUpdate()->first();
if ($tenant === null) {
abort(404, 'tenant not found');
}
$balance = (string) $tenant->balance_rub;
if (bccomp($amount, $balance, 2) === 1) {
abort(422, 'refund amount exceeds tenant balance');
}
$newBalance = bcsub($balance, $amount, 2);
DB::table('tenants')->where('id', $id)->update([
'balance_rub' => $newBalance,
'updated_at' => now(),
]);
$tx = BalanceTransaction::create([
'tenant_id' => $id,
'type' => BalanceTransaction::TYPE_REFUND,
'amount_rub' => '-'.$amount,
'amount_leads' => 0,
'balance_rub_after' => $newBalance,
'description' => $validated['reason'],
'admin_user_id' => $adminUserId,
'created_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => 'tenant.refund',
'target_type' => 'tenant',
'target_id' => $id,
'target_tenant_id' => $id,
'payload_before' => ['balance_rub' => $balance],
'payload_after' => ['balance_rub' => $newBalance, 'amount_rub' => $amount, 'transaction_id' => $tx->id],
'reason' => $validated['reason'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
return ['transaction_id' => (int) $tx->id, 'balance_rub' => $newBalance];
});
return response()->json([
'id' => $id,
'balance_rub' => $result['balance_rub'],
'transaction_id' => $result['transaction_id'],
]);
}
/** PATCH /api/admin/billing/tenants/{id}/tariff — сменить тарифный план тенанта. */
public function changeTariff(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'tariff_id' => ['required', 'integer', 'exists:tariff_plans,id'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
]);
$tenant = $this->findActiveTenant($id);
$tariff = DB::table('tariff_plans')->where('id', $validated['tariff_id'])->first();
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
DB::transaction(function () use ($tenant, $tariff, $validated, $adminUserId, $request): void {
// tenants / saas_admin_audit_log не под RLS — SET LOCAL не нужен (ср. refund()).
DB::table('tenants')->where('id', $tenant->id)->update([
'current_tariff_id' => $tariff->id,
'updated_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => 'tenant.change_tariff',
'target_type' => 'tenant',
'target_id' => $tenant->id,
'target_tenant_id' => $tenant->id,
'payload_before' => ['current_tariff_id' => $tenant->current_tariff_id],
'payload_after' => ['current_tariff_id' => (int) $tariff->id],
'reason' => $validated['reason'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
});
return response()->json([
'id' => $tenant->id,
'tariff_id' => (int) $tariff->id,
'tariff_name' => $tariff->name,
]);
}
/**
* Возвращает не-удалённого тенанта либо abort(404).
*
* @return object{id:int,status:string,balance_rub:string,current_tariff_id:int|null}
*/
private function findActiveTenant(int $id): object
{
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')->first();
if ($tenant === null) {
abort(404, 'tenant not found');
}
return $tenant;
}
/** GET /api/admin/billing?search= */
public function index(Request $request): JsonResponse
{
$search = trim((string) $request->query('search', ''));
$monthStart = now()->startOfMonth();
$query = DB::table('tenants')
->leftJoin('tariff_plans', 'tariff_plans.id', '=', 'tenants.current_tariff_id')
->whereNull('tenants.deleted_at');
if ($search !== '') {
$like = '%'.$search.'%';
$query->where(function ($q) use ($like) {
$q->where('tenants.organization_name', 'ilike', $like)
->orWhere('tenants.subdomain', 'ilike', $like);
});
}
$tenants = $query->select([
'tenants.id',
'tenants.subdomain',
'tenants.organization_name',
'tenants.contact_email',
'tenants.status',
'tenants.balance_rub',
'tenants.is_trial',
'tenants.chargeback_unrecovered_rub',
'tariff_plans.id as tariff_id',
'tariff_plans.name as tariff_name',
'tariff_plans.price_monthly as tariff_price_monthly',
])->get();
$tenantIds = $tenants->pluck('id')->all();
// Aggregate balance_transactions за текущий месяц по типам.
$aggregates = [];
if ($tenantIds !== []) {
$rows = DB::table('balance_transactions')
->whereIn('tenant_id', $tenantIds)
->where('created_at', '>=', $monthStart)
->selectRaw('
tenant_id,
SUM(CASE WHEN type = ? THEN amount_rub ELSE 0 END) as topups,
SUM(CASE WHEN type = ? THEN ABS(amount_rub) ELSE 0 END) as charges,
MAX(CASE WHEN type = ? THEN created_at ELSE NULL END) as last_payment_at
', ['topup', 'lead_charge', 'topup'])
->groupBy('tenant_id')
->get();
foreach ($rows as $r) {
$aggregates[(int) $r->tenant_id] = [
'topups' => (string) $r->topups,
'charges' => (string) $r->charges,
'last_payment_at' => $r->last_payment_at,
];
}
}
$rows = $tenants->map(function ($t) use ($aggregates) {
$agg = $aggregates[(int) $t->id] ?? [
'topups' => '0.00',
'charges' => '0.00',
'last_payment_at' => null,
];
return [
'id' => (int) $t->id,
'subdomain' => $t->subdomain,
'organization_name' => $t->organization_name,
'contact_email' => $t->contact_email,
'status' => $t->status,
'balance_rub' => (string) $t->balance_rub,
'tariff_id' => $t->tariff_id !== null ? (int) $t->tariff_id : null,
'tariff_name' => $t->tariff_name,
'mrr_rub' => $t->tariff_price_monthly !== null && ! $t->is_trial
? (string) $t->tariff_price_monthly
: '0.00',
'monthly_topups_rub' => $agg['topups'],
'monthly_charges_rub' => $agg['charges'],
'last_payment_at' => $agg['last_payment_at'] !== null
? CarbonImmutable::parse($agg['last_payment_at'])->toIso8601String()
: null,
'chargeback_unrecovered_rub' => (string) $t->chargeback_unrecovered_rub,
];
});
return response()->json([
'tenants' => $rows,
'summary' => $this->computeSummary($monthStart),
]);
}
/**
* Общая выручка за месяц + кол-во просрочек + возвраты за 30 дн.
*
* @return array<string, int|string>
*/
private function computeSummary(Carbon $monthStart): array
{
$thirtyDaysAgo = now()->subDays(30);
// Total MRR — сумма tariff_price_monthly для не-trial тенантов с активным тарифом.
$totalMrr = DB::table('tenants')
->leftJoin('tariff_plans', 'tariff_plans.id', '=', 'tenants.current_tariff_id')
->whereNull('tenants.deleted_at')
->where('tenants.is_trial', false)
->whereNotNull('tariff_plans.price_monthly')
->sum('tariff_plans.price_monthly');
// Monthly revenue = сумма всех topups за текущий месяц.
$monthlyRevenue = DB::table('balance_transactions')
->where('type', 'topup')
->where('created_at', '>=', $monthStart)
->sum('amount_rub');
// Overdue tenants (balance<0 OR chargeback>0).
$overdueCount = DB::table('tenants')
->whereNull('deleted_at')
->where(function ($q) {
$q->where('balance_rub', '<', 0)
->orWhere('chargeback_unrecovered_rub', '>', 0);
})
->count();
// Refunds за 30 дн — count balance_transactions type='refund'.
$refundsCount = DB::table('balance_transactions')
->where('type', 'refund')
->where('created_at', '>=', $thirtyDaysAgo)
->count();
return [
'total_mrr_rub' => (string) $totalMrr,
'monthly_revenue_rub' => (string) $monthlyRevenue,
'overdue_count' => $overdueCount,
'refunds_count_30d' => $refundsCount,
];
}
}