| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Modules\Subscriptions\Services; |
| 4 |
|
| 5 |
use FluentCart\App\App; |
| 6 |
use FluentCart\App\Events\Subscription\SubscriptionCanceled; |
| 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; |
| 11 |
use FluentCart\App\Events\Subscription\SubscriptionRenewed; |
| 12 |
use FluentCart\App\Events\Subscription\SubscriptionResumed; |
| 13 |
use FluentCart\App\Events\Subscription\SubscriptionUpdated; |
| 14 |
use FluentCart\App\Events\Subscription\SubscriptionValidityExpired; |
| 15 |
use FluentCart\App\Helpers\Status; |
| 16 |
use FluentCart\App\Helpers\StatusHelper; |
| 17 |
use FluentCart\App\Models\Order; |
| 18 |
use FluentCart\App\Models\OrderItem; |
| 19 |
use FluentCart\App\Models\OrderTaxRate; |
| 20 |
use FluentCart\App\Models\OrderTransaction; |
| 21 |
use FluentCart\App\Models\Subscription; |
| 22 |
use FluentCart\App\Modules\StoreManagedRenewal\Services\RenewalService; |
| 23 |
use WP_Error; |
| 24 |
use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API; |
| 25 |
use FluentCart\App\Services\DateTime\DateTime; |
| 26 |
use FluentCart\App\Services\Payments\PaymentHelper; |
| 27 |
use FluentCart\Framework\Support\Arr; |
| 28 |
|
| 29 |
class SubscriptionService |
| 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 |
*/ |
| 56 |
public static function recordRenewalPayment($transactionData, $subscriptionModel = null, $subscriptionUpdateArgs = []) |
| 57 |
{ |
| 58 |
if (!$subscriptionModel) { |
| 59 |
$subscriptionModel = Subscription::query()->find($transactionData['subscription_id']); |
| 60 |
} |
| 61 |
|
| 62 |
if (!$subscriptionModel) { |
| 63 |
return new \WP_Error('subscription_not_found', __('Subscription not found.', 'fluent-cart')); |
| 64 |
} |
| 65 |
|
| 66 |
$vendorTransactionId = $transactionData['vendor_charge_id'] ?? null; |
| 67 |
|
| 68 |
global $wpdb; |
| 69 |
$lockName = null; |
| 70 |
|
| 71 |
if ($vendorTransactionId) { |
| 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)); |
| 82 |
return new \WP_Error('transaction_exists', __('This transaction already exists for this subscription.', 'fluent-cart')); |
| 83 |
} |
| 84 |
} |
| 85 |
|
| 86 |
$parentOrder = $subscriptionModel->order; |
| 87 |
|
| 88 |
if (!$parentOrder) { |
| 89 |
return new \WP_Error('parent_order_not_found', __('Parent order not found for this subscription.', 'fluent-cart')); |
| 90 |
} |
| 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 |
|
| 155 |
$transactionDefaults = [ |
| 156 |
'order_id' => $parentOrder->id, |
| 157 |
'subscription_id' => $subscriptionModel->id, |
| 158 |
'order_type' => Status::ORDER_TYPE_RENEWAL, |
| 159 |
'transaction_type' => Status::TRANSACTION_TYPE_CHARGE, |
| 160 |
'payment_method' => $subscriptionModel->current_payment_method, |
| 161 |
'payment_mode' => $parentOrder->mode, |
| 162 |
'status' => Status::TRANSACTION_SUCCEEDED, |
| 163 |
'currency' => $parentOrder->currency, |
| 164 |
'total' => $subscriptionModel->recurring_total, |
| 165 |
'meta' => Arr::get($transactionData, 'meta', []) |
| 166 |
]; |
| 167 |
|
| 168 |
$transactionData = wp_parse_args($transactionData, $transactionDefaults); |
| 169 |
|
| 170 |
$createdAt = self::normalizeGatewayTime(Arr::get($transactionData, 'created_at')); |
| 171 |
|
| 172 |
// Let's create the order item first |
| 173 |
$variation = $subscriptionModel->variation; |
| 174 |
$product = $subscriptionModel->product; |
| 175 |
|
| 176 |
$parentOrderItem = OrderItem::query() |
| 177 |
->where('order_id', $parentOrder->id) |
| 178 |
->where('payment_type', Status::ORDER_TYPE_SUBSCRIPTION) |
| 179 |
->first(); |
| 180 |
|
| 181 |
$taxTotal = Arr::get($transactionData, 'tax_total', 0); |
| 182 |
if (!$taxTotal && $subscriptionModel->recurring_tax_total) { |
| 183 |
$taxTotal = $subscriptionModel->recurring_tax_total; |
| 184 |
} |
| 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) { |
| 193 |
$taxTotal = (int) Arr::get($parentOrderItem->other_info, 'recurring_tax', 0); |
| 194 |
} |
| 195 |
|
| 196 |
$subtotal = $transactionData['total']; |
| 197 |
if ($taxTotal) { |
| 198 |
$subtotal = $transactionData['total'] - $taxTotal; |
| 199 |
} |
| 200 |
|
| 201 |
$orderItem = [ |
| 202 |
'post_id' => $subscriptionModel->product_id, |
| 203 |
'object_id' => $subscriptionModel->variation_id, |
| 204 |
'payment_type' => Status::ORDER_TYPE_SUBSCRIPTION, |
| 205 |
'post_title' => $product && $product->post_title ? $product->post_title : $subscriptionModel->item_name, |
| 206 |
'title' => $product && $variation ? $variation->variation_title : '', |
| 207 |
'quantity' => 1, |
| 208 |
'fulfillment_type' => $parentOrderItem ? $parentOrderItem->fulfillment_type : 'digital', |
| 209 |
'unit_price' => $subtotal, |
| 210 |
'subtotal' => $subtotal, |
| 211 |
'tax_amount' => $taxTotal, |
| 212 |
'line_total' => $transactionData['total'], |
| 213 |
'line_meta' => [], |
| 214 |
'other_info' => [] |
| 215 |
]; |
| 216 |
|
| 217 |
$bundleItemIds = Arr::get($parentOrderItem->line_meta, 'bundle_item_ids', []); |
| 218 |
|
| 219 |
$isBundleOrder = false; |
| 220 |
if ($bundleItemIds) { |
| 221 |
$isBundleOrder = true; |
| 222 |
$orderItem['line_meta'] = array_merge( |
| 223 |
$orderItem['line_meta'], |
| 224 |
[ |
| 225 |
'bundle_item_ids' => $bundleItemIds |
| 226 |
] |
| 227 |
); |
| 228 |
} |
| 229 |
|
| 230 |
$fulfillmentType = $orderItem['fulfillment_type']; |
| 231 |
|
| 232 |
$wpdb->query('START TRANSACTION'); |
| 233 |
|
| 234 |
// Let's create the order first |
| 235 |
$childOrderData = [ |
| 236 |
'parent_id' => $parentOrder->id, |
| 237 |
'fulfillment_type' => $fulfillmentType, |
| 238 |
'status' => $fulfillmentType === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED, |
| 239 |
'type' => Status::ORDER_TYPE_RENEWAL, |
| 240 |
'mode' => $transactionData['payment_mode'], |
| 241 |
'shipping_status' => $fulfillmentType === 'physical' ? Status::SHIPPING_UNSHIPPED : '', |
| 242 |
'customer_id' => $subscriptionModel->customer_id, |
| 243 |
'payment_method' => $transactionData['payment_method'], |
| 244 |
'payment_status' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? Status::PAYMENT_PAID : Status::PAYMENT_PENDING, |
| 245 |
'currency' => $transactionData['currency'], |
| 246 |
'tax_behavior' => $parentOrder->tax_behavior, |
| 247 |
'subtotal' => $subtotal, |
| 248 |
'tax_total' => $taxTotal, |
| 249 |
'total_amount' => $transactionData['total'], |
| 250 |
'total_paid' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? $transactionData['total'] : 0, |
| 251 |
'completed_at' => $createdAt, |
| 252 |
'created_at' => $createdAt, |
| 253 |
'config' => [] |
| 254 |
]; |
| 255 |
|
| 256 |
try { |
| 257 |
$childOrder = Order::query()->create($childOrderData); |
| 258 |
|
| 259 |
if (!$childOrder) { |
| 260 |
throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart')); |
| 261 |
} |
| 262 |
|
| 263 |
$billingAddress = $parentOrder->billing_address; |
| 264 |
$shippingAddress = $parentOrder->shipping_address; |
| 265 |
|
| 266 |
$customer = $parentOrder->customer; |
| 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 |
} |
| 278 |
|
| 279 |
$billingAddressData = $billingAddress ? [ |
| 280 |
'type' => 'billing', |
| 281 |
'full_name' => $fullName, |
| 282 |
'address_1' => $billingAddress->address_1, |
| 283 |
'address_2' => $billingAddress->address_2, |
| 284 |
'city' => $billingAddress->city, |
| 285 |
'state' => $billingAddress->state, |
| 286 |
'postcode' => $billingAddress->postcode, |
| 287 |
'country' => $billingAddress->country, |
| 288 |
'email' => $email, |
| 289 |
'first_name' => $firstName, |
| 290 |
'last_name' => $lastName |
| 291 |
] : []; |
| 292 |
|
| 293 |
$shippingAddressData = $shippingAddress ? [ |
| 294 |
'type' => 'shipping', |
| 295 |
'full_name' => $fullName, |
| 296 |
'address_1' => $shippingAddress->address_1, |
| 297 |
'address_2' => $shippingAddress->address_2, |
| 298 |
'city' => $shippingAddress->city, |
| 299 |
'state' => $shippingAddress->state, |
| 300 |
'postcode' => $shippingAddress->postcode, |
| 301 |
'country' => $shippingAddress->country, |
| 302 |
'email' => $email, |
| 303 |
'first_name' => $firstName, |
| 304 |
'last_name' => $lastName |
| 305 |
] : []; |
| 306 |
|
| 307 |
\FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( |
| 308 |
$childOrder->id, |
| 309 |
$billingAddressData, |
| 310 |
$shippingAddressData |
| 311 |
); |
| 312 |
|
| 313 |
\FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'billing', $billingAddress); |
| 314 |
\FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'shipping', $shippingAddress); |
| 315 |
|
| 316 |
// Copy tax ID meta from parent order if exists |
| 317 |
$parentTaxId = $parentOrder->getMeta('tax_id', ''); |
| 318 |
if ($parentTaxId) { |
| 319 |
$childOrder->updateMeta('tax_id', $parentTaxId); |
| 320 |
} |
| 321 |
|
| 322 |
// Copy order tax rates from parent order |
| 323 |
$parentTaxRates = $parentOrder->orderTaxRates; |
| 324 |
foreach ($parentTaxRates as $taxRate) { |
| 325 |
OrderTaxRate::query()->create([ |
| 326 |
'order_id' => $childOrder->id, |
| 327 |
'tax_rate_id' => $taxRate->tax_rate_id, |
| 328 |
'shipping_tax' => $taxRate->shipping_tax, |
| 329 |
'order_tax' => $taxRate->order_tax, |
| 330 |
'total_tax' => $taxRate->total_tax, |
| 331 |
'meta' => $taxRate->meta, |
| 332 |
]); |
| 333 |
} |
| 334 |
|
| 335 |
// Create Order Item |
| 336 |
$orderItem['order_id'] = $childOrder->id; |
| 337 |
$orderItem['created_at'] = $createdAt; |
| 338 |
OrderItem::query()->create($orderItem); |
| 339 |
|
| 340 |
// let's create the transaction |
| 341 |
$transactionData['order_id'] = $childOrder->id; |
| 342 |
|
| 343 |
$createdTransaction = OrderTransaction::query()->create($transactionData); |
| 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 |
|
| 356 |
(new SubscriptionRenewed($subscriptionModel, $childOrder, $parentOrder, $childOrder->customer))->dispatch(); |
| 357 |
|
| 358 |
return $createdTransaction; |
| 359 |
} |
| 360 |
|
| 361 |
/** |
| 362 |
* @param $subscriptionModel |
| 363 |
* @param $subscriptionUpdateArgs |
| 364 |
* - next_billing_date - You must provide this if you want to update the next billing date. |
| 365 |
* * - Accepts all other filliable attributes of the Subscription model. |
| 366 |
* @return mixed |
| 367 |
*/ |
| 368 |
public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = [], $expectedStatus = null) |
| 369 |
{ |
| 370 |
$billsCount = $subscriptionModel->calculateBillCount(); |
| 371 |
|
| 372 |
$subscriptionUpdateArgs['bill_count'] = $billsCount; |
| 373 |
$billTimes = $subscriptionModel->bill_times; |
| 374 |
$oldStatus = $subscriptionModel->status; |
| 375 |
|
| 376 |
$subscriptionUpdateArgs['bill_count'] = $billsCount; |
| 377 |
$isEot = $billTimes > 0 && $billsCount >= $billTimes; |
| 378 |
|
| 379 |
if ($isEot) { |
| 380 |
$subscriptionUpdateArgs['status'] = 'completed'; |
| 381 |
$subscriptionUpdateArgs['next_billing_date'] = NULL; |
| 382 |
$subscriptionUpdateArgs['canceled_at'] = NULL; |
| 383 |
} else if (!$subscriptionModel->next_billing_date && empty($subscriptionUpdateArgs['next_billing_date'])) { |
| 384 |
$subscriptionUpdateArgs['next_billing_date'] = $subscriptionModel->guessNextBillingDate(); |
| 385 |
} |
| 386 |
|
| 387 |
if (Arr::get($subscriptionUpdateArgs, 'status') === Status::SUBSCRIPTION_ACTIVE) { |
| 388 |
$subscriptionUpdateArgs['recurring_total'] = Arr::get($subscriptionUpdateArgs, 'recurring_total', $subscriptionModel->recurring_total); |
| 389 |
} |
| 390 |
|
| 391 |
$givenSubscriptionStatus = Arr::get($subscriptionUpdateArgs, 'status'); |
| 392 |
if ($givenSubscriptionStatus === Status::SUBSCRIPTION_CANCELED && empty($subscriptionUpdateArgs['canceled_at'])) { |
| 393 |
$subscriptionUpdateArgs['canceled_at'] = gmdate('Y-m-d H:i:s'); |
| 394 |
} |
| 395 |
|
| 396 |
$subscriptionModel->fill($subscriptionUpdateArgs); |
| 397 |
$dirtyData = $subscriptionModel->getDirty(); |
| 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 |
|
| 420 |
$meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', [])); |
| 421 |
|
| 422 |
foreach ($meta as $key => $value) { |
| 423 |
$subscriptionModel->updateMeta($key, $value); |
| 424 |
} |
| 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 |
|
| 439 |
if ($oldStatus === $subscriptionModel->status) { |
| 440 |
if ($dirtyData) { |
| 441 |
do_action('fluent_cart/subscription/data_updated', [ |
| 442 |
'subscription' => $subscriptionModel, |
| 443 |
'updated_data' => $dirtyData |
| 444 |
]); |
| 445 |
} |
| 446 |
|
| 447 |
return $subscriptionModel; // No change in status |
| 448 |
} |
| 449 |
|
| 450 |
if ($isEot) { |
| 451 |
(new SubscriptionEOT($subscriptionModel, $subscriptionModel->order))->dispatch(); |
| 452 |
} |
| 453 |
|
| 454 |
do_action('fluent_cart/payments/subscription_status_changed', [ |
| 455 |
'subscription' => $subscriptionModel, |
| 456 |
'order' => $subscriptionModel->order, |
| 457 |
'customer' => $subscriptionModel->customer, |
| 458 |
'old_status' => $oldStatus, |
| 459 |
'new_status' => $subscriptionModel->status |
| 460 |
]); |
| 461 |
|
| 462 |
/** |
| 463 |
* lists of hooks for this action |
| 464 |
* fluent_cart/payments/subscription_canceled |
| 465 |
* fluent_cart/payments/subscription_active |
| 466 |
* fluent_cart/payments/subscription_paused |
| 467 |
* fluent_cart/payments/subscription_expired |
| 468 |
* fluent_cart/payments/subscription_failing |
| 469 |
* fluent_cart/payments/subscription_expiring |
| 470 |
* fluent_cart/payments/subscription_completed |
| 471 |
**/ |
| 472 |
do_action('fluent_cart/payments/subscription_' . $subscriptionModel->status, [ |
| 473 |
'subscription' => $subscriptionModel, |
| 474 |
'order' => $subscriptionModel->order, |
| 475 |
'customer' => $subscriptionModel->customer, |
| 476 |
'old_status' => $oldStatus, |
| 477 |
'new_status' => $subscriptionModel->status |
| 478 |
]); |
| 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 |
|
| 490 |
// note: we needed this event, currently being used in integrations |
| 491 |
if ($subscriptionModel->status === Status::SUBSCRIPTION_EXPIRED) { |
| 492 |
$subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s')); |
| 493 |
(new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch(); |
| 494 |
} |
| 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 |
|
| 509 |
return $subscriptionModel; |
| 510 |
} |
| 511 |
|
| 512 |
|
| 513 |
/** |
| 514 |
* |
| 515 |
* Use this method when you are reactivating a expired subscription manually by creating order, transaction etc. |
| 516 |
* Make sure you already handle your transaction statuses! |
| 517 |
* |
| 518 |
* @param \FluentCart\App\Models\Subscription $subscriptionModel |
| 519 |
* @param \FluentCart\App\Models\OrderTransaction $transaction |
| 520 |
* @param $args |
| 521 |
* @return mixed |
| 522 |
*/ |
| 523 |
public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = []) |
| 524 |
{ |
| 525 |
$renewalOrder = $transaction->order; |
| 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. |
| 537 |
$orderUpdateData = [ |
| 538 |
'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED, |
| 539 |
'type' => Status::ORDER_TYPE_RENEWAL, |
| 540 |
'payment_method' => $transaction->payment_method, |
| 541 |
'completed_at' => self::normalizeGatewayTime($settledAt) |
| 542 |
]; |
| 543 |
|
| 544 |
$renewalOrder->fill($orderUpdateData); |
| 545 |
$renewalOrder->save(); |
| 546 |
|
| 547 |
if ($billingInfo = Arr::get($args, 'billing_info', [])) { |
| 548 |
$subscriptionModel->updateMeta('active_payment_method', $billingInfo); |
| 549 |
} |
| 550 |
|
| 551 |
$updateData = wp_parse_args(Arr::get($args, 'subscription_args', []), [ |
| 552 |
'status' => Status::SUBSCRIPTION_ACTIVE, |
| 553 |
'current_payment_method' => $transaction->payment_method, |
| 554 |
]); |
| 555 |
|
| 556 |
$subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $updateData); |
| 557 |
|
| 558 |
(new StatusHelper($transaction->order))->syncOrderStatuses($transaction); |
| 559 |
|
| 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()) { |
| 574 |
(new SubscriptionRenewed($subscriptionModel, $renewalOrder, $subscriptionModel->order, $renewalOrder->customer))->dispatch(); |
| 575 |
} |
| 576 |
|
| 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 |
} |
| 1442 |
} |
| 1443 |
} |
| 1444 |
|