feat: enable paypal lifetime checkout and harden billing mode/payment requests
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", '');
|
||||
|
||||
@@ -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 @@
|
||||
<div class="hidden text-xs text-gray-500" id="lifetime-pay-note"></div>
|
||||
<button type="button"
|
||||
id="lifetime-pay-btn"
|
||||
data-paypal-enabled="{{ $paypalLifetimeUrl ? 'true' : 'false' }}"
|
||||
data-paypal-enabled="{{ $paypalEnabled ? 'true' : 'false' }}"
|
||||
data-qris-enabled="{{ $canQris ? 'true' : 'false' }}"
|
||||
class="force-white w-full py-2.5 rounded-xl border border-brand-ocean/60 text-brand-ocean font-semibold text-center block hover:bg-brand-ocean/10">
|
||||
Pay now
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden">
|
||||
<a href="{{ $paypalLifetimeUrl ?: '#' }}"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
data-paypal-lifetime="true"
|
||||
class="{{ $paypalLifetimeUrl ? '' : 'pointer-events-none' }}">
|
||||
PayPal Lifetime
|
||||
</a>
|
||||
<button type="button" data-paypal-plan="personal_lifetime" data-original="Get Lifetime Access"></button>
|
||||
<button type="button"
|
||||
data-qris-plan="personal_lifetime" data-original="Get Lifetime Access">
|
||||
QRIS Lifetime
|
||||
@@ -537,7 +529,7 @@
|
||||
lifetimePay.addEventListener('click', async () => {
|
||||
if (lifetimePay.disabled) return;
|
||||
if (currency === 'USD') {
|
||||
const paypal = document.querySelector('[data-paypal-lifetime="true"]');
|
||||
const paypal = document.querySelector('[data-paypal-plan="personal_lifetime"]');
|
||||
paypal?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user