From 3d41cea1587104d522b22011931b2839941247da Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sat, 14 Feb 2026 23:10:08 +0700 Subject: [PATCH] feat: enable paypal lifetime checkout and harden billing mode/payment requests --- .../Controllers/Billing/PakasirController.php | 10 +- .../Controllers/Billing/PayPalController.php | 259 +++++++++++++++++- .../Http/Controllers/Web/SiteController.php | 22 +- app/resources/views/site/pricing.blade.php | 14 +- 4 files changed, 281 insertions(+), 24 deletions(-) diff --git a/app/app/Http/Controllers/Billing/PakasirController.php b/app/app/Http/Controllers/Billing/PakasirController.php index 2dff61a..a250b80 100644 --- a/app/app/Http/Controllers/Billing/PakasirController.php +++ b/app/app/Http/Controllers/Billing/PakasirController.php @@ -77,10 +77,14 @@ class PakasirController extends Controller ]; $endpoint = $apiBase.'/api/transactioncreate/qris'; - $res = Http::timeout($timeout)->post($endpoint, $payload); + $res = Http::asForm()->timeout($timeout)->post($endpoint, $payload); - if (!$res->ok()) { - Log::warning('Pakasir create transaction failed', ['body' => $res->body()]); + if (!$res->successful()) { + Log::warning('Pakasir create transaction failed', [ + 'status' => $res->status(), + 'endpoint' => $endpoint, + 'body' => $res->body(), + ]); return response()->json(['error' => 'pakasir_create_failed'], 502); } diff --git a/app/app/Http/Controllers/Billing/PayPalController.php b/app/app/Http/Controllers/Billing/PayPalController.php index 36d49e8..c725737 100644 --- a/app/app/Http/Controllers/Billing/PayPalController.php +++ b/app/app/Http/Controllers/Billing/PayPalController.php @@ -23,7 +23,7 @@ class PayPalController extends Controller public function createSubscription(Request $request): RedirectResponse|JsonResponse { $data = $request->validate([ - 'plan_code' => 'required|string|in:personal_monthly,personal_annual', + 'plan_code' => 'required|string|in:personal_monthly,personal_annual,personal_lifetime', ]); $user = $request->user(); @@ -31,10 +31,15 @@ class PayPalController extends Controller return response()->json(['error' => 'auth_required'], 401); } - $mode = $this->billingMode(); + $mode = $this->resolvePaypalMode($this->billingMode()); if (!$this->paypalConfigured($mode)) { return response()->json(['error' => 'paypal_not_configured'], 422); } + + if ($data['plan_code'] === 'personal_lifetime') { + return $this->createLifetimeOrder($request, $mode); + } + $planId = $this->resolvePlanId($data['plan_code'], $mode); if (!$planId) { return response()->json(['error' => 'paypal_plan_missing'], 422); @@ -142,6 +147,16 @@ class PayPalController extends Controller public function return(Request $request): RedirectResponse { + if ((string) $request->query('flow', '') === 'lifetime' && (string) $request->query('status', '') === 'success') { + $token = trim((string) $request->query('token', '')); + if ($token !== '') { + $captured = $this->captureLifetimeOrder($token, $request->user()?->id); + if (!$captured) { + return redirect()->route('dashboard.billing', ['status' => 'error']); + } + } + } + $status = (string) $request->query('status', 'success'); return redirect()->route('dashboard.billing', ['status' => $status]); } @@ -193,13 +208,11 @@ class PayPalController extends Controller { $type = (string) ($payload['event_type'] ?? ''); $resource = $payload['resource'] ?? []; - $subscriptionId = (string) ($resource['id'] ?? $resource['subscription_id'] ?? ''); - - if ($subscriptionId === '') { - return false; - } - if ($type === 'BILLING.SUBSCRIPTION.ACTIVATED') { + $subscriptionId = (string) ($resource['id'] ?? $resource['subscription_id'] ?? ''); + if ($subscriptionId === '') { + return false; + } $sub = Subscription::firstOrNew([ 'provider' => 'paypal', 'provider_ref' => $subscriptionId, @@ -226,6 +239,10 @@ class PayPalController extends Controller } if (in_array($type, ['BILLING.SUBSCRIPTION.CANCELLED', 'BILLING.SUBSCRIPTION.SUSPENDED'], true)) { + $subscriptionId = (string) ($resource['id'] ?? $resource['subscription_id'] ?? ''); + if ($subscriptionId === '') { + return false; + } $sub = Subscription::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->first(); if ($sub) { $sub->status = 'canceled'; @@ -235,6 +252,15 @@ class PayPalController extends Controller return true; } + if ($type === 'PAYMENT.CAPTURE.COMPLETED') { + $orderId = (string) ($resource['supplementary_data']['related_ids']['order_id'] ?? ''); + if ($orderId === '') { + return false; + } + + return $this->markLifetimeOrderPaid($orderId, $payload); + } + return false; } @@ -265,6 +291,20 @@ class PayPalController extends Controller return (int) round($plan->amount / $rate); } + private function resolvePlanAmountUsdValue(string $planCode): string + { + $plan = PricingPlan::where('code', $planCode)->first(); + if (!$plan) { + return '0.00'; + } + $rate = (int) config('dewemoji.pricing.usd_rate', 15000); + if ($rate <= 0) { + return '0.00'; + } + + return number_format($plan->amount / $rate, 2, '.', ''); + } + private function getAccessToken(string $mode): ?string { $clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id"); @@ -282,7 +322,7 @@ class PayPalController extends Controller 'grant_type' => 'client_credentials', ]); - if (!$res->ok()) { + if (!$res->successful()) { return null; } @@ -305,6 +345,20 @@ class PayPalController extends Controller return (string) ($settings->get('billing_mode', config('dewemoji.billing.mode', 'sandbox')) ?: 'sandbox'); } + private function resolvePaypalMode(string $preferred): string + { + if ($this->paypalConfigured($preferred)) { + return $preferred; + } + + $fallback = $preferred === 'live' ? 'sandbox' : 'live'; + if ($this->paypalConfigured($fallback)) { + return $fallback; + } + + return $preferred; + } + private function verifySignature(string $mode, string $webhookId, array $payload, Request $request): bool { $token = $this->getAccessToken($mode); @@ -333,4 +387,191 @@ class PayPalController extends Controller return $res->ok() && $res->json('verification_status') === 'SUCCESS'; } + + private function createLifetimeOrder(Request $request, string $mode): JsonResponse + { + $user = $request->user(); + if (!$user) { + return response()->json(['error' => 'auth_required'], 401); + } + + $token = $this->getAccessToken($mode); + if (!$token) { + return response()->json(['error' => 'paypal_auth_failed'], 502); + } + + $amountUsd = $this->resolvePlanAmountUsd('personal_lifetime'); + $amountUsdValue = $this->resolvePlanAmountUsdValue('personal_lifetime'); + if ($amountUsd <= 0 || (float) $amountUsdValue <= 0) { + return response()->json(['error' => 'invalid_plan_amount'], 422); + } + + $appUrl = rtrim(config('app.url'), '/'); + $payload = [ + 'intent' => 'CAPTURE', + 'purchase_units' => [ + [ + 'reference_id' => 'dewemoji-personal-lifetime', + 'description' => 'Dewemoji Personal Lifetime', + 'amount' => [ + 'currency_code' => 'USD', + 'value' => $amountUsdValue, + ], + ], + ], + 'application_context' => [ + 'brand_name' => 'Dewemoji', + 'locale' => 'en-US', + 'return_url' => $appUrl.'/billing/paypal/return?flow=lifetime&status=success', + 'cancel_url' => $appUrl.'/billing/paypal/return?flow=lifetime&status=cancel', + ], + ]; + + $apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base"); + $res = Http::withToken($token) + ->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10)) + ->post(rtrim((string) $apiBase, '/').'/v2/checkout/orders', $payload); + + $body = $res->json(); + $orderId = (string) ($body['id'] ?? ''); + $approveUrl = collect($body['links'] ?? [])->firstWhere('rel', 'approve')['href'] ?? null; + if ($orderId === '' || !$approveUrl) { + Log::warning('PayPal create lifetime order failed', [ + 'status' => $res->status(), + 'body' => $res->body(), + ]); + + return response()->json(['error' => 'paypal_invalid_response'], 502); + } + + Order::where('user_id', $user->id) + ->where('provider', 'paypal') + ->where('type', 'one_time') + ->where('status', 'pending') + ->update(['status' => 'cancelled']); + Payment::where('user_id', $user->id) + ->where('provider', 'paypal') + ->where('type', 'one_time') + ->where('status', 'pending') + ->update(['status' => 'cancelled']); + + $order = Order::create([ + 'user_id' => $user->id, + 'plan_code' => 'personal_lifetime', + 'type' => 'one_time', + 'currency' => 'USD', + 'amount' => $amountUsd, + 'status' => 'pending', + 'provider' => 'paypal', + 'provider_ref' => $orderId, + ]); + + Payment::create([ + 'user_id' => $user->id, + 'order_id' => $order->id, + 'provider' => 'paypal', + 'type' => 'one_time', + 'plan_code' => 'personal_lifetime', + 'currency' => 'USD', + 'amount' => $amountUsd, + 'status' => 'pending', + 'provider_ref' => $orderId, + 'raw_payload' => $body, + ]); + + return response()->json(['approve_url' => $approveUrl]); + } + + private function captureLifetimeOrder(string $orderId, ?int $userId = null): bool + { + $mode = $this->resolvePaypalMode($this->billingMode()); + $token = $this->getAccessToken($mode); + if (!$token) { + return false; + } + + $order = Order::where('provider', 'paypal') + ->where('provider_ref', $orderId) + ->where('type', 'one_time') + ->first(); + if (!$order) { + return false; + } + if ($userId !== null && (int) $order->user_id !== $userId) { + return false; + } + if ($order->status === 'paid') { + return true; + } + + $apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base"); + $res = Http::withToken($token) + ->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10)) + ->post(rtrim((string) $apiBase, '/').'/v2/checkout/orders/'.$orderId.'/capture'); + + if ($res->successful()) { + return $this->markLifetimeOrderPaid($orderId, $res->json() ?? []); + } + + $status = $res->status(); + if ($status === 422) { + $check = Http::withToken($token) + ->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10)) + ->get(rtrim((string) $apiBase, '/').'/v2/checkout/orders/'.$orderId); + if ($check->successful() && strtoupper((string) $check->json('status')) === 'COMPLETED') { + return $this->markLifetimeOrderPaid($orderId, $check->json() ?? []); + } + } + + Log::warning('PayPal lifetime capture failed', [ + 'order_id' => $orderId, + 'status' => $status, + 'body' => $res->body(), + ]); + + return false; + } + + private function markLifetimeOrderPaid(string $orderId, array $rawPayload = []): bool + { + $order = Order::where('provider', 'paypal') + ->where('provider_ref', $orderId) + ->where('type', 'one_time') + ->first(); + if (!$order) { + return false; + } + + $order->status = 'paid'; + $order->save(); + + $payment = Payment::where('order_id', $order->id) + ->where('provider', 'paypal') + ->where('type', 'one_time') + ->first(); + if ($payment) { + $payment->status = 'paid'; + if (!empty($rawPayload)) { + $payment->raw_payload = $rawPayload; + } + $payment->save(); + } + + Subscription::updateOrCreate([ + 'provider' => 'paypal', + 'provider_ref' => $orderId, + ], [ + 'user_id' => $order->user_id, + 'plan' => 'personal_lifetime', + 'status' => 'active', + 'started_at' => now(), + 'expires_at' => null, + 'next_renewal_at' => null, + 'canceled_at' => null, + ]); + + User::where('id', $order->user_id)->update(['tier' => 'personal']); + + return true; + } } diff --git a/app/app/Http/Controllers/Web/SiteController.php b/app/app/Http/Controllers/Web/SiteController.php index 108fe32..460d57d 100644 --- a/app/app/Http/Controllers/Web/SiteController.php +++ b/app/app/Http/Controllers/Web/SiteController.php @@ -29,7 +29,17 @@ class SiteController extends Controller private function billingMode(): string { $settings = app(SettingsService::class); - return (string) ($settings->get('billing_mode', config('dewemoji.billing.mode', 'sandbox')) ?: 'sandbox'); + $preferred = (string) ($settings->get('billing_mode', config('dewemoji.billing.mode', 'sandbox')) ?: 'sandbox'); + if ($this->paypalConfiguredMode($preferred)) { + return $preferred; + } + + $fallback = $preferred === 'live' ? 'sandbox' : 'live'; + if ($this->paypalConfiguredMode($fallback)) { + return $fallback; + } + + return $preferred; } public function home(Request $request): View @@ -158,6 +168,16 @@ class SiteController extends Controller } private function paypalEnabled(string $mode): bool + { + if ($this->paypalConfiguredMode($mode)) { + return true; + } + + $fallback = $mode === 'live' ? 'sandbox' : 'live'; + return $this->paypalConfiguredMode($fallback); + } + + private function paypalConfiguredMode(string $mode): bool { $enabled = (bool) config('dewemoji.billing.providers.paypal.enabled', false); $clientId = (string) config("dewemoji.billing.providers.paypal.{$mode}.client_id", ''); diff --git a/app/resources/views/site/pricing.blade.php b/app/resources/views/site/pricing.blade.php index 34eb9ac..cfa0ff9 100644 --- a/app/resources/views/site/pricing.blade.php +++ b/app/resources/views/site/pricing.blade.php @@ -132,9 +132,6 @@ $annualUsd = $pricing['personal_annual']['usd'] ?? 20; $lifetimeUsd = $pricing['personal_lifetime']['usd'] ?? 60; $qrisUrl = $payments['qris_url'] ?? ''; - $paypalUrl = $payments['paypal_url'] ?? ''; - $paypalJoiner = $paypalUrl && str_contains($paypalUrl, '?') ? '&' : '?'; - $paypalLifetimeUrl = $paypalUrl ? $paypalUrl.$paypalJoiner.'plan=personal_lifetime' : ''; $canQris = $pakasirEnabled ?? false; $paypalEnabled = $paypalEnabled ?? false; $paypalPlans = $paypalPlans ?? ['personal_monthly' => false, 'personal_annual' => false]; @@ -224,19 +221,14 @@