| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Modules\Subscriptions\Services; |
| 4 |
|
| 5 |
use FluentCart\App\Events\Subscription\SubscriptionEOT; |
| 6 |
use FluentCart\App\Events\Subscription\SubscriptionRenewed; |
| 7 |
use FluentCart\App\Events\Subscription\SubscriptionValidityExpired; |
| 8 |
use FluentCart\App\Helpers\Status; |
| 9 |
use FluentCart\App\Helpers\StatusHelper; |
| 10 |
use FluentCart\App\Models\Order; |
| 11 |
use FluentCart\App\Models\OrderItem; |
| 12 |
use FluentCart\App\Models\OrderTaxRate; |
| 13 |
use FluentCart\App\Models\OrderTransaction; |
| 14 |
use FluentCart\App\Models\Subscription; |
| 15 |
use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API; |
| 16 |
use FluentCart\App\Services\DateTime\DateTime; |
| 17 |
use FluentCart\App\Services\Payments\PaymentHelper; |
| 18 |
use FluentCart\Framework\Support\Arr; |
| 19 |
|
| 20 |
class SubscriptionService |
| 21 |
{ |
| 22 |
public static function recordRenewalPayment($transactionData, $subscriptionModel = null, $subscriptionUpdateArgs = []) |
| 23 |
{ |
| 24 |
if (!$subscriptionModel) { |
| 25 |
$subscriptionModel = Subscription::query()->find($transactionData['subscription_id']); |
| 26 |
} |
| 27 |
|
| 28 |
if (!$subscriptionModel) { |
| 29 |
return new \WP_Error('subscription_not_found', __('Subscription not found.', 'fluent-cart')); |
| 30 |
} |
| 31 |
|
| 32 |
$vendorTransactionId = $transactionData['vendor_charge_id'] ?? null; |
| 33 |
|
| 34 |
if ($vendorTransactionId) { |
| 35 |
if (OrderTransaction::query()->where('vendor_charge_id', $vendorTransactionId)->exists()) { |
| 36 |
return new \WP_Error('transaction_exists', __('This transaction already exists for this subscription.', 'fluent-cart')); |
| 37 |
} |
| 38 |
} |
| 39 |
|
| 40 |
$parentOrder = $subscriptionModel->order; |
| 41 |
|
| 42 |
if (!$parentOrder) { |
| 43 |
return new \WP_Error('parent_order_not_found', __('Parent order not found for this subscription.', 'fluent-cart')); |
| 44 |
} |
| 45 |
|
| 46 |
$transactionDefaults = [ |
| 47 |
'order_id' => $parentOrder->id, |
| 48 |
'subscription_id' => $subscriptionModel->id, |
| 49 |
'order_type' => Status::ORDER_TYPE_RENEWAL, |
| 50 |
'transaction_type' => Status::TRANSACTION_TYPE_CHARGE, |
| 51 |
'payment_method' => $subscriptionModel->current_payment_method, |
| 52 |
'payment_mode' => $parentOrder->mode, |
| 53 |
'status' => Status::TRANSACTION_SUCCEEDED, |
| 54 |
'currency' => $parentOrder->currency, |
| 55 |
'total' => $subscriptionModel->recurring_total, |
| 56 |
'meta' => Arr::get($transactionData, 'meta', []) |
| 57 |
]; |
| 58 |
|
| 59 |
$transactionData = wp_parse_args($transactionData, $transactionDefaults); |
| 60 |
|
| 61 |
$createdAt = Arr::get($transactionData, 'created_at', DateTime::now()->format('Y-m-d H:i:s')); |
| 62 |
|
| 63 |
// Let's create the order item first |
| 64 |
$variation = $subscriptionModel->variation; |
| 65 |
$product = $subscriptionModel->product; |
| 66 |
|
| 67 |
$parentOrderItem = OrderItem::query() |
| 68 |
->where('order_id', $parentOrder->id) |
| 69 |
->where('payment_type', Status::ORDER_TYPE_SUBSCRIPTION) |
| 70 |
->first(); |
| 71 |
|
| 72 |
$taxTotal = Arr::get($transactionData, 'tax_total', 0); |
| 73 |
if (!$taxTotal && $subscriptionModel->recurring_tax_total) { |
| 74 |
$taxTotal = $subscriptionModel->recurring_tax_total; |
| 75 |
} |
| 76 |
|
| 77 |
// A subscription item may be inclusive even when the parent order is mixed (behavior=3). |
| 78 |
// Check the per-item line_meta to determine the actual inclusion for this item. |
| 79 |
$isItemInclusive = $parentOrder->tax_behavior === 2 |
| 80 |
|| ($parentOrder->tax_behavior === 3 && $parentOrderItem !== null |
| 81 |
&& (bool) Arr::get((array) $parentOrderItem->line_meta, 'tax_config.inclusive', false)); |
| 82 |
|
| 83 |
if (!$taxTotal && $isItemInclusive && $parentOrderItem) { |
| 84 |
$taxTotal = (int) Arr::get($parentOrderItem->other_info, 'recurring_tax', 0); |
| 85 |
} |
| 86 |
|
| 87 |
$subtotal = $transactionData['total']; |
| 88 |
if ($taxTotal) { |
| 89 |
$subtotal = $transactionData['total'] - $taxTotal; |
| 90 |
} |
| 91 |
|
| 92 |
$orderItem = [ |
| 93 |
'post_id' => $subscriptionModel->product_id, |
| 94 |
'object_id' => $subscriptionModel->variation_id, |
| 95 |
'payment_type' => Status::ORDER_TYPE_SUBSCRIPTION, |
| 96 |
'post_title' => $product && $product->post_title ? $product->post_title : $subscriptionModel->item_name, |
| 97 |
'title' => $product && $variation ? $variation->variation_title : '', |
| 98 |
'quantity' => 1, |
| 99 |
'fulfillment_type' => $parentOrderItem ? $parentOrderItem->fulfillment_type : 'digital', |
| 100 |
'unit_price' => $subtotal, |
| 101 |
'subtotal' => $subtotal, |
| 102 |
'tax_amount' => $taxTotal, |
| 103 |
'line_total' => $transactionData['total'], |
| 104 |
'line_meta' => [], |
| 105 |
'other_info' => [] |
| 106 |
]; |
| 107 |
|
| 108 |
$bundleItemIds = Arr::get($parentOrderItem->line_meta, 'bundle_item_ids', []); |
| 109 |
|
| 110 |
$isBundleOrder = false; |
| 111 |
if ($bundleItemIds) { |
| 112 |
$isBundleOrder = true; |
| 113 |
$orderItem['line_meta'] = array_merge( |
| 114 |
$orderItem['line_meta'], |
| 115 |
[ |
| 116 |
'bundle_item_ids' => $bundleItemIds |
| 117 |
] |
| 118 |
); |
| 119 |
} |
| 120 |
|
| 121 |
$fulfillmentType = $orderItem['fulfillment_type']; |
| 122 |
|
| 123 |
// Let's create the order first |
| 124 |
$childOrderData = [ |
| 125 |
'parent_id' => $parentOrder->id, |
| 126 |
'fulfillment_type' => $fulfillmentType, |
| 127 |
'status' => $fulfillmentType === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED, |
| 128 |
'type' => Status::ORDER_TYPE_RENEWAL, |
| 129 |
'mode' => $transactionData['payment_mode'], |
| 130 |
'shipping_status' => $fulfillmentType === 'physical' ? Status::SHIPPING_UNSHIPPED : '', |
| 131 |
'customer_id' => $subscriptionModel->customer_id, |
| 132 |
'payment_method' => $transactionData['payment_method'], |
| 133 |
'payment_status' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? Status::PAYMENT_PAID : Status::PAYMENT_PENDING, |
| 134 |
'currency' => $transactionData['currency'], |
| 135 |
'tax_behavior' => $parentOrder->tax_behavior, |
| 136 |
'subtotal' => $subtotal, |
| 137 |
'tax_total' => $taxTotal, |
| 138 |
'total_amount' => $transactionData['total'], |
| 139 |
'total_paid' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? $transactionData['total'] : 0, |
| 140 |
'completed_at' => DateTime::now()->format('Y-m-d H:i:s'), |
| 141 |
'created_at' => $createdAt, |
| 142 |
'config' => [] |
| 143 |
]; |
| 144 |
|
| 145 |
$childOrder = Order::query()->create($childOrderData); |
| 146 |
|
| 147 |
if (!$childOrder) { |
| 148 |
return new \WP_Error('order_creation_failed', __('Failed to create child order for the subscription renewal.', 'fluent-cart')); |
| 149 |
} |
| 150 |
|
| 151 |
$billingAddress = $parentOrder->billing_address; |
| 152 |
$shippingAddress = $parentOrder->shipping_address; |
| 153 |
|
| 154 |
$customer = $parentOrder->customer; |
| 155 |
|
| 156 |
$fullName = ''; |
| 157 |
$email = ''; |
| 158 |
$firstName = ''; |
| 159 |
$lastName = ''; |
| 160 |
if ($customer) { |
| 161 |
$fullName = $customer->first_name . ' ' . $customer->last_name; |
| 162 |
$email = $customer->email; |
| 163 |
$firstName = $customer->first_name; |
| 164 |
$lastName = $customer->last_name; |
| 165 |
} |
| 166 |
|
| 167 |
$billingAddressData = $billingAddress ? [ |
| 168 |
'type' => 'billing', |
| 169 |
'full_name' => $fullName, |
| 170 |
'address_1' => $billingAddress->address_1, |
| 171 |
'address_2' => $billingAddress->address_2, |
| 172 |
'city' => $billingAddress->city, |
| 173 |
'state' => $billingAddress->state, |
| 174 |
'postcode' => $billingAddress->postcode, |
| 175 |
'country' => $billingAddress->country, |
| 176 |
'email' => $email, |
| 177 |
'first_name' => $firstName, |
| 178 |
'last_name' => $lastName |
| 179 |
] : []; |
| 180 |
|
| 181 |
$shippingAddressData = $shippingAddress ? [ |
| 182 |
'type' => 'shipping', |
| 183 |
'full_name' => $fullName, |
| 184 |
'address_1' => $shippingAddress->address_1, |
| 185 |
'address_2' => $shippingAddress->address_2, |
| 186 |
'city' => $shippingAddress->city, |
| 187 |
'state' => $shippingAddress->state, |
| 188 |
'postcode' => $shippingAddress->postcode, |
| 189 |
'country' => $shippingAddress->country, |
| 190 |
'email' => $email, |
| 191 |
'first_name' => $firstName, |
| 192 |
'last_name' => $lastName |
| 193 |
] : []; |
| 194 |
|
| 195 |
\FluentCart\App\Helpers\AddressHelper::insertOrderAddresses( |
| 196 |
$childOrder->id, |
| 197 |
$billingAddressData, |
| 198 |
$shippingAddressData |
| 199 |
); |
| 200 |
|
| 201 |
// Copy tax ID meta from parent order if exists |
| 202 |
$parentTaxId = $parentOrder->getMeta('tax_id', ''); |
| 203 |
if ($parentTaxId) { |
| 204 |
$childOrder->updateMeta('tax_id', $parentTaxId); |
| 205 |
} |
| 206 |
|
| 207 |
// Copy order tax rates from parent order |
| 208 |
$parentTaxRates = $parentOrder->orderTaxRates; |
| 209 |
foreach ($parentTaxRates as $taxRate) { |
| 210 |
OrderTaxRate::query()->create([ |
| 211 |
'order_id' => $childOrder->id, |
| 212 |
'tax_rate_id' => $taxRate->tax_rate_id, |
| 213 |
'shipping_tax' => $taxRate->shipping_tax, |
| 214 |
'order_tax' => $taxRate->order_tax, |
| 215 |
'total_tax' => $taxRate->total_tax, |
| 216 |
'meta' => $taxRate->meta, |
| 217 |
]); |
| 218 |
} |
| 219 |
|
| 220 |
// Create Order Item |
| 221 |
$orderItem['order_id'] = $childOrder->id; |
| 222 |
$orderItem['created_at'] = $createdAt; |
| 223 |
OrderItem::query()->create($orderItem); |
| 224 |
|
| 225 |
// let's create the transaction |
| 226 |
$transactionData['order_id'] = $childOrder->id; |
| 227 |
|
| 228 |
$createdTransaction = OrderTransaction::query()->create($transactionData); |
| 229 |
|
| 230 |
$subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs); |
| 231 |
|
| 232 |
(new SubscriptionRenewed($subscriptionModel, $childOrder, $parentOrder, $childOrder->customer))->dispatch(); |
| 233 |
|
| 234 |
return $createdTransaction; |
| 235 |
} |
| 236 |
|
| 237 |
/** |
| 238 |
* @param $subscriptionModel |
| 239 |
* @param $subscriptionUpdateArgs |
| 240 |
* - next_billing_date - You must provide this if you want to update the next billing date. |
| 241 |
* * - Accepts all other filliable attributes of the Subscription model. |
| 242 |
* @return mixed |
| 243 |
*/ |
| 244 |
public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = []) |
| 245 |
{ |
| 246 |
$billsCount = OrderTransaction::query() |
| 247 |
->where('subscription_id', $subscriptionModel->id) |
| 248 |
->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE) |
| 249 |
->where('status', Status::TRANSACTION_SUCCEEDED) |
| 250 |
->where('total', '>', 0) |
| 251 |
->count(); |
| 252 |
|
| 253 |
|
| 254 |
$earlyPaymentHistory = $subscriptionModel->getMeta('early_payment_history', []); |
| 255 |
foreach ($earlyPaymentHistory as $earlyPayment) { |
| 256 |
$paidCount = (int) Arr::get($earlyPayment, 'count', 1); |
| 257 |
if ($paidCount > 1) { |
| 258 |
$billsCount += ($paidCount - 1); |
| 259 |
} |
| 260 |
} |
| 261 |
|
| 262 |
$subscriptionUpdateArgs['bill_count'] = $billsCount; |
| 263 |
$billTimes = $subscriptionModel->bill_times; |
| 264 |
$oldStatus = $subscriptionModel->status; |
| 265 |
|
| 266 |
$subscriptionUpdateArgs['bill_count'] = $billsCount; |
| 267 |
$isEot = $billTimes > 0 && $billsCount >= $billTimes; |
| 268 |
|
| 269 |
if ($isEot) { |
| 270 |
$subscriptionUpdateArgs['status'] = 'completed'; |
| 271 |
$subscriptionUpdateArgs['next_billing_date'] = NULL; |
| 272 |
$subscriptionUpdateArgs['canceled_at'] = NULL; |
| 273 |
} else if (!$subscriptionModel->next_billing_date && empty($subscriptionUpdateArgs['next_billing_date'])) { |
| 274 |
$subscriptionUpdateArgs['next_billing_date'] = $subscriptionModel->guessNextBillingDate(); |
| 275 |
} |
| 276 |
|
| 277 |
if (Arr::get($subscriptionUpdateArgs, 'status') === Status::SUBSCRIPTION_ACTIVE) { |
| 278 |
$subscriptionUpdateArgs['recurring_total'] = Arr::get($subscriptionUpdateArgs, 'recurring_total', $subscriptionModel->recurring_total); |
| 279 |
} |
| 280 |
|
| 281 |
$givenSubscriptionStatus = Arr::get($subscriptionUpdateArgs, 'status'); |
| 282 |
if ($givenSubscriptionStatus === Status::SUBSCRIPTION_CANCELED && empty($subscriptionUpdateArgs['canceled_at'])) { |
| 283 |
$subscriptionUpdateArgs['canceled_at'] = gmdate('Y-m-d H:i:s'); |
| 284 |
} |
| 285 |
|
| 286 |
$subscriptionModel->fill($subscriptionUpdateArgs); |
| 287 |
$dirtyData = $subscriptionModel->getDirty(); |
| 288 |
$subscriptionModel->save(); |
| 289 |
|
| 290 |
$meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', [])); |
| 291 |
|
| 292 |
foreach ($meta as $key => $value) { |
| 293 |
$subscriptionModel->updateMeta($key, $value); |
| 294 |
} |
| 295 |
|
| 296 |
// validity_expired_at should only exist when status IS expired |
| 297 |
if ($subscriptionModel->status !== Status::SUBSCRIPTION_EXPIRED) { |
| 298 |
$subscriptionModel->deleteMeta('validity_expired_at'); |
| 299 |
} |
| 300 |
|
| 301 |
if ($oldStatus === $subscriptionModel->status) { |
| 302 |
if ($dirtyData) { |
| 303 |
do_action('fluent_cart/subscription/data_updated', [ |
| 304 |
'subscription' => $subscriptionModel, |
| 305 |
'updated_data' => $dirtyData |
| 306 |
]); |
| 307 |
} |
| 308 |
|
| 309 |
return $subscriptionModel; // No change in status |
| 310 |
} |
| 311 |
|
| 312 |
if ($isEot) { |
| 313 |
(new SubscriptionEOT($subscriptionModel, $subscriptionModel->order))->dispatch(); |
| 314 |
} |
| 315 |
|
| 316 |
do_action('fluent_cart/payments/subscription_status_changed', [ |
| 317 |
'subscription' => $subscriptionModel, |
| 318 |
'order' => $subscriptionModel->order, |
| 319 |
'customer' => $subscriptionModel->customer, |
| 320 |
'old_status' => $oldStatus, |
| 321 |
'new_status' => $subscriptionModel->status |
| 322 |
]); |
| 323 |
|
| 324 |
/** |
| 325 |
* lists of hooks for this action |
| 326 |
* fluent_cart/payments/subscription_canceled |
| 327 |
* fluent_cart/payments/subscription_active |
| 328 |
* fluent_cart/payments/subscription_paused |
| 329 |
* fluent_cart/payments/subscription_expired |
| 330 |
* fluent_cart/payments/subscription_failing |
| 331 |
* fluent_cart/payments/subscription_expiring |
| 332 |
* fluent_cart/payments/subscription_completed |
| 333 |
**/ |
| 334 |
do_action('fluent_cart/payments/subscription_' . $subscriptionModel->status, [ |
| 335 |
'subscription' => $subscriptionModel, |
| 336 |
'order' => $subscriptionModel->order, |
| 337 |
'customer' => $subscriptionModel->customer, |
| 338 |
'old_status' => $oldStatus, |
| 339 |
'new_status' => $subscriptionModel->status |
| 340 |
]); |
| 341 |
|
| 342 |
// note: we needed this event, currently being used in integrations |
| 343 |
if ($subscriptionModel->status === Status::SUBSCRIPTION_EXPIRED) { |
| 344 |
$subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s')); |
| 345 |
(new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch(); |
| 346 |
} |
| 347 |
|
| 348 |
return $subscriptionModel; |
| 349 |
} |
| 350 |
|
| 351 |
|
| 352 |
/** |
| 353 |
* |
| 354 |
* Use this method when you are reactivating a expired subscription manually by creating order, transaction etc. |
| 355 |
* Make sure you already handle your transaction statuses! |
| 356 |
* |
| 357 |
* @param \FluentCart\App\Models\Subscription $subscriptionModel |
| 358 |
* @param \FluentCart\App\Models\OrderTransaction $transaction |
| 359 |
* @param $args |
| 360 |
* @return mixed |
| 361 |
*/ |
| 362 |
public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = []) |
| 363 |
{ |
| 364 |
$renewalOrder = $transaction->order; |
| 365 |
|
| 366 |
$orderUpdateData = [ |
| 367 |
'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED, |
| 368 |
'type' => Status::ORDER_TYPE_RENEWAL, |
| 369 |
'payment_method' => $transaction->payment_method, |
| 370 |
'payment_status' => Status::PAYMENT_PAID, |
| 371 |
'total_paid' => $transaction->total, |
| 372 |
'completed_at' => DateTime::now()->format('Y-m-d H:i:s') |
| 373 |
]; |
| 374 |
|
| 375 |
$renewalOrder->fill($orderUpdateData); |
| 376 |
$renewalOrder->save(); |
| 377 |
|
| 378 |
if ($billingInfo = Arr::get($args, 'billing_info', [])) { |
| 379 |
$subscriptionModel->updateMeta('active_payment_method', $billingInfo); |
| 380 |
} |
| 381 |
|
| 382 |
$updateData = wp_parse_args(Arr::get($args, 'subscription_args', []), [ |
| 383 |
'status' => Status::SUBSCRIPTION_ACTIVE, |
| 384 |
'current_payment_method' => $transaction->payment_method, |
| 385 |
]); |
| 386 |
|
| 387 |
$subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $updateData); |
| 388 |
|
| 389 |
(new StatusHelper($transaction->order))->syncOrderStatuses($transaction); |
| 390 |
|
| 391 |
if ($transaction->total > 0) { |
| 392 |
(new SubscriptionRenewed($subscriptionModel, $renewalOrder, $subscriptionModel->order, $renewalOrder->customer))->dispatch(); |
| 393 |
} |
| 394 |
|
| 395 |
return $subscriptionModel; |
| 396 |
} |
| 397 |
} |
| 398 |
|