PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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
← All changes | app/Services/PlanUpgradeService.php +52 -18 1.3.19 → 1.6.5 View file →
@@ -8,8 +8,9 @@
8 8 use FluentCart\App\Models\Order;
9 9 use FluentCart\App\Models\OrderItem;
10 10 use FluentCart\App\Models\ProductVariation;
11 11 use FluentCart\App\Models\Subscription;
12 +use FluentCart\App\Services\Payments\PaymentHelper;
12 13 use FluentCart\Framework\Support\Arr;
13 14
14 15 class PlanUpgradeService
15 16 {
@@ -152,9 +153,13 @@
152 153 $prorateCredit = self::calculateUpgradeToDiscount($order, $originalItem);
153 154 }
154 155
155 156 $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 + if (empty($signupFee)) {
158 + $signupFee = 0;
159 + }
160 +
161 + $cost = floatval($variant->item_price + $signupFee - $prorateCredit - $discountAmount);
157 162
158 163 if ($cost < 0) {
159 164 $cost = 0;
160 165 }
@@ -161,12 +166,12 @@
161 166
162 167 $paymentType = Arr::get($variant, 'payment_type');
163 168 $paymentSummary = Helper::toDecimal($cost) . ' one-time';
164 169 if ($paymentType === 'subscription') {
165 - $paymentSummary = '<strong>' . Helper::toDecimal($cost) . '</strong> first '
170 + $paymentSummary = '<strong>' . Helper::toDecimal($cost) . '</strong> ' . __('first', 'fluent-cart') . ' '
166 171 . Helper::humanIntervalMaps(Arr::get($variant->other_info, 'repeat_interval'))
167 - . ', then ' . Helper::toDecimal($variant->item_price)
168 - . '/' . Helper::humanIntervalMaps(Arr::get($variant->other_info, 'repeat_interval')) . ' thereafter.';
172 + . ', ' . __('then', 'fluent-cart') . ' ' . Helper::toDecimal($variant->item_price)
173 + . '/' . Helper::humanIntervalMaps(Arr::get($variant->other_info, 'repeat_interval')) . ' ' . __('thereafter', 'fluent-cart') . '.';
169 174 }
170 175
171 176 $upgradePaths[] = [
172 177 'title' => $variant->variation_title,
@@ -197,10 +202,13 @@
197 202
198 203
199 204 public static function calculateUpgradeToDiscount(Order $order, OrderItem $originalItem)
200 205 {
201 - // check for signup fee item
202 - $totalPaid = $originalItem->line_total - $originalItem->refund_total;
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);
203 211
204 212 $additionalItemIds = Arr::get($originalItem->line_meta, 'additional_item_ids', []);
205 213 if (!empty($additionalItemIds)) {
206 214 $additionalItems = OrderItem::query()
@@ -208,13 +216,19 @@
208 216 ->get();
209 217
210 218 foreach ($additionalItems as $item) {
211 219 if (Arr::get($item->line_meta, 'parent_item_id', '') == $originalItem->id) {
212 - $totalPaid += $item->line_total - $item->refund_total;
220 + $totalPaid += self::itemUnrefundedPaidWithTax($item);
213 221 }
214 222 }
215 223 }
216 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 +
217 231 if ($originalItem->payment_type === 'onetime') {
218 232 return $totalPaid < 0 ? 0 : $totalPaid;
219 233 }
220 234
@@ -234,28 +248,48 @@
234 248 }
235 249
236 250 $daysRemaining = ceil((strtotime($subscription->next_billing_date) - time()) / 86400); // convert seconds to days
237 251
238 - $maps = [
239 - 'monthly' => (int) gmdate('t'), // Get exact days in current month (handles leap year) to avoid false remaining days calculation
240 - 'yearly' => 365,
241 - 'weekly' => 7,
242 - 'daily' => 1,
243 - ];
252 + $divider = PaymentHelper::getIntervalDays($subscription->billing_interval);
244 253
245 - if (!isset($maps[$subscription->billing_interval])) {
246 - return 0; // Invalid repeat interval
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;
247 258 }
248 259
249 - $divider = $maps[$subscription->billing_interval];
250 -
251 260 if ($daysRemaining > $divider) { // making sure we are not giving discount more than the actual amount
252 261 $daysRemaining = $divider;
253 262 }
254 263
255 - $discountAmount = intval($totalPaid / $divider * $daysRemaining);
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);
256 267
257 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);
258 292 }
259 293
260 294
261 295 }