PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.4
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Modules / PaymentMethods / PayPalGateway / Processor.php

Processor.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.4, at app/Modules/PaymentMethods/PayPalGateway/Processor.php

467 lines 21.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\PayPalGateway;
4
5 use FluentCart\App\Events\Subscription\SubscriptionActivated;
6 use FluentCart\App\Helpers\Helper;
7 use FluentCart\App\Helpers\Status;
8 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
9 use FluentCart\App\Helpers\StatusHelper;
10 use FluentCart\App\Models\Order;
11 use FluentCart\App\Models\OrderTransaction;
12 use FluentCart\App\Models\Subscription;
13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
14 use FluentCart\App\Services\DateTime\DateTime;
15 use FluentCart\App\Services\Payments\PaymentHelper;
16 use FluentCart\App\Services\Payments\PaymentInstance;
17 use FluentCart\Framework\Support\Arr;
18
19 class Processor
20 {
21 public function handleSinglePayment(PaymentInstance $paymentInstance, $args = [])
22 {
23 $transaction = $paymentInstance->transaction;
24 $order = $paymentInstance->order;
25
26 $itemsSubTotal = 0;
27 $formattedItems = [];
28
29 foreach ($order->order_items as $item) {
30 $quantity = $item->quantity ?? 1;
31 $perQuantity = $this->toDecimal($item->line_total / $quantity);
32 $title = $item->post_title . ' ' . $item->title;
33
34 $formattedItems[] = [
35 'name' => strlen($title) > 127 ? substr($title, 0, 120) . '...' : $title,
36 'description' => strlen($title) > 4000 ? substr($title, 0, 3997) . '...' : $title,
37 'unit_amount' => [
38 'currency_code' => $transaction->currency,
39 'value' => number_format($perQuantity, 2, '.', ''),
40 ],
41 'quantity' => $quantity,
42 ];
43
44 $itemsSubTotal += $perQuantity * $quantity;
45 }
46
47 $chargingAmount = $this->toDecimal($transaction->total);
48 $pushedTotal = $itemsSubTotal;
49
50
51 // Learn more at: https://developer.paypal.com/docs/api/orders/v2/#definition-purchase_unit
52 $purchaseUnits = [
53 'reference_id' => $transaction->uuid, // This is the order UUID
54 'amount' => [ // https://developer.paypal.com/docs/api/orders/v2/#definition-amount_breakdown
55 'currency_code' => $transaction->currency,
56 'value' => number_format($chargingAmount, 2, '.', ''),
57 'breakdown' => [
58 'item_total' => [
59 'currency_code' => $transaction->currency,
60 'value' => number_format($itemsSubTotal, 2, '.', ''),
61 ]
62 ]
63 ],
64 'items' => $formattedItems
65 ];
66
67 // if there is no defined credential for specific mode,
68 // then add merchantId as it's a partner app connection
69 $payPalSettings = new PayPalSettingsBase();
70 if ($merchantId = $payPalSettings->getMerchantId()) {
71 if ($payPalSettings->getProviderType() === 'api_keys') {
72 $purchaseUnits['payee'] = [
73 "merchant_id" => $merchantId
74 ];
75 }
76 }
77
78 if ($order->shipping_total > 0) {
79 $shippingAmount = $this->toDecimal($order->shipping_total);
80 $purchaseUnits['amount']['breakdown']['shipping'] = [
81 'currency_code' => $transaction->currency,
82 'value' => number_format($shippingAmount, 2, '.', ''),
83 ];
84 $pushedTotal += $shippingAmount;
85 }
86
87
88
89 $taxBehavior = (int) $order->tax_behavior;
90 $exclusiveTaxTotal = (int) $order->getMeta('exclusive_tax_total');
91 $storeTaxBehavior = (int) $order->getMeta('store_tax_behavior');
92 $feeTax = (int) $order->getMeta('fee_tax');
93
94 // Fallback: if meta missing (old order), use tax_behavior as store_tax_behavior
95 if (empty($storeTaxBehavior) && $taxBehavior > 0) {
96 $storeTaxBehavior = $taxBehavior;
97 }
98
99 if ($taxBehavior === 1) {
100 // Pure exclusive: all tax is additive on top of item prices.
101 // tax_total includes product + fee tax (both exclusive).
102 $taxTotal = $this->toDecimal($order->tax_total) + $this->toDecimal($order->shipping_tax);
103 } elseif ($taxBehavior === 3) {
104 // Mixed: only exclusive product + fee tax is additive; shipping conditional.
105 $taxTotal = $this->toDecimal($exclusiveTaxTotal);
106 if ($storeTaxBehavior === 1) {
107 // Store is exclusive: fees and shipping are also exclusive.
108 $taxTotal += $this->toDecimal($order->shipping_tax);
109 $taxTotal += $this->toDecimal($feeTax);
110 }
111 } else {
112 $taxTotal = 0;
113 }
114
115 if ($taxTotal > 0) {
116 $purchaseUnits['amount']['breakdown']['tax_total'] = [
117 'currency_code' => $transaction->currency,
118 'value' => number_format($taxTotal, 2, '.', ''),
119 ];
120 $pushedTotal += $taxTotal;
121 }
122
123 if ($chargingAmount < $pushedTotal) {
124 $discount = $pushedTotal - $chargingAmount;
125 $purchaseUnits['amount']['breakdown']['discount'] = [
126 'currency_code' => $transaction->currency,
127 'value' => number_format($discount, 2, '.', ''),
128 ];
129 } else if ($chargingAmount > $pushedTotal) {
130 $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal;
131 $formattedItems[] = [
132 'name' => __('Adjustment Amount', 'fluent-cart'),
133 'unit_amount' => [
134 'currency_code' => $transaction->currency,
135 'value' => number_format($extraChargeNeedToBeAdded, 2, '.', ''),
136 ],
137 'quantity' => 1,
138 ];
139
140 $purchaseUnits['items'] = $formattedItems;
141
142 //now the total amount need to be adjusted with item total value
143 $adjustedItemTotal = $itemsSubTotal + $extraChargeNeedToBeAdded;
144 $purchaseUnits['amount']['breakdown']['item_total']['value'] = number_format($adjustedItemTotal, 2, '.', '');
145 }
146
147 // No PayPal-Request-Id on order-create: it reserves intent, it does not move money
148 // (capture runs client-side against one order id). A transaction-lifetime id
149 // outlives the 3h PayPal order and replays a reversed order id at the buyer.
150 // See .claude/skills/coding-rules/payment-idempotency.md.
151 $paypalOrder = API::createOrder($purchaseUnits);
152
153 if (is_wp_error($paypalOrder)) {
154 return $paypalOrder;
155 }
156
157 $paypalOrderId = Arr::get($paypalOrder, 'id');
158
159 $transaction->update([
160 'meta' => array_merge($transaction->meta ?? [], ['paypal_order_id' => $paypalOrderId])
161 ]);
162
163 return [
164 'nextAction' => 'paypal',
165 'actionName' => 'custom',
166 'status' => 'success',
167 'data' => [
168 'order' => [
169 'uuid' => $order->uuid,
170 ],
171 'transaction' => [
172 'uuid' => $transaction->uuid,
173 ]
174 ],
175 'message' => __('Order has been placed successfully', 'fluent-cart'),
176 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
177 'response' => [
178 'paypalOrderId' => $paypalOrderId,
179 ]
180 ];
181 }
182
183 public function handleSubscriptionPaymentFromPaymentInstance(PaymentInstance $paymentInstance, $args = [])
184 {
185 $orderType = $paymentInstance->order->type;
186 $subscription = $paymentInstance->subscription;
187 $feeTotal = $orderType !== 'renewal' ? (int)$paymentInstance->order->fee_total : 0;
188 $initialAmount = (int)$subscription->signup_fee + $paymentInstance->getExtraAddonAmount() + $feeTotal;
189 $status = Status::SUBSCRIPTION_INTENDED;
190
191 if ($orderType == 'renewal') {
192 $requiredBillTimes = $subscription->getRequiredBillTimes();
193
194 if ($requiredBillTimes === -1) {
195 return new \WP_Error('already_completed', __('Invalid bill times for the subscription.', 'fluent-cart'));
196 }
197
198 $data = [
199 'order_id' => $subscription->parent_order_id,
200 'product_id' => $subscription->product_id,
201 'variation_id' => $subscription->variation_id,
202 'trial_days' => $subscription->getReactivationTrialDays(), // trial days for reactivation
203 'billing_interval' => $subscription->billing_interval,
204 'currency' => $paymentInstance->order->currency,
205 'interval_count' => 1, // 1
206 'recurring_amount' => $subscription->getCurrentRenewalAmount(), // default recurring total in cents
207 'signup_fee' => 0, // default setup fee in cents ($0.00)
208 'bill_times' => $requiredBillTimes, // 0 for unlimited
209 ];
210 $status = $subscription->status;
211 } else {
212 $data = [
213 'order_id' => $subscription->parent_order_id,
214 'product_id' => $subscription->product_id,
215 'variation_id' => $subscription->variation_id,
216 'trial_days' => $subscription->trial_days,
217 'billing_interval' => $subscription->billing_interval,
218 'currency' => $paymentInstance->order->currency,
219 'interval_count' => 1, // 1
220 'recurring_amount' => $subscription->recurring_total, // default recurring total in cents
221 'signup_fee' => $initialAmount, // default setup fee in cents ($0.00)
222 'bill_times' => $subscription->getInitialRemoteBillTimes(), // 0 for unlimited; simulated-trial first installment excluded
223 ];
224
225 }
226
227 $paypalPlan = PayPalHelper::getPayPalPlan($data);
228
229 if (is_wp_error($paypalPlan)) {
230 return $paypalPlan;
231 }
232
233 $subscription->update([
234 'status' => $status,
235 'vendor_plan_id' => Arr::get($paypalPlan, 'id'),
236 'vendor_response' => json_encode($paypalPlan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
237 ]);
238
239 return [
240 'status' => 'success',
241 'nextAction' => 'paypal',
242 'actionName' => 'custom',
243 'message' => __('Order has been placed successfully', 'fluent-cart'),
244 'data' => [
245 'order' => [
246 'uuid' => $paymentInstance->order->uuid,
247 ],
248 'transaction' => [
249 'uuid' => $paymentInstance->transaction->uuid,
250 ],
251 'subscription' => [
252 'uuid' => $subscription->uuid,
253 ]
254 ],
255 'response' => [
256 'planId' => Arr::get($paypalPlan, 'id')
257 ]
258 ];
259 }
260
261 /**
262 * Confirm payment success
263 * Currently used by:
264 * @param OrderTransaction $transaction
265 * @param array $args
266 * @param array $transactionArgs
267 * string vendor_charge_id - The intent_id from paypal
268 * string total - The amount charged in cents
269 * string status - The status of the transaction ('succeeded', 'pending', etc.))
270 * array payer - The payer information from PayPal.
271 * array payment_source - The payment source information from PayPal.
272 *
273 * @param string $args ['intent_id'] - The intent ID from Stripe.
274 * @return Order
275 */
276 public function confirmPaymentSuccessByCharge(OrderTransaction $transaction, $transactionArgs = [])
277 {
278 $transactionUpdateData = array_filter([
279 'vendor_charge_id' => Arr::get($transactionArgs, 'vendor_charge_id', ''),
280 'payment_method' => 'paypal',
281 'status' => Arr::get($transactionArgs, 'status', Status::TRANSACTION_SUCCEEDED),
282 'total' => (int)Arr::get($transactionArgs, 'total', 0),
283 // payment_method_type: this is the intent ID. We may need that later In case we don't have the vendor_charge_id
284 'payment_method_type' => Arr::get($transactionArgs, 'payment_method_type', ''),
285 ]);
286
287 $order = Order::query()->where('id', $transaction->order_id)->first();
288 // in race conditions between webhook and AJAX confirmation
289 $transaction = OrderTransaction::query()->where('id', $transaction->id)->first();
290 if ($transaction->status === Status::TRANSACTION_SUCCEEDED || $transactionUpdateData['status'] !== Status::TRANSACTION_SUCCEEDED) {
291 if (!$transaction->vendor_charge_id && !empty($transactionUpdateData['vendor_charge_id'])) {
292 $transaction->update(['vendor_charge_id' => $transactionUpdateData['vendor_charge_id']]);
293 }
294 return $order; // already confirmed or not needed to confirm
295 }
296
297 // handle payment source
298 $cardData = Arr::get($transactionArgs, 'payment_source.card', []);
299 if ($cardData) {
300 $transactionUpdateData['card_last_4'] = strlen(Arr::get($cardData, 'last_digits')) > 4 ? substr(Arr::get($cardData, 'last_digits'), -4) : Arr::get($cardData, 'last_digits');
301 $transactionUpdateData['card_brand'] = Arr::get($cardData, 'brand');
302 }
303
304 $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', []));
305
306 $transaction->fill($transactionUpdateData);
307 $transaction->save();
308
309 fluent_cart_add_log(__('PayPal Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from PayPal. Transaction ID: ', 'fluent-cart') . Arr::get($transactionArgs, 'vendor_charge_id', ''), 'info', [
310 'module_name' => 'order',
311 'module_id' => $order->id,
312 ]);
313
314 // Maybe we have to save the billing details
315
316 // We are assuming. This is only for one time payment. No subscription or renewal will be here!
317
318 return (new StatusHelper($order))->syncOrderStatuses($transaction);
319 }
320
321
322 // This should be only used from the ajax call for the very first time subscription activation
323 public function activateSubscription($paypalSubscription, OrderTransaction $transaction, $subscriptionModel = null)
324 {
325 $order = $transaction->order;
326
327 if (!$subscriptionModel) {
328 $subscriptionModel = Subscription::query()->where('id', $transaction->subscription_id)->first();
329 }
330
331 if (!$subscriptionModel || $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
332 return $subscriptionModel; // already active or invalid
333 }
334
335 // Verify the PayPal subscription's plan matches the expected plan
336 if ($subscriptionModel->vendor_plan_id) {
337 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
338 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
339 fluent_cart_add_log(
340 __('PayPal Subscription Plan Mismatch', 'fluent-cart'),
341 sprintf(
342 /* translators: %1$s: expected plan ID, %2$s: received plan ID */
343 __('PayPal subscription plan mismatch. Expected: %1$s, Received: %2$s. Subscription not activated.', 'fluent-cart'),
344 $subscriptionModel->vendor_plan_id,
345 $paypalPlanId
346 ),
347 'error',
348 [
349 'module_name' => 'order',
350 'module_id' => $order->id,
351 'log_type' => 'api'
352 ]
353 );
354 return $subscriptionModel; // Do not activate
355 }
356 }
357
358 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time') ?? null;
359 if ($nextBillingDate) {
360 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($nextBillingDate));
361 } else {
362 // calculate the next billing date, as PayPal has not been charged yet
363 $billingIntervalDays = PaymentHelper::getIntervalDays($subscriptionModel->billing_interval) + (int) $subscriptionModel->trial_days;
364 $nextBillingDate = DateTime::gmtNow()->addDays($billingIntervalDays)->format('Y-m-d H:i:s');
365 }
366
367 $subscriptionUpdateData = array_filter([
368 'next_billing_date' => $nextBillingDate,
369 'status' => Status::SUBSCRIPTION_ACTIVE,
370 'vendor_subscription_id' => $paypalSubscription['id'],
371 'vendor_customer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id', ''),
372 'current_payment_method' => 'paypal',
373 ]);
374
375 $transactionUpdateData = [];
376 $lastTransactionAmount = Helper::toCent(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.value', 0));
377
378 if (($lastTransactionAmount && $transaction->total == $lastTransactionAmount) || $transaction->total == 0) {
379 $transactionUpdateData = [
380 'order_id' => $order->id,
381 'status' => Status::TRANSACTION_SUCCEEDED,
382 'payment_method' => 'paypal'
383 ];
384 }
385
386 if ($transactionUpdateData) {
387 $transactionUpdateData = array_filter([
388 'order_id' => $order->id,
389 'status' => Status::TRANSACTION_SUCCEEDED,
390 'payment_method' => 'paypal',
391 ]);
392
393 $transaction->fill($transactionUpdateData);
394 $transaction->save();
395 }
396
397
398 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
399 $subscriptionUpdateData['canceled_at'] = null;
400 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
401 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
402 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
403 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
404 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
405 ]);
406
407 SubscriptionService::recordManualRenewal($subscriptionModel, $transaction, [
408 'billing_info' => $billingInfo,
409 'subscription_args' => $subscriptionUpdateData
410 ]);
411
412 } else {
413 // This can be a trialing subscription
414 if ($subscriptionModel->trial_days > 0) {
415 $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING;
416 }
417
418 // Atomic conditional update: only the caller that actually flips status out of a
419 // pre-active state wins the transition, so concurrent AJAX-return + webhook calls
420 // can't both dispatch SubscriptionActivated.
421 $activatedNow = (bool) Subscription::query()
422 ->where('id', $subscriptionModel->id)
423 ->whereNotIn('status', [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
424 ->update($subscriptionUpdateData);
425
426 $subscriptionModel->fill($subscriptionUpdateData);
427
428 // updateMeta() is check-then-create with no unique (subscription_id, meta_key)
429 // constraint — gate it behind $activatedNow too, else a losing concurrent caller
430 // still inserts a duplicate active_payment_method meta row.
431 if ($activatedNow) {
432 $subscriptionModel->updateMeta('active_payment_method', PaymentHelper::parsePaymentMethodDetails('paypal', [
433 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
434 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
435 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
436 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
437 ]));
438
439 if (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status) {
440 (new SubscriptionActivated($subscriptionModel, $order, $order->customer))->dispatch();
441 }
442 }
443 }
444
445 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
446 (new StatusHelper($order))->syncOrderStatuses($transaction);
447 } else {
448 fluent_cart_add_log('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.', [
449 'module_name' => 'order',
450 'module_id' => $order->id,
451 ]);
452 if ($subscriptionModel) {
453 $subscriptionModel->addLog('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.');
454 }
455 }
456
457 return $subscriptionModel;
458 }
459
460
461 private function toDecimal($cents)
462 {
463 return Helper::toDecimalWithoutComma($cents);
464 }
465
466 }
467