diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml index 9313ab46..8e3f4a84 100644 --- a/.github/workflows/linting.yaml +++ b/.github/workflows/linting.yaml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - php: [8.3] + php: [8.4] steps: - name: Checkout code diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d48d7ae9..c1928c15 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -12,8 +12,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-latest ] - php: [ 8.3 ] + os: [ubuntu-latest] + php: [8.4] # services: # redis: # image: redis diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php index 5ea69c93..7860cec9 100644 --- a/app/Http/Controllers/CartController.php +++ b/app/Http/Controllers/CartController.php @@ -5,19 +5,18 @@ use App\Models\Plugin; use App\Models\PluginBundle; use App\Services\CartService; -use App\Services\StripeConnectService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\View\View; +use Laravel\Cashier\Cashier; class CartController extends Controller { public function __construct( - protected CartService $cartService, - protected StripeConnectService $stripeConnectService + protected CartService $cartService ) {} public function show(Request $request): View @@ -217,6 +216,8 @@ public function checkout(Request $request): RedirectResponse try { $session = $this->createMultiItemCheckoutSession($cart, $user); + $cart->update(['stripe_checkout_session_id' => $session->id]); + return redirect($session->url); } catch (\Exception $e) { Log::error('Cart checkout failed', [ @@ -241,11 +242,7 @@ public function success(Request $request): View|RedirectResponse ->with('error', 'Invalid checkout session. Please try again.'); } - $user = Auth::user(); - - // Clear the cart - the webhook will create the licenses - $cart = $this->cartService->getCart($user); - $cart->clear(); + // Cart will be marked as completed by the webhook after licenses are created return view('cart.success', [ 'sessionId' => $sessionId, @@ -256,9 +253,27 @@ public function status(Request $request, string $sessionId): JsonResponse { $user = Auth::user(); - // Check if licenses exist for this checkout session + // Retrieve the checkout session to get the invoice ID + try { + $session = Cashier::stripe()->checkout->sessions->retrieve($sessionId); + $invoiceId = $session->invoice; + } catch (\Exception $e) { + return response()->json([ + 'status' => 'error', + 'message' => 'Unable to verify purchase status.', + ], 400); + } + + if (! $invoiceId) { + return response()->json([ + 'status' => 'pending', + 'message' => 'Processing your purchase...', + ]); + } + + // Check if licenses exist for this invoice $licenses = $user->pluginLicenses() - ->where('stripe_checkout_session_id', $sessionId) + ->where('stripe_invoice_id', $invoiceId) ->with('plugin') ->get(); @@ -274,6 +289,7 @@ public function status(Request $request, string $sessionId): JsonResponse 'message' => 'Purchase complete!', 'licenses' => $licenses->map(fn ($license) => [ 'id' => $license->id, + 'plugin_id' => $license->plugin->id, 'plugin_name' => $license->plugin->name, 'plugin_slug' => $license->plugin->slug, ]), @@ -296,20 +312,10 @@ public function count(Request $request): JsonResponse protected function createMultiItemCheckoutSession($cart, $user): \Stripe\Checkout\Session { - $stripe = new \Stripe\StripeClient(config('cashier.secret')); - // Eager load items with plugins and bundles to avoid any stale data issues $cart->load('items.plugin', 'items.pluginBundle.plugins'); $lineItems = []; - $metadata = [ - 'cart_id' => $cart->id, - 'user_id' => $user->id, - 'plugin_ids' => [], - 'price_ids' => [], - 'bundle_ids' => [], - 'bundle_plugin_ids' => [], - ]; Log::info('Creating multi-item checkout session', [ 'cart_id' => $cart->id, @@ -321,14 +327,6 @@ protected function createMultiItemCheckoutSession($cart, $user): \Stripe\Checkou if ($item->isBundle()) { $bundle = $item->pluginBundle; - Log::info('Adding bundle to checkout session', [ - 'cart_id' => $cart->id, - 'cart_item_id' => $item->id, - 'bundle_id' => $bundle->id, - 'bundle_name' => $bundle->name, - 'plugin_count' => $bundle->plugins->count(), - ]); - $pluginNames = $bundle->plugins->pluck('name')->take(3)->implode(', '); if ($bundle->plugins->count() > 3) { $pluginNames .= ' and '.($bundle->plugins->count() - 3).' more'; @@ -345,20 +343,9 @@ protected function createMultiItemCheckoutSession($cart, $user): \Stripe\Checkou ], 'quantity' => 1, ]; - - $metadata['bundle_ids'][] = $bundle->id; - $metadata['bundle_plugin_ids'][$bundle->id] = $bundle->plugins->pluck('id')->implode(':'); } else { $plugin = $item->plugin; - Log::info('Adding plugin to checkout session', [ - 'cart_id' => $cart->id, - 'cart_item_id' => $item->id, - 'plugin_id' => $plugin->id, - 'plugin_name' => $plugin->name, - 'price_id' => $item->plugin_price_id, - ]); - $lineItems[] = [ 'price_data' => [ 'currency' => strtolower($item->currency), @@ -370,32 +357,18 @@ protected function createMultiItemCheckoutSession($cart, $user): \Stripe\Checkou ], 'quantity' => 1, ]; - - $metadata['plugin_ids'][] = $plugin->id; - $metadata['price_ids'][] = $item->plugin_price_id; } } - // Encode arrays for Stripe metadata (must be strings) - $metadata['cart_id'] = (string) $metadata['cart_id']; - $metadata['user_id'] = (string) $metadata['user_id']; - $metadata['plugin_ids'] = implode(',', $metadata['plugin_ids']); - $metadata['price_ids'] = implode(',', $metadata['price_ids']); - $metadata['bundle_ids'] = implode(',', $metadata['bundle_ids']); - $metadata['bundle_plugin_ids'] = json_encode($metadata['bundle_plugin_ids']); + // Ensure the user has a valid Stripe customer ID + $this->ensureValidStripeCustomer($user); - Log::info('Checkout session metadata prepared', [ - 'cart_id' => $cart->id, - 'metadata' => $metadata, - 'line_items_count' => count($lineItems), - ]); - - // Ensure the user has a Stripe customer ID (required for Stripe Accounts V2) - if (! $user->stripe_id) { - $user->createAsStripeCustomer(); - } + // Metadata only needs cart_id - we'll look up items from the cart + $metadata = [ + 'cart_id' => (string) $cart->id, + ]; - return $stripe->checkout->sessions->create([ + $session = Cashier::stripe()->checkout->sessions->create([ 'mode' => 'payment', 'line_items' => $lineItems, 'success_url' => route('cart.success').'?session_id={CHECKOUT_SESSION_ID}', @@ -414,8 +387,51 @@ protected function createMultiItemCheckoutSession($cart, $user): \Stripe\Checkou 'invoice_data' => [ 'description' => 'NativePHP Plugin Purchase', 'footer' => 'Thank you for your purchase!', + 'metadata' => $metadata, ], ], ]); + + // Store the Stripe checkout session ID on the cart + $cart->update(['stripe_checkout_session_id' => $session->id]); + + Log::info('Checkout session created', [ + 'cart_id' => $cart->id, + 'session_id' => $session->id, + ]); + + return $session; + } + + /** + * Ensure the user has a valid Stripe customer ID. + * Creates a new customer if none exists or if the existing one is invalid. + */ + protected function ensureValidStripeCustomer($user): void + { + if (! $user->stripe_id) { + $user->createAsStripeCustomer(); + + return; + } + + // Verify the customer exists in Stripe + try { + Cashier::stripe()->customers->retrieve($user->stripe_id); + } catch (\Stripe\Exception\InvalidRequestException $e) { + // Customer doesn't exist in Stripe, create a new one + if (str_contains($e->getMessage(), 'No such customer')) { + Log::warning('Stripe customer not found, creating new customer', [ + 'user_id' => $user->id, + 'old_stripe_id' => $user->stripe_id, + ]); + + $user->stripe_id = null; + $user->save(); + $user->createAsStripeCustomer(); + } else { + throw $e; + } + } } } diff --git a/app/Http/Controllers/PluginPurchaseController.php b/app/Http/Controllers/PluginPurchaseController.php index 2e03b48b..609c3e5c 100644 --- a/app/Http/Controllers/PluginPurchaseController.php +++ b/app/Http/Controllers/PluginPurchaseController.php @@ -3,87 +3,80 @@ namespace App\Http\Controllers; use App\Models\Plugin; -use App\Services\StripeConnectService; +use App\Services\CartService; +use App\Services\GrandfatheringService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Log; use Illuminate\View\View; +use Laravel\Cashier\Cashier; class PluginPurchaseController extends Controller { public function __construct( - protected StripeConnectService $stripeConnectService + protected CartService $cartService, + protected GrandfatheringService $grandfatheringService ) {} - public function show(Request $request, string $vendor, string $package): View|RedirectResponse + public function show(Request $request, Plugin $plugin): View|RedirectResponse { - $plugin = Plugin::findByVendorPackageOrFail($vendor, $package); - if ($plugin->isFree()) { - return redirect()->route('plugins.show', $this->pluginRouteParams($plugin)); + return redirect()->route('plugins.show', $plugin); } $user = $request->user(); - $bestPrice = $plugin->getBestPriceForUser($user); - $regularPrice = $plugin->getRegularPrice(); + $activePrice = $plugin->activePrice; - if (! $bestPrice) { - return redirect()->route('plugins.show', $this->pluginRouteParams($plugin)) + if (! $activePrice) { + return redirect()->route('plugins.show', $plugin) ->with('error', 'This plugin is not available for purchase.'); } + $discountPercent = $this->grandfatheringService->getApplicableDiscount($user, $plugin->is_official); + $discountedAmount = $activePrice->getDiscountedAmount($discountPercent); + return view('plugins.purchase', [ 'plugin' => $plugin, - 'price' => $bestPrice, - 'regularPrice' => $regularPrice, - 'hasDiscount' => $regularPrice && $bestPrice->id !== $regularPrice->id, + 'price' => $activePrice, + 'discountPercent' => $discountPercent, + 'discountedAmount' => $discountedAmount, + 'originalAmount' => $activePrice->amount, ]); } - public function checkout(Request $request, string $vendor, string $package): RedirectResponse + public function checkout(Request $request, Plugin $plugin): RedirectResponse { - $plugin = Plugin::findByVendorPackageOrFail($vendor, $package); $user = $request->user(); if ($plugin->isFree()) { - return redirect()->route('plugins.show', $this->pluginRouteParams($plugin)); + return redirect()->route('plugins.show', $plugin); } - $bestPrice = $plugin->getBestPriceForUser($user); - - if (! $bestPrice) { - return redirect()->route('plugins.show', $this->pluginRouteParams($plugin)) + if (! $plugin->activePrice) { + return redirect()->route('plugins.show', $plugin) ->with('error', 'This plugin is not available for purchase.'); } - try { - $session = $this->stripeConnectService->createCheckoutSession($bestPrice, $user); - - return redirect($session->url); - } catch (\Exception $e) { - Log::error('Plugin checkout failed', [ - 'plugin_id' => $plugin->id, - 'user_id' => $user->id, - 'price_id' => $bestPrice->id, - 'price_tier' => $bestPrice->tier->value, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); + // Add plugin to cart and redirect to cart checkout + $cart = $this->cartService->getCart($user); - return redirect()->route('plugins.purchase.show', $this->pluginRouteParams($plugin)) - ->with('error', 'Unable to start checkout. Please try again.'); + try { + $this->cartService->addPlugin($cart, $plugin); + } catch (\InvalidArgumentException $e) { + return redirect()->route('plugins.show', $plugin) + ->with('error', $e->getMessage()); } + + return redirect()->route('cart.checkout'); } - public function success(Request $request, string $vendor, string $package): View|RedirectResponse + public function success(Request $request, Plugin $plugin): View|RedirectResponse { - $plugin = Plugin::findByVendorPackageOrFail($vendor, $package); $sessionId = $request->query('session_id'); // Validate session ID exists and looks like a real Stripe session ID if (! $sessionId || ! str_starts_with($sessionId, 'cs_')) { - return redirect()->route('plugins.show', $this->pluginRouteParams($plugin)) + return redirect()->route('plugins.show', $plugin) ->with('error', 'Invalid checkout session. Please try again.'); } @@ -93,14 +86,31 @@ public function success(Request $request, string $vendor, string $package): View ]); } - public function status(Request $request, string $vendor, string $package, string $sessionId): JsonResponse + public function status(Request $request, Plugin $plugin, string $sessionId): JsonResponse { - $plugin = Plugin::findByVendorPackageOrFail($vendor, $package); $user = $request->user(); - // Check if license exists for this checkout session and plugin + // Retrieve the checkout session to get the invoice ID + try { + $session = Cashier::stripe()->checkout->sessions->retrieve($sessionId); + $invoiceId = $session->invoice; + } catch (\Exception $e) { + return response()->json([ + 'status' => 'error', + 'message' => 'Unable to verify purchase status.', + ], 400); + } + + if (! $invoiceId) { + return response()->json([ + 'status' => 'pending', + 'message' => 'Processing your purchase...', + ]); + } + + // Check if license exists for this invoice and plugin $license = $user->pluginLicenses() - ->where('stripe_checkout_session_id', $sessionId) + ->where('stripe_invoice_id', $invoiceId) ->where('plugin_id', $plugin->id) ->first(); @@ -118,23 +128,9 @@ public function status(Request $request, string $vendor, string $package, string ]); } - public function cancel(Request $request, string $vendor, string $package): RedirectResponse + public function cancel(Request $request, Plugin $plugin): RedirectResponse { - $plugin = Plugin::findByVendorPackageOrFail($vendor, $package); - - return redirect()->route('plugins.show', $this->pluginRouteParams($plugin)) + return redirect()->route('plugins.show', $plugin) ->with('message', 'Purchase cancelled.'); } - - /** - * Get route parameters for a plugin's vendor/package URL. - * - * @return array{vendor: string, package: string} - */ - protected function pluginRouteParams(Plugin $plugin): array - { - [$vendor, $package] = explode('/', $plugin->name); - - return ['vendor' => $vendor, 'package' => $package]; - } } diff --git a/app/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index 93acd550..1f299a7e 100644 --- a/app/Jobs/HandleInvoicePaidJob.php +++ b/app/Jobs/HandleInvoicePaidJob.php @@ -2,15 +2,25 @@ namespace App\Jobs; +use App\Enums\PayoutStatus; use App\Enums\Subscription; use App\Exceptions\InvalidStateException; +use App\Models\Cart; +use App\Models\CartItem; use App\Models\License; +use App\Models\Plugin; +use App\Models\PluginBundle; +use App\Models\PluginLicense; +use App\Models\PluginPayout; +use App\Models\PluginPrice; use App\Models\User; +use App\Services\StripeConnectService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; use Laravel\Cashier\Cashier; use Laravel\Cashier\SubscriptionItem; use Stripe\Invoice; @@ -26,11 +36,17 @@ public function __construct(public Invoice $invoice) {} public function handle(): void { + Log::info('HandleInvoicePaidJob started', [ + 'invoice_id' => $this->invoice->id, + 'billing_reason' => $this->invoice->billing_reason, + 'metadata' => (array) $this->invoice->metadata, + ]); + match ($this->invoice->billing_reason) { Invoice::BILLING_REASON_SUBSCRIPTION_CREATE => $this->handleSubscriptionCreated(), Invoice::BILLING_REASON_SUBSCRIPTION_UPDATE => null, // TODO: Handle subscription update Invoice::BILLING_REASON_SUBSCRIPTION_CYCLE => $this->handleSubscriptionRenewal(), - Invoice::BILLING_REASON_MANUAL => null, + Invoice::BILLING_REASON_MANUAL => $this->handleManualInvoice(), default => null, }; } @@ -178,6 +194,331 @@ private function handleSubscriptionRenewal(): void ]); } + private function handleManualInvoice(): void + { + $metadata = $this->invoice->metadata->toArray(); + + // Check for cart-based purchase first (new approach) + if (! empty($metadata['cart_id'])) { + $this->processCartPurchase($metadata['cart_id']); + + return; + } + + // Legacy: Only process if this is a plugin purchase (has plugin metadata) + if (empty($metadata['plugin_ids']) && empty($metadata['bundle_ids'])) { + return; + } + + $user = $this->billable(); + + Log::info('Processing manual invoice for plugin purchase (legacy metadata)', [ + 'invoice_id' => $this->invoice->id, + 'user_id' => $user->id, + 'metadata' => $metadata, + ]); + + // Handle bundle purchases + if (! empty($metadata['bundle_ids'])) { + $this->processPluginBundles($user, $metadata); + } + + // Handle individual plugin purchases + if (! empty($metadata['plugin_ids'])) { + $this->processPluginPurchases($user, $metadata); + } + + // Ensure user has a plugin license key + $user->getPluginLicenseKey(); + } + + private function processCartPurchase(string $cartId): void + { + $cart = Cart::with(['items.plugin.developerAccount', 'items.pluginBundle.plugins.developerAccount']) + ->find($cartId); + + if (! $cart) { + Log::error('Cart not found for invoice', [ + 'invoice_id' => $this->invoice->id, + 'cart_id' => $cartId, + ]); + + return; + } + + // Idempotency: skip if cart already completed + if ($cart->isCompleted()) { + Log::info('Cart already processed, skipping', [ + 'invoice_id' => $this->invoice->id, + 'cart_id' => $cartId, + ]); + + return; + } + + $user = $cart->user; + + if (! $user) { + Log::error('Cart has no associated user', [ + 'invoice_id' => $this->invoice->id, + 'cart_id' => $cartId, + ]); + + return; + } + + Log::info('Processing cart purchase from invoice', [ + 'invoice_id' => $this->invoice->id, + 'cart_id' => $cartId, + 'user_id' => $user->id, + 'item_count' => $cart->items->count(), + ]); + + foreach ($cart->items as $item) { + if ($item->isBundle()) { + $this->processCartBundleItem($user, $item); + } else { + $this->processCartPluginItem($user, $item); + } + } + + // Mark cart as completed + $cart->markAsCompleted(); + + // Ensure user has a plugin license key + $user->getPluginLicenseKey(); + + Log::info('Cart purchase completed', [ + 'invoice_id' => $this->invoice->id, + 'cart_id' => $cartId, + 'user_id' => $user->id, + ]); + } + + private function processCartPluginItem(User $user, CartItem $item): void + { + $plugin = $item->plugin; + + if (! $plugin) { + Log::warning('Plugin not found for cart item', ['cart_item_id' => $item->id]); + + return; + } + + // Check if license already exists for this invoice + plugin + if (PluginLicense::where('stripe_invoice_id', $this->invoice->id) + ->where('plugin_id', $plugin->id) + ->whereNull('plugin_bundle_id') + ->exists()) { + Log::info('License already exists for plugin', [ + 'invoice_id' => $this->invoice->id, + 'plugin_id' => $plugin->id, + ]); + + return; + } + + $this->createPluginLicense($user, $plugin, $item->price_at_addition); + } + + private function processCartBundleItem(User $user, CartItem $item): void + { + $bundle = $item->pluginBundle; + + if (! $bundle) { + Log::warning('Bundle not found for cart item', ['cart_item_id' => $item->id]); + + return; + } + + // Load plugins with developer accounts if not already loaded + $bundle->loadMissing('plugins.developerAccount'); + + // Calculate proportional allocation based on price at time of addition + $allocations = $bundle->calculateProportionalAllocation($item->bundle_price_at_addition); + + foreach ($bundle->plugins as $plugin) { + // Check if license already exists for this invoice + plugin + bundle + if (PluginLicense::where('stripe_invoice_id', $this->invoice->id) + ->where('plugin_id', $plugin->id) + ->where('plugin_bundle_id', $bundle->id) + ->exists()) { + continue; + } + + $allocatedAmount = $allocations[$plugin->id] ?? 0; + + $this->createBundlePluginLicense($user, $plugin, $bundle, $allocatedAmount); + } + + Log::info('Processed bundle from cart', [ + 'invoice_id' => $this->invoice->id, + 'bundle_id' => $bundle->id, + 'bundle_name' => $bundle->name, + ]); + } + + private function processPluginPurchases(User $user, array $metadata): void + { + $pluginIds = array_filter(explode(',', $metadata['plugin_ids'])); + $priceIds = array_filter(explode(',', $metadata['price_ids'] ?? '')); + + // Get already processed plugin IDs for this invoice + $alreadyProcessedPluginIds = PluginLicense::where('stripe_invoice_id', $this->invoice->id) + ->whereNull('plugin_bundle_id') + ->pluck('plugin_id') + ->toArray(); + + foreach ($pluginIds as $index => $pluginId) { + if (in_array((int) $pluginId, $alreadyProcessedPluginIds)) { + continue; + } + + $plugin = Plugin::find($pluginId); + + if (! $plugin) { + Log::warning('Plugin not found during invoice processing', ['plugin_id' => $pluginId]); + + continue; + } + + $priceId = $priceIds[$index] ?? null; + $price = $priceId ? PluginPrice::find($priceId) : $plugin->activePrice; + $amount = $price ? $price->amount : 0; + + $this->createPluginLicense($user, $plugin, $amount); + } + } + + private function processPluginBundles(User $user, array $metadata): void + { + $bundleIds = array_filter(explode(',', $metadata['bundle_ids'])); + $bundlePluginIds = json_decode($metadata['bundle_plugin_ids'] ?? '{}', true); + + foreach ($bundleIds as $bundleId) { + $bundle = PluginBundle::with(['plugins.developerAccount', 'plugins.activePrice']) + ->find($bundleId); + + if (! $bundle) { + Log::warning('Bundle not found during invoice processing', ['bundle_id' => $bundleId]); + + continue; + } + + // Check if bundle already fully processed for this invoice + $existingLicenseCount = PluginLicense::where('stripe_invoice_id', $this->invoice->id) + ->where('plugin_bundle_id', $bundleId) + ->count(); + + if ($existingLicenseCount === $bundle->plugins->count()) { + continue; + } + + // Calculate proportional allocation for developer payouts + $allocations = $bundle->calculateProportionalAllocation(); + + foreach ($bundle->plugins as $plugin) { + // Skip if license already exists for this plugin in this invoice + if (PluginLicense::where('stripe_invoice_id', $this->invoice->id) + ->where('plugin_id', $plugin->id) + ->where('plugin_bundle_id', $bundleId) + ->exists()) { + continue; + } + + $allocatedAmount = $allocations[$plugin->id] ?? 0; + + $this->createBundlePluginLicense($user, $plugin, $bundle, $allocatedAmount); + } + + Log::info('Processed bundle from invoice', [ + 'invoice_id' => $this->invoice->id, + 'bundle_id' => $bundleId, + 'bundle_name' => $bundle->name, + ]); + } + } + + private function createPluginLicense(User $user, Plugin $plugin, int $amount): PluginLicense + { + $license = PluginLicense::create([ + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + 'stripe_invoice_id' => $this->invoice->id, + 'stripe_payment_intent_id' => $this->invoice->payment_intent, + 'price_paid' => $amount, + 'currency' => strtoupper($this->invoice->currency), + 'is_grandfathered' => false, + 'purchased_at' => now(), + ]); + + // Create payout record for developer if applicable + if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $amount > 0) { + $split = PluginPayout::calculateSplit($amount); + + $payout = PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $plugin->developerAccount->id, + 'gross_amount' => $amount, + 'platform_fee' => $split['platform_fee'], + 'developer_amount' => $split['developer_amount'], + 'status' => PayoutStatus::Pending, + ]); + + $stripeConnectService = app(StripeConnectService::class); + $stripeConnectService->processTransfer($payout); + } + + Log::info('Created plugin license from invoice', [ + 'invoice_id' => $this->invoice->id, + 'license_id' => $license->id, + 'plugin_id' => $plugin->id, + ]); + + return $license; + } + + private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBundle $bundle, int $allocatedAmount): PluginLicense + { + $license = PluginLicense::create([ + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + 'plugin_bundle_id' => $bundle->id, + 'stripe_invoice_id' => $this->invoice->id, + 'stripe_payment_intent_id' => $this->invoice->payment_intent, + 'price_paid' => $allocatedAmount, + 'currency' => strtoupper($this->invoice->currency), + 'is_grandfathered' => false, + 'purchased_at' => now(), + ]); + + // Create proportional payout for developer + if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $allocatedAmount > 0) { + $split = PluginPayout::calculateSplit($allocatedAmount); + + $payout = PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $plugin->developerAccount->id, + 'gross_amount' => $allocatedAmount, + 'platform_fee' => $split['platform_fee'], + 'developer_amount' => $split['developer_amount'], + 'status' => PayoutStatus::Pending, + ]); + + $stripeConnectService = app(StripeConnectService::class); + $stripeConnectService->processTransfer($payout); + } + + Log::info('Created bundle plugin license from invoice', [ + 'invoice_id' => $this->invoice->id, + 'bundle_id' => $bundle->id, + 'plugin_id' => $plugin->id, + 'allocated_amount' => $allocatedAmount, + ]); + + return $license; + } + private function billable(): User { if ($user = Cashier::findBillable($this->invoice->customer)) { diff --git a/app/Listeners/StripeWebhookReceivedListener.php b/app/Listeners/StripeWebhookReceivedListener.php index 64e64014..4889e803 100644 --- a/app/Listeners/StripeWebhookReceivedListener.php +++ b/app/Listeners/StripeWebhookReceivedListener.php @@ -4,7 +4,6 @@ use App\Jobs\CreateUserFromStripeCustomer; use App\Jobs\HandleInvoicePaidJob; -use App\Jobs\ProcessPluginCheckoutJob; use App\Jobs\RemoveDiscordMaxRoleJob; use App\Models\User; use Exception; @@ -24,7 +23,6 @@ public function handle(WebhookReceived $event): void 'customer.subscription.created' => $this->createUserIfNotExists($event->payload['data']['object']['customer']), 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event), 'customer.subscription.updated' => $this->handleSubscriptionUpdated($event), - 'checkout.session.completed' => $this->handleCheckoutSessionCompleted($event), default => null, }; } @@ -48,9 +46,18 @@ private function createUserIfNotExists(string $stripeCustomerId): void private function handleInvoicePaid(WebhookReceived $event): void { + Log::info('handleInvoicePaid called', [ + 'invoice_id' => $event->payload['data']['object']['id'] ?? 'unknown', + 'billing_reason' => $event->payload['data']['object']['billing_reason'] ?? 'unknown', + ]); + $invoice = Invoice::constructFrom($event->payload['data']['object']); + Log::info('Dispatching HandleInvoicePaidJob', ['invoice_id' => $invoice->id]); + dispatch(new HandleInvoicePaidJob($invoice)); + + Log::info('HandleInvoicePaidJob dispatched'); } private function handleSubscriptionDeleted(WebhookReceived $event): void @@ -97,37 +104,4 @@ private function removeDiscordRoleIfNoMaxLicense(User $user): void dispatch(new RemoveDiscordMaxRoleJob($user)); } - - private function handleCheckoutSessionCompleted(WebhookReceived $event): void - { - $session = $event->payload['data']['object']; - - // Only process completed payment sessions for plugins - if ($session['payment_status'] !== 'paid') { - return; - } - - $metadata = $session['metadata'] ?? []; - - // Only process if this is a plugin purchase (has plugin_id or plugin_ids in metadata) - if (! isset($metadata['plugin_id']) && ! isset($metadata['plugin_ids'])) { - return; - } - - Log::info('Dispatching ProcessPluginCheckoutJob from webhook', [ - 'session_id' => $session['id'], - 'metadata' => $metadata, - 'has_cart_id' => isset($metadata['cart_id']), - 'has_plugin_ids' => isset($metadata['plugin_ids']), - 'plugin_ids_value' => $metadata['plugin_ids'] ?? null, - ]); - - dispatch(new ProcessPluginCheckoutJob( - checkoutSessionId: $session['id'], - metadata: $metadata, - amountTotal: $session['amount_total'], - currency: $session['currency'], - paymentIntentId: $session['payment_intent'] ?? null, - )); - } } diff --git a/app/Models/Cart.php b/app/Models/Cart.php index 97f5cacb..010f2aa0 100644 --- a/app/Models/Cart.php +++ b/app/Models/Cart.php @@ -15,6 +15,7 @@ class Cart extends Model protected $casts = [ 'expires_at' => 'datetime', + 'completed_at' => 'datetime', ]; /** @@ -81,6 +82,16 @@ public function clear(): void $this->items()->delete(); } + public function isCompleted(): bool + { + return $this->completed_at !== null; + } + + public function markAsCompleted(): void + { + $this->update(['completed_at' => now()]); + } + /** * Find all bundles that contain at least one plugin from the cart. * diff --git a/app/Services/CartService.php b/app/Services/CartService.php index 56717667..5a00fe6f 100644 --- a/app/Services/CartService.php +++ b/app/Services/CartService.php @@ -26,7 +26,9 @@ public function getCart(?User $user = null): Cart protected function getCartForUser(User $user): Cart { - $cart = Cart::where('user_id', $user->id)->first(); + $cart = Cart::where('user_id', $user->id) + ->whereNull('completed_at') + ->first(); if (! $cart) { $cart = Cart::create([ @@ -45,6 +47,7 @@ protected function getCartForSession(): Cart if ($sessionId) { $cart = Cart::where('session_id', $sessionId) ->whereNull('user_id') + ->whereNull('completed_at') ->first(); if ($cart && ! $cart->isExpired()) { @@ -147,7 +150,9 @@ public function transferGuestCartToUser(User $user): ?Cart return null; } - $userCart = Cart::where('user_id', $user->id)->first(); + $userCart = Cart::where('user_id', $user->id) + ->whereNull('completed_at') + ->first(); if ($userCart) { // Merge guest cart items into user cart @@ -293,11 +298,16 @@ public function removeAlreadyOwned(Cart $cart, User $user): array public function getCartItemCount(?User $user = null): int { if ($user) { - $cart = Cart::where('user_id', $user->id)->first(); + $cart = Cart::where('user_id', $user->id) + ->whereNull('completed_at') + ->first(); } else { $sessionId = Session::get(self::SESSION_KEY); $cart = $sessionId - ? Cart::where('session_id', $sessionId)->whereNull('user_id')->first() + ? Cart::where('session_id', $sessionId) + ->whereNull('user_id') + ->whereNull('completed_at') + ->first() : null; } diff --git a/app/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index e6ee56aa..f64813f7 100644 --- a/app/Services/StripeConnectService.php +++ b/app/Services/StripeConnectService.php @@ -11,20 +11,16 @@ use App\Models\PluginPrice; use App\Models\User; use Illuminate\Support\Facades\Log; -use Stripe\StripeClient; +use Laravel\Cashier\Cashier; +/** + * Service for managing Stripe Connect accounts and processing developer payouts. + */ class StripeConnectService { - protected StripeClient $stripe; - - public function __construct() - { - $this->stripe = new StripeClient(config('cashier.secret')); - } - public function createConnectAccount(User $user): DeveloperAccount { - $account = $this->stripe->accounts->create([ + $account = Cashier::stripe()->accounts->create([ 'type' => 'express', 'email' => $user->email, 'metadata' => [ @@ -46,7 +42,7 @@ public function createConnectAccount(User $user): DeveloperAccount public function createOnboardingLink(DeveloperAccount $account): string { - $accountLink = $this->stripe->accountLinks->create([ + $accountLink = Cashier::stripe()->accountLinks->create([ 'account' => $account->stripe_connect_account_id, 'refresh_url' => route('customer.developer.onboarding.refresh'), 'return_url' => route('customer.developer.onboarding.return'), @@ -58,7 +54,7 @@ public function createOnboardingLink(DeveloperAccount $account): string public function refreshAccountStatus(DeveloperAccount $account): void { - $stripeAccount = $this->stripe->accounts->retrieve($account->stripe_connect_account_id); + $stripeAccount = Cashier::stripe()->accounts->retrieve($account->stripe_connect_account_id); $account->update([ 'payouts_enabled' => $stripeAccount->payouts_enabled, @@ -74,173 +70,6 @@ public function refreshAccountStatus(DeveloperAccount $account): void ]); } - public function createCheckoutSession(PluginPrice $price, User $buyer): \Stripe\Checkout\Session - { - $plugin = $price->plugin; - $developerAccount = $plugin->developerAccount; - - // Ensure the buyer has a Stripe customer ID (required for Stripe Accounts V2) - if (! $buyer->stripe_id) { - $buyer->createAsStripeCustomer(); - } - - $productName = $plugin->name; - if (! $price->isRegularTier()) { - $productName .= ' ('.$price->tier->label().' pricing)'; - } - - $sessionParams = [ - 'mode' => 'payment', - 'line_items' => [ - [ - 'price_data' => [ - 'currency' => strtolower($price->currency), - 'unit_amount' => $price->amount, - 'product_data' => [ - 'name' => $productName, - 'description' => $plugin->description ?? 'NativePHP Plugin', - ], - ], - 'quantity' => 1, - ], - ], - 'success_url' => route('plugins.purchase.success', $plugin->routeParams()).'?session_id={CHECKOUT_SESSION_ID}', - 'cancel_url' => route('plugins.purchase.cancel', $plugin->routeParams()), - 'customer' => $buyer->stripe_id, - 'customer_update' => [ - 'name' => 'auto', - 'address' => 'auto', - ], - 'metadata' => [ - 'plugin_id' => $plugin->id, - 'user_id' => $buyer->id, - 'price_id' => $price->id, - 'price_tier' => $price->tier->value, - ], - 'allow_promotion_codes' => true, - 'billing_address_collection' => 'required', - 'tax_id_collection' => ['enabled' => true], - 'invoice_creation' => [ - 'enabled' => true, - 'invoice_data' => [ - 'description' => 'NativePHP Plugin Purchase', - 'footer' => 'Thank you for your purchase!', - ], - ], - ]; - - if ($developerAccount && $developerAccount->canReceivePayouts()) { - $split = PluginPayout::calculateSplit($price->amount); - - $sessionParams['payment_intent_data'] = [ - 'transfer_data' => [ - 'destination' => $developerAccount->stripe_connect_account_id, - 'amount' => $split['developer_amount'], - ], - ]; - } - - return $this->stripe->checkout->sessions->create($sessionParams); - } - - public function processSuccessfulPayment(string $sessionId): PluginLicense - { - $session = $this->stripe->checkout->sessions->retrieve($sessionId, [ - 'expand' => ['payment_intent'], - ]); - - $pluginId = $session->metadata->plugin_id; - $userId = $session->metadata->user_id; - $priceId = $session->metadata->price_id; - - $plugin = Plugin::findOrFail($pluginId); - $user = User::findOrFail($userId); - $price = PluginPrice::findOrFail($priceId); - - $license = PluginLicense::create([ - 'user_id' => $user->id, - 'plugin_id' => $plugin->id, - 'stripe_payment_intent_id' => $session->payment_intent->id, - 'price_paid' => $session->amount_total, - 'currency' => strtoupper($session->currency), - 'is_grandfathered' => false, - 'purchased_at' => now(), - ]); - - if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts()) { - $this->createPayout($license, $plugin->developerAccount); - } - - $user->getPluginLicenseKey(); - - Log::info('Created plugin license from successful payment', [ - 'license_id' => $license->id, - 'user_id' => $user->id, - 'plugin_id' => $plugin->id, - ]); - - return $license; - } - - /** - * Process a multi-item cart payment and create licenses for each plugin. - * - * @return array - */ - public function processMultiItemPayment(string $sessionId): array - { - $session = $this->stripe->checkout->sessions->retrieve($sessionId, [ - 'expand' => ['payment_intent', 'line_items'], - ]); - - $userId = $session->metadata->user_id; - $pluginIds = explode(',', $session->metadata->plugin_ids); - $priceIds = explode(',', $session->metadata->price_ids); - - $user = User::findOrFail($userId); - $licenses = []; - - // Get line items to match prices - $lineItems = $session->line_items->data; - - foreach ($pluginIds as $index => $pluginId) { - $plugin = Plugin::findOrFail($pluginId); - $priceId = $priceIds[$index] ?? null; - $price = $priceId ? PluginPrice::find($priceId) : null; - - // Get the amount from line items - $amount = isset($lineItems[$index]) - ? $lineItems[$index]->amount_total - : ($price ? $price->amount : 0); - - $license = PluginLicense::create([ - 'user_id' => $user->id, - 'plugin_id' => $plugin->id, - 'stripe_payment_intent_id' => $session->payment_intent->id, - 'price_paid' => $amount, - 'currency' => strtoupper($session->currency), - 'is_grandfathered' => false, - 'purchased_at' => now(), - ]); - - if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts()) { - $this->createPayout($license, $plugin->developerAccount); - } - - $licenses[] = $license; - - Log::info('Created plugin license from cart payment', [ - 'license_id' => $license->id, - 'user_id' => $user->id, - 'plugin_id' => $plugin->id, - ]); - } - - $user->getPluginLicenseKey(); - - return $licenses; - } - public function createPayout(PluginLicense $license, DeveloperAccount $developerAccount): PluginPayout { $split = PluginPayout::calculateSplit($license->price_paid); @@ -292,7 +121,7 @@ public function processTransfer(PluginPayout $payout): bool $transferParams['source_transaction'] = $chargeId; } - $transfer = $this->stripe->transfers->create($transferParams); + $transfer = Cashier::stripe()->transfers->create($transferParams); $payout->markAsTransferred($transfer->id); @@ -326,7 +155,7 @@ protected function getChargeIdFromPayout(PluginPayout $payout): ?string } try { - $paymentIntent = $this->stripe->paymentIntents->retrieve($license->stripe_payment_intent_id); + $paymentIntent = Cashier::stripe()->paymentIntents->retrieve($license->stripe_payment_intent_id); return $paymentIntent->latest_charge; } catch (\Exception $e) { @@ -354,7 +183,7 @@ protected function determineStatus(\Stripe\Account $account): StripeConnectStatu public function createProductAndPrice(Plugin $plugin, int $amountCents, string $currency = 'usd'): PluginPrice { - $product = $this->stripe->products->create([ + $product = Cashier::stripe()->products->create([ 'name' => $plugin->name, 'description' => $plugin->description, 'metadata' => [ @@ -362,7 +191,7 @@ public function createProductAndPrice(Plugin $plugin, int $amountCents, string $ ], ]); - $price = $this->stripe->prices->create([ + $price = Cashier::stripe()->prices->create([ 'product' => $product->id, 'unit_amount' => $amountCents, 'currency' => $currency, diff --git a/database/migrations/2026_01_20_040642_add_stripe_invoice_id_to_plugin_licenses_table.php b/database/migrations/2026_01_20_040642_add_stripe_invoice_id_to_plugin_licenses_table.php new file mode 100644 index 00000000..d45d9745 --- /dev/null +++ b/database/migrations/2026_01_20_040642_add_stripe_invoice_id_to_plugin_licenses_table.php @@ -0,0 +1,30 @@ +string('stripe_invoice_id')->nullable()->after('stripe_checkout_session_id'); + $table->index('stripe_invoice_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugin_licenses', function (Blueprint $table) { + $table->dropIndex(['stripe_invoice_id']); + $table->dropColumn('stripe_invoice_id'); + }); + } +}; diff --git a/database/migrations/2026_01_22_151050_add_stripe_checkout_session_to_carts_table.php b/database/migrations/2026_01_22_151050_add_stripe_checkout_session_to_carts_table.php new file mode 100644 index 00000000..aa0cd5c2 --- /dev/null +++ b/database/migrations/2026_01_22_151050_add_stripe_checkout_session_to_carts_table.php @@ -0,0 +1,26 @@ +string('stripe_checkout_session_id')->nullable()->after('session_id')->index(); + $table->timestamp('completed_at')->nullable()->after('expires_at'); + }); + } + + public function down(): void + { + Schema::table('carts', function (Blueprint $table) { + $table->dropColumn(['stripe_checkout_session_id', 'completed_at']); + }); + } +}; diff --git a/resources/views/cart/success.blade.php b/resources/views/cart/success.blade.php index 38fb78e7..89daca88 100644 --- a/resources/views/cart/success.blade.php +++ b/resources/views/cart/success.blade.php @@ -22,6 +22,7 @@ try { const response = await fetch('{{ route('cart.status', ['sessionId' => $sessionId]) }}'); const data = await response.json(); + console.log(data); this.status = data.status; if (data.licenses) { this.licenses = data.licenses; @@ -77,7 +78,7 @@ class="rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-gr - + View Plugin diff --git a/resources/views/livewire/plugin-directory.blade.php b/resources/views/livewire/plugin-directory.blade.php index 01b71ab3..d6feb06d 100644 --- a/resources/views/livewire/plugin-directory.blade.php +++ b/resources/views/livewire/plugin-directory.blade.php @@ -1,4 +1,4 @@ -
+
{{-- Header --}}
@@ -113,7 +113,7 @@ class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-600" @if ($view === 'bundles') {{-- Bundles Grid --}} @if ($bundles->count() > 0) -
+
@foreach ($bundles as $bundle) @endforeach @@ -145,7 +145,7 @@ class="mt-4 inline-flex items-center gap-2 rounded-lg bg-gray-200 px-4 py-2 text @else {{-- Plugins Grid --}} @if ($plugins->count() > 0) -
+
@foreach ($plugins as $plugin) @endforeach diff --git a/resources/views/plugin-license.blade.php b/resources/views/plugin-license.blade.php index f7f84ecb..55c3f687 100644 --- a/resources/views/plugin-license.blade.php +++ b/resources/views/plugin-license.blade.php @@ -1,6 +1,6 @@
diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index 52b4be8e..e83fdb93 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -1,6 +1,6 @@
diff --git a/resources/views/plugins.blade.php b/resources/views/plugins.blade.php index 6488c409..448aa370 100644 --- a/resources/views/plugins.blade.php +++ b/resources/views/plugins.blade.php @@ -1,5 +1,5 @@ -
+
{{-- Hero Section --}}
@@ -161,7 +161,7 @@ class="flex items-center justify-center gap-2.5 rounded-xl bg-gray-200 px-6 py-4

