PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.0
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 trunk All 48 releases
fluent-cart / app / Services / PlanUpgradeService.php

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

300 lines 11.4 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 if (empty($signupFee)) {
157 $signupFee = 0;
158 }
159
160 $cost = floatval($variant->item_price + $signupFee - $prorateCredit - $discountAmount);
161
162 if ($cost < 0) {
163 $cost = 0;
164 }
165
166 $paymentType = Arr::get($variant, 'payment_type');
167 $paymentSummary = Helper::toDecimal($cost) . ' one-time';
168 if ($paymentType === 'subscription') {
169 $paymentSummary = '<strong>' . Helper::toDecimal($cost) . '</strong> ' . __('first', 'fluent-cart') . ' '
170 . Helper::humanIntervalMaps(Arr::get($variant->other_info, 'repeat_interval'))
171 . ', ' . __('then', 'fluent-cart') . ' ' . Helper::toDecimal($variant->item_price)
172 . '/' . Helper::humanIntervalMaps(Arr::get($variant->other_info, 'repeat_interval')) . ' ' . __('thereafter', 'fluent-cart') . '.';
173 }
174
175 $upgradePaths[] = [
176 'title' => $variant->variation_title,
177 'to_variant' => $toVariant,
178 'discount_amount' => (float)$discountAmount,
179 'prorate_credit' => (float)$prorateCredit,
180 'signup_fee' => (float)$signupFee,
181 'signup_fee_name' => Arr::get($variant->other_info, 'signup_fee_name', ''),
182 'payment_summary' => $paymentSummary,
183 'price' => (float)$variant->item_price,
184 'original_price' => floatval($variant->item_price + $signupFee),
185 'cost' => $cost,
186 'currency' => $order->currency,
187 'upgrade_url' => add_query_arg([
188 'fluent-cart' => 'upgrade_plan',
189 'order_hash' => $orderHash,
190 'path_id' => $upgrade->id,
191 'target_id' => $toVariant,
192 ], home_url())
193 ];
194 }
195 }
196
197
198 return $upgradePaths;
199 }
200
201
202
203 public static function calculateUpgradeToDiscount(Order $order, OrderItem $originalItem)
204 {
205 // The prorate credit reflects what the customer actually PAID for the plan they
206 // are leaving — tax included — so it isn't undercredited by the tax they paid.
207 // For tax-exclusive lines the tax was added on top (add tax_amount); for inclusive
208 // lines it is already baked into line_total. Then prorated by days remaining below.
209 $totalPaid = self::itemUnrefundedPaidWithTax($originalItem);
210
211 $additionalItemIds = Arr::get($originalItem->line_meta, 'additional_item_ids', []);
212 if (!empty($additionalItemIds)) {
213 $additionalItems = OrderItem::query()
214 ->whereIn('id', $additionalItemIds)
215 ->get();
216
217 foreach ($additionalItems as $item) {
218 if (Arr::get($item->line_meta, 'parent_item_id', '') == $originalItem->id) {
219 $totalPaid += self::itemUnrefundedPaidWithTax($item);
220 }
221 }
222 }
223
224 // Chained upgrade: post-tax adjustments don't reduce line totals, so on an order
225 // that itself came from an upgrade, line_total overstates what was paid by the
226 // gifted upgrade discount — remove it from the credit base. The previous prorate
227 // credit stays: it is money the customer actually paid on earlier plans.
228 $totalPaid -= (int) Arr::get($order->config, 'upgrade_discount', 0);
229
230 if ($originalItem->payment_type === 'onetime') {
231 return $totalPaid < 0 ? 0 : $totalPaid;
232 }
233
234
235 $parentOrderId = $order->id;
236
237 if ($order->type === 'renewal') {
238 $parentOrderId = $order->parent_id;
239 }
240
241 $subscription = Subscription::query()
242 ->where('parent_order_id', $parentOrderId)
243 ->first();
244
245 if (!$subscription || !$subscription->hasAccessValidity() || ($subscription->status == Status::SUBSCRIPTION_TRIALING && Arr::get($subscription->config, 'is_trial_days_simulated', 'no') != 'yes')) {
246 return 0;
247 }
248
249 $daysRemaining = ceil((strtotime($subscription->next_billing_date) - time()) / 86400); // convert seconds to days
250
251 $maps = [
252 'monthly' => (int) gmdate('t'), // Get exact days in current month (handles leap year) to avoid false remaining days calculation
253 'yearly' => 365,
254 'weekly' => 7,
255 'daily' => 1,
256 ];
257
258 if (!isset($maps[$subscription->billing_interval])) {
259 return 0; // Invalid repeat interval
260 }
261
262 $divider = $maps[$subscription->billing_interval];
263
264 if ($daysRemaining > $divider) { // making sure we are not giving discount more than the actual amount
265 $daysRemaining = $divider;
266 }
267
268 // Multiply before dividing and round (not truncate) so float imprecision can't
269 // shave a cent — e.g. 11900/365*365 = 11899.9999998 would truncate to 11899.
270 $discountAmount = (int) round($totalPaid * $daysRemaining / $divider);
271
272 return $discountAmount < 0 ? 0 : $discountAmount;
273 }
274
275 /**
276 * Unrefunded amount the customer actually paid for an order item, tax included (cents).
277 *
278 * Exclusive tax was added on top of line_total, so it is added back here. Inclusive
279 * tax is already part of line_total and must not be double-counted.
280 *
281 * Refunds are allocated against line_total (see Order::updateRefundedItems), so the
282 * exclusive tax is scaled to the unrefunded fraction of the line — otherwise a
283 * refunded line would still contribute its tax_amount to the upgrade credit.
284 */
285 private static function itemUnrefundedPaidWithTax(OrderItem $item)
286 {
287 $lineTotal = (int) $item->line_total;
288 $unrefunded = max(0, $lineTotal - (int) $item->refund_total);
289 $inclusive = (bool) Arr::get($item->line_meta, 'tax_config.inclusive', false);
290
291 if ($inclusive || $lineTotal <= 0) {
292 return $unrefunded;
293 }
294
295 return $unrefunded + (int) round((int) $item->tax_amount * $unrefunded / $lineTotal);
296 }
297
298
299 }
300