PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Modules/Subscriptions/Services/SubscriptionService.php +126 -83 1.6.2 → 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')