fix: remaining pricing-critical .days floor calculations → math.ceil

Same bug as device pricing: timedelta.days floors partial days.
Fixed 14 more pricing-critical locations across 7 files:

- traffic addon pricing (bot handler + cabinet + miniapp)
- country addon pricing (bot handler + miniapp)
- generic addon pricing helper (common.py)
- auto-purchase device recomputation
- subscription CRUD pricing helper

Display-only .days usages intentionally left as floor (correct for
showing "X days left" to users).
This commit is contained in:
Fringg
2026-04-29 07:27:55 +03:00
parent cf60ae2967
commit bada41ecd6
7 changed files with 18 additions and 12 deletions
@@ -9,6 +9,7 @@ POST /subscription/traffic/save-cart
from __future__ import annotations
import math
from datetime import UTC, datetime
from typing import Any
@@ -478,7 +479,7 @@ async def save_traffic_cart(
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
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))
prorated_price, _ = _calc_prorated(
base_price_kopeks,
subscription.end_date,
+2 -1
View File
@@ -1,3 +1,4 @@
import math
import secrets
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
@@ -1296,7 +1297,7 @@ async def add_subscription_servers(
if paid_prices is None:
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
paid_prices = []
from app.database.models import ServerSquad
+2 -1
View File
@@ -1,6 +1,7 @@
import asyncio
import base64
import html as html_mod
import math
import re
import time
from datetime import UTC, datetime
@@ -545,7 +546,7 @@ def get_traffic_switch_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:
+2 -1
View File
@@ -1,4 +1,5 @@
import html
import math
from datetime import UTC, datetime
from aiogram import types
@@ -266,7 +267,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
logger.info('🔧 Добавлено: Удалено', added=added, removed=removed)
now = datetime.now(UTC)
days_to_pay = max(1, (subscription.end_date - now).days)
days_to_pay = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_to_pay if days_to_pay > 0 else None
+4 -3
View File
@@ -1,3 +1,4 @@
import math
from datetime import UTC, datetime
from aiogram import types
@@ -807,7 +808,7 @@ async def confirm_switch_traffic(
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
period_hint_days = days_remaining if days_remaining > 0 else None
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
@@ -911,7 +912,7 @@ async def execute_switch_traffic(
base_traffic = current_traffic - purchased_traffic
old_price_per_month = settings.get_traffic_price(base_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'traffic',
@@ -936,7 +937,7 @@ async def execute_switch_traffic(
await callback.answer('⚠️ Ошибка списания средств', show_alert=True)
return
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
await create_transaction(
db=db,
user_id=db_user.id,
@@ -3,6 +3,7 @@
from __future__ import annotations
import html
import math
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -1533,7 +1534,7 @@ async def _auto_add_devices(
# Recompute price fresh under lock (pricing config may have changed since cart was saved)
devices_price_per_month = devices_to_add * tariff_device_price
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(
user,
'devices',
+4 -4
View File
@@ -5755,7 +5755,7 @@ async def update_subscription_servers_endpoint(
subscription.end_date,
)
else:
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))
added_server_ids = [catalog[uuid].get('server_id') for uuid in added if catalog[uuid].get('server_id') is not None]
added_server_prices = [
@@ -5933,7 +5933,7 @@ async def update_subscription_traffic_endpoint(
},
)
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
period_hint_days = days_remaining
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
@@ -6111,7 +6111,7 @@ async def update_subscription_devices_endpoint(
chargeable_diff = new_chargeable - current_chargeable
price_per_month = chargeable_diff * tariff_device_price
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))
period_hint_days = days_remaining
# Lock user BEFORE price computation to prevent TOCTOU on promo discount
@@ -6159,7 +6159,7 @@ async def update_subscription_devices_endpoint(
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_to_charge,
description=f'{description} за {charged_days or max(1, (subscription.end_date - datetime.now(UTC)).days)} дн.',
description=f'{description} за {charged_days or max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))} дн.',
)
if price_to_charge > 0: