PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Services / PlanUpgradeService.php

PlanUpgradeService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Services/PlanUpgradeService.php

296 lines 11.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Models\Meta;
8 use FluentCart\App\Models\Order;
9 use FluentCart\App\Models\OrderItem;
10 use FluentCart\App\Models\ProductVariation;
11 use FluentCart\App\Models\Subscription;
12 use FluentCart\App\Services\Payments\PaymentHelper;
13 use FluentCart\Framework\Support\Arr;
14
15 class PlanUpgradeService
16 {
17
18 static string $metaType = 'variant_upgrade';
19 static string $metaKey = 'variant_upgrade_path';
20
21 public static function getUpgradeSettings($productId, $variantId = null)
22 {
23 if ($variantId) {
24 return Meta::query()
25 ->where('object_id', $variantId)
26 ->where('object_type', static::$metaType)
27 ->where('meta_key', static::$metaKey)
28 ->get();
29 }
30
31 return Meta::query()
32 ->upgradeablePath($productId)
33 ->get();
34
35
36 }
37
38 public static function getAvailableUpgradePaths($variantId, $metaValue)
39 {
40 $metaValue = json_decode($metaValue, true);
41 return Arr::get($metaValue, 'paths' . '.' . $variantId, []);
42 }
43
44
45 public static function saveUpgradeSetting($settings)
46 {
47 $data = [
48 'object_id' => Arr::get($settings, 'from_variant'),
49 'object_type' => static::$metaType,
50 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
51 'meta_key' => static::$metaKey,
52 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
53 'meta_value' => [
54 'to_variants' => Arr::get($settings, 'to_variants'),
55 'is_prorate' => Arr::get($settings, 'is_prorate'),
56 'discount_amount' => Arr::get($settings, 'discount_amount')
57 ],
58 ];
59
60 return Meta::query()->create($data);
61 }
62
63 public static function updateUpgradeSetting($id, $settings)
64 {
65 $data = [
66 'to_variants' => Arr::get($settings, 'to_variants'),
67 'is_prorate' => Arr::get($settings, 'is_prorate'),
68 'discount_amount' => Arr::get($settings, 'discount_amount')
69 ];
70
71 return Meta::query()->where('id', $id)->update([
72 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
73 'meta_value' => json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
74 ]);
75 }
76
77 public static function validateUpgradePaths($paths)
78 {
79 foreach ($paths as $variationId => $path) {
80 if (empty($path)) {
81 continue;
82 }
83
84 $targetVariationIds = array_column($path, 'target_variation_id');
85 $uniqueIds = array_unique($targetVariationIds);
86
87 if (count($targetVariationIds) !== count($uniqueIds)) {
88 $duplicates = array_diff_assoc($targetVariationIds, $uniqueIds);
89 wp_send_json([
90 'message' => sprintf(
91 /* translators: 1: Variation ID */
92 __('Duplicate upgrade path found for variation #%1$s. Each target variation must be unique.', 'fluent-cart'),
93 $variationId
94 ),
95 'duplicates' => $duplicates
96 ], 423);
97 }
98 }
99
100 }
101
102 public static function getUpgardePathsFromVariation($variationId, $orderHash)
103 {
104 if (!$variationId || !$orderHash) {
105 return [];
106 }
107
108 $upgrades = Meta::query()->where('meta_key', 'variant_upgrade_path')
109 ->where('object_type', 'variant_upgrade')
110 ->where('object_id', $variationId)
111 ->get();
112
113 $order = Order::query()->where('uuid', $orderHash)->first();
114
115 if ($order->type === 'subscription') {
116 $lastRenewal = Order::query()
117 ->where('parent_id', $order->id)
118 ->where('type', 'renewal')
119 ->whereIn('payment_status', ['paid', 'partially_refunded'])
120 ->orderBy('id', 'DESC')
121 ->first();
122
123 if ($lastRenewal) {
124 $order = $lastRenewal;
125 }
126 }
127
128 $originalItem = $order->order_items->where('object_id', $variationId)->first();
129 if (!$originalItem) {
130 return [];
131 }
132
133 //TODO: Allow upgrades for bundle items
134 if ($originalItem->payment_type == 'bundle') {
135 return [];
136 }
137
138 $upgradePaths = [];
139 foreach ($upgrades as $upgrade) {
140 $toVariants = Arr::get($upgrade->meta_value, 'to_variants', []);
141 $isProrate = Arr::get($upgrade->meta_value, 'is_prorate', false);
142 $discountAmount = Helper::toCent(Arr::get($upgrade->meta_value, 'discount_amount'));
143
144 foreach ($toVariants as $toVariant) {
145 // calculate prorate credit, discount amount
146 $variant = ProductVariation::query()->find($toVariant);
147 if (!$variant) {
148 continue;
149 }
150
151 $prorateCredit = 0;
152 if ($isProrate) {
153 $prorateCredit = self::calculateUpgradeToDiscount($order, $originalItem);
154 }
155
156 $signupFee = Arr::get($variant->other_info, 'signup_fee', 0);
157 if (empty($signupFee)) {
158 $signupFee = 0;
159 }
160
161 $cost = floatval($variant->item_price + $signupFee - $prorateCredit - $discountAmount);
162
163 if ($cost < 0) {
164 $cost = 0;
165 }
166
167 $paymentType = Arr::get($variant, 'payment_type');
168 $paymentSummary = Helper::toDecimal($cost) . ' one-time';
169 if ($paymentType === 'subscription') {
170 $paymentSummary = '<strong>' . Helper::toDecimal($cost) . '</strong> ' . __('first', 'fluent-cart') . ' '
171 . Helper::humanIntervalMaps(Arr::get($variant->other_info, 'repeat_interval'))
172 . ', ' . __('then', 'fluent-cart') . ' ' . Helper::toDecimal($variant->item_price)
173 . '/' . Helper::humanIntervalMaps(Arr::get($variant->other_info, 'repeat_interval')) . ' ' . __('thereafter', 'fluent-cart') . '.';
174 }
175
176 $upgradePaths[] = [
177 'title' => $variant->variation_title,
178 'to_variant' => $toVariant,
179 'discount_amount' => (float)$discountAmount,
180 'prorate_credit' => (float)$prorateCredit,
181 'signup_fee' => (float)$signupFee,
182 'signup_fee_name' => Arr::get($variant->other_info, 'signup_fee_name', ''),
183 'payment_summary' => $paymentSummary,
184 'price' => (float)$variant->item_price,
185 'original_price' => floatval($variant->item_price + $signupFee),
186 'cost' => $cost,
187 'currency' => $order->currency,
188 'upgrade_url' => add_query_arg([
189 'fluent-cart' => 'upgrade_plan',
190 'order_hash' => $orderHash,
191 'path_id' => $upgrade->id,
192 'target_id' => $toVariant,
193 ], home_url())
194 ];
195 }
196 }
197
198
199 return $upgradePaths;
200 }
201
202
203
204 public static function calculateUpgradeToDiscount(Order $order, OrderItem $originalItem)
205 {
206 // The prorate credit reflects what the customer actually PAID for the plan they
207 // are leaving — tax included — so it isn't undercredited by the tax they paid.
208 // For tax-exclusive lines the tax was added on top (add tax_amount); for inclusive
209 // lines it is already baked into line_total. Then prorated by days remaining below.
210 $totalPaid = self::itemUnrefundedPaidWithTax($originalItem);
211
212 $additionalItemIds = Arr::get($originalItem->line_meta, 'additional_item_ids', []);
213 if (!empty($additionalItemIds)) {
214 $additionalItems = OrderItem::query()
215 ->whereIn('id', $additionalItemIds)
216 ->get();
217
218 foreach ($additionalItems as $item) {
219 if (Arr::get($item->line_meta, 'parent_item_id', '') == $originalItem->id) {
220 $totalPaid += self::itemUnrefundedPaidWithTax($item);
221 }
222 }
223 }
224
225 // Chained upgrade: post-tax adjustments don't reduce line totals, so on an order
226 // that itself came from an upgrade, line_total overstates what was paid by the
227 // gifted upgrade discount — remove it from the credit base. The previous prorate
228 // credit stays: it is money the customer actually paid on earlier plans.
229 $totalPaid -= (int) Arr::get($order->config, 'upgrade_discount', 0);
230
231 if ($originalItem->payment_type === 'onetime') {
232 return $totalPaid < 0 ? 0 : $totalPaid;
233 }
234
235
236 $parentOrderId = $order->id;
237
238 if ($order->type === 'renewal') {
239 $parentOrderId = $order->parent_id;
240 }
241
242 $subscription = Subscription::query()
243 ->where('parent_order_id', $parentOrderId)
244 ->first();
245
246 if (!$subscription || !$subscription->hasAccessValidity() || ($subscription->status == Status::SUBSCRIPTION_TRIALING && Arr::get($subscription->config, 'is_trial_days_simulated', 'no') != 'yes')) {
247 return 0;
248 }
249
250 $daysRemaining = ceil((strtotime($subscription->next_billing_date) - time()) / 86400); // convert seconds to days
251
252 $divider = PaymentHelper::getIntervalDays($subscription->billing_interval);
253
254 // 0 = interval neither core nor filter-resolved; no credit rather than the
255 // full paid amount the days-remaining cap would otherwise allow.
256 if ($divider < 1) {
257 return 0;
258 }
259
260 if ($daysRemaining > $divider) { // making sure we are not giving discount more than the actual amount
261 $daysRemaining = $divider;
262 }
263
264 // Multiply before dividing and round (not truncate) so float imprecision can't
265 // shave a cent — e.g. 11900/365*365 = 11899.9999998 would truncate to 11899.
266 $discountAmount = (int) round($totalPaid * $daysRemaining / $divider);
267
268 return $discountAmount < 0 ? 0 : $discountAmount;
269 }
270
271 /**
272 * Unrefunded amount the customer actually paid for an order item, tax included (cents).
273 *
274 * Exclusive tax was added on top of line_total, so it is added back here. Inclusive
275 * tax is already part of line_total and must not be double-counted.
276 *
277 * Refunds are allocated against line_total (see Order::updateRefundedItems), so the
278 * exclusive tax is scaled to the unrefunded fraction of the line — otherwise a
279 * refunded line would still contribute its tax_amount to the upgrade credit.
280 */
281 private static function itemUnrefundedPaidWithTax(OrderItem $item)
282 {
283 $lineTotal = (int) $item->line_total;
284 $unrefunded = max(0, $lineTotal - (int) $item->refund_total);
285 $inclusive = (bool) Arr::get($item->line_meta, 'tax_config.inclusive', false);
286
287 if ($inclusive || $lineTotal <= 0) {
288 return $unrefunded;
289 }
290
291 return $unrefunded + (int) round((int) $item->tax_amount * $unrefunded / $lineTotal);
292 }
293
294
295 }
296