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
← All changes | app/Modules/PaymentMethods/PayPalGateway/PayPalSubscriptions.php +633 -75 1.6.2 → 1.6.5 View file →
@@ -45,14 +45,285 @@
45 45 'next_billing_date' => $nextBilling ? gmdate('Y-m-d H:i:s', strtotime($nextBilling)) : '',
46 46 ];
47 47 }
48 48
49 - public function reSyncSubscriptionFromRemote(Subscription $subscriptionModel)
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)
50 58 {
51 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 + }
52 66
53 - $paypalSubscription = (new API())->verifySubscription($subscriptionModel->vendor_subscription_id, $order->mode);
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);
54 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 +
55 326 if (is_wp_error($paypalSubscription)) {
56 327 return $paypalSubscription;
57 328 }
58 329
@@ -68,9 +339,11 @@
68 339 }
69 340
70 341 $subscriptionUpdateData = array_filter([
71 342 'current_payment_method' => 'paypal',
72 - 'status' => $subscriptionStatus
343 + 'status' => $subscriptionStatus,
344 + 'vendor_customer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
345 + 'vendor_plan_id' => Arr::get($paypalSubscription, 'plan_id')
73 346 ]);
74 347
75 348 if ($nextBillingDate) {
76 349 $subscriptionUpdateData['next_billing_date'] = $nextBillingDate;
@@ -82,97 +355,149 @@
82 355 $subscriptionUpdateData['canceled_at'] = gmdate('Y-m-d H:i:s', strtotime($statusUpdateTime));
83 356 }
84 357 }
85 358
86 - $startTime = Arr::get($paypalSubscription, 'start_time');
87 - $endTime = DateTime::gmtNow()->format('Y-m-d\TH:i:s.v\Z');
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);
88 364
89 - $response = (new API())->getResource('billing/subscriptions/' . $subscriptionModel->vendor_subscription_id . '/transactions', [
90 - 'start_time' => $startTime,
91 - 'end_time' => $endTime
92 - ], $order->mode);
365 + if (is_wp_error($paypalTransactions)) {
366 + return $paypalTransactions;
367 + }
368 + }
93 369
94 - if (is_wp_error($response)) {
95 - return $response;
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 + }
96 377 }
97 378
98 - // reverse the array to get the latest transaction last
99 - $paypalTransactions = array_reverse(Arr::get($response, 'transactions', []));
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 + }
100 391
392 + $isEarliestCompleted = true;
101 393
102 - if (!empty($paypalTransactions)) {
103 - foreach ($paypalTransactions as $paypalTransaction) {
394 + foreach ($paypalTransactions as $paypalTransaction) {
395 + if (strtolower((string) Arr::get($paypalTransaction, 'status')) !== 'completed') {
396 + continue;
397 + }
104 398
105 - $amount = Helper::toCent(Arr::get($paypalTransaction, 'amount_with_breakdown.gross_amount.value', 0));
106 - $chargeId = Arr::get($paypalTransaction, 'id');
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;
107 404
108 - $status = strtolower(Arr::get($paypalTransaction, 'status'));
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');
109 408
110 - if ($status == 'completed') {
111 - // PayPal reports when the remote charge completed; that is the
112 - // settlement moment, not this resync's run time.
113 - $settledAt = DateTime::anyTimeToGmt(Arr::get($paypalTransaction, 'time'))->format('Y-m-d H:i:s');
409 + // Step 1 — sale already recorded locally: make sure it is confirmed.
410 + $transaction = isset($localByChargeId[$chargeId]) ? $localByChargeId[$chargeId] : null;
114 411
115 - // status drives the branch below and meta is merged on update,
116 - // so both must be selected — omitting them would read null and
117 - // wipe unrelated meta keys.
118 - $transaction = OrderTransaction::query()
119 - ->select(['id', 'order_id', 'status', 'meta'])
120 - ->where('vendor_charge_id', $chargeId)
121 - ->first();
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 + }
122 422
123 - if (!$transaction) {
124 - // check if any transaction related to this subscription exists without vendor_charge_id, mainly for first cycle payment
125 - $transaction = OrderTransaction::query()
126 - ->select(['id', 'order_id', 'status', 'meta'])
127 - ->where('subscription_id', $subscriptionModel->id)
128 - ->where('vendor_charge_id', '')
129 - ->where('total', $amount)
130 - ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
131 - ->first();
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 + );
132 433
133 - if ($transaction) {
134 - $meta = array_merge($transaction->meta, ['payer' => $payer]);
135 - if (empty($meta['settled_at'])) {
136 - $meta['settled_at'] = $settledAt;
137 - }
138 - $transaction->update([
139 - 'vendor_charge_id' => $chargeId,
140 - 'status' => Status::TRANSACTION_SUCCEEDED,
141 - 'payment_method_type' => 'PayPal',
142 - 'meta' => $meta
143 - ]);
144 - continue;
145 - }
434 + if ($firstCycleTransaction) {
146 435
147 - $transactionData = [
148 - 'subscription_id' => $subscriptionModel->id,
149 - 'payment_method' => 'paypal',
150 - 'vendor_charge_id' => $chargeId,
151 - 'payment_method_type' => 'PayPal',
152 - 'total' => $amount,
153 - 'meta' => [
154 - 'payer' => $payer,
155 - 'settled_at' => $settledAt
156 - ],
157 - 'created_at' => DateTime::anyTimeToGmt(Arr::get($paypalTransaction, 'time'))->format('Y-m-d H:i:s'),
158 - ];
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 + }
159 442
160 - $newPayment = true;
161 - SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData);
162 - } else if ($transaction->status !== Status::TRANSACTION_SUCCEEDED) {
163 - $meta = array_merge($transaction->meta, ['payer' => $payer]);
164 - if (empty($meta['settled_at'])) {
165 - $meta['settled_at'] = $settledAt;
166 - }
167 - $transaction->update([
168 - 'vendor_charge_id' => $chargeId,
169 - 'status' => Status::TRANSACTION_SUCCEEDED,
170 - 'meta' => $meta
171 - ]);
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;
172 486 }
487 +
488 + // Do not revoke credit that the concurrent recorder may need.
489 + return $result;
173 490 }
491 +
492 + if ($credited) {
493 + $this->revokeOutstandingCollection($subscriptionModel, $chargeId);
494 + }
495 +
496 + return $result;
174 497 }
498 +
499 + $newPayment = true;
175 500 }
176 501
177 502 if (!$newPayment) {
178 503 $subscriptionModel = SubscriptionService::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateData);
@@ -180,8 +505,241 @@
180 505 $subscriptionModel = Subscription::query()->find($subscriptionModel->id);
181 506 }
182 507
183 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;
184 742 }
185 743
186 744 public function cancel($vendorSubscriptionId, $args = [])
187 745 {