fix: device/traffic addon pricing — use ceil instead of floor for days_left

timedelta.days is integer floor: 29 days 23 hours = 29, not 30.
When a user bought extra devices on the same day as their subscription,
they were charged for ~1 day instead of the full remaining period.

Fix: math.ceil(total_seconds / 86400) rounds partial days UP.
Applied to all 11 locations across 4 files:
- app/handlers/subscription/devices.py (5 spots)
- app/cabinet/routes/subscription_modules/devices.py (3 spots)
- app/keyboards/inline.py (3 spots — display pricing)
- app/utils/pricing_utils.py (1 spot — traffic prorated pricing)
This commit is contained in:
Fringg
2026-04-29 07:21:07 +03:00
parent 47c7d45793
commit cf60ae2967
4 changed files with 16 additions and 12 deletions
@@ -13,6 +13,7 @@ POST /subscription/devices/save-cart
from __future__ import annotations
import math
from datetime import UTC, datetime
from typing import Any
@@ -380,7 +381,7 @@ async def purchase_devices(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30 # Base period for device price calculation
# Устройства в пределах тарифного лимита — бесплатные
@@ -658,7 +659,7 @@ async def save_devices_cart(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30
# Устройства в пределах тарифного лимита — бесплатные
@@ -772,7 +773,7 @@ async def get_device_price(
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400))
total_days = 30
# Устройства в пределах тарифного лимита — бесплатные
+6 -5
View File
@@ -1,4 +1,5 @@
import html as html_mod
import math
from datetime import UTC, datetime
from aiogram import types
@@ -343,7 +344,7 @@ async def confirm_change_devices(
# Считаем стоимость по оставшимся дням подписки
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
@@ -572,7 +573,7 @@ async def execute_change_devices(
chargeable_devices = devices_difference
devices_price_per_month = chargeable_devices * price_per_device
days_left = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_left = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'devices',
@@ -601,7 +602,7 @@ async def execute_change_devices(
)
return
charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days)
charged_days = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
await create_transaction(
db=db,
user_id=db_user.id,
@@ -1253,7 +1254,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
if is_daily_tariff:
# Для суточных тарифов считаем по дням (как в кабинете)
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
@@ -1274,7 +1275,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
else:
# Для обычных тарифов - по дням (как в кабинете)
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_left
devices_discount_percent = PricingEngine.get_addon_discount_percent(
+4 -3
View File
@@ -1,3 +1,4 @@
import math
from datetime import UTC, datetime
import structlog
@@ -2169,7 +2170,7 @@ def get_add_traffic_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
@@ -2311,7 +2312,7 @@ def get_change_devices_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
@@ -2473,7 +2474,7 @@ def get_manage_countries_keyboard(
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
now = datetime.now(UTC)
days_left = max(1, (subscription_end_date - now).days)
days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400))
price_multiplier = days_left / 30
logger.info(
'🔍 Расчет для управления странами: осталось дней до',
+2 -1
View File
@@ -1,3 +1,4 @@
import math
from collections.abc import Sequence
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Optional
@@ -26,7 +27,7 @@ def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_
tuple of (total_price_kopeks, days_charged)
"""
now = datetime.now(UTC)
days_remaining = max(1, (end_date - now).days)
days_remaining = max(1, math.ceil((end_date - now).total_seconds() / 86400))
days_to_charge = max(min_charge_days, days_remaining)
total_price = monthly_price * days_to_charge // 30