Extends prior #860 DunningService with the full RFC.md surface. Lands across 44 modified/new files: * Contracts/PaymentGatewayContract.php — implemented by both Services/StripeGateway.php and Services/BTCPayGateway.php * Boot.php — provider bindings + route groups + Commerce Matrix training mode middleware * Services/WebhookService.php — DB::transaction wrapping + ProcessWebhookEvent job dispatched ->afterCommit; idempotency via webhook_events unique (gateway, event_id) — duplicates rejected silently * Jobs/ProcessWebhookEvent.php * DTOs/ — readonly PHP 8.2+ classes per RFC.dto.md * Services/SubscriptionStateMachine.php — active → suspended (failed payment) → cancelled → expired transitions * Services/ProrationService.php — credit unused old plan time, charge new plan remainder, applied via CreditNote + Invoice * DunningService extended — 1d/3d/7d/14d retry config + cancel * Migrations — guarded migrations for missing short-name billing tables (orders/payments/invoices) + RFC compatibility columns * routes/api.php — /v1/* endpoints * Checkout success/cancel routes * Commerce Matrix training-mode endpoint + record-permissions logic * Console/Commands — RFC.commands.md signatures * Events per RFC.events.md * Models extended php -l clean. composer validate passes. pest unrunnable in sandbox. Co-authored-by: Codex <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=845
116 lines
7.3 KiB
PHP
116 lines
7.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Core\Mod\Commerce\Controllers\Api\CommerceController;
|
|
use Core\Mod\Commerce\Controllers\Webhooks\BTCPayWebhookController;
|
|
use Core\Mod\Commerce\Controllers\Webhooks\StripeWebhookController;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Commerce API Routes
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| API routes for the Commerce module including payment webhooks,
|
|
| billing management, and provisioning endpoints.
|
|
|
|
|
*/
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Payment Webhooks (no auth - uses signature verification + IP-based rate limiting)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
Route::prefix('webhooks')->group(function () {
|
|
// Rate limiting is handled per-IP in the controllers via WebhookRateLimiter
|
|
// This provides better protection than global throttle middleware:
|
|
// - Per-IP limits (60/min default, 300/min for trusted gateway IPs)
|
|
// - Different limits per gateway
|
|
// - Proper 429 responses with Retry-After headers
|
|
Route::post('/btcpay', [BTCPayWebhookController::class, 'handle'])
|
|
->name('api.webhook.btcpay');
|
|
Route::post('/stripe', [StripeWebhookController::class, 'handle'])
|
|
->name('api.webhook.stripe');
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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');
|
|
// });
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Commerce Billing API (authenticated)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
Route::middleware('auth')->prefix('commerce')->group(function () {
|
|
// Billing overview
|
|
Route::get('/billing', [CommerceController::class, 'billing'])
|
|
->name('api.commerce.billing');
|
|
|
|
// Orders
|
|
Route::get('/orders', [CommerceController::class, 'orders'])
|
|
->name('api.commerce.orders.index');
|
|
Route::get('/orders/{order}', [CommerceController::class, 'showOrder'])
|
|
->name('api.commerce.orders.show');
|
|
|
|
// Invoices
|
|
Route::get('/invoices', [CommerceController::class, 'invoices'])
|
|
->name('api.commerce.invoices.index');
|
|
Route::get('/invoices/{invoice}', [CommerceController::class, 'showInvoice'])
|
|
->name('api.commerce.invoices.show');
|
|
Route::get('/invoices/{invoice}/download', [CommerceController::class, 'downloadInvoice'])
|
|
->name('api.commerce.invoices.download');
|
|
|
|
// Subscription
|
|
Route::get('/subscription', [CommerceController::class, 'subscription'])
|
|
->name('api.commerce.subscription');
|
|
Route::post('/cancel', [CommerceController::class, 'cancelSubscription'])
|
|
->name('api.commerce.cancel');
|
|
Route::post('/resume', [CommerceController::class, 'resumeSubscription'])
|
|
->name('api.commerce.resume');
|
|
|
|
// Usage
|
|
Route::get('/usage', [CommerceController::class, 'usage'])
|
|
->name('api.commerce.usage');
|
|
|
|
// Plan changes
|
|
Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade'])
|
|
->name('api.commerce.upgrade.preview');
|
|
Route::post('/upgrade', [CommerceController::class, 'executeUpgrade'])
|
|
->name('api.commerce.upgrade');
|
|
});
|
|
|
|
Route::middleware('auth')->prefix('v1')->name('api.v1.')->group(function () {
|
|
Route::post('/checkout', [CommerceController::class, 'checkout'])->name('checkout.store');
|
|
Route::get('/checkout/{id}', [CommerceController::class, 'checkoutStatus'])->name('checkout.show');
|
|
Route::post('/checkout/{id}/confirm', [CommerceController::class, 'confirmCheckout'])->name('checkout.confirm');
|
|
|
|
Route::get('/orders', [CommerceController::class, 'orders'])->name('orders.index');
|
|
Route::get('/orders/{order}', [CommerceController::class, 'showOrder'])->name('orders.show');
|
|
|
|
Route::get('/subscriptions', [CommerceController::class, 'subscriptions'])->name('subscriptions.index');
|
|
Route::post('/subscriptions/{subscription}/cancel', [CommerceController::class, 'cancelSubscriptionById'])->name('subscriptions.cancel');
|
|
Route::post('/subscriptions/{subscription}/change-plan', [CommerceController::class, 'changePlan'])->name('subscriptions.change-plan');
|
|
|
|
Route::get('/invoices', [CommerceController::class, 'invoices'])->name('invoices.index');
|
|
Route::get('/invoices/{invoice}', [CommerceController::class, 'showInvoice'])->name('invoices.show');
|
|
Route::get('/invoices/{invoice}/pdf', [CommerceController::class, 'downloadInvoice'])->name('invoices.pdf');
|
|
|
|
Route::get('/payment-methods', [CommerceController::class, 'paymentMethods'])->name('payment-methods.index');
|
|
Route::post('/payment-methods', [CommerceController::class, 'storePaymentMethod'])->name('payment-methods.store');
|
|
Route::delete('/payment-methods/{paymentMethod}', [CommerceController::class, 'deletePaymentMethod'])->name('payment-methods.destroy');
|
|
Route::post('/payment-methods/{paymentMethod}/default', [CommerceController::class, 'setDefaultPaymentMethod'])->name('payment-methods.default');
|
|
});
|