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

1,045 lines 46.2 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\Modules\Subscriptions\Services\SystemChargeService;
15 use FluentCart\App\Services\DateTime\DateTime;
16 use FluentCart\App\Services\Payments\PaymentHelper;
17 use FluentCart\App\Services\Payments\PaymentInstance;
18 use FluentCart\Framework\Support\Arr;
19
20 class Processor
21 {
22 public function handleSinglePayment(PaymentInstance $paymentInstance, $args = [])
23 {
24 $transaction = $paymentInstance->transaction;
25 $order = $paymentInstance->order;
26
27 $itemsSubTotal = 0;
28 $formattedItems = [];
29
30 foreach ($order->order_items as $item) {
31 $quantity = $item->quantity ?? 1;
32 $perQuantity = $this->toDecimal($item->line_total / $quantity);
33 $title = $item->post_title . ' ' . $item->title;
34
35 $formattedItems[] = [
36 'name' => strlen($title) > 127 ? substr($title, 0, 120) . '...' : $title,
37 'description' => strlen($title) > 4000 ? substr($title, 0, 3997) . '...' : $title,
38 'unit_amount' => [
39 'currency_code' => $transaction->currency,
40 'value' => number_format($perQuantity, 2, '.', ''),
41 ],
42 'quantity' => $quantity,
43 ];
44
45 $itemsSubTotal += $perQuantity * $quantity;
46 }
47
48 $chargingAmount = $this->toDecimal($transaction->total);
49 $pushedTotal = $itemsSubTotal;
50
51
52 // Learn more at: https://developer.paypal.com/docs/api/orders/v2/#definition-purchase_unit
53 $purchaseUnits = [
54 'reference_id' => $transaction->uuid, // This is the order UUID
55 'amount' => [ // https://developer.paypal.com/docs/api/orders/v2/#definition-amount_breakdown
56 'currency_code' => $transaction->currency,
57 'value' => number_format($chargingAmount, 2, '.', ''),
58 'breakdown' => [
59 'item_total' => [
60 'currency_code' => $transaction->currency,
61 'value' => number_format($itemsSubTotal, 2, '.', ''),
62 ]
63 ]
64 ],
65 'items' => $formattedItems
66 ];
67
68 // if there is no defined credential for specific mode,
69 // then add merchantId as it's a partner app connection
70 $payPalSettings = new PayPalSettingsBase();
71 if ($merchantId = $payPalSettings->getMerchantId()) {
72 if ($payPalSettings->getProviderType() === 'api_keys') {
73 $purchaseUnits['payee'] = [
74 "merchant_id" => $merchantId
75 ];
76 }
77 }
78
79 if ($order->shipping_total > 0) {
80 $shippingAmount = $this->toDecimal($order->shipping_total);
81 $purchaseUnits['amount']['breakdown']['shipping'] = [
82 'currency_code' => $transaction->currency,
83 'value' => number_format($shippingAmount, 2, '.', ''),
84 ];
85 $pushedTotal += $shippingAmount;
86 }
87
88
89
90 $taxBehavior = (int) $order->tax_behavior;
91 $exclusiveTaxTotal = (int) $order->getMeta('exclusive_tax_total');
92 $storeTaxBehavior = (int) $order->getMeta('store_tax_behavior');
93 $feeTax = (int) $order->getMeta('fee_tax');
94
95 // Fallback: if meta missing (old order), use tax_behavior as store_tax_behavior
96 if (empty($storeTaxBehavior) && $taxBehavior > 0) {
97 $storeTaxBehavior = $taxBehavior;
98 }
99
100 if ($taxBehavior === 1) {
101 // Pure exclusive: all tax is additive on top of item prices.
102 // tax_total includes product + fee tax (both exclusive).
103 $taxTotal = $this->toDecimal($order->tax_total) + $this->toDecimal($order->shipping_tax);
104 } elseif ($taxBehavior === 3) {
105 // Mixed: only exclusive product + fee tax is additive; shipping conditional.
106 $taxTotal = $this->toDecimal($exclusiveTaxTotal);
107 if ($storeTaxBehavior === 1) {
108 // Store is exclusive: fees and shipping are also exclusive.
109 $taxTotal += $this->toDecimal($order->shipping_tax);
110 $taxTotal += $this->toDecimal($feeTax);
111 }
112 } else {
113 $taxTotal = 0;
114 }
115
116 if ($taxTotal > 0) {
117 $purchaseUnits['amount']['breakdown']['tax_total'] = [
118 'currency_code' => $transaction->currency,
119 'value' => number_format($taxTotal, 2, '.', ''),
120 ];
121 $pushedTotal += $taxTotal;
122 }
123
124 if ($chargingAmount < $pushedTotal) {
125 $discount = $pushedTotal - $chargingAmount;
126 $purchaseUnits['amount']['breakdown']['discount'] = [
127 'currency_code' => $transaction->currency,
128 'value' => number_format($discount, 2, '.', ''),
129 ];
130 } else if ($chargingAmount > $pushedTotal) {
131 $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal;
132 $formattedItems[] = [
133 'name' => __('Adjustment Amount', 'fluent-cart'),
134 'unit_amount' => [
135 'currency_code' => $transaction->currency,
136 'value' => number_format($extraChargeNeedToBeAdded, 2, '.', ''),
137 ],
138 'quantity' => 1,
139 ];
140
141 $purchaseUnits['items'] = $formattedItems;
142
143 //now the total amount need to be adjusted with item total value
144 $adjustedItemTotal = $itemsSubTotal + $extraChargeNeedToBeAdded;
145 $purchaseUnits['amount']['breakdown']['item_total']['value'] = number_format($adjustedItemTotal, 2, '.', '');
146 }
147
148 // System (auto-charged, store-billed) subscription checkout: vault the
149 // buyer's PayPal account during this purchase (Vault v3 save-on-success)
150 // so future renewal invoices can be charged merchant-initiated. The buyer
151 // sees and approves the save agreement inside PayPal's own approval UI.
152 $extraBody = [];
153 if (!empty($args['vault_on_success'])) {
154 $vaultAttributes = apply_filters('fluent_cart/paypal/vault_attributes', [
155 'store_in_vault' => 'ON_SUCCESS',
156 'usage_type' => 'MERCHANT',
157 'customer_type' => 'CONSUMER',
158 ], [
159 'order' => $order,
160 'subscription' => $paymentInstance->subscription,
161 ]);
162
163 $extraBody['payment_source'] = [
164 'paypal' => [
165 'attributes' => ['vault' => $vaultAttributes],
166 'experience_context' => [
167 'return_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
168 'cancel_url' => \FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway::getCancelUrl(),
169 'shipping_preference' => 'NO_SHIPPING',
170 ],
171 ],
172 ];
173 }
174
175 $paypalOrder = API::createOrder($purchaseUnits, $extraBody);
176
177 if (is_wp_error($paypalOrder)) {
178 return $paypalOrder;
179 }
180
181 $paypalOrderId = Arr::get($paypalOrder, 'id');
182
183 $transaction->update([
184 'meta' => array_merge($transaction->meta ?? [], ['paypal_order_id' => $paypalOrderId])
185 ]);
186
187 return [
188 'nextAction' => 'paypal',
189 'actionName' => 'custom',
190 'status' => 'success',
191 'data' => [
192 'order' => [
193 'uuid' => $order->uuid,
194 ],
195 'transaction' => [
196 'uuid' => $transaction->uuid,
197 ]
198 ],
199 'message' => __('Order has been placed successfully', 'fluent-cart'),
200 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
201 'response' => [
202 'paypalOrderId' => $paypalOrderId,
203 ]
204 ];
205 }
206
207 /**
208 * Zero-payable system subscription checkout (free trial): a $0 PayPal order
209 * is invalid, so the buyer's PayPal account is vaulted via a Vault v3 setup
210 * token; confirmVaultSetup() exchanges it, completes the $0 order, and the
211 * trial-end invoice is charged off-session like any other system renewal.
212 * The save agreement is carried by PayPal's own approval popup; the checkout
213 * page shows the informational disclosure next to the buttons.
214 */
215 public function handleSetupOnlyPayment(PaymentInstance $paymentInstance)
216 {
217 $order = $paymentInstance->order;
218 $transaction = $paymentInstance->transaction;
219
220 $setupToken = API::makeRequest('vault/setup-tokens', 'v3', 'POST', [
221 'payment_source' => [
222 'paypal' => [
223 'usage_type' => 'MERCHANT',
224 'customer_type' => 'CONSUMER',
225 'experience_context' => [
226 'return_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
227 'cancel_url' => \FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway::getCancelUrl(),
228 'shipping_preference' => 'NO_SHIPPING',
229 ],
230 ],
231 ],
232 ]);
233
234 if (is_wp_error($setupToken)) {
235 return $setupToken;
236 }
237
238 $setupTokenId = Arr::get($setupToken, 'id');
239
240 if (!$setupTokenId) {
241 return new \WP_Error('setup_token_failed', __('PayPal did not return a setup token.', 'fluent-cart'));
242 }
243
244 // confirmVaultSetup() binds the buyer's approval to this transaction by
245 // this id; the write takes the same lock as confirmation so a
246 // replacement can never interleave with an in-flight confirm.
247 if (!self::acquireVaultTransactionLock($transaction->uuid)) {
248 return new \WP_Error('setup_in_progress', __('Another payment confirmation is in progress. Please try again.', 'fluent-cart'));
249 }
250
251 try {
252 $transaction->update([
253 'meta' => array_merge($transaction->meta ?? [], ['paypal_setup_token_id' => $setupTokenId])
254 ]);
255 } finally {
256 self::releaseVaultTransactionLock($transaction->uuid);
257 }
258
259 return [
260 'nextAction' => 'paypal',
261 'actionName' => 'custom',
262 'status' => 'success',
263 'data' => [
264 'order' => [
265 'uuid' => $order->uuid,
266 ],
267 'transaction' => [
268 'uuid' => $transaction->uuid,
269 ]
270 ],
271 'message' => __('Order has been placed successfully', 'fluent-cart'),
272 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
273 'response' => [
274 'setupTokenId' => $setupTokenId,
275 ]
276 ];
277 }
278
279 /**
280 * Vault-flow lock, keyed on the transaction uuid — shared by the setup-token
281 * binding write and the confirmation endpoint so token replacement and
282 * confirmation of one transaction always serialize.
283 */
284 public static function acquireVaultTransactionLock($transactionUuid)
285 {
286 global $wpdb;
287
288 $result = $wpdb->get_var($wpdb->prepare(
289 'SELECT GET_LOCK(%s, %d)',
290 'fluent_cart_paypal_vault_' . md5($transactionUuid),
291 10
292 ));
293
294 return (string) $result === '1';
295 }
296
297 public static function releaseVaultTransactionLock($transactionUuid)
298 {
299 global $wpdb;
300
301 $wpdb->get_var($wpdb->prepare(
302 'SELECT RELEASE_LOCK(%s)',
303 'fluent_cart_paypal_vault_' . md5($transactionUuid)
304 ));
305 }
306
307 /**
308 * Exchange an approved setup token for a durable payment token, persist it
309 * on the system subscription, and complete the $0 order — the trial then
310 * activates through the normal status-sync path.
311 *
312 * @param OrderTransaction $transaction
313 * @param string $setupTokenId
314 * @return true|\WP_Error
315 */
316 public function confirmVaultSetup(OrderTransaction $transaction, $setupTokenId)
317 {
318 // A prior confirmation may have died between marking the transaction
319 // succeeded and syncing the order — always re-run the idempotent sync.
320 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
321 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
322 return true;
323 }
324
325 /** @var Subscription|null $subscription */
326 $subscription = Subscription::query()->find($transaction->subscription_id);
327
328 if (!$subscription || !$subscription->isSystem()) {
329 return new \WP_Error('invalid_subscription', __('No auto-charged subscription is attached to this transaction.', 'fluent-cart'));
330 }
331
332 // Keyed on the setup token: a double-fired confirmation replays the
333 // original payment token instead of vaulting twice.
334 $paymentToken = API::makeRequest('vault/payment-tokens', 'v3', 'POST', [
335 'payment_source' => [
336 'token' => [
337 'id' => $setupTokenId,
338 'type' => 'SETUP_TOKEN',
339 ],
340 ],
341 ], '', [
342 'PayPal-Request-Id' => 'fct_paypal_pt_' . md5($setupTokenId),
343 ]);
344
345 if (is_wp_error($paymentToken)) {
346 return $paymentToken;
347 }
348
349 $tokenId = Arr::get($paymentToken, 'id');
350
351 if (!$tokenId) {
352 return new \WP_Error('vault_failed', __('PayPal did not return a saved payment method.', 'fluent-cart'));
353 }
354
355 $vaultCustomerId = Arr::get($paymentToken, 'customer.id', '');
356 if ($vaultCustomerId && !$subscription->vendor_customer_id) {
357 $subscription->vendor_customer_id = $vaultCustomerId;
358 $subscription->save();
359 }
360
361 $paypalSource = Arr::get($paymentToken, 'payment_source.paypal', []);
362 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
363 'email' => Arr::get($paypalSource, 'email_address', ''),
364 'payer_id' => Arr::get($paypalSource, 'account_id', ''),
365 'name' => trim(Arr::get($paypalSource, 'name.given_name', '') . ' ' . Arr::get($paypalSource, 'name.surname', '')),
366 ]);
367 $billingInfo['vendor_method_id'] = $tokenId;
368
369 $subscription->updateMeta('active_payment_method', $billingInfo);
370
371 $subscription->addLog(
372 'PayPal account saved',
373 __('PayPal payment method vaulted for automatic renewal charges.', 'fluent-cart'),
374 'info'
375 );
376
377 $transaction->fill([
378 'status' => Status::TRANSACTION_SUCCEEDED,
379 'payment_method' => 'paypal',
380 ]);
381 $transaction->save();
382
383 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
384
385 return true;
386 }
387
388 public function handleSubscriptionPaymentFromPaymentInstance(PaymentInstance $paymentInstance, $args = [])
389 {
390 $orderType = $paymentInstance->order->type;
391 $subscription = $paymentInstance->subscription;
392 $feeTotal = $orderType !== 'renewal' ? (int)$paymentInstance->order->fee_total : 0;
393 $initialAmount = (int)$subscription->signup_fee + $paymentInstance->getExtraAddonAmount() + $feeTotal;
394 $status = Status::SUBSCRIPTION_INTENDED;
395
396 if ($orderType == 'renewal') {
397 $requiredBillTimes = $subscription->getRequiredBillTimes();
398
399 if ($requiredBillTimes === -1) {
400 return new \WP_Error('already_completed', __('Invalid bill times for the subscription.', 'fluent-cart'));
401 }
402
403 $data = [
404 'order_id' => $subscription->parent_order_id,
405 'product_id' => $subscription->product_id,
406 'variation_id' => $subscription->variation_id,
407 'trial_days' => $subscription->getReactivationTrialDays(), // trial days for reactivation
408 'billing_interval' => $subscription->billing_interval,
409 'currency' => $paymentInstance->order->currency,
410 'interval_count' => 1, // 1
411 'recurring_amount' => $subscription->getCurrentRenewalAmount(), // default recurring total in cents
412 'signup_fee' => 0, // default setup fee in cents ($0.00)
413 'bill_times' => $requiredBillTimes, // 0 for unlimited
414 ];
415 $status = $subscription->status;
416 } else {
417 $data = [
418 'order_id' => $subscription->parent_order_id,
419 'product_id' => $subscription->product_id,
420 'variation_id' => $subscription->variation_id,
421 'trial_days' => $subscription->trial_days,
422 'billing_interval' => $subscription->billing_interval,
423 'currency' => $paymentInstance->order->currency,
424 'interval_count' => 1, // 1
425 'recurring_amount' => $subscription->recurring_total, // default recurring total in cents
426 'signup_fee' => $initialAmount, // default setup fee in cents ($0.00)
427 'bill_times' => $subscription->getInitialRemoteBillTimes(), // 0 for unlimited; simulated-trial first installment excluded
428 ];
429
430 }
431
432 $paypalPlan = PayPalHelper::getPayPalPlan($data);
433
434 if (is_wp_error($paypalPlan)) {
435 return $paypalPlan;
436 }
437
438 $subscriptionUpdateFields = [
439 'status' => $status,
440 'vendor_plan_id' => Arr::get($paypalPlan, 'id'),
441 'vendor_response' => json_encode($paypalPlan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
442 ];
443
444 if ($orderType == 'renewal' && !empty($data['trial_days'])) {
445 $config = $subscription->config ?: [];
446 $subscriptionUpdateFields['config'] = array_merge($config, ['is_trial_days_simulated' => 'yes']);
447 }
448
449 $subscription->update($subscriptionUpdateFields);
450
451 return [
452 'status' => 'success',
453 'nextAction' => 'paypal',
454 'actionName' => 'custom',
455 'message' => __('Order has been placed successfully', 'fluent-cart'),
456 'data' => [
457 'order' => [
458 'uuid' => $paymentInstance->order->uuid,
459 ],
460 'transaction' => [
461 'uuid' => $paymentInstance->transaction->uuid,
462 ],
463 'subscription' => [
464 'uuid' => $subscription->uuid,
465 ]
466 ],
467 'response' => [
468 'planId' => Arr::get($paypalPlan, 'id')
469 ]
470 ];
471 }
472
473 /**
474 * Confirm payment success
475 * Currently used by:
476 * @param OrderTransaction $transaction
477 * @param array $args
478 * @param array $transactionArgs
479 * string vendor_charge_id - The intent_id from paypal
480 * string total - The amount charged in cents
481 * string status - The status of the transaction ('succeeded', 'pending', etc.))
482 * array payer - The payer information from PayPal.
483 * array payment_source - The payment source information from PayPal.
484 *
485 * @param string $args ['intent_id'] - The intent ID from Stripe.
486 * @return Order
487 */
488 public function confirmPaymentSuccessByCharge(OrderTransaction $transaction, $transactionArgs = [])
489 {
490 $transactionUpdateData = array_filter([
491 'vendor_charge_id' => Arr::get($transactionArgs, 'vendor_charge_id', ''),
492 'payment_method' => 'paypal',
493 'status' => Arr::get($transactionArgs, 'status', Status::TRANSACTION_SUCCEEDED),
494 'total' => (int)Arr::get($transactionArgs, 'total', 0),
495 // payment_method_type: this is the intent ID. We may need that later In case we don't have the vendor_charge_id
496 'payment_method_type' => Arr::get($transactionArgs, 'payment_method_type', ''),
497 ]);
498
499 $order = Order::query()->where('id', $transaction->order_id)->first();
500 // in race conditions between webhook and AJAX confirmation
501 $transaction = OrderTransaction::query()->where('id', $transaction->id)->first();
502 if ($transaction->status === Status::TRANSACTION_SUCCEEDED || $transactionUpdateData['status'] !== Status::TRANSACTION_SUCCEEDED) {
503 if (!$transaction->vendor_charge_id && !empty($transactionUpdateData['vendor_charge_id'])) {
504 $transaction->update(['vendor_charge_id' => $transactionUpdateData['vendor_charge_id']]);
505 }
506 return $order; // already confirmed or not needed to confirm
507 }
508
509 // handle payment source
510 $cardData = Arr::get($transactionArgs, 'payment_source.card', []);
511 if ($cardData) {
512 $transactionUpdateData['card_last_4'] = strlen(Arr::get($cardData, 'last_digits')) > 4 ? substr(Arr::get($cardData, 'last_digits'), -4) : Arr::get($cardData, 'last_digits');
513 $transactionUpdateData['card_brand'] = Arr::get($cardData, 'brand');
514 }
515
516 $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', []));
517
518 $transaction->fill($transactionUpdateData);
519 $transaction->save();
520
521 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', [
522 'module_name' => 'order',
523 'module_id' => $order->id,
524 ]);
525
526 // Maybe we have to save the billing details
527
528 // We are assuming. This is only for one time payment. No subscription or renewal will be here!
529
530 return (new StatusHelper($order))->syncOrderStatuses($transaction);
531 }
532
533
534 // This should be only used from the ajax call for the very first time subscription activation
535 public function activateSubscription($paypalSubscription, OrderTransaction $transaction, $subscriptionModel = null)
536 {
537 $order = $transaction->order;
538
539 if (!$subscriptionModel) {
540 $subscriptionModel = Subscription::query()->where('id', $transaction->subscription_id)->first();
541 }
542
543 if (!$subscriptionModel) {
544 return null;
545 }
546
547 if ($order->type !== Status::ORDER_TYPE_RENEWAL && $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
548 return $subscriptionModel;
549 }
550
551 // Verify the PayPal subscription's plan matches the expected plan
552 if ($subscriptionModel->vendor_plan_id) {
553 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
554 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
555 fluent_cart_add_log(
556 __('PayPal Subscription Plan Mismatch', 'fluent-cart'),
557 sprintf(
558 /* translators: %1$s: expected plan ID, %2$s: received plan ID */
559 __('PayPal subscription plan mismatch. Expected: %1$s, Received: %2$s. Subscription not activated.', 'fluent-cart'),
560 $subscriptionModel->vendor_plan_id,
561 $paypalPlanId
562 ),
563 'error',
564 [
565 'module_name' => 'order',
566 'module_id' => $order->id,
567 'log_type' => 'api'
568 ]
569 );
570 return $subscriptionModel; // Do not activate
571 }
572 }
573
574 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time') ?? null;
575 if ($nextBillingDate) {
576 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($nextBillingDate));
577 } else {
578 // calculate the next billing date, as PayPal has not been charged yet
579 $billingIntervalDays = PaymentHelper::getIntervalDays($subscriptionModel->billing_interval) + (int) $subscriptionModel->trial_days;
580 $nextBillingDate = DateTime::gmtNow()->addDays($billingIntervalDays)->format('Y-m-d H:i:s');
581 }
582
583 $subscriptionUpdateData = array_filter([
584 'next_billing_date' => $nextBillingDate,
585 'status' => Status::SUBSCRIPTION_ACTIVE,
586 'vendor_subscription_id' => $paypalSubscription['id'],
587 'vendor_customer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id', ''),
588 'current_payment_method' => 'paypal',
589 ]);
590
591 $lastPaymentAmount = Helper::toCent(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.value', 0));
592 $lastPaymentCurrency = strtoupper(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.currency_code', ''));
593
594 // A subscription can legitimately be ACTIVE with no initial payment yet — a free
595 // trial, or a future start_time whose first charge PayPal has not run. Only mark the
596 // initial transaction SUCCEEDED (which flips the order to paid and triggers
597 // fulfilment) when PayPal reports a real initial payment whose amount AND currency
598 // match what we expected, or when nothing is owed (total == 0). ACTIVE alone is never
599 // treated as paid: an amount- or currency-mismatched payment leaves the order pending
600 // for the PAYMENT.SALE.COMPLETED webhook to reconcile, so a forced activation can
601 // never deliver a paid product for free.
602 $currencyMatches = !$lastPaymentCurrency || !$transaction->currency
603 || strtoupper($transaction->currency) === $lastPaymentCurrency;
604
605 $initialPaymentVerified = $lastPaymentAmount
606 && $transaction->total == $lastPaymentAmount
607 && $currencyMatches;
608
609 if ($initialPaymentVerified || $transaction->total == 0) {
610 $transactionUpdateData = array_filter([
611 'order_id' => $order->id,
612 'status' => Status::TRANSACTION_SUCCEEDED,
613 'payment_method' => 'paypal',
614 ]);
615
616 $transaction->fill($transactionUpdateData);
617 $transaction->save();
618 } elseif ($lastPaymentAmount && $transaction->total > 0) {
619 // A payment was reported but its amount or currency does not match the expected
620 // charge — do not mark the order paid; record it for audit (possible tampering).
621 fluent_cart_warning_log(
622 __('PayPal Subscription Payment Mismatch', 'fluent-cart'),
623 sprintf(
624 /* translators: %1$s: expected amount, %2$s: expected currency, %3$s: received amount, %4$s: received currency */
625 __('Subscription initial payment mismatch. Expected: %1$s %2$s, Received: %3$s %4$s. Order not marked paid; awaiting webhook.', 'fluent-cart'),
626 Helper::toDecimal($transaction->total),
627 $transaction->currency,
628 Helper::toDecimal($lastPaymentAmount),
629 $lastPaymentCurrency
630 ),
631 [
632 'module_name' => 'order',
633 'module_id' => $order->id,
634 'log_type' => 'api'
635 ]
636 );
637 }
638
639
640 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
641 $subscriptionUpdateData['canceled_at'] = null;
642 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
643 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
644 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
645 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
646 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
647 ]);
648
649 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
650 SubscriptionService::recordManualRenewal($subscriptionModel, $transaction, [
651 'billing_info' => $billingInfo,
652 'subscription_args' => $subscriptionUpdateData
653 ]);
654 } else {
655 $subscriptionModel->fill($subscriptionUpdateData)->save();
656 $subscriptionModel->updateMeta('active_payment_method', $billingInfo);
657 do_action('fluent_cart/renewal/payment_scheduled', [
658 'order' => $order,
659 'subscription' => $subscriptionModel,
660 ]);
661 }
662
663 } else {
664 // This can be a trialing subscription
665 if ($subscriptionModel->trial_days > 0) {
666 $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING;
667 }
668
669 // Atomic conditional update: only the caller that actually flips status out of a
670 // pre-active state wins the transition, so concurrent AJAX-return + webhook calls
671 // can't both dispatch SubscriptionActivated.
672 $activatedNow = (bool) Subscription::query()
673 ->where('id', $subscriptionModel->id)
674 ->whereNotIn('status', [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
675 ->update($subscriptionUpdateData);
676
677 $subscriptionModel->fill($subscriptionUpdateData);
678
679 // updateMeta() is check-then-create with no unique (subscription_id, meta_key)
680 // constraint — gate it behind $activatedNow too, else a losing concurrent caller
681 // still inserts a duplicate active_payment_method meta row.
682 if ($activatedNow) {
683 $subscriptionModel->updateMeta('active_payment_method', PaymentHelper::parsePaymentMethodDetails('paypal', [
684 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
685 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
686 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
687 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
688 ]));
689
690 if (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status) {
691 (new SubscriptionActivated($subscriptionModel, $order, $order->customer))->dispatch();
692 }
693 }
694 }
695
696 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
697 (new StatusHelper($order))->syncOrderStatuses($transaction);
698 } else {
699 fluent_cart_add_log('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.', [
700 'module_name' => 'order',
701 'module_id' => $order->id,
702 ]);
703 if ($subscriptionModel) {
704 $subscriptionModel->addLog('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.');
705 }
706 }
707
708 return $subscriptionModel;
709 }
710
711
712 private function toDecimal($cents)
713 {
714 return Helper::toDecimalWithoutComma($cents);
715 }
716
717 /**
718 * Persist the vaulted PayPal payment token from a captured order onto the
719 * system subscription — the token future renewal charges read (at fire time)
720 * from active_payment_method. Idempotent per token; shared by the AJAX
721 * confirmation and the PAYMENT.CAPTURE.COMPLETED webhook (whichever lands
722 * first wins).
723 *
724 * When the FIRST (initial) capture of a system subscription carries NO vault
725 * token — vaulting declined or unavailable on the merchant account — the
726 * subscription is demoted to plain manual invoicing immediately: a `system`
727 * subscription without a token would fail every scheduled charge forever.
728 *
729 * @param OrderTransaction $transaction
730 * @param array $paypalOrder The captured Orders-v2 order (full representation).
731 */
732 public function maybePersistVaultToken(OrderTransaction $transaction, $paypalOrder)
733 {
734 if (!$transaction->subscription_id || !is_array($paypalOrder)) {
735 return;
736 }
737
738 /** @var Subscription|null $subscription */
739 $subscription = Subscription::query()->find($transaction->subscription_id);
740
741 if (!$subscription || !$subscription->isSystem()) {
742 return;
743 }
744
745 $vault = Arr::get($paypalOrder, 'payment_source.paypal.attributes.vault', []);
746 $tokenId = Arr::get($vault, 'id', '');
747
748 $existing = $subscription->getMeta('active_payment_method', []) ?: [];
749
750 if ($tokenId) {
751 if (Arr::get($existing, 'vendor_method_id') === $tokenId) {
752 return; // already persisted (webhook/AJAX race)
753 }
754
755 $vaultCustomerId = Arr::get($vault, 'customer.id', '');
756 if ($vaultCustomerId && !$subscription->vendor_customer_id) {
757 $subscription->vendor_customer_id = $vaultCustomerId;
758 $subscription->save();
759 }
760
761 $payerEmail = Arr::get($paypalOrder, 'payment_source.paypal.email_address', '');
762 if (!$payerEmail) {
763 $payerEmail = Arr::get($paypalOrder, 'payer.email_address', '');
764 }
765
766 $payerName = trim(Arr::get($paypalOrder, 'payer.name.given_name', '') . ' ' . Arr::get($paypalOrder, 'payer.name.surname', ''));
767
768 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
769 'email' => $payerEmail,
770 'payer_id' => Arr::get($paypalOrder, 'payer.payer_id', ''),
771 'name' => $payerName,
772 ]);
773 $billingInfo['vendor_method_id'] = $tokenId;
774
775 $subscription->updateMeta('active_payment_method', $billingInfo);
776
777 $subscription->addLog(
778 'PayPal account saved',
779 __('PayPal payment method vaulted for automatic renewal charges.', 'fluent-cart'),
780 'info'
781 );
782
783 return;
784 }
785
786 // No token on the INITIAL capture and none stored yet — never leave a
787 // system subscription that can never be charged.
788 if ($transaction->order
789 && $transaction->order->type === Status::ORDER_TYPE_SUBSCRIPTION
790 && !Arr::get($existing, 'vendor_method_id')
791 ) {
792 SystemChargeService::demoteToManual(
793 $subscription,
794 __('PayPal did not return a saved payment method for automatic charging.', 'fluent-cart')
795 );
796 }
797 }
798
799 /**
800 * Merchant-initiated off-session charge of a renewal invoice against the
801 * vaulted PayPal token (Orders v2 create with payment_source.paypal.vault_id).
802 * Contract per dev-docs/system-subscriptions/gateway-implementation-guide.md:
803 * true = confirmed through the normal capture path; 'processing' = accepted
804 * but settling (eCheck); WP_Error = definitive failure.
805 */
806 public function chargeVaultedRenewal(PaymentInstance $paymentInstance, $args = [])
807 {
808 $order = $paymentInstance->order;
809 $transaction = $paymentInstance->transaction;
810 $subscription = $paymentInstance->subscription;
811
812 if (!$order || !$transaction || !$subscription) {
813 return new \WP_Error('invalid_instance', __('Renewal invoice is missing its order, transaction, or subscription.', 'fluent-cart'));
814 }
815
816 // Token read AT FIRE TIME — never snapshotted. Both meta shapes accepted.
817 $paymentMethodMeta = $subscription->getMeta('active_payment_method', []) ?: [];
818 $token = Arr::get($paymentMethodMeta, 'vendor_method_id');
819 if (!$token) {
820 $token = Arr::get($paymentMethodMeta, 'details.payment_method_id');
821 }
822
823 if (!$token) {
824 return new \WP_Error('missing_token', __('No saved PayPal payment method is available for this subscription.', 'fluent-cart'));
825 }
826
827 $attempt = max(1, (int) Arr::get($args, 'attempt', 1));
828
829 $purchaseUnit = [
830 'reference_id' => $transaction->uuid,
831 'custom_id' => $transaction->uuid,
832 'amount' => [
833 'currency_code' => strtoupper($transaction->currency),
834 'value' => number_format($this->toDecimal((int) $transaction->total), 2, '.', ''),
835 ],
836 ];
837
838 $paypalOrder = API::createOrder($purchaseUnit, [
839 'payment_source' => ['paypal' => ['vault_id' => $token]],
840 ], [
841 // One vendor charge per (order, attempt) — a scheduler double-fire
842 // replays the original response instead of charging twice.
843 'PayPal-Request-Id' => 'fct_system_charge_' . $order->id . '_' . $attempt,
844 ]);
845
846 if (is_wp_error($paypalOrder)) {
847 return $paypalOrder;
848 }
849
850 return $this->settleVaultChargeResponse($transaction, $paypalOrder);
851 }
852
853 /**
854 * Re-check a processing vault charge (lost webhook / slow eCheck). A transient
855 * API error reports 'processing' — never fail a possibly-settled payment.
856 */
857 public function reconcileVaultedRenewal(PaymentInstance $paymentInstance)
858 {
859 $transaction = $paymentInstance->transaction;
860
861 if (!$transaction) {
862 return new \WP_Error('missing_intent', __('No transaction is recorded for this renewal order.', 'fluent-cart'));
863 }
864
865 // Preferred: the capture id recorded when the charge was accepted.
866 if ($transaction->vendor_charge_id) {
867 $capture = API::makeRequest('payments/captures/' . $transaction->vendor_charge_id, 'v2', 'GET');
868
869 if (is_wp_error($capture)) {
870 return 'processing';
871 }
872
873 $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
874
875 if ($captureStatus === 'COMPLETED') {
876 $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
877 'vendor_charge_id' => Arr::get($capture, 'id', $transaction->vendor_charge_id),
878 'status' => Status::TRANSACTION_SUCCEEDED,
879 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
880 'payment_method_type' => 'PayPal',
881 ]);
882 return true;
883 }
884
885 if ($captureStatus === 'PENDING') {
886 return 'processing';
887 }
888
889 return new \WP_Error('charge_failed', sprintf(
890 /* translators: %1$s: PayPal capture status */
891 __('The pending PayPal payment could not be completed (status: %1$s).', 'fluent-cart'),
892 $captureStatus !== '' ? $captureStatus : 'unknown'
893 ));
894 }
895
896 // Fallback: the vault order id stored at charge time.
897 $paypalOrderId = Arr::get($transaction->meta ?? [], 'paypal_vault_order_id', '');
898
899 if (!$paypalOrderId) {
900 return new \WP_Error('missing_intent', __('No PayPal charge is recorded for this renewal order.', 'fluent-cart'));
901 }
902
903 $paypalOrder = API::verifyPayment($paypalOrderId);
904
905 if (is_wp_error($paypalOrder)) {
906 return 'processing';
907 }
908
909 return $this->settleVaultChargeResponse(OrderTransaction::query()->find($transaction->id), $paypalOrder);
910 }
911
912 public function syncRemoteTransaction(OrderTransaction $transaction)
913 {
914 $mode = $transaction->payment_mode ?: '';
915
916 $capture = API::makeRequest('payments/captures/' . $transaction->vendor_charge_id, 'v2', 'GET', [], $mode);
917
918 if (is_wp_error($capture)) {
919 return $capture;
920 }
921
922 $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
923
924 if ($captureStatus === 'COMPLETED') {
925 $captureCurrency = strtoupper((string) Arr::get($capture, 'amount.currency_code', ''));
926 if ($captureCurrency && $transaction->currency && strtoupper($transaction->currency) !== $captureCurrency) {
927 fluent_cart_warning_log(
928 __('PayPal Currency Mismatch On Sync', 'fluent-cart'),
929 sprintf(
930 /* translators: %1$s: expected currency, %2$s: received currency */
931 __('Capture currency mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
932 $transaction->currency,
933 $captureCurrency
934 ),
935 [
936 'module_name' => 'order',
937 'module_id' => $transaction->order_id,
938 'log_type' => 'api'
939 ]
940 );
941
942 return new \WP_Error('currency_mismatch', __('The PayPal payment currency does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart'));
943 }
944
945 $captureAmount = Helper::toCent(Arr::get($capture, 'amount.value', 0));
946 if ($captureAmount !== (int) $transaction->total) {
947 fluent_cart_warning_log(
948 __('PayPal Amount Mismatch On Sync', 'fluent-cart'),
949 sprintf(
950 /* translators: %1$s: expected amount, %2$s: received amount */
951 __('Capture amount mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
952 Helper::toDecimal($transaction->total),
953 Helper::toDecimal($captureAmount)
954 ),
955 [
956 'module_name' => 'order',
957 'module_id' => $transaction->order_id,
958 'log_type' => 'api'
959 ]
960 );
961
962 return new \WP_Error('amount_mismatch', __('The PayPal payment amount does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart'));
963 }
964
965 $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
966 'vendor_charge_id' => Arr::get($capture, 'id', $transaction->vendor_charge_id),
967 'status' => Status::TRANSACTION_SUCCEEDED,
968 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
969 'payment_method_type' => 'PayPal',
970 ]);
971
972 return OrderTransaction::query()->find($transaction->id);
973 }
974
975 if ($captureStatus === 'PENDING') {
976 return new \WP_Error('still_pending', sprintf(
977 /* translators: %1$s: PayPal pending hold reason */
978 __('The payment is still pending at PayPal (reason: %1$s). Please try again later.', 'fluent-cart'),
979 Arr::get($capture, 'status_details.reason', '') ?: 'unknown'
980 ));
981 }
982
983 return new \WP_Error('charge_not_completed', sprintf(
984 /* translators: %1$s: PayPal capture status */
985 __('The PayPal payment could not be completed (status: %1$s).', 'fluent-cart'),
986 $captureStatus !== '' ? $captureStatus : 'unknown'
987 ));
988 }
989
990 /**
991 * Shared outcome derivation for a vault-charged Orders-v2 order: record the
992 * ids for reconciliation, confirm completed captures through the normal
993 * capture path, report settling captures as 'processing', everything else as
994 * a definitive failure with PayPal's reason.
995 *
996 * @return true|string|\WP_Error
997 */
998 private function settleVaultChargeResponse(OrderTransaction $transaction, $paypalOrder)
999 {
1000 $orderStatus = strtoupper((string) Arr::get($paypalOrder, 'status', ''));
1001 $capture = Arr::get($paypalOrder, 'purchase_units.0.payments.captures.0', []);
1002 $captureId = Arr::get($capture, 'id', '');
1003 $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
1004
1005 // Persist ids FIRST — the reconciliation loop and webhook dedup key on them.
1006 $transactionMeta = array_merge($transaction->meta ?? [], [
1007 'paypal_vault_order_id' => Arr::get($paypalOrder, 'id', ''),
1008 ]);
1009 $transactionUpdate = ['meta' => $transactionMeta];
1010 if ($captureId && !$transaction->vendor_charge_id) {
1011 $transactionUpdate['vendor_charge_id'] = $captureId;
1012 }
1013 $transaction->update($transactionUpdate);
1014
1015 if ($captureId && $captureStatus === 'COMPLETED') {
1016 $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
1017 'vendor_charge_id' => $captureId,
1018 'status' => Status::TRANSACTION_SUCCEEDED,
1019 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
1020 'payment_method_type' => 'PayPal',
1021 'payment_source' => Arr::get($paypalOrder, 'payment_source', []),
1022 'meta' => ['payer' => Arr::get($paypalOrder, 'payer', [])],
1023 ]);
1024 return true;
1025 }
1026
1027 if ($captureStatus === 'PENDING' || $orderStatus === 'PENDING') {
1028 return 'processing';
1029 }
1030
1031 $reason = Arr::get($capture, 'status_details.reason', '');
1032
1033 if ($reason) {
1034 /* translators: %1$s: PayPal decline reason code */
1035 $message = sprintf(__('Automatic PayPal charge failed: %1$s', 'fluent-cart'), $reason);
1036 } else {
1037 /* translators: %1$s: PayPal order status */
1038 $message = sprintf(__('Automatic PayPal charge could not be completed (status: %1$s).', 'fluent-cart'), $orderStatus !== '' ? $orderStatus : 'unknown');
1039 }
1040
1041 return new \WP_Error('charge_failed', $message);
1042 }
1043
1044 }
1045