← All changes
|
app/Modules/Subscriptions/Services/SubscriptionService.php
+1156
-99
1.3.19
→
1.6.6
View file →
| @@ -1,10 +1,17 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace FluentCart\App\Modules\Subscriptions\Services; |
| 4 | 4 | |
| 5 | +use FluentCart\App\App; | |
| 6 | +use FluentCart\App\Events\Subscription\SubscriptionCanceled; | |
| 5 | 7 | use FluentCart\App\Events\Subscription\SubscriptionEOT; |
| 8 | +use FluentCart\App\Events\Subscription\SubscriptionPaused; | |
| 9 | +use FluentCart\App\Events\Subscription\SubscriptionPeriodSkipped; | |
| 10 | +use FluentCart\App\Events\Subscription\SubscriptionReactivated; | |
| 6 | 11 | use FluentCart\App\Events\Subscription\SubscriptionRenewed; |
| 12 | +use FluentCart\App\Events\Subscription\SubscriptionResumed; | |
| 13 | +use FluentCart\App\Events\Subscription\SubscriptionUpdated; | |
| 7 | 14 | use FluentCart\App\Events\Subscription\SubscriptionValidityExpired; |
| 8 | 15 | use FluentCart\App\Helpers\Status; |
| 9 | 16 | use FluentCart\App\Helpers\StatusHelper; |
| 10 | 17 | use FluentCart\App\Models\Order; |
| @@ -11,8 +18,10 @@ | ||
| 11 | 18 | use FluentCart\App\Models\OrderItem; |
| 12 | 19 | use FluentCart\App\Models\OrderTaxRate; |
| 13 | 20 | use FluentCart\App\Models\OrderTransaction; |
| 14 | 21 | use FluentCart\App\Models\Subscription; |
| 22 | +use FluentCart\App\Modules\StoreManagedRenewal\Services\RenewalService; | |
| 23 | +use WP_Error; | |
| 15 | 24 | use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API; |
| 16 | 25 | use FluentCart\App\Services\DateTime\DateTime; |
| 17 | 26 | use FluentCart\App\Services\Payments\PaymentHelper; |
| 18 | 27 | use FluentCart\Framework\Support\Arr; |
| @@ -18,8 +27,33 @@ | ||
| 18 | 27 | use FluentCart\Framework\Support\Arr; |
| 19 | 28 | |
| 20 | 29 | class SubscriptionService |
| 21 | 30 | { |
| 31 | + /** | |
| 32 | + * Record a gateway-managed (automatic collection) renewal payment. | |
| 33 | + * | |
| 34 | + * Called from gateway recurring-charge webhooks — Stripe invoice.paid, PayPal IPN, | |
| 35 | + * Mollie, Paddle, Authorize.Net. Creates the renewal child order ALREADY PAID | |
| 36 | + * (payment_status = paid, total_paid = total) in a single insert. | |
| 37 | + * | |
| 38 | + * Because there is no pending → paid transition and syncOrderStatuses() is never | |
| 39 | + * called on the new order, this path does NOT fire fluent_cart/renewal_paid. That | |
| 40 | + * is intentional: both listeners on that hook (RenewalService::handleRenewalPaid, | |
| 41 | + * SystemChargeService::cancelPendingCharge) are scoped to manual/system collection, | |
| 42 | + * and for automatic subscriptions the gateway owns next_billing_date. The renewal is | |
| 43 | + * announced here by dispatching SubscriptionRenewed instead — the one event that | |
| 44 | + * covers both renewal paths. Anything that must react to every renewal regardless of | |
| 45 | + * collection method belongs on SubscriptionRenewed, not on renewal_paid. | |
| 46 | + * | |
| 47 | + * Exception: when a pending/scheduled invoice already exists for this subscription, | |
| 48 | + * this method delegates to recordManualRenewal() below, which DOES go through | |
| 49 | + * syncOrderStatuses() and therefore does fire renewal_paid. | |
| 50 | + * | |
| 51 | + * @param array $transactionData | |
| 52 | + * @param Subscription|null $subscriptionModel | |
| 53 | + * @param array $subscriptionUpdateArgs | |
| 54 | + * @return OrderTransaction|\WP_Error | |
| 55 | + */ | |
| 22 | 56 | public static function recordRenewalPayment($transactionData, $subscriptionModel = null, $subscriptionUpdateArgs = []) |
| 23 | 57 | { |
| 24 | 58 | if (!$subscriptionModel) { |
| 25 | 59 | $subscriptionModel = Subscription::query()->find($transactionData['subscription_id']); |
| @@ -30,10 +64,22 @@ | ||
| 30 | 64 | } |
| 31 | 65 | |
| 32 | 66 | $vendorTransactionId = $transactionData['vendor_charge_id'] ?? null; |
| 33 | 67 | |
| 68 | + global $wpdb; | |
| 69 | + $lockName = null; | |
| 70 | + | |
| 34 | 71 | if ($vendorTransactionId) { |
| 35 | - if (OrderTransaction::query()->where('vendor_charge_id', $vendorTransactionId)->exists()) { | |
| 72 | + $lockName = 'fc_webhook_' . $vendorTransactionId; | |
| 73 | + $acquired = (bool) $wpdb->get_var($wpdb->prepare("SELECT GET_LOCK(%s, 5)", $lockName)); | |
| 74 | + if (!$acquired) { | |
| 75 | + return new \WP_Error('lock_failed', __('Duplicate webhook processing in progress.', 'fluent-cart')); | |
| 76 | + } | |
| 77 | + if (OrderTransaction::query() | |
| 78 | + ->where('vendor_charge_id', $vendorTransactionId) | |
| 79 | + ->where('status', '!=', Status::TRANSACTION_FAILED) | |
| 80 | + ->exists()) { | |
| 81 | + $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)); | |
| 36 | 82 | return new \WP_Error('transaction_exists', __('This transaction already exists for this subscription.', 'fluent-cart')); |
| 37 | 83 | } |
| 38 | 84 | } |
| 39 | 85 | |
| @@ -42,8 +88,71 @@ | ||
| 42 | 88 | if (!$parentOrder) { |
| 43 | 89 | return new \WP_Error('parent_order_not_found', __('Parent order not found for this subscription.', 'fluent-cart')); |
| 44 | 90 | } |
| 45 | 91 | |
| 92 | + // If a pending manual invoice exists for this subscription, process it instead of | |
| 93 | + // creating a new renewal order. This handles the case where a manual renewal invoice | |
| 94 | + // was paid via a gateway that converts the subscription to automatic (e.g. Stripe), | |
| 95 | + // and the actual charge fires as a subscription_cycle webhook after a deferred period. | |
| 96 | + $existingInvoice = Order::query() | |
| 97 | + ->where('parent_id', $parentOrder->id) | |
| 98 | + ->where('type', Status::ORDER_TYPE_RENEWAL) | |
| 99 | + ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) | |
| 100 | + ->first(); | |
| 101 | + | |
| 102 | + if ($existingInvoice) { | |
| 103 | + $existingTransaction = OrderTransaction::query() | |
| 104 | + ->where('order_id', $existingInvoice->id) | |
| 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 | + }) | |
| 114 | + ->orderBy('id', 'DESC') | |
| 115 | + ->first(); | |
| 116 | + | |
| 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 | + | |
| 128 | + $transactionUpdateData = array_filter([ | |
| 129 | + 'total' => $transactionData['total'] ?? $existingTransaction->total, | |
| 130 | + 'status' => Status::TRANSACTION_SUCCEEDED, | |
| 131 | + 'payment_method' => $transactionData['payment_method'] ?? $existingTransaction->payment_method, | |
| 132 | + 'vendor_charge_id' => $transactionData['vendor_charge_id'] ?? null, | |
| 133 | + 'card_last_4' => $transactionData['card_last_4'] ?? '', | |
| 134 | + 'card_brand' => $transactionData['card_brand'] ?? '', | |
| 135 | + 'payment_method_type' => $transactionData['payment_method_type'] ?? '', | |
| 136 | + ]); | |
| 137 | + $existingTransaction->update($transactionUpdateData); | |
| 138 | + $existingTransaction = OrderTransaction::query()->find($existingTransaction->id); | |
| 139 | + | |
| 140 | + $billingInfo = $subscriptionModel->getMeta('active_payment_method', []) ?: []; | |
| 141 | + | |
| 142 | + static::recordManualRenewal($subscriptionModel, $existingTransaction, [ | |
| 143 | + 'billing_info' => $billingInfo, | |
| 144 | + 'subscription_args' => $subscriptionUpdateArgs, | |
| 145 | + ]); | |
| 146 | + | |
| 147 | + if ($lockName) { | |
| 148 | + $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)); | |
| 149 | + } | |
| 150 | + | |
| 151 | + return $existingTransaction; | |
| 152 | + } | |
| 153 | + } | |
| 154 | + | |
| 46 | 155 | $transactionDefaults = [ |
| 47 | 156 | 'order_id' => $parentOrder->id, |
| 48 | 157 | 'subscription_id' => $subscriptionModel->id, |
| 49 | 158 | 'order_type' => Status::ORDER_TYPE_RENEWAL, |
| @@ -57,9 +166,9 @@ | ||
| 57 | 166 | ]; |
| 58 | 167 | |
| 59 | 168 | $transactionData = wp_parse_args($transactionData, $transactionDefaults); |
| 60 | 169 | |
| 61 | - $createdAt = Arr::get($transactionData, 'created_at', DateTime::now()->format('Y-m-d H:i:s')); | |
| 170 | + $createdAt = self::normalizeGatewayTime(Arr::get($transactionData, 'created_at')); | |
| 62 | 171 | |
| 63 | 172 | // Let's create the order item first |
| 64 | 173 | $variation = $subscriptionModel->variation; |
| 65 | 174 | $product = $subscriptionModel->product; |
| @@ -72,10 +181,16 @@ | ||
| 72 | 181 | $taxTotal = Arr::get($transactionData, 'tax_total', 0); |
| 73 | 182 | if (!$taxTotal && $subscriptionModel->recurring_tax_total) { |
| 74 | 183 | $taxTotal = $subscriptionModel->recurring_tax_total; |
| 75 | 184 | } |
| 76 | - | |
| 77 | - if (!$taxTotal && $parentOrder->tax_behavior == 2 && $parentOrderItem) { | |
| 185 | + | |
| 186 | + // A subscription item may be inclusive even when the parent order is mixed (behavior=3). | |
| 187 | + // Check the per-item line_meta to determine the actual inclusion for this item. | |
| 188 | + $isItemInclusive = $parentOrder->tax_behavior === 2 | |
| 189 | + || ($parentOrder->tax_behavior === 3 && $parentOrderItem !== null | |
| 190 | + && (bool) Arr::get((array) $parentOrderItem->line_meta, 'tax_config.inclusive', false)); | |
| 191 | + | |
| 192 | + if (!$taxTotal && $isItemInclusive && $parentOrderItem) { | |
| 78 | 193 | $taxTotal = (int) Arr::get($parentOrderItem->other_info, 'recurring_tax', 0); |
| 79 | 194 | } |
| 80 | 195 | |
| 81 | 196 | $subtotal = $transactionData['total']; |
| @@ -113,8 +228,10 @@ | ||
| 113 | 228 | } |
| 114 | 229 | |
| 115 | 230 | $fulfillmentType = $orderItem['fulfillment_type']; |
| 116 | 231 | |
| 232 | + $wpdb->query('START TRANSACTION'); | |
| 233 | + | |
| 117 | 234 | // Let's create the order first |
| 118 | 235 | $childOrderData = [ |
| 119 | 236 | 'parent_id' => $parentOrder->id, |
| 120 | 237 | 'fulfillment_type' => $fulfillmentType, |
| @@ -130,100 +247,113 @@ | ||
| 130 | 247 | 'subtotal' => $subtotal, |
| 131 | 248 | 'tax_total' => $taxTotal, |
| 132 | 249 | 'total_amount' => $transactionData['total'], |
| 133 | 250 | 'total_paid' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? $transactionData['total'] : 0, |
| 134 | - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s'), | |
| 251 | + 'completed_at' => $createdAt, | |
| 135 | 252 | 'created_at' => $createdAt, |
| 136 | 253 | 'config' => [] |
| 137 | 254 | ]; |
| 138 | 255 | |
| 139 | - $childOrder = Order::query()->create($childOrderData); | |
| 256 | + try { | |
| 257 | + $childOrder = Order::query()->create($childOrderData); | |
| 140 | 258 | |
| 141 | - if (!$childOrder) { | |
| 142 | - return new \WP_Error('order_creation_failed', __('Failed to create child order for the subscription renewal.', 'fluent-cart')); | |
| 143 | - } | |
| 259 | + if (!$childOrder) { | |
| 260 | + throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart')); | |
| 261 | + } | |
| 144 | 262 | |
| 145 | - $billingAddress = $parentOrder->billing_address; | |
| 146 | - $shippingAddress = $parentOrder->shipping_address; | |
| 263 | + $billingAddress = $parentOrder->billing_address; | |
| 264 | + $shippingAddress = $parentOrder->shipping_address; | |
| 147 | 265 | |
| 148 | - $customer = $parentOrder->customer; | |
| 266 | + $customer = $parentOrder->customer; | |
| 149 | 267 | |
| 150 | - $fullName = ''; | |
| 151 | - $email = ''; | |
| 152 | - $firstName = ''; | |
| 153 | - $lastName = ''; | |
| 154 | - if ($customer) { | |
| 155 | - $fullName = $customer->first_name . ' ' . $customer->last_name; | |
| 156 | - $email = $customer->email; | |
| 157 | - $firstName = $customer->first_name; | |
| 158 | - $lastName = $customer->last_name; | |
| 159 | - } | |
| 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 | + } | |
| 160 | 278 | |
| 161 | - $billingAddressData = $billingAddress ? [ | |
| 162 | - 'type' => 'billing', | |
| 163 | - 'full_name' => $fullName, | |
| 164 | - 'address_1' => $billingAddress->address_1, | |
| 165 | - 'address_2' => $billingAddress->address_2, | |
| 166 | - 'city' => $billingAddress->city, | |
| 167 | - 'state' => $billingAddress->state, | |
| 168 | - 'postcode' => $billingAddress->postcode, | |
| 169 | - 'country' => $billingAddress->country, | |
| 170 | - 'email' => $email, | |
| 171 | - 'first_name' => $firstName, | |
| 172 | - 'last_name' => $lastName | |
| 173 | - ] : []; | |
| 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 | + ] : []; | |
| 174 | 292 | |
| 175 | - $shippingAddressData = $shippingAddress ? [ | |
| 176 | - 'type' => 'shipping', | |
| 177 | - 'full_name' => $fullName, | |
| 178 | - 'address_1' => $shippingAddress->address_1, | |
| 179 | - 'address_2' => $shippingAddress->address_2, | |
| 180 | - 'city' => $shippingAddress->city, | |
| 181 | - 'state' => $shippingAddress->state, | |
| 182 | - 'postcode' => $shippingAddress->postcode, | |
| 183 | - 'country' => $shippingAddress->country, | |
| 184 | - 'email' => $email, | |
| 185 | - 'first_name' => $firstName, | |
| 186 | - 'last_name' => $lastName | |
| 187 | - ] : []; | |
| 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 | + ] : []; | |
| 188 | 306 | |
| 189 | - \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( | |
| 190 | - $childOrder->id, | |
| 191 | - $billingAddressData, | |
| 192 | - $shippingAddressData | |
| 193 | - ); | |
| 307 | + \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( | |
| 308 | + $childOrder->id, | |
| 309 | + $billingAddressData, | |
| 310 | + $shippingAddressData | |
| 311 | + ); | |
| 194 | 312 | |
| 195 | - // Copy tax ID meta from parent order if exists | |
| 196 | - $parentTaxId = $parentOrder->getMeta('tax_id', ''); | |
| 197 | - if ($parentTaxId) { | |
| 198 | - $childOrder->updateMeta('tax_id', $parentTaxId); | |
| 199 | - } | |
| 313 | + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'billing', $billingAddress); | |
| 314 | + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'shipping', $shippingAddress); | |
| 200 | 315 | |
| 201 | - // Copy order tax rates from parent order | |
| 202 | - $parentTaxRates = $parentOrder->orderTaxRates; | |
| 203 | - foreach ($parentTaxRates as $taxRate) { | |
| 204 | - OrderTaxRate::query()->create([ | |
| 205 | - 'order_id' => $childOrder->id, | |
| 206 | - 'tax_rate_id' => $taxRate->tax_rate_id, | |
| 207 | - 'shipping_tax' => $taxRate->shipping_tax, | |
| 208 | - 'order_tax' => $taxRate->order_tax, | |
| 209 | - 'total_tax' => $taxRate->total_tax, | |
| 210 | - 'meta' => $taxRate->meta, | |
| 211 | - ]); | |
| 212 | - } | |
| 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 | + } | |
| 213 | 321 | |
| 214 | - // Create Order Item | |
| 215 | - $orderItem['order_id'] = $childOrder->id; | |
| 216 | - $orderItem['created_at'] = $createdAt; | |
| 217 | - 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 | + } | |
| 218 | 334 | |
| 219 | - // let's create the transaction | |
| 220 | - $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); | |
| 221 | 339 | |
| 222 | - $createdTransaction = OrderTransaction::query()->create($transactionData); | |
| 340 | + // let's create the transaction | |
| 341 | + $transactionData['order_id'] = $childOrder->id; | |
| 223 | 342 | |
| 224 | - $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs); | |
| 343 | + $createdTransaction = OrderTransaction::query()->create($transactionData); | |
| 225 | 344 | |
| 345 | + $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs); | |
| 346 | + | |
| 347 | + $wpdb->query('COMMIT'); | |
| 348 | + } catch (\Throwable $e) { | |
| 349 | + $wpdb->query('ROLLBACK'); | |
| 350 | + if ($lockName) { | |
| 351 | + $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)); | |
| 352 | + } | |
| 353 | + return new \WP_Error('renewal_failed', $e->getMessage()); | |
| 354 | + } | |
| 355 | + | |
| 226 | 356 | (new SubscriptionRenewed($subscriptionModel, $childOrder, $parentOrder, $childOrder->customer))->dispatch(); |
| 227 | 357 | |
| 228 | 358 | return $createdTransaction; |
| 229 | 359 | } |
| @@ -234,26 +364,12 @@ | ||
| 234 | 364 | * - next_billing_date - You must provide this if you want to update the next billing date. |
| 235 | 365 | * * - Accepts all other filliable attributes of the Subscription model. |
| 236 | 366 | * @return mixed |
| 237 | 367 | */ |
| 238 | - public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = []) | |
| 368 | + public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = [], $expectedStatus = null) | |
| 239 | 369 | { |
| 240 | - $billsCount = OrderTransaction::query() | |
| 241 | - ->where('subscription_id', $subscriptionModel->id) | |
| 242 | - ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE) | |
| 243 | - ->where('status', Status::TRANSACTION_SUCCEEDED) | |
| 244 | - ->where('total', '>', 0) | |
| 245 | - ->count(); | |
| 370 | + $billsCount = $subscriptionModel->calculateBillCount(); | |
| 246 | 371 | |
| 247 | - | |
| 248 | - $earlyPaymentHistory = $subscriptionModel->getMeta('early_payment_history', []); | |
| 249 | - foreach ($earlyPaymentHistory as $earlyPayment) { | |
| 250 | - $paidCount = (int) Arr::get($earlyPayment, 'count', 1); | |
| 251 | - if ($paidCount > 1) { | |
| 252 | - $billsCount += ($paidCount - 1); | |
| 253 | - } | |
| 254 | - } | |
| 255 | - | |
| 256 | 372 | $subscriptionUpdateArgs['bill_count'] = $billsCount; |
| 257 | 373 | $billTimes = $subscriptionModel->bill_times; |
| 258 | 374 | $oldStatus = $subscriptionModel->status; |
| 259 | 375 | |
| @@ -278,10 +394,30 @@ | ||
| 278 | 394 | } |
| 279 | 395 | |
| 280 | 396 | $subscriptionModel->fill($subscriptionUpdateArgs); |
| 281 | 397 | $dirtyData = $subscriptionModel->getDirty(); |
| 282 | - $subscriptionModel->save(); | |
| 283 | 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 | + | |
| 284 | 420 | $meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', [])); |
| 285 | 421 | |
| 286 | 422 | foreach ($meta as $key => $value) { |
| 287 | 423 | $subscriptionModel->updateMeta($key, $value); |
| @@ -286,8 +422,21 @@ | ||
| 286 | 422 | foreach ($meta as $key => $value) { |
| 287 | 423 | $subscriptionModel->updateMeta($key, $value); |
| 288 | 424 | } |
| 289 | 425 | |
| 426 | + // The gateway on file just changed (a renewal invoice paid through a different | |
| 427 | + // gateway, an admin edit). `system` is only meaningful while that gateway can | |
| 428 | + // token-charge, so re-derive it — otherwise the subscription keeps claiming | |
| 429 | + // auto-charge against a gateway that will refuse every attempt. | |
| 430 | + if (isset($dirtyData['current_payment_method'])) { | |
| 431 | + SystemChargeService::reconcileGatewayCapability($subscriptionModel); | |
| 432 | + } | |
| 433 | + | |
| 434 | + // validity_expired_at should only exist when status IS expired | |
| 435 | + if ($subscriptionModel->status !== Status::SUBSCRIPTION_EXPIRED) { | |
| 436 | + $subscriptionModel->deleteMeta('validity_expired_at'); | |
| 437 | + } | |
| 438 | + | |
| 290 | 439 | if ($oldStatus === $subscriptionModel->status) { |
| 291 | 440 | if ($dirtyData) { |
| 292 | 441 | do_action('fluent_cart/subscription/data_updated', [ |
| 293 | 442 | 'subscription' => $subscriptionModel, |
| @@ -327,8 +476,18 @@ | ||
| 327 | 476 | 'old_status' => $oldStatus, |
| 328 | 477 | 'new_status' => $subscriptionModel->status |
| 329 | 478 | ]); |
| 330 | 479 | |
| 480 | + // Gateway-originated cancel (webhook) reaches only the raw status bus above; | |
| 481 | + // route it through the chokepoint so void + native event fire like every | |
| 482 | + // other path. The old_status === status early return keeps this once-only. | |
| 483 | + if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED) { | |
| 484 | + self::finalizeCancellation( | |
| 485 | + $subscriptionModel, | |
| 486 | + Arr::get($subscriptionUpdateArgs, 'reason', __('Canceled at gateway', 'fluent-cart')) | |
| 487 | + ); | |
| 488 | + } | |
| 489 | + | |
| 331 | 490 | // note: we needed this event, currently being used in integrations |
| 332 | 491 | if ($subscriptionModel->status === Status::SUBSCRIPTION_EXPIRED) { |
| 333 | 492 | $subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s')); |
| 334 | 493 | (new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch(); |
| @@ -333,8 +492,21 @@ | ||
| 333 | 492 | $subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s')); |
| 334 | 493 | (new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch(); |
| 335 | 494 | } |
| 336 | 495 | |
| 496 | + if ($subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE && | |
| 497 | + in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) { | |
| 498 | + (new SubscriptionReactivated($subscriptionModel, $subscriptionModel->order, $subscriptionModel->customer, $oldStatus))->dispatch(); | |
| 499 | + } | |
| 500 | + | |
| 501 | + if ($subscriptionModel->status === Status::SUBSCRIPTION_PAUSED && $oldStatus === Status::SUBSCRIPTION_ACTIVE) { | |
| 502 | + self::dispatchStatusEvent($subscriptionModel, 'paused', ['old_status' => $oldStatus]); | |
| 503 | + } | |
| 504 | + | |
| 505 | + if ($subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE && $oldStatus === Status::SUBSCRIPTION_PAUSED) { | |
| 506 | + self::dispatchStatusEvent($subscriptionModel, 'resumed', ['old_status' => $oldStatus]); | |
| 507 | + } | |
| 508 | + | |
| 337 | 509 | return $subscriptionModel; |
| 338 | 510 | } |
| 339 | 511 | |
| 340 | 512 | |
| @@ -351,15 +523,23 @@ | ||
| 351 | 523 | public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = []) |
| 352 | 524 | { |
| 353 | 525 | $renewalOrder = $transaction->order; |
| 354 | 526 | |
| 527 | + $settledAt = Arr::get((array) $transaction->meta, 'settled_at'); | |
| 528 | + | |
| 529 | + // payment_status and total_paid are deliberately NOT set here — every caller has | |
| 530 | + // already marked the transaction succeeded, and syncOrderStatuses() below derives | |
| 531 | + // both from the transactions and claims the pending → paid transition atomically. | |
| 532 | + // Pre-setting them destroyed that transition, which (a) suppressed | |
| 533 | + // fluent_cart/renewal_paid, so RenewalService::handleRenewalPaid() never | |
| 534 | + // advanced next_billing_date (the customer was re-invoiced forever), and | |
| 535 | + // (b) bypassed the atomic claim that stops a webhook and a browser confirmation | |
| 536 | + // from both processing the same renewal payment. | |
| 355 | 537 | $orderUpdateData = [ |
| 356 | 538 | 'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED, |
| 357 | 539 | 'type' => Status::ORDER_TYPE_RENEWAL, |
| 358 | 540 | 'payment_method' => $transaction->payment_method, |
| 359 | - 'payment_status' => Status::PAYMENT_PAID, | |
| 360 | - 'total_paid' => $transaction->total, | |
| 361 | - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s') | |
| 541 | + 'completed_at' => self::normalizeGatewayTime($settledAt) | |
| 362 | 542 | ]; |
| 363 | 543 | |
| 364 | 544 | $renewalOrder->fill($orderUpdateData); |
| 365 | 545 | $renewalOrder->save(); |
| @@ -376,11 +556,888 @@ | ||
| 376 | 556 | $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $updateData); |
| 377 | 557 | |
| 378 | 558 | (new StatusHelper($transaction->order))->syncOrderStatuses($transaction); |
| 379 | 559 | |
| 380 | - if ($transaction->total > 0) { | |
| 560 | + // Single-event contract for renewal processing — exactly one owner per | |
| 561 | + // subscription type, so SubscriptionRenewed fires exactly once: | |
| 562 | + // | |
| 563 | + // store-billed (manual/system) → RenewalService::handleRenewalPaid(), | |
| 564 | + // reached through the fluent_cart/renewal_paid hook that | |
| 565 | + // syncOrderStatuses() fires above. It advances next_billing_date, | |
| 566 | + // derives bill_count / EOT, and dispatches the event. | |
| 567 | + // gateway-billed (automatic) → here. The invoice engine does not handle | |
| 568 | + // these, so this is their only dispatch point. | |
| 569 | + // | |
| 570 | + // Keyed on the collection method rather than on the `renewal_processed` | |
| 571 | + // marker: handleRenewalPaid() stamps that marker before its EOT early-return, | |
| 572 | + // so a marker check would swallow the event on a final installment. | |
| 573 | + if ($transaction->total > 0 && !$subscriptionModel->usesRenewalEngine()) { | |
| 381 | 574 | (new SubscriptionRenewed($subscriptionModel, $renewalOrder, $subscriptionModel->order, $renewalOrder->customer))->dispatch(); |
| 382 | 575 | } |
| 383 | 576 | |
| 384 | 577 | return $subscriptionModel; |
| 578 | + } | |
| 579 | + | |
| 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 | + /** | |
| 602 | + * Single dispatch point for subscription lifecycle status events. | |
| 603 | + * | |
| 604 | + * Every confirmed transition — manual local update, gateway sync response, or | |
| 605 | + * gateway webhook/confirmation — routes through here so the first-class event | |
| 606 | + * (and the hook it fires) happens exactly once, whatever path caused the change. | |
| 607 | + * | |
| 608 | + * @param Subscription $subscription | |
| 609 | + * @param string $event One of: paused, resumed, updated, period_skipped | |
| 610 | + * @param array $context order, customer, old_status, reason, updates, changes, | |
| 611 | + * old_next_billing_date, new_next_billing_date | |
| 612 | + * @return void | |
| 613 | + */ | |
| 614 | + public static function dispatchStatusEvent(Subscription $subscription, string $event, array $context = []) | |
| 615 | + { | |
| 616 | + $order = Arr::get($context, 'order') ?: $subscription->order; | |
| 617 | + $customer = Arr::get($context, 'customer') ?: ($order ? $order->customer : null); | |
| 618 | + $oldStatus = Arr::get($context, 'old_status'); | |
| 619 | + $reason = (string) Arr::get($context, 'reason', ''); | |
| 620 | + | |
| 621 | + switch ($event) { | |
| 622 | + case 'paused': | |
| 623 | + (new SubscriptionPaused($subscription, $order, $customer, $oldStatus, $reason))->dispatch(); | |
| 624 | + break; | |
| 625 | + case 'resumed': | |
| 626 | + (new SubscriptionResumed($subscription, $order, $customer, $oldStatus, $reason))->dispatch(); | |
| 627 | + break; | |
| 628 | + case 'updated': | |
| 629 | + (new SubscriptionUpdated($subscription, $order, $customer, Arr::get($context, 'updates', []), Arr::get($context, 'changes', [])))->dispatch(); | |
| 630 | + break; | |
| 631 | + case 'period_skipped': | |
| 632 | + (new SubscriptionPeriodSkipped($subscription, $order, $customer, Arr::get($context, 'old_next_billing_date'), Arr::get($context, 'new_next_billing_date')))->dispatch(); | |
| 633 | + break; | |
| 634 | + } | |
| 635 | + } | |
| 636 | + | |
| 637 | + /** | |
| 638 | + * Pause a subscription | |
| 639 | + * | |
| 640 | + * For manual subscriptions, this just updates the local status. | |
| 641 | + * For automatic subscriptions, delegates to the gateway. | |
| 642 | + * | |
| 643 | + * @param Subscription $subscription | |
| 644 | + * @param string $reason | |
| 645 | + * @return true|\WP_Error | |
| 646 | + */ | |
| 647 | + public static function pauseSubscription(Subscription $subscription, $reason = '') | |
| 648 | + { | |
| 649 | + if (!$subscription->canPause()) { | |
| 650 | + return new \WP_Error( | |
| 651 | + 'cannot_pause', | |
| 652 | + __('This subscription cannot be paused.', 'fluent-cart') | |
| 653 | + ); | |
| 654 | + } | |
| 655 | + | |
| 656 | + // Store-billed (manual/system) subscriptions: local status update. | |
| 657 | + if ($subscription->usesRenewalEngine()) { | |
| 658 | + $oldStatus = $subscription->status; | |
| 659 | + $subscription->status = Status::SUBSCRIPTION_PAUSED; | |
| 660 | + $subscription->save(); | |
| 661 | + | |
| 662 | + self::voidPendingRenewals( | |
| 663 | + $subscription, | |
| 664 | + 'Subscription paused; open renewal order voided.' | |
| 665 | + ); | |
| 666 | + | |
| 667 | + $subscription->addLog( | |
| 668 | + 'Subscription paused', | |
| 669 | + $reason ?: __('Subscription paused manually', 'fluent-cart'), | |
| 670 | + 'info' | |
| 671 | + ); | |
| 672 | + | |
| 673 | + // Fires fluent_cart/subscription_paused once, with the original | |
| 674 | + // subscription/reason keys plus order/customer/old_status. | |
| 675 | + self::dispatchStatusEvent($subscription, 'paused', [ | |
| 676 | + 'old_status' => $oldStatus, | |
| 677 | + 'reason' => $reason, | |
| 678 | + ]); | |
| 679 | + | |
| 680 | + return true; | |
| 681 | + } | |
| 682 | + | |
| 683 | + // Automatic subscriptions: delegate to gateway | |
| 684 | + $gateway = App::gateway($subscription->current_payment_method); | |
| 685 | + | |
| 686 | + if (!$gateway || !in_array('pause_subscription', $gateway->supportedFeatures)) { | |
| 687 | + return new \WP_Error( | |
| 688 | + 'unsupported_pause', | |
| 689 | + __('Current payment method does not support pausing.', 'fluent-cart') | |
| 690 | + ); | |
| 691 | + } | |
| 692 | + | |
| 693 | + if (method_exists($gateway->subscriptions, 'pause')) { | |
| 694 | + return $gateway->subscriptions->pause($subscription, $reason); | |
| 695 | + } | |
| 696 | + | |
| 697 | + return new \WP_Error( | |
| 698 | + 'unsupported_pause', | |
| 699 | + __('Current payment method does not support pausing.', 'fluent-cart') | |
| 700 | + ); | |
| 701 | + } | |
| 702 | + | |
| 703 | + /** | |
| 704 | + * Resume a paused subscription | |
| 705 | + * | |
| 706 | + * For manual subscriptions, this updates status back to active. | |
| 707 | + * For automatic subscriptions, delegates to the gateway. | |
| 708 | + * | |
| 709 | + * @param Subscription $subscription | |
| 710 | + * @param string $reason | |
| 711 | + * @return true|\WP_Error | |
| 712 | + */ | |
| 713 | + public static function resumeSubscription(Subscription $subscription, $reason = '') | |
| 714 | + { | |
| 715 | + if (!$subscription->canResume()) { | |
| 716 | + return new \WP_Error( | |
| 717 | + 'cannot_resume', | |
| 718 | + __('This subscription cannot be resumed.', 'fluent-cart') | |
| 719 | + ); | |
| 720 | + } | |
| 721 | + | |
| 722 | + // Store-billed (manual/system) subscriptions: local status update. | |
| 723 | + if ($subscription->usesRenewalEngine()) { | |
| 724 | + $oldStatus = $subscription->status; | |
| 725 | + $subscription->status = Status::SUBSCRIPTION_ACTIVE; | |
| 726 | + $subscription->save(); | |
| 727 | + | |
| 728 | + SystemChargeService::restoreScheduledChargesForSubscription($subscription); | |
| 729 | + | |
| 730 | + $subscription->addLog( | |
| 731 | + 'Subscription resumed', | |
| 732 | + $reason ?: __('Subscription resumed manually', 'fluent-cart'), | |
| 733 | + 'info' | |
| 734 | + ); | |
| 735 | + | |
| 736 | + // Fires fluent_cart/subscription_resumed once, with the original | |
| 737 | + // subscription/reason keys plus order/customer/old_status. | |
| 738 | + self::dispatchStatusEvent($subscription, 'resumed', [ | |
| 739 | + 'old_status' => $oldStatus, | |
| 740 | + 'reason' => $reason, | |
| 741 | + ]); | |
| 742 | + | |
| 743 | + return true; | |
| 744 | + } | |
| 745 | + | |
| 746 | + // Automatic subscriptions: delegate to gateway | |
| 747 | + $gateway = App::gateway($subscription->current_payment_method); | |
| 748 | + | |
| 749 | + if (!$gateway || !in_array('resume_subscription', $gateway->supportedFeatures)) { | |
| 750 | + return new \WP_Error( | |
| 751 | + 'unsupported_resume', | |
| 752 | + __('Current payment method does not support resuming.', 'fluent-cart') | |
| 753 | + ); | |
| 754 | + } | |
| 755 | + | |
| 756 | + if (method_exists($gateway->subscriptions, 'resume')) { | |
| 757 | + return $gateway->subscriptions->resume($subscription, $reason); | |
| 758 | + } | |
| 759 | + | |
| 760 | + return new \WP_Error( | |
| 761 | + 'unsupported_resume', | |
| 762 | + __('Current payment method does not support resuming.', 'fluent-cart') | |
| 763 | + ); | |
| 764 | + } | |
| 765 | + | |
| 766 | + /** | |
| 767 | + * Reactivate a canceled/expired store-billed (manual or system) subscription locally — | |
| 768 | + * no gateway/checkout involved. Voids any pending renewal invoice from the missed | |
| 769 | + * period and advances next_billing_date so the overdue scanner doesn't immediately | |
| 770 | + * re-flag it. Shared by the admin reactivate endpoint and the customer-dashboard | |
| 771 | + * future-dated reactivation short-circuit. | |
| 772 | + * | |
| 773 | + * @param Subscription $subscription | |
| 774 | + * @return Subscription|\WP_Error | |
| 775 | + */ | |
| 776 | + public static function reactivateSubscriptionLocally(Subscription $subscription) | |
| 777 | + { | |
| 778 | + if (!$subscription->usesRenewalEngine()) { | |
| 779 | + return new \WP_Error( | |
| 780 | + 'unsupported_local_reactivation', | |
| 781 | + __('This subscription must be reactivated through its payment gateway.', 'fluent-cart') | |
| 782 | + ); | |
| 783 | + } | |
| 784 | + | |
| 785 | + if (!$subscription->canReactivate()) { | |
| 786 | + return new \WP_Error( | |
| 787 | + 'cannot_reactivate', | |
| 788 | + __('This subscription cannot be reactivated.', 'fluent-cart') | |
| 789 | + ); | |
| 790 | + } | |
| 791 | + | |
| 792 | + global $wpdb; | |
| 793 | + | |
| 794 | + $wpdb->query('START TRANSACTION'); | |
| 795 | + | |
| 796 | + try { | |
| 797 | + // Lock subscription before orders — skipNextPeriod locks in this order too; | |
| 798 | + // diverging risks a deadlock on the same rows. | |
| 799 | + $locked = Subscription::query() | |
| 800 | + ->where('id', $subscription->id) | |
| 801 | + ->lockForUpdate() | |
| 802 | + ->first(); | |
| 803 | + | |
| 804 | + // Re-check under the lock: canReactivate() ran on pre-lock state. | |
| 805 | + $subscription->fill([ | |
| 806 | + 'status' => $locked ? $locked->status : $subscription->status, | |
| 807 | + 'next_billing_date' => $locked ? $locked->next_billing_date : $subscription->next_billing_date, | |
| 808 | + ]); | |
| 809 | + | |
| 810 | + if (!$locked || !$subscription->canReactivate()) { | |
| 811 | + $wpdb->query('ROLLBACK'); | |
| 812 | + return new \WP_Error( | |
| 813 | + 'cannot_reactivate', | |
| 814 | + __('This subscription cannot be reactivated.', 'fluent-cart') | |
| 815 | + ); | |
| 816 | + } | |
| 817 | + | |
| 818 | + $oldStatus = $subscription->status; | |
| 819 | + | |
| 820 | + $pendingOrderIds = Order::query() | |
| 821 | + ->where('type', Status::ORDER_TYPE_RENEWAL) | |
| 822 | + ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) | |
| 823 | + ->where('parent_id', $subscription->parent_order_id) | |
| 824 | + ->pluck('id'); | |
| 825 | + | |
| 826 | + if ($pendingOrderIds->isNotEmpty()) { | |
| 827 | + // Re-assert payment_status at mutation time — a webhook may have paid | |
| 828 | + // this order between the select above and this update. | |
| 829 | + Order::query() | |
| 830 | + ->whereIn('id', $pendingOrderIds) | |
| 831 | + ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) | |
| 832 | + ->update([ | |
| 833 | + 'status' => Status::ORDER_CANCELED, | |
| 834 | + 'payment_status' => Status::PAYMENT_FAILED, | |
| 835 | + ]); | |
| 836 | + | |
| 837 | + // Only fail transactions for orders actually voided above — a | |
| 838 | + // paid-in-the-race order is excluded by the update's payment_status | |
| 839 | + // predicate, so it must be excluded here too. | |
| 840 | + $voidedOrderIds = Order::query() | |
| 841 | + ->whereIn('id', $pendingOrderIds) | |
| 842 | + ->where('status', Status::ORDER_CANCELED) | |
| 843 | + ->where('payment_status', Status::PAYMENT_FAILED) | |
| 844 | + ->pluck('id'); | |
| 845 | + | |
| 846 | + if ($voidedOrderIds->isNotEmpty()) { | |
| 847 | + OrderTransaction::query() | |
| 848 | + ->whereIn('order_id', $voidedOrderIds) | |
| 849 | + ->where('status', Status::TRANSACTION_PENDING) | |
| 850 | + ->update(['status' => Status::TRANSACTION_FAILED]); | |
| 851 | + } | |
| 852 | + } | |
| 853 | + | |
| 854 | + // Overdue date must not survive reactivation, or it lands instantly due. | |
| 855 | + if ($subscription->next_billing_date && strtotime($subscription->next_billing_date) <= time()) { | |
| 856 | + $advancedDate = RenewalService::computeSkippedDate($subscription); | |
| 857 | + if ($advancedDate) { | |
| 858 | + $subscription->fill(['next_billing_date' => $advancedDate]); | |
| 859 | + } | |
| 860 | + } | |
| 861 | + | |
| 862 | + // Not syncSubscriptionStates: its EOT check would flip status to completed. | |
| 863 | + $reactivationData = [ | |
| 864 | + 'status' => Status::SUBSCRIPTION_ACTIVE, | |
| 865 | + 'canceled_at' => null, | |
| 866 | + ]; | |
| 867 | + | |
| 868 | + // Guess can itself be in the past (empty date + old last order) — advance past now. | |
| 869 | + if (empty($subscription->next_billing_date) | |
| 870 | + || strtotime($subscription->next_billing_date) <= time()) { | |
| 871 | + $guessedTs = strtotime($subscription->guessNextBillingDate()); | |
| 872 | + $intervalDays = PaymentHelper::getIntervalDays($subscription->billing_interval); | |
| 873 | + if ($intervalDays > 0) { | |
| 874 | + while ($guessedTs <= time()) { | |
| 875 | + $guessedTs += $intervalDays * DAY_IN_SECONDS; | |
| 876 | + } | |
| 877 | + } | |
| 878 | + $reactivationData['next_billing_date'] = gmdate('Y-m-d H:i:s', $guessedTs); | |
| 879 | + } | |
| 880 | + | |
| 881 | + $subscription->fill($reactivationData)->save(); | |
| 882 | + | |
| 883 | + $wpdb->query('COMMIT'); | |
| 884 | + } catch (\Throwable $e) { | |
| 885 | + $wpdb->query('ROLLBACK'); | |
| 886 | + | |
| 887 | + return new \WP_Error('reactivation_failed', $e->getMessage()); | |
| 888 | + } | |
| 889 | + | |
| 890 | + do_action('fluent_cart/payments/subscription_status_changed', [ | |
| 891 | + 'subscription' => $subscription, | |
| 892 | + 'order' => $subscription->order, | |
| 893 | + 'customer' => $subscription->customer, | |
| 894 | + 'old_status' => $oldStatus, | |
| 895 | + 'new_status' => Status::SUBSCRIPTION_ACTIVE, | |
| 896 | + ]); | |
| 897 | + | |
| 898 | + do_action('fluent_cart/payments/subscription_active', [ | |
| 899 | + 'subscription' => $subscription, | |
| 900 | + 'order' => $subscription->order, | |
| 901 | + 'customer' => $subscription->customer, | |
| 902 | + 'old_status' => $oldStatus, | |
| 903 | + 'new_status' => Status::SUBSCRIPTION_ACTIVE, | |
| 904 | + ]); | |
| 905 | + | |
| 906 | + if (in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) { | |
| 907 | + (new SubscriptionReactivated($subscription, $subscription->order, $subscription->customer, $oldStatus))->dispatch(); | |
| 908 | + } | |
| 909 | + | |
| 910 | + do_action('fluent_cart/subscription/reactivated_locally', $subscription); | |
| 911 | + | |
| 912 | + return $subscription; | |
| 913 | + } | |
| 914 | + | |
| 915 | + /** | |
| 916 | + * Update subscription details (for manual subscriptions) | |
| 917 | + * | |
| 918 | + * Allowed fields for manual subscriptions: | |
| 919 | + * - recurring_total: Update the next invoice/payment amount (in cents) | |
| 920 | + * - bill_times: Update the number of billing cycles (0 = unlimited) | |
| 921 | + * - billing_interval: Change billing frequency (daily, weekly, monthly, etc.) | |
| 922 | + * - expire_at: Update expiration date | |
| 923 | + * - trial_days: Update trial period | |
| 924 | + * - next_billing_date: Update next billing date | |
| 925 | + * | |
| 926 | + * @param Subscription $subscription | |
| 927 | + * @param array $data | |
| 928 | + * @return true|\WP_Error | |
| 929 | + */ | |
| 930 | + public static function updateSubscription(Subscription $subscription, array $data) | |
| 931 | + { | |
| 932 | + if (!$subscription->usesRenewalEngine()) { | |
| 933 | + return new \WP_Error( | |
| 934 | + 'cannot_update_automatic', | |
| 935 | + __('Only store-billed (manual or auto-charge) subscriptions can be updated directly.', 'fluent-cart') | |
| 936 | + ); | |
| 937 | + } | |
| 938 | + | |
| 939 | + $allowedFields = [ | |
| 940 | + 'recurring_total', | |
| 941 | + 'bill_times', | |
| 942 | + 'billing_interval', | |
| 943 | + 'next_billing_date', | |
| 944 | + 'status' | |
| 945 | + ]; | |
| 946 | + | |
| 947 | + $updates = []; | |
| 948 | + $changes = []; | |
| 949 | + | |
| 950 | + foreach ($data as $key => $value) { | |
| 951 | + if (!in_array($key, $allowedFields)) { | |
| 952 | + continue; | |
| 953 | + } | |
| 954 | + | |
| 955 | + // Normalize recurring_total from frontend decimal to cents before comparison | |
| 956 | + if ($key === 'recurring_total') { | |
| 957 | + $value = (int) round(floatval($value) * 100); | |
| 958 | + } | |
| 959 | + | |
| 960 | + $oldValue = $subscription->{$key}; | |
| 961 | + | |
| 962 | + // DB attributes come back as strings — normalize numeric fields on both | |
| 963 | + // sides or the strict compare below always reports a change. | |
| 964 | + if (in_array($key, ['recurring_total', 'bill_times'])) { | |
| 965 | + $oldValue = (int) $oldValue; | |
| 966 | + $value = intval($value); | |
| 967 | + } | |
| 968 | + | |
| 969 | + if ($oldValue === $value) { | |
| 970 | + continue; | |
| 971 | + } | |
| 972 | + | |
| 973 | + // Validate and convert numeric fields | |
| 974 | + if (in_array($key, ['recurring_total', 'bill_times'])) { | |
| 975 | + $value = $key === 'bill_times' ? intval($value) : $value; // recurring_total already converted above | |
| 976 | + if ($key === 'bill_times' && $value < 0) { | |
| 977 | + return new \WP_Error( | |
| 978 | + 'invalid_value', | |
| 979 | + __('bill_times cannot be negative.', 'fluent-cart') | |
| 980 | + ); | |
| 981 | + } | |
| 982 | + if ($key === 'bill_times' && $value > 0 && $value < $subscription->bill_count) { | |
| 983 | + return new \WP_Error( | |
| 984 | + 'invalid_value', | |
| 985 | + sprintf( | |
| 986 | + __('bill_times cannot be less than the number of payments already made (%d).', 'fluent-cart'), | |
| 987 | + $subscription->bill_count | |
| 988 | + ) | |
| 989 | + ); | |
| 990 | + } | |
| 991 | + if ($key === 'recurring_total' && $value < 0) { | |
| 992 | + return new \WP_Error( | |
| 993 | + 'invalid_value', | |
| 994 | + __('recurring_total cannot be negative.', 'fluent-cart') | |
| 995 | + ); | |
| 996 | + } | |
| 997 | + | |
| 998 | + if ($key === 'recurring_total') { | |
| 999 | + // Keep recurring_amount in sync: total minus existing tax | |
| 1000 | + $recurringAmount = $value - ($subscription->recurring_tax_total ?? 0); | |
| 1001 | + if ($recurringAmount < 0) { | |
| 1002 | + return new \WP_Error( | |
| 1003 | + 'invalid_value', | |
| 1004 | + __('recurring_total cannot be less than the existing tax total.', 'fluent-cart') | |
| 1005 | + ); | |
| 1006 | + } | |
| 1007 | + $updates['recurring_amount'] = $recurringAmount; | |
| 1008 | + } | |
| 1009 | + } | |
| 1010 | + | |
| 1011 | + if ($key === 'billing_interval') { | |
| 1012 | + $validIntervals = apply_filters('fluent_cart/subscription/allowed_intervals', ['daily', 'weekly', 'monthly', 'quarterly', 'half_yearly', 'yearly'], [ | |
| 1013 | + 'subscription' => $subscription, | |
| 1014 | + 'current_interval' => $subscription->billing_interval, | |
| 1015 | + 'new_interval' => $value | |
| 1016 | + ]); | |
| 1017 | + | |
| 1018 | + if (!in_array($value, $validIntervals)) { | |
| 1019 | + return new \WP_Error( | |
| 1020 | + 'invalid_interval', | |
| 1021 | + __('Invalid billing interval.', 'fluent-cart') | |
| 1022 | + ); | |
| 1023 | + } | |
| 1024 | + | |
| 1025 | + $incomingDate = isset($data['next_billing_date']) ? $data['next_billing_date'] : null; | |
| 1026 | + $adminChangedDate = $incomingDate && $incomingDate !== $subscription->next_billing_date; | |
| 1027 | + if (!$adminChangedDate && $subscription->next_billing_date) { | |
| 1028 | + $oldInterval = $subscription->billing_interval; | |
| 1029 | + $subscription->billing_interval = $value; | |
| 1030 | + $updates['next_billing_date'] = $subscription->guessNextBillingDate(true); | |
| 1031 | + $subscription->billing_interval = $oldInterval; | |
| 1032 | + } | |
| 1033 | + } | |
| 1034 | + | |
| 1035 | + if ($key === 'status') { | |
| 1036 | + $validStatuses = [ | |
| 1037 | + Status::SUBSCRIPTION_ACTIVE, | |
| 1038 | + Status::SUBSCRIPTION_PAUSED, | |
| 1039 | + Status::SUBSCRIPTION_TRIALING, | |
| 1040 | + Status::SUBSCRIPTION_CANCELED, | |
| 1041 | + Status::SUBSCRIPTION_EXPIRED, | |
| 1042 | + Status::SUBSCRIPTION_COMPLETED, | |
| 1043 | + Status::SUBSCRIPTION_PAST_DUE | |
| 1044 | + ]; | |
| 1045 | + if (!in_array($value, $validStatuses)) { | |
| 1046 | + return new \WP_Error( | |
| 1047 | + 'invalid_status', | |
| 1048 | + __('Invalid subscription status.', 'fluent-cart') | |
| 1049 | + ); | |
| 1050 | + } | |
| 1051 | + | |
| 1052 | + // Sync companion fields; syncSubscriptionStates is intentionally | |
| 1053 | + // not used here because its EOT check recalculates bill_count and | |
| 1054 | + // can silently override the admin's explicit status choice. | |
| 1055 | + if ($value === Status::SUBSCRIPTION_CANCELED && empty($subscription->canceled_at)) { | |
| 1056 | + $updates['canceled_at'] = gmdate('Y-m-d H:i:s'); | |
| 1057 | + } elseif (in_array($value, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])) { | |
| 1058 | + $updates['canceled_at'] = null; | |
| 1059 | + if (empty($subscription->next_billing_date) && empty($data['next_billing_date'])) { | |
| 1060 | + $updates['next_billing_date'] = $subscription->guessNextBillingDate(); | |
| 1061 | + } | |
| 1062 | + } elseif (in_array($value, [Status::SUBSCRIPTION_COMPLETED, Status::SUBSCRIPTION_EXPIRED])) { | |
| 1063 | + $updates['next_billing_date'] = null; | |
| 1064 | + } | |
| 1065 | + | |
| 1066 | + $terminalStatuses = [ | |
| 1067 | + Status::SUBSCRIPTION_CANCELED, | |
| 1068 | + Status::SUBSCRIPTION_COMPLETED, | |
| 1069 | + Status::SUBSCRIPTION_EXPIRED, | |
| 1070 | + ]; | |
| 1071 | + if (in_array($value, $terminalStatuses)) { | |
| 1072 | + self::voidPendingRenewals( | |
| 1073 | + $subscription, | |
| 1074 | + sprintf('Subscription marked as %s by admin.', $value) | |
| 1075 | + ); | |
| 1076 | + } | |
| 1077 | + | |
| 1078 | + // Same contract as pauseSubscription(): pausing retires the open | |
| 1079 | + // invoice (and its queued system charge) so nothing collects while | |
| 1080 | + // the subscription is paused. | |
| 1081 | + if ($value === Status::SUBSCRIPTION_PAUSED && $subscription->status !== Status::SUBSCRIPTION_PAUSED) { | |
| 1082 | + self::voidPendingRenewals( | |
| 1083 | + $subscription, | |
| 1084 | + __('Subscription paused; open renewal order voided.', 'fluent-cart') | |
| 1085 | + ); | |
| 1086 | + } | |
| 1087 | + } | |
| 1088 | + | |
| 1089 | + $updates[$key] = $value; | |
| 1090 | + $logOld = $key === 'recurring_total' ? number_format($oldValue / 100, 2) : $oldValue; | |
| 1091 | + $logNew = $key === 'recurring_total' ? number_format($value / 100, 2) : $value; | |
| 1092 | + $changes[] = sprintf('%s: %s → %s', $key, $logOld, $logNew); | |
| 1093 | + } | |
| 1094 | + | |
| 1095 | + if (empty($updates)) { | |
| 1096 | + return new \WP_Error( | |
| 1097 | + 'no_changes', | |
| 1098 | + __('No changes detected.', 'fluent-cart') | |
| 1099 | + ); | |
| 1100 | + } | |
| 1101 | + | |
| 1102 | + $oldStatus = $subscription->status; | |
| 1103 | + | |
| 1104 | + foreach ($updates as $key => $value) { | |
| 1105 | + $subscription->{$key} = $value; | |
| 1106 | + } | |
| 1107 | + | |
| 1108 | + $subscription->save(); | |
| 1109 | + | |
| 1110 | + // Sync pending renewal invoice if amount or due date changed | |
| 1111 | + $amountChanged = isset($updates['recurring_total']); | |
| 1112 | + $dueDateChanged = isset($updates['next_billing_date']); | |
| 1113 | + | |
| 1114 | + if ($amountChanged || $dueDateChanged) { | |
| 1115 | + $pendingInvoice = Order::query() | |
| 1116 | + ->where('parent_id', $subscription->parent_order_id) | |
| 1117 | + ->where('type', 'renewal') | |
| 1118 | + ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) | |
| 1119 | + ->first(); | |
| 1120 | + | |
| 1121 | + if ($pendingInvoice) { | |
| 1122 | + if ($amountChanged) { | |
| 1123 | + $newTotal = $subscription->recurring_total; | |
| 1124 | + $newTax = $subscription->recurring_tax_total; | |
| 1125 | + $newSubtotal = $subscription->recurring_amount; | |
| 1126 | + | |
| 1127 | + $pendingInvoice->subtotal = $newSubtotal; | |
| 1128 | + $pendingInvoice->tax_total = $newTax; | |
| 1129 | + $pendingInvoice->total_amount = $newTotal; | |
| 1130 | + $pendingInvoice->save(); | |
| 1131 | + | |
| 1132 | + // Same unit_price convention as RenewalService::createRenewalOrders(): | |
| 1133 | + // gross per-unit for inclusive tax (behavior 2), net otherwise — the | |
| 1134 | + // re-pay checkout feeds unit_price back as item_price, so a net value | |
| 1135 | + // on an inclusive-tax invoice would drop the included tax. | |
| 1136 | + $unitPriceBase = $pendingInvoice->tax_behavior == 2 ? $newTotal : $newSubtotal; | |
| 1137 | + | |
| 1138 | + OrderItem::query() | |
| 1139 | + ->where('order_id', $pendingInvoice->id) | |
| 1140 | + ->update([ | |
| 1141 | + 'subtotal' => $newSubtotal, | |
| 1142 | + 'tax_amount' => $newTax, | |
| 1143 | + 'line_total' => $newTotal, | |
| 1144 | + 'unit_price' => $subscription->quantity > 1 | |
| 1145 | + ? (int) round($unitPriceBase / $subscription->quantity) | |
| 1146 | + : $unitPriceBase, | |
| 1147 | + ]); | |
| 1148 | + | |
| 1149 | + OrderTransaction::query() | |
| 1150 | + ->where('order_id', $pendingInvoice->id) | |
| 1151 | + ->where('status', Status::TRANSACTION_PENDING) | |
| 1152 | + ->update(['total' => $newTotal]); | |
| 1153 | + } | |
| 1154 | + | |
| 1155 | + if ($dueDateChanged) { | |
| 1156 | + $pendingInvoice->updateMeta('due_date', $subscription->next_billing_date); | |
| 1157 | + | |
| 1158 | + if ($subscription->isSystem()) { | |
| 1159 | + SystemChargeService::unscheduleCharges($pendingInvoice); | |
| 1160 | + SystemChargeService::scheduleCharge($pendingInvoice, $subscription); | |
| 1161 | + } | |
| 1162 | + } | |
| 1163 | + | |
| 1164 | + $pendingInvoice->addLog( | |
| 1165 | + 'Renewal order updated by subscription edit', | |
| 1166 | + 'Pending renewal order synced after admin edited subscription details.', | |
| 1167 | + 'info' | |
| 1168 | + ); | |
| 1169 | + } | |
| 1170 | + } | |
| 1171 | + | |
| 1172 | + $subscription->addLog( | |
| 1173 | + 'Subscription updated', | |
| 1174 | + sprintf('Admin updated: %s', implode(', ', $changes)), | |
| 1175 | + 'info' | |
| 1176 | + ); | |
| 1177 | + | |
| 1178 | + // Fires fluent_cart/subscription_updated once, with the original | |
| 1179 | + // subscription/updates/changes keys plus order/customer. | |
| 1180 | + self::dispatchStatusEvent($subscription, 'updated', [ | |
| 1181 | + 'updates' => $updates, | |
| 1182 | + 'changes' => $changes, | |
| 1183 | + ]); | |
| 1184 | + | |
| 1185 | + // Same contract as syncSubscriptionStates()'s no-status-change branch — | |
| 1186 | + // Pro's license-extension listener only reacts to this hook. | |
| 1187 | + do_action('fluent_cart/subscription/data_updated', [ | |
| 1188 | + 'subscription' => $subscription, | |
| 1189 | + 'updated_data' => $updates | |
| 1190 | + ]); | |
| 1191 | + | |
| 1192 | + if ($oldStatus !== $subscription->status) { | |
| 1193 | + do_action('fluent_cart/payments/subscription_status_changed', [ | |
| 1194 | + 'subscription' => $subscription, | |
| 1195 | + 'order' => $subscription->order, | |
| 1196 | + 'customer' => $subscription->customer, | |
| 1197 | + 'old_status' => $oldStatus, | |
| 1198 | + 'new_status' => $subscription->status, | |
| 1199 | + ]); | |
| 1200 | + | |
| 1201 | + do_action('fluent_cart/payments/subscription_' . $subscription->status, [ | |
| 1202 | + 'subscription' => $subscription, | |
| 1203 | + 'order' => $subscription->order, | |
| 1204 | + 'customer' => $subscription->customer, | |
| 1205 | + 'old_status' => $oldStatus, | |
| 1206 | + 'new_status' => $subscription->status, | |
| 1207 | + ]); | |
| 1208 | + | |
| 1209 | + if ($subscription->status === Status::SUBSCRIPTION_EXPIRED) { | |
| 1210 | + $subscription->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s')); | |
| 1211 | + (new SubscriptionValidityExpired($subscription, $subscription->order, $subscription->customer))->dispatch(); | |
| 1212 | + } | |
| 1213 | + | |
| 1214 | + if ($subscription->status === Status::SUBSCRIPTION_COMPLETED) { | |
| 1215 | + (new SubscriptionEOT($subscription, $subscription->order))->dispatch(); | |
| 1216 | + } | |
| 1217 | + | |
| 1218 | + // Event was the only missing cancel side-effect on the edit path; the | |
| 1219 | + // event now drives both email and reminder-clear. | |
| 1220 | + if ($subscription->status === Status::SUBSCRIPTION_CANCELED) { | |
| 1221 | + self::finalizeCancellation($subscription, __('Canceled by admin edit', 'fluent-cart')); | |
| 1222 | + } | |
| 1223 | + | |
| 1224 | + if ($subscription->status === Status::SUBSCRIPTION_ACTIVE && | |
| 1225 | + in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) { | |
| 1226 | + (new SubscriptionReactivated($subscription, $subscription->order, $subscription->customer, $oldStatus))->dispatch(); | |
| 1227 | + } | |
| 1228 | + | |
| 1229 | + // Same contract as pauseSubscription()/resumeSubscription(): fire the | |
| 1230 | + // dedicated pause/resume events, and re-queue any system charge that was | |
| 1231 | + // skipped while paused so a resumed subscription cannot strand a | |
| 1232 | + // payment_scheduled invoice. | |
| 1233 | + if ($subscription->status === Status::SUBSCRIPTION_PAUSED && $oldStatus === Status::SUBSCRIPTION_ACTIVE) { | |
| 1234 | + self::dispatchStatusEvent($subscription, 'paused', ['old_status' => $oldStatus]); | |
| 1235 | + } | |
| 1236 | + | |
| 1237 | + if ($subscription->status === Status::SUBSCRIPTION_ACTIVE && $oldStatus === Status::SUBSCRIPTION_PAUSED) { | |
| 1238 | + SystemChargeService::restoreScheduledChargesForSubscription($subscription); | |
| 1239 | + self::dispatchStatusEvent($subscription, 'resumed', ['old_status' => $oldStatus]); | |
| 1240 | + } | |
| 1241 | + } | |
| 1242 | + | |
| 1243 | + if (isset($updates['bill_times']) && !isset($updates['status'])) { | |
| 1244 | + $preSyncStatus = $subscription->status; | |
| 1245 | + self::syncSubscriptionStates($subscription, []); | |
| 1246 | + if ($subscription->status === Status::SUBSCRIPTION_COMPLETED | |
| 1247 | + && $preSyncStatus !== Status::SUBSCRIPTION_COMPLETED | |
| 1248 | + ) { | |
| 1249 | + self::voidPendingRenewals( | |
| 1250 | + $subscription, | |
| 1251 | + __('Subscription completed — billing times reached.', 'fluent-cart') | |
| 1252 | + ); | |
| 1253 | + } | |
| 1254 | + } | |
| 1255 | + | |
| 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; | |
| 1376 | + } | |
| 1377 | + | |
| 1378 | + /** | |
| 1379 | + * Single cancellation chokepoint. Voids open renewals and dispatches the | |
| 1380 | + * SubscriptionCanceled event so email + reminder-clear (both listen on the | |
| 1381 | + * event hook) fire once, regardless of which cancel path ran. | |
| 1382 | + * | |
| 1383 | + * @param bool $dispatchEvent fire SubscriptionCanceled (email/reminder-clear/automations) | |
| 1384 | + */ | |
| 1385 | + public static function finalizeCancellation(Subscription $subscription, string $reason = '', bool $dispatchEvent = true): void | |
| 1386 | + { | |
| 1387 | + self::voidPendingRenewals($subscription, $reason ?: __('Subscription canceled', 'fluent-cart')); | |
| 1388 | + | |
| 1389 | + if ($dispatchEvent) { | |
| 1390 | + (new SubscriptionCanceled($subscription, $subscription->order, $subscription->customer, $reason))->dispatch(); | |
| 1391 | + } | |
| 1392 | + } | |
| 1393 | + | |
| 1394 | + /** | |
| 1395 | + * Void all pending renewal invoices for a subscription. | |
| 1396 | + * Sets order status to canceled and payment_status to failed. | |
| 1397 | + */ | |
| 1398 | + public static function voidPendingRenewals(Subscription $subscription, string $reason = ''): void | |
| 1399 | + { | |
| 1400 | + $pendingInvoices = Order::query() | |
| 1401 | + ->where('parent_id', $subscription->parent_order_id) | |
| 1402 | + ->where('type', 'renewal') | |
| 1403 | + ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) | |
| 1404 | + ->get(); | |
| 1405 | + | |
| 1406 | + foreach ($pendingInvoices as $invoice) { | |
| 1407 | + if ($subscription->isSystem()) { | |
| 1408 | + SystemChargeService::unscheduleCharges($invoice); | |
| 1409 | + | |
| 1410 | + $chargeState = $subscription->getMeta('system_charge_state', []) ?: []; | |
| 1411 | + if ((int) Arr::get($chargeState, 'order_id') === (int) $invoice->id) { | |
| 1412 | + $subscription->deleteMeta('system_charge_state'); | |
| 1413 | + } | |
| 1414 | + } | |
| 1415 | + | |
| 1416 | + // Re-assert payment_status at mutation time — a webhook may have paid this | |
| 1417 | + // invoice between the select above and this update (same pattern as | |
| 1418 | + // reactivateSubscriptionLocally). A raced-paid invoice is left untouched. | |
| 1419 | + $voided = Order::query() | |
| 1420 | + ->where('id', $invoice->id) | |
| 1421 | + ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) | |
| 1422 | + ->update([ | |
| 1423 | + 'status' => Status::ORDER_CANCELED, | |
| 1424 | + 'payment_status' => Status::PAYMENT_FAILED, | |
| 1425 | + ]); | |
| 1426 | + | |
| 1427 | + if (!$voided) { | |
| 1428 | + continue; | |
| 1429 | + } | |
| 1430 | + | |
| 1431 | + OrderTransaction::query() | |
| 1432 | + ->where('order_id', $invoice->id) | |
| 1433 | + ->where('status', Status::TRANSACTION_PENDING) | |
| 1434 | + ->update(['status' => Status::TRANSACTION_FAILED]); | |
| 1435 | + | |
| 1436 | + $invoice->addLog( | |
| 1437 | + 'Renewal order voided', | |
| 1438 | + $reason ?: 'Renewal order voided automatically.', | |
| 1439 | + 'info' | |
| 1440 | + ); | |
| 1441 | + } | |
| 385 | 1442 | } |
| 386 | 1443 | } |