feat: provisioning API endpoints and service documentation

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>
This commit is contained in:
Claude 2026-03-24 16:34:57 +00:00
parent 5bce748a0f
commit 0685429c74
No known key found for this signature in database
GPG key ID: AF404715446AEB41
9 changed files with 560 additions and 15 deletions

View file

@ -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);

View file

@ -0,0 +1,381 @@
<?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);
}
}
}

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Commerce\Models\Product;
use Core\Mod\Commerce\Services\ProductCatalogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Provisioning API controller for product catalogue queries.
*
* Provides read-only access to the product catalogue for internal
* services and external integrations. Authenticated via Bearer token
* through the CommerceApiAuth middleware.
*/
class ProductApiController extends Controller
{
public function __construct(
protected ProductCatalogService $catalogService,
) {}
/**
* Health-check / connectivity ping.
*
* GET /api/v1/provisioning/ping
*/
public function ping(): JsonResponse
{
return response()->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]);
}
}

View file

@ -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
{

View file

@ -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
{
/**

View file

@ -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
{

View file

@ -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(

View file

@ -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(

View file

@ -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)