| 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 |
$transactionUpdateData = array_filter([ |
| 119 |
'total' => $transactionData['total'] ?? $existingTransaction->total, |
| 120 |
'status' => Status::TRANSACTION_SUCCEEDED, |
| 121 |
'payment_method' => $transactionData['payment_method'] ?? $existingTransaction->payment_method, |
| 122 |
'vendor_charge_id' => $transactionData['vendor_charge_id'] ?? null, |
| 123 |
'card_last_4' => $transactionData['card_last_4'] ?? '', |
| 124 |
'card_brand' => $transactionData['card_brand'] ?? '', |
| 125 |
'payment_method_type' => $transactionData['payment_method_type'] ?? '', |
| 126 |
]); |
| 127 |
$existingTransaction->update($transactionUpdateData); |
| 128 |
$existingTransaction = OrderTransaction::query()->find($existingTransaction->id); |
| 129 |
|
| 130 |
$billingInfo = $subscriptionModel->getMeta('active_payment_method', []) ?: []; |
| 131 |
|
| 132 |
static::recordManualRenewal($subscriptionModel, $existingTransaction, [ |
| 133 |
'billing_info' => $billingInfo, |
| 134 |
'subscription_args' => $subscriptionUpdateArgs, |
| 135 |
]); |
| 136 |
|
| 137 |
if ($lockName) { |
| 138 |
$wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)); |
| 139 |
} |
| 140 |
|
| 141 |
return $existingTransaction; |
| 142 |
} |
| 143 |
} |
| 144 |
|
| 145 |
$transactionDefaults = [ |
| 146 |
'order_id' => $parentOrder->id, |
| 147 |
'subscription_id' => $subscriptionModel->id, |
| 148 |
'order_type' => Status::ORDER_TYPE_RENEWAL, |
| 149 |
'transaction_type' => Status::TRANSACTION_TYPE_CHARGE, |
| 150 |
'payment_method' => $subscriptionModel->current_payment_method, |
| 151 |
'payment_mode' => $parentOrder->mode, |
| 152 |
'status' => Status::TRANSACTION_SUCCEEDED, |
| 153 |
'currency' => $parentOrder->currency, |
| 154 |
'total' => $subscriptionModel->recurring_total, |
| 155 |
'meta' => Arr::get($transactionData, 'meta', []) |
| 156 |
]; |
| 157 |
|
| 158 |
$transactionData = wp_parse_args($transactionData, $transactionDefaults); |
| 159 |
|
| 160 |
$createdAt = Arr::get($transactionData, 'created_at', DateTime::now()->format('Y-m-d H:i:s')); |
| 161 |
|
| 162 |
// Let's create the order item first |
| 163 |
$variation = $subscriptionModel->variation; |
| 164 |
$product = $subscriptionModel->product; |
| 165 |
|
| 166 |
$parentOrderItem = OrderItem::query() |
| 167 |
->where('order_id', $parentOrder->id) |
| 168 |
->where('payment_type', Status::ORDER_TYPE_SUBSCRIPTION) |
| 169 |
->first(); |
| 170 |
|
| 171 |
$taxTotal = Arr::get($transactionData, 'tax_total', 0); |
| 172 |
if (!$taxTotal && $subscriptionModel->recurring_tax_total) { |
| 173 |
$taxTotal = $subscriptionModel->recurring_tax_total; |
| 174 |
} |
| 175 |
|
| 176 |
// A subscription item may be inclusive even when the parent order is mixed (behavior=3). |
| 177 |
// Check the per-item line_meta to determine the actual inclusion for this item. |
| 178 |
$isItemInclusive = $parentOrder->tax_behavior === 2 |
| 179 |
|| ($parentOrder->tax_behavior === 3 && $parentOrderItem !== null |
| 180 |
&& (bool) Arr::get((array) $parentOrderItem->line_meta, 'tax_config.inclusive', false)); |
| 181 |
|
| 182 |
if (!$taxTotal && $isItemInclusive && $parentOrderItem) { |
| 183 |
$taxTotal = (int) Arr::get($parentOrderItem->other_info, 'recurring_tax', 0); |
| 184 |
} |
| 185 |
|
| 186 |
$subtotal = $transactionData['total']; |
| 187 |
if ($taxTotal) { |
| 188 |
$subtotal = $transactionData['total'] - $taxTotal; |
| 189 |
} |
| 190 |
|
| 191 |
$orderItem = [ |
| 192 |
'post_id' => $subscriptionModel->product_id, |
| 193 |
'object_id' => $subscriptionModel->variation_id, |
| 194 |
'payment_type' => Status::ORDER_TYPE_SUBSCRIPTION, |
| 195 |
'post_title' => $product && $product->post_title ? $product->post_title : $subscriptionModel->item_name, |
| 196 |
'title' => $product && $variation ? $variation->variation_title : '', |
| 197 |
'quantity' => 1, |
| 198 |
'fulfillment_type' => $parentOrderItem ? $parentOrderItem->fulfillment_type : 'digital', |
| 199 |
'unit_price' => $subtotal, |
| 200 |
'subtotal' => $subtotal, |
| 201 |
'tax_amount' => $taxTotal, |
| 202 |
'line_total' => $transactionData['total'], |
| 203 |
'line_meta' => [], |
| 204 |
'other_info' => [] |
| 205 |
]; |
| 206 |
|
| 207 |
$bundleItemIds = Arr::get($parentOrderItem->line_meta, 'bundle_item_ids', []); |
| 208 |
|
| 209 |
$isBundleOrder = false; |
| 210 |
if ($bundleItemIds) { |
| 211 |
$isBundleOrder = true; |
| 212 |
$orderItem['line_meta'] = array_merge( |
| 213 |
$orderItem['line_meta'], |
| 214 |
[ |
| 215 |
'bundle_item_ids' => $bundleItemIds |
| 216 |
] |
| 217 |
); |
| 218 |
} |
| 219 |
|
| 220 |
$fulfillmentType = $orderItem['fulfillment_type']; |
| 221 |
|
| 222 |
$wpdb->query('START TRANSACTION'); |
| 223 |
|
| 224 |
// Let's create the order first |
| 225 |
$childOrderData = [ |
| 226 |
'parent_id' => $parentOrder->id, |
| 227 |
'fulfillment_type' => $fulfillmentType, |
| 228 |
'status' => $fulfillmentType === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED, |
| 229 |
'type' => Status::ORDER_TYPE_RENEWAL, |
| 230 |
'mode' => $transactionData['payment_mode'], |
| 231 |
'shipping_status' => $fulfillmentType === 'physical' ? Status::SHIPPING_UNSHIPPED : '', |
| 232 |
'customer_id' => $subscriptionModel->customer_id, |
| 233 |
'payment_method' => $transactionData['payment_method'], |
| 234 |
'payment_status' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? Status::PAYMENT_PAID : Status::PAYMENT_PENDING, |
| 235 |
'currency' => $transactionData['currency'], |
| 236 |
'tax_behavior' => $parentOrder->tax_behavior, |
| 237 |
'subtotal' => $subtotal, |
| 238 |
'tax_total' => $taxTotal, |
| 239 |
'total_amount' => $transactionData['total'], |
| 240 |
'total_paid' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? $transactionData['total'] : 0, |
| 241 |
'completed_at' => DateTime::now()->format('Y-m-d H:i:s'), |
| 242 |
'created_at' => $createdAt, |
| 243 |
'config' => [] |
| 244 |
]; |
| 245 |
|
| 246 |
try { |
| 247 |
$childOrder = Order::query()->create($childOrderData); |
| 248 |
|
| 249 |
if (!$childOrder) { |
| 250 |
throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart')); |
| 251 |
} |
| 252 |
|
| 253 |
$billingAddress = $parentOrder->billing_address; |
| 254 |
$shippingAddress = $parentOrder->shipping_address; |
| 255 |
|
| 256 |
$customer = $parentOrder->customer; |
| 257 |
|
| 258 |
$fullName = ''; |
| 259 |
$email = ''; |
| 260 |
$firstName = ''; |
| 261 |
$lastName = ''; |
| 262 |
if ($customer) { |
| 263 |
$fullName = $customer->first_name . ' ' . $customer->last_name; |
| 264 |
$email = $customer->email; |
| 265 |
$firstName = $customer->first_name; |
| 266 |
$lastName = $customer->last_name; |
| 267 |
} |
| 268 |
|
| 269 |
$billingAddressData = $billingAddress ? [ |
| 270 |
'type' => 'billing', |
| 271 |
'full_name' => $fullName, |
| 272 |
'address_1' => $billingAddress->address_1, |
| 273 |
'address_2' => $billingAddress->address_2, |
| 274 |
'city' => $billingAddress->city, |
| 275 |
'state' => $billingAddress->state, |
| 276 |
'postcode' => $billingAddress->postcode, |
| 277 |
'country' => $billingAddress->country, |
| 278 |
'email' => $email, |
| 279 |
'first_name' => $firstName, |
| 280 |
'last_name' => $lastName |
| 281 |
] : []; |
| 282 |
|
| 283 |
$shippingAddressData = $shippingAddress ? [ |
| 284 |
'type' => 'shipping', |
| 285 |
'full_name' => $fullName, |
| 286 |
'address_1' => $shippingAddress->address_1, |
| 287 |
'address_2' => $shippingAddress->address_2, |
| 288 |
'city' => $shippingAddress->city, |
| 289 |
'state' => $shippingAddress->state, |
| 290 |
'postcode' => $shippingAddress->postcode, |
| 291 |
'country' => $shippingAddress->country, |
| 292 |
'email' => $email, |
| 293 |
'first_name' => $firstName, |
| 294 |
'last_name' => $lastName |
| 295 |
] : []; |
| 296 |
|
| 297 |
\FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( |
| 298 |
$childOrder->id, |
| 299 |
$billingAddressData, |
| 300 |
$shippingAddressData |
| 301 |
); |
| 302 |
|
| 303 |
// Copy tax ID meta from parent order if exists |
| 304 |
$parentTaxId = $parentOrder->getMeta('tax_id', ''); |
| 305 |
if ($parentTaxId) { |
| 306 |
$childOrder->updateMeta('tax_id', $parentTaxId); |
| 307 |
} |
| 308 |
|
| 309 |
// Copy order tax rates from parent order |
| 310 |
$parentTaxRates = $parentOrder->orderTaxRates; |
| 311 |
foreach ($parentTaxRates as $taxRate) { |
| 312 |
OrderTaxRate::query()->create([ |
| 313 |
'order_id' => $childOrder->id, |
| 314 |
'tax_rate_id' => $taxRate->tax_rate_id, |
| 315 |
'shipping_tax' => $taxRate->shipping_tax, |
| 316 |
'order_tax' => $taxRate->order_tax, |
| 317 |
'total_tax' => $taxRate->total_tax, |
| 318 |
'meta' => $taxRate->meta, |
| 319 |
]); |
| 320 |
} |
| 321 |
|
| 322 |
// Create Order Item |
| 323 |
$orderItem['order_id'] = $childOrder->id; |
| 324 |
$orderItem['created_at'] = $createdAt; |
| 325 |
OrderItem::query()->create($orderItem); |
| 326 |
|
| 327 |
// let's create the transaction |
| 328 |
$transactionData['order_id'] = $childOrder->id; |
| 329 |
|
| 330 |
$createdTransaction = OrderTransaction::query()->create($transactionData); |
| 331 |
|
| 332 |
$subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs); |
| 333 |
|
| 334 |
$wpdb->query('COMMIT'); |
| 335 |
} catch (\Throwable $e) { |
| 336 |
$wpdb->query('ROLLBACK'); |
| 337 |
if ($lockName) { |
| 338 |
$wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)); |
| 339 |
} |
| 340 |
return new \WP_Error('renewal_failed', $e->getMessage()); |
| 341 |
} |
| 342 |
|
| 343 |
(new SubscriptionRenewed($subscriptionModel, $childOrder, $parentOrder, $childOrder->customer))->dispatch(); |
| 344 |
|
| 345 |
return $createdTransaction; |
| 346 |
} |
| 347 |
|
| 348 |
/** |
| 349 |
* @param $subscriptionModel |
| 350 |
* @param $subscriptionUpdateArgs |
| 351 |
* - next_billing_date - You must provide this if you want to update the next billing date. |
| 352 |
* * - Accepts all other filliable attributes of the Subscription model. |
| 353 |
* @return mixed |
| 354 |
*/ |
| 355 |
public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = []) |
| 356 |
{ |
| 357 |
$billsCount = $subscriptionModel->calculateBillCount(); |
| 358 |
|
| 359 |
$subscriptionUpdateArgs['bill_count'] = $billsCount; |
| 360 |
$billTimes = $subscriptionModel->bill_times; |
| 361 |
$oldStatus = $subscriptionModel->status; |
| 362 |
|
| 363 |
$subscriptionUpdateArgs['bill_count'] = $billsCount; |
| 364 |
$isEot = $billTimes > 0 && $billsCount >= $billTimes; |
| 365 |
|
| 366 |
if ($isEot) { |
| 367 |
$subscriptionUpdateArgs['status'] = 'completed'; |
| 368 |
$subscriptionUpdateArgs['next_billing_date'] = NULL; |
| 369 |
$subscriptionUpdateArgs['canceled_at'] = NULL; |
| 370 |
} else if (!$subscriptionModel->next_billing_date && empty($subscriptionUpdateArgs['next_billing_date'])) { |
| 371 |
$subscriptionUpdateArgs['next_billing_date'] = $subscriptionModel->guessNextBillingDate(); |
| 372 |
} |
| 373 |
|
| 374 |
if (Arr::get($subscriptionUpdateArgs, 'status') === Status::SUBSCRIPTION_ACTIVE) { |
| 375 |
$subscriptionUpdateArgs['recurring_total'] = Arr::get($subscriptionUpdateArgs, 'recurring_total', $subscriptionModel->recurring_total); |
| 376 |
} |
| 377 |
|
| 378 |
$givenSubscriptionStatus = Arr::get($subscriptionUpdateArgs, 'status'); |
| 379 |
if ($givenSubscriptionStatus === Status::SUBSCRIPTION_CANCELED && empty($subscriptionUpdateArgs['canceled_at'])) { |
| 380 |
$subscriptionUpdateArgs['canceled_at'] = gmdate('Y-m-d H:i:s'); |
| 381 |
} |
| 382 |
|
| 383 |
$subscriptionModel->fill($subscriptionUpdateArgs); |
| 384 |
$dirtyData = $subscriptionModel->getDirty(); |
| 385 |
$subscriptionModel->save(); |
| 386 |
|
| 387 |
$meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', [])); |
| 388 |
|
| 389 |
foreach ($meta as $key => $value) { |
| 390 |
$subscriptionModel->updateMeta($key, $value); |
| 391 |
} |
| 392 |
|
| 393 |
// The gateway on file just changed (a renewal invoice paid through a different |
| 394 |
// gateway, an admin edit). `system` is only meaningful while that gateway can |
| 395 |
// token-charge, so re-derive it — otherwise the subscription keeps claiming |
| 396 |
// auto-charge against a gateway that will refuse every attempt. |
| 397 |
if (isset($dirtyData['current_payment_method'])) { |
| 398 |
SystemChargeService::reconcileGatewayCapability($subscriptionModel); |
| 399 |
} |
| 400 |
|
| 401 |
// validity_expired_at should only exist when status IS expired |
| 402 |
if ($subscriptionModel->status !== Status::SUBSCRIPTION_EXPIRED) { |
| 403 |
$subscriptionModel->deleteMeta('validity_expired_at'); |
| 404 |
} |
| 405 |
|
| 406 |
if ($oldStatus === $subscriptionModel->status) { |
| 407 |
if ($dirtyData) { |
| 408 |
do_action('fluent_cart/subscription/data_updated', [ |
| 409 |
'subscription' => $subscriptionModel, |
| 410 |
'updated_data' => $dirtyData |
| 411 |
]); |
| 412 |
} |
| 413 |
|
| 414 |
return $subscriptionModel; // No change in status |
| 415 |
} |
| 416 |
|
| 417 |
if ($isEot) { |
| 418 |
(new SubscriptionEOT($subscriptionModel, $subscriptionModel->order))->dispatch(); |
| 419 |
} |
| 420 |
|
| 421 |
do_action('fluent_cart/payments/subscription_status_changed', [ |
| 422 |
'subscription' => $subscriptionModel, |
| 423 |
'order' => $subscriptionModel->order, |
| 424 |
'customer' => $subscriptionModel->customer, |
| 425 |
'old_status' => $oldStatus, |
| 426 |
'new_status' => $subscriptionModel->status |
| 427 |
]); |
| 428 |
|
| 429 |
/** |
| 430 |
* lists of hooks for this action |
| 431 |
* fluent_cart/payments/subscription_canceled |
| 432 |
* fluent_cart/payments/subscription_active |
| 433 |
* fluent_cart/payments/subscription_paused |
| 434 |
* fluent_cart/payments/subscription_expired |
| 435 |
* fluent_cart/payments/subscription_failing |
| 436 |
* fluent_cart/payments/subscription_expiring |
| 437 |
* fluent_cart/payments/subscription_completed |
| 438 |
**/ |
| 439 |
do_action('fluent_cart/payments/subscription_' . $subscriptionModel->status, [ |
| 440 |
'subscription' => $subscriptionModel, |
| 441 |
'order' => $subscriptionModel->order, |
| 442 |
'customer' => $subscriptionModel->customer, |
| 443 |
'old_status' => $oldStatus, |
| 444 |
'new_status' => $subscriptionModel->status |
| 445 |
]); |
| 446 |
|
| 447 |
// Gateway-originated cancel (webhook) reaches only the raw status bus above; |
| 448 |
// route it through the chokepoint so void + native event fire like every |
| 449 |
// other path. The old_status === status early return keeps this once-only. |
| 450 |
if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED) { |
| 451 |
self::finalizeCancellation( |
| 452 |
$subscriptionModel, |
| 453 |
Arr::get($subscriptionUpdateArgs, 'reason', __('Canceled at gateway', 'fluent-cart')) |
| 454 |
); |
| 455 |
} |
| 456 |
|
| 457 |
// note: we needed this event, currently being used in integrations |
| 458 |
if ($subscriptionModel->status === Status::SUBSCRIPTION_EXPIRED) { |
| 459 |
$subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s')); |
| 460 |
(new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch(); |
| 461 |
} |
| 462 |
|
| 463 |
if ($subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE && |
| 464 |
in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) { |
| 465 |
(new SubscriptionReactivated($subscriptionModel, $subscriptionModel->order, $subscriptionModel->customer, $oldStatus))->dispatch(); |
| 466 |
} |
| 467 |
|
| 468 |
if ($subscriptionModel->status === Status::SUBSCRIPTION_PAUSED && $oldStatus === Status::SUBSCRIPTION_ACTIVE) { |
| 469 |
self::dispatchStatusEvent($subscriptionModel, 'paused', ['old_status' => $oldStatus]); |
| 470 |
} |
| 471 |
|
| 472 |
if ($subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE && $oldStatus === Status::SUBSCRIPTION_PAUSED) { |
| 473 |
self::dispatchStatusEvent($subscriptionModel, 'resumed', ['old_status' => $oldStatus]); |
| 474 |
} |
| 475 |
|
| 476 |
return $subscriptionModel; |
| 477 |
} |
| 478 |
|
| 479 |
|
| 480 |
/** |
| 481 |
* |
| 482 |
* Use this method when you are reactivating a expired subscription manually by creating order, transaction etc. |
| 483 |
* Make sure you already handle your transaction statuses! |
| 484 |
* |
| 485 |
* @param \FluentCart\App\Models\Subscription $subscriptionModel |
| 486 |
* @param \FluentCart\App\Models\OrderTransaction $transaction |
| 487 |
* @param $args |
| 488 |
* @return mixed |
| 489 |
*/ |
| 490 |
public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = []) |
| 491 |
{ |
| 492 |
$renewalOrder = $transaction->order; |
| 493 |
|
| 494 |
// payment_status and total_paid are deliberately NOT set here — every caller has |
| 495 |
// already marked the transaction succeeded, and syncOrderStatuses() below derives |
| 496 |
// both from the transactions and claims the pending → paid transition atomically. |
| 497 |
// Pre-setting them destroyed that transition, which (a) suppressed |
| 498 |
// fluent_cart/renewal_paid, so RenewalService::handleRenewalPaid() never |
| 499 |
// advanced next_billing_date (the customer was re-invoiced forever), and |
| 500 |
// (b) bypassed the atomic claim that stops a webhook and a browser confirmation |
| 501 |
// from both processing the same renewal payment. |
| 502 |
$orderUpdateData = [ |
| 503 |
'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED, |
| 504 |
'type' => Status::ORDER_TYPE_RENEWAL, |
| 505 |
'payment_method' => $transaction->payment_method, |
| 506 |
'completed_at' => DateTime::now()->format('Y-m-d H:i:s') |
| 507 |
]; |
| 508 |
|
| 509 |
$renewalOrder->fill($orderUpdateData); |
| 510 |
$renewalOrder->save(); |
| 511 |
|
| 512 |
if ($billingInfo = Arr::get($args, 'billing_info', [])) { |
| 513 |
$subscriptionModel->updateMeta('active_payment_method', $billingInfo); |
| 514 |
} |
| 515 |
|
| 516 |
$updateData = wp_parse_args(Arr::get($args, 'subscription_args', []), [ |
| 517 |
'status' => Status::SUBSCRIPTION_ACTIVE, |
| 518 |
'current_payment_method' => $transaction->payment_method, |
| 519 |
]); |
| 520 |
|
| 521 |
$subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $updateData); |
| 522 |
|
| 523 |
(new StatusHelper($transaction->order))->syncOrderStatuses($transaction); |
| 524 |
|
| 525 |
// Single-event contract for renewal processing — exactly one owner per |
| 526 |
// subscription type, so SubscriptionRenewed fires exactly once: |
| 527 |
// |
| 528 |
// store-billed (manual/system) → RenewalService::handleRenewalPaid(), |
| 529 |
// reached through the fluent_cart/renewal_paid hook that |
| 530 |
// syncOrderStatuses() fires above. It advances next_billing_date, |
| 531 |
// derives bill_count / EOT, and dispatches the event. |
| 532 |
// gateway-billed (automatic) → here. The invoice engine does not handle |
| 533 |
// these, so this is their only dispatch point. |
| 534 |
// |
| 535 |
// Keyed on the collection method rather than on the `renewal_processed` |
| 536 |
// marker: handleRenewalPaid() stamps that marker before its EOT early-return, |
| 537 |
// so a marker check would swallow the event on a final installment. |
| 538 |
if ($transaction->total > 0 && !$subscriptionModel->usesRenewalEngine()) { |
| 539 |
(new SubscriptionRenewed($subscriptionModel, $renewalOrder, $subscriptionModel->order, $renewalOrder->customer))->dispatch(); |
| 540 |
} |
| 541 |
|
| 542 |
return $subscriptionModel; |
| 543 |
} |
| 544 |
|
| 545 |
/** |
| 546 |
* Single dispatch point for subscription lifecycle status events. |
| 547 |
* |
| 548 |
* Every confirmed transition — manual local update, gateway sync response, or |
| 549 |
* gateway webhook/confirmation — routes through here so the first-class event |
| 550 |
* (and the hook it fires) happens exactly once, whatever path caused the change. |
| 551 |
* |
| 552 |
* @param Subscription $subscription |
| 553 |
* @param string $event One of: paused, resumed, updated, period_skipped |
| 554 |
* @param array $context order, customer, old_status, reason, updates, changes, |
| 555 |
* old_next_billing_date, new_next_billing_date |
| 556 |
* @return void |
| 557 |
*/ |
| 558 |
public static function dispatchStatusEvent(Subscription $subscription, string $event, array $context = []) |
| 559 |
{ |
| 560 |
$order = Arr::get($context, 'order') ?: $subscription->order; |
| 561 |
$customer = Arr::get($context, 'customer') ?: ($order ? $order->customer : null); |
| 562 |
$oldStatus = Arr::get($context, 'old_status'); |
| 563 |
$reason = (string) Arr::get($context, 'reason', ''); |
| 564 |
|
| 565 |
switch ($event) { |
| 566 |
case 'paused': |
| 567 |
(new SubscriptionPaused($subscription, $order, $customer, $oldStatus, $reason))->dispatch(); |
| 568 |
break; |
| 569 |
case 'resumed': |
| 570 |
(new SubscriptionResumed($subscription, $order, $customer, $oldStatus, $reason))->dispatch(); |
| 571 |
break; |
| 572 |
case 'updated': |
| 573 |
(new SubscriptionUpdated($subscription, $order, $customer, Arr::get($context, 'updates', []), Arr::get($context, 'changes', [])))->dispatch(); |
| 574 |
break; |
| 575 |
case 'period_skipped': |
| 576 |
(new SubscriptionPeriodSkipped($subscription, $order, $customer, Arr::get($context, 'old_next_billing_date'), Arr::get($context, 'new_next_billing_date')))->dispatch(); |
| 577 |
break; |
| 578 |
} |
| 579 |
} |
| 580 |
|
| 581 |
/** |
| 582 |
* Pause a subscription |
| 583 |
* |
| 584 |
* For manual subscriptions, this just updates the local status. |
| 585 |
* For automatic subscriptions, delegates to the gateway. |
| 586 |
* |
| 587 |
* @param Subscription $subscription |
| 588 |
* @param string $reason |
| 589 |
* @return true|\WP_Error |
| 590 |
*/ |
| 591 |
public static function pauseSubscription(Subscription $subscription, $reason = '') |
| 592 |
{ |
| 593 |
if (!$subscription->canPause()) { |
| 594 |
return new \WP_Error( |
| 595 |
'cannot_pause', |
| 596 |
__('This subscription cannot be paused.', 'fluent-cart') |
| 597 |
); |
| 598 |
} |
| 599 |
|
| 600 |
// Store-billed (manual/system) subscriptions: local status update. |
| 601 |
if ($subscription->usesRenewalEngine()) { |
| 602 |
$oldStatus = $subscription->status; |
| 603 |
$subscription->status = Status::SUBSCRIPTION_PAUSED; |
| 604 |
$subscription->save(); |
| 605 |
|
| 606 |
self::voidPendingRenewals( |
| 607 |
$subscription, |
| 608 |
'Subscription paused; open renewal order voided.' |
| 609 |
); |
| 610 |
|
| 611 |
$subscription->addLog( |
| 612 |
'Subscription paused', |
| 613 |
$reason ?: __('Subscription paused manually', 'fluent-cart'), |
| 614 |
'info' |
| 615 |
); |
| 616 |
|
| 617 |
// Fires fluent_cart/subscription_paused once, with the original |
| 618 |
// subscription/reason keys plus order/customer/old_status. |
| 619 |
self::dispatchStatusEvent($subscription, 'paused', [ |
| 620 |
'old_status' => $oldStatus, |
| 621 |
'reason' => $reason, |
| 622 |
]); |
| 623 |
|
| 624 |
return true; |
| 625 |
} |
| 626 |
|
| 627 |
// Automatic subscriptions: delegate to gateway |
| 628 |
$gateway = App::gateway($subscription->current_payment_method); |
| 629 |
|
| 630 |
if (!$gateway || !in_array('pause_subscription', $gateway->supportedFeatures)) { |
| 631 |
return new \WP_Error( |
| 632 |
'unsupported_pause', |
| 633 |
__('Current payment method does not support pausing.', 'fluent-cart') |
| 634 |
); |
| 635 |
} |
| 636 |
|
| 637 |
if (method_exists($gateway->subscriptions, 'pause')) { |
| 638 |
return $gateway->subscriptions->pause($subscription, $reason); |
| 639 |
} |
| 640 |
|
| 641 |
return new \WP_Error( |
| 642 |
'unsupported_pause', |
| 643 |
__('Current payment method does not support pausing.', 'fluent-cart') |
| 644 |
); |
| 645 |
} |
| 646 |
|
| 647 |
/** |
| 648 |
* Resume a paused subscription |
| 649 |
* |
| 650 |
* For manual subscriptions, this updates status back to active. |
| 651 |
* For automatic subscriptions, delegates to the gateway. |
| 652 |
* |
| 653 |
* @param Subscription $subscription |
| 654 |
* @param string $reason |
| 655 |
* @return true|\WP_Error |
| 656 |
*/ |
| 657 |
public static function resumeSubscription(Subscription $subscription, $reason = '') |
| 658 |
{ |
| 659 |
if (!$subscription->canResume()) { |
| 660 |
return new \WP_Error( |
| 661 |
'cannot_resume', |
| 662 |
__('This subscription cannot be resumed.', 'fluent-cart') |
| 663 |
); |
| 664 |
} |
| 665 |
|
| 666 |
// Store-billed (manual/system) subscriptions: local status update. |
| 667 |
if ($subscription->usesRenewalEngine()) { |
| 668 |
$oldStatus = $subscription->status; |
| 669 |
$subscription->status = Status::SUBSCRIPTION_ACTIVE; |
| 670 |
$subscription->save(); |
| 671 |
|
| 672 |
SystemChargeService::restoreScheduledChargesForSubscription($subscription); |
| 673 |
|
| 674 |
$subscription->addLog( |
| 675 |
'Subscription resumed', |
| 676 |
$reason ?: __('Subscription resumed manually', 'fluent-cart'), |
| 677 |
'info' |
| 678 |
); |
| 679 |
|
| 680 |
// Fires fluent_cart/subscription_resumed once, with the original |
| 681 |
// subscription/reason keys plus order/customer/old_status. |
| 682 |
self::dispatchStatusEvent($subscription, 'resumed', [ |
| 683 |
'old_status' => $oldStatus, |
| 684 |
'reason' => $reason, |
| 685 |
]); |
| 686 |
|
| 687 |
return true; |
| 688 |
} |
| 689 |
|
| 690 |
// Automatic subscriptions: delegate to gateway |
| 691 |
$gateway = App::gateway($subscription->current_payment_method); |
| 692 |
|
| 693 |
if (!$gateway || !in_array('resume_subscription', $gateway->supportedFeatures)) { |
| 694 |
return new \WP_Error( |
| 695 |
'unsupported_resume', |
| 696 |
__('Current payment method does not support resuming.', 'fluent-cart') |
| 697 |
); |
| 698 |
} |
| 699 |
|
| 700 |
if (method_exists($gateway->subscriptions, 'resume')) { |
| 701 |
return $gateway->subscriptions->resume($subscription, $reason); |
| 702 |
} |
| 703 |
|
| 704 |
return new \WP_Error( |
| 705 |
'unsupported_resume', |
| 706 |
__('Current payment method does not support resuming.', 'fluent-cart') |
| 707 |
); |
| 708 |
} |
| 709 |
|
| 710 |
/** |
| 711 |
* Reactivate a canceled/expired store-billed (manual or system) subscription locally — |
| 712 |
* no gateway/checkout involved. Voids any pending renewal invoice from the missed |
| 713 |
* period and advances next_billing_date so the overdue scanner doesn't immediately |
| 714 |
* re-flag it. Shared by the admin reactivate endpoint and the customer-dashboard |
| 715 |
* future-dated reactivation short-circuit. |
| 716 |
* |
| 717 |
* @param Subscription $subscription |
| 718 |
* @return Subscription|\WP_Error |
| 719 |
*/ |
| 720 |
public static function reactivateSubscriptionLocally(Subscription $subscription) |
| 721 |
{ |
| 722 |
if (!$subscription->usesRenewalEngine()) { |
| 723 |
return new \WP_Error( |
| 724 |
'unsupported_local_reactivation', |
| 725 |
__('This subscription must be reactivated through its payment gateway.', 'fluent-cart') |
| 726 |
); |
| 727 |
} |
| 728 |
|
| 729 |
if (!$subscription->canReactivate()) { |
| 730 |
return new \WP_Error( |
| 731 |
'cannot_reactivate', |
| 732 |
__('This subscription cannot be reactivated.', 'fluent-cart') |
| 733 |
); |
| 734 |
} |
| 735 |
|
| 736 |
global $wpdb; |
| 737 |
|
| 738 |
$wpdb->query('START TRANSACTION'); |
| 739 |
|
| 740 |
try { |
| 741 |
// Lock subscription before orders — skipNextPeriod locks in this order too; |
| 742 |
// diverging risks a deadlock on the same rows. |
| 743 |
$locked = Subscription::query() |
| 744 |
->where('id', $subscription->id) |
| 745 |
->lockForUpdate() |
| 746 |
->first(); |
| 747 |
|
| 748 |
// Re-check under the lock: canReactivate() ran on pre-lock state. |
| 749 |
$subscription->fill([ |
| 750 |
'status' => $locked ? $locked->status : $subscription->status, |
| 751 |
'next_billing_date' => $locked ? $locked->next_billing_date : $subscription->next_billing_date, |
| 752 |
]); |
| 753 |
|
| 754 |
if (!$locked || !$subscription->canReactivate()) { |
| 755 |
$wpdb->query('ROLLBACK'); |
| 756 |
return new \WP_Error( |
| 757 |
'cannot_reactivate', |
| 758 |
__('This subscription cannot be reactivated.', 'fluent-cart') |
| 759 |
); |
| 760 |
} |
| 761 |
|
| 762 |
$oldStatus = $subscription->status; |
| 763 |
|
| 764 |
$pendingOrderIds = Order::query() |
| 765 |
->where('type', Status::ORDER_TYPE_RENEWAL) |
| 766 |
->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) |
| 767 |
->where('parent_id', $subscription->parent_order_id) |
| 768 |
->pluck('id'); |
| 769 |
|
| 770 |
if ($pendingOrderIds->isNotEmpty()) { |
| 771 |
// Re-assert payment_status at mutation time — a webhook may have paid |
| 772 |
// this order between the select above and this update. |
| 773 |
Order::query() |
| 774 |
->whereIn('id', $pendingOrderIds) |
| 775 |
->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) |
| 776 |
->update([ |
| 777 |
'status' => Status::ORDER_CANCELED, |
| 778 |
'payment_status' => Status::PAYMENT_FAILED, |
| 779 |
]); |
| 780 |
|
| 781 |
// Only fail transactions for orders actually voided above — a |
| 782 |
// paid-in-the-race order is excluded by the update's payment_status |
| 783 |
// predicate, so it must be excluded here too. |
| 784 |
$voidedOrderIds = Order::query() |
| 785 |
->whereIn('id', $pendingOrderIds) |
| 786 |
->where('status', Status::ORDER_CANCELED) |
| 787 |
->where('payment_status', Status::PAYMENT_FAILED) |
| 788 |
->pluck('id'); |
| 789 |
|
| 790 |
if ($voidedOrderIds->isNotEmpty()) { |
| 791 |
OrderTransaction::query() |
| 792 |
->whereIn('order_id', $voidedOrderIds) |
| 793 |
->where('status', Status::TRANSACTION_PENDING) |
| 794 |
->update(['status' => Status::TRANSACTION_FAILED]); |
| 795 |
} |
| 796 |
} |
| 797 |
|
| 798 |
// Overdue date must not survive reactivation, or it lands instantly due. |
| 799 |
if ($subscription->next_billing_date && strtotime($subscription->next_billing_date) <= time()) { |
| 800 |
$advancedDate = RenewalService::computeSkippedDate($subscription); |
| 801 |
if ($advancedDate) { |
| 802 |
$subscription->fill(['next_billing_date' => $advancedDate]); |
| 803 |
} |
| 804 |
} |
| 805 |
|
| 806 |
// Not syncSubscriptionStates: its EOT check would flip status to completed. |
| 807 |
$reactivationData = [ |
| 808 |
'status' => Status::SUBSCRIPTION_ACTIVE, |
| 809 |
'canceled_at' => null, |
| 810 |
]; |
| 811 |
|
| 812 |
// Guess can itself be in the past (empty date + old last order) — advance past now. |
| 813 |
if (empty($subscription->next_billing_date) |
| 814 |
|| strtotime($subscription->next_billing_date) <= time()) { |
| 815 |
$guessedTs = strtotime($subscription->guessNextBillingDate()); |
| 816 |
$intervalDays = PaymentHelper::getIntervalDays($subscription->billing_interval); |
| 817 |
if ($intervalDays > 0) { |
| 818 |
while ($guessedTs <= time()) { |
| 819 |
$guessedTs += $intervalDays * DAY_IN_SECONDS; |
| 820 |
} |
| 821 |
} |
| 822 |
$reactivationData['next_billing_date'] = gmdate('Y-m-d H:i:s', $guessedTs); |
| 823 |
} |
| 824 |
|
| 825 |
$subscription->fill($reactivationData)->save(); |
| 826 |
|
| 827 |
$wpdb->query('COMMIT'); |
| 828 |
} catch (\Throwable $e) { |
| 829 |
$wpdb->query('ROLLBACK'); |
| 830 |
|
| 831 |
return new \WP_Error('reactivation_failed', $e->getMessage()); |
| 832 |
} |
| 833 |
|
| 834 |
do_action('fluent_cart/payments/subscription_status_changed', [ |
| 835 |
'subscription' => $subscription, |
| 836 |
'order' => $subscription->order, |
| 837 |
'customer' => $subscription->customer, |
| 838 |
'old_status' => $oldStatus, |
| 839 |
'new_status' => Status::SUBSCRIPTION_ACTIVE, |
| 840 |
]); |
| 841 |
|
| 842 |
do_action('fluent_cart/payments/subscription_active', [ |
| 843 |
'subscription' => $subscription, |
| 844 |
'order' => $subscription->order, |
| 845 |
'customer' => $subscription->customer, |
| 846 |
'old_status' => $oldStatus, |
| 847 |
'new_status' => Status::SUBSCRIPTION_ACTIVE, |
| 848 |
]); |
| 849 |
|
| 850 |
if (in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) { |
| 851 |
(new SubscriptionReactivated($subscription, $subscription->order, $subscription->customer, $oldStatus))->dispatch(); |
| 852 |
} |
| 853 |
|
| 854 |
do_action('fluent_cart/subscription/reactivated_locally', $subscription); |
| 855 |
|
| 856 |
return $subscription; |
| 857 |
} |
| 858 |
|
| 859 |
/** |
| 860 |
* Update subscription details (for manual subscriptions) |
| 861 |
* |
| 862 |
* Allowed fields for manual subscriptions: |
| 863 |
* - recurring_total: Update the next invoice/payment amount (in cents) |
| 864 |
* - bill_times: Update the number of billing cycles (0 = unlimited) |
| 865 |
* - billing_interval: Change billing frequency (daily, weekly, monthly, etc.) |
| 866 |
* - expire_at: Update expiration date |
| 867 |
* - trial_days: Update trial period |
| 868 |
* - next_billing_date: Update next billing date |
| 869 |
* |
| 870 |
* @param Subscription $subscription |
| 871 |
* @param array $data |
| 872 |
* @return true|\WP_Error |
| 873 |
*/ |
| 874 |
public static function updateSubscription(Subscription $subscription, array $data) |
| 875 |
{ |
| 876 |
if (!$subscription->usesRenewalEngine()) { |
| 877 |
return new \WP_Error( |
| 878 |
'cannot_update_automatic', |
| 879 |
__('Only store-billed (manual or auto-charge) subscriptions can be updated directly.', 'fluent-cart') |
| 880 |
); |
| 881 |
} |
| 882 |
|
| 883 |
$allowedFields = [ |
| 884 |
'recurring_total', |
| 885 |
'bill_times', |
| 886 |
'billing_interval', |
| 887 |
'next_billing_date', |
| 888 |
'status' |
| 889 |
]; |
| 890 |
|
| 891 |
$updates = []; |
| 892 |
$changes = []; |
| 893 |
|
| 894 |
foreach ($data as $key => $value) { |
| 895 |
if (!in_array($key, $allowedFields)) { |
| 896 |
continue; |
| 897 |
} |
| 898 |
|
| 899 |
// Normalize recurring_total from frontend decimal to cents before comparison |
| 900 |
if ($key === 'recurring_total') { |
| 901 |
$value = (int) round(floatval($value) * 100); |
| 902 |
} |
| 903 |
|
| 904 |
$oldValue = $subscription->{$key}; |
| 905 |
|
| 906 |
// DB attributes come back as strings — normalize numeric fields on both |
| 907 |
// sides or the strict compare below always reports a change. |
| 908 |
if (in_array($key, ['recurring_total', 'bill_times'])) { |
| 909 |
$oldValue = (int) $oldValue; |
| 910 |
$value = intval($value); |
| 911 |
} |
| 912 |
|
| 913 |
if ($oldValue === $value) { |
| 914 |
continue; |
| 915 |
} |
| 916 |
|
| 917 |
// Validate and convert numeric fields |
| 918 |
if (in_array($key, ['recurring_total', 'bill_times'])) { |
| 919 |
$value = $key === 'bill_times' ? intval($value) : $value; // recurring_total already converted above |
| 920 |
if ($key === 'bill_times' && $value < 0) { |
| 921 |
return new \WP_Error( |
| 922 |
'invalid_value', |
| 923 |
__('bill_times cannot be negative.', 'fluent-cart') |
| 924 |
); |
| 925 |
} |
| 926 |
if ($key === 'bill_times' && $value > 0 && $value < $subscription->bill_count) { |
| 927 |
return new \WP_Error( |
| 928 |
'invalid_value', |
| 929 |
sprintf( |
| 930 |
__('bill_times cannot be less than the number of payments already made (%d).', 'fluent-cart'), |
| 931 |
$subscription->bill_count |
| 932 |
) |
| 933 |
); |
| 934 |
} |
| 935 |
if ($key === 'recurring_total' && $value < 0) { |
| 936 |
return new \WP_Error( |
| 937 |
'invalid_value', |
| 938 |
__('recurring_total cannot be negative.', 'fluent-cart') |
| 939 |
); |
| 940 |
} |
| 941 |
|
| 942 |
if ($key === 'recurring_total') { |
| 943 |
// Keep recurring_amount in sync: total minus existing tax |
| 944 |
$recurringAmount = $value - ($subscription->recurring_tax_total ?? 0); |
| 945 |
if ($recurringAmount < 0) { |
| 946 |
return new \WP_Error( |
| 947 |
'invalid_value', |
| 948 |
__('recurring_total cannot be less than the existing tax total.', 'fluent-cart') |
| 949 |
); |
| 950 |
} |
| 951 |
$updates['recurring_amount'] = $recurringAmount; |
| 952 |
} |
| 953 |
} |
| 954 |
|
| 955 |
if ($key === 'billing_interval') { |
| 956 |
$validIntervals = apply_filters('fluent_cart/subscription/allowed_intervals', ['daily', 'weekly', 'monthly', 'quarterly', 'half_yearly', 'yearly'], [ |
| 957 |
'subscription' => $subscription, |
| 958 |
'current_interval' => $subscription->billing_interval, |
| 959 |
'new_interval' => $value |
| 960 |
]); |
| 961 |
|
| 962 |
if (!in_array($value, $validIntervals)) { |
| 963 |
return new \WP_Error( |
| 964 |
'invalid_interval', |
| 965 |
__('Invalid billing interval.', 'fluent-cart') |
| 966 |
); |
| 967 |
} |
| 968 |
|
| 969 |
$incomingDate = isset($data['next_billing_date']) ? $data['next_billing_date'] : null; |
| 970 |
$adminChangedDate = $incomingDate && $incomingDate !== $subscription->next_billing_date; |
| 971 |
if (!$adminChangedDate && $subscription->next_billing_date) { |
| 972 |
$oldInterval = $subscription->billing_interval; |
| 973 |
$subscription->billing_interval = $value; |
| 974 |
$updates['next_billing_date'] = $subscription->guessNextBillingDate(true); |
| 975 |
$subscription->billing_interval = $oldInterval; |
| 976 |
} |
| 977 |
} |
| 978 |
|
| 979 |
if ($key === 'status') { |
| 980 |
$validStatuses = [ |
| 981 |
Status::SUBSCRIPTION_ACTIVE, |
| 982 |
Status::SUBSCRIPTION_PAUSED, |
| 983 |
Status::SUBSCRIPTION_TRIALING, |
| 984 |
Status::SUBSCRIPTION_CANCELED, |
| 985 |
Status::SUBSCRIPTION_EXPIRED, |
| 986 |
Status::SUBSCRIPTION_COMPLETED, |
| 987 |
Status::SUBSCRIPTION_PAST_DUE |
| 988 |
]; |
| 989 |
if (!in_array($value, $validStatuses)) { |
| 990 |
return new \WP_Error( |
| 991 |
'invalid_status', |
| 992 |
__('Invalid subscription status.', 'fluent-cart') |
| 993 |
); |
| 994 |
} |
| 995 |
|
| 996 |
// Sync companion fields; syncSubscriptionStates is intentionally |
| 997 |
// not used here because its EOT check recalculates bill_count and |
| 998 |
// can silently override the admin's explicit status choice. |
| 999 |
if ($value === Status::SUBSCRIPTION_CANCELED && empty($subscription->canceled_at)) { |
| 1000 |
$updates['canceled_at'] = gmdate('Y-m-d H:i:s'); |
| 1001 |
} elseif (in_array($value, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])) { |
| 1002 |
$updates['canceled_at'] = null; |
| 1003 |
if (empty($subscription->next_billing_date) && empty($data['next_billing_date'])) { |
| 1004 |
$updates['next_billing_date'] = $subscription->guessNextBillingDate(); |
| 1005 |
} |
| 1006 |
} elseif (in_array($value, [Status::SUBSCRIPTION_COMPLETED, Status::SUBSCRIPTION_EXPIRED])) { |
| 1007 |
$updates['next_billing_date'] = null; |
| 1008 |
} |
| 1009 |
|
| 1010 |
$terminalStatuses = [ |
| 1011 |
Status::SUBSCRIPTION_CANCELED, |
| 1012 |
Status::SUBSCRIPTION_COMPLETED, |
| 1013 |
Status::SUBSCRIPTION_EXPIRED, |
| 1014 |
]; |
| 1015 |
if (in_array($value, $terminalStatuses)) { |
| 1016 |
self::voidPendingRenewals( |
| 1017 |
$subscription, |
| 1018 |
sprintf('Subscription marked as %s by admin.', $value) |
| 1019 |
); |
| 1020 |
} |
| 1021 |
|
| 1022 |
// Same contract as pauseSubscription(): pausing retires the open |
| 1023 |
// invoice (and its queued system charge) so nothing collects while |
| 1024 |
// the subscription is paused. |
| 1025 |
if ($value === Status::SUBSCRIPTION_PAUSED && $subscription->status !== Status::SUBSCRIPTION_PAUSED) { |
| 1026 |
self::voidPendingRenewals( |
| 1027 |
$subscription, |
| 1028 |
__('Subscription paused; open renewal order voided.', 'fluent-cart') |
| 1029 |
); |
| 1030 |
} |
| 1031 |
} |
| 1032 |
|
| 1033 |
$updates[$key] = $value; |
| 1034 |
$logOld = $key === 'recurring_total' ? number_format($oldValue / 100, 2) : $oldValue; |
| 1035 |
$logNew = $key === 'recurring_total' ? number_format($value / 100, 2) : $value; |
| 1036 |
$changes[] = sprintf('%s: %s → %s', $key, $logOld, $logNew); |
| 1037 |
} |
| 1038 |
|
| 1039 |
if (empty($updates)) { |
| 1040 |
return new \WP_Error( |
| 1041 |
'no_changes', |
| 1042 |
__('No changes detected.', 'fluent-cart') |
| 1043 |
); |
| 1044 |
} |
| 1045 |
|
| 1046 |
$oldStatus = $subscription->status; |
| 1047 |
|
| 1048 |
foreach ($updates as $key => $value) { |
| 1049 |
$subscription->{$key} = $value; |
| 1050 |
} |
| 1051 |
|
| 1052 |
$subscription->save(); |
| 1053 |
|
| 1054 |
// Sync pending renewal invoice if amount or due date changed |
| 1055 |
$amountChanged = isset($updates['recurring_total']); |
| 1056 |
$dueDateChanged = isset($updates['next_billing_date']); |
| 1057 |
|
| 1058 |
if ($amountChanged || $dueDateChanged) { |
| 1059 |
$pendingInvoice = Order::query() |
| 1060 |
->where('parent_id', $subscription->parent_order_id) |
| 1061 |
->where('type', 'renewal') |
| 1062 |
->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) |
| 1063 |
->first(); |
| 1064 |
|
| 1065 |
if ($pendingInvoice) { |
| 1066 |
if ($amountChanged) { |
| 1067 |
$newTotal = $subscription->recurring_total; |
| 1068 |
$newTax = $subscription->recurring_tax_total; |
| 1069 |
$newSubtotal = $subscription->recurring_amount; |
| 1070 |
|
| 1071 |
$pendingInvoice->subtotal = $newSubtotal; |
| 1072 |
$pendingInvoice->tax_total = $newTax; |
| 1073 |
$pendingInvoice->total_amount = $newTotal; |
| 1074 |
$pendingInvoice->save(); |
| 1075 |
|
| 1076 |
// Same unit_price convention as RenewalService::createRenewalOrders(): |
| 1077 |
// gross per-unit for inclusive tax (behavior 2), net otherwise — the |
| 1078 |
// re-pay checkout feeds unit_price back as item_price, so a net value |
| 1079 |
// on an inclusive-tax invoice would drop the included tax. |
| 1080 |
$unitPriceBase = $pendingInvoice->tax_behavior == 2 ? $newTotal : $newSubtotal; |
| 1081 |
|
| 1082 |
OrderItem::query() |
| 1083 |
->where('order_id', $pendingInvoice->id) |
| 1084 |
->update([ |
| 1085 |
'subtotal' => $newSubtotal, |
| 1086 |
'tax_amount' => $newTax, |
| 1087 |
'line_total' => $newTotal, |
| 1088 |
'unit_price' => $subscription->quantity > 1 |
| 1089 |
? (int) round($unitPriceBase / $subscription->quantity) |
| 1090 |
: $unitPriceBase, |
| 1091 |
]); |
| 1092 |
|
| 1093 |
OrderTransaction::query() |
| 1094 |
->where('order_id', $pendingInvoice->id) |
| 1095 |
->where('status', Status::TRANSACTION_PENDING) |
| 1096 |
->update(['total' => $newTotal]); |
| 1097 |
} |
| 1098 |
|
| 1099 |
if ($dueDateChanged) { |
| 1100 |
$pendingInvoice->updateMeta('due_date', $subscription->next_billing_date); |
| 1101 |
|
| 1102 |
if ($subscription->isSystem()) { |
| 1103 |
SystemChargeService::unscheduleCharges($pendingInvoice); |
| 1104 |
SystemChargeService::scheduleCharge($pendingInvoice, $subscription); |
| 1105 |
} |
| 1106 |
} |
| 1107 |
|
| 1108 |
$pendingInvoice->addLog( |
| 1109 |
'Renewal order updated by subscription edit', |
| 1110 |
'Pending renewal order synced after admin edited subscription details.', |
| 1111 |
'info' |
| 1112 |
); |
| 1113 |
} |
| 1114 |
} |
| 1115 |
|
| 1116 |
$subscription->addLog( |
| 1117 |
'Subscription updated', |
| 1118 |
sprintf('Admin updated: %s', implode(', ', $changes)), |
| 1119 |
'info' |
| 1120 |
); |
| 1121 |
|
| 1122 |
// Fires fluent_cart/subscription_updated once, with the original |
| 1123 |
// subscription/updates/changes keys plus order/customer. |
| 1124 |
self::dispatchStatusEvent($subscription, 'updated', [ |
| 1125 |
'updates' => $updates, |
| 1126 |
'changes' => $changes, |
| 1127 |
]); |
| 1128 |
|
| 1129 |
// Same contract as syncSubscriptionStates()'s no-status-change branch — |
| 1130 |
// Pro's license-extension listener only reacts to this hook. |
| 1131 |
do_action('fluent_cart/subscription/data_updated', [ |
| 1132 |
'subscription' => $subscription, |
| 1133 |
'updated_data' => $updates |
| 1134 |
]); |
| 1135 |
|
| 1136 |
if ($oldStatus !== $subscription->status) { |
| 1137 |
do_action('fluent_cart/payments/subscription_status_changed', [ |
| 1138 |
'subscription' => $subscription, |
| 1139 |
'order' => $subscription->order, |
| 1140 |
'customer' => $subscription->customer, |
| 1141 |
'old_status' => $oldStatus, |
| 1142 |
'new_status' => $subscription->status, |
| 1143 |
]); |
| 1144 |
|
| 1145 |
do_action('fluent_cart/payments/subscription_' . $subscription->status, [ |
| 1146 |
'subscription' => $subscription, |
| 1147 |
'order' => $subscription->order, |
| 1148 |
'customer' => $subscription->customer, |
| 1149 |
'old_status' => $oldStatus, |
| 1150 |
'new_status' => $subscription->status, |
| 1151 |
]); |
| 1152 |
|
| 1153 |
if ($subscription->status === Status::SUBSCRIPTION_EXPIRED) { |
| 1154 |
$subscription->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s')); |
| 1155 |
(new SubscriptionValidityExpired($subscription, $subscription->order, $subscription->customer))->dispatch(); |
| 1156 |
} |
| 1157 |
|
| 1158 |
if ($subscription->status === Status::SUBSCRIPTION_COMPLETED) { |
| 1159 |
(new SubscriptionEOT($subscription, $subscription->order))->dispatch(); |
| 1160 |
} |
| 1161 |
|
| 1162 |
// Event was the only missing cancel side-effect on the edit path; the |
| 1163 |
// event now drives both email and reminder-clear. |
| 1164 |
if ($subscription->status === Status::SUBSCRIPTION_CANCELED) { |
| 1165 |
self::finalizeCancellation($subscription, __('Canceled by admin edit', 'fluent-cart')); |
| 1166 |
} |
| 1167 |
|
| 1168 |
if ($subscription->status === Status::SUBSCRIPTION_ACTIVE && |
| 1169 |
in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) { |
| 1170 |
(new SubscriptionReactivated($subscription, $subscription->order, $subscription->customer, $oldStatus))->dispatch(); |
| 1171 |
} |
| 1172 |
|
| 1173 |
// Same contract as pauseSubscription()/resumeSubscription(): fire the |
| 1174 |
// dedicated pause/resume events, and re-queue any system charge that was |
| 1175 |
// skipped while paused so a resumed subscription cannot strand a |
| 1176 |
// payment_scheduled invoice. |
| 1177 |
if ($subscription->status === Status::SUBSCRIPTION_PAUSED && $oldStatus === Status::SUBSCRIPTION_ACTIVE) { |
| 1178 |
self::dispatchStatusEvent($subscription, 'paused', ['old_status' => $oldStatus]); |
| 1179 |
} |
| 1180 |
|
| 1181 |
if ($subscription->status === Status::SUBSCRIPTION_ACTIVE && $oldStatus === Status::SUBSCRIPTION_PAUSED) { |
| 1182 |
SystemChargeService::restoreScheduledChargesForSubscription($subscription); |
| 1183 |
self::dispatchStatusEvent($subscription, 'resumed', ['old_status' => $oldStatus]); |
| 1184 |
} |
| 1185 |
} |
| 1186 |
|
| 1187 |
if (isset($updates['bill_times']) && !isset($updates['status'])) { |
| 1188 |
$preSyncStatus = $subscription->status; |
| 1189 |
self::syncSubscriptionStates($subscription, []); |
| 1190 |
if ($subscription->status === Status::SUBSCRIPTION_COMPLETED |
| 1191 |
&& $preSyncStatus !== Status::SUBSCRIPTION_COMPLETED |
| 1192 |
) { |
| 1193 |
self::voidPendingRenewals( |
| 1194 |
$subscription, |
| 1195 |
__('Subscription completed — billing times reached.', 'fluent-cart') |
| 1196 |
); |
| 1197 |
} |
| 1198 |
} |
| 1199 |
|
| 1200 |
return true; |
| 1201 |
} |
| 1202 |
|
| 1203 |
/** |
| 1204 |
* Single cancellation chokepoint. Voids open renewals and dispatches the |
| 1205 |
* SubscriptionCanceled event so email + reminder-clear (both listen on the |
| 1206 |
* event hook) fire once, regardless of which cancel path ran. |
| 1207 |
* |
| 1208 |
* @param bool $dispatchEvent fire SubscriptionCanceled (email/reminder-clear/automations) |
| 1209 |
*/ |
| 1210 |
public static function finalizeCancellation(Subscription $subscription, string $reason = '', bool $dispatchEvent = true): void |
| 1211 |
{ |
| 1212 |
self::voidPendingRenewals($subscription, $reason ?: __('Subscription canceled', 'fluent-cart')); |
| 1213 |
|
| 1214 |
if ($dispatchEvent) { |
| 1215 |
(new SubscriptionCanceled($subscription, $subscription->order, $subscription->customer, $reason))->dispatch(); |
| 1216 |
} |
| 1217 |
} |
| 1218 |
|
| 1219 |
/** |
| 1220 |
* Void all pending renewal invoices for a subscription. |
| 1221 |
* Sets order status to canceled and payment_status to failed. |
| 1222 |
*/ |
| 1223 |
public static function voidPendingRenewals(Subscription $subscription, string $reason = ''): void |
| 1224 |
{ |
| 1225 |
$pendingInvoices = Order::query() |
| 1226 |
->where('parent_id', $subscription->parent_order_id) |
| 1227 |
->where('type', 'renewal') |
| 1228 |
->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) |
| 1229 |
->get(); |
| 1230 |
|
| 1231 |
foreach ($pendingInvoices as $invoice) { |
| 1232 |
if ($subscription->isSystem()) { |
| 1233 |
SystemChargeService::unscheduleCharges($invoice); |
| 1234 |
|
| 1235 |
$chargeState = $subscription->getMeta('system_charge_state', []) ?: []; |
| 1236 |
if ((int) Arr::get($chargeState, 'order_id') === (int) $invoice->id) { |
| 1237 |
$subscription->deleteMeta('system_charge_state'); |
| 1238 |
} |
| 1239 |
} |
| 1240 |
|
| 1241 |
// Re-assert payment_status at mutation time — a webhook may have paid this |
| 1242 |
// invoice between the select above and this update (same pattern as |
| 1243 |
// reactivateSubscriptionLocally). A raced-paid invoice is left untouched. |
| 1244 |
$voided = Order::query() |
| 1245 |
->where('id', $invoice->id) |
| 1246 |
->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) |
| 1247 |
->update([ |
| 1248 |
'status' => Status::ORDER_CANCELED, |
| 1249 |
'payment_status' => Status::PAYMENT_FAILED, |
| 1250 |
]); |
| 1251 |
|
| 1252 |
if (!$voided) { |
| 1253 |
continue; |
| 1254 |
} |
| 1255 |
|
| 1256 |
OrderTransaction::query() |
| 1257 |
->where('order_id', $invoice->id) |
| 1258 |
->where('status', Status::TRANSACTION_PENDING) |
| 1259 |
->update(['status' => Status::TRANSACTION_FAILED]); |
| 1260 |
|
| 1261 |
$invoice->addLog( |
| 1262 |
'Renewal order voided', |
| 1263 |
$reason ?: 'Renewal order voided automatically.', |
| 1264 |
'info' |
| 1265 |
); |
| 1266 |
} |
| 1267 |
} |
| 1268 |
} |
| 1269 |
|