{{-- Plugin Cards Grid --}} -
+
@forelse ($featuredPlugins as $plugin) @empty @@ -219,7 +219,7 @@ class="flex items-center justify-center gap-2.5 rounded-xl bg-gray-200 px-6 py-4

{{-- Bundle Cards Grid --}} -
+
@foreach ($bundles as $bundle) @endforeach @@ -257,7 +257,7 @@ class="flex items-center justify-center gap-2.5 rounded-xl bg-gray-200 px-6 py-4

{{-- Plugin Cards Grid --}} -
+
@forelse ($latestPlugins as $plugin) @empty diff --git a/routes/web.php b/routes/web.php index d6c162f7..28691ebd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -284,7 +284,7 @@ Route::delete('cart/bundle/{bundle:slug}', [CartController::class, 'removeBundle'])->name('cart.bundle.remove'); Route::delete('cart/clear', [CartController::class, 'clear'])->name('cart.clear'); Route::get('cart/count', [CartController::class, 'count'])->name('cart.count'); - Route::post('cart/checkout', [CartController::class, 'checkout'])->name('cart.checkout'); + Route::match(['get', 'post'], 'cart/checkout', [CartController::class, 'checkout'])->name('cart.checkout'); Route::get('cart/success', [CartController::class, 'success'])->name('cart.success')->middleware('auth'); Route::get('cart/status/{sessionId}', [CartController::class, 'status'])->name('cart.status')->middleware('auth'); Route::get('cart/cancel', [CartController::class, 'cancel'])->name('cart.cancel');