PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 / PayPalSubscriptions.php

PayPalSubscriptions.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Modules/PaymentMethods/PayPalGateway/PayPalSubscriptions.php

844 lines 35.0 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\Helpers\Helper;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Models\OrderTransaction;
8 use FluentCart\App\Models\Subscription;
9 use FluentCart\App\Modules\PaymentMethods\Core\AbstractSubscriptionModule;
10 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
11 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
12 use FluentCart\App\Services\DateTime\DateTime;
13 use FluentCart\Framework\Support\Arr;
14
15 class PayPalSubscriptions extends AbstractSubscriptionModule
16 {
17 /**
18 * Read-only lookup used by the admin "Edit Vendor IDs" verify action.
19 *
20 * PayPal's status vocabulary is its own (ACTIVE / SUSPENDED / CANCELLED), so it is
21 * mapped through SubscriptionManager the same way the resync path does.
22 */
23 public function verifyVendorSubscription(array $args, $mode = 'current')
24 {
25 $vendorSubscriptionId = Arr::get($args, 'vendor_subscription_id');
26
27 if (!$vendorSubscriptionId) {
28 return new \WP_Error('invalid_subscription', __('A Vendor Subscription ID is required to look up a PayPal subscription.', 'fluent-cart'));
29 }
30
31 $subscription = (new API())->verifySubscription($vendorSubscriptionId, $mode);
32
33 if (is_wp_error($subscription)) {
34 return $subscription;
35 }
36
37 $nextBilling = Arr::get($subscription, 'billing_info.next_billing_time');
38
39 return [
40 'id' => Arr::get($subscription, 'id'),
41 'status' => (new SubscriptionManager)->getCorrectSubscriptionStatus(Arr::get($subscription, 'status')),
42 'customer_id' => Arr::get($subscription, 'subscriber.payer_id'),
43 'amount' => Arr::get($subscription, 'billing_info.last_payment.amount.value', ''),
44 'currency' => strtoupper((string) Arr::get($subscription, 'billing_info.last_payment.amount.currency_code')),
45 'next_billing_date' => $nextBilling ? gmdate('Y-m-d H:i:s', strtotime($nextBilling)) : '',
46 ];
47 }
48
49 /**
50 * Fetch the subscription's remote transaction list and sort it ascending by
51 * time — PayPal's own ordering is never trusted, so "earliest" means the same
52 * thing to every consumer (the first-payment check, the resync loop).
53 *
54 * @param array $paypalSubscription already-fetched PayPal subscription payload
55 * @return array|\WP_Error
56 */
57 public function fetchSortedRemoteTransactions(Subscription $subscriptionModel, array $paypalSubscription)
58 {
59 $order = $subscriptionModel->order;
60 if (!$order) {
61 return new \WP_Error(
62 'parent_order_not_found',
63 __('The subscription\'s parent order no longer exists.', 'fluent-cart')
64 );
65 }
66
67 $response = (new API())->getResource('billing/subscriptions/' . $subscriptionModel->vendor_subscription_id . '/transactions', [
68 'start_time' => Arr::get($paypalSubscription, 'start_time'),
69 'end_time' => DateTime::gmtNow()->format('Y-m-d\TH:i:s.v\Z')
70 ], $order->mode);
71
72 if (is_wp_error($response)) {
73 return $response;
74 }
75
76 $paypalTransactions = Arr::get($response, 'transactions', []);
77 usort($paypalTransactions, function ($a, $b) {
78 return strtotime((string) Arr::get($a, 'time')) - strtotime((string) Arr::get($b, 'time'));
79 });
80
81 return $paypalTransactions;
82 }
83
84 /**
85 * Earliest completed sale on the remote list, or null when none completed yet.
86 *
87 * @param array $paypalTransactions sorted output of fetchSortedRemoteTransactions()
88 * @return array|null
89 */
90 public function getEarliestCompletedRemoteSale(array $paypalTransactions)
91 {
92 foreach ($paypalTransactions as $paypalTransaction) {
93 if (strtolower((string) Arr::get($paypalTransaction, 'status')) === 'completed') {
94 return $paypalTransaction;
95 }
96 }
97
98 return null;
99 }
100
101 /**
102 * Credit the extra cycles a sale collected via auto_bill_outstanding: a gross of
103 * exactly k × recurring_total (k >= 2) means k − 1 missed cycles were billed with
104 * this one. Written as an early_payment_history entry, which calculateBillCount()
105 * already folds in. Idempotent per sale id; runs under acquireHistoryLock().
106 *
107 * @param Subscription $subscriptionModel
108 * @param string $saleId
109 * @param int $grossCents
110 * @return bool|\WP_Error true when this call wrote the credit, false when none owed
111 * or already present, WP_Error on lock timeout (unknown —
112 * caller must retry, not record uncredited)
113 */
114 public function creditOutstandingCollection(Subscription $subscriptionModel, $saleId, $grossCents)
115 {
116 $cyclePrice = PayPalHelper::wireCents($subscriptionModel->recurring_total, $subscriptionModel->currency);
117 $grossCents = (int) $grossCents;
118
119 if (!$saleId || $cyclePrice <= 0 || $grossCents <= $cyclePrice) {
120 return false;
121 }
122
123 // payment_failure_threshold is 3, so more than 3 consecutive missed
124 // cycles cannot accumulate — the subscription would have suspended.
125 $maxCyclesPerSale = 4;
126
127 if ($grossCents % $cyclePrice !== 0 || ($grossCents / $cyclePrice) > $maxCyclesPerSale) {
128 fluent_cart_add_log(
129 __('PayPal renewal amount exceeds the cycle price', 'fluent-cart'),
130 sprintf(
131 /* translators: 1: PayPal sale ID, 2: charged amount in cents, 3: cycle price in cents */
132 __('PayPal sale %1$s charged %2$d against a cycle price of %3$d — not an exact cycle multiple, so no extra cycles were credited. Review the subscription\'s billing history manually.', 'fluent-cart'),
133 $saleId,
134 $grossCents,
135 $cyclePrice
136 ),
137 'error',
138 [
139 'module_name' => 'subscription',
140 'module_id' => $subscriptionModel->id
141 ]
142 );
143 return false;
144 }
145
146 $cyclesPaid = (int) ($grossCents / $cyclePrice);
147
148 // normal single-cycle payment, nothing to credit
149 if ($cyclesPaid <= 1) {
150 return false;
151 }
152
153 if (!$this->acquireHistoryLock($subscriptionModel)) {
154 fluent_cart_add_log(
155 __('PayPal outstanding credit deferred — lock timeout', 'fluent-cart'),
156 sprintf(
157 /* translators: %s: PayPal sale ID */
158 __('Could not acquire the billing-history lock while crediting PayPal sale %s; the payment was left unrecorded so a redelivery or scheduled resync can credit and record it together.', 'fluent-cart'),
159 $saleId
160 ),
161 'warning',
162 [
163 'module_name' => 'subscription',
164 'module_id' => $subscriptionModel->id
165 ]
166 );
167 return new \WP_Error(
168 'paypal_history_lock_timeout',
169 __('Could not acquire the billing-history lock to credit this collection.', 'fluent-cart')
170 );
171 }
172
173 try {
174 $history = (array) $subscriptionModel->getMeta('early_payment_history', []);
175
176 foreach ($history as $entry) {
177 if (Arr::get($entry, 'type') === 'outstanding_collection'
178 && Arr::get($entry, 'vendor_charge_id') === $saleId
179 ) {
180 return false;
181 }
182 }
183
184 $history[] = [
185 'type' => 'outstanding_collection',
186 'count' => $cyclesPaid,
187 'vendor_charge_id' => $saleId,
188 'amount' => $grossCents,
189 'date' => DateTime::gmtNow()->format('Y-m-d H:i:s'),
190 ];
191
192 $subscriptionModel->updateMeta('early_payment_history', $history);
193 } finally {
194 $this->releaseHistoryLock($subscriptionModel);
195 }
196
197 fluent_cart_add_log(
198 __('PayPal outstanding balance collected', 'fluent-cart'),
199 sprintf(
200 /* translators: 1: PayPal sale ID, 2: number of cycles the sale covered */
201 __('PayPal sale %1$s collected the outstanding balance of previously missed cycles — one payment covering %2$d billing cycles. The extra cycles were credited to the local bill count.', 'fluent-cart'),
202 $saleId,
203 $cyclesPaid
204 ),
205 'info',
206 [
207 'module_name' => 'subscription',
208 'module_id' => $subscriptionModel->id
209 ]
210 );
211
212 return true;
213 }
214
215 /**
216 * Undo a credit whose renewal recording rolled back. The credit meta commits
217 * outside recordRenewalPayment()'s DB transaction, so it must be revoked by hand.
218 *
219 * @param Subscription $subscriptionModel
220 * @param string $saleId
221 * @return void
222 */
223 public function revokeOutstandingCollection(Subscription $subscriptionModel, $saleId)
224 {
225 if (!$saleId || !$this->acquireHistoryLock($subscriptionModel)) {
226 return;
227 }
228
229 $revoked = false;
230
231 try {
232 $history = (array) $subscriptionModel->getMeta('early_payment_history', []);
233 $kept = [];
234
235 foreach ($history as $entry) {
236 if (Arr::get($entry, 'type') === 'outstanding_collection'
237 && Arr::get($entry, 'vendor_charge_id') === $saleId
238 ) {
239 continue;
240 }
241 $kept[] = $entry;
242 }
243
244 if (count($kept) !== count($history)) {
245 // An empty array does not survive the meta cast round-trip
246 // (it comes back as the string "[]"), so drop the row instead.
247 if ($kept) {
248 $subscriptionModel->updateMeta('early_payment_history', array_values($kept));
249 } else {
250 $subscriptionModel->deleteMeta('early_payment_history');
251 }
252 $revoked = true;
253 }
254 } finally {
255 $this->releaseHistoryLock($subscriptionModel);
256 }
257
258 if ($revoked) {
259 fluent_cart_add_log(
260 __('PayPal outstanding credit revoked', 'fluent-cart'),
261 sprintf(
262 /* translators: 1: PayPal sale ID */
263 __('The outstanding-collection credit for PayPal sale %1$s was revoked because the renewal recording it backed failed. The retry or redelivery that finally records the sale will credit it again.', 'fluent-cart'),
264 $saleId
265 ),
266 'warning',
267 [
268 'module_name' => 'subscription',
269 'module_id' => $subscriptionModel->id
270 ]
271 );
272 }
273 }
274
275 /**
276 * Per-subscription lock for early_payment_history read-modify-writes (the blob
277 * is one row per subscription, so a per-sale lock would let two sales clobber
278 * it). Always released before recordRenewalPayment() takes its per-sale
279 * fc_webhook_ lock — the two are never held together.
280 *
281 * @param Subscription $subscriptionModel
282 * @return bool
283 */
284 private function acquireHistoryLock(Subscription $subscriptionModel)
285 {
286 global $wpdb;
287 return (bool) $wpdb->get_var($wpdb->prepare("SELECT GET_LOCK(%s, 5)", 'fc_sub_history_' . $subscriptionModel->id));
288 }
289
290 /**
291 * @param Subscription $subscriptionModel
292 * @return void
293 */
294 private function releaseHistoryLock(Subscription $subscriptionModel)
295 {
296 global $wpdb;
297 $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", 'fc_sub_history_' . $subscriptionModel->id));
298 }
299
300 /**
301 * Repair local rows against PayPal: bind the earliest completed sale to the
302 * first-cycle transaction, record every missing sale as a dated renewal, sync
303 * status fields.
304 *
305 * @param Subscription $subscriptionModel
306 * @param array|null $paypalSubscription fetched here when null
307 * @param array|null $paypalTransactions sorted list; fetched here when null
308 * @return Subscription|\WP_Error
309 */
310 public function reSyncSubscriptionFromRemote(Subscription $subscriptionModel, $paypalSubscription = null, $paypalTransactions = null)
311 {
312 $order = $subscriptionModel->order;
313 if (!$order) {
314 return new \WP_Error(
315 'parent_order_not_found',
316 __('The subscription\'s parent order no longer exists.', 'fluent-cart')
317 );
318 }
319
320 // The webhook has usually fetched the subscription already (plan
321 // verification) — reuse its payload instead of asking PayPal again.
322 if (!is_array($paypalSubscription)) {
323 $paypalSubscription = (new API())->verifySubscription($subscriptionModel->vendor_subscription_id, $order->mode);
324 }
325
326 if (is_wp_error($paypalSubscription)) {
327 return $paypalSubscription;
328 }
329
330 $newPayment = false;
331
332 $subscriptionStatus = (new SubscriptionManager)->getCorrectSubscriptionStatus(Arr::get($paypalSubscription, 'status'));
333 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time') ?? null;
334
335 $payer = Arr::get($paypalSubscription, 'subscriber', []);
336
337 if ($nextBillingDate) {
338 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($nextBillingDate));
339 }
340
341 $subscriptionUpdateData = array_filter([
342 'current_payment_method' => 'paypal',
343 'status' => $subscriptionStatus,
344 'vendor_customer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
345 'vendor_plan_id' => Arr::get($paypalSubscription, 'plan_id')
346 ]);
347
348 if ($nextBillingDate) {
349 $subscriptionUpdateData['next_billing_date'] = $nextBillingDate;
350 }
351
352 if (Arr::get($paypalSubscription, 'status') === 'CANCELLED') {
353 $statusUpdateTime = Arr::get($paypalSubscription, 'status_update_time');
354 if ($statusUpdateTime) {
355 $subscriptionUpdateData['canceled_at'] = gmdate('Y-m-d H:i:s', strtotime($statusUpdateTime));
356 }
357 }
358
359 // A caller that already pulled and sorted this same list (the IPN
360 // first-payment check) hands it in directly — do not ask PayPal for it
361 // a second time.
362 if ($paypalTransactions === null) {
363 $paypalTransactions = $this->fetchSortedRemoteTransactions($subscriptionModel, $paypalSubscription);
364
365 if (is_wp_error($paypalTransactions)) {
366 return $paypalTransactions;
367 }
368 }
369
370 $completedRemoteIds = [];
371 $completedRemoteSales = [];
372 foreach ($paypalTransactions as $paypalTransaction) {
373 if (strtolower((string) Arr::get($paypalTransaction, 'status')) === 'completed') {
374 $completedRemoteSales[] = $paypalTransaction;
375 $completedRemoteIds[] = Arr::get($paypalTransaction, 'id');
376 }
377 }
378
379 // One indexed query for every locally recorded sale instead of one per
380 // remote transaction — long-running subscriptions carry many cycles.
381 $localByChargeId = [];
382 if ($completedRemoteIds) {
383 $localTransactions = OrderTransaction::query()
384 ->select(['id', 'order_id', 'status', 'meta', 'vendor_charge_id'])
385 ->whereIn('vendor_charge_id', $completedRemoteIds)
386 ->get();
387 foreach ($localTransactions as $localTransaction) {
388 $localByChargeId[$localTransaction->vendor_charge_id] = $localTransaction;
389 }
390 }
391
392 $isEarliestCompleted = true;
393
394 foreach ($paypalTransactions as $paypalTransaction) {
395 if (strtolower((string) Arr::get($paypalTransaction, 'status')) !== 'completed') {
396 continue;
397 }
398
399 // Only the earliest completed sale may claim the first-cycle row —
400 // consume the flag here, even when this sale matches by charge id
401 // and never reaches the claim step.
402 $mayClaimFirstCycle = $isEarliestCompleted;
403 $isEarliestCompleted = false;
404
405 $chargeId = Arr::get($paypalTransaction, 'id');
406 $amount = Helper::toCent(Arr::get($paypalTransaction, 'amount_with_breakdown.gross_amount.value', 0));
407 $settledAt = DateTime::anyTimeToGmt(Arr::get($paypalTransaction, 'time'))->format('Y-m-d H:i:s');
408
409 // Step 1 — sale already recorded locally: make sure it is confirmed.
410 $transaction = isset($localByChargeId[$chargeId]) ? $localByChargeId[$chargeId] : null;
411
412 if ($transaction) {
413 // @TODO Remove this call (and maybeBackfillOutstandingCredit itself)
414 // if recurring_total ever becomes updatable on a live
415 // subscription — see the method's docblock.
416 if (!$mayClaimFirstCycle) {
417 $this->maybeBackfillOutstandingCredit($subscriptionModel, $completedRemoteSales, $chargeId, $amount);
418 }
419 $this->bindSaleToTransaction($transaction, $chargeId, $amount, $payer, $settledAt);
420 continue;
421 }
422
423 // Step 2 — the earliest completed sale claims the first-cycle row:
424 // either it never got its id (missed first-payment webhook) or it
425 // was mis-stamped with a later sale id by the removed IPN fill-in.
426 if ($mayClaimFirstCycle) {
427 $firstCycleTransaction = $this->findClaimableFirstCycleTransaction(
428 $subscriptionModel,
429 $chargeId,
430 $amount,
431 $completedRemoteSales
432 );
433
434 if ($firstCycleTransaction) {
435
436 // A mis-stamped row was preloaded under the id it wrongly
437 // held; drop that stale key so the displaced sale falls
438 // through to Step 3 when its own iteration comes around.
439 if ($firstCycleTransaction->vendor_charge_id) {
440 unset($localByChargeId[$firstCycleTransaction->vendor_charge_id]);
441 }
442
443 $this->bindSaleToTransaction($firstCycleTransaction, $chargeId, $amount, $payer, $settledAt);
444 continue;
445 }
446 }
447
448 // Step 3 — unknown completed sale: record a renewal payment dated
449 // by PayPal's own settle time, not the resync run time. The
450 // outstanding-collection credit goes on the books first —
451 // recordRenewalPayment() recomputes bill_count and the installment
452 // end-of-term inside itself, and the credit is idempotent per sale.
453 // Never for the earliest sale: only a REGULAR-cycle price is an
454 // immutable multiple base — the first sale can carry a signup fee.
455 $credited = $mayClaimFirstCycle
456 ? false
457 : $this->creditOutstandingCollection($subscriptionModel, $chargeId, $amount);
458
459 if (is_wp_error($credited)) {
460 return $credited;
461 }
462
463 $result = SubscriptionService::recordRenewalPayment([
464 'subscription_id' => $subscriptionModel->id,
465 'payment_method' => 'paypal',
466 'vendor_charge_id' => $chargeId,
467 'payment_method_type' => 'PayPal',
468 'total' => $amount,
469 'meta' => [
470 'payer' => $payer,
471 'settled_at' => $settledAt
472 ],
473 'created_at' => $settledAt,
474 ], $subscriptionModel, $subscriptionUpdateData);
475
476 if (is_wp_error($result)) {
477 if ($result->get_error_code() === 'transaction_exists') {
478 continue;
479 }
480
481 if ($result->get_error_code() === 'lock_failed') {
482 // The preloaded map predates the lock wait. Read again before
483 // treating this sale as recorded; its holder may still fail.
484 if ($this->hasRecordedSale($subscriptionModel, $chargeId)) {
485 continue;
486 }
487
488 // Do not revoke credit that the concurrent recorder may need.
489 return $result;
490 }
491
492 if ($credited) {
493 $this->revokeOutstandingCollection($subscriptionModel, $chargeId);
494 }
495
496 return $result;
497 }
498
499 $newPayment = true;
500 }
501
502 if (!$newPayment) {
503 $subscriptionModel = SubscriptionService::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateData);
504 } else {
505 $subscriptionModel = Subscription::query()->find($subscriptionModel->id);
506 }
507
508 return $subscriptionModel;
509 }
510
511 /**
512 * Whether a settled (succeeded or refunded) charge row exists for this sale.
513 *
514 * @param Subscription $subscriptionModel
515 * @param string $saleId
516 * @return bool
517 */
518 public function hasRecordedSale(Subscription $subscriptionModel, $saleId): bool
519 {
520 return OrderTransaction::query()
521 ->where('subscription_id', $subscriptionModel->id)
522 ->where('payment_method', 'paypal')
523 ->where('vendor_charge_id', $saleId)
524 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
525 ->whereIn('status', [Status::TRANSACTION_SUCCEEDED])
526 ->exists();
527 }
528
529 /**
530 * Retroactive outstanding-collection credit for a sale recorded before the credit
531 * existed. Credits only when another completed sale on the list charged exactly
532 * the current recurring_total, since a historical gross is compared against the
533 * CURRENT price. Self-contained: this method and its single Step-1 call are the
534 * whole feature.
535 *
536 * @TODO REMOVE (do not patch) if recurring_total ever becomes updatable on a live
537 * subscription — the comparison is only sound while the vendor plan price is
538 * immutable.
539 *
540 * @param Subscription $subscriptionModel
541 * @param array $completedRemoteSales completed entries of the sorted remote list
542 * @param string $chargeId
543 * @param int $grossCents
544 * @return void
545 */
546 private function maybeBackfillOutstandingCredit(Subscription $subscriptionModel, array $completedRemoteSales, $chargeId, $grossCents)
547 {
548 $cyclePrice = PayPalHelper::wireCents($subscriptionModel->recurring_total, $subscriptionModel->currency);
549
550 if ($cyclePrice <= 0 || (int) $grossCents <= $cyclePrice) {
551 return;
552 }
553
554 $corroborated = false;
555 foreach ($completedRemoteSales as $remoteSale) {
556 if (Arr::get($remoteSale, 'id') === $chargeId) {
557 continue;
558 }
559 if (Helper::toCent(Arr::get($remoteSale, 'amount_with_breakdown.gross_amount.value', 0)) === $cyclePrice) {
560 $corroborated = true;
561 break;
562 }
563 }
564
565 if (!$corroborated) {
566 return;
567 }
568
569 // Best effort by design: this sale is already recorded, and a later
570 // resync retries the backfill, so a lock timeout just skips this pass.
571 if (true === $this->creditOutstandingCollection($subscriptionModel, $chargeId, $grossCents)) {
572 SubscriptionService::syncSubscriptionStates($subscriptionModel, []);
573
574 // The shared credit log reads like a live collection; make the
575 // history say this one was added retroactively by a resync.
576 fluent_cart_add_log(
577 __('PayPal outstanding credit backfilled', 'fluent-cart'),
578 sprintf(
579 /* translators: 1: PayPal sale ID */
580 __('A resync found PayPal sale %1$s already recorded with a multi-cycle gross but no outstanding-collection credit — the credit was added retroactively and the bill count resynced.', 'fluent-cart'),
581 $chargeId
582 ),
583 'info',
584 [
585 'module_name' => 'subscription',
586 'module_id' => $subscriptionModel->id
587 ]
588 );
589 }
590 }
591
592 /**
593 * Find the first-cycle transaction the earliest completed sale should own.
594 * Two shapes, checked in order:
595 * 1. Row never got its vendor_charge_id (missed first-payment webhook).
596 * 2. Row was mis-stamped with a later sale id by the removed IPN fill-in.
597 *
598 * @param Subscription $subscriptionModel
599 * @param string $chargeId earliest completed remote sale id
600 * @param int $amount that sale's gross amount in cents
601 * @param array $completedRemoteSales completed entries of the sorted remote list
602 * @return OrderTransaction|null
603 */
604 private function findClaimableFirstCycleTransaction(Subscription $subscriptionModel, $chargeId, $amount, array $completedRemoteSales)
605 {
606 // The amount cannot be an SQL constraint: a zero-decimal total is stored
607 // x100 but charged rounded, so a stored JPY 100050 arrives back from
608 // PayPal as 100100 and matches no row. Match on what PayPal would
609 // actually have moved instead. Dropping the amount check altogether
610 // would let any unbound charge row claim this sale.
611 // id ASC: two unbound rows can share a wire amount (a missed first-payment
612 // webhook plus a later renewal at the same price), and the earliest sale
613 // owns the oldest row. Without it the claim rides on storage-engine order.
614 $unbound = OrderTransaction::query()
615 ->select(['id', 'order_id', 'status', 'meta', 'vendor_charge_id', 'total', 'currency'])
616 ->where('subscription_id', $subscriptionModel->id)
617 ->where('vendor_charge_id', '')
618 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
619 ->orderBy('id', 'ASC')
620 ->get();
621
622 foreach ($unbound as $candidate) {
623 if (PayPalHelper::wireCents($candidate->total, $candidate->currency) === $amount) {
624 return $candidate;
625 }
626 }
627
628 return $this->findMisStampedFirstCycleTransaction($subscriptionModel, $chargeId, $completedRemoteSales);
629 }
630
631 /**
632 * Bind a completed remote sale to its local transaction. A non-succeeded row
633 * goes through full payment confirmation (order paid, events fire); an already
634 * succeeded row only gets the id column restamped, since its paid side effects
635 * already ran.
636 *
637 * @param OrderTransaction $transaction
638 * @param string $chargeId
639 * @param int $amount
640 * @param array $payer
641 * @param string $settledAt
642 * @return void
643 */
644 public function bindSaleToTransaction(OrderTransaction $transaction, $chargeId, $amount, array $payer, $settledAt)
645 {
646 if ($transaction->status !== Status::TRANSACTION_SUCCEEDED) {
647 (new Processor())->confirmPaymentSuccessByCharge(
648 OrderTransaction::query()->find($transaction->id),
649 [
650 'vendor_charge_id' => $chargeId,
651 'status' => Status::TRANSACTION_SUCCEEDED,
652 'total' => $amount,
653 'payment_method_type' => 'PayPal',
654 'meta' => [
655 'payer' => $payer,
656 'settled_at' => $settledAt
657 ]
658 ]
659 );
660 return;
661 }
662
663 if ($transaction->vendor_charge_id === $chargeId) {
664 return;
665 }
666
667 $meta = array_merge($transaction->meta, ['payer' => $payer]);
668 if (empty($meta['settled_at'])) {
669 $meta['settled_at'] = $settledAt;
670 }
671
672 $transaction->update([
673 'vendor_charge_id' => $chargeId,
674 'status' => Status::TRANSACTION_SUCCEEDED,
675 'payment_method_type' => 'PayPal',
676 'meta' => $meta
677 ]);
678 }
679
680 /**
681 * Find a first-cycle row mis-stamped with a later sale id by the removed IPN
682 * fill-in. Fingerprint: the row predates the sale it carries — a legit row never
683 * does. Restamping frees the displaced id to be recorded as its own renewal.
684 *
685 * @param Subscription $subscriptionModel
686 * @param string $earliestChargeId
687 * @param array $completedRemoteSales completed entries of the sorted remote list
688 * @return OrderTransaction|null
689 */
690 private function findMisStampedFirstCycleTransaction(Subscription $subscriptionModel, $earliestChargeId, array $completedRemoteSales)
691 {
692 // Every completed sale except the earliest, with PayPal's own settle
693 // time — the only ids the old fill-in could have wrongly stamped (it
694 // stamped whichever sale arrived first, not necessarily the 2nd).
695 $laterSaleTimes = [];
696 foreach ($completedRemoteSales as $remoteSale) {
697 $saleId = Arr::get($remoteSale, 'id');
698 if ($saleId && $saleId !== $earliestChargeId) {
699 $laterSaleTimes[$saleId] = strtotime((string) Arr::get($remoteSale, 'time'));
700 }
701 }
702
703 if (!$laterSaleTimes) {
704 return null;
705 }
706
707 // Charge rows claiming a later sale id — no position or order-type
708 // filter, so parent-order and reactivation/switch renewal-order rows
709 // are both covered. id ASC: the mis-stamped row is always the oldest.
710 $candidates = OrderTransaction::query()
711 ->select(['id', 'order_id', 'status', 'meta', 'vendor_charge_id', 'total', 'created_at'])
712 ->where('subscription_id', $subscriptionModel->id)
713 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
714 ->whereIn('vendor_charge_id', array_keys($laterSaleTimes))
715 ->orderBy('id', 'ASC')
716 ->get();
717
718 foreach ($candidates as $candidate) {
719 // A refund was executed at PayPal against the id this row currently
720 // holds — that binding became real; restamping would fork the
721 // ledgers. The displaced sale still gets its own renewal in Step 3.
722 if ((int) Arr::get($candidate->meta, 'refunded_total', 0) > 0) {
723 continue;
724 }
725
726 // Mis-stamp = row created first, id updated later: created_at
727 // predates the claimed sale by a full cycle. A legit row never
728 // does — recordRenewalPayment stamps created_at with the sale's
729 // own settle time. (Pending-invoice rows do predate their sale, but
730 // never hold agreement sale ids — invoices are store-billed only.)
731 // 1h margin covers clock drift; PayPal's minimum interval is daily.
732 // Both timestamps are UTC. Unparseable time on either side → skip.
733 $claimedSaleTime = $laterSaleTimes[$candidate->vendor_charge_id];
734 $rowCreatedTime = strtotime($candidate->created_at . ' UTC');
735
736 if ($claimedSaleTime && $rowCreatedTime && $rowCreatedTime < $claimedSaleTime - HOUR_IN_SECONDS) {
737 return $candidate;
738 }
739 }
740
741 return null;
742 }
743
744 public function cancel($vendorSubscriptionId, $args = [])
745 {
746 if (!$vendorSubscriptionId) {
747 return new \WP_Error('invalid_subscription', __('Invalid vendor subscription ID.', 'fluent-cart'));
748 }
749
750 // first check , before canceling the subscription
751 $paypalSubscription = (new API())->verifySubscription($vendorSubscriptionId, Arr::get($args, 'mode', ''));
752
753 if (is_wp_error($paypalSubscription)) {
754 return $paypalSubscription;
755 }
756
757 $subscriptionStatus = (new SubscriptionManager)->getCorrectSubscriptionStatus(Arr::get($paypalSubscription, 'status'));
758
759 // CANCELLED and EXPIRED are terminal at PayPal — the cancel API rejects them with SUBSCRIPTION_STATUS_INVALID
760 if (in_array($subscriptionStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) {
761 $result = [
762 'status' => $subscriptionStatus
763 ];
764
765 if ($subscriptionStatus === Status::SUBSCRIPTION_CANCELED) {
766 $statusUpdateTime = Arr::get($paypalSubscription, 'status_update_time');
767 $result['canceled_at'] = $statusUpdateTime ? gmdate('Y-m-d H:i:s', strtotime($statusUpdateTime)) : NULL;
768 }
769
770 return $result;
771 }
772
773 $response = API::createResource('billing/subscriptions/' . $vendorSubscriptionId . '/cancel', [
774 'reason' => Arr::get($args, 'reason', __('Subscription canceled.', 'fluent-cart')),
775 ], Arr::get($args, 'mode', ''));
776
777 if (is_wp_error($response)) {
778 return $response;
779 }
780
781 return [
782 'status' => Status::SUBSCRIPTION_CANCELED
783 ];
784 }
785
786 /**
787 * Resume (activate) a suspended PayPal subscription.
788 *
789 * Called by SubscriptionService::resumeSubscription for automatic subscriptions.
790 * Remote first: activates the subscription at PayPal, and only on success flips
791 * the local status and fires the SubscriptionResumed event.
792 *
793 * @param Subscription $subscription
794 * @param string $reason
795 * @return true|\WP_Error
796 */
797 public function resume(Subscription $subscription, $reason = '')
798 {
799 $vendorSubscriptionId = $subscription->vendor_subscription_id;
800
801 if (!$vendorSubscriptionId) {
802 return new \WP_Error('invalid_subscription', __('Invalid vendor subscription ID.', 'fluent-cart'));
803 }
804
805 $order = $subscription->order;
806
807 $response = API::createResource('billing/subscriptions/' . $vendorSubscriptionId . '/activate', [
808 'reason' => $reason ?: __('Subscription resumed.', 'fluent-cart'),
809 ], $order ? $order->mode : '');
810
811 if (is_wp_error($response)) {
812 return $response;
813 }
814
815 $oldStatus = $subscription->status;
816 $subscription->status = Status::SUBSCRIPTION_ACTIVE;
817 $subscription->save();
818
819 $subscription->addLog(
820 'Subscription resumed',
821 $reason ?: __('Subscription resumed via PayPal', 'fluent-cart'),
822 'info'
823 );
824
825 SubscriptionService::dispatchStatusEvent($subscription, 'resumed', [
826 'old_status' => $oldStatus,
827 'reason' => $reason,
828 ]);
829
830 return true;
831 }
832
833 public function getOrCreateNewPlan($subscriptionId, $reason)
834 {
835 (new SubscriptionManager())->getOrCreateNewPlan($subscriptionId, $reason);
836 }
837
838 public function confirmSubscriptionSwitch($data, $subscriptionId)
839 {
840 (new SubscriptionManager())->confirmSubscriptionSwitch($data, $subscriptionId);
841 }
842
843 }
844