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.6 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 All 49 releases
← 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