← All changes
|
app/Modules/Subscriptions/Services/SubscriptionService.php
+126
-83
1.6.3
→
1.6.5
View file →
| @@ -166,9 +166,9 @@ | ||
| 166 | 166 | ]; |
| 167 | 167 | |
| 168 | 168 | $transactionData = wp_parse_args($transactionData, $transactionDefaults); |
| 169 | 169 | |
| 170 | - $createdAt = Arr::get($transactionData, 'created_at', DateTime::now()->format('Y-m-d H:i:s')); | |
| 170 | + $createdAt = self::normalizeGatewayTime(Arr::get($transactionData, 'created_at')); | |
| 171 | 171 | |
| 172 | 172 | // Let's create the order item first |
| 173 | 173 | $variation = $subscriptionModel->variation; |
| 174 | 174 | $product = $subscriptionModel->product; |
| @@ -181,9 +181,9 @@ | ||
| 181 | 181 | $taxTotal = Arr::get($transactionData, 'tax_total', 0); |
| 182 | 182 | if (!$taxTotal && $subscriptionModel->recurring_tax_total) { |
| 183 | 183 | $taxTotal = $subscriptionModel->recurring_tax_total; |
| 184 | 184 | } |
| 185 | - | |
| 185 | + | |
| 186 | 186 | // A subscription item may be inclusive even when the parent order is mixed (behavior=3). |
| 187 | 187 | // Check the per-item line_meta to determine the actual inclusion for this item. |
| 188 | 188 | $isItemInclusive = $parentOrder->tax_behavior === 2 |
| 189 | 189 | || ($parentOrder->tax_behavior === 3 && $parentOrderItem !== null |
| @@ -247,105 +247,105 @@ | ||
| 247 | 247 | 'subtotal' => $subtotal, |
| 248 | 248 | 'tax_total' => $taxTotal, |
| 249 | 249 | 'total_amount' => $transactionData['total'], |
| 250 | 250 | 'total_paid' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? $transactionData['total'] : 0, |
| 251 | - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s'), | |
| 251 | + 'completed_at' => $createdAt, | |
| 252 | 252 | 'created_at' => $createdAt, |
| 253 | 253 | 'config' => [] |
| 254 | 254 | ]; |
| 255 | 255 | |
| 256 | 256 | try { |
| 257 | - $childOrder = Order::query()->create($childOrderData); | |
| 257 | + $childOrder = Order::query()->create($childOrderData); | |
| 258 | 258 | |
| 259 | - if (!$childOrder) { | |
| 260 | - throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart')); | |
| 261 | - } | |
| 259 | + if (!$childOrder) { | |
| 260 | + throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart')); | |
| 261 | + } | |
| 262 | 262 | |
| 263 | - $billingAddress = $parentOrder->billing_address; | |
| 264 | - $shippingAddress = $parentOrder->shipping_address; | |
| 263 | + $billingAddress = $parentOrder->billing_address; | |
| 264 | + $shippingAddress = $parentOrder->shipping_address; | |
| 265 | 265 | |
| 266 | - $customer = $parentOrder->customer; | |
| 266 | + $customer = $parentOrder->customer; | |
| 267 | 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 | + $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 | + } | |
| 278 | 278 | |
| 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 | - ] : []; | |
| 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 | + ] : []; | |
| 292 | 292 | |
| 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 | - ] : []; | |
| 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 | + ] : []; | |
| 306 | 306 | |
| 307 | - \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( | |
| 308 | - $childOrder->id, | |
| 309 | - $billingAddressData, | |
| 310 | - $shippingAddressData | |
| 311 | - ); | |
| 307 | + \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( | |
| 308 | + $childOrder->id, | |
| 309 | + $billingAddressData, | |
| 310 | + $shippingAddressData | |
| 311 | + ); | |
| 312 | 312 | |
| 313 | - \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'billing', $billingAddress); | |
| 314 | - \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'shipping', $shippingAddress); | |
| 313 | + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'billing', $billingAddress); | |
| 314 | + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'shipping', $shippingAddress); | |
| 315 | 315 | |
| 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 | - } | |
| 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 | - // 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 | - } | |
| 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 | + } | |
| 334 | 334 | |
| 335 | - // Create Order Item | |
| 336 | - $orderItem['order_id'] = $childOrder->id; | |
| 337 | - $orderItem['created_at'] = $createdAt; | |
| 338 | - OrderItem::query()->create($orderItem); | |
| 335 | + // Create Order Item | |
| 336 | + $orderItem['order_id'] = $childOrder->id; | |
| 337 | + $orderItem['created_at'] = $createdAt; | |
| 338 | + OrderItem::query()->create($orderItem); | |
| 339 | 339 | |
| 340 | - // let's create the transaction | |
| 341 | - $transactionData['order_id'] = $childOrder->id; | |
| 340 | + // let's create the transaction | |
| 341 | + $transactionData['order_id'] = $childOrder->id; | |
| 342 | 342 | |
| 343 | - $createdTransaction = OrderTransaction::query()->create($transactionData); | |
| 343 | + $createdTransaction = OrderTransaction::query()->create($transactionData); | |
| 344 | 344 | |
| 345 | - $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs); | |
| 345 | + $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs); | |
| 346 | 346 | |
| 347 | - $wpdb->query('COMMIT'); | |
| 347 | + $wpdb->query('COMMIT'); | |
| 348 | 348 | } catch (\Throwable $e) { |
| 349 | 349 | $wpdb->query('ROLLBACK'); |
| 350 | 350 | if ($lockName) { |
| 351 | 351 | $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)); |
| @@ -364,9 +364,9 @@ | ||
| 364 | 364 | * - next_billing_date - You must provide this if you want to update the next billing date. |
| 365 | 365 | * * - Accepts all other filliable attributes of the Subscription model. |
| 366 | 366 | * @return mixed |
| 367 | 367 | */ |
| 368 | - public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = []) | |
| 368 | + public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = [], $expectedStatus = null) | |
| 369 | 369 | { |
| 370 | 370 | $billsCount = $subscriptionModel->calculateBillCount(); |
| 371 | 371 | |
| 372 | 372 | $subscriptionUpdateArgs['bill_count'] = $billsCount; |
| @@ -394,10 +394,30 @@ | ||
| 394 | 394 | } |
| 395 | 395 | |
| 396 | 396 | $subscriptionModel->fill($subscriptionUpdateArgs); |
| 397 | 397 | $dirtyData = $subscriptionModel->getDirty(); |
| 398 | - $subscriptionModel->save(); | |
| 399 | 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 | + | |
| 400 | 420 | $meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', [])); |
| 401 | 421 | |
| 402 | 422 | foreach ($meta as $key => $value) { |
| 403 | 423 | $subscriptionModel->updateMeta($key, $value); |
| @@ -503,8 +523,10 @@ | ||
| 503 | 523 | public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = []) |
| 504 | 524 | { |
| 505 | 525 | $renewalOrder = $transaction->order; |
| 506 | 526 | |
| 527 | + $settledAt = Arr::get((array) $transaction->meta, 'settled_at'); | |
| 528 | + | |
| 507 | 529 | // payment_status and total_paid are deliberately NOT set here — every caller has |
| 508 | 530 | // already marked the transaction succeeded, and syncOrderStatuses() below derives |
| 509 | 531 | // both from the transactions and claims the pending → paid transition atomically. |
| 510 | 532 | // Pre-setting them destroyed that transition, which (a) suppressed |
| @@ -515,9 +537,9 @@ | ||
| 515 | 537 | $orderUpdateData = [ |
| 516 | 538 | 'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED, |
| 517 | 539 | 'type' => Status::ORDER_TYPE_RENEWAL, |
| 518 | 540 | 'payment_method' => $transaction->payment_method, |
| 519 | - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s') | |
| 541 | + 'completed_at' => self::normalizeGatewayTime($settledAt) | |
| 520 | 542 | ]; |
| 521 | 543 | |
| 522 | 544 | $renewalOrder->fill($orderUpdateData); |
| 523 | 545 | $renewalOrder->save(); |
| @@ -555,8 +577,29 @@ | ||
| 555 | 577 | return $subscriptionModel; |
| 556 | 578 | } |
| 557 | 579 | |
| 558 | 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 | + /** | |
| 559 | 602 | * Single dispatch point for subscription lifecycle status events. |
| 560 | 603 | * |
| 561 | 604 | * Every confirmed transition — manual local update, gateway sync response, or |
| 562 | 605 | * gateway webhook/confirmation — routes through here so the first-class event |
| @@ -970,9 +1013,9 @@ | ||
| 970 | 1013 | 'subscription' => $subscription, |
| 971 | 1014 | 'current_interval' => $subscription->billing_interval, |
| 972 | 1015 | 'new_interval' => $value |
| 973 | 1016 | ]); |
| 974 | - | |
| 1017 | + | |
| 975 | 1018 | if (!in_array($value, $validIntervals)) { |
| 976 | 1019 | return new \WP_Error( |
| 977 | 1020 | 'invalid_interval', |
| 978 | 1021 | __('Invalid billing interval.', 'fluent-cart') |