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

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