PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.4.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.4.0
1.6.6 1.6.5 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 All 49 releases
fluent-cart / app / Services / PlanUpgradeService.php

PlanUpgradeService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.4.0, 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\Framework\Support\Arr;
13
14 class PlanUpgradeService
15 {
16
17 static string $metaType = 'variant_upgrade';
18 static string $metaKey = 'variant_upgrade_path';
19
20 public static function getUpgradeSettings($productId, $variantId = null)
21 {
22 if ($variantId) {
23 return Meta::query()
24 ->where('object_id', $variantId)
25 ->where('object_type', static::$metaType)
26 ->where('meta_key', static::$metaKey)
27 ->get();
28 }
29
30 return Meta::query()
31 ->upgradeablePath($productId)
32 ->get();
33
34
35 }
36
37 public static function getAvailableUpgradePaths($variantId, $metaValue)
38 {
39 $metaValue = json_decode($metaValue, true);
40 return Arr::get($metaValue, 'paths' . '.' . $variantId, []);
41 }
42
43
44 public static function saveUpgradeSetting($settings)
45 {
46 $data = [
47 'object_id' => Arr::get($settings, 'from_variant'),
48 'object_type' => static::$metaType,
49 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
50 'meta_key' => static::$metaKey,
51 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
52 'meta_value' => [
53 'to_variants' => Arr::get($settings, 'to_variants'),
54 'is_prorate' => Arr::get($settings, 'is_prorate'),
55 'discount_amount' => Arr::get($settings, 'discount_amount')
56 ],
57 ];
58
59 return Meta::query()->create($data);
60 }
61
62 public static function updateUpgradeSetting($id, $settings)
63 {
64 $data = [
65 'to_variants' => Arr::get($settings, 'to_variants'),
66 'is_prorate' => Arr::get($settings, 'is_prorate'),
67 'discount_amount' => Arr::get($settings, 'discount_amount')
68 ];
69
70 return Meta::query()->where('id', $id)->update([
71 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
72 'meta_value' => json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
73 ]);
74 }
75
76 public static function validateUpgradePaths($paths)
77 {
78 foreach ($paths as $variationId => $path) {
79 if (empty($path)) {
80 continue;
81 }
82
83 $targetVariationIds = array_column($path, 'target_variation_id');
84 $uniqueIds = array_unique($targetVariationIds);
85
86 if (count($targetVariationIds) !== count($uniqueIds)) {
87 $duplicates = array_diff_assoc($targetVariationIds, $uniqueIds);
88 wp_send_json([
89 'message' => sprintf(
90 /* translators: 1: Variation ID */
91 __('Duplicate upgrade path found for variation #%1$s. Each target variation must be unique.', 'fluent-cart'),
92 $variationId
93 ),
94 'duplicates' => $duplicates
95 ], 423);
96 }
97 }
98
99 }
100
101 public static function getUpgardePathsFromVariation($variationId, $orderHash)
102 {
103 if (!$variationId || !$orderHash) {
104 return [];
105 }
106
107 $upgrades = Meta::query()->where('meta_key', 'variant_upgrade_path')
108 ->where('object_type', 'variant_upgrade')
109 ->where('object_id', $variationId)
110 ->get();
111
112 $order = Order::query()->where('uuid', $orderHash)->first();
113
114 if ($order->type === 'subscription') {
115 $lastRenewal = Order::query()
116 ->where('parent_id', $order->id)
117 ->where('type', 'renewal')
118 ->whereIn('payment_status', ['paid', 'partially_refunded'])
119 ->orderBy('id', 'DESC')
120 ->first();
121
122 if ($lastRenewal) {
123 $order = $lastRenewal;
124 }
125 }
126
127 $originalItem = $order->order_items->where('object_id', $variationId)->first();
128 if (!$originalItem) {
129 return [];
130 }
131
132 //TODO: Allow upgrades for bundle items
133 if ($originalItem->payment_type == 'bundle') {
134 return [];
135 }
136
137 $upgradePaths = [];
138 foreach ($upgrades as $upgrade) {
139 $toVariants = Arr::get($upgrade->meta_value, 'to_variants', []);
140 $isProrate = Arr::get($upgrade->meta_value, 'is_prorate', false);
141 $discountAmount = Helper::toCent(Arr::get($upgrade->meta_value, 'discount_amount'));
142
143 foreach ($toVariants as $toVariant) {
144 // calculate prorate credit, discount amount
145 $variant = ProductVariation::query()->find($toVariant);
146 if (!$variant) {
147 continue;
148 }
149
150 $prorateCredit = 0;
151 if ($isProrate) {
152 $prorateCredit = self::calculateUpgradeToDiscount($order, $originalItem);
153 }
154
155 $signupFee = Arr::get($variant->other_info, 'signup_fee', 0);
156 $cost = floatval($variant->item_price + Arr::get($variant->other_info, 'signup_fee', 0) - $prorateCredit - $discountAmount);
157
158 if ($cost < 0) {
159 $cost = 0;
160 }
161
162 $paymentType = Arr::get($variant, 'payment_type');
163 $paymentSummary = Helper::toDecimal($cost) . ' one-time';
164 if ($paymentType === 'subscription') {
165 $paymentSummary = '<strong>' . Helper::toDecimal($cost) . '</strong> ' . __('first', 'fluent-cart') . ' '
166 . Helper::humanIntervalMaps(Arr::get($variant->other_info, 'repeat_interval'))
167 . ', ' . __('then', 'fluent-cart') . ' ' . Helper::toDecimal($variant->item_price)
168 . '/' . Helper::humanIntervalMaps(Arr::get($variant->other_info, 'repeat_interval')) . ' ' . __('thereafter', 'fluent-cart') . '.';
169 }
170
171 $upgradePaths[] = [
172 'title' => $variant->variation_title,
173 'to_variant' => $toVariant,
174 'discount_amount' => (float)$discountAmount,
175 'prorate_credit' => (float)$prorateCredit,
176 'signup_fee' => (float)$signupFee,
177 'signup_fee_name' => Arr::get($variant->other_info, 'signup_fee_name', ''),
178 'payment_summary' => $paymentSummary,
179 'price' => (float)$variant->item_price,
180 'original_price' => floatval($variant->item_price + $signupFee),
181 'cost' => $cost,
182 'currency' => $order->currency,
183 'upgrade_url' => add_query_arg([
184 'fluent-cart' => 'upgrade_plan',
185 'order_hash' => $orderHash,
186 'path_id' => $upgrade->id,
187 'target_id' => $toVariant,
188 ], home_url())
189 ];
190 }
191 }
192
193
194 return $upgradePaths;
195 }
196
197
198
199 public static function calculateUpgradeToDiscount(Order $order, OrderItem $originalItem)
200 {
201 // The prorate credit reflects what the customer actually PAID for the plan they
202 // are leaving — tax included — so it isn't undercredited by the tax they paid.
203 // For tax-exclusive lines the tax was added on top (add tax_amount); for inclusive
204 // lines it is already baked into line_total. Then prorated by days remaining below.
205 $totalPaid = self::itemUnrefundedPaidWithTax($originalItem);
206
207 $additionalItemIds = Arr::get($originalItem->line_meta, 'additional_item_ids', []);
208 if (!empty($additionalItemIds)) {
209 $additionalItems = OrderItem::query()
210 ->whereIn('id', $additionalItemIds)
211 ->get();
212
213 foreach ($additionalItems as $item) {
214 if (Arr::get($item->line_meta, 'parent_item_id', '') == $originalItem->id) {
215 $totalPaid += self::itemUnrefundedPaidWithTax($item);
216 }
217 }
218 }
219
220 // Chained upgrade: post-tax adjustments don't reduce line totals, so on an order
221 // that itself came from an upgrade, line_total overstates what was paid by the
222 // gifted upgrade discount — remove it from the credit base. The previous prorate
223 // credit stays: it is money the customer actually paid on earlier plans.
224 $totalPaid -= (int) Arr::get($order->config, 'upgrade_discount', 0);
225
226 if ($originalItem->payment_type === 'onetime') {
227 return $totalPaid < 0 ? 0 : $totalPaid;
228 }
229
230
231 $parentOrderId = $order->id;
232
233 if ($order->type === 'renewal') {
234 $parentOrderId = $order->parent_id;
235 }
236
237 $subscription = Subscription::query()
238 ->where('parent_order_id', $parentOrderId)
239 ->first();
240
241 if (!$subscription || !$subscription->hasAccessValidity() || ($subscription->status == Status::SUBSCRIPTION_TRIALING && Arr::get($subscription->config, 'is_trial_days_simulated', 'no') != 'yes')) {
242 return 0;
243 }
244
245 $daysRemaining = ceil((strtotime($subscription->next_billing_date) - time()) / 86400); // convert seconds to days
246
247 $maps = [
248 'monthly' => (int) gmdate('t'), // Get exact days in current month (handles leap year) to avoid false remaining days calculation
249 'yearly' => 365,
250 'weekly' => 7,
251 'daily' => 1,
252 ];
253
254 if (!isset($maps[$subscription->billing_interval])) {
255 return 0; // Invalid repeat interval
256 }
257
258 $divider = $maps[$subscription->billing_interval];
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