File: /home/xedaptot/be.naniguide.com/app/Http/Controllers/Refactor/Admin/SubscriptionController.php
<?php
namespace App\Http\Controllers\Refactor\Admin;
use App\Dto\Refactor\Subscription\SubscriptionListFilterDto;
use App\Http\Controllers\Controller;
use App\Http\Requests\Refactor\Admin\Subscription\CreateSubscriptionRequest;
use App\Http\Requests\Refactor\Admin\Subscription\DeleteSubscriptionsRequest;
use App\Http\Requests\Refactor\Admin\Subscription\ListSubscriptionsRequest;
use App\Http\Requests\Refactor\Admin\Subscription\RejectPendingRequest;
use App\Http\Requests\Refactor\Admin\Subscription\SetCreditRequest;
use App\Model\Customer;
use App\Model\Plan;
use App\Model\Subscription;
use App\Services\Admin\SubscriptionService;
use App\Services\Plans\Credits\CreditKey;
use App\Services\Plans\Credits\CreditsService;
use App\Services\Subscription\SubscriptionManagementService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Thin controller — orchestration only.
*
* Layering: FormRequest (auth + validate) → DTO (typed transport)
* → SubscriptionService (read aggregator)
* → SubscriptionManagementService (write side: approve / terminate / sync / credits)
* → view / JSON.
*/
class SubscriptionController extends Controller
{
public function index(Request $request, SubscriptionService $service)
{
$admin = $request->user()->admin;
return view('refactor.pages.admin.subscriptions.index', [
'plans' => Plan::where('status', 'active')->get(),
'lifecycle' => $service->lifecycleCounts($admin),
'attention' => $service->attention($admin),
'stats' => $service->dashboardStats($admin),
'sidebar' => $service->sidebarStats($admin),
]);
}
public function listing(ListSubscriptionsRequest $request, SubscriptionService $service)
{
$admin = $request->user()->admin;
$filter = SubscriptionListFilterDto::fromRequest($request);
return view('refactor.pages.admin.subscriptions._list', [
'subscriptions' => $service->list($filter, $admin),
'lifecycle' => $filter->lifecycle,
]);
}
public function approve(Request $request, $id, SubscriptionManagementService $svc)
{
$sub = $this->findOr404($id);
if (!$request->user()->admin->can('approve', $sub)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
try {
$svc->approvePendingSubscription($sub);
} catch (\Throwable $e) {
Log::warning('Subscription approve failed', ['uid' => $id, 'error' => $e->getMessage()]);
return response()->json(['message' => $e->getMessage()], 400);
}
return response()->json(['message' => trans('refactor/admin_subscriptions.flash.approved')]);
}
public function terminate(Request $request, $id, SubscriptionManagementService $svc)
{
$sub = $this->findOr404($id);
if (!$request->user()->admin->can('terminate', $sub)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
try {
$svc->terminate($sub);
} catch (\Throwable $e) {
Log::warning('Subscription terminate failed', ['uid' => $id, 'error' => $e->getMessage()]);
return response()->json(['message' => $e->getMessage()], 400);
}
return response()->json(['message' => trans('refactor/admin_subscriptions.flash.terminated')]);
}
public function disableRecurring(Request $request, $id, SubscriptionManagementService $svc)
{
$sub = $this->findOr404($id);
if (!$request->user()->admin->can('disableRecurring', $sub)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$svc->disableRecurring($sub);
return response()->json(['message' => trans('refactor/admin_subscriptions.flash.recurring_disabled')]);
}
public function enableRecurring(Request $request, $id, SubscriptionManagementService $svc)
{
$sub = $this->findOr404($id);
if (!$request->user()->admin->can('enableRecurring', $sub)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$svc->enableRecurring($sub);
return response()->json(['message' => trans('refactor/admin_subscriptions.flash.recurring_enabled')]);
}
public function delete(DeleteSubscriptionsRequest $request, SubscriptionManagementService $svc)
{
$admin = $request->user()->admin;
foreach ($request->uids() as $uid) {
$sub = Subscription::findByUid($uid);
if ($sub && $admin->can('delete', $sub)) {
$svc->deleteSubscription($sub);
}
}
return response()->json(['message' => trans('refactor/admin_subscriptions.flash.deleted')]);
}
public function setCredit(SetCreditRequest $request, string $id, string $creditKey, SubscriptionManagementService $svc)
{
$sub = $this->findOr404($id);
if (!$request->user()->admin->can('replenishSendingCredits', $sub)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$key = CreditKey::tryFrom($creditKey);
if ($key === null) {
return response()->json(['error' => 'Unknown credit key'], 422);
}
try {
$svc->setCreditRemaining(
$sub,
$key,
(int) $request->input('remaining'),
$request->user()->id,
$request->input('note'),
);
} catch (\Throwable $e) {
Log::warning('Subscription setCredit failed', ['uid' => $id, 'key' => $creditKey, 'error' => $e->getMessage()]);
return response()->json(['error' => $e->getMessage()], 422);
}
return response()->json(['message' => trans('refactor/admin_subscriptions.flash.credits_updated')]);
}
public function refreshCredits(string $id, CreditsService $credits)
{
$sub = $this->findOr404($id);
// DB-snapshot refresh — see docs/cache "DB-snapshot cache" section. Mirror
// of `AccountController::refreshCredits` (customer side). Sync because the
// work is trivial (1 file read + a few row UPDATEs); returns the fresh
// values inline so the JS can update credit spans in place.
return response()->json([
'credits' => $credits->syncSnapshotFromTracker($sub),
'last_synced_at' => now()->toIso8601String(),
]);
}
/**
* Credits Debug page action: heal presence drift for ONE credit on this
* subscription immediately (without waiting for the next billing cycle).
* Single endpoint; the action (insert vs delete) is derived from the
* current plan_credits / subscription_credit_state state, so double-clicks
* and concurrent submissions resolve to a clean no-op rather than corrupt.
*/
public function syncCreditFromPlan(Request $request, string $id, string $creditKey, CreditsService $credits)
{
$sub = $this->findOr404($id);
// Reuse the same admin policy that gates "Set Credits" on the show page —
// both actions modify per-sub credit state, identical authority required.
if (!$request->user()->admin->can('replenishSendingCredits', $sub)) {
return redirect()->route('refactor.admin.subscriptions.credits-debug', $sub->uid)
->with('error', trans('messages.not_authorized'));
}
$key = CreditKey::tryFrom($creditKey);
if ($key === null) {
return redirect()->route('refactor.admin.subscriptions.credits-debug', $sub->uid)
->with('error', trans('refactor/admin_subscriptions.credits_debug.sync.unknown_key'));
}
try {
$result = $credits->syncOneCreditFromPlan($sub, $key);
} catch (\Throwable $e) {
Log::warning('Subscription credits-debug sync failed', [
'uid' => $sub->uid, 'key' => $creditKey, 'error' => $e->getMessage(),
]);
return redirect()->route('refactor.admin.subscriptions.credits-debug', $sub->uid)
->with('error', $e->getMessage());
}
$flashKey = match ($result['action']) {
'inserted' => 'refactor/admin_subscriptions.credits_debug.sync.flash_inserted',
'deleted' => 'refactor/admin_subscriptions.credits_debug.sync.flash_deleted',
default => 'refactor/admin_subscriptions.credits_debug.sync.flash_noop',
};
// Audit trail — mirror Set-Credits pattern so admin overrides are searchable in one place.
try {
\App\Library\Facades\SubscriptionFacade::log(
$sub,
\App\ActivityLog\Activities\SubscriptionEvent::TYPE_CREDIT_MODIFIED,
null,
[
'credit_key' => $key->value,
'action' => $result['action'],
'plan_value' => $result['plan_value'],
'note' => 'manual sync from credits-debug page',
],
$request->user()->id,
);
} catch (\Throwable $e) {
Log::warning('credits-debug sync audit log failed (non-fatal)', ['error' => $e->getMessage()]);
}
return redirect()
->route('refactor.admin.subscriptions.credits-debug', $sub->uid)
->with('success', trans($flashKey, ['key' => $key->label()]));
}
/**
* GET — render the create-subscription popup form. Plain Request (NOT a
* FormRequest) so the validator doesn't fire on form-render; submission
* goes to the sibling store() action.
*/
public function create()
{
return view('refactor.pages.admin.subscriptions.create', [
'plans' => Plan::where('status', 'active')->get(),
]);
}
/**
* POST — process the create-subscription submission. Mirrors the customer
* self-subscribe flow in Refactor\SubscriptionController::assignPlan so the
* admin path enforces the same invariants:
*
* 1. Customer + plan must exist.
* 2. Customer must NOT have an active subscription — assigning a plan on
* top of an existing one would fork billing state. The Customer
* select2 already filters these out (whereDoesntHave 'subscriptions'
* newOrActive), but check defensively here in case a stale dropdown
* submits.
* 3. Use SubscriptionManagementService::replacePendingPlan (NOT raw
* assignPlan) — it cleans up any abandoned new/pending sub before
* assigning, matching what a customer hitting their /select-plan
* route would experience.
*
* Side-effect parity with the customer flow:
* - Lifecycle::onSubscribe primes credits (cold snapshot + hot tracker).
* - An init Order + Invoice are created via createNewSubscriptionOrder,
* awaiting payment. Sub status = 'new' until invoice settles.
* - The popup just returns success; admin can then navigate to the
* subscription show page to walk the customer through payment (or
* mark the invoice paid via the invoice approve action for offline).
*/
public function store(CreateSubscriptionRequest $request, SubscriptionManagementService $svc)
{
$customer = Customer::findByUid($request->input('customer_uid'));
$plan = Plan::findByUid($request->input('plan_uid'));
if (!$customer || !$plan) {
return response()->json([
'message' => trans('refactor/admin_subscriptions.create.errors.not_found'),
], 422);
}
if ($customer->getCurrentActiveSubscription()) {
return response()->json([
'message' => trans('refactor/admin_subscriptions.create.errors.has_active_sub'),
'errors' => ['customer_uid' => [trans('refactor/admin_subscriptions.create.errors.has_active_sub')]],
], 422);
}
try {
$svc->replacePendingPlan($customer, $plan);
} catch (\Throwable $e) {
Log::warning('Admin assign plan failed', [
'customer_uid' => $customer->uid,
'plan_uid' => $plan->uid,
'error' => $e->getMessage(),
]);
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['message' => trans('refactor/admin_subscriptions.flash.created')]);
}
public function show($id)
{
$sub = $this->findOr404($id);
$customer = $sub->customer()->first();
$plan = $sub->plan()->first();
// Credits (editable — per-sub snapshot, NOT plan live; Plan Versioning §10).
$creditsService = app(CreditsService::class);
$subCreditMap = $creditsService->creditsFromSnapshot($sub);
$credits = [];
foreach (CreditKey::cases() as $ck) {
if (!array_key_exists($ck->value, $subCreditMap)) {
continue;
}
$amountPerCycle = $subCreditMap[$ck->value];
$remaining = $creditsService->remainingSnapshot($sub, $ck);
$used = ($amountPerCycle === null || $remaining === null)
? null
: max(0, (int) $amountPerCycle - (int) $remaining);
$credits[] = [
'key' => $ck->value,
'label' => $ck->label(),
'amount_per_cycle' => $amountPerCycle,
'remaining' => $remaining,
'used' => $used,
];
}
$creditsLastSynced = \App\Model\SubscriptionCreditState::where('subscription_id', $sub->id)
->max('last_synced_at');
if ($creditsLastSynced) {
$creditsLastSynced = \Carbon\Carbon::parse($creditsLastSynced);
}
// Quotas (read-only — derived from plan)
$quotasService = app(\App\Services\Plans\Quotas\QuotasService::class);
$quotas = [];
foreach ($quotasService->quotasOfPlan($plan) as $quotaKey => $limitValue) {
$key = \App\Services\Plans\Quotas\QuotaKey::tryFrom($quotaKey);
if (!$key) {
continue;
}
$used = null;
try {
$used = $quotasService->used($customer, $key);
} catch (\Throwable $e) {
Log::debug('Subscription quota used() unavailable', ['key' => $key->value, 'reason' => $e->getMessage()]);
}
$quotas[] = [
'key' => $key->value,
'label' => str_replace('_', ' ', $key->value),
'limit_value' => $limitValue,
'used' => $used,
];
}
// Entitlements (read-only — grouped)
$entitlementMap = app(\App\Services\Plans\Entitlements\EntitlementsService::class)
->entitlementsOfPlan($plan);
$entitlements = [];
foreach (\App\Services\Plans\Entitlements\EntitlementKey::cases() as $ek) {
$entitlements[] = [
'key' => $ek->value,
'label' => $ek->label(),
'group' => $ek->group(),
'enabled' => (bool) ($entitlementMap[$ek->value] ?? false),
];
}
// Rate Limits (read-only)
$rateLimits = array_map(
fn ($r) => [
'key' => $r->rate_key,
'limit_value' => (int) $r->limit_value,
'time_amount' => (int) $r->time_amount,
'time_unit' => $r->time_unit,
],
array_values(app(\App\Services\Plans\RateLimits\RateLimitsService::class)->configsOfPlan($plan))
);
// Activity timeline (unified activity_logs filtered by subject)
$activityLogs = \App\Model\ActivityLog::where('subject_type', 'subscription')
->where('subject_id', $sub->id)
->orderByDesc('created_at')
->limit(50)
->get();
return view('refactor.pages.admin.subscriptions.show', [
'subscription' => $sub,
'customer' => $customer,
'plan' => $plan,
'credits' => $credits,
'creditsLastSynced' => $creditsLastSynced,
'quotas' => $quotas,
'entitlements' => $entitlements,
'rateLimits' => $rateLimits,
'activityLogs' => $activityLogs,
]);
}
/**
* Credits debug — side-by-side view of plan_credits row vs
* subscription_credit_state snapshot vs live tracker file. Surfaces
* drift between plan-config and per-sub state caused by mid-cycle plan
* edits (Plan Versioning §10 — snapshot frozen at onSubscribe/onRenew).
*/
public function creditsDebug($id)
{
$sub = $this->findOr404($id);
$plan = $sub->plan()->first();
$creditsService = app(CreditsService::class);
$planCredits = $plan ? $creditsService->creditsOfPlan($plan) : []; // credit_key => ?int (null=unlimited; key absent=no row)
$subGranted = $creditsService->creditsFromSnapshot($sub); // credit_key => ?int (same convention)
$rows = [];
foreach (CreditKey::cases() as $key) {
$planHas = array_key_exists($key->value, $planCredits);
$subHas = array_key_exists($key->value, $subGranted);
$state = \App\Model\SubscriptionCreditState::where('subscription_id', $sub->id)
->where('credit_key', $key->value)
->first();
$tracker = $this->peekTrackerLive($sub, $key);
$rows[] = [
'key' => $key->value,
'label' => $key->label(),
'plan_has_row' => $planHas,
'plan_value' => $planHas ? $planCredits[$key->value] : null,
'sub_has_row' => $subHas,
'sub_granted' => $subHas ? $subGranted[$key->value] : null,
'sub_remaining' => $state?->remaining,
'tracker_exists' => $tracker['exists'],
'tracker_value' => $tracker['value'],
];
}
return view('refactor.pages.admin.subscriptions.credits_debug', [
'subscription' => $sub,
'plan' => $plan,
'rows' => $rows,
'distributedMode' => (bool) config('custom.distributed_mode'),
]);
}
/**
* Read the live tracker without side effects (no createFileIfNotExists).
* Returns ['exists' => bool|null, 'value' => ?int].
* exists=null means distributed mode (we don't peek in-memory state).
* value=null means unlimited (when exists=true) or unknown.
*/
private function peekTrackerLive(Subscription $sub, CreditKey $key): array
{
if (config('custom.distributed_mode')) {
return ['exists' => null, 'value' => null];
}
$file = storage_path('app/quota/' . $key->trackerBasename($sub->uid));
if (!file_exists($file)) {
return ['exists' => false, 'value' => null];
}
$raw = trim((string) @file_get_contents($file));
if ($raw === '') {
return ['exists' => true, 'value' => 0];
}
$int = (int) $raw;
return ['exists' => true, 'value' => $int === -1 ? null : $int];
}
/** GET — render the reject-pending popup form. */
public function rejectPendingForm($id)
{
$sub = $this->findOr404($id);
return view('refactor.pages.admin.subscriptions.reject_pending', ['subscription' => $sub]);
}
/** POST — process the rejection submission. RejectPendingRequest validates the body. */
public function rejectPending(RejectPendingRequest $request, $id, SubscriptionManagementService $svc)
{
$sub = $this->findOr404($id);
$order = $sub->getUnpaidOrder();
if ($order) {
try {
$svc->rejectPendingSubscription($sub, $request->input('reason'));
} catch (\Throwable $e) {
Log::warning('Subscription rejectPending failed', ['uid' => $id, 'error' => $e->getMessage()]);
return response()->json(['error' => $e->getMessage()], 422);
}
}
return response()->json(['message' => trans('refactor/admin_subscriptions.flash.rejected')]);
}
public function invoices($id)
{
$sub = $this->findOr404($id);
$orderItems = $sub->orderItems()
->with([
'order.currency',
'order.invoices.billingCountry',
'order.invoices.customer',
'order.invoices.paymentIntents.paymentGateway',
])
->orderBy('created_at', 'desc')
->get();
$logs = \App\Model\ActivityLog::where('subject_type', 'subscription')
->where('subject_id', $sub->id)
->orderBy('created_at', 'desc')
->limit(50)
->get();
return view('refactor.pages.admin.subscriptions.invoices', [
'subscription' => $sub,
'orderItems' => $orderItems,
'logs' => $logs,
]);
}
public function syncRemote($id, SubscriptionManagementService $svc)
{
$sub = $this->findOr404($id);
if (!$sub->hasRemoteSubscription()) {
return response()->json(['error' => 'No remote subscription'], 400);
}
$result = $svc->syncRemoteSubscription($sub);
$warningsStr = '';
if (!empty($result->warnings)) {
$warningsStr = ' | Warnings: ' . implode('; ', $result->warnings);
}
return response()->json([
'status' => $result->hasWarnings() ? 'warning' : 'success',
'sync_status' => $result->status,
'changes' => $result->changes,
'warnings' => $result->warnings,
'error' => $result->error,
'message' => match ($result->status) {
'in_sync' => 'Subscription is in sync with remote.' . $warningsStr,
'drifted' => 'Subscription was updated from remote: ' . count($result->changes) . ' change(s).' . $warningsStr,
'mismatch' => 'Subscription has plan mismatches: ' . implode('; ', $result->warnings),
'remote_not_found' => 'Remote subscription not found: ' . ($result->error ?? ''),
default => 'Sync error: ' . ($result->error ?? 'unknown'),
},
]);
}
public function remoteInfo($id, SubscriptionManagementService $svc)
{
$sub = $this->findOr404($id);
if (!$sub->hasRemoteSubscription()) {
abort(404);
}
$meta = $sub->getRemoteMetadataArray();
$gateway = $sub->remoteGateway()->first();
$mapping = ($sub->plan_id && $gateway)
? \App\Model\PlanRemoteMapping::where('plan_id', $sub->plan_id)
->where('payment_gateway_id', $gateway->id)
->first()
: null;
$mismatches = $mapping ? $mapping->getMismatches() : [];
$remoteData = null;
$remoteError = null;
try {
$remoteData = $svc->fetchRemoteSubscription($sub);
} catch (\Throwable $e) {
$remoteError = $e->getMessage();
}
$remotePaymentMethod = null;
$remotePaymentMethodError = null;
try {
$remotePaymentMethod = $svc->fetchRemotePaymentMethod($sub);
} catch (\Throwable $e) {
$remotePaymentMethodError = $e->getMessage();
}
$logs = \App\Model\ActivityLog::where('subject_type', 'subscription')
->where('subject_id', $sub->id)
->orderByDesc('created_at')
->limit(50)
->get();
// ─── Invoice join ─── local Invoices (linked via Order→OrderItem.subscription_id)
// FULL OUTER JOIN with remote billing events (vendor's invoices/transactions list).
// Keys:
// - matched → local + remote both present
// - local-only→ row exists locally but vendor doesn't show it (rare; orphan/race)
// - remote-only→ vendor billed but sync hasn't materialized yet (or skipped MANUAL/REFUND)
// The view marks the row whose remote_id == subscription.last_seen_remote_invoice_id with " *".
$localInvoices = \App\Model\Invoice::whereHas(
'order.orderItems',
fn ($q) => $q->where('subscription_id', $sub->id)
)->orderBy('id')->get();
$remoteInvoices = [];
$remoteInvoicesError = null;
if ($gateway) {
try {
$service = \App\Library\Facades\Billing::resolveService($gateway);
if ($service instanceof \App\Cashier\Contracts\RemoteSubscriptionGatewayInterface
&& $sub->remote_subscription_id) {
$cursor = null;
do {
$page = $service->getRemoteInvoices($sub->remote_subscription_id, $cursor, 100);
foreach ($page['data'] as $dto) {
$remoteInvoices[] = $dto;
$cursor = $dto->id;
}
} while (!empty($page['has_more']));
}
} catch (\Throwable $e) {
$remoteInvoicesError = $e->getMessage();
}
}
// Build join keyed by remote id. Local rows with remote_id stamped → match by id.
// Local rows with NULL remote_id → bucket "LOCAL:<local_id>" (won't collide with vendor ids).
$invoiceJoin = [];
foreach ($localInvoices as $inv) {
$key = $inv->remote_invoice_id ?: ('LOCAL:' . $inv->id);
$invoiceJoin[$key] = ['local' => $inv, 'remote' => null];
}
foreach ($remoteInvoices as $dto) {
if (isset($invoiceJoin[$dto->id])) {
$invoiceJoin[$dto->id]['remote'] = $dto;
} else {
$invoiceJoin[$dto->id] = ['local' => null, 'remote' => $dto];
}
}
// ─── Local PaymentMethod lookup map ─── keyed by vendor's pm id stored in
// autobilling_data. Used by the view to render "Visa **3155 (PM #42)" with
// a link to the local row when the card is still on file, or "(deleted
// locally)" when the customer removed it but the vendor remembered the
// historical charge.
//
// Each driver knows its own autobilling_data shape — we go through the
// interface (extractRemotePaymentMethodId) instead of hardcoding key
// names here, so adding a new gateway plugin doesn't require touching
// this controller.
$pmMap = [];
// $gatewayHasLocalPmConcept stays false if no local PMs exist for this
// gateway+customer at all. View uses it to distinguish "deleted locally"
// (gateway DOES track PMs but THIS row's pm is gone) vs "vendor-managed"
// (Paddle hosted-checkout etc. — never had local PMs).
$gatewayHasLocalPmConcept = false;
if ($gateway && $sub->customer) {
try {
$svcForPm = \App\Library\Facades\Billing::resolveService($gateway);
} catch (\Throwable $e) {
$svcForPm = null; // gateway type unregistered (plugin disabled)
}
if ($svcForPm instanceof \App\Cashier\Contracts\RemoteSubscriptionGatewayInterface) {
$localPms = \App\Model\PaymentMethod::where('payment_gateway_id', $gateway->id)
->where('customer_id', $sub->customer_id)
->get();
$gatewayHasLocalPmConcept = $localPms->isNotEmpty();
foreach ($localPms as $pm) {
$remoteId = $svcForPm->extractRemotePaymentMethodId($pm->getAutoBillingData());
if ($remoteId) {
$pmMap[$remoteId] = $pm;
}
}
}
}
return view('refactor.pages.admin.subscriptions.remote_info', [
'subscription' => $sub,
'meta' => $meta,
'gateway' => $gateway,
'mapping' => $mapping,
'mismatches' => $mismatches,
'remoteData' => $remoteData,
'remoteError' => $remoteError,
'remotePaymentMethod' => $remotePaymentMethod,
'remotePaymentMethodError' => $remotePaymentMethodError,
'logs' => $logs,
'invoiceJoin' => $invoiceJoin,
'remoteInvoicesError' => $remoteInvoicesError,
'pmMap' => $pmMap,
'gatewayHasLocalPmConcept' => $gatewayHasLocalPmConcept,
]);
}
private function findOr404(string $uid): Subscription
{
$sub = Subscription::findByUid($uid);
if (!$sub) {
abort(response()->json(['error' => 'Not found'], 404));
}
return $sub;
}
}