340 lines
14 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|