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 +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