Skip to content

Commit 4c52268

Browse files
author
Derssen
committed
feat: add Make integration with burn rate analysis and auto-renewal tracking
1 parent 9f24915 commit 4c52268

File tree

8 files changed

+363
-367
lines changed

8 files changed

+363
-367
lines changed

config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ class Config:
2020
WAZZUP_TOKEN: str = os.getenv("WAZZUP_TOKEN")
2121
DIDWW_KEY: str = os.getenv("DIDWW_KEY")
2222

23+
# NEW: Make Credentials
24+
MAKE_API_KEY: str = os.getenv("MAKE_API_KEY")
25+
MAKE_ORG_ID: str = os.getenv("MAKE_ORG_ID")
26+
2327
# Financial Constants
2428
LOW_BALANCE_THRESHOLD: float = 10.0
2529
MIN_TOP_UP_AMOUNT: float = 5.0
@@ -42,22 +46,24 @@ class Config:
4246
'DIDWW': 'USD',
4347
'Streamtele': 'UAH',
4448
'Callii': 'USD',
49+
'Make': 'Ops', # Operations
4550
}
4651

4752
CURRENCY_SIGNS: dict = {
4853
'USD': '$',
4954
'UAH': '₴',
5055
'RUB': '₽',
56+
'Ops': '⚡', # Icon for operations
5157
}
5258

5359
# API Service Toggle
5460
API_SERVICE_STATUSES: dict = {
5561
'Zadarma': os.getenv("ZADARMA_ENABLED", "True").lower() in ('true', '1', 't'),
5662
'DIDWW': os.getenv("DIDWW_ENABLED", "True").lower() in ('true', '1', 't'),
63+
'Make': os.getenv("MAKE_ENABLED", "True").lower() in ('true', '1', 't'),
5764
}
5865

5966
SETTINGS = Config()
6067

61-
# Validation
6268
if not SETTINGS.BOT_TOKEN or SETTINGS.TARGET_CHAT_ID == -1:
6369
raise ValueError("Critical configuration missing: BOT_TOKEN or TARGET_CHAT_ID.")

db/models.py

Lines changed: 35 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,68 @@
1-
from datetime import datetime
2-
import pytz
31
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, text
4-
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, AsyncEngine
2+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
53
from sqlalchemy.orm import sessionmaker, declarative_base
64
from sqlalchemy.future import select
7-
5+
from datetime import datetime
6+
import pytz
87
from config import SETTINGS
98

10-
# Define Timezone
11-
TIMEZONE = pytz.timezone('Asia/Makassar')
9+
TIMEZONE = pytz.timezone('Asia/Makassar')
1210

1311
Base = declarative_base()
1412

1513
class Service(Base):
16-
"""Database model representing a tracked service."""
1714
__tablename__ = 'services'
18-
1915
id = Column(Integer, primary_key=True)
2016
name = Column(String, unique=True, nullable=False)
21-
22-
# Balance Monitoring
2317
last_balance = Column(Float, default=0.0)
2418
low_balance_alert_sent = Column(Boolean, default=False)
25-
26-
# Financial Configuration
27-
currency = Column(String, default="USD") # USD, RUB, UAH
28-
daily_cost = Column(Float, nullable=True) # Estimated daily cost
29-
monthly_fee = Column(Float, nullable=True) # Fixed monthly fee
30-
31-
# Alert Schedule
19+
currency = Column(String, default="USD")
20+
daily_cost = Column(Float, nullable=True)
21+
monthly_fee = Column(Float, nullable=True)
3222
next_alert_date = Column(DateTime, nullable=True)
3323
next_monthly_alert = Column(DateTime, nullable=True)
24+
last_alert_at = Column(DateTime, nullable=True)
3425

3526
def __repr__(self):
3627
return f"<Service(name='{self.name}', balance={self.last_balance})>"
3728

38-
async def init_db(database_url: str) -> sessionmaker:
39-
"""Initialize the database engine and session factory."""
29+
async def init_db(database_url: str):
4030
engine = create_async_engine(database_url, echo=False)
4131
async with engine.begin() as conn:
4232
await conn.run_sync(Base.metadata.create_all)
43-
44-
async_session = sessionmaker(
45-
autocommit=False,
46-
autoflush=False,
47-
bind=engine,
48-
class_=AsyncSession,
49-
expire_on_commit=False,
50-
)
51-
return async_session
52-
53-
async def initialize_services(session_factory) -> None:
54-
"""
55-
Populate the database with default services and perform schema migrations if necessary.
56-
"""
57-
async with session_factory() as session:
58-
# 1. Simple schema migration (add columns if missing)
59-
pragma_stmt = text("PRAGMA table_info(services)")
60-
result = await session.execute(pragma_stmt)
61-
existing_columns = {row[1] for row in result.fetchall()}
33+
return sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
6234

