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