PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.19
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.19
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.19, at app/Modules/PaymentMethods/PayPalGateway/Processor.php

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