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

493 lines 22.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' => 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 $lastPaymentAmount = Helper::toCent(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.value', 0));
376 $lastPaymentCurrency = strtoupper(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.currency_code', ''));
377
378 // A subscription can legitimately be ACTIVE with no initial payment yet — a free
379 // trial, or a future start_time whose first charge PayPal has not run. Only mark the
380 // initial transaction SUCCEEDED (which flips the order to paid and triggers
381 // fulfilment) when PayPal reports a real initial payment whose amount AND currency
382 // match what we expected, or when nothing is owed (total == 0). ACTIVE alone is never
383 // treated as paid: an amount- or currency-mismatched payment leaves the order pending
384 // for the PAYMENT.SALE.COMPLETED webhook to reconcile, so a forced activation can
385 // never deliver a paid product for free.
386 $currencyMatches = !$lastPaymentCurrency || !$transaction->currency
387 || strtoupper($transaction->currency) === $lastPaymentCurrency;
388
389 $initialPaymentVerified = $lastPaymentAmount
390 && $transaction->total == $lastPaymentAmount
391 && $currencyMatches;
392
393 if ($initialPaymentVerified || $transaction->total == 0) {
394 $transactionUpdateData = array_filter([
395 'order_id' => $order->id,
396 'status' => Status::TRANSACTION_SUCCEEDED,
397 'payment_method' => 'paypal',
398 ]);
399
400 $transaction->fill($transactionUpdateData);
401 $transaction->save();
402 } elseif ($lastPaymentAmount && $transaction->total > 0) {
403 // A payment was reported but its amount or currency does not match the expected
404 // charge — do not mark the order paid; record it for audit (possible tampering).
405 fluent_cart_warning_log(
406 __('PayPal Subscription Payment Mismatch', 'fluent-cart'),
407 sprintf(
408 /* translators: %1$s: expected amount, %2$s: expected currency, %3$s: received amount, %4$s: received currency */
409 __('Subscription initial payment mismatch. Expected: %1$s %2$s, Received: %3$s %4$s. Order not marked paid; awaiting webhook.', 'fluent-cart'),
410 Helper::toDecimal($transaction->total),
411 $transaction->currency,
412 Helper::toDecimal($lastPaymentAmount),
413 $lastPaymentCurrency
414 ),
415 [
416 'module_name' => 'order',
417 'module_id' => $order->id,
418 'log_type' => 'api'
419 ]
420 );
421 }
422
423
424 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
425 $subscriptionUpdateData['canceled_at'] = null;
426 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
427 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
428 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
429 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
430 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
431 ]);
432
433 SubscriptionService::recordManualRenewal($subscriptionModel, $transaction, [
434 'billing_info' => $billingInfo,
435 'subscription_args' => $subscriptionUpdateData
436 ]);
437
438 } else {
439 // This can be a trialing subscription
440 if ($subscriptionModel->trial_days > 0) {
441 $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING;
442 }
443
444 // Atomic conditional update: only the caller that actually flips status out of a
445 // pre-active state wins the transition, so concurrent AJAX-return + webhook calls
446 // can't both dispatch SubscriptionActivated.
447 $activatedNow = (bool) Subscription::query()
448 ->where('id', $subscriptionModel->id)
449 ->whereNotIn('status', [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
450 ->update($subscriptionUpdateData);
451
452 $subscriptionModel->fill($subscriptionUpdateData);
453
454 // updateMeta() is check-then-create with no unique (subscription_id, meta_key)
455 // constraint — gate it behind $activatedNow too, else a losing concurrent caller
456 // still inserts a duplicate active_payment_method meta row.
457 if ($activatedNow) {
458 $subscriptionModel->updateMeta('active_payment_method', PaymentHelper::parsePaymentMethodDetails('paypal', [
459 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
460 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
461 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
462 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
463 ]));
464
465 if (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status) {
466 (new SubscriptionActivated($subscriptionModel, $order, $order->customer))->dispatch();
467 }
468 }
469 }
470
471 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
472 (new StatusHelper($order))->syncOrderStatuses($transaction);
473 } else {
474 fluent_cart_add_log('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.', [
475 'module_name' => 'order',
476 'module_id' => $order->id,
477 ]);
478 if ($subscriptionModel) {
479 $subscriptionModel->addLog('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.');
480 }
481 }
482
483 return $subscriptionModel;
484 }
485
486
487 private function toDecimal($cents)
488 {
489 return Helper::toDecimalWithoutComma($cents);
490 }
491
492 }
493