| @@ -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 | } |