35+
async def initialize_services(SessionLocal):
36+
async with SessionLocal() as session:
37+
# Schema migration
38+
result = await session.execute(text("PRAGMA table_info(services)"))
39+
columns = {row[1] for row in result.fetchall()}
6340
alter_statements = []
64-
required_columns = ['currency', 'daily_cost', 'monthly_fee', 'next_monthly_alert']
6541

66-
for col in required_columns:
67-
if col not in existing_columns:
68-
col_type = 'DATETIME' if 'next' in col else ('FLOAT' if 'cost' in col or 'fee' in col else 'VARCHAR')
69-
alter_statements.append(f"ALTER TABLE services ADD COLUMN {col} {col_type}")
70-
71-
for stmt in alter_statements:
72-
try:
73-
await session.execute(text(stmt))
74-
except Exception:
75-
pass # Ignore errors if column exists
42+
for col in ['currency', 'daily_cost', 'monthly_fee', 'next_monthly_alert', 'last_alert_at']:
43+
if col not in columns:
44+
ctype = 'DATETIME' if 'alert' in col else ('FLOAT' if 'cost' in col or 'fee' in col else 'VARCHAR')
45+
alter_statements.append(f"ALTER TABLE services ADD COLUMN {col} {ctype}")
7646

77-
if alter_statements:
78-
await session.commit()
47+
for stmt in alter_statements:
48+
try: await session.execute(text(stmt))
49+
except Exception: pass
50+
if alter_statements: await session.commit()
7951

80-
# 2. Seed default data
81-
services_to_seed = [
82-
{
83-
'name': 'Zadarma',
84-
'last_balance': 0.0,
85-
'currency': SETTINGS.SERVICE_CURRENCIES.get('Zadarma', 'USD'),
86-
},
87-
{
88-
'name': 'Wazzup24 Подписка',
89-
'last_balance': 0.0,
90-
'currency': SETTINGS.SERVICE_CURRENCIES.get('Wazzup24 Подписка', 'RUB'),
91-
'monthly_fee': SETTINGS.WAZZUP_MONTHLY_FEE,
92-
'next_monthly_alert': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0)),
93-
},
94-
{
95-
'name': 'Wazzup24 Баланс номера',
96-
'last_balance': 0.0,
97-
'currency': SETTINGS.SERVICE_CURRENCIES.get('Wazzup24 Баланс номера', 'RUB'),
98-
'daily_cost': SETTINGS.WAZZUP_DAILY_COST,
99-
'next_alert_date': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0)),
100-
},
101-
{
102-
'name': 'DIDWW',
103-
'last_balance': 0.0,
104-
'currency': SETTINGS.SERVICE_CURRENCIES.get('DIDWW', 'USD'),
105-
'monthly_fee': SETTINGS.DIDWW_MONTHLY_FEE,
106-
'next_monthly_alert': TIMEZONE.localize(datetime(2025, 12, 20, 10, 0)),
107-
},
108-
{
109-
'name': 'Callii',
110-
'next_alert_date': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0)),
111-
'currency': SETTINGS.SERVICE_CURRENCIES.get('Callii', 'USD'),
112-
'daily_cost': SETTINGS.CALLII_DAILY_COST,
113-
},
114-
{
115-
'name': 'Streamtele',
116-
'currency': SETTINGS.SERVICE_CURRENCIES.get('Streamtele', 'UAH'),
117-
'monthly_fee': SETTINGS.STREAMTELE_MONTHLY_FEE,
118-
'next_monthly_alert': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0)),
119-
},
52+
# Seed Data
53+
services_to_add = [
54+
{'name': 'Zadarma', 'last_balance': 0.0, 'currency': 'USD'},
55+
{'name': 'DIDWW', 'last_balance': 0.0, 'currency': 'USD', 'monthly_fee': SETTINGS.DIDWW_MONTHLY_FEE, 'next_monthly_alert': TIMEZONE.localize(datetime(2025, 12, 20, 10, 0))},
56+
{'name': 'Make', 'last_balance': 0.0, 'currency': 'Ops'}, # NEW
57+
{'name': 'Wazzup24 Подписка', 'last_balance': 0.0, 'currency': 'RUB', 'monthly_fee': SETTINGS.WAZZUP_MONTHLY_FEE, 'next_monthly_alert': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0))},
58+
{'name': 'Wazzup24 Баланс номера', 'last_balance': 0.0, 'currency': 'RUB', 'daily_cost': SETTINGS.WAZZUP_DAILY_COST, 'next_alert_date': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0))},
59+
{'name': 'Callii', 'next_alert_date': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0)), 'currency': 'USD', 'daily_cost': SETTINGS.CALLII_DAILY_COST},
60+
{'name': 'Streamtele', 'currency': 'UAH', 'monthly_fee': SETTINGS.STREAMTELE_MONTHLY_FEE, 'next_monthly_alert': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0))},
12061
]
12162

