← All changes
|
app/Modules/Subscriptions/Services/SubscriptionService.php
+273
-83
1.6.0
→
1.6.5
View file →
| @@ -73,9 +73,12 @@ | ||
| 73 | 73 | $acquired = (bool) $wpdb->get_var($wpdb->prepare("SELECT GET_LOCK(%s, 5)", $lockName)); |
| 74 | 74 | if (!$acquired) { |
| 75 | 75 | return new \WP_Error('lock_failed', __('Duplicate webhook processing in progress.', 'fluent-cart')); |
| 76 | 76 | } |
| 77 | - if (OrderTransaction::query()->where('vendor_charge_id', $vendorTransactionId)->exists()) { | |
| 77 | + if (OrderTransaction::query() | |
| 78 | + ->where('vendor_charge_id', $vendorTransactionId) | |
| 79 | + ->where('status', '!=', Status::TRANSACTION_FAILED) | |
| 80 | + ->exists()) { | |
| 78 | 81 | $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)); |
| 79 | 82 | return new \WP_Error('transaction_exists', __('This transaction already exists for this subscription.', 'fluent-cart')); |
| 80 | 83 | } |
| 81 | 84 | } |
| @@ -98,13 +101,31 @@ | ||
| 98 | 101 | |
| 99 | 102 | if ($existingInvoice) { |
| 100 | 103 | $existingTransaction = OrderTransaction::query() |
| 101 | 104 | ->where('order_id', $existingInvoice->id) |
| 102 | - ->where('status', Status::TRANSACTION_PENDING) | |
| 105 | + ->where(function ($query) use ($vendorTransactionId) { | |
| 106 | + $query->where('status', Status::TRANSACTION_PENDING) | |
| 107 | + ->orWhere(function ($query) use ($vendorTransactionId) { | |
| 108 | + // A failed row only stands in for the invoice if it's the same | |
| 109 | + // PaymentIntent being retried — otherwise it's an unrelated attempt. | |
| 110 | + $query->where('status', Status::TRANSACTION_FAILED) | |
| 111 | + ->where('vendor_charge_id', $vendorTransactionId); | |
| 112 | + }); | |
| 113 | + }) | |
| 103 | 114 | ->orderBy('id', 'DESC') |
| 104 | 115 | ->first(); |
| 105 | 116 | |
| 106 | 117 | if ($existingTransaction) { |
| 118 | + // Gateways that know the exact remote charge time pass it as | |
| 119 | + // meta.settled_at; carry it onto the pending invoice's transaction | |
| 120 | + // (empty-only, same contract as the model hook's fallback stamp). | |
| 121 | + $settledAt = Arr::get($transactionData, 'meta.settled_at'); | |
| 122 | + if ($settledAt && empty($existingTransaction->meta['settled_at'])) { | |
| 123 | + $existingTransaction->meta = array_merge($existingTransaction->meta, [ | |
| 124 | + 'settled_at' => $settledAt | |
| 125 | + ]); | |
| 126 | + } | |
| 127 | + | |
| 107 | 128 | $transactionUpdateData = array_filter([ |
| 108 | 129 | 'total' => $transactionData['total'] ?? $existingTransaction->total, |
| 109 | 130 | 'status' => Status::TRANSACTION_SUCCEEDED, |
| 110 | 131 | 'payment_method' => $transactionData['payment_method'] ?? $existingTransaction->payment_method, |
| @@ -122,8 +143,12 @@ | ||
| 122 | 143 | 'billing_info' => $billingInfo, |
| 123 | 144 | 'subscription_args' => $subscriptionUpdateArgs, |
| 124 | 145 | ]); |
| 125 | 146 | |
| 147 | + if ($lockName) { | |
| 148 | + $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)); | |
| 149 | + } | |
| 150 | + | |
| 126 | 151 | return $existingTransaction; |
| 127 | 152 | } |
| 128 | 153 | } |
| 129 | 154 | |
| @@ -141,9 +166,9 @@ | ||
| 141 | 166 | ]; |
| 142 | 167 | |
| 143 | 168 | $transactionData = wp_parse_args($transactionData, $transactionDefaults); |
| 144 | 169 | |
| 145 | - $createdAt = Arr::get($transactionData, 'created_at', DateTime::now()->format('Y-m-d H:i:s')); | |
| 170 | + $createdAt = self::normalizeGatewayTime(Arr::get($transactionData, 'created_at')); | |
| 146 | 171 | |
| 147 | 172 | // Let's create the order item first |
| 148 | 173 | $variation = $subscriptionModel->variation; |
| 149 | 174 | $product = $subscriptionModel->product; |
| @@ -156,9 +181,9 @@ | ||
| 156 | 181 | $taxTotal = Arr::get($transactionData, 'tax_total', 0); |
| 157 | 182 | if (!$taxTotal && $subscriptionModel->recurring_tax_total) { |
| 158 | 183 | $taxTotal = $subscriptionModel->recurring_tax_total; |
| 159 | 184 | } |
| 160 | - | |
| 185 | + | |
| 161 | 186 | // A subscription item may be inclusive even when the parent order is mixed (behavior=3). |
| 162 | 187 | // Check the per-item line_meta to determine the actual inclusion for this item. |
| 163 | 188 | $isItemInclusive = $parentOrder->tax_behavior === 2 |
| 164 | 189 | || ($parentOrder->tax_behavior === 3 && $parentOrderItem !== null |
| @@ -222,102 +247,105 @@ | ||
| 222 | 247 | 'subtotal' => $subtotal, |
| 223 | 248 | 'tax_total' => $taxTotal, |
| 224 | 249 | 'total_amount' => $transactionData['total'], |
| 225 | 250 | 'total_paid' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? $transactionData['total'] : 0, |
| 226 | - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s'), | |
| 251 | + 'completed_at' => $createdAt, | |
| 227 | 252 | 'created_at' => $createdAt, |
| 228 | 253 | 'config' => [] |
| 229 | 254 | ]; |
| 230 | 255 | |
| 231 | 256 | try { |
| 232 | - $childOrder = Order::query()->create($childOrderData); | |
| 257 | + $childOrder = Order::query()->create($childOrderData); | |
| 233 | 258 | |
| 234 | - if (!$childOrder) { | |
| 235 | - throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart')); | |
| 236 | - } | |
| 259 | + if (!$childOrder) { | |
| 260 | + throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart')); | |
| 261 | + } | |
| 237 | 262 | |
| 238 | - $billingAddress = $parentOrder->billing_address; | |
| 239 | - $shippingAddress = $parentOrder->shipping_address; | |
| 263 | + $billingAddress = $parentOrder->billing_address; | |
| 264 | + $shippingAddress = $parentOrder->shipping_address; | |
| 240 | 265 | |
| 241 | - $customer = $parentOrder->customer; | |
| 266 | + $customer = $parentOrder->customer; | |
| 242 | 267 | |
| 243 | - $fullName = ''; | |
| 244 | - $email = ''; | |
| 245 | - $firstName = ''; | |
| 246 | - $lastName = ''; | |
| 247 | - if ($customer) { | |
| 248 | - $fullName = $customer->first_name . ' ' . $customer->last_name; | |
| 249 | - $email = $customer->email; | |
| 250 | - $firstName = $customer->first_name; | |
| 251 | - $lastName = $customer->last_name; | |
| 252 | - } | |
| 268 | + $fullName = ''; | |
| 269 | + $email = ''; | |
| 270 | + $firstName = ''; | |
| 271 | + $lastName = ''; | |
| 272 | + if ($customer) { | |
| 273 | + $fullName = $customer->first_name . ' ' . $customer->last_name; | |
| 274 | + $email = $customer->email; | |
| 275 | + $firstName = $customer->first_name; | |
| 276 | + $lastName = $customer->last_name; | |
| 277 | + } | |
| 253 | 278 | |
| 254 | - $billingAddressData = $billingAddress ? [ | |
| 255 | - 'type' => 'billing', | |
| 256 | - 'full_name' => $fullName, | |
| 257 | - 'address_1' => $billingAddress->address_1, | |
| 258 | - 'address_2' => $billingAddress->address_2, | |
| 259 | - 'city' => $billingAddress->city, | |
| 260 | - 'state' => $billingAddress->state, | |
| 261 | - 'postcode' => $billingAddress->postcode, | |
| 262 | - 'country' => $billingAddress->country, | |
| 263 | - 'email' => $email, | |
| 264 | - 'first_name' => $firstName, | |
| 265 | - 'last_name' => $lastName | |
| 266 | - ] : []; | |
| 279 | + $billingAddressData = $billingAddress ? [ | |
| 280 | + 'type' => 'billing', | |
| 281 | + 'full_name' => $fullName, | |
| 282 | + 'address_1' => $billingAddress->address_1, | |
| 283 | + 'address_2' => $billingAddress->address_2, | |
| 284 | + 'city' => $billingAddress->city, | |
| 285 | + 'state' => $billingAddress->state, | |
| 286 | + 'postcode' => $billingAddress->postcode, | |
| 287 | + 'country' => $billingAddress->country, | |
| 288 | + 'email' => $email, | |
| 289 | + 'first_name' => $firstName, | |
| 290 | + 'last_name' => $lastName | |
| 291 | + ] : []; | |
| 267 | 292 | |
| 268 | - $shippingAddressData = $shippingAddress ? [ | |
| 269 | - 'type' => 'shipping', | |
| 270 | - 'full_name' => $fullName, | |
| 271 | - 'address_1' => $shippingAddress->address_1, | |
| 272 | - 'address_2' => $shippingAddress->address_2, | |
| 273 | - 'city' => $shippingAddress->city, | |
| 274 | - 'state' => $shippingAddress->state, | |
| 275 | - 'postcode' => $shippingAddress->postcode, | |
| 276 | - 'country' => $shippingAddress->country, | |
| 277 | - 'email' => $email, | |
| 278 | - 'first_name' => $firstName, | |
| 279 | - 'last_name' => $lastName | |
| 280 | - ] : []; | |
| 293 | + $shippingAddressData = $shippingAddress ? [ | |
| 294 | + 'type' => 'shipping', | |
| 295 | + 'full_name' => $fullName, | |
| 296 | + 'address_1' => $shippingAddress->address_1, | |
| 297 | + 'address_2' => $shippingAddress->address_2, | |
| 298 | + 'city' => $shippingAddress->city, | |
| 299 | + 'state' => $shippingAddress->state, | |
| 300 | + 'postcode' => $shippingAddress->postcode, | |
| 301 | + 'country' => $shippingAddress->country, | |
| 302 | + 'email' => $email, | |
| 303 | + 'first_name' => $firstName, | |
| 304 | + 'last_name' => $lastName | |
| 305 | + ] : []; | |
| 281 | 306 | |
| 282 | - \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( | |
| 283 | - $childOrder->id, | |
| 284 | - $billingAddressData, | |
| 285 | - $shippingAddressData | |
| 286 | - ); | |
| 307 | + \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( | |
| 308 | + $childOrder->id, | |
| 309 | + $billingAddressData, | |
| 310 | + $shippingAddressData | |
| 311 | + ); | |
| 287 | 312 | |
| 288 | - // Copy tax ID meta from parent order if exists | |
| 289 | - $parentTaxId = $parentOrder->getMeta('tax_id', ''); | |
| 290 | - if ($parentTaxId) { | |
| 291 | - $childOrder->updateMeta('tax_id', $parentTaxId); | |
| 292 | - } | |
| 313 | + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'billing', $billingAddress); | |
| 314 | + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'shipping', $shippingAddress); | |
| 293 | 315 | |
| 294 | - // Copy order tax rates from parent order | |
| 295 | - $parentTaxRates = $parentOrder->orderTaxRates; | |
| 296 | - foreach ($parentTaxRates as $taxRate) { | |
| 297 | - OrderTaxRate::query()->create([ | |
| 298 | - 'order_id' => $childOrder->id, | |
| 299 | - 'tax_rate_id' => $taxRate->tax_rate_id, | |
| 300 | - 'shipping_tax' => $taxRate->shipping_tax, | |
| 301 | - 'order_tax' => $taxRate->order_tax, | |
| 302 | - 'total_tax' => $taxRate->total_tax, | |
| 303 | - 'meta' => $taxRate->meta, | |
| 304 | - ]); | |
| 305 | - } | |
| 316 | + // Copy tax ID meta from parent order if exists | |
| 317 | + $parentTaxId = $parentOrder->getMeta('tax_id', ''); | |
| 318 | + if ($parentTaxId) { | |
| 319 | + $childOrder->updateMeta('tax_id', $parentTaxId); | |
| 320 | + } | |
| 306 | 321 | |
| 307 | - // Create Order Item | |
| 308 | - $orderItem['order_id'] = $childOrder->id; | |
| 309 | - $orderItem['created_at'] = $createdAt; | |
| 310 | - OrderItem::query()->create($orderItem); | |
| 322 | + // Copy order tax rates from parent order | |
| 323 | + $parentTaxRates = $parentOrder->orderTaxRates; | |
| 324 | + foreach ($parentTaxRates as $taxRate) { | |
| 325 | + OrderTaxRate::query()->create([ | |
| 326 | + 'order_id' => $childOrder->id, | |
| 327 | + 'tax_rate_id' => $taxRate->tax_rate_id, | |
| 328 | + 'shipping_tax' => $taxRate->shipping_tax, | |
| 329 | + 'order_tax' => $taxRate->order_tax, | |
| 330 | + 'total_tax' => $taxRate->total_tax, | |
| 331 | + 'meta' => $taxRate->meta, | |
| 332 | + ]); | |
| 333 | + } | |
| 311 | 334 | |
| 312 | - // let's create the transaction | |
| 313 | - $transactionData['order_id'] = $childOrder->id; | |
| 335 | + // Create Order Item | |
| 336 | + $orderItem['order_id'] = $childOrder->id; | |
| 337 | + $orderItem['created_at'] = $createdAt; | |
| 338 | + OrderItem::query()->create($orderItem); | |
| 314 | 339 | |
| 315 | - $createdTransaction = OrderTransaction::query()->create($transactionData); | |
| 340 | + // let's create the transaction | |
| 341 | + $transactionData['order_id'] = $childOrder->id; | |
| 316 | 342 | |
| 317 | - $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs); | |
| 343 | + $createdTransaction = OrderTransaction::query()->create($transactionData); | |
| 318 | 344 | |
| 319 | - $wpdb->query('COMMIT'); | |
| 345 | + $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs); | |
| 346 | + | |
| 347 | + $wpdb->query('COMMIT'); | |
| 320 | 348 | } catch (\Throwable $e) { |
| 321 | 349 | $wpdb->query('ROLLBACK'); |
| 322 | 350 | if ($lockName) { |
| 323 | 351 | $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)); |
| @@ -336,9 +364,9 @@ | ||
| 336 | 364 | * - next_billing_date - You must provide this if you want to update the next billing date. |
| 337 | 365 | * * - Accepts all other filliable attributes of the Subscription model. |
| 338 | 366 | * @return mixed |
| 339 | 367 | */ |
| 340 | - public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = []) | |
| 368 | + public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = [], $expectedStatus = null) | |
| 341 | 369 | { |
| 342 | 370 | $billsCount = $subscriptionModel->calculateBillCount(); |
| 343 | 371 | |
| 344 | 372 | $subscriptionUpdateArgs['bill_count'] = $billsCount; |
| @@ -366,10 +394,30 @@ | ||
| 366 | 394 | } |
| 367 | 395 | |
| 368 | 396 | $subscriptionModel->fill($subscriptionUpdateArgs); |
| 369 | 397 | $dirtyData = $subscriptionModel->getDirty(); |
| 370 | - $subscriptionModel->save(); | |
| 371 | 398 | |
| 399 | + if ($expectedStatus !== null) { | |
| 400 | + // Compare-and-swap: only write if the row still holds the expected status, so a | |
| 401 | + // concurrent transition (e.g. a renewal payment reactivating a past_due row) is | |
| 402 | + // never clobbered. On a lost race, skip all side effects below. | |
| 403 | + $writable = $dirtyData; | |
| 404 | + unset($writable['meta']); | |
| 405 | + | |
| 406 | + $affected = Subscription::query() | |
| 407 | + ->where('id', $subscriptionModel->id) | |
| 408 | + ->where('status', $expectedStatus) | |
| 409 | + ->update($writable); | |
| 410 | + | |
| 411 | + if (!$affected) { | |
| 412 | + return null; | |
| 413 | + } | |
| 414 | + | |
| 415 | + $subscriptionModel->syncOriginal(); | |
| 416 | + } else { | |
| 417 | + $subscriptionModel->save(); | |
| 418 | + } | |
| 419 | + | |
| 372 | 420 | $meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', [])); |
| 373 | 421 | |
| 374 | 422 | foreach ($meta as $key => $value) { |
| 375 | 423 | $subscriptionModel->updateMeta($key, $value); |
| @@ -475,8 +523,10 @@ | ||
| 475 | 523 | public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = []) |
| 476 | 524 | { |
| 477 | 525 | $renewalOrder = $transaction->order; |
| 478 | 526 | |
| 527 | + $settledAt = Arr::get((array) $transaction->meta, 'settled_at'); | |
| 528 | + | |
| 479 | 529 | // payment_status and total_paid are deliberately NOT set here — every caller has |
| 480 | 530 | // already marked the transaction succeeded, and syncOrderStatuses() below derives |
| 481 | 531 | // both from the transactions and claims the pending → paid transition atomically. |
| 482 | 532 | // Pre-setting them destroyed that transition, which (a) suppressed |
| @@ -487,9 +537,9 @@ | ||
| 487 | 537 | $orderUpdateData = [ |
| 488 | 538 | 'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED, |
| 489 | 539 | 'type' => Status::ORDER_TYPE_RENEWAL, |
| 490 | 540 | 'payment_method' => $transaction->payment_method, |
| 491 | - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s') | |
| 541 | + 'completed_at' => self::normalizeGatewayTime($settledAt) | |
| 492 | 542 | ]; |
| 493 | 543 | |
| 494 | 544 | $renewalOrder->fill($orderUpdateData); |
| 495 | 545 | $renewalOrder->save(); |
| @@ -527,8 +577,29 @@ | ||
| 527 | 577 | return $subscriptionModel; |
| 528 | 578 | } |
| 529 | 579 | |
| 530 | 580 | /** |
| 581 | + * A gateway-supplied charge time (meta.settled_at / created_at) normalized to a | |
| 582 | + * GMT datetime string, falling back to now when absent or unparseable — a | |
| 583 | + * malformed timestamp must never fatal a webhook. | |
| 584 | + * | |
| 585 | + * @param mixed $time | |
| 586 | + * @return string | |
| 587 | + */ | |
| 588 | + private static function normalizeGatewayTime($time) | |
| 589 | + { | |
| 590 | + if ($time) { | |
| 591 | + try { | |
| 592 | + return DateTime::anyTimeToGmt($time)->format('Y-m-d H:i:s'); | |
| 593 | + } catch (\Exception $e) { | |
| 594 | + // fall through to now | |
| 595 | + } | |
| 596 | + } | |
| 597 | + | |
| 598 | + return DateTime::now()->format('Y-m-d H:i:s'); | |
| 599 | + } | |
| 600 | + | |
| 601 | + /** | |
| 531 | 602 | * Single dispatch point for subscription lifecycle status events. |
| 532 | 603 | * |
| 533 | 604 | * Every confirmed transition — manual local update, gateway sync response, or |
| 534 | 605 | * gateway webhook/confirmation — routes through here so the first-class event |
| @@ -942,9 +1013,9 @@ | ||
| 942 | 1013 | 'subscription' => $subscription, |
| 943 | 1014 | 'current_interval' => $subscription->billing_interval, |
| 944 | 1015 | 'new_interval' => $value |
| 945 | 1016 | ]); |
| 946 | - | |
| 1017 | + | |
| 947 | 1018 | if (!in_array($value, $validIntervals)) { |
| 948 | 1019 | return new \WP_Error( |
| 949 | 1020 | 'invalid_interval', |
| 950 | 1021 | __('Invalid billing interval.', 'fluent-cart') |
| @@ -1182,8 +1253,127 @@ | ||
| 1182 | 1253 | } |
| 1183 | 1254 | } |
| 1184 | 1255 | |
| 1185 | 1256 | return true; |
| 1257 | + } | |
| 1258 | + | |
| 1259 | + /** | |
| 1260 | + * Correct the gateway identifiers on an automatic subscription. | |
| 1261 | + * | |
| 1262 | + * Deliberately separate from updateSubscription(): nothing here touches | |
| 1263 | + * billing state, so no renewal is voided, no invoice re-synced and no | |
| 1264 | + * status event dispatched. Only the two identifier columns move. | |
| 1265 | + * | |
| 1266 | + * @param array $data vendor_subscription_id and/or vendor_customer_id | |
| 1267 | + * @return true|\WP_Error | |
| 1268 | + */ | |
| 1269 | + public static function updateVendorIds(Subscription $subscription, array $data) | |
| 1270 | + { | |
| 1271 | + if (!$subscription->canEditVendorIds()) { | |
| 1272 | + return new \WP_Error( | |
| 1273 | + 'cannot_edit_vendor_ids', | |
| 1274 | + __('Vendor IDs can only be edited on an active gateway-billed subscription.', 'fluent-cart') | |
| 1275 | + ); | |
| 1276 | + } | |
| 1277 | + | |
| 1278 | + $updates = []; | |
| 1279 | + $changes = []; | |
| 1280 | + | |
| 1281 | + foreach (['vendor_subscription_id', 'vendor_customer_id'] as $field) { | |
| 1282 | + if (!array_key_exists($field, $data)) { | |
| 1283 | + continue; | |
| 1284 | + } | |
| 1285 | + | |
| 1286 | + $value = trim((string) $data[$field]); | |
| 1287 | + $oldValue = (string) $subscription->{$field}; | |
| 1288 | + | |
| 1289 | + if ($oldValue === $value) { | |
| 1290 | + continue; | |
| 1291 | + } | |
| 1292 | + | |
| 1293 | + $updates[$field] = $value; | |
| 1294 | + $changes[] = sprintf( | |
| 1295 | + '%1$s: %2$s → %3$s', | |
| 1296 | + $field, | |
| 1297 | + $oldValue !== '' ? $oldValue : '(none)', | |
| 1298 | + $value !== '' ? $value : '(none)' | |
| 1299 | + ); | |
| 1300 | + } | |
| 1301 | + | |
| 1302 | + if (empty($updates)) { | |
| 1303 | + return new \WP_Error( | |
| 1304 | + 'no_changes', | |
| 1305 | + __('No changes detected.', 'fluent-cart') | |
| 1306 | + ); | |
| 1307 | + } | |
| 1308 | + | |
| 1309 | + // fct_subscriptions indexes vendor_subscription_id but does not enforce | |
| 1310 | + // uniqueness, and every gateway IPN resolves its subscription through | |
| 1311 | + // that column — a duplicate would silently route webhooks into the wrong | |
| 1312 | + // row. A gateway never reissues an id inside its own account, so the | |
| 1313 | + // collision that matters is same-gateway. | |
| 1314 | + // | |
| 1315 | + // Claim it with one statement rather than SELECT-then-save: the anti-join | |
| 1316 | + // makes "nobody else holds this id" part of the UPDATE itself, so two | |
| 1317 | + // concurrent edits racing for the same id cannot both pass the check. | |
| 1318 | + // Zero affected rows means the other one won. | |
| 1319 | + if (!empty($updates['vendor_subscription_id'])) { | |
| 1320 | + if (!self::claimVendorSubscriptionId($subscription, $updates)) { | |
| 1321 | + return new \WP_Error( | |
| 1322 | + 'vendor_subscription_id_taken', | |
| 1323 | + __('Another subscription on this payment method is already using this Vendor Subscription ID.', 'fluent-cart') | |
| 1324 | + ); | |
| 1325 | + } | |
| 1326 | + | |
| 1327 | + $subscription->fill($updates)->syncOriginal(); | |
| 1328 | + } else { | |
| 1329 | + $subscription->fill($updates)->save(); | |
| 1330 | + } | |
| 1331 | + | |
| 1332 | + $subscription->addLog( | |
| 1333 | + 'Vendor IDs updated', | |
| 1334 | + sprintf('Admin updated: %s', implode(', ', $changes)), | |
| 1335 | + 'info' | |
| 1336 | + ); | |
| 1337 | + | |
| 1338 | + return true; | |
| 1339 | + } | |
| 1340 | + | |
| 1341 | + /** | |
| 1342 | + * Write the vendor identifiers only if no other subscription on the same | |
| 1343 | + * payment method already holds the incoming vendor_subscription_id. | |
| 1344 | + * | |
| 1345 | + * The anti-join makes the check part of the write, so the check-then-write | |
| 1346 | + * window a separate SELECT would leave open does not exist. | |
| 1347 | + * | |
| 1348 | + * @return bool false when another row already holds the id | |
| 1349 | + */ | |
| 1350 | + private static function claimVendorSubscriptionId(Subscription $subscription, array $updates): bool | |
| 1351 | + { | |
| 1352 | + $newId = $updates['vendor_subscription_id']; | |
| 1353 | + $method = (string) $subscription->current_payment_method; | |
| 1354 | + | |
| 1355 | + $values = ['s.vendor_subscription_id' => $newId]; | |
| 1356 | + | |
| 1357 | + if (array_key_exists('vendor_customer_id', $updates)) { | |
| 1358 | + $values['s.vendor_customer_id'] = $updates['vendor_customer_id']; | |
| 1359 | + } | |
| 1360 | + | |
| 1361 | + $values['s.updated_at'] = DateTime::gmtNow()->format('Y-m-d H:i:s'); | |
| 1362 | + | |
| 1363 | + $affected = Subscription::query() | |
| 1364 | + ->getConnection() | |
| 1365 | + ->table('fct_subscriptions as s') | |
| 1366 | + ->leftJoin('fct_subscriptions as o', function ($join) use ($newId, $method) { | |
| 1367 | + $join->on('o.id', '<>', 's.id') | |
| 1368 | + ->where('o.vendor_subscription_id', '=', $newId) | |
| 1369 | + ->where('o.current_payment_method', '=', $method); | |
| 1370 | + }) | |
| 1371 | + ->where('s.id', $subscription->id) | |
| 1372 | + ->whereNull('o.id') | |
| 1373 | + ->update($values); | |
| 1374 | + | |
| 1375 | + return (int) $affected > 0; | |
| 1186 | 1376 | } |
| 1187 | 1377 | |
| 1188 | 1378 | /** |
| 1189 | 1379 | * Single cancellation chokepoint. Voids open renewals and dispatches the |