Implement the provisioning API endpoints referenced in routes/api.php and add comprehensive PHPDoc to service classes missing documentation. Provisioning API (Issue #15): - ProductApiController: ping, product listing, product lookup by SKU - EntitlementApiController: create, show, suspend, unsuspend, cancel, renew - Uncomment and activate provisioning route group with commerce.api middleware - Register commerce.api and commerce.matrix middleware aliases in Boot.php Service documentation (Issue #14): - CreditNoteService: lifecycle, FIFO ordering, state machine - RefundService: gateway orchestration, eligibility, transaction safety - SubscriptionService: lifecycle, proration, fixed-day billing periods - CouponService: sanitisation, validation, Orderable polymorphism - InvoiceService: PDF generation, storage, email delivery Fixes #14 Fixes #15 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
381 lines
13 KiB
PHP
381 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Mod\Commerce\Controllers\Api;
|
|
|
|
use Core\Front\Controller;
|
|
use Core\Mod\Commerce\Models\Subscription;
|
|
use Core\Mod\Commerce\Services\SubscriptionService;
|
|
use Core\Tenant\Models\Workspace;
|
|
use Core\Tenant\Services\EntitlementService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Provisioning API controller for entitlement lifecycle management.
|
|
*
|
|
* Provides endpoints for creating, inspecting, suspending, unsuspending,
|
|
* cancelling, and renewing entitlements (workspace package subscriptions).
|
|
* Authenticated via Bearer token through the CommerceApiAuth middleware.
|
|
*
|
|
* Entitlements represent the link between a workspace, a package, and
|
|
* its active subscription. Each entitlement is identified by its
|
|
* subscription ID.
|
|
*/
|
|
class EntitlementApiController extends Controller
|
|
{
|
|
public function __construct(
|
|
protected EntitlementService $entitlements,
|
|
protected SubscriptionService $subscriptionService,
|
|
) {}
|
|
|
|
/**
|
|
* Provision a new entitlement for a workspace.
|
|
*
|
|
* POST /api/v1/provisioning/entitlements
|
|
*
|
|
* Request body:
|
|
* - workspace_id (required): The workspace to provision
|
|
* - package_code (required): The entitlement package code
|
|
* - billing_cycle (optional): "monthly" or "yearly" (default: "monthly")
|
|
* - gateway (optional): Payment gateway identifier
|
|
* - gateway_subscription_id (optional): External subscription reference
|
|
*/
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'workspace_id' => 'required|integer|exists:workspaces,id',
|
|
'package_code' => 'required|string',
|
|
'billing_cycle' => 'in:monthly,yearly',
|
|
'gateway' => 'nullable|string',
|
|
'gateway_subscription_id' => 'nullable|string',
|
|
]);
|
|
|
|
$workspace = Workspace::findOrFail($validated['workspace_id']);
|
|
$billingCycle = $validated['billing_cycle'] ?? 'monthly';
|
|
|
|
try {
|
|
$result = DB::transaction(function () use ($workspace, $validated, $billingCycle) {
|
|
// Provision the package entitlements
|
|
$workspacePackage = $this->entitlements->provisionPackage(
|
|
$workspace,
|
|
$validated['package_code'],
|
|
[
|
|
'source' => 'provisioning_api',
|
|
'gateway' => $validated['gateway'] ?? null,
|
|
]
|
|
);
|
|
|
|
// Create the subscription record
|
|
$subscription = $this->subscriptionService->create(
|
|
$workspacePackage,
|
|
$billingCycle,
|
|
$validated['gateway'] ?? null,
|
|
$validated['gateway_subscription_id'] ?? null
|
|
);
|
|
|
|
return [
|
|
'workspace_package' => $workspacePackage,
|
|
'subscription' => $subscription,
|
|
];
|
|
});
|
|
|
|
Log::info('Entitlement provisioned via API', [
|
|
'workspace_id' => $workspace->id,
|
|
'package_code' => $validated['package_code'],
|
|
'subscription_id' => $result['subscription']->id,
|
|
]);
|
|
|
|
return response()->json([
|
|
'data' => [
|
|
'id' => $result['subscription']->id,
|
|
'workspace_id' => $workspace->id,
|
|
'package_code' => $validated['package_code'],
|
|
'billing_cycle' => $billingCycle,
|
|
'status' => $result['subscription']->status,
|
|
'current_period_start' => $result['subscription']->current_period_start?->toIso8601String(),
|
|
'current_period_end' => $result['subscription']->current_period_end?->toIso8601String(),
|
|
],
|
|
'message' => 'Entitlement provisioned successfully',
|
|
], 201);
|
|
} catch (\Exception $e) {
|
|
Log::error('Entitlement provisioning failed', [
|
|
'workspace_id' => $workspace->id,
|
|
'package_code' => $validated['package_code'],
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return response()->json([
|
|
'error' => 'provisioning_failed',
|
|
'message' => $e->getMessage(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show entitlement details by subscription ID.
|
|
*
|
|
* GET /api/v1/provisioning/entitlements/{id}
|
|
*/
|
|
public function show(int $id): JsonResponse
|
|
{
|
|
$subscription = Subscription::with(['workspace', 'workspacePackage.package'])
|
|
->find($id);
|
|
|
|
if (! $subscription) {
|
|
return response()->json([
|
|
'error' => 'not_found',
|
|
'message' => 'Entitlement not found',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json([
|
|
'data' => [
|
|
'id' => $subscription->id,
|
|
'workspace_id' => $subscription->workspace_id,
|
|
'package_code' => $subscription->workspacePackage?->package?->code,
|
|
'package_name' => $subscription->workspacePackage?->package?->name,
|
|
'status' => $subscription->status,
|
|
'billing_cycle' => $subscription->billing_cycle,
|
|
'gateway' => $subscription->gateway,
|
|
'current_period_start' => $subscription->current_period_start?->toIso8601String(),
|
|
'current_period_end' => $subscription->current_period_end?->toIso8601String(),
|
|
'cancelled_at' => $subscription->cancelled_at?->toIso8601String(),
|
|
'cancellation_reason' => $subscription->cancellation_reason,
|
|
'paused_at' => $subscription->paused_at?->toIso8601String(),
|
|
'ended_at' => $subscription->ended_at?->toIso8601String(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Suspend an entitlement (pause the subscription and restrict access).
|
|
*
|
|
* POST /api/v1/provisioning/entitlements/{id}/suspend
|
|
*/
|
|
public function suspend(Request $request, int $id): JsonResponse
|
|
{
|
|
$subscription = Subscription::find($id);
|
|
|
|
if (! $subscription) {
|
|
return response()->json([
|
|
'error' => 'not_found',
|
|
'message' => 'Entitlement not found',
|
|
], 404);
|
|
}
|
|
|
|
if (! $subscription->isActive()) {
|
|
return response()->json([
|
|
'error' => 'invalid_state',
|
|
'message' => "Cannot suspend entitlement in '{$subscription->status}' state",
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
$this->subscriptionService->pause($subscription, force: true);
|
|
|
|
if ($subscription->workspace) {
|
|
$reason = $request->get('reason', 'api_suspension');
|
|
$this->entitlements->suspendWorkspace($subscription->workspace, $reason);
|
|
}
|
|
|
|
Log::info('Entitlement suspended via API', [
|
|
'subscription_id' => $subscription->id,
|
|
'workspace_id' => $subscription->workspace_id,
|
|
]);
|
|
|
|
return response()->json([
|
|
'message' => 'Entitlement suspended successfully',
|
|
'data' => [
|
|
'id' => $subscription->id,
|
|
'status' => $subscription->fresh()->status,
|
|
],
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'error' => 'suspension_failed',
|
|
'message' => $e->getMessage(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsuspend an entitlement (unpause and restore access).
|
|
*
|
|
* POST /api/v1/provisioning/entitlements/{id}/unsuspend
|
|
*/
|
|
public function unsuspend(int $id): JsonResponse
|
|
{
|
|
$subscription = Subscription::find($id);
|
|
|
|
if (! $subscription) {
|
|
return response()->json([
|
|
'error' => 'not_found',
|
|
'message' => 'Entitlement not found',
|
|
], 404);
|
|
}
|
|
|
|
if (! $subscription->isPaused()) {
|
|
return response()->json([
|
|
'error' => 'invalid_state',
|
|
'message' => "Cannot unsuspend entitlement in '{$subscription->status}' state",
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
$this->subscriptionService->unpause($subscription);
|
|
|
|
if ($subscription->workspace) {
|
|
$this->entitlements->reactivateWorkspace($subscription->workspace, 'api_unsuspension');
|
|
}
|
|
|
|
Log::info('Entitlement unsuspended via API', [
|
|
'subscription_id' => $subscription->id,
|
|
'workspace_id' => $subscription->workspace_id,
|
|
]);
|
|
|
|
return response()->json([
|
|
'message' => 'Entitlement unsuspended successfully',
|
|
'data' => [
|
|
'id' => $subscription->id,
|
|
'status' => $subscription->fresh()->status,
|
|
],
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'error' => 'unsuspend_failed',
|
|
'message' => $e->getMessage(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel an entitlement.
|
|
*
|
|
* POST /api/v1/provisioning/entitlements/{id}/cancel
|
|
*
|
|
* Request body:
|
|
* - reason (optional): Cancellation reason
|
|
* - immediately (optional): Whether to cancel immediately (default: false)
|
|
*/
|
|
public function cancel(Request $request, int $id): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'reason' => 'nullable|string|max:500',
|
|
'immediately' => 'boolean',
|
|
]);
|
|
|
|
$subscription = Subscription::find($id);
|
|
|
|
if (! $subscription) {
|
|
return response()->json([
|
|
'error' => 'not_found',
|
|
'message' => 'Entitlement not found',
|
|
], 404);
|
|
}
|
|
|
|
if ($subscription->isCancelled() || $subscription->status === 'expired') {
|
|
return response()->json([
|
|
'error' => 'invalid_state',
|
|
'message' => 'Entitlement is already cancelled or expired',
|
|
], 422);
|
|
}
|
|
|
|
$immediately = $validated['immediately'] ?? false;
|
|
|
|
try {
|
|
$this->subscriptionService->cancel(
|
|
$subscription,
|
|
$validated['reason'] ?? null
|
|
);
|
|
|
|
if ($immediately) {
|
|
$this->subscriptionService->expire($subscription);
|
|
|
|
if ($subscription->workspace && $subscription->workspacePackage?->package) {
|
|
$this->entitlements->revokePackage(
|
|
$subscription->workspace,
|
|
$subscription->workspacePackage->package->code
|
|
);
|
|
}
|
|
}
|
|
|
|
Log::info('Entitlement cancelled via API', [
|
|
'subscription_id' => $subscription->id,
|
|
'workspace_id' => $subscription->workspace_id,
|
|
'immediately' => $immediately,
|
|
]);
|
|
|
|
$fresh = $subscription->fresh();
|
|
|
|
return response()->json([
|
|
'message' => $immediately
|
|
? 'Entitlement cancelled immediately'
|
|
: 'Entitlement will be cancelled at end of billing period',
|
|
'data' => [
|
|
'id' => $fresh->id,
|
|
'status' => $fresh->status,
|
|
'cancelled_at' => $fresh->cancelled_at?->toIso8601String(),
|
|
'ended_at' => $fresh->ended_at?->toIso8601String(),
|
|
],
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'error' => 'cancellation_failed',
|
|
'message' => $e->getMessage(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renew an entitlement for another billing period.
|
|
*
|
|
* POST /api/v1/provisioning/entitlements/{id}/renew
|
|
*/
|
|
public function renew(int $id): JsonResponse
|
|
{
|
|
$subscription = Subscription::find($id);
|
|
|
|
if (! $subscription) {
|
|
return response()->json([
|
|
'error' => 'not_found',
|
|
'message' => 'Entitlement not found',
|
|
], 404);
|
|
}
|
|
|
|
if (! in_array($subscription->status, ['active', 'past_due'])) {
|
|
return response()->json([
|
|
'error' => 'invalid_state',
|
|
'message' => "Cannot renew entitlement in '{$subscription->status}' state",
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
$renewed = $this->subscriptionService->renew($subscription);
|
|
|
|
Log::info('Entitlement renewed via API', [
|
|
'subscription_id' => $renewed->id,
|
|
'workspace_id' => $renewed->workspace_id,
|
|
'new_period_end' => $renewed->current_period_end,
|
|
]);
|
|
|
|
return response()->json([
|
|
'message' => 'Entitlement renewed successfully',
|
|
'data' => [
|
|
'id' => $renewed->id,
|
|
'status' => $renewed->status,
|
|
'current_period_start' => $renewed->current_period_start?->toIso8601String(),
|
|
'current_period_end' => $renewed->current_period_end?->toIso8601String(),
|
|
],
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'error' => 'renewal_failed',
|
|
'message' => $e->getMessage(),
|
|
], 422);
|
|
}
|
|
}
|
|
}
|