← All changes
|
app/Modules/Subscriptions/Services/SubscriptionService.php
+256
-81
1.6.1
→
1.6.5
View file →
| @@ -114,8 +114,18 @@ | ||
| 114 | 114 | ->orderBy('id', 'DESC') |
| 115 | 115 | ->first(); |
| 116 | 116 | |
| 117 | 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 | + | |
| 118 | 128 | $transactionUpdateData = array_filter([ |
| 119 | 129 | 'total' => $transactionData['total'] ?? $existingTransaction->total, |
| 120 | 130 | 'status' => Status::TRANSACTION_SUCCEEDED, |
| 121 | 131 | 'payment_method' => $transactionData['payment_method'] ?? $existingTransaction->payment_method, |
| @@ -156,9 +166,9 @@ | ||
| 156 | 166 | ]; |
| 157 | 167 | |
| 158 | 168 | $transactionData = wp_parse_args($transactionData, $transactionDefaults); |
| 159 | 169 | |
| 160 | - $createdAt = Arr::get($transactionData, 'created_at', DateTime::now()->format('Y-m-d H:i:s')); | |
| 170 | + $createdAt = self::normalizeGatewayTime(Arr::get($transactionData, 'created_at')); | |
| 161 | 171 | |
| 162 | 172 | // Let's create the order item first |
| 163 | 173 | $variation = $subscriptionModel->variation; |
| 164 | 174 | $product = $subscriptionModel->product; |
| @@ -171,9 +181,9 @@ | ||
| 171 | 181 | $taxTotal = Arr::get($transactionData, 'tax_total', 0); |
| 172 | 182 | if (!$taxTotal && $subscriptionModel->recurring_tax_total) { |
| 173 | 183 | $taxTotal = $subscriptionModel->recurring_tax_total; |
| 174 | 184 | } |
| 175 | - | |
| 185 | + | |
| 176 | 186 | // A subscription item may be inclusive even when the parent order is mixed (behavior=3). |
| 177 | 187 | // Check the per-item line_meta to determine the actual inclusion for this item. |
| 178 | 188 | $isItemInclusive = $parentOrder->tax_behavior === 2 |
| 179 | 189 | || ($parentOrder->tax_behavior === 3 && $parentOrderItem !== null |
| @@ -237,102 +247,105 @@ | ||
| 237 | 247 | 'subtotal' => $subtotal, |
| 238 | 248 | 'tax_total' => $taxTotal, |
| 239 | 249 | 'total_amount' => $transactionData['total'], |
| 240 | 250 | 'total_paid' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? $transactionData['total'] : 0, |
| 241 | - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s'), | |
| 251 | + 'completed_at' => $createdAt, | |
| 242 | 252 | 'created_at' => $createdAt, |
| 243 | 253 | 'config' => [] |
| 244 | 254 | ]; |
| 245 | 255 | |
| 246 | 256 | try { |
| 247 | - $childOrder = Order::query()->create($childOrderData); | |
| 257 | + $childOrder = Order::query()->create($childOrderData); | |
| 248 | 258 | |
| 249 | - if (!$childOrder) { | |
| 250 | - throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart')); | |
| 251 | - } | |
| 259 | + if (!$childOrder) { | |
| 260 | + throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart')); | |
| 261 | + } | |
| 252 | 262 | |
| 253 | - $billingAddress = $parentOrder->billing_address; | |
| 254 | - $shippingAddress = $parentOrder->shipping_address; | |
| 263 | + $billingAddress = $parentOrder->billing_address; | |
| 264 | + $shippingAddress = $parentOrder->shipping_address; | |
| 255 | 265 | |
| 256 | - $customer = $parentOrder->customer; | |
| 266 | + $customer = $parentOrder->customer; | |
| 257 | 267 | |
| 258 | - $fullName = ''; | |
| 259 | - $email = ''; | |
| 260 | - $firstName = ''; | |
| 261 | - $lastName = ''; | |
| 262 | - if ($customer) { | |
| 263 | - $fullName = $customer->first_name . ' ' . $customer->last_name; | |
| 264 | - $email = $customer->email; | |
| 265 | - $firstName = $customer->first_name; | |
| 266 | - $lastName = $customer->last_name; | |
| 267 | - } | |
| 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 | + } | |
| 268 | 278 | |
| 269 | - $billingAddressData = $billingAddress ? [ | |
| 270 | - 'type' => 'billing', | |
| 271 | - 'full_name' => $fullName, | |
| 272 | - 'address_1' => $billingAddress->address_1, | |
| 273 | - 'address_2' => $billingAddress->address_2, | |
| 274 | - 'city' => $billingAddress->city, | |
| 275 | - 'state' => $billingAddress->state, | |
| 276 | - 'postcode' => $billingAddress->postcode, | |
| 277 | - 'country' => $billingAddress->country, | |
| 278 | - 'email' => $email, | |
| 279 | - 'first_name' => $firstName, | |
| 280 | - 'last_name' => $lastName | |
| 281 | - ] : []; | |
| 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 | + ] : []; | |
| 282 | 292 | |
| 283 | - $shippingAddressData = $shippingAddress ? [ | |
| 284 | - 'type' => 'shipping', | |
| 285 | - 'full_name' => $fullName, | |
| 286 | - 'address_1' => $shippingAddress->address_1, | |
| 287 | - 'address_2' => $shippingAddress->address_2, | |
| 288 | - 'city' => $shippingAddress->city, | |
| 289 | - 'state' => $shippingAddress->state, | |
| 290 | - 'postcode' => $shippingAddress->postcode, | |
| 291 | - 'country' => $shippingAddress->country, | |
| 292 | - 'email' => $email, | |
| 293 | - 'first_name' => $firstName, | |
| 294 | - 'last_name' => $lastName | |
| 295 | - ] : []; | |
| 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 | + ] : []; | |
| 296 | 306 | |
| 297 | - \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( | |
| 298 | - $childOrder->id, | |
| 299 | - $billingAddressData, | |
| 300 | - $shippingAddressData | |
| 301 | - ); | |
| 307 | + \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( | |
| 308 | + $childOrder->id, | |
| 309 | + $billingAddressData, | |
| 310 | + $shippingAddressData | |
| 311 | + ); | |
| 302 | 312 | |
| 303 | - // Copy tax ID meta from parent order if exists | |
| 304 | - $parentTaxId = $parentOrder->getMeta('tax_id', ''); | |
| 305 | - if ($parentTaxId) { | |
| 306 | - $childOrder->updateMeta('tax_id', $parentTaxId); | |
| 307 | - } | |
| 313 | + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'billing', $billingAddress); | |
| 314 | + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'shipping', $shippingAddress); | |
| 308 | 315 | |
| 309 | - // Copy order tax rates from parent order | |
| 310 | - $parentTaxRates = $parentOrder->orderTaxRates; | |
| 311 | - foreach ($parentTaxRates as $taxRate) { | |
| 312 | - OrderTaxRate::query()->create([ | |
| 313 | - 'order_id' => $childOrder->id, | |
| 314 | - 'tax_rate_id' => $taxRate->tax_rate_id, | |
| 315 | - 'shipping_tax' => $taxRate->shipping_tax, | |
| 316 | - 'order_tax' => $taxRate->order_tax, | |
| 317 | - 'total_tax' => $taxRate->total_tax, | |
| 318 | - 'meta' => $taxRate->meta, | |
| 319 | - ]); | |
| 320 | - } | |
| 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 | + } | |
| 321 | 321 | |
| 322 | - // Create Order Item | |
| 323 | - $orderItem['order_id'] = $childOrder->id; | |
| 324 | - $orderItem['created_at'] = $createdAt; | |
| 325 | - 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 | + } | |
| 326 | 334 | |
| 327 | - // let's create the transaction | |
| 328 | - $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); | |
| 329 | 339 | |
| 330 | - $createdTransaction = OrderTransaction::query()->create($transactionData); | |
| 340 | + // let's create the transaction | |
| 341 | + $transactionData['order_id'] = $childOrder->id; | |
| 331 | 342 | |
| 332 | - $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs); | |
| 343 | + $createdTransaction = OrderTransaction::query()->create($transactionData); | |
| 333 | 344 | |
| 334 | - $wpdb->query('COMMIT'); | |
| 345 | + $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs); | |
| 346 | + | |
| 347 | + $wpdb->query('COMMIT'); | |
| 335 | 348 | } catch (\Throwable $e) { |
| 336 | 349 | $wpdb->query('ROLLBACK'); |
| 337 | 350 | if ($lockName) { |
| 338 | 351 | $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)); |
| @@ -351,9 +364,9 @@ | ||
| 351 | 364 | * - next_billing_date - You must provide this if you want to update the next billing date. |
| 352 | 365 | * * - Accepts all other filliable attributes of the Subscription model. |
| 353 | 366 | * @return mixed |
| 354 | 367 | */ |
| 355 | - public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = []) | |
| 368 | + public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = [], $expectedStatus = null) | |
| 356 | 369 | { |
| 357 | 370 | $billsCount = $subscriptionModel->calculateBillCount(); |
| 358 | 371 | |
| 359 | 372 | $subscriptionUpdateArgs['bill_count'] = $billsCount; |
| @@ -381,10 +394,30 @@ | ||
| 381 | 394 | } |
| 382 | 395 | |
| 383 | 396 | $subscriptionModel->fill($subscriptionUpdateArgs); |
| 384 | 397 | $dirtyData = $subscriptionModel->getDirty(); |
| 385 | - $subscriptionModel->save(); | |
| 386 | 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 | + | |
| 387 | 420 | $meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', [])); |
| 388 | 421 | |
| 389 | 422 | foreach ($meta as $key => $value) { |
| 390 | 423 | $subscriptionModel->updateMeta($key, $value); |
| @@ -490,8 +523,10 @@ | ||
| 490 | 523 | public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = []) |
| 491 | 524 | { |
| 492 | 525 | $renewalOrder = $transaction->order; |
| 493 | 526 | |
| 527 | + $settledAt = Arr::get((array) $transaction->meta, 'settled_at'); | |
| 528 | + | |
| 494 | 529 | // payment_status and total_paid are deliberately NOT set here — every caller has |
| 495 | 530 | // already marked the transaction succeeded, and syncOrderStatuses() below derives |
| 496 | 531 | // both from the transactions and claims the pending → paid transition atomically. |
| 497 | 532 | // Pre-setting them destroyed that transition, which (a) suppressed |
| @@ -502,9 +537,9 @@ | ||
| 502 | 537 | $orderUpdateData = [ |
| 503 | 538 | 'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED, |
| 504 | 539 | 'type' => Status::ORDER_TYPE_RENEWAL, |
| 505 | 540 | 'payment_method' => $transaction->payment_method, |
| 506 | - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s') | |
| 541 | + 'completed_at' => self::normalizeGatewayTime($settledAt) | |
| 507 | 542 | ]; |
| 508 | 543 | |
| 509 | 544 | $renewalOrder->fill($orderUpdateData); |
| 510 | 545 | $renewalOrder->save(); |
| @@ -542,8 +577,29 @@ | ||
| 542 | 577 | return $subscriptionModel; |
| 543 | 578 | } |
| 544 | 579 | |
| 545 | 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 | + /** | |
| 546 | 602 | * Single dispatch point for subscription lifecycle status events. |
| 547 | 603 | * |
| 548 | 604 | * Every confirmed transition — manual local update, gateway sync response, or |
| 549 | 605 | * gateway webhook/confirmation — routes through here so the first-class event |
| @@ -957,9 +1013,9 @@ | ||
| 957 | 1013 | 'subscription' => $subscription, |
| 958 | 1014 | 'current_interval' => $subscription->billing_interval, |
| 959 | 1015 | 'new_interval' => $value |
| 960 | 1016 | ]); |
| 961 | - | |
| 1017 | + | |
| 962 | 1018 | if (!in_array($value, $validIntervals)) { |
| 963 | 1019 | return new \WP_Error( |
| 964 | 1020 | 'invalid_interval', |
| 965 | 1021 | __('Invalid billing interval.', 'fluent-cart') |
| @@ -1197,8 +1253,127 @@ | ||
| 1197 | 1253 | } |
| 1198 | 1254 | } |
| 1199 | 1255 | |
| 1200 | 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; | |
| 1201 | 1376 | } |
| 1202 | 1377 | |
| 1203 | 1378 | /** |
| 1204 | 1379 | * Single cancellation chokepoint. Voids open renewals and dispatches the |