PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.21
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.21
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 trunk All 48 releases
fluent-cart / app / Modules / PaymentMethods / PayPalGateway / Processor.php

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

447 lines 19.7 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' => $perQuantity,
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' => $chargingAmount,
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' => $shippingAmount,
83 ];
84 $pushedTotal += $shippingAmount;
85 }
86
87
88
89 $taxTotal = $this->toDecimal($order->tax_total) + $this->toDecimal($order->shipping_tax);
90 if ($taxTotal > 0 && $order->tax_behavior == 1) {
91 $purchaseUnits['amount']['breakdown']['tax_total'] = [
92 'currency_code' => $transaction->currency,
93 'value' => number_format($taxTotal, 2, '.', ''),
94 ];
95 $pushedTotal += $taxTotal;
96 }
97
98 if ($chargingAmount < $pushedTotal) {
99 $discount = $pushedTotal - $chargingAmount;
100 $purchaseUnits['amount']['breakdown']['discount'] = [
101 'currency_code' => $transaction->currency,
102 'value' => number_format($discount, 2, '.', ''),
103 ];
104 } else if ($chargingAmount > $pushedTotal) {
105 $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal;
106 $formattedItems[] = [
107 'name' => __('Adjustment Amount', 'fluent-cart'),
108 'unit_amount' => [
109 'currency_code' => $transaction->currency,
110 'value' => number_format($extraChargeNeedToBeAdded, 2, '.', ''),
111 ],
112 'quantity' => 1,
113 ];
114
115 $purchaseUnits['items'] = $formattedItems;
116
117 //now the total amount need to be adjusted with item total value
118 $adjustedItemTotal = $itemsSubTotal + $extraChargeNeedToBeAdded;
119 $purchaseUnits['amount']['breakdown']['item_total']['value'] = number_format($adjustedItemTotal, 2, '.', '');
120 }
121
122 $paypalOrder = API::createOrder($purchaseUnits);
123
124 if (is_wp_error($paypalOrder)) {
125 return $paypalOrder;
126 }
127
128 $paypalOrderId = Arr::get($paypalOrder, 'id');
129
130 $transaction->update([
131 'meta' => array_merge($transaction->meta ?? [], ['paypal_order_id' => $paypalOrderId])
132 ]);
133
134 return [
135 'nextAction' => 'paypal',
136 'actionName' => 'custom',
137 'status' => 'success',
138 'data' => [
139 'order' => [
140 'uuid' => $order->uuid,
141 ],
142 'transaction' => [
143 'uuid' => $transaction->uuid,
144 ]
145 ],
146 'message' => __('Order has been placed successfully', 'fluent-cart'),
147 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
148 'response' => [
149 'paypalOrderId' => $paypalOrderId,
150 ]
151 ];
152 }
153
154 public function handleSubscriptionPaymentFromPaymentInstance(PaymentInstance $paymentInstance, $args = [])
155 {
156 $orderType = $paymentInstance->order->type;
157 $subscription = $paymentInstance->subscription;
158 $feeTotal = $orderType !== 'renewal' ? (int)$paymentInstance->order->fee_total : 0;
159 $initialAmount = (int)$subscription->signup_fee + $paymentInstance->getExtraAddonAmount() + $feeTotal;
160 $status = Status::SUBSCRIPTION_INTENDED;
161
162 if ($orderType == 'renewal') {
163 $requiredBillTimes = $subscription->getRequiredBillTimes();
164
165 if ($requiredBillTimes === -1) {
166 return new \WP_Error('already_completed', __('Invalid bill times for the subscription.', 'fluent-cart'));
167 }
168
169 $data = [
170 'order_id' => $subscription->parent_order_id,
171 'product_id' => $subscription->product_id,
172 'variation_id' => $subscription->variation_id,
173 'trial_days' => $subscription->getReactivationTrialDays(), // trial days for reactivation
174 'billing_interval' => $subscription->billing_interval,
175 'currency' => $paymentInstance->order->currency,
176 'interval_count' => 1, // 1
177 'recurring_amount' => $subscription->getCurrentRenewalAmount(), // default recurring total in cents
178 'signup_fee' => 0, // default setup fee in cents ($0.00)
179 'bill_times' => $requiredBillTimes, // 0 for unlimited
180 ];
181 $status = $subscription->status;
182 } else {
183 $data = [
184 'order_id' => $subscription->parent_order_id,
185 'product_id' => $subscription->product_id,
186 'variation_id' => $subscription->variation_id,
187 'trial_days' => $subscription->trial_days,
188 'billing_interval' => $subscription->billing_interval,
189 'currency' => $paymentInstance->order->currency,
190 'interval_count' => 1, // 1
191 'recurring_amount' => $subscription->recurring_total, // default recurring total in cents
192 'signup_fee' => $initialAmount, // default setup fee in cents ($0.00)
193 'bill_times' => (int)$subscription->bill_times, // 0 for unlimited
194 ];
195
196 }
197
198 $paypalPlan = PayPalHelper::getPayPalPlan($data);
199
200 if (is_wp_error($paypalPlan)) {
201 return $paypalPlan;
202 }
203
204 $subscription->update([
205 'status' => $status,
206 'vendor_plan_id' => Arr::get($paypalPlan, 'id'),
207 'vendor_response' => json_encode($paypalPlan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
208 ]);
209
210 return [
211 'status' => 'success',
212 'nextAction' => 'paypal',
213 'actionName' => 'custom',
214 'message' => __('Order has been placed successfully', 'fluent-cart'),
215 'data' => [
216 'order' => [
217 'uuid' => $paymentInstance->order->uuid,
218 ],
219 'transaction' => [
220 'uuid' => $paymentInstance->transaction->uuid,
221 ],
222 'subscription' => [
223 'uuid' => $subscription->uuid,
224 ]
225 ],
226 'response' => [
227 'planId' => Arr::get($paypalPlan, 'id')
228 ]
229 ];
230 }
231
232 /**
233 * Confirm payment success
234 * Currently used by:
235 * @param OrderTransaction $transaction
236 * @param array $args
237 * @param array $transactionArgs
238 * string vendor_charge_id - The intent_id from paypal
239 * string total - The amount charged in cents
240 * string status - The status of the transaction ('succeeded', 'pending', etc.))
241 * array payer - The payer information from PayPal.
242 * array payment_source - The payment source information from PayPal.
243 *
244 * @param string $args ['intent_id'] - The intent ID from Stripe.
245 * @return Order
246 */
247 public function confirmPaymentSuccessByCharge(OrderTransaction $transaction, $transactionArgs = [])
248 {
249 $transactionUpdateData = array_filter([
250 'vendor_charge_id' => Arr::get($transactionArgs, 'vendor_charge_id', ''),
251 'payment_method' => 'paypal',
252 'status' => Arr::get($transactionArgs, 'status', Status::TRANSACTION_SUCCEEDED),
253 'total' => (int)Arr::get($transactionArgs, 'total', 0),
254 // payment_method_type: this is the intent ID. We may need that later In case we don't have the vendor_charge_id
255 'payment_method_type' => Arr::get($transactionArgs, 'payment_method_type', ''),
256 ]);
257
258 $order = Order::query()->where('id', $transaction->order_id)->first();
259 // in race conditions between webhook and AJAX confirmation
260 $transaction = OrderTransaction::query()->where('id', $transaction->id)->first();
261 if ($transaction->status === Status::TRANSACTION_SUCCEEDED || $transactionUpdateData['status'] !== Status::TRANSACTION_SUCCEEDED) {
262 if (!$transaction->vendor_charge_id && !empty($transactionUpdateData['vendor_charge_id'])) {
263 $transaction->update(['vendor_charge_id' => $transactionUpdateData['vendor_charge_id']]);
264 }
265 return $order; // already confirmed or not needed to confirm
266 }
267
268 // handle payment source
269 $cardData = Arr::get($transactionArgs, 'payment_source.card', []);
270 if ($cardData) {
271 $transactionUpdateData['card_last_4'] = strlen(Arr::get($cardData, 'last_digits')) > 4 ? substr(Arr::get($cardData, 'last_digits'), -4) : Arr::get($cardData, 'last_digits');
272 $transactionUpdateData['card_brand'] = Arr::get($cardData, 'brand');
273 }
274
275 $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', []));
276
277 $transaction->fill($transactionUpdateData);
278 $transaction->save();
279
280 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', [
281 'module_name' => 'order',
282 'module_id' => $order->id,
283 ]);
284
285 // Maybe we have to save the billing details
286
287 // We are assuming. This is only for one time payment. No subscription or renewal will be here!
288
289 return (new StatusHelper($order))->syncOrderStatuses($transaction);
290 }
291
292
293 // This should be only used from the ajax call for the very first time subscription activation
294 public function activateSubscription($paypalSubscription, OrderTransaction $transaction, $subscriptionModel = null)
295 {
296 $order = $transaction->order;
297
298 if (!$subscriptionModel) {
299 $subscriptionModel = Subscription::query()->where('id', $transaction->subscription_id)->first();
300 }
301
302 if (!$subscriptionModel || $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
303 return $subscriptionModel; // already active or invalid
304 }
305
306 // Verify the PayPal subscription's plan matches the expected plan
307 if ($subscriptionModel->vendor_plan_id) {
308 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
309 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
310 fluent_cart_add_log(
311 __('PayPal Subscription Plan Mismatch', 'fluent-cart'),
312 sprintf(
313 /* translators: %1$s: expected plan ID, %2$s: received plan ID */
314 __('PayPal subscription plan mismatch. Expected: %1$s, Received: %2$s. Subscription not activated.', 'fluent-cart'),
315 $subscriptionModel->vendor_plan_id,
316 $paypalPlanId
317 ),
318 'error',
319 [
320 'module_name' => 'order',
321 'module_id' => $order->id,
322 'log_type' => 'api'
323 ]
324 );
325 return $subscriptionModel; // Do not activate
326 }
327 }
328
329 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time') ?? null;
330 if ($nextBillingDate) {
331 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($nextBillingDate));
332 } else {
333 // calculate the next billing date, as PayPal has not been charged yet
334 $billingIntervalDays = PaymentHelper::getIntervalDays($subscriptionModel->billing_interval) + (int) $subscriptionModel->trial_days;
335 $nextBillingDate = DateTime::gmtNow()->addDays($billingIntervalDays)->format('Y-m-d H:i:s');
336 }
337
338 $subscriptionUpdateData = array_filter([
339 'next_billing_date' => $nextBillingDate,
340 'status' => Status::SUBSCRIPTION_ACTIVE,
341 'vendor_subscription_id' => $paypalSubscription['id'],
342 'vendor_customer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id', ''),
343 'current_payment_method' => 'paypal',
344 ]);
345
346 $transactionUpdateData = [];
347 $lastTransactionAmount = Helper::toCent(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.value', 0));
348
349 if ($lastTransactionAmount && $transaction->total > 0 && $lastTransactionAmount != $transaction->total) {
350 fluent_cart_add_log(
351 __('PayPal Subscription Amount Mismatch', 'fluent-cart'),
352 sprintf(
353 /* translators: %1$s: expected amount, %2$s: received amount */
354 __('PayPal subscription billing amount mismatch. Expected: %1$s, Received: %2$s. Subscription not activated.', 'fluent-cart'),
355 Helper::toDecimal($transaction->total),
356 Helper::toDecimal($lastTransactionAmount)
357 ),
358 'error',
359 [
360 'module_name' => 'order',
361 'module_id' => $order->id,
362 'log_type' => 'api'
363 ]
364 );
365 return $subscriptionModel; // Do not activate
366 }
367
368 if (($lastTransactionAmount && $transaction->total == $lastTransactionAmount) || $transaction->total == 0) {
369 $transactionUpdateData = [
370 'order_id' => $order->id,
371 'status' => Status::TRANSACTION_SUCCEEDED,
372 'payment_method' => 'paypal'
373 ];
374 }
375
376 if ($transactionUpdateData) {
377 $transactionUpdateData = array_filter([
378 'order_id' => $order->id,
379 'status' => Status::TRANSACTION_SUCCEEDED,
380 'payment_method' => 'paypal',
381 ]);
382
383 $transaction->fill($transactionUpdateData);
384 $transaction->save();
385 }
386
387
388 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
389 $subscriptionUpdateData['canceled_at'] = null;
390 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
391 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
392 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
393 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
394 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
395 ]);
396
397 SubscriptionService::recordManualRenewal($subscriptionModel, $transaction, [
398 'billing_info' => $billingInfo,
399 'subscription_args' => $subscriptionUpdateData
400 ]);
401
402 } else {
403 // This can be a trialing subscription
404 if ($subscriptionModel->trial_days > 0) {
405 $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING;
406 }
407
408 $oldStatus = $subscriptionModel->status;
409
410 $subscriptionModel->fill($subscriptionUpdateData);
411 $subscriptionModel->save();
412
413 $subscriptionModel->updateMeta('active_payment_method', PaymentHelper::parsePaymentMethodDetails('paypal', [
414 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
415 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
416 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
417 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
418 ]));
419
420 if ($oldStatus != $subscriptionModel->status && (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status)) {
421 (new SubscriptionActivated($subscriptionModel, $order, $order->customer))->dispatch();
422 }
423 }
424
425 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
426 (new StatusHelper($order))->syncOrderStatuses($transaction);
427 } else {
428 fluent_cart_add_log('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.', [
429 'module_name' => 'order',
430 'module_id' => $order->id,
431 ]);
432 if ($subscriptionModel) {
433 $subscriptionModel->addLog('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.');
434 }
435 }
436
437 return $subscriptionModel;
438 }
439
440
441 private function toDecimal($cents)
442 {
443 return Helper::toDecimalWithoutComma($cents);
444 }
445
446 }
447