← 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 | { |