diff --git a/Boot.php b/Boot.php index 8684c5b..08f54ac 100644 --- a/Boot.php +++ b/Boot.php @@ -34,6 +34,7 @@ use Core\Mod\Commerce\Services\SubscriptionService; use Core\Mod\Commerce\Services\TaxService; use Core\Mod\Commerce\Services\UsageBillingService; use Core\Mod\Commerce\Services\WebhookRateLimiter; +use Illuminate\Routing\Router; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -65,6 +66,11 @@ class Boot extends ServiceProvider { $this->loadMigrationsFrom(__DIR__.'/Migrations'); + // Register middleware aliases + $router = $this->app->make(Router::class); + $router->aliasMiddleware('commerce.api', Middleware\CommerceApiAuth::class); + $router->aliasMiddleware('commerce.matrix', Middleware\CommerceMatrixGate::class); + // Laravel event listeners (not lifecycle events) Event::subscribe(ProvisionSocialHostSubscription::class); Event::listen(SubscriptionCreated::class, RewardAgentReferralOnSubscription::class); diff --git a/Controllers/Api/EntitlementApiController.php b/Controllers/Api/EntitlementApiController.php new file mode 100644 index 0000000..01127d1 --- /dev/null +++ b/Controllers/Api/EntitlementApiController.php @@ -0,0 +1,381 @@ +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); + } + } +} diff --git a/Controllers/Api/ProductApiController.php b/Controllers/Api/ProductApiController.php new file mode 100644 index 0000000..1f4a86c --- /dev/null +++ b/Controllers/Api/ProductApiController.php @@ -0,0 +1,92 @@ +json([ + 'status' => 'ok', + 'service' => 'commerce-provisioning', + 'timestamp' => now()->toIso8601String(), + ]); + } + + /** + * List all active, visible products. + * + * GET /api/v1/provisioning/products + * + * Query parameters: + * - category: filter by product category + * - type: filter by product type (simple, virtual, subscription, etc.) + * - per_page: pagination size (default 25, max 100) + */ + public function index(Request $request): JsonResponse + { + $query = Product::query() + ->active() + ->visible() + ->orderBy('sort_order'); + + if ($category = $request->get('category')) { + $query->where('category', $category); + } + + if ($type = $request->get('type')) { + $query->where('type', $type); + } + + $perPage = min((int) $request->get('per_page', 25), 100); + $products = $query->paginate($perPage); + + return response()->json($products); + } + + /** + * Show a single product by SKU code. + * + * GET /api/v1/provisioning/products/{code} + */ + public function show(string $code): JsonResponse + { + $product = Product::where('sku', strtoupper($code)) + ->active() + ->visible() + ->first(); + + if (! $product) { + return response()->json([ + 'error' => 'not_found', + 'message' => "Product with SKU '{$code}' not found", + ], 404); + } + + return response()->json(['data' => $product]); + } +} diff --git a/Services/CouponService.php b/Services/CouponService.php index 45f3ecc..7d1ffce 100644 --- a/Services/CouponService.php +++ b/Services/CouponService.php @@ -15,7 +15,19 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; /** - * Coupon validation and application service. + * Coupon validation, application, and lifecycle management service. + * + * Provides input sanitisation, format validation, eligibility checks + * (expiry, usage limits, package restrictions), discount calculation, + * and usage tracking. Supports both workspace-scoped and polymorphic + * Orderable-scoped validation for User and Workspace purchases. + * + * Coupon codes are normalised to uppercase and restricted to alphanumeric + * characters, hyphens, and underscores (3-50 characters) to prevent + * brute-force enumeration and injection attacks. + * + * @see Coupon + * @see CouponValidationResult */ class CouponService { diff --git a/Services/CreditNoteService.php b/Services/CreditNoteService.php index 96ef936..e6e011c 100644 --- a/Services/CreditNoteService.php +++ b/Services/CreditNoteService.php @@ -13,6 +13,18 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +/** + * Credit note lifecycle management service. + * + * Handles creation, application, and voiding of credit notes for workspace + * billing. Credit notes may originate from partial refunds, goodwill gestures, + * or administrative adjustments and can be applied to future orders using + * FIFO ordering (oldest credits consumed first). + * + * Lifecycle: draft -> issued -> (partially_used | used | void) + * + * @see CreditNote + */ class CreditNoteService { /** diff --git a/Services/InvoiceService.php b/Services/InvoiceService.php index 65f4f36..1c90033 100644 --- a/Services/InvoiceService.php +++ b/Services/InvoiceService.php @@ -18,7 +18,19 @@ use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\StreamedResponse; /** - * Invoice generation and management service. + * Invoice generation, PDF rendering, and delivery service. + * + * Creates invoices from completed orders and subscription renewals, + * generates PDF documents via DomPDF, and dispatches email notifications. + * Invoices are linked to workspaces and support configurable due dates, + * tax calculations, and multiple line items. + * + * PDF storage is configurable via `commerce.pdf.storage_disk` and + * `commerce.pdf.storage_path`. Invoices are generated on demand and + * cached for subsequent downloads. + * + * @see Invoice + * @see InvoiceItem */ class InvoiceService { diff --git a/Services/RefundService.php b/Services/RefundService.php index 7722e37..4ac1c2a 100644 --- a/Services/RefundService.php +++ b/Services/RefundService.php @@ -12,6 +12,19 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +/** + * Refund processing service. + * + * Orchestrates full and partial refunds through the appropriate payment + * gateway, validates refund eligibility (amount limits, time windows), + * and dispatches notifications to workspace owners. All refund operations + * are wrapped in database transactions for consistency. + * + * Gateway-specific constraints (e.g. Stripe's 180-day refund window) are + * enforced via the configurable `commerce.refunds.window_days` setting. + * + * @see Refund + */ class RefundService { public function __construct( diff --git a/Services/SubscriptionService.php b/Services/SubscriptionService.php index aecfffa..e2f53a0 100644 --- a/Services/SubscriptionService.php +++ b/Services/SubscriptionService.php @@ -15,6 +15,23 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +/** + * Subscription lifecycle management service. + * + * Manages the full subscription lifecycle: creation, renewal, cancellation, + * pausing/unpausing, expiration, and plan changes (upgrades/downgrades). + * Integrates with the EntitlementService to provision and revoke workspace + * packages when subscriptions change state. + * + * Plan changes support both immediate application (with proration) and + * deferred application at the end of the current billing period. + * + * Billing periods use fixed-day intervals (30 days monthly, 365 days yearly) + * for predictable, deterministic billing calculations. + * + * @see Subscription + * @see ProrationResult + */ class SubscriptionService { public function __construct( diff --git a/routes/api.php b/routes/api.php index 1c0e3fc..775699f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Core\Mod\Commerce\Controllers\Api\CommerceController; +use Core\Mod\Commerce\Controllers\Api\EntitlementApiController; +use Core\Mod\Commerce\Controllers\Api\ProductApiController; use Core\Mod\Commerce\Controllers\Webhooks\BTCPayWebhookController; use Core\Mod\Commerce\Controllers\Webhooks\StripeWebhookController; use Illuminate\Support\Facades\Route; @@ -35,21 +37,19 @@ Route::prefix('webhooks')->group(function () { // ───────────────────────────────────────────────────────────────────────────── // Commerce Provisioning API (Bearer token auth) -// TODO: Create ProductApiController and EntitlementApiController in -// Mod\Commerce\Controllers\Api\ for provisioning endpoints // ───────────────────────────────────────────────────────────────────────────── -// Route::middleware('commerce.api')->prefix('provisioning')->group(function () { -// Route::get('/ping', [ProductApiController::class, 'ping'])->name('api.commerce.ping'); -// Route::get('/products', [ProductApiController::class, 'index'])->name('api.commerce.products'); -// Route::get('/products/{code}', [ProductApiController::class, 'show'])->name('api.commerce.products.show'); -// Route::post('/entitlements', [EntitlementApiController::class, 'store'])->name('api.commerce.entitlements.store'); -// Route::get('/entitlements/{id}', [EntitlementApiController::class, 'show'])->name('api.commerce.entitlements.show'); -// Route::post('/entitlements/{id}/suspend', [EntitlementApiController::class, 'suspend'])->name('api.commerce.entitlements.suspend'); -// Route::post('/entitlements/{id}/unsuspend', [EntitlementApiController::class, 'unsuspend'])->name('api.commerce.entitlements.unsuspend'); -// Route::post('/entitlements/{id}/cancel', [EntitlementApiController::class, 'cancel'])->name('api.commerce.entitlements.cancel'); -// Route::post('/entitlements/{id}/renew', [EntitlementApiController::class, 'renew'])->name('api.commerce.entitlements.renew'); -// }); +Route::middleware('commerce.api')->prefix('provisioning')->group(function () { + Route::get('/ping', [ProductApiController::class, 'ping'])->name('api.commerce.ping'); + Route::get('/products', [ProductApiController::class, 'index'])->name('api.commerce.products'); + Route::get('/products/{code}', [ProductApiController::class, 'show'])->name('api.commerce.products.show'); + Route::post('/entitlements', [EntitlementApiController::class, 'store'])->name('api.commerce.entitlements.store'); + Route::get('/entitlements/{id}', [EntitlementApiController::class, 'show'])->name('api.commerce.entitlements.show'); + Route::post('/entitlements/{id}/suspend', [EntitlementApiController::class, 'suspend'])->name('api.commerce.entitlements.suspend'); + Route::post('/entitlements/{id}/unsuspend', [EntitlementApiController::class, 'unsuspend'])->name('api.commerce.entitlements.unsuspend'); + Route::post('/entitlements/{id}/cancel', [EntitlementApiController::class, 'cancel'])->name('api.commerce.entitlements.cancel'); + Route::post('/entitlements/{id}/renew', [EntitlementApiController::class, 'renew'])->name('api.commerce.entitlements.renew'); +}); // ───────────────────────────────────────────────────────────────────────────── // Commerce Billing API (authenticated + verified)