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

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