feat: enable paypal lifetime checkout and harden billing mode/payment requests

This commit is contained in:
Dwindi Ramadhana
2026-02-14 23:10:08 +07:00
parent c8230cb19d
commit 3d41cea158
4 changed files with 281 additions and 24 deletions

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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", '');

View File

@@ -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;
}