From 4e4337e412a56c51e27c2cff8bb95bd7474dc647 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 22:55:49 +0100 Subject: [PATCH] =?UTF-8?q?feat(commerce):=20implement=20RFC.md=20?= =?UTF-8?q?=E2=80=94=20billing,=20subscriptions,=20Stripe=20+=20BTCPay,=20?= =?UTF-8?q?Commerce=20Matrix=20(#845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Closes tasks.lthn.sh/view.php?id=845 --- Boot.php | 23 ++ Console/PlantSubscriberTrees.php | 2 +- Console/ProcessDunning.php | 2 +- Console/RefreshExchangeRates.php | 2 +- Contracts/PaymentGatewayContract.php | 39 +++ Controllers/Api/CommerceController.php | 207 +++++++++++++ .../Webhooks/BTCPayWebhookController.php | 4 + .../Webhooks/StripeWebhookController.php | 4 + DTOs/BundleItem.php | 26 ++ DTOs/CouponValidationResult.php | 28 ++ DTOs/FraudAssessment.php | 31 ++ DTOs/ParsedItem.php | 26 ++ DTOs/PermissionResult.php | 29 ++ DTOs/ProrationResult.php | 34 +++ DTOs/SkuOption.php | 26 ++ DTOs/SkuParseResult.php | 34 +++ Events/OrderPaid.php | 12 +- Events/SubscriptionCancelled.php | 12 +- Events/SubscriptionCreated.php | 12 +- Events/SubscriptionRenewed.php | 9 +- Events/SubscriptionUpdated.php | 12 +- Jobs/ProcessWebhookEvent.php | 59 ++++ ...04_25_000001_create_rfc_billing_tables.php | 283 ++++++++++++++++++ .../2026_04_25_000002_align_rfc_columns.php | 75 +++++ Models/BundleHash.php | 2 + Models/CreditNote.php | 6 + Models/Order.php | 3 + Models/Payment.php | 1 + Models/PermissionMatrix.php | 8 + Models/PermissionRequest.php | 14 + Models/ProductAssignment.php | 1 + Models/ProductPrice.php | 1 + Models/Subscription.php | 18 +- Services/BTCPayGateway.php | 102 +++++++ Services/CommerceService.php | 134 +++++++++ Services/CurrencyService.php | 18 ++ Services/DunningService.php | 63 +++- Services/InvoiceService.php | 39 +++ Services/PermissionMatrixService.php | 146 ++++++++- Services/ProrationService.php | 169 +++++++++++ Services/RefundService.php | 5 + Services/SkuBuilderService.php | 20 +- Services/StripeGateway.php | 97 ++++++ Services/SubscriptionService.php | 30 +- Services/SubscriptionStateMachine.php | 100 +++++++ Services/WebhookService.php | 109 +++++++ config.php | 7 +- routes/admin.php | 5 + routes/api.php | 22 ++ routes/web.php | 5 + tests/Feature/RfcSurfaceTest.php | 36 +++ 51 files changed, 2112 insertions(+), 40 deletions(-) create mode 100644 Contracts/PaymentGatewayContract.php create mode 100644 DTOs/BundleItem.php create mode 100644 DTOs/CouponValidationResult.php create mode 100644 DTOs/FraudAssessment.php create mode 100644 DTOs/ParsedItem.php create mode 100644 DTOs/PermissionResult.php create mode 100644 DTOs/ProrationResult.php create mode 100644 DTOs/SkuOption.php create mode 100644 DTOs/SkuParseResult.php create mode 100644 Jobs/ProcessWebhookEvent.php create mode 100644 Migrations/2026_04_25_000001_create_rfc_billing_tables.php create mode 100644 Migrations/2026_04_25_000002_align_rfc_columns.php create mode 100644 Services/BTCPayGateway.php create mode 100644 Services/ProrationService.php create mode 100644 Services/StripeGateway.php create mode 100644 Services/SubscriptionStateMachine.php create mode 100644 Services/WebhookService.php create mode 100644 tests/Feature/RfcSurfaceTest.php diff --git a/Boot.php b/Boot.php index 8684c5b..2671843 100644 --- a/Boot.php +++ b/Boot.php @@ -8,6 +8,7 @@ use Core\Events\AdminPanelBooting; use Core\Events\ApiRoutesRegistering; use Core\Events\ConsoleBooting; use Core\Events\WebRoutesRegistering; +use Core\Mod\Commerce\Contracts\PaymentGatewayContract as RfcPaymentGatewayContract; use Core\Mod\Commerce\Events\OrderPaid; use Core\Mod\Commerce\Events\SubscriptionCreated; use Core\Mod\Commerce\Events\SubscriptionRenewed; @@ -27,12 +28,15 @@ use Core\Mod\Commerce\Services\PaymentGateway\PaymentGatewayContract; use Core\Mod\Commerce\Services\PaymentGateway\StripeGateway; use Core\Mod\Commerce\Services\PaymentMethodService; use Core\Mod\Commerce\Services\PermissionMatrixService; +use Core\Mod\Commerce\Services\ProrationService; use Core\Mod\Commerce\Services\ReferralService; use Core\Mod\Commerce\Services\SkuBuilderService; use Core\Mod\Commerce\Services\SkuParserService; +use Core\Mod\Commerce\Services\SubscriptionStateMachine; use Core\Mod\Commerce\Services\SubscriptionService; use Core\Mod\Commerce\Services\TaxService; use Core\Mod\Commerce\Services\UsageBillingService; +use Core\Mod\Commerce\Services\WebhookService; use Core\Mod\Commerce\Services\WebhookRateLimiter; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; @@ -98,6 +102,9 @@ class Boot extends ServiceProvider $this->app->singleton(FraudService::class); $this->app->singleton(CheckoutRateLimiter::class); $this->app->singleton(WebhookRateLimiter::class); + $this->app->singleton(WebhookService::class); + $this->app->singleton(SubscriptionStateMachine::class); + $this->app->singleton(ProrationService::class); // Payment Gateways $this->app->singleton('commerce.gateway.btcpay', function ($app) { @@ -108,6 +115,14 @@ class Boot extends ServiceProvider return new StripeGateway; }); + $this->app->singleton('commerce.rfc_gateway.btcpay', function ($app) { + return new Services\BTCPayGateway; + }); + + $this->app->singleton('commerce.rfc_gateway.stripe', function ($app) { + return new Services\StripeGateway; + }); + $this->app->bind(PaymentGatewayContract::class, function ($app) { $defaultGateway = config('commerce.gateways.btcpay.enabled') ? 'btcpay' @@ -115,6 +130,14 @@ class Boot extends ServiceProvider return $app->make("commerce.gateway.{$defaultGateway}"); }); + + $this->app->bind(RfcPaymentGatewayContract::class, function ($app) { + $defaultGateway = config('commerce.gateways.btcpay.enabled') + ? 'btcpay' + : 'stripe'; + + return $app->make("commerce.rfc_gateway.{$defaultGateway}"); + }); } // ------------------------------------------------------------------------- diff --git a/Console/PlantSubscriberTrees.php b/Console/PlantSubscriberTrees.php index cf54d6b..d488037 100644 --- a/Console/PlantSubscriberTrees.php +++ b/Console/PlantSubscriberTrees.php @@ -22,7 +22,7 @@ use Mod\Trees\Models\TreePlanting; */ class PlantSubscriberTrees extends Command { - protected $signature = 'trees:subscriber-monthly + protected $signature = 'commerce:plant-trees {--dry-run : Show what would be planted without actually planting} {--force : Ignore monthly check and plant regardless}'; diff --git a/Console/ProcessDunning.php b/Console/ProcessDunning.php index ba6d7cc..43d9b93 100644 --- a/Console/ProcessDunning.php +++ b/Console/ProcessDunning.php @@ -12,7 +12,7 @@ use Illuminate\Support\Facades\Log; class ProcessDunning extends Command { - protected $signature = 'commerce:process-dunning + protected $signature = 'commerce:dunning {--dry-run : Show what would happen without making changes} {--stage= : Process only a specific stage (retry, pause, suspend, cancel, expire)}'; diff --git a/Console/RefreshExchangeRates.php b/Console/RefreshExchangeRates.php index c75da5e..cbd3048 100644 --- a/Console/RefreshExchangeRates.php +++ b/Console/RefreshExchangeRates.php @@ -15,7 +15,7 @@ use Illuminate\Console\Command; */ class RefreshExchangeRates extends Command { - protected $signature = 'commerce:refresh-exchange-rates + protected $signature = 'commerce:exchange-rates {--force : Force refresh even if rates are fresh}'; protected $description = 'Refresh exchange rates from the configured provider'; diff --git a/Contracts/PaymentGatewayContract.php b/Contracts/PaymentGatewayContract.php new file mode 100644 index 0000000..61a1a76 --- /dev/null +++ b/Contracts/PaymentGatewayContract.php @@ -0,0 +1,39 @@ + + */ + public function createSession(Order $order, PaymentMethod $paymentMethod): array; + + /** + * Confirm a gateway payment against a local payment record. + * + * @param array $gatewayData + */ + public function confirmPayment(Payment $payment, array $gatewayData): Payment; + + public function refund(Payment $payment, float $amount, string $reason): Refund; + + public function validateWebhookSignature(Request $request): bool; + + /** + * Parse the request payload into a normalised gateway event. + * + * @return array + */ + public function parseWebhookEvent(Request $request): array; +} diff --git a/Controllers/Api/CommerceController.php b/Controllers/Api/CommerceController.php index 58e5875..5d114f1 100644 --- a/Controllers/Api/CommerceController.php +++ b/Controllers/Api/CommerceController.php @@ -7,6 +7,7 @@ namespace Core\Mod\Commerce\Controllers\Api; use Core\Front\Controller; use Core\Mod\Commerce\Models\Invoice; use Core\Mod\Commerce\Models\Order; +use Core\Mod\Commerce\Models\PaymentMethod; use Core\Mod\Commerce\Models\Subscription; use Core\Mod\Commerce\Services\CommerceService; use Core\Mod\Commerce\Services\InvoiceService; @@ -52,6 +53,82 @@ class CommerceController extends Controller return $user->defaultHostWorkspace(); } + public function checkout(Request $request): JsonResponse + { + $validated = $request->validate([ + 'items' => 'required|array|min:1', + 'payment_method_id' => 'required|string', + 'coupon_code' => 'nullable|string', + 'currency' => 'nullable|string|size:3', + ]); + + $workspace = $this->getWorkspace($request); + + if (! $workspace) { + return response()->json(['error' => 'No workspace found'], 404); + } + + if (! method_exists($this->commerceService, 'processCheckout')) { + return response()->json([ + 'error' => 'checkout_unavailable', + 'message' => 'Checkout orchestration is not available for this frontage.', + ], 501); + } + + $result = $this->commerceService->processCheckout( + $workspace->id, + $validated['items'], + $validated['payment_method_id'] ?? '', + $validated['coupon_code'] ?? null + ); + + return response()->json(['data' => $result]); + } + + public function checkoutStatus(Request $request, string $id): JsonResponse + { + $workspace = $this->getWorkspace($request); + $order = Order::query() + ->where('id', $id) + ->orWhere('order_number', $id) + ->first(); + + if (! $workspace || ! $order || $order->workspace_id !== $workspace->id) { + return response()->json(['error' => 'Not found'], 404); + } + + return response()->json([ + 'data' => [ + 'id' => $order->id, + 'order_number' => $order->order_number, + 'status' => $order->status, + 'total' => $order->total, + 'currency' => $order->currency, + ], + ]); + } + + public function confirmCheckout(Request $request, string $id): JsonResponse + { + $workspace = $this->getWorkspace($request); + $order = Order::query() + ->where('id', $id) + ->orWhere('order_number', $id) + ->first(); + + if (! $workspace || ! $order || $order->workspace_id !== $workspace->id) { + return response()->json(['error' => 'Not found'], 404); + } + + return response()->json([ + 'data' => [ + 'id' => $order->id, + 'status' => $order->status, + 'paid' => $order->isPaid(), + ], + ]); + } + /** * List orders for the workspace. * @@ -189,6 +266,136 @@ class CommerceController extends Controller ]); } + public function subscriptions(Request $request): JsonResponse + { + $workspace = $this->getWorkspace($request); + + if (! $workspace) { + return response()->json(['error' => 'No workspace found'], 404); + } + + return response()->json([ + 'data' => $workspace->subscriptions() + ->latest() + ->paginate($request->integer('per_page', 25)), + ]); + } + + public function cancelSubscriptionById(Request $request, Subscription $subscription): JsonResponse + { + $validated = $request->validate([ + 'immediate' => 'boolean', + 'reason' => 'nullable|string|max:500', + ]); + + $workspace = $this->getWorkspace($request); + + if (! $workspace || $subscription->workspace_id !== $workspace->id) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $subscription = $this->subscriptionService->cancel( + $subscription, + $validated['immediate'] ?? false, + $validated['reason'] ?? '' + ); + + return response()->json(['data' => $subscription]); + } + + public function changePlan(Request $request, Subscription $subscription): JsonResponse + { + $validated = $request->validate([ + 'package_code' => 'required|string|exists:entitlement_packages,code', + 'prorate' => 'boolean', + ]); + + $workspace = $this->getWorkspace($request); + + if (! $workspace || $subscription->workspace_id !== $workspace->id) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $newPackage = Package::where('code', $validated['package_code'])->firstOrFail(); + $result = $this->subscriptionService->changePlan( + $subscription, + $newPackage, + $validated['prorate'] ?? true + ); + + return response()->json(['data' => $result]); + } + + public function paymentMethods(Request $request): JsonResponse + { + $workspace = $this->getWorkspace($request); + + if (! $workspace) { + return response()->json(['error' => 'No workspace found'], 404); + } + + return response()->json([ + 'data' => PaymentMethod::query() + ->where('workspace_id', $workspace->id) + ->where('is_active', true) + ->latest() + ->get(), + ]); + } + + public function storePaymentMethod(Request $request): JsonResponse + { + $validated = $request->validate([ + 'gateway' => 'required|string|max:32', + 'gateway_payment_method_id' => 'nullable|string|max:255', + 'type' => 'required|string|max:32', + 'is_default' => 'boolean', + ]); + + $workspace = $this->getWorkspace($request); + + if (! $workspace) { + return response()->json(['error' => 'No workspace found'], 404); + } + + $method = PaymentMethod::create(array_merge($validated, [ + 'workspace_id' => $workspace->id, + 'is_active' => true, + ])); + + if ($method->is_default) { + $method->setAsDefault(); + } + + return response()->json(['data' => $method], 201); + } + + public function deletePaymentMethod(Request $request, PaymentMethod $paymentMethod): JsonResponse + { + $workspace = $this->getWorkspace($request); + + if (! $workspace || $paymentMethod->workspace_id !== $workspace->id) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $paymentMethod->deactivate(); + + return response()->json(null, 204); + } + + public function setDefaultPaymentMethod(Request $request, PaymentMethod $paymentMethod): JsonResponse + { + $workspace = $this->getWorkspace($request); + + if (! $workspace || $paymentMethod->workspace_id !== $workspace->id) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $paymentMethod->setAsDefault(); + + return response()->json(['data' => $paymentMethod->fresh()]); + } + /** * Get usage summary for the workspace. * diff --git a/Controllers/Webhooks/BTCPayWebhookController.php b/Controllers/Webhooks/BTCPayWebhookController.php index 2192e97..8665dd2 100644 --- a/Controllers/Webhooks/BTCPayWebhookController.php +++ b/Controllers/Webhooks/BTCPayWebhookController.php @@ -152,6 +152,10 @@ class BTCPayWebhookController extends Controller return false; } + if (! $webhookEvent->wasRecentlyCreated) { + return true; + } + // If the webhook event we just created has a different ID than the one // that already existed in the database, it means this is a duplicate $existingEvent = WebhookEvent::where('gateway', 'btcpay') diff --git a/Controllers/Webhooks/StripeWebhookController.php b/Controllers/Webhooks/StripeWebhookController.php index 9d43c93..f1fb25f 100644 --- a/Controllers/Webhooks/StripeWebhookController.php +++ b/Controllers/Webhooks/StripeWebhookController.php @@ -148,6 +148,10 @@ class StripeWebhookController extends Controller return false; } + if (! $webhookEvent->wasRecentlyCreated) { + return true; + } + // If the webhook event we just created has a different ID than the one // that already existed in the database, it means this is a duplicate $existingEvent = WebhookEvent::where('gateway', 'stripe') diff --git a/DTOs/BundleItem.php b/DTOs/BundleItem.php new file mode 100644 index 0000000..a4c4b26 --- /dev/null +++ b/DTOs/BundleItem.php @@ -0,0 +1,26 @@ + $this->productId, + 'quantity' => $this->quantity, + 'price_override' => $this->priceOverride, + ]; + } +} diff --git a/DTOs/CouponValidationResult.php b/DTOs/CouponValidationResult.php new file mode 100644 index 0000000..abc5cbe --- /dev/null +++ b/DTOs/CouponValidationResult.php @@ -0,0 +1,28 @@ + $this->valid, + 'reason' => $this->reason, + 'discount_amount' => $this->discountAmount, + 'discount_type' => $this->discountType, + ]; + } +} diff --git a/DTOs/FraudAssessment.php b/DTOs/FraudAssessment.php new file mode 100644 index 0000000..dd1277e --- /dev/null +++ b/DTOs/FraudAssessment.php @@ -0,0 +1,31 @@ + $reasons + */ + public function __construct( + public int $score, + public string $riskLevel, + public array $reasons, + public bool $block, + ) {} + + /** + * @return array{score: int, risk_level: string, reasons: array, block: bool} + */ + public function toArray(): array + { + return [ + 'score' => $this->score, + 'risk_level' => $this->riskLevel, + 'reasons' => $this->reasons, + 'block' => $this->block, + ]; + } +} diff --git a/DTOs/ParsedItem.php b/DTOs/ParsedItem.php new file mode 100644 index 0000000..b6392b0 --- /dev/null +++ b/DTOs/ParsedItem.php @@ -0,0 +1,26 @@ + $this->segment, + 'type' => $this->type, + 'value' => $this->value, + ]; + } +} diff --git a/DTOs/PermissionResult.php b/DTOs/PermissionResult.php new file mode 100644 index 0000000..1529909 --- /dev/null +++ b/DTOs/PermissionResult.php @@ -0,0 +1,29 @@ + $permissions + */ + public function __construct( + public bool $allowed, + public ?string $reason, + public array $permissions, + ) {} + + /** + * @return array{allowed: bool, reason: string|null, permissions: array} + */ + public function toArray(): array + { + return [ + 'allowed' => $this->allowed, + 'reason' => $this->reason, + 'permissions' => $this->permissions, + ]; + } +} diff --git a/DTOs/ProrationResult.php b/DTOs/ProrationResult.php new file mode 100644 index 0000000..f68e215 --- /dev/null +++ b/DTOs/ProrationResult.php @@ -0,0 +1,34 @@ +chargeAmount - $this->creditAmount, 2); + } + + /** + * @return array{credit_amount: float, charge_amount: float, effective_date: string, net_amount: float} + */ + public function toArray(): array + { + return [ + 'credit_amount' => $this->creditAmount, + 'charge_amount' => $this->chargeAmount, + 'effective_date' => $this->effectiveDate->toIso8601String(), + 'net_amount' => $this->netAmount(), + ]; + } +} diff --git a/DTOs/SkuOption.php b/DTOs/SkuOption.php new file mode 100644 index 0000000..3ff850d --- /dev/null +++ b/DTOs/SkuOption.php @@ -0,0 +1,26 @@ + $this->key, + 'value' => $this->value, + 'position' => $this->position, + ]; + } +} diff --git a/DTOs/SkuParseResult.php b/DTOs/SkuParseResult.php new file mode 100644 index 0000000..fb0a410 --- /dev/null +++ b/DTOs/SkuParseResult.php @@ -0,0 +1,34 @@ + $options + */ + public function __construct( + public string $baseSku, + public array $options, + public string $entityPrefix, + public bool $valid, + ) {} + + /** + * @return array{base_sku: string, options: array, entity_prefix: string, valid: bool} + */ + public function toArray(): array + { + return [ + 'base_sku' => $this->baseSku, + 'options' => array_map( + fn (SkuOption $option): array => $option->toArray(), + $this->options + ), + 'entity_prefix' => $this->entityPrefix, + 'valid' => $this->valid, + ]; + } +} diff --git a/Events/OrderPaid.php b/Events/OrderPaid.php index 122557b..1d8c557 100644 --- a/Events/OrderPaid.php +++ b/Events/OrderPaid.php @@ -20,5 +20,15 @@ class OrderPaid public function __construct( public Order $order, public Payment $payment - ) {} + ) { + $this->orderId = (int) $order->id; + $this->paymentId = (int) $payment->id; + $this->amount = (float) $payment->amount; + } + + public int $orderId; + + public int $paymentId; + + public float $amount; } diff --git a/Events/SubscriptionCancelled.php b/Events/SubscriptionCancelled.php index a7c087c..b1776ff 100644 --- a/Events/SubscriptionCancelled.php +++ b/Events/SubscriptionCancelled.php @@ -14,6 +14,14 @@ class SubscriptionCancelled public function __construct( public Subscription $subscription, - public bool $immediate = false - ) {} + public bool $immediate = false, + public string $reason = '', + ) { + $this->subscriptionId = (int) $subscription->id; + $this->cancelledAt = $subscription->cancelled_at ?? now(); + } + + public int $subscriptionId; + + public \DateTimeInterface $cancelledAt; } diff --git a/Events/SubscriptionCreated.php b/Events/SubscriptionCreated.php index 86d076b..d0d7fd4 100644 --- a/Events/SubscriptionCreated.php +++ b/Events/SubscriptionCreated.php @@ -14,5 +14,15 @@ class SubscriptionCreated public function __construct( public Subscription $subscription - ) {} + ) { + $this->subscriptionId = (int) $subscription->id; + $this->workspaceId = (int) $subscription->workspace_id; + $this->productId = $subscription->product_id ? (int) $subscription->product_id : null; + } + + public int $subscriptionId; + + public int $workspaceId; + + public ?int $productId; } diff --git a/Events/SubscriptionRenewed.php b/Events/SubscriptionRenewed.php index db76512..d98bf85 100644 --- a/Events/SubscriptionRenewed.php +++ b/Events/SubscriptionRenewed.php @@ -14,6 +14,11 @@ class SubscriptionRenewed public function __construct( public Subscription $subscription, - public ?\DateTimeInterface $previousPeriodEnd = null - ) {} + public ?\DateTimeInterface $previousPeriodEnd = null, + public ?int $invoiceId = null, + ) { + $this->subscriptionId = (int) $subscription->id; + } + + public int $subscriptionId; } diff --git a/Events/SubscriptionUpdated.php b/Events/SubscriptionUpdated.php index 3f1fced..94bdf55 100644 --- a/Events/SubscriptionUpdated.php +++ b/Events/SubscriptionUpdated.php @@ -14,6 +14,14 @@ class SubscriptionUpdated public function __construct( public Subscription $subscription, - public ?string $previousStatus = null - ) {} + public ?string $previousStatus = null, + public ?int $oldProductId = null, + public ?int $newProductId = null, + ) { + $this->subscriptionId = (int) $subscription->id; + $this->oldProductId ??= $subscription->getOriginal('product_id') ? (int) $subscription->getOriginal('product_id') : null; + $this->newProductId ??= $subscription->product_id ? (int) $subscription->product_id : null; + } + + public int $subscriptionId; } diff --git a/Jobs/ProcessWebhookEvent.php b/Jobs/ProcessWebhookEvent.php new file mode 100644 index 0000000..cb55eca --- /dev/null +++ b/Jobs/ProcessWebhookEvent.php @@ -0,0 +1,59 @@ +webhookEventId); + + if (! $webhookEvent || ! $webhookEvent->isPending()) { + return; + } + + try { + Event::dispatch( + "commerce.webhook.{$webhookEvent->gateway}.{$webhookEvent->event_type}", + [$webhookEvent, $webhookEvent->getDecodedPayload()] + ); + + $webhookEvent->markProcessed(); + } catch (\Throwable $e) { + $webhookEvent->markFailed($e->getMessage()); + + Log::error('Queued webhook event processing failed', [ + 'webhook_event_id' => $webhookEvent->id, + 'gateway' => $webhookEvent->gateway, + 'event_type' => $webhookEvent->event_type, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } +} diff --git a/Migrations/2026_04_25_000001_create_rfc_billing_tables.php b/Migrations/2026_04_25_000001_create_rfc_billing_tables.php new file mode 100644 index 0000000..23116e5 --- /dev/null +++ b/Migrations/2026_04_25_000001_create_rfc_billing_tables.php @@ -0,0 +1,283 @@ +id(); + $table->nullableMorphs('orderable'); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->string('order_number')->unique(); + $table->string('status')->default('pending')->index(); + $table->string('type')->nullable(); + $table->string('billing_cycle')->nullable(); + $table->string('currency', 3)->default('GBP'); + $table->string('display_currency', 3)->nullable(); + $table->decimal('exchange_rate_used', 16, 8)->nullable(); + $table->decimal('base_currency_total', 12, 2)->nullable(); + $table->decimal('subtotal', 12, 2)->default(0); + $table->decimal('tax_amount', 12, 2)->default(0); + $table->decimal('discount_amount', 12, 2)->default(0); + $table->decimal('total', 12, 2)->default(0); + $table->string('payment_method')->nullable(); + $table->unsignedBigInteger('payment_method_id')->nullable()->index(); + $table->string('payment_gateway')->nullable(); + $table->string('gateway')->nullable(); + $table->string('gateway_order_id')->nullable(); + $table->string('gateway_session_id')->nullable(); + $table->unsignedBigInteger('coupon_id')->nullable()->index(); + $table->string('billing_name')->nullable(); + $table->string('billing_email')->nullable(); + $table->decimal('tax_rate', 8, 4)->nullable(); + $table->string('tax_country', 2)->nullable(); + $table->json('billing_address')->nullable(); + $table->json('metadata')->nullable(); + $table->string('idempotency_key')->nullable()->unique(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('order_items')) { + Schema::create('order_items', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('order_id')->index(); + $table->unsignedBigInteger('product_id')->nullable()->index(); + $table->string('item_type')->nullable(); + $table->unsignedBigInteger('item_id')->nullable(); + $table->string('item_code')->nullable(); + $table->string('description'); + $table->unsignedInteger('quantity')->default(1); + $table->decimal('unit_price', 12, 2)->default(0); + $table->decimal('line_total', 12, 2)->default(0); + $table->decimal('total', 12, 2)->default(0); + $table->string('billing_cycle')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + if (! Schema::hasTable('invoices')) { + Schema::create('invoices', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('workspace_id')->nullable()->index(); + $table->unsignedBigInteger('order_id')->nullable()->index(); + $table->unsignedBigInteger('payment_id')->nullable()->index(); + $table->string('invoice_number')->unique(); + $table->string('number')->nullable()->index(); + $table->string('status')->default('pending')->index(); + $table->string('currency', 3)->default('GBP'); + $table->string('display_currency', 3)->nullable(); + $table->decimal('exchange_rate_used', 16, 8)->nullable(); + $table->decimal('base_currency_total', 12, 2)->nullable(); + $table->decimal('subtotal', 12, 2)->default(0); + $table->decimal('tax_amount', 12, 2)->default(0); + $table->decimal('tax_rate', 8, 4)->nullable(); + $table->string('tax_country', 2)->nullable(); + $table->decimal('discount_amount', 12, 2)->default(0); + $table->decimal('total', 12, 2)->default(0); + $table->decimal('amount_paid', 12, 2)->default(0); + $table->decimal('amount_due', 12, 2)->default(0); + $table->date('issue_date')->nullable(); + $table->date('due_date')->nullable(); + $table->timestamp('due_at')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->string('billing_name')->nullable(); + $table->string('billing_email')->nullable(); + $table->json('billing_address')->nullable(); + $table->string('tax_id')->nullable(); + $table->string('pdf_path')->nullable(); + $table->boolean('auto_charge')->default(true); + $table->unsignedInteger('charge_attempts')->default(0); + $table->timestamp('last_charge_attempt')->nullable(); + $table->timestamp('next_charge_attempt')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('invoice_items')) { + Schema::create('invoice_items', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('invoice_id')->index(); + $table->unsignedBigInteger('order_item_id')->nullable()->index(); + $table->string('description'); + $table->unsignedInteger('quantity')->default(1); + $table->decimal('unit_price', 12, 2)->default(0); + $table->decimal('line_total', 12, 2)->default(0); + $table->decimal('total', 12, 2)->default(0); + $table->boolean('taxable')->default(true); + $table->decimal('tax_rate', 8, 4)->default(0); + $table->decimal('tax_amount', 12, 2)->default(0); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + if (! Schema::hasTable('payments')) { + Schema::create('payments', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('workspace_id')->nullable()->index(); + $table->unsignedBigInteger('invoice_id')->nullable()->index(); + $table->unsignedBigInteger('order_id')->nullable()->index(); + $table->unsignedBigInteger('payment_method_id')->nullable()->index(); + $table->string('gateway', 32)->index(); + $table->string('gateway_payment_id')->nullable()->index(); + $table->string('gateway_customer_id')->nullable(); + $table->string('gateway_id')->nullable()->index(); + $table->string('currency', 3)->default('GBP'); + $table->decimal('amount', 12, 2)->default(0); + $table->decimal('fee', 12, 2)->default(0); + $table->decimal('net_amount', 12, 2)->default(0); + $table->string('status')->default('pending')->index(); + $table->string('failure_reason')->nullable(); + $table->string('payment_method_type')->nullable(); + $table->string('payment_method_last4')->nullable(); + $table->string('payment_method_brand')->nullable(); + $table->json('gateway_response')->nullable(); + $table->decimal('refunded_amount', 12, 2)->default(0); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('subscriptions')) { + Schema::create('subscriptions', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('workspace_id')->nullable()->index(); + $table->unsignedBigInteger('workspace_package_id')->nullable()->index(); + $table->unsignedBigInteger('product_id')->nullable()->index(); + $table->string('gateway')->default('btcpay'); + $table->string('gateway_subscription_id')->nullable()->index(); + $table->string('gateway_customer_id')->nullable(); + $table->string('gateway_price_id')->nullable(); + $table->string('status')->default('active')->index(); + $table->string('billing_cycle')->default('monthly'); + $table->timestamp('current_period_start')->nullable(); + $table->timestamp('current_period_end')->nullable()->index(); + $table->timestamp('trial_ends_at')->nullable(); + $table->boolean('cancel_at_period_end')->default(false); + $table->timestamp('cancelled_at')->nullable(); + $table->string('cancellation_reason')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->timestamp('paused_at')->nullable(); + $table->unsignedInteger('pause_count')->default(0); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('refunds')) { + Schema::create('refunds', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('payment_id')->index(); + $table->string('gateway_refund_id')->nullable()->index(); + $table->decimal('amount', 12, 2); + $table->string('currency', 3)->default('GBP'); + $table->string('status')->default('pending')->index(); + $table->string('reason')->nullable(); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('initiated_by')->nullable()->index(); + $table->json('gateway_response')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('coupons')) { + Schema::create('coupons', function (Blueprint $table): void { + $table->id(); + $table->string('code')->unique(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->string('type')->default('percent'); + $table->decimal('value', 12, 2)->default(0); + $table->decimal('min_amount', 12, 2)->nullable(); + $table->decimal('max_discount', 12, 2)->nullable(); + $table->string('applies_to')->default('all'); + $table->json('package_ids')->nullable(); + $table->unsignedInteger('max_uses')->nullable(); + $table->unsignedInteger('max_uses_per_workspace')->default(1); + $table->unsignedInteger('used_count')->default(0); + $table->string('duration')->default('once'); + $table->unsignedInteger('duration_months')->nullable(); + $table->timestamp('valid_from')->nullable(); + $table->timestamp('valid_until')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->boolean('is_active')->default(true); + $table->string('stripe_coupon_id')->nullable(); + $table->string('btcpay_coupon_id')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('coupon_usages')) { + Schema::create('coupon_usages', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('coupon_id')->index(); + $table->unsignedBigInteger('workspace_id')->nullable()->index(); + $table->unsignedBigInteger('order_id')->nullable()->index(); + $table->decimal('discount_amount', 12, 2)->default(0); + $table->timestamp('created_at')->nullable(); + }); + } + + if (! Schema::hasTable('tax_rates')) { + Schema::create('tax_rates', function (Blueprint $table): void { + $table->id(); + $table->string('country_code', 2)->index(); + $table->string('country', 2)->nullable()->index(); + $table->string('state_code')->nullable(); + $table->string('region')->nullable(); + $table->string('name'); + $table->string('type')->default('vat'); + $table->decimal('rate', 8, 4); + $table->boolean('is_digital_services')->default(true); + $table->date('effective_from'); + $table->date('effective_until')->nullable(); + $table->boolean('is_active')->default(true); + $table->string('stripe_tax_rate_id')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('commerce_inventory_movements')) { + Schema::create('commerce_inventory_movements', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('inventory_id')->nullable()->index(); + $table->unsignedBigInteger('product_id')->index(); + $table->unsignedBigInteger('warehouse_id')->index(); + $table->string('type')->index(); + $table->integer('quantity'); + $table->integer('balance_after')->default(0); + $table->string('reference')->nullable()->index(); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->integer('unit_cost')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('commerce_inventory_movements'); + Schema::dropIfExists('tax_rates'); + Schema::dropIfExists('coupon_usages'); + Schema::dropIfExists('coupons'); + Schema::dropIfExists('refunds'); + Schema::dropIfExists('subscriptions'); + Schema::dropIfExists('payments'); + Schema::dropIfExists('invoice_items'); + Schema::dropIfExists('invoices'); + Schema::dropIfExists('order_items'); + Schema::dropIfExists('orders'); + } +}; diff --git a/Migrations/2026_04_25_000002_align_rfc_columns.php b/Migrations/2026_04_25_000002_align_rfc_columns.php new file mode 100644 index 0000000..8848dc1 --- /dev/null +++ b/Migrations/2026_04_25_000002_align_rfc_columns.php @@ -0,0 +1,75 @@ +addColumn('subscriptions', 'product_id', fn (Blueprint $table) => $table->unsignedBigInteger('product_id')->nullable()->index()); + $this->addColumn('orders', 'payment_method_id', fn (Blueprint $table) => $table->unsignedBigInteger('payment_method_id')->nullable()->index()); + $this->addColumn('orders', 'gateway', fn (Blueprint $table) => $table->string('gateway')->nullable()->index()); + $this->addColumn('orders', 'gateway_session_id', fn (Blueprint $table) => $table->string('gateway_session_id')->nullable()->index()); + $this->addColumn('credit_notes', 'invoice_id', fn (Blueprint $table) => $table->unsignedBigInteger('invoice_id')->nullable()->index()); + $this->addColumn('permission_matrix', 'target_entity_id', fn (Blueprint $table) => $table->unsignedBigInteger('target_entity_id')->nullable()->index()); + $this->addColumn('permission_matrix', 'permissions', fn (Blueprint $table) => $table->json('permissions')->nullable()); + $this->addColumn('permission_requests', 'from_entity_id', fn (Blueprint $table) => $table->unsignedBigInteger('from_entity_id')->nullable()->index()); + $this->addColumn('permission_requests', 'to_entity_id', fn (Blueprint $table) => $table->unsignedBigInteger('to_entity_id')->nullable()->index()); + $this->addColumn('permission_requests', 'permissions', fn (Blueprint $table) => $table->json('permissions')->nullable()); + $this->addColumn('commerce_product_prices', 'billing_cycle', fn (Blueprint $table) => $table->string('billing_cycle')->nullable()->index()); + $this->addColumn('commerce_product_assignments', 'entity_type', fn (Blueprint $table) => $table->string('entity_type')->nullable()->index()); + $this->addColumn('commerce_bundle_hashes', 'product_ids', fn (Blueprint $table) => $table->json('product_ids')->nullable()); + $this->addColumn('commerce_warehouses', 'location', fn (Blueprint $table) => $table->string('location')->nullable()); + + if (Schema::hasTable('commerce_products')) { + $this->addColumn('commerce_products', 'owner_entity_id', fn (Blueprint $table) => $table->unsignedBigInteger('owner_entity_id')->nullable()->index()); + $this->addColumn('commerce_products', 'price', fn (Blueprint $table) => $table->integer('price')->default(0)); + $this->addColumn('commerce_products', 'is_active', fn (Blueprint $table) => $table->boolean('is_active')->default(true)->index()); + $this->addColumn('commerce_products', 'slug', fn (Blueprint $table) => $table->string('slug')->nullable()->index()); + } + } + + public function down(): void + { + $this->dropColumn('commerce_products', 'slug'); + $this->dropColumn('commerce_products', 'is_active'); + $this->dropColumn('commerce_products', 'price'); + $this->dropColumn('commerce_products', 'owner_entity_id'); + $this->dropColumn('commerce_warehouses', 'location'); + $this->dropColumn('commerce_bundle_hashes', 'product_ids'); + $this->dropColumn('commerce_product_assignments', 'entity_type'); + $this->dropColumn('commerce_product_prices', 'billing_cycle'); + $this->dropColumn('permission_requests', 'permissions'); + $this->dropColumn('permission_requests', 'to_entity_id'); + $this->dropColumn('permission_requests', 'from_entity_id'); + $this->dropColumn('permission_matrix', 'permissions'); + $this->dropColumn('permission_matrix', 'target_entity_id'); + $this->dropColumn('credit_notes', 'invoice_id'); + $this->dropColumn('orders', 'gateway_session_id'); + $this->dropColumn('orders', 'gateway'); + $this->dropColumn('orders', 'payment_method_id'); + $this->dropColumn('subscriptions', 'product_id'); + } + + protected function addColumn(string $table, string $column, Closure $callback): void + { + if (! Schema::hasTable($table) || Schema::hasColumn($table, $column)) { + return; + } + + Schema::table($table, $callback); + } + + protected function dropColumn(string $table, string $column): void + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) { + return; + } + + Schema::table($table, fn (Blueprint $table) => $table->dropColumn($column)); + } +}; diff --git a/Models/BundleHash.php b/Models/BundleHash.php index 5cba380..d7017c9 100644 --- a/Models/BundleHash.php +++ b/Models/BundleHash.php @@ -24,6 +24,7 @@ class BundleHash extends Model protected $fillable = [ 'hash', + 'product_ids', 'base_skus', 'coupon_code', 'fixed_price', @@ -42,6 +43,7 @@ class BundleHash extends Model protected $casts = [ 'fixed_price' => 'decimal:2', + 'product_ids' => 'array', 'discount_percent' => 'decimal:2', 'discount_amount' => 'decimal:2', 'min_quantity' => 'integer', diff --git a/Models/CreditNote.php b/Models/CreditNote.php index 8162627..9dad731 100644 --- a/Models/CreditNote.php +++ b/Models/CreditNote.php @@ -49,6 +49,7 @@ class CreditNote extends Model protected $fillable = [ 'workspace_id', 'user_id', + 'invoice_id', 'order_id', 'refund_id', 'reference_number', @@ -93,6 +94,11 @@ class CreditNote extends Model return $this->belongsTo(Order::class); } + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + public function refund(): BelongsTo { return $this->belongsTo(Refund::class); diff --git a/Models/Order.php b/Models/Order.php index 55d2456..007d3ca 100644 --- a/Models/Order.php +++ b/Models/Order.php @@ -73,8 +73,11 @@ class Order extends Model 'discount_amount', 'total', 'payment_method', + 'payment_method_id', 'payment_gateway', + 'gateway', 'gateway_order_id', + 'gateway_session_id', 'coupon_id', 'billing_name', 'billing_email', diff --git a/Models/Payment.php b/Models/Payment.php index baa5c0d..dfa2cd1 100644 --- a/Models/Payment.php +++ b/Models/Payment.php @@ -45,6 +45,7 @@ class Payment extends Model 'workspace_id', 'invoice_id', 'order_id', + 'payment_method_id', 'gateway', 'gateway_payment_id', 'gateway_customer_id', diff --git a/Models/PermissionMatrix.php b/Models/PermissionMatrix.php index d983cf9..9d2a7c3 100644 --- a/Models/PermissionMatrix.php +++ b/Models/PermissionMatrix.php @@ -40,8 +40,10 @@ class PermissionMatrix extends Model protected $fillable = [ 'entity_id', + 'target_entity_id', 'key', 'scope', + 'permissions', 'allowed', 'locked', 'source', @@ -52,6 +54,7 @@ class PermissionMatrix extends Model protected $casts = [ 'allowed' => 'boolean', + 'permissions' => 'array', 'locked' => 'boolean', 'trained_at' => 'datetime', ]; @@ -63,6 +66,11 @@ class PermissionMatrix extends Model return $this->belongsTo(Entity::class, 'entity_id'); } + public function targetEntity(): BelongsTo + { + return $this->belongsTo(Entity::class, 'target_entity_id'); + } + public function setByEntity(): BelongsTo { return $this->belongsTo(Entity::class, 'set_by_entity_id'); diff --git a/Models/PermissionRequest.php b/Models/PermissionRequest.php index ab285b9..df2ac52 100644 --- a/Models/PermissionRequest.php +++ b/Models/PermissionRequest.php @@ -43,9 +43,12 @@ class PermissionRequest extends Model protected $fillable = [ 'entity_id', + 'from_entity_id', + 'to_entity_id', 'method', 'route', 'action', + 'permissions', 'scope', 'request_data', 'user_agent', @@ -58,6 +61,7 @@ class PermissionRequest extends Model protected $casts = [ 'request_data' => 'array', + 'permissions' => 'array', 'was_trained' => 'boolean', 'trained_at' => 'datetime', ]; @@ -69,6 +73,16 @@ class PermissionRequest extends Model return $this->belongsTo(Entity::class, 'entity_id'); } + public function fromEntity(): BelongsTo + { + return $this->belongsTo(Entity::class, 'from_entity_id'); + } + + public function toEntity(): BelongsTo + { + return $this->belongsTo(Entity::class, 'to_entity_id'); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/Models/ProductAssignment.php b/Models/ProductAssignment.php index d5dc507..f285ae8 100644 --- a/Models/ProductAssignment.php +++ b/Models/ProductAssignment.php @@ -42,6 +42,7 @@ class ProductAssignment extends Model protected $fillable = [ 'entity_id', + 'entity_type', 'product_id', 'sku_suffix', 'price_override', diff --git a/Models/ProductPrice.php b/Models/ProductPrice.php index 166e9a8..53aef15 100644 --- a/Models/ProductPrice.php +++ b/Models/ProductPrice.php @@ -28,6 +28,7 @@ class ProductPrice extends Model 'product_id', 'currency', 'amount', + 'billing_cycle', 'is_manual', 'exchange_rate_used', ]; diff --git a/Models/Subscription.php b/Models/Subscription.php index f4f3e66..a3ba1f0 100644 --- a/Models/Subscription.php +++ b/Models/Subscription.php @@ -59,6 +59,7 @@ class Subscription extends Model protected $fillable = [ 'workspace_id', 'workspace_package_id', + 'product_id', 'gateway', 'gateway_subscription_id', 'gateway_customer_id', @@ -101,6 +102,11 @@ class Subscription extends Model return $this->belongsTo(WorkspacePackage::class); } + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + public function usageRecords(): HasMany { return $this->hasMany(SubscriptionUsage::class); @@ -130,7 +136,7 @@ class Subscription extends Model public function isPaused(): bool { - return $this->status === 'paused'; + return in_array($this->status, ['paused', 'suspended'], true); } public function isCancelled(): bool @@ -169,7 +175,7 @@ class Subscription extends Model public function isValid(): bool { - return in_array($this->status, ['active', 'trialing', 'past_due']); + return in_array($this->status, ['active', 'trialing', 'past_due', 'suspended'], true); } public function onTrial(): bool @@ -230,6 +236,14 @@ class Subscription extends Model $this->update(['status' => 'paused']); } + public function suspend(): void + { + $this->update([ + 'status' => 'suspended', + 'paused_at' => $this->paused_at ?? now(), + ]); + } + public function markPastDue(): void { $this->update(['status' => 'past_due']); diff --git a/Services/BTCPayGateway.php b/Services/BTCPayGateway.php new file mode 100644 index 0000000..95114f0 --- /dev/null +++ b/Services/BTCPayGateway.php @@ -0,0 +1,102 @@ +gateway ??= new LegacyBTCPayGateway; + } + + /** + * @return array + */ + public function createSession(Order $order, PaymentMethod $paymentMethod): array + { + $successUrl = url('/checkout/success?order='.$order->order_number); + $cancelUrl = url('/checkout/cancel?order='.$order->order_number); + + $session = $this->gateway->createCheckoutSession($order, $successUrl, $cancelUrl); + + return [ + 'invoice_id' => $session['session_id'] ?? null, + 'checkout_url' => $session['checkout_url'] ?? null, + 'session_id' => $session['session_id'] ?? null, + ]; + } + + /** + * @param array $gatewayData + */ + public function confirmPayment(Payment $payment, array $gatewayData): Payment + { + $payment->update([ + 'gateway_payment_id' => $gatewayData['invoiceId'] ?? $gatewayData['id'] ?? $payment->gateway_payment_id, + 'status' => 'succeeded', + 'paid_at' => now(), + 'gateway_response' => $gatewayData, + ]); + + return $payment->fresh(); + } + + public function refund(Payment $payment, float $amount, string $reason): Refund + { + $refund = Refund::create([ + 'payment_id' => $payment->id, + 'amount' => $amount, + 'currency' => $payment->currency, + 'status' => 'pending', + 'reason' => $reason, + ]); + + if (! $this->gateway->isEnabled()) { + return $refund; + } + + $result = $this->gateway->refund($payment, $amount, $reason); + + if (($result['success'] ?? false) === true) { + $refund->markAsSucceeded($result['refund_id'] ?? null); + } else { + $refund->markAsFailed($result); + } + + return $refund->fresh(); + } + + public function validateWebhookSignature(Request $request): bool + { + return $this->gateway->verifyWebhookSignature( + $request->getContent(), + (string) $request->header('BTCPay-Sig', $request->header('BTCPay-Signature', '')) + ); + } + + /** + * @return array + */ + public function parseWebhookEvent(Request $request): array + { + $event = $this->gateway->parseWebhookEvent($request->getContent()); + + return [ + 'type' => $event['type'] ?? 'unknown', + 'id' => $event['id'] ?? null, + 'data' => $event['raw'] ?? [], + 'raw' => $event['raw'] ?? $event, + ]; + } +} diff --git a/Services/CommerceService.php b/Services/CommerceService.php index 0a7acce..d3fce31 100644 --- a/Services/CommerceService.php +++ b/Services/CommerceService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Core\Mod\Commerce\Services; use Core\Mod\Commerce\Contracts\Orderable; +use Core\Mod\Commerce\Contracts\PaymentGatewayContract as RfcPaymentGatewayContract; use Core\Mod\Commerce\Data\FraudAssessment; use Core\Mod\Commerce\Events\OrderPaid; use Core\Mod\Commerce\Exceptions\CheckoutRateLimitException; @@ -14,6 +15,8 @@ use Core\Mod\Commerce\Models\Invoice; use Core\Mod\Commerce\Models\Order; use Core\Mod\Commerce\Models\OrderItem; use Core\Mod\Commerce\Models\Payment; +use Core\Mod\Commerce\Models\PaymentMethod; +use Core\Mod\Commerce\Models\Product; use Core\Mod\Commerce\Models\Refund; use Core\Mod\Commerce\Models\Subscription; use Core\Mod\Commerce\Services\PaymentGateway\PaymentGatewayContract; @@ -248,6 +251,120 @@ class CommerceService return $result; } + /** + * RFC checkout entrypoint for an existing order and stored payment method. + * + * @return array{order: Order, payment: Payment, gateway_session: array} + */ + public function checkout(Order $order, PaymentMethod $paymentMethod): array + { + $payment = Payment::create([ + 'workspace_id' => $order->workspace_id, + 'order_id' => $order->id, + 'payment_method_id' => $paymentMethod->id, + 'gateway' => $paymentMethod->gateway, + 'amount' => $order->total, + 'currency' => $order->currency, + 'status' => 'pending', + ]); + + $gateway = app()->bound("commerce.rfc_gateway.{$paymentMethod->gateway}") + ? app("commerce.rfc_gateway.{$paymentMethod->gateway}") + : app(RfcPaymentGatewayContract::class); + + $gatewaySession = $gateway->createSession($order, $paymentMethod); + + return [ + 'order' => $order->fresh(), + 'payment' => $payment, + 'gateway_session' => $gatewaySession, + ]; + } + + public function confirmPayment(Payment $payment, string $gatewayTransactionId): void + { + $payment->update([ + 'gateway_payment_id' => $gatewayTransactionId, + 'status' => 'succeeded', + 'paid_at' => now(), + ]); + + $order = Order::find($payment->order_id); + + if ($order && ! $order->isPaid()) { + $this->fulfillOrder($order, $payment->fresh()); + } + } + + /** + * RFC full checkout flow for cart-style product items. + * + * @param array $cartItems + * @return array{order: Order, payment: Payment, gateway_session: array} + */ + public function processCheckout( + int $workspaceId, + array $cartItems, + string $paymentMethodId, + ?string $couponCode = null + ): array { + $workspace = Workspace::findOrFail($workspaceId); + $paymentMethod = PaymentMethod::findOrFail($paymentMethodId); + $currency = config('commerce.currency', 'GBP'); + + $order = DB::transaction(function () use ($workspace, $cartItems, $currency, $couponCode): Order { + $order = Order::create([ + 'orderable_type' => Workspace::class, + 'orderable_id' => $workspace->id, + 'user_id' => null, + 'order_number' => Order::generateOrderNumber(), + 'status' => 'pending', + 'type' => 'checkout', + 'currency' => $currency, + 'subtotal' => 0, + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total' => 0, + 'billing_name' => $workspace->billing_name ?? $workspace->name, + 'billing_email' => $workspace->billing_email ?? $workspace->owner()?->email, + 'billing_address' => method_exists($workspace, 'getBillingAddress') ? $workspace->getBillingAddress() : null, + 'metadata' => ['coupon_code' => $couponCode], + ]); + + $subtotal = 0.0; + + foreach ($cartItems as $item) { + $product = Product::findOrFail((int) ($item['product_id'] ?? 0)); + $quantity = max(1, (int) ($item['quantity'] ?? 1)); + $unitPrice = $this->productUnitPrice($product); + $lineTotal = round($unitPrice * $quantity, 2); + $subtotal += $lineTotal; + + OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'item_type' => 'product', + 'item_id' => $product->id, + 'item_code' => $product->sku, + 'description' => $product->name, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'line_total' => $lineTotal, + 'billing_cycle' => $product->isSubscription() ? 'monthly' : 'onetime', + ]); + } + + $order->update([ + 'subtotal' => $subtotal, + 'total' => $subtotal, + ]); + + return $order->fresh(['items']); + }); + + return $this->checkout($order, $paymentMethod); + } + /** * Assess order for fraud before checkout. * @@ -737,4 +854,21 @@ class CommerceService { return $this->currencyService->convert($amount, $from, $to); } + + protected function productUnitPrice(Product $product): float + { + $price = $product->prices() + ->where('currency', config('commerce.currency', 'GBP')) + ->first(); + + if ($price) { + return $price->amount / 100; + } + + if (isset($product->price)) { + return ((int) $product->price) / 100; + } + + return (float) ($product->base_price ?? 0); + } } diff --git a/Services/CurrencyService.php b/Services/CurrencyService.php index 92b70ac..8706303 100644 --- a/Services/CurrencyService.php +++ b/Services/CurrencyService.php @@ -214,6 +214,24 @@ class CurrencyService return ExchangeRate::convert($amount, $from, $to); } + public function rate(string $from, string $to): float + { + return (float) ($this->getExchangeRate($from, $to) ?? 1.0); + } + + /** + * @return array + */ + public function supported(): array + { + return $this->getSupportedCurrencies(); + } + + public function refresh(): void + { + $this->refreshExchangeRates(); + } + /** * Convert cents between currencies. */ diff --git a/Services/DunningService.php b/Services/DunningService.php index ee5da99..fc3778e 100644 --- a/Services/DunningService.php +++ b/Services/DunningService.php @@ -87,8 +87,12 @@ class DunningService /** * Retry payment for an overdue invoice. */ - public function retry(Invoice $invoice): PaymentResult + public function retry(Invoice|Subscription $invoice): PaymentResult|bool { + if ($invoice instanceof Subscription) { + return $this->retrySubscription($invoice); + } + if ($invoice->isPaid()) { $subscription = $this->findSubscriptionForInvoice($invoice); if ($subscription) { @@ -171,6 +175,17 @@ class DunningService return PaymentResult::failed('Payment retry failed.', $attempts, $nextRetry); } + public function retrySubscription(Subscription $subscription): bool + { + $invoice = $this->latestDunningInvoice($subscription); + + if (! $invoice) { + return false; + } + + return $this->retry($invoice)->succeeded(); + } + /** * Suspend a subscription and its workspace after dunning is exhausted. */ @@ -371,7 +386,7 @@ class DunningService */ public function retryPayment(Invoice $invoice): bool { - $retryDays = config('commerce.dunning.retry_days', [1, 3, 7]); + $retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]); $maxRetries = count($retryDays); try { @@ -425,7 +440,7 @@ class DunningService */ public function getSubscriptionsForPause(): Collection { - $retryDays = config('commerce.dunning.retry_days', [1, 3, 7]); + $retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]); $pauseAfterDays = array_sum($retryDays) + 1; // Day after last retry return Subscription::query() @@ -516,7 +531,7 @@ class DunningService $cancelAfterDays = config('commerce.dunning.cancel_after_days', 30); return Subscription::query() - ->where('status', 'paused') + ->whereIn('status', ['paused', 'suspended']) ->where('paused_at', '<=', now()->subDays($cancelAfterDays)) ->with('workspace') ->get(); @@ -573,7 +588,7 @@ class DunningService */ public function calculateNextRetry(int $currentAttempts): ?Carbon { - $retryDays = config('commerce.dunning.retry_days', [1, 3, 7]); + $retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]); // Account for the initial attempt (attempt 0 used grace period) $retryIndex = $currentAttempts; @@ -587,6 +602,40 @@ class DunningService return $daysUntilNext ? now()->addDays($daysUntilNext) : null; } + public function nextRetryAt(int $attemptCount): Carbon + { + return $this->calculateNextRetry($attemptCount) ?? now()->addDays(14); + } + + /** + * Run the RFC dunning pass. + * + * @return array{retried: int, recovered: int, cancelled: int} + */ + public function processAll(): array + { + $results = [ + 'retried' => 0, + 'recovered' => 0, + 'cancelled' => 0, + ]; + + foreach ($this->getInvoicesDueForRetry() as $invoice) { + $results['retried']++; + + if ($this->retry($invoice)->succeeded()) { + $results['recovered']++; + } + } + + foreach ($this->getSubscriptionsForCancellation() as $subscription) { + $this->cancelSubscription($subscription); + $results['cancelled']++; + } + + return $results; + } + /** * Get the dunning status for a subscription. * @@ -626,7 +675,7 @@ class DunningService ]; } - if ($subscription->status === 'paused') { + if (in_array($subscription->status, ['paused', 'suspended'], true)) { $pausedDays = $subscription->paused_at ? (int) $subscription->paused_at->diffInDays(now(), false) : 0; @@ -676,7 +725,7 @@ class DunningService */ protected function retryDays(): array { - $retryDays = config('commerce.dunning.retry_days', [1, 3, 7]); + $retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]); if (! is_array($retryDays)) { throw new InvalidArgumentException('Dunning retry days must be configured as an array.'); diff --git a/Services/InvoiceService.php b/Services/InvoiceService.php index 65f4f36..ad69bd5 100644 --- a/Services/InvoiceService.php +++ b/Services/InvoiceService.php @@ -76,6 +76,30 @@ class InvoiceService return $invoice; } + public function generateFromOrder(Order $order): Invoice + { + return $this->createFromOrder($order); + } + + public function generateFromSubscription(\Core\Mod\Commerce\Models\Subscription $subscription): Invoice + { + $workspace = $subscription->workspace; + + if (! $workspace) { + throw new \InvalidArgumentException('Cannot generate an invoice for a subscription without a workspace.'); + } + + $package = $subscription->workspacePackage?->package; + $billingCycle = $subscription->billing_cycle ?? 'monthly'; + $amount = $package ? (float) $package->getPrice($billingCycle) : 0.0; + + return $this->createForRenewal( + $workspace, + $amount, + $package ? "{$package->name} subscription renewal" : 'Subscription renewal' + ); + } + /** * Create an invoice for a subscription renewal. */ @@ -133,6 +157,16 @@ class InvoiceService $invoice->markAsPaid($payment); } + public function markPaid(Invoice $invoice, Payment $payment): void + { + $this->markAsPaid($invoice, $payment); + } + + public function markOverdue(Invoice $invoice): void + { + $invoice->update(['status' => 'overdue']); + } + /** * Mark invoice as void. */ @@ -221,6 +255,11 @@ class InvoiceService Mail::to($recipientEmail)->queue(new InvoiceGenerated($invoice)); } + public function sendByEmail(Invoice $invoice): void + { + $this->sendEmail($invoice); + } + /** * Get invoices for a workspace. */ diff --git a/Services/PermissionMatrixService.php b/Services/PermissionMatrixService.php index 74a2a3d..fb5d2ea 100644 --- a/Services/PermissionMatrixService.php +++ b/Services/PermissionMatrixService.php @@ -102,20 +102,11 @@ class PermissionMatrixService $this->logRequest($request, $entity, $action, $scope, $result); } - // Training mode: undefined permissions become pending for approval + // Training mode records undefined permissions and allows the request. if ($result->isUndefined() && $this->trainingMode) { - // Log as pending PermissionRequest::fromRequest($entity, $action, PermissionRequest::STATUS_PENDING, $scope); - return PermissionResult::pending( - key: $action, - scope: $scope, - trainingUrl: route('commerce.matrix.train', [ - 'entity' => $entity->id, - 'key' => $action, - 'scope' => $scope, - ]) - ); + return PermissionResult::allowed(); } // Production mode (strict): undefined = denied @@ -137,6 +128,139 @@ class PermissionMatrixService return $result; } + public function check(Entity $entity, Entity $target, string $permission): \Core\Mod\Commerce\DTOs\PermissionResult + { + $matrix = PermissionMatrix::query() + ->where('entity_id', $entity->id) + ->where('target_entity_id', $target->id) + ->where(function ($query) use ($permission): void { + $query->where('key', $permission) + ->orWhereJsonContains('permissions', $permission); + }) + ->first(); + + if ($this->trainingMode && ! $matrix) { + PermissionRequest::create([ + 'entity_id' => $entity->id, + 'from_entity_id' => $entity->id, + 'to_entity_id' => $target->id, + 'method' => request()->method(), + 'route' => request()->path(), + 'action' => $permission, + 'permissions' => [$permission], + 'request_data' => request()->except([ + 'password', + 'password_confirmation', + 'token', + 'api_key', + 'secret', + 'credit_card', + 'card_number', + 'cvv', + 'ssn', + ]), + 'user_agent' => request()->userAgent(), + 'ip_address' => request()->ip(), + 'user_id' => auth()->id(), + 'status' => PermissionRequest::STATUS_PENDING, + ]); + + return new \Core\Mod\Commerce\DTOs\PermissionResult(true, 'training', [$permission]); + } + + if (! $matrix) { + return new \Core\Mod\Commerce\DTOs\PermissionResult(false, "No permission defined for {$permission}", []); + } + + if (! $matrix->allowed) { + return new \Core\Mod\Commerce\DTOs\PermissionResult(false, 'Permission denied', []); + } + + $permissions = $matrix->permissions ?: [$matrix->key]; + + return new \Core\Mod\Commerce\DTOs\PermissionResult(true, null, $permissions); + } + + public function grant(Entity $entity, Entity $target, array $permissions): PermissionMatrix + { + $this->assertCanGrant($entity, $target); + + return PermissionMatrix::updateOrCreate( + [ + 'entity_id' => $entity->id, + 'target_entity_id' => $target->id, + 'key' => 'matrix.grant', + 'scope' => (string) $target->id, + ], + [ + 'permissions' => array_values($permissions), + 'allowed' => true, + 'locked' => false, + 'source' => PermissionMatrix::SOURCE_EXPLICIT, + 'set_by_entity_id' => $entity->id, + ] + ); + } + + public function revoke(Entity $entity, Entity $target, array $permissions): void + { + $matrix = PermissionMatrix::query() + ->where('entity_id', $entity->id) + ->where('target_entity_id', $target->id) + ->first(); + + if (! $matrix) { + return; + } + + $remaining = array_values(array_diff($matrix->permissions ?? [], $permissions)); + + if ($remaining === []) { + $matrix->delete(); + + return; + } + + $matrix->update(['permissions' => $remaining]); + } + + public function approveRequest(PermissionRequest $request): void + { + $request->update([ + 'status' => PermissionRequest::STATUS_ALLOWED, + 'was_trained' => true, + 'trained_at' => now(), + ]); + + if ($request->from_entity_id && $request->to_entity_id) { + $from = Entity::find($request->from_entity_id); + $to = Entity::find($request->to_entity_id); + + if ($from && $to) { + $this->grant($from, $to, $request->permissions ?: [$request->action]); + } + } + } + + public function denyRequest(PermissionRequest $request): void + { + $request->update([ + 'status' => PermissionRequest::STATUS_DENIED, + 'was_trained' => true, + 'trained_at' => now(), + ]); + } + + protected function assertCanGrant(Entity $entity, Entity $target): void + { + $allowed = ($entity->isM1() && $target->isM2()) + || ($entity->isM2() && $target->isM3()); + + if (! $allowed) { + throw new \InvalidArgumentException('Commerce Matrix permissions may only be granted M1 to M2 or M2 to M3.'); + } + } + /** * Train a permission (dev mode). */ diff --git a/Services/ProrationService.php b/Services/ProrationService.php new file mode 100644 index 0000000..9186972 --- /dev/null +++ b/Services/ProrationService.php @@ -0,0 +1,169 @@ +current_period_start ?? $effectiveDate->copy(); + $periodEnd = $subscription->current_period_end ?? $effectiveDate->copy(); + + $totalSeconds = max(1, $periodStart->diffInSeconds($periodEnd, absolute: true)); + $remainingSeconds = max(0, $effectiveDate->diffInSeconds($periodEnd, absolute: false)); + $remainingRatio = min(1, $remainingSeconds / $totalSeconds); + + $oldProduct = $subscription->product; + $oldPlanPrice = $oldProduct instanceof Product + ? $this->periodPrice($oldProduct, $subscription->billing_cycle ?? 'monthly') + : 0.0; + $newPlanPrice = $this->periodPrice($newProduct, $subscription->billing_cycle ?? 'monthly'); + + return new ProrationResult( + creditAmount: round($oldPlanPrice * $remainingRatio, 2), + chargeAmount: round($newPlanPrice * $remainingRatio, 2), + effectiveDate: $effectiveDate, + ); + } + + /** + * Apply the proration by creating a credit note for unused old plan time and + * an invoice for the new plan remainder. + * + * @return array{credit_note: CreditNote|null, invoice: Invoice|null} + */ + public function apply(Subscription $subscription, Product $newProduct, ProrationResult $proration): array + { + return DB::transaction(function () use ($subscription, $newProduct, $proration): array { + $creditNote = $proration->creditAmount > 0 + ? $this->createCreditNote($subscription, $proration) + : null; + + $netCharge = max(0, $proration->chargeAmount - $proration->creditAmount); + $invoice = $netCharge > 0 + ? $this->createInvoice($subscription, $newProduct, $netCharge, $proration) + : null; + + $subscription->update([ + 'product_id' => $newProduct->id, + 'metadata' => array_merge($subscription->metadata ?? [], [ + 'last_proration' => $proration->toArray(), + ]), + ]); + + return [ + 'credit_note' => $creditNote, + 'invoice' => $invoice, + ]; + }); + } + + protected function createCreditNote(Subscription $subscription, ProrationResult $proration): ?CreditNote + { + $workspace = $subscription->workspace; + $user = $workspace?->owner(); + + if (! $workspace || ! $user) { + return null; + } + + return CreditNote::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'invoice_id' => null, + 'reference_number' => CreditNote::generateReferenceNumber(), + 'amount' => $proration->creditAmount, + 'currency' => config('commerce.currency', 'GBP'), + 'reason' => 'subscription_proration', + 'description' => 'Credit for unused subscription time', + 'status' => 'issued', + 'issued_at' => now(), + 'metadata' => [ + 'subscription_id' => $subscription->id, + 'proration' => $proration->toArray(), + ], + ]); + } + + protected function createInvoice( + Subscription $subscription, + Product $newProduct, + float $amount, + ProrationResult $proration + ): ?Invoice { + $workspace = $subscription->workspace; + + if (! $workspace) { + return null; + } + + $invoice = Invoice::create([ + 'workspace_id' => $workspace->id, + 'invoice_number' => Invoice::generateInvoiceNumber(), + 'status' => 'pending', + 'subtotal' => $amount, + 'discount_amount' => 0, + 'tax_amount' => 0, + 'total' => $amount, + 'amount_paid' => 0, + 'amount_due' => $amount, + 'currency' => config('commerce.currency', 'GBP'), + 'billing_name' => $workspace->billing_name ?? $workspace->name, + 'billing_email' => $workspace->billing_email ?? $workspace->owner()?->email, + 'billing_address' => method_exists($workspace, 'getBillingAddress') ? $workspace->getBillingAddress() : null, + 'issue_date' => now(), + 'due_date' => now()->addDays(config('commerce.billing.invoice_due_days', 14)), + 'metadata' => [ + 'subscription_id' => $subscription->id, + 'product_id' => $newProduct->id, + 'proration' => $proration->toArray(), + ], + ]); + + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'description' => "Prorated subscription change to {$newProduct->name}", + 'quantity' => 1, + 'unit_price' => $amount, + 'line_total' => $amount, + 'taxable' => true, + 'tax_rate' => 0, + 'tax_amount' => 0, + ]); + + return $invoice; + } + + protected function periodPrice(Product $product, string $billingCycle): float + { + $price = $product->prices() + ->where('currency', config('commerce.currency', 'GBP')) + ->where(function ($query) use ($billingCycle): void { + $query->whereNull('billing_cycle')->orWhere('billing_cycle', $billingCycle); + }) + ->orderByRaw('billing_cycle IS NULL') + ->first(); + + if ($price) { + return $price->amount / 100; + } + + if (isset($product->price)) { + return ((int) $product->price) / 100; + } + + return (float) ($product->base_price ?? 0); + } +} diff --git a/Services/RefundService.php b/Services/RefundService.php index 7722e37..6092467 100644 --- a/Services/RefundService.php +++ b/Services/RefundService.php @@ -113,6 +113,11 @@ class RefundService }); } + public function process(Payment $payment, float $amount, string $reason): Refund + { + return $this->refund($payment, $amount, $reason); + } + /** * Check if a payment can be refunded. */ diff --git a/Services/SkuBuilderService.php b/Services/SkuBuilderService.php index cfa45f4..ae9e4fb 100644 --- a/Services/SkuBuilderService.php +++ b/Services/SkuBuilderService.php @@ -23,8 +23,26 @@ class SkuBuilderService * * @param array $lineItems */ - public function build(array $lineItems): string + public function build(array|string $lineItems, ?string $baseSku = null, array $options = []): string { + if (is_string($lineItems)) { + $prefix = strtoupper(trim($lineItems, '-')); + $sku = strtoupper((string) $baseSku); + + foreach ($options as $key => $value) { + if (is_array($value)) { + $key = $value['key'] ?? $value['code'] ?? $key; + $value = $value['value'] ?? ''; + } + + if ($value !== '') { + $sku .= '-'.strtoupper((string) $key).strtoupper((string) $value); + } + } + + return $prefix === '' ? $sku : "{$prefix}-{$sku}"; + } + if (empty($lineItems)) { return ''; } diff --git a/Services/StripeGateway.php b/Services/StripeGateway.php new file mode 100644 index 0000000..72ac769 --- /dev/null +++ b/Services/StripeGateway.php @@ -0,0 +1,97 @@ +gateway ??= new LegacyStripeGateway; + } + + /** + * @return array + */ + public function createSession(Order $order, PaymentMethod $paymentMethod): array + { + $successUrl = url('/checkout/success?order='.$order->order_number); + $cancelUrl = url('/checkout/cancel?order='.$order->order_number); + + return $this->gateway->createCheckoutSession($order, $successUrl, $cancelUrl); + } + + /** + * @param array $gatewayData + */ + public function confirmPayment(Payment $payment, array $gatewayData): Payment + { + $payment->update([ + 'gateway_payment_id' => $gatewayData['payment_intent'] ?? $gatewayData['id'] ?? $payment->gateway_payment_id, + 'gateway_customer_id' => $gatewayData['customer'] ?? $payment->gateway_customer_id, + 'status' => 'succeeded', + 'paid_at' => now(), + 'gateway_response' => $gatewayData, + ]); + + return $payment->fresh(); + } + + public function refund(Payment $payment, float $amount, string $reason): Refund + { + $refund = Refund::create([ + 'payment_id' => $payment->id, + 'amount' => $amount, + 'currency' => $payment->currency, + 'status' => 'pending', + 'reason' => $reason, + ]); + + if (! $this->gateway->isEnabled()) { + return $refund; + } + + $result = $this->gateway->refund($payment, $amount, $reason); + + if (($result['success'] ?? false) === true) { + $refund->markAsSucceeded($result['refund_id'] ?? null); + } else { + $refund->markAsFailed($result); + } + + return $refund->fresh(); + } + + public function validateWebhookSignature(Request $request): bool + { + return $this->gateway->verifyWebhookSignature( + $request->getContent(), + (string) $request->header('Stripe-Signature', '') + ); + } + + /** + * @return array + */ + public function parseWebhookEvent(Request $request): array + { + $event = $this->gateway->parseWebhookEvent($request->getContent()); + + return [ + 'type' => $event['type'] ?? 'unknown', + 'id' => $event['raw']['id'] ?? $event['id'] ?? null, + 'data' => $event['raw']['data']['object'] ?? [], + 'raw' => $event['raw'] ?? $event, + ]; + } +} diff --git a/Services/SubscriptionService.php b/Services/SubscriptionService.php index aecfffa..18dea88 100644 --- a/Services/SubscriptionService.php +++ b/Services/SubscriptionService.php @@ -51,16 +51,38 @@ class SubscriptionService /** * Cancel a subscription (set to expire at period end). */ - public function cancel(Subscription $subscription, ?string $reason = null): Subscription - { - $subscription->update([ + public function cancel( + Subscription $subscription, + bool|string|null $immediateOrReason = null, + ?string $reason = null + ): Subscription { + $immediate = is_bool($immediateOrReason) ? $immediateOrReason : false; + $reason = is_bool($immediateOrReason) ? $reason : $immediateOrReason; + + $updates = [ 'cancelled_at' => Carbon::now(), 'cancellation_reason' => $reason, - ]); + ]; + + if ($immediate) { + $updates['status'] = 'cancelled'; + $updates['ended_at'] = Carbon::now(); + $updates['current_period_end'] = Carbon::now(); + } + + $subscription->update($updates); return $subscription->fresh(); } + public function getDueForRenewal(): Collection + { + return Subscription::query() + ->where('status', 'active') + ->where('current_period_end', '<=', Carbon::now()) + ->get(); + } + /** * Resume a cancelled subscription (before it expires). */ diff --git a/Services/SubscriptionStateMachine.php b/Services/SubscriptionStateMachine.php new file mode 100644 index 0000000..87a9c46 --- /dev/null +++ b/Services/SubscriptionStateMachine.php @@ -0,0 +1,100 @@ +> + */ + private const TRANSITIONS = [ + 'active' => ['suspended', 'cancelled'], + 'suspended' => ['active', 'cancelled'], + 'cancelled' => ['expired'], + 'expired' => [], + ]; + + /** + * @return array + */ + public function allowedTransitions(string $status): array + { + return self::TRANSITIONS[$status] ?? []; + } + + public function canTransition(Subscription $subscription, string $to): bool + { + return in_array($to, $this->allowedTransitions((string) $subscription->status), true); + } + + public function transition(Subscription $subscription, string $to, string $reason = ''): Subscription + { + if (! $this->canTransition($subscription, $to)) { + throw new InvalidArgumentException( + "Cannot transition subscription {$subscription->id} from {$subscription->status} to {$to}." + ); + } + + return DB::transaction(function () use ($subscription, $to, $reason): Subscription { + $updates = [ + 'status' => $to, + 'metadata' => array_merge($subscription->metadata ?? [], [ + 'state_machine' => [ + 'from' => $subscription->status, + 'to' => $to, + 'reason' => $reason, + 'changed_at' => now()->toIso8601String(), + ], + ]), + ]; + + if ($to === 'suspended') { + $updates['paused_at'] = $subscription->paused_at ?? now(); + } + + if ($to === 'cancelled') { + $updates['cancelled_at'] = now(); + $updates['cancellation_reason'] = $reason; + } + + if ($to === 'expired') { + $updates['ended_at'] = now(); + } + + $subscription->update($updates); + + if ($to === 'cancelled') { + event(new SubscriptionCancelled($subscription->fresh(), true)); + } + + return $subscription->fresh(); + }); + } + + public function suspend(Subscription $subscription, string $reason = 'failed_payment'): Subscription + { + return $this->transition($subscription, 'suspended', $reason); + } + + public function reactivate(Subscription $subscription, string $reason = 'payment_recovered'): Subscription + { + return $this->transition($subscription, 'active', $reason); + } + + public function cancel(Subscription $subscription, string $reason = ''): Subscription + { + return $this->transition($subscription, 'cancelled', $reason); + } + + public function expire(Subscription $subscription, string $reason = 'period_ended'): Subscription + { + return $this->transition($subscription, 'expired', $reason); + } +} diff --git a/Services/WebhookService.php b/Services/WebhookService.php new file mode 100644 index 0000000..d9e1bfd --- /dev/null +++ b/Services/WebhookService.php @@ -0,0 +1,109 @@ +gateway($gateway); + + if (! $paymentGateway->validateWebhookSignature($request)) { + Log::warning('Webhook signature rejected', [ + 'gateway' => $gateway, + 'ip' => $request->ip(), + ]); + + return null; + } + + $event = $paymentGateway->parseWebhookEvent($request); + $eventId = $event['id'] ?? null; + $eventType = (string) ($event['type'] ?? 'unknown'); + + if (is_string($eventId) && $this->exists($gateway, $eventId)) { + return null; + } + + try { + $webhookEvent = WebhookEvent::record( + gateway: $gateway, + eventType: $eventType, + payload: $request->getContent(), + eventId: is_string($eventId) ? $eventId : null, + headers: $this->headers($request, $gateway), + ); + } catch (QueryException $e) { + if ($this->isDuplicate($e)) { + return null; + } + + throw $e; + } + + ProcessWebhookEvent::dispatch($webhookEvent->id)->afterCommit(); + + return $webhookEvent; + }); + } + + protected function exists(string $gateway, string $eventId): bool + { + return WebhookEvent::query() + ->where('gateway', $gateway) + ->where('event_id', $eventId) + ->exists(); + } + + /** + * @return array + */ + protected function headers(Request $request, string $gateway): array + { + $headers = [ + 'Content-Type' => (string) $request->header('Content-Type', ''), + 'User-Agent' => (string) $request->header('User-Agent', ''), + 'X-Forwarded-For' => (string) $request->header('X-Forwarded-For', ''), + ]; + + if ($gateway === 'stripe') { + $headers['Stripe-Signature'] = (string) $request->header('Stripe-Signature', ''); + } + + if ($gateway === 'btcpay') { + $headers['BTCPay-Sig'] = (string) $request->header('BTCPay-Sig', ''); + $headers['BTCPay-Signature'] = (string) $request->header('BTCPay-Signature', ''); + } + + return array_filter($headers, fn (string $value): bool => $value !== ''); + } + + protected function isDuplicate(QueryException $e): bool + { + $driverCode = $e->errorInfo[0] ?? null; + $vendorCode = $e->errorInfo[1] ?? null; + $message = $e->getMessage(); + + return $vendorCode === 1062 + || $vendorCode === 19 + || $driverCode === '23505' + || str_contains($message, 'webhook_events_idempotency') + || str_contains($message, 'UNIQUE constraint failed'); + } +} diff --git a/config.php b/config.php index 255cfa2..6d612f3 100644 --- a/config.php +++ b/config.php @@ -232,9 +232,8 @@ return [ 'dunning' => [ 'enabled' => true, - // Exponential backoff: days after initial failure to schedule each retry - // [1, 3, 7] = retry at day 1, day 3, day 7 (total ~11 days of retries) - 'retry_days' => [1, 3, 7], + // Escalating backoff: retry at 1, 3, 7, and 14 days, then cancel. + 'retry_days' => [1, 3, 7, 14], // Days after subscription paused to suspend workspace entitlements // Paused = billing stopped but workspace accessible @@ -243,7 +242,7 @@ return [ // Days after subscription paused to cancel entirely // After cancellation, workspace may be downgraded to free tier - 'cancel_after_days' => 30, + 'cancel_after_days' => 14, // Grace period before first retry (hours) // Gives customer time to fix payment method before automated retries diff --git a/routes/admin.php b/routes/admin.php index 9051a6c..9c42b3e 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -49,3 +49,8 @@ Route::prefix('hub/commerce')->name('hub.commerce.')->group(function () { Route::get('/credit-notes', CreditNoteManager::class)->name('credit-notes'); Route::get('/referrals', ReferralManager::class)->name('referrals'); }); + +Route::prefix('admin/commerce')->name('admin.commerce.')->group(function () { + Route::get('/matrix/training', [Core\Mod\Commerce\Controllers\MatrixTrainingController::class, 'pending']) + ->name('matrix.training'); +}); diff --git a/routes/api.php b/routes/api.php index 23d1b63..25b6a7d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -92,3 +92,25 @@ Route::middleware('auth')->prefix('commerce')->group(function () { 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'); +}); diff --git a/routes/web.php b/routes/web.php index d3d1f2b..9bb8768 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Core\Mod\Commerce\Controllers\MatrixTrainingController; +use Core\Mod\Commerce\View\Modal\Web\CheckoutCancel; +use Core\Mod\Commerce\View\Modal\Web\CheckoutSuccess; use Illuminate\Support\Facades\Route; /* @@ -34,3 +36,6 @@ Route::prefix('commerce')->name('commerce.')->group(function () { }); }); + +Route::get('/checkout/success', CheckoutSuccess::class)->name('checkout.success'); +Route::get('/checkout/cancel', CheckoutCancel::class)->name('checkout.cancel'); diff --git a/tests/Feature/RfcSurfaceTest.php b/tests/Feature/RfcSurfaceTest.php new file mode 100644 index 0000000..81b34e4 --- /dev/null +++ b/tests/Feature/RfcSurfaceTest.php @@ -0,0 +1,36 @@ +toArray())->toMatchArray([ + 'valid' => true, + 'discount_amount' => 5.0, + 'discount_type' => 'fixed', + ])->and($proration->netAmount())->toBe(15.0); +}); + +it('provides Stripe and BTCPay RFC gateway implementations', function (): void { + expect(is_subclass_of(StripeGateway::class, PaymentGatewayContract::class))->toBeTrue() + ->and(is_subclass_of(BTCPayGateway::class, PaymentGatewayContract::class))->toBeTrue(); +});