php-commerce/View/Modal/Admin/SubscriptionManager.php
Snider a774f4e285 refactor: migrate namespace from Core\Commerce to Core\Mod\Commerce
Align commerce module with the monorepo module structure by updating
all namespaces to use the Core\Mod\Commerce convention. This change
supports the recent monorepo separation and ensures consistency with
other modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:23:12 +00:00

425 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\View\Modal\Admin;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Commerce\Services\SubscriptionService;
use Core\Mod\Tenant\Models\Workspace;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
#[Title('Subscriptions')]
class SubscriptionManager extends Component
{
use WithPagination;
// Bulk selection
public array $selected = [];
public bool $selectAll = false;
// Filters
public string $search = '';
public string $statusFilter = '';
public string $gatewayFilter = '';
public ?int $workspaceFilter = null;
// Detail modal
public bool $showDetailModal = false;
public ?Subscription $selectedSubscription = null;
// Status update modal
public bool $showStatusModal = false;
public string $newStatus = '';
public string $statusNote = '';
// Extend period modal
public bool $showExtendModal = false;
public int $extendDays = 30;
/**
* Authorize access - Hades tier only.
*/
public function mount(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades tier required for subscription management.');
}
}
public function updatingSearch(): void
{
$this->resetPage();
$this->selected = [];
$this->selectAll = false;
}
public function updatedSelectAll(bool $value): void
{
if ($value) {
$this->selected = $this->subscriptions->pluck('id')->map(fn ($id) => (string) $id)->all();
} else {
$this->selected = [];
}
}
public function exportSelected(): void
{
if (empty($this->selected)) {
session()->flash('error', __('commerce::commerce.bulk.no_selection'));
return;
}
$subscriptions = Subscription::with(['workspace', 'workspacePackage.package'])->whereIn('id', $this->selected)->get();
$csv = "Workspace,Package,Gateway,Status,Billing Cycle,Period End,Created\n";
foreach ($subscriptions as $sub) {
$csv .= sprintf(
"%s,%s,%s,%s,%s,%s,%s\n",
str_replace(',', ' ', $sub->workspace?->name ?? 'Unknown'),
str_replace(',', ' ', $sub->workspacePackage?->package?->name ?? 'Unknown'),
$sub->gateway ?? 'unknown',
$sub->status,
$sub->billing_cycle ?? 'monthly',
$sub->current_period_end?->format('Y-m-d') ?? '',
$sub->created_at->format('Y-m-d H:i:s')
);
}
$this->dispatch('download-csv', filename: 'subscriptions-export-'.now()->format('Y-m-d').'.csv', content: $csv);
session()->flash('message', __('commerce::commerce.bulk.export_success', ['count' => count($this->selected)]));
$this->selected = [];
$this->selectAll = false;
}
public function bulkUpdateStatus(string $status): void
{
if (empty($this->selected)) {
session()->flash('error', __('commerce::commerce.bulk.no_selection'));
return;
}
$validStatuses = ['active', 'trialing', 'past_due', 'paused', 'cancelled', 'incomplete', 'expired'];
if (! in_array($status, $validStatuses)) {
session()->flash('error', 'Invalid status selected.');
return;
}
$count = Subscription::whereIn('id', $this->selected)->update([
'status' => $status,
]);
// Handle status-specific updates
if ($status === 'cancelled') {
Subscription::whereIn('id', $this->selected)->update([
'cancelled_at' => now(),
'ended_at' => now(),
]);
} elseif ($status === 'paused') {
Subscription::whereIn('id', $this->selected)->update(['paused_at' => now()]);
}
session()->flash('message', __('commerce::commerce.bulk.status_updated', ['count' => $count, 'status' => ucfirst($status)]));
$this->selected = [];
$this->selectAll = false;
}
public function bulkExtendPeriod(): void
{
if (empty($this->selected)) {
session()->flash('error', __('commerce::commerce.bulk.no_selection'));
return;
}
$count = Subscription::whereIn('id', $this->selected)
->whereNotNull('current_period_end')
->update([
'current_period_end' => \Illuminate\Support\Facades\DB::raw('DATE_ADD(current_period_end, INTERVAL 30 DAY)'),
]);
session()->flash('message', __('commerce::commerce.bulk.period_extended', ['count' => $count, 'days' => 30]));
$this->selected = [];
$this->selectAll = false;
}
public function viewSubscription(int $id): void
{
$this->selectedSubscription = Subscription::with([
'workspace',
'workspacePackage.package',
])->findOrFail($id);
$this->showDetailModal = true;
}
public function openStatusChange(int $id): void
{
$this->selectedSubscription = Subscription::findOrFail($id);
$this->newStatus = $this->selectedSubscription->status;
$this->statusNote = '';
$this->showStatusModal = true;
}
public function updateStatus(): void
{
if (! $this->selectedSubscription) {
return;
}
$validStatuses = ['active', 'trialing', 'past_due', 'paused', 'cancelled', 'incomplete', 'expired'];
if (! in_array($this->newStatus, $validStatuses)) {
session()->flash('error', 'Invalid status selected.');
return;
}
$oldStatus = $this->selectedSubscription->status;
$this->selectedSubscription->update([
'status' => $this->newStatus,
'metadata' => array_merge($this->selectedSubscription->metadata ?? [], [
'status_history' => array_merge(
$this->selectedSubscription->metadata['status_history'] ?? [],
[[
'from' => $oldStatus,
'to' => $this->newStatus,
'note' => $this->statusNote ?: null,
'by' => auth()->id(),
'at' => now()->toIso8601String(),
]]
),
]),
]);
// Handle status-specific updates
if ($this->newStatus === 'cancelled') {
$this->selectedSubscription->update([
'cancelled_at' => now(),
'ended_at' => now(),
]);
} elseif ($this->newStatus === 'paused') {
$this->selectedSubscription->update(['paused_at' => now()]);
} elseif ($this->newStatus === 'active' && $oldStatus === 'paused') {
$this->selectedSubscription->update(['paused_at' => null]);
}
session()->flash('message', "Subscription status updated from {$oldStatus} to {$this->newStatus}.");
$this->closeStatusModal();
}
public function openExtendPeriod(int $id): void
{
$this->selectedSubscription = Subscription::findOrFail($id);
$this->extendDays = 30;
$this->showExtendModal = true;
}
public function extendPeriod(): void
{
if (! $this->selectedSubscription) {
return;
}
$newEndDate = $this->selectedSubscription->current_period_end->addDays($this->extendDays);
$this->selectedSubscription->update([
'current_period_end' => $newEndDate,
'metadata' => array_merge($this->selectedSubscription->metadata ?? [], [
'period_extensions' => array_merge(
$this->selectedSubscription->metadata['period_extensions'] ?? [],
[[
'days' => $this->extendDays,
'by' => auth()->id(),
'at' => now()->toIso8601String(),
]]
),
]),
]);
session()->flash('message', "Subscription extended by {$this->extendDays} days.");
$this->closeExtendModal();
}
public function cancelSubscription(int $id): void
{
$subscription = Subscription::findOrFail($id);
app(SubscriptionService::class)->cancel($subscription, 'Cancelled by admin');
session()->flash('message', 'Subscription cancelled.');
}
public function resumeSubscription(int $id): void
{
$subscription = Subscription::findOrFail($id);
app(SubscriptionService::class)->resume($subscription);
session()->flash('message', 'Subscription resumed.');
}
public function closeDetailModal(): void
{
$this->showDetailModal = false;
$this->selectedSubscription = null;
}
public function closeStatusModal(): void
{
$this->showStatusModal = false;
$this->selectedSubscription = null;
$this->newStatus = '';
$this->statusNote = '';
}
public function closeExtendModal(): void
{
$this->showExtendModal = false;
$this->selectedSubscription = null;
$this->extendDays = 30;
}
#[Computed]
public function subscriptions()
{
return Subscription::query()
->with(['workspace', 'workspacePackage.package'])
->when($this->search, function ($query) {
$query->whereHas('workspace', fn ($q) => $q->where('name', 'like', "%{$this->search}%"));
})
->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter))
->when($this->gatewayFilter, fn ($q) => $q->where('gateway', $this->gatewayFilter))
->when($this->workspaceFilter, fn ($q) => $q->where('workspace_id', $this->workspaceFilter))
->latest()
->paginate(25);
}
#[Computed]
public function workspaces()
{
return Workspace::orderBy('name')->get(['id', 'name']);
}
#[Computed]
public function statuses(): array
{
return [
'active' => 'Active',
'trialing' => 'Trialing',
'past_due' => 'Past Due',
'paused' => 'Paused',
'cancelled' => 'Cancelled',
'incomplete' => 'Incomplete',
'expired' => 'Expired',
];
}
#[Computed]
public function gateways(): array
{
return [
'stripe' => 'Stripe',
'btcpay' => 'BTCPay',
];
}
#[Computed]
public function tableColumns(): array
{
return [
'Workspace',
'Package',
'Gateway',
['label' => 'Status', 'align' => 'center'],
'Billing',
'Period Ends',
['label' => 'Actions', 'align' => 'center'],
];
}
#[Computed]
public function tableRowIds(): array
{
return $this->subscriptions->pluck('id')->all();
}
#[Computed]
public function tableRows(): array
{
return $this->subscriptions->map(function ($s) {
$statusColors = [
'active' => 'green',
'trialing' => 'blue',
'past_due' => 'amber',
'paused' => 'gray',
'cancelled' => 'red',
'incomplete' => 'amber',
'expired' => 'gray',
];
$actions = [
['icon' => 'eye', 'click' => "viewSubscription({$s->id})", 'title' => 'View details'],
['icon' => 'pencil', 'click' => "openStatusChange({$s->id})", 'title' => 'Change status'],
['icon' => 'clock', 'click' => "openExtendPeriod({$s->id})", 'title' => 'Extend period'],
];
if ($s->cancelled_at && ! $s->ended_at) {
$actions[] = ['icon' => 'play', 'click' => "resumeSubscription({$s->id})", 'title' => 'Resume', 'class' => 'text-green-600'];
} elseif ($s->isActive()) {
$actions[] = ['icon' => 'x-mark', 'click' => "cancelSubscription({$s->id})", 'confirm' => 'Are you sure you want to cancel this subscription?', 'title' => 'Cancel', 'class' => 'text-red-600'];
}
return [
['bold' => $s->workspace?->name ?? 'Unknown'],
[
'lines' => [
['bold' => $s->workspacePackage?->package?->name ?? 'Unknown'],
['mono' => $s->workspacePackage?->package?->code],
],
],
['badge' => ucfirst($s->gateway ?? 'unknown'), 'color' => 'gray'],
[
'lines' => array_filter([
['badge' => ucfirst($s->status), 'color' => $statusColors[$s->status] ?? 'gray'],
$s->cancel_at_period_end ? ['muted' => 'Cancels at period end'] : null,
]),
],
ucfirst($s->billing_cycle ?? 'monthly'),
$s->current_period_end
? [
'lines' => [
['bold' => $s->current_period_end->format('d M Y')],
['muted' => $s->current_period_end->isPast()
? 'Ended '.$s->current_period_end->diffForHumans()
: $s->current_period_end->diffForHumans()],
],
]
: '-',
['actions' => $actions],
];
})->all();
}
public function render()
{
return view('commerce::admin.subscription-manager')
->layout('hub::admin.layouts.app', ['title' => 'Subscriptions']);
}
}