122-
for data in services_to_seed:
123-
stmt = select(Service).filter(Service.name == data['name'])
124-
result = await session.execute(stmt)
125-
exists = result.scalar_one_or_none()
126-
63+
for data in services_to_add:
64+
stmt = select(Service).where(Service.name == data['name'])
65+
exists = (await session.execute(stmt)).scalar_one_or_none()
12766
if not exists:
12867
session.add(Service(name=data['name'], **{k: v for k, v in data.items() if k != 'name'}))
12968

handlers/balance.py

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414
'USD': '$',
1515
'EUR': '€',
1616
'RUB': '₽',
17-
'UAH': '₴'
17+
'UAH': '₴',
18+
'Ops': '⚡',
1819
}
1920

20-
# Display order configuration
21+
# Added 'Make' to the list
2122
DISPLAY_ORDER = [
2223
'Zadarma',
2324
'DIDWW',
25+
'Make',
2426
'Streamtele',
2527
'Callii',
2628
'Wazzup24 Подписка',
@@ -29,37 +31,29 @@
2931

3032
@router.message(Command("balance"))
3133
async def handle_balance_command(message: Message, session: AsyncSession):
32-
"""
33-
Handles /balance command. Fetches data from APIs and DB to show a summary.
34-
"""
34+
"""Handles /balance command."""
3535
response_parts = ["💰 **Текущие балансы сервисов:**"]
3636

37-
# 1. Fetch all services
3837
stmt = select(Service)
3938
result = await session.execute(stmt)
4039
services_unsorted = result.scalars().all()
41-
4240
services_map = {s.name: s for s in services_unsorted}
4341

44-
# 2. Iterate based on predefined order
4542
for name in DISPLAY_ORDER:
4643
service = services_map.get(name)
47-
if not service:
48-
continue
44+
if not service: continue
4945

5046
sym = CURRENCY_SYMBOLS.get(service.currency, '$')
51-
5247
display_amount = 0.0
5348
status_suffix = ""
5449
is_subscription = False
5550

56-
# A. API Services (Zadarma, DIDWW)
51+
# A. API Services
5752
if name in API_CLIENTS and SETTINGS.API_SERVICE_STATUSES.get(name, True):
5853
try:
5954
client = API_CLIENTS[name]
6055
real_balance = await client.get_balance()
6156

62-
# Update DB with fresh data
6357
if real_balance is not None:
6458
service.last_balance = real_balance
6559
await session.commit()
@@ -68,33 +62,35 @@ async def handle_balance_command(message: Message, session: AsyncSession):
6862
else:
6963
display_amount = service.last_balance
7064
status_suffix = "(Ошибка API)"
71-
7265
except Exception:
7366
display_amount = service.last_balance
7467
status_suffix = "(Сбой API)"
7568

76-
# B. Subscription Services
69+
# B. Subscriptions
7770
elif service.monthly_fee and service.monthly_fee > 0:
7871
display_amount = service.monthly_fee
7972
is_subscription = True
80-
81-
# C. Manual Balance Services
73+
74+
# C. Manual
8275
else:
8376
display_amount = service.last_balance
8477
status_suffix = "(примерно)"
8578

86-
# Formatting Output
79+
# Formatting
80+
amount_fmt = f"{int(display_amount)}" if service.currency == 'Ops' else f"{display_amount:.2f}"
81+
8782
if is_subscription:
88-
line = f"• **{name}:** Подписка: {sym}{display_amount:.2f}"
83+
line = f"• **{name}:** Подписка: `{sym}{amount_fmt}`"
8984
else:
90-
line = f"• **{name}:** {sym}{display_amount:.2f} {status_suffix}"
85+
line = f"• **{name}:** `{sym}{amount_fmt}` {status_suffix}"
9186

9287
response_parts.append(line)
9388

94-
# Append Next Alert Date if available
89+
# Alerts
9590
alert_date = service.next_alert_date or service.next_monthly_alert
9691
if alert_date:
9792
date_str = alert_date.strftime('%Y-%m-%d')
98-
response_parts.append(f" _След. оплата:_ {date_str}")
93+
label = "Сброс:" if name == 'Make' else "След. оплата:"
94+
response_parts.append(f" _{label}_ {date_str}")
9995

10096
await message.answer('\n'.join(response_parts))

handlers/callii.py

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ class CalliiPayment(StatesGroup):
1717

1818
@router.callback_query(F.data == "callii_paid")
1919
async def process_callii_paid(callback: CallbackQuery, state: FSMContext):
20-
"""Callback for 'Paid' button on Callii alert."""
2120
await callback.message.edit_text(
2221
"Спасибо за оплату. **Введите сумму пополнения (числом в USD):**"
2322
)
@@ -26,37 +25,32 @@ async def process_callii_paid(callback: CallbackQuery, state: FSMContext):
2625

2726
@router.message(CalliiPayment.waiting_for_amount, F.text.regexp(r'^\d+(\.\d{1,2})?$'))
2827
async def process_callii_amount(message: Message, state: FSMContext, session: AsyncSession):
29-
"""Process the entered amount and recalculate next alert date."""
3028
try:
31-
amount = float(message.text)
29+
top_up_amount = float(message.text)
3230
daily_cost = SETTINGS.CALLII_DAILY_COST
3331

34-
# Calculate coverage days
35-
days = math.floor(amount / daily_cost)
36-
37-
if days < 1:
38-
await message.answer(
39-
"Сумма слишком мала для покрытия дневного расхода. Введите большую сумму."
40-
)
41-
return
42-
43-
# Calculate next alert date (10:00 AM)
44-
next_alert_datetime = datetime.now(TIMEZONE) + timedelta(days=days)
45-
next_alert_datetime = next_alert_datetime.replace(hour=10, minute=0, second=0, microsecond=0)
46-
47-
# Update DB
4832
stmt = select(Service).where(Service.name == 'Callii')
4933
result = await session.execute(stmt)
5034
service = result.scalar_one()
5135

36+
current_balance = service.last_balance or 0.0
37+
new_total_balance = current_balance + top_up_amount
38+
39+
days_covered = math.floor(new_total_balance / daily_cost)
40+
41+
next_alert_datetime = datetime.now(TIMEZONE) + timedelta(days=days_covered)
42+
next_alert_datetime = next_alert_datetime.replace(hour=10, minute=0, second=0, microsecond=0)
43+
44+
service.last_balance = new_total_balance
5245
service.next_alert_date = next_alert_datetime
5346
await session.commit()
5447

5548
await message.answer(
5649
f"💰 **Платеж Callii обработан!**\n"
57-
f"Сумма: **${amount:.2f}**\n"
58-
f"Дней покрытия (расход ${daily_cost}/день): **{days}**.\n"
59-
f"Следующий контроль баланса запланирован на **{next_alert_datetime.strftime('%Y-%m-%d в 10:00')}**."
50+
f"Было: `${current_balance:.2f}`\n"
51+
f"Внесено: `${top_up_amount:.2f}`\n"
52+
f"Стало: `${new_total_balance:.2f}`\n"
53+
f"Хватит на дней: **{days_covered}** (до {next_alert_datetime.strftime('%Y-%m-%d')})."
6054
)
6155
await state.clear()
6256

@@ -66,4 +60,4 @@ async def process_callii_amount(message: Message, state: FSMContext, session: As
6660

6761
@router.message(CalliiPayment.waiting_for_amount)
6862
async def process_callii_amount_invalid(message: Message):
69-
await message.answer("Неверный формат. Пожалуйста, введите число (например, 50 или 50.50).")
63+
await message.answer("Неверный формат. Пожалуйста, введите число.")

0 commit comments

Comments
 (0)