Compare commits
2 Commits
d98383285e
...
287f8eebda
| Author | SHA1 | Date | |
|---|---|---|---|
| 287f8eebda | |||
| 049f31118d |
41
Dockerfile.api
Normal file
41
Dockerfile.api
Normal file
@@ -0,0 +1,41 @@
|
||||
# Stage 1: Builder - Install dependencies
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install build-time dependencies required for compiling packages like asyncpg
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
# Stage 2: Runner - Final lightweight image
|
||||
FROM python:3.13-slim AS runner
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies required by asyncpg
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed Python packages from the builder stage
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Copy application source code
|
||||
COPY . .
|
||||
|
||||
RUN sed -i 's/\r$//' ./entrypoints/api.sh && \
|
||||
chmod +x ./entrypoints/api.sh
|
||||
|
||||
# Ensure the api script is executable
|
||||
RUN chmod +x ./entrypoints/api.sh
|
||||
|
||||
# Default command runs migrations and starts the API
|
||||
CMD ["sh", "./entrypoints/api.sh"]
|
||||
36
alembic/versions/b2ecec0ef260_plan_specifications_added.py
Normal file
36
alembic/versions/b2ecec0ef260_plan_specifications_added.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""plan specifications added
|
||||
|
||||
Revision ID: b2ecec0ef260
|
||||
Revises: e8da1e4516fa
|
||||
Create Date: 2026-04-27 20:16:04.252427
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b2ecec0ef260'
|
||||
down_revision: Union[str, Sequence[str], None] = 'e8da1e4516fa'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('subscriptions', sa.Column('devices', sa.INTEGER(), nullable=False, server_default="3"))
|
||||
op.add_column('subscriptions', sa.Column('whitelists', sa.BOOLEAN(), nullable=False, server_default="false"))
|
||||
# ### end Alembic commands ###
|
||||
op.alter_column("subscriptions", "devices", server_default=None)
|
||||
op.alter_column("subscriptions", "whitelists", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('subscriptions', 'whitelists')
|
||||
op.drop_column('subscriptions', 'devices')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,81 @@
|
||||
"""replace_billstatus_with_depositstatus
|
||||
|
||||
Revision ID: e6541f81f7de
|
||||
Revises: b2ecec0ef260
|
||||
Create Date: 2026-04-27 20:38:12.226592
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e6541f81f7de'
|
||||
down_revision: Union[str, Sequence[str], None] = 'b2ecec0ef260'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('balance_transactions',
|
||||
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.BIGINT(), nullable=False),
|
||||
sa.Column('amount', sa.INTEGER(), nullable=False),
|
||||
sa.Column('tx_type', sa.Enum('DEPOSIT', 'PURCHASE', 'REFUND', 'REFERRAL_BONUS', 'MANUAL', name='balancetxtype'), nullable=False),
|
||||
sa.Column('balance_before', sa.INTEGER(), nullable=False),
|
||||
sa.Column('balance_after', sa.INTEGER(), nullable=False),
|
||||
sa.Column('description', sa.TEXT(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_balance_transactions_user_id'), 'balance_transactions', ['user_id'], unique=False)
|
||||
op.execute("CREATE TYPE depositstatus AS ENUM ('pending', 'success', 'failed', 'expired')")
|
||||
op.execute("ALTER TABLE bills ALTER COLUMN status DROP DEFAULT")
|
||||
op.execute("""
|
||||
ALTER TABLE bills
|
||||
ALTER COLUMN status TYPE depositstatus
|
||||
USING (
|
||||
CASE status::text
|
||||
WHEN 'NEW' THEN 'pending'::depositstatus
|
||||
WHEN 'PROCESS' THEN 'pending'::depositstatus
|
||||
WHEN 'UNDERPAID' THEN 'pending'::depositstatus
|
||||
WHEN 'SUCCESS' THEN 'success'::depositstatus
|
||||
WHEN 'OVERPAID' THEN 'success'::depositstatus
|
||||
WHEN 'FAIL' THEN 'failed'::depositstatus
|
||||
ELSE 'pending'::depositstatus
|
||||
END
|
||||
)
|
||||
""")
|
||||
op.execute("ALTER TABLE bills ALTER COLUMN status SET DEFAULT 'pending'::depositstatus")
|
||||
op.execute("DROP TYPE billstatus")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.execute("CREATE TYPE billstatus AS ENUM ('NEW', 'PROCESS', 'UNDERPAID', 'SUCCESS', 'OVERPAID', 'FAIL')")
|
||||
op.execute("ALTER TABLE bills ALTER COLUMN status DROP DEFAULT")
|
||||
op.execute("""
|
||||
ALTER TABLE bills
|
||||
ALTER COLUMN status TYPE billstatus
|
||||
USING (
|
||||
CASE status::text
|
||||
WHEN 'pending' THEN 'NEW'::billstatus
|
||||
WHEN 'success' THEN 'SUCCESS'::billstatus
|
||||
WHEN 'failed' THEN 'FAIL'::billstatus
|
||||
WHEN 'expired' THEN 'FAIL'::billstatus
|
||||
ELSE 'NEW'::billstatus
|
||||
END
|
||||
)
|
||||
""")
|
||||
op.execute("ALTER TABLE bills ALTER COLUMN status SET DEFAULT 'NEW'::billstatus")
|
||||
op.execute("DROP TYPE depositstatus")
|
||||
op.drop_index(op.f('ix_balance_transactions_user_id'), table_name='balance_transactions')
|
||||
op.drop_table('balance_transactions')
|
||||
# ### end Alembic commands ###
|
||||
39
alembic/versions/e8da1e4516fa_add_subscription_table.py
Normal file
39
alembic/versions/e8da1e4516fa_add_subscription_table.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""add subscription table
|
||||
|
||||
Revision ID: e8da1e4516fa
|
||||
Revises: d21c5f9364ea
|
||||
Create Date: 2026-04-25 16:29:08.186456
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e8da1e4516fa'
|
||||
down_revision: Union[str, Sequence[str], None] = 'd21c5f9364ea'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('subscriptions',
|
||||
sa.Column('user_id', sa.BIGINT(), nullable=False),
|
||||
sa.Column('end_date', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('autorenew', sa.Boolean(), nullable=False),
|
||||
sa.Column('status', sa.Enum('EXPIRED', 'ACTIVE', name='subscriptionstatus'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('user_id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('subscriptions')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,69 @@
|
||||
"""fix_balancetxtype_to_lowercase
|
||||
|
||||
Revision ID: f0668f8e7310
|
||||
Revises: e6541f81f7de
|
||||
Create Date: 2026-04-27 21:06:55.168624
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f0668f8e7310'
|
||||
down_revision: Union[str, Sequence[str], None] = 'e6541f81f7de'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TYPE balancetxtype_new AS ENUM (
|
||||
'deposit', 'purchase', 'refund', 'referral', 'manual'
|
||||
)
|
||||
""")
|
||||
op.execute("ALTER TABLE balance_transactions ALTER COLUMN tx_type DROP DEFAULT")
|
||||
op.execute("""
|
||||
ALTER TABLE balance_transactions
|
||||
ALTER COLUMN tx_type TYPE balancetxtype_new
|
||||
USING (
|
||||
CASE tx_type::text
|
||||
WHEN 'DEPOSIT' THEN 'deposit'::balancetxtype_new
|
||||
WHEN 'PURCHASE' THEN 'purchase'::balancetxtype_new
|
||||
WHEN 'REFUND' THEN 'refund'::balancetxtype_new
|
||||
WHEN 'REFERRAL_BONUS' THEN 'referral'::balancetxtype_new
|
||||
WHEN 'MANUAL' THEN 'manual'::balancetxtype_new
|
||||
ELSE 'manual'::balancetxtype_new
|
||||
END
|
||||
)
|
||||
""")
|
||||
op.execute("DROP TYPE balancetxtype")
|
||||
op.execute("ALTER TYPE balancetxtype_new RENAME TO balancetxtype")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TYPE balancetxtype_old AS ENUM (
|
||||
'DEPOSIT', 'PURCHASE', 'REFUND', 'REFERRAL_BONUS', 'MANUAL'
|
||||
)
|
||||
""")
|
||||
op.execute("ALTER TABLE balance_transactions ALTER COLUMN tx_type DROP DEFAULT")
|
||||
op.execute("""
|
||||
ALTER TABLE balance_transactions
|
||||
ALTER COLUMN tx_type TYPE balancetxtype_old
|
||||
USING (
|
||||
CASE tx_type::text
|
||||
WHEN 'deposit' THEN 'DEPOSIT'::balancetxtype_old
|
||||
WHEN 'purchase' THEN 'PURCHASE'::balancetxtype_old
|
||||
WHEN 'refund' THEN 'REFUND'::balancetxtype_old
|
||||
WHEN 'referral' THEN 'REFERRAL_BONUS'::balancetxtype_old
|
||||
WHEN 'manual' THEN 'MANUAL'::balancetxtype_old
|
||||
ELSE 'MANUAL'::balancetxtype_old
|
||||
END
|
||||
)
|
||||
""")
|
||||
op.execute("DROP TYPE balancetxtype")
|
||||
op.execute("ALTER TYPE balancetxtype_old RENAME TO balancetxtype")
|
||||
|
||||
18
api/core/deps.py
Normal file
18
api/core/deps.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from aiogram import Bot
|
||||
from fastapi import Request
|
||||
|
||||
from db.session import async_session
|
||||
from schemas.di import APIDependenciesDTO
|
||||
|
||||
|
||||
def get_bot(request: Request) -> Bot:
|
||||
return request.app.state.bot
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as db:
|
||||
yield db
|
||||
|
||||
|
||||
def get_deps(request: Request) -> APIDependenciesDTO:
|
||||
return request.app.state.deps
|
||||
13
api/core/notifications.py
Normal file
13
api/core/notifications.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
SUCCESSFUL_PAYMENT = "<b>💸 Зачислен платёж на {amount}₽</b>"
|
||||
|
||||
close_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||
[[InlineKeyboardButton(text="❌", callback_data="delete")]]
|
||||
).as_markup()
|
||||
|
||||
|
||||
async def successful_payment_notification(bot: Bot, user_id: int, amount: int):
|
||||
await bot.send_message(user_id, SUCCESSFUL_PAYMENT.format(amount=amount), reply_markup=close_kb)
|
||||
56
api/main.py
Normal file
56
api/main.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from fastapi import FastAPI
|
||||
|
||||
from api.routes import routers
|
||||
from config import settings
|
||||
from repositories.bills import BillsRepository
|
||||
from repositories.subscriptions import SubscriptionRepository
|
||||
from repositories.users import UserRepository
|
||||
from schemas.di import APIDependenciesDTO
|
||||
from services.billing_service import BillingService
|
||||
from services.user_service import UserService
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator:
|
||||
# Bot Init
|
||||
bot_default = DefaultBotProperties(parse_mode="HTML")
|
||||
bot = Bot(token=settings.bot_token, default=bot_default)
|
||||
app.state.bot = bot
|
||||
|
||||
user_repository = UserRepository()
|
||||
bills_repository = BillsRepository()
|
||||
subscription_repository = SubscriptionRepository()
|
||||
|
||||
user_service = UserService(user_repository, subscription_repository=subscription_repository)
|
||||
bills_service = BillingService(
|
||||
user_repository=user_repository, bills_repository=bills_repository
|
||||
)
|
||||
|
||||
deps_dto = APIDependenciesDTO(
|
||||
user_repository=user_repository,
|
||||
user_service=user_service,
|
||||
bills_repository=bills_repository,
|
||||
billing_service=bills_service,
|
||||
)
|
||||
app.state.deps = deps_dto
|
||||
|
||||
for r in routers:
|
||||
app.include_router(r)
|
||||
|
||||
yield # App's lifespan
|
||||
|
||||
await bot.session.close() # D IE!!!!!!!!
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Malenia API",
|
||||
description="Payment webhook handler and user balance management",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="",
|
||||
)
|
||||
3
api/routes/__init__.py
Normal file
3
api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .pally import router as pally_router
|
||||
|
||||
routers = [pally_router]
|
||||
106
api/routes/pally.py
Normal file
106
api/routes/pally.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
|
||||
from aiogram import Bot
|
||||
from fastapi import Depends, Form, HTTPException
|
||||
from fastapi.routing import APIRouter
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.core.deps import get_bot, get_db, get_deps
|
||||
from api.core.notifications import successful_payment_notification
|
||||
from config import settings
|
||||
from db.models.bills import DepositStatus
|
||||
from db.models.transactions import BalanceTxType
|
||||
from misc.pally import BillStatus
|
||||
from schemas.di import APIDependenciesDTO
|
||||
|
||||
router = APIRouter(prefix="/checkout/pally")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def pally_callback(
|
||||
id: str = Form(...),
|
||||
bill_id: str = Form(...),
|
||||
status: str = Form(...),
|
||||
req_amount: str = Form(...),
|
||||
currency: str = Form(...),
|
||||
order_id: str | None = Form(None),
|
||||
signature: str = Form(...),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
bot: Bot = Depends(get_bot),
|
||||
deps: APIDependenciesDTO = Depends(get_deps),
|
||||
):
|
||||
oid = order_id or ""
|
||||
raw_string = f"{id}{status}{req_amount}{currency}{oid}{settings.pally_token}"
|
||||
md5_hash = hashlib.md5(raw_string.encode("utf-8")).hexdigest()
|
||||
expected_signature = hashlib.sha1(md5_hash.encode("utf-8")).hexdigest()
|
||||
|
||||
if signature != expected_signature:
|
||||
logger.critical("Invalid signature for bill %s", bill_id)
|
||||
raise HTTPException(403, detail="Invalid signature.")
|
||||
|
||||
if status != BillStatus.SUCCESS or not req_amount.isdigit():
|
||||
logger.info("Bill %s skipped: status=%s, req_amount=%s", bill_id, status, req_amount)
|
||||
return "OK"
|
||||
|
||||
logger.info("Received successfully paid bill %s", bill_id)
|
||||
|
||||
if not oid.isdigit():
|
||||
logger.critical("Invalid order_id format for bill %s", bill_id)
|
||||
return "OK"
|
||||
|
||||
bill = await deps.bills_repository.get_bill_by_id(session, int(oid))
|
||||
|
||||
if not bill:
|
||||
logger.critical("Bill %s is not found in db", bill_id)
|
||||
return "OK"
|
||||
|
||||
if bill.status != DepositStatus.PENDING:
|
||||
logger.warning("bill=%s is already paid according to the db", bill.id)
|
||||
return "OK"
|
||||
|
||||
try:
|
||||
amount = int(req_amount)
|
||||
|
||||
if bill.amount != amount:
|
||||
logger.warning("requested amount for bill=%s is not equal to db entry", bill.id)
|
||||
return "OK"
|
||||
|
||||
await deps.user_repository.increase_user_balance(
|
||||
session,
|
||||
bill.creator_id,
|
||||
amount=amount,
|
||||
tx_type=BalanceTxType.DEPOSIT,
|
||||
description="personal balance deposit via PALLY",
|
||||
)
|
||||
await successful_payment_notification(bot, bill.creator_id, amount)
|
||||
|
||||
user = await deps.user_repository.get_user_by_id(session, bill.creator_id)
|
||||
|
||||
referal = user.referal_id if user else None
|
||||
if referal is not None:
|
||||
referal_amount = math.floor(amount * (settings.referal_bonus / 100))
|
||||
await deps.user_repository.increase_user_balance(
|
||||
session,
|
||||
referal,
|
||||
referal_amount,
|
||||
tx_type=BalanceTxType.REFERRAL_BONUS,
|
||||
description=f"referal reward for referal_id={bill.creator_id}",
|
||||
)
|
||||
await successful_payment_notification(bot, referal, referal_amount)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Exception caught while processing balances for bill %s (Creator: %s)",
|
||||
bill_id,
|
||||
bill.creator_id,
|
||||
)
|
||||
|
||||
await deps.bills_repository.update_bill_status(
|
||||
session, int(bill.id), status=DepositStatus.SUCCESS
|
||||
)
|
||||
|
||||
return "OK"
|
||||
@@ -2,6 +2,7 @@ from .ads import router as ads_router
|
||||
from .billing import router as billing_router
|
||||
from .buy import router as buy_router
|
||||
from .common import router as common_router
|
||||
from .deposit import router as deposit_router
|
||||
from .menus import router as menus_router
|
||||
from .prehandling import router as prehandling_router
|
||||
from .referals import router as referal_router
|
||||
@@ -11,6 +12,7 @@ routers = [
|
||||
prehandling_router,
|
||||
referal_router,
|
||||
billing_router,
|
||||
deposit_router,
|
||||
menus_router,
|
||||
ads_router,
|
||||
support_router,
|
||||
@@ -5,11 +5,11 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from keyboards.client import pay_url, return_to_menu
|
||||
from bot.keyboards.client import pay_url, return_to_menu
|
||||
from bot.states.buy import BillingStorage
|
||||
from bot.texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
|
||||
from schemas.billing import BillingContext, BillingProviders
|
||||
from schemas.di import DependenciesDTO
|
||||
from states.buy import BillingStorage
|
||||
from texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,8 +29,11 @@ async def pally_init(
|
||||
await state.clear()
|
||||
|
||||
ctx.provider = BillingProviders.PALLY
|
||||
ctx.amount = int(ctx.amount)
|
||||
|
||||
await cb.message.edit_text(BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title()))
|
||||
await cb.message.edit_text(
|
||||
BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title())
|
||||
)
|
||||
|
||||
bill = await deps.billing_service.pally_initiate_payment(
|
||||
session, deps.pally_client, user_id=cb.from_user.id, amount=ctx.amount
|
||||
@@ -4,32 +4,30 @@ import math
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from keyboards.client import (
|
||||
devices_selector,
|
||||
duration_selector,
|
||||
payment_gateways,
|
||||
return_to_menu,
|
||||
from bot.keyboards.client import deposit_kb, devices_selector, duration_selector, return_to_menu
|
||||
from bot.states.buy import SubscriptionStorage
|
||||
from bot.texts import (
|
||||
CALLBACK_FALLBACK,
|
||||
CONTEXT_REINITIATED,
|
||||
INSUFFICIENT_FUNDS,
|
||||
NOTHING_PLACEHOLDER,
|
||||
STANDARD_FALLBACK,
|
||||
SUBSCRIPTION_DEVICE_SELECTOR,
|
||||
SUBSCRIPTION_DURATION_SELECTOR,
|
||||
SUCCESSFULLY_CREATED_SUBSCRIPTION,
|
||||
)
|
||||
from config import settings
|
||||
from db.models.transactions import BalanceTxType
|
||||
from misc.utils import (
|
||||
calculate_price,
|
||||
convert_duration_to_int,
|
||||
convert_int_to_duration,
|
||||
get_discount,
|
||||
)
|
||||
from schemas.billing import BillingContext
|
||||
from schemas.di import DependenciesDTO
|
||||
from schemas.subscriptions import SubscriptionPlan
|
||||
from states.buy import BillingStorage, SubscriptionStorage
|
||||
from texts import (
|
||||
CALLBACK_FALLBACK,
|
||||
CHECKOUT_PAYMENT_CHOICE,
|
||||
CONTEXT_REINITIATED,
|
||||
NOTHING_PLACEHOLDER,
|
||||
STANDARD_FALLBACK,
|
||||
SUBSCRIPTION_DEVICE_SELECTOR,
|
||||
SUBSCRIPTION_DURATION_SELECTOR,
|
||||
)
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -157,7 +155,9 @@ async def select_duration(cb: CallbackQuery, state: FSMContext):
|
||||
|
||||
|
||||
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm")
|
||||
async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
|
||||
async def proceed_to_checkout(
|
||||
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
||||
):
|
||||
data = await state.get_data()
|
||||
await state.clear()
|
||||
|
||||
@@ -166,11 +166,43 @@ async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
|
||||
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
|
||||
return
|
||||
|
||||
await cb.message.edit_text(
|
||||
CHECKOUT_PAYMENT_CHOICE,
|
||||
reply_markup=payment_gateways,
|
||||
) # FIXME: temp solution that's incredibly stupid
|
||||
amount = calculate_price(ctx)
|
||||
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
|
||||
|
||||
await state.set_state(BillingStorage.pending)
|
||||
billing_context = BillingContext(amount=calculate_price(ctx))
|
||||
await state.set_data({"ctx": billing_context})
|
||||
if user.balance < amount:
|
||||
await cb.message.edit_text(
|
||||
INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), reply_markup=deposit_kb
|
||||
)
|
||||
return
|
||||
|
||||
if not user:
|
||||
await cb.message.edit_text(CALLBACK_FALLBACK)
|
||||
return
|
||||
|
||||
try:
|
||||
await deps.user_service.buy_subscription(session, ctx, deps.rw_sdk, cb.from_user)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"buy_subscription caught an exception on user_id=%s, amount=%s. balance was NOT affected, transaction is rejected.",
|
||||
user.id,
|
||||
amount,
|
||||
)
|
||||
await cb.message.edit_text(CALLBACK_FALLBACK)
|
||||
return
|
||||
|
||||
deducted_user = await deps.user_repository.decrease_user_balance(
|
||||
session,
|
||||
user.id,
|
||||
amount,
|
||||
BalanceTxType.PURCHASE,
|
||||
description=f"subscription purchase: devices={ctx.devices}, duration={ctx.duration}, whitelists={ctx.whitelists}",
|
||||
)
|
||||
if not deducted_user:
|
||||
logger.critical(
|
||||
"RECONCILIATION NEEDED: subscription created for user_id=%s "
|
||||
"but balance deduction FAILED. amount=%s.",
|
||||
cb.from_user.id,
|
||||
amount,
|
||||
)
|
||||
|
||||
await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu)
|
||||
@@ -2,7 +2,7 @@ from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from texts import UNDEFINED_MESSAGE
|
||||
from bot.texts import UNDEFINED_MESSAGE
|
||||
|
||||
router = Router()
|
||||
|
||||
46
bot/handlers/deposit.py
Normal file
46
bot/handlers/deposit.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from bot.keyboards.client import payment_gateways, return_to_menu
|
||||
from bot.states.buy import BillingStorage
|
||||
from bot.states.deposit import DepositStorage
|
||||
from bot.texts import (
|
||||
CHECKOUT_PAYMENT_CHOICE,
|
||||
DEPOSIT_AMOUNT,
|
||||
DEPOSIT_AMOUNT_FALLBACK,
|
||||
)
|
||||
from schemas.billing import BillingContext
|
||||
from schemas.common import GeneralMessageContext
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "deposit")
|
||||
async def deposit_init(cb: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
|
||||
await cb.message.edit_text(DEPOSIT_AMOUNT, reply_markup=return_to_menu)
|
||||
|
||||
ctx = GeneralMessageContext(cb.message)
|
||||
await state.set_state(DepositStorage.amount)
|
||||
await state.set_data({"ctx": ctx})
|
||||
|
||||
|
||||
@router.message(DepositStorage.amount)
|
||||
async def deposit_amount(msg: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
await state.clear()
|
||||
await msg.delete()
|
||||
|
||||
ctx: GeneralMessageContext | None = data.get("ctx")
|
||||
if not isinstance(ctx, GeneralMessageContext) or not msg.text.isdigit():
|
||||
await msg.answer(DEPOSIT_AMOUNT_FALLBACK, reply_markup=return_to_menu)
|
||||
await state.set_state(DepositStorage.amount)
|
||||
await state.set_data(data)
|
||||
return
|
||||
|
||||
await ctx.msg.edit_text(CHECKOUT_PAYMENT_CHOICE, reply_markup=payment_gateways)
|
||||
await state.set_state(BillingStorage.pending)
|
||||
billing_context = BillingContext(amount=int(msg.text))
|
||||
await state.set_data({"ctx": billing_context})
|
||||
@@ -4,11 +4,11 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from keyboards.client import close_kb, main_menu, return_to_menu
|
||||
from bot.keyboards.client import close_kb, main_menu, return_to_menu
|
||||
from bot.texts import FAQ_TEXT, GENERAL_PROCESSING
|
||||
from misc.utils import build_main_menu_text, format_subscription_link
|
||||
from schemas.di import DependenciesDTO
|
||||
from services.rw import get_user_by_telegram_id
|
||||
from texts import FAQ_TEXT, GENERAL_PROCESSING
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -34,7 +34,9 @@ async def fetch_referal(
|
||||
)
|
||||
|
||||
await msg.answer(
|
||||
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||
build_main_menu_text(
|
||||
format_subscription_link(user.subscription_link), balance=user.balance
|
||||
),
|
||||
reply_markup=main_menu,
|
||||
)
|
||||
|
||||
@@ -51,7 +53,9 @@ async def standard_start(
|
||||
user = await deps.user_service.add_user(session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk)
|
||||
|
||||
await msg.answer(
|
||||
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||
build_main_menu_text(
|
||||
format_subscription_link(user.subscription_link), balance=user.balance
|
||||
),
|
||||
reply_markup=main_menu,
|
||||
)
|
||||
|
||||
@@ -64,7 +68,9 @@ async def main_menu_cb(
|
||||
|
||||
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
|
||||
await cb.message.edit_text(
|
||||
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||
build_main_menu_text(
|
||||
format_subscription_link(user.subscription_link), balance=user.balance
|
||||
),
|
||||
reply_markup=main_menu,
|
||||
)
|
||||
|
||||
@@ -79,7 +85,9 @@ async def update_link(
|
||||
user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
|
||||
await cb.answer("✅")
|
||||
await cb.message.edit_text(
|
||||
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||
build_main_menu_text(
|
||||
format_subscription_link(user.subscription_link), balance=user.balance
|
||||
),
|
||||
reply_markup=main_menu,
|
||||
)
|
||||
|
||||
@@ -4,12 +4,10 @@ from aiogram.types import CallbackQuery
|
||||
from aiogram.utils.deep_linking import create_start_link
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from keyboards.client import referals_kb
|
||||
from bot.keyboards.client import referals_kb
|
||||
from bot.texts import REFERALS_MENU
|
||||
from misc.utils import format_share_deep_link
|
||||
from schemas.di import DependenciesDTO
|
||||
from texts import (
|
||||
REFERALS_MENU,
|
||||
)
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -5,8 +5,8 @@ from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from keyboards.client import support_url_kb
|
||||
from texts import SUPPORT_INIT
|
||||
from bot.keyboards.client import support_url_kb
|
||||
from bot.texts import SUPPORT_INIT
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
0
bot/keyboards/__init__.py
Normal file
0
bot/keyboards/__init__.py
Normal file
@@ -9,14 +9,17 @@ main_menu = InlineKeyboardBuilder(
|
||||
[
|
||||
[InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Купить", callback_data="buy"),
|
||||
InlineKeyboardButton(text="⁉️ FAQ", callback_data="faq"),
|
||||
InlineKeyboardButton(text="🛒 Купить", callback_data="buy"),
|
||||
InlineKeyboardButton(text="💸 Пополнить", callback_data="deposit"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📢 Наш Канал", url="https://t.me/maleniavpn"),
|
||||
InlineKeyboardButton(text="🎧 Тех. Поддержка", url="https://t.me/maleniasupportbot"),
|
||||
],
|
||||
[InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link")],
|
||||
[
|
||||
InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link"),
|
||||
InlineKeyboardButton(text="⁉️ FAQ", callback_data="faq"),
|
||||
],
|
||||
[InlineKeyboardButton(text="👤 Реферальная система", callback_data="referal")],
|
||||
]
|
||||
).as_markup()
|
||||
@@ -46,6 +49,10 @@ support_url_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||
]
|
||||
).as_markup()
|
||||
|
||||
deposit_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||
[[InlineKeyboardButton(text="💸 Пополнить", callback_data="deposit")], [_return_to_menu_btn]]
|
||||
).as_markup()
|
||||
|
||||
|
||||
def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton:
|
||||
return InlineKeyboardButton(text=text, callback_data=data)
|
||||
@@ -6,12 +6,13 @@ from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from remnawave import RemnawaveSDK
|
||||
|
||||
from bot.handlers import routers
|
||||
from bot.middlewares import DIMiddleware
|
||||
from config import settings
|
||||
from db.session import async_session
|
||||
from handlers import routers
|
||||
from middlewares import DIMiddleware
|
||||
from misc.pally import PallyClient
|
||||
from repositories.bills import BillsRepository
|
||||
from repositories.subscriptions import SubscriptionRepository
|
||||
from repositories.users import UserRepository
|
||||
from schemas.di import DependenciesDTO
|
||||
from services.billing_service import BillingService
|
||||
@@ -26,9 +27,10 @@ async def main():
|
||||
pally_client = PallyClient(settings.pally_token)
|
||||
|
||||
user_repository = UserRepository()
|
||||
user_service = UserService(user_repository)
|
||||
|
||||
bills_repository = BillsRepository()
|
||||
subscriptions_repository = SubscriptionRepository()
|
||||
|
||||
user_service = UserService(user_repository, subscription_repository=subscriptions_repository)
|
||||
billing_service = BillingService(
|
||||
user_repository=user_repository, bills_repository=bills_repository
|
||||
)
|
||||
@@ -45,6 +47,7 @@ async def main():
|
||||
pally_client=pally_client,
|
||||
bills_repository=bills_repository,
|
||||
billing_service=billing_service,
|
||||
subscriptions_repository=subscriptions_repository,
|
||||
)
|
||||
dp.update.middleware.register(DIMiddleware(deps, async_session))
|
||||
|
||||
0
bot/states/__init__.py
Normal file
0
bot/states/__init__.py
Normal file
5
bot/states/deposit.py
Normal file
5
bot/states/deposit.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class DepositStorage(StatesGroup):
|
||||
amount = State()
|
||||
@@ -94,7 +94,8 @@ NOTHING_PLACEHOLDER = "∅"
|
||||
MAIN_MENU = (
|
||||
str(MALENIA_LOGO)
|
||||
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
|
||||
+ f"<i>{MALENIA_COINS} Личный кабинет</i>\n"
|
||||
+ f"<i>{MALENIA_HEART} Личный кабинет</i>\n"
|
||||
+ f"<b>{MALENIA_COINS} Ваш баланс:</b> <code>{{balance}}₽</code>\n"
|
||||
+ f"<b>{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code></b>"
|
||||
)
|
||||
|
||||
@@ -162,7 +163,7 @@ SUBSCRIPTION_DURATION_SELECTOR = (
|
||||
|
||||
WHITELISTS_ALREADY_ON = "Белые списки уже активны."
|
||||
|
||||
CHECKOUT_PAYMENT_CHOICE = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки."
|
||||
CHECKOUT_PAYMENT_CHOICE = "<b>💳 Пополнение баланса:</b>\n\n<i>❓ Выберите способ оплаты:</i>"
|
||||
|
||||
# ============================================================
|
||||
# BILLING
|
||||
@@ -180,6 +181,13 @@ BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING
|
||||
|
||||
BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK
|
||||
|
||||
INSUFFICIENT_FUNDS = "<b>⚠️ Недостаточно средств для проведения операции.</b>\n\n💰 Текущий баланс: {balance}₽\n💸 Требуется: {amount}₽"
|
||||
SUCCESSFULLY_CREATED_SUBSCRIPTION = (
|
||||
"<b>💚 Ваша подписка успешно оплачена!</b> Ссылка на добавления доступна в главном меню."
|
||||
)
|
||||
|
||||
DEPOSIT_AMOUNT = "<b>💸 Укажите сумму пополнения</b>"
|
||||
DEPOSIT_AMOUNT_FALLBACK = "<b>❌ Сумма пополнения указана неверно, повторите попытку.</b>"
|
||||
|
||||
# ============================================================
|
||||
# ПОДДЕРЖКА
|
||||
15
config.py
15
config.py
@@ -6,24 +6,31 @@ load_dotenv(override=True)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
bot_token: str = Field(alias="BOT_TOKEN")
|
||||
postgres_url: str = Field(
|
||||
"postgresql+asyncpg://malenia:skibidi@localhost:5432/malenia_db", alias="POSTGRES_URL"
|
||||
)
|
||||
|
||||
bot_token: str = Field(alias="BOT_TOKEN")
|
||||
|
||||
pally_shop_id: str = Field(alias="PALLY_SHOP_ID")
|
||||
pally_token: str = Field(alias="PALLY_TOKEN")
|
||||
|
||||
referal_bonus: int = Field(10, alias="REFERAL_BONUS")
|
||||
|
||||
proxy: str | None = Field(None, alias="PROXY")
|
||||
|
||||
remnawave_url: str = Field(alias="REMNAWAVE_URL")
|
||||
subscription_url: str = Field(alias="SUBSCRIPTION_URL")
|
||||
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN")
|
||||
|
||||
pally_shop_id: str = Field(alias="PALLY_SHOP_ID")
|
||||
pally_token: str = Field(alias="PALLY_TOKEN")
|
||||
|
||||
min_devices: int = Field(3, alias="MIN_DEVICES")
|
||||
max_devices: int = Field(12, alias="MAX_DEVICES")
|
||||
per_device_cost: int = Field(50, alias="PER_DEVICE_COST")
|
||||
whitelist_cost: int = Field(100, alias="WHITELIST_COST")
|
||||
whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD")
|
||||
|
||||
general_squad: str = Field("2de0b5ea-49b9-4181-916f-ae67b9c83b80")
|
||||
whitelist_squad: str = Field("e4435baa-64d3-4b29-a18e-81ad1c60d977")
|
||||
|
||||
|
||||
settings = Settings() # type: ignore
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from .bills import Bill
|
||||
from .subscriptions import Subscription
|
||||
from .transactions import BalanceTransaction
|
||||
from .users import User
|
||||
|
||||
__all__ = ["Bill", "User"]
|
||||
__all__ = ["BalanceTransaction", "Bill", "Subscription", "User"]
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import BIGINT, INTEGER, Enum, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from db.base import Base
|
||||
from misc.pally import BillStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from db.models.users import User
|
||||
|
||||
|
||||
class DepositStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class Bill(Base):
|
||||
__tablename__ = "bills"
|
||||
|
||||
@@ -18,8 +25,10 @@ class Bill(Base):
|
||||
)
|
||||
creator_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False)
|
||||
amount: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
||||
status: Mapped[BillStatus] = mapped_column(
|
||||
Enum(BillStatus), nullable=False, default=BillStatus.NEW
|
||||
status: Mapped[DepositStatus] = mapped_column(
|
||||
Enum(DepositStatus, name="depositstatus", values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
default=DepositStatus.PENDING,
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(
|
||||
|
||||
35
db/models/subscriptions.py
Normal file
35
db/models/subscriptions.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import BOOLEAN, INTEGER, Boolean, DateTime, Enum, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from db.base import Base
|
||||
from schemas.subscriptions import SubscriptionStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from db.models.users import User
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
|
||||
end_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
autorenew: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
status: Mapped[SubscriptionStatus] = mapped_column(
|
||||
Enum(
|
||||
SubscriptionStatus,
|
||||
name="subscriptionstatus",
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
default=SubscriptionStatus.EXPIRED,
|
||||
)
|
||||
|
||||
devices: Mapped[int] = mapped_column(INTEGER, nullable=False, default=3)
|
||||
whitelists: Mapped[bool] = mapped_column(BOOLEAN, nullable=False, default=False)
|
||||
|
||||
plan_price_paid: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0)
|
||||
duration_days: Mapped[int] = mapped_column(INTEGER, nullable=False, default=30)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="subscription")
|
||||
41
db/models/transactions.py
Normal file
41
db/models/transactions.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import BIGINT, INTEGER, TEXT, DateTime, Enum, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from db.base import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from db.models.users import User
|
||||
|
||||
|
||||
class BalanceTxType(StrEnum):
|
||||
DEPOSIT = "deposit"
|
||||
PURCHASE = "purchase"
|
||||
REFUND = "refund"
|
||||
REFERRAL_BONUS = "referral"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class BalanceTransaction(Base):
|
||||
__tablename__ = "balance_transactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(BIGINT, autoincrement=True, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False, index=True)
|
||||
amount: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
||||
|
||||
tx_type: Mapped[BalanceTxType] = mapped_column(
|
||||
Enum(BalanceTxType, name="balancetxtype", values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
)
|
||||
balance_before: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
||||
balance_after: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
||||
description: Mapped[str] = mapped_column(TEXT, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now() # noqa: PLW0108
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="balance_transactions")
|
||||
@@ -7,6 +7,8 @@ from db.base import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from db.models.bills import Bill
|
||||
from db.models.subscriptions import Subscription
|
||||
from db.models.transactions import BalanceTransaction
|
||||
|
||||
|
||||
class User(Base):
|
||||
@@ -22,3 +24,9 @@ class User(Base):
|
||||
back_populates="user",
|
||||
cascade="all, delete",
|
||||
)
|
||||
subscription: Mapped["Subscription"] = relationship(
|
||||
"Subscription", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
balance_transactions: Mapped[list["BalanceTransaction"]] = relationship(
|
||||
"BalanceTransaction", back_populates="user", cascade="all, delete"
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ services:
|
||||
image: postgres:16-alpine
|
||||
container_name: maleniabot_db
|
||||
environment:
|
||||
TZ: UTC
|
||||
POSTGRES_USER: ${POSTGRES_USER:-malenia}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-malenia_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-malenia_db}
|
||||
@@ -23,11 +24,12 @@ services:
|
||||
bot:
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
container_name: maleniabot_app
|
||||
container_name: maleniabot_bot
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
TZ: UTC
|
||||
BOT_TOKEN: ${BOT_TOKEN}
|
||||
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
|
||||
PROXY: ${PROXY:-}
|
||||
@@ -43,5 +45,31 @@ services:
|
||||
PALLY_SHOP_ID: ${PALLY_SHOP_ID}
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
dockerfile: Dockerfile.api
|
||||
container_name: maleniabot_api
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
TZ: UTC
|
||||
BOT_TOKEN: ${BOT_TOKEN}
|
||||
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
|
||||
PROXY: ${PROXY:-}
|
||||
REMNAWAVE_URL: ${REMNAWAVE_URL}
|
||||
SUBSCRIPTION_URL: ${SUBSCRIPTION_URL}
|
||||
REMNAWAVE_TOKEN: ${REMNAWAVE_TOKEN}
|
||||
MIN_DEVICES: ${MIN_DEVICES:-3}
|
||||
MAX_DEVICES: ${MAX_DEVICES:-12}
|
||||
PER_DEVICE_COST: ${PER_DEVICE_COST:-50}
|
||||
WHITELIST_COST: ${WHITELIST_COST:-100}
|
||||
WHITELIST_DEVICE_THRESHOLD: ${WHITELIST_DEVICE_THRESHOLD:-6}
|
||||
PALLY_TOKEN: ${PALLY_TOKEN}
|
||||
PALLY_SHOP_ID: ${PALLY_SHOP_ID}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
5
entrypoints/api.sh
Normal file
5
entrypoints/api.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "[+] Starting API server..."
|
||||
python -m uvicorn api.main:app --host 0.0.0.0 --port 8000
|
||||
@@ -6,4 +6,4 @@ echo
|
||||
alembic upgrade head
|
||||
|
||||
echo "[+] Starting bot..."
|
||||
python main.py
|
||||
python -m bot.main
|
||||
@@ -3,7 +3,7 @@ import urllib.parse
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from random import choice
|
||||
|
||||
import texts
|
||||
from bot import texts
|
||||
from config import settings
|
||||
from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan
|
||||
|
||||
@@ -231,5 +231,5 @@ def get_subscription_link(short_uuid: str):
|
||||
return settings.subscription_url + "/" + short_uuid
|
||||
|
||||
|
||||
def build_main_menu_text(link: str) -> str:
|
||||
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link)
|
||||
def build_main_menu_text(link: str, balance: int) -> str:
|
||||
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link, balance=balance)
|
||||
|
||||
1299
plans/FINANCE_REFINEMENT.md
Normal file
1299
plans/FINANCE_REFINEMENT.md
Normal file
File diff suppressed because it is too large
Load Diff
1183
plans/SUBSCRIPTION_FIX.md
Normal file
1183
plans/SUBSCRIPTION_FIX.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,8 @@ ignore = [
|
||||
"PLR0913",
|
||||
"RUF001",
|
||||
"RUF002",
|
||||
"RUF003"
|
||||
"RUF003",
|
||||
"B008"
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models.bills import Bill, BillStatus
|
||||
from db.models.bills import Bill, DepositStatus
|
||||
|
||||
|
||||
class BillsRepository:
|
||||
@@ -17,9 +17,15 @@ class BillsRepository:
|
||||
*,
|
||||
creator_id: int,
|
||||
amount: int,
|
||||
status: BillStatus | None = BillStatus.NEW,
|
||||
status: DepositStatus | None = DepositStatus.PENDING,
|
||||
) -> Bill:
|
||||
bill = Bill(creator_id=creator_id, amount=amount, status=status)
|
||||
session.add(bill)
|
||||
await session.commit()
|
||||
return bill
|
||||
|
||||
async def update_bill_status(self, session: AsyncSession, bill_id: int, status: DepositStatus):
|
||||
stmt = update(Bill).where(Bill.id == bill_id).values(status=status).returning(Bill)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
74
repositories/subscriptions.py
Normal file
74
repositories/subscriptions.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models.subscriptions import Subscription, SubscriptionStatus
|
||||
|
||||
|
||||
class SubscriptionRepository:
|
||||
async def get_subscription_by_user_id(
|
||||
self, session: AsyncSession, user_id: int
|
||||
) -> Subscription | None:
|
||||
stmt = select(Subscription).where(Subscription.user_id == user_id)
|
||||
res = await session.execute(stmt)
|
||||
return res.scalar_one_or_none()
|
||||
|
||||
async def create_subscription(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
end_date: datetime,
|
||||
devices: int,
|
||||
duration_days: int,
|
||||
plan_price_paid: int,
|
||||
whitelists: bool,
|
||||
autorenew: bool = False,
|
||||
status: SubscriptionStatus = SubscriptionStatus.EXPIRED,
|
||||
) -> Subscription:
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
end_date=end_date,
|
||||
autorenew=autorenew,
|
||||
status=status,
|
||||
devices=devices,
|
||||
whitelists=whitelists,
|
||||
duration_days=duration_days,
|
||||
plan_price_paid=plan_price_paid,
|
||||
)
|
||||
session.add(subscription)
|
||||
await session.commit()
|
||||
|
||||
return subscription
|
||||
|
||||
async def update_sub(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
end_date: datetime,
|
||||
devices: int,
|
||||
whitelists: bool,
|
||||
duration_days: int,
|
||||
plan_price_paid: int,
|
||||
autorenew: bool = False,
|
||||
status: SubscriptionStatus = SubscriptionStatus.ACTIVE,
|
||||
) -> Subscription | None:
|
||||
stmt = (
|
||||
update(Subscription)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.values(
|
||||
end_date=end_date,
|
||||
autorenew=autorenew,
|
||||
status=status,
|
||||
devices=devices,
|
||||
whitelists=whitelists,
|
||||
duration_days=duration_days,
|
||||
plan_price_paid=plan_price_paid,
|
||||
)
|
||||
.returning(Subscription)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return result.scalar_one_or_none()
|
||||
41
repositories/transactions.py
Normal file
41
repositories/transactions.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models.transactions import BalanceTransaction, BalanceTxType
|
||||
|
||||
|
||||
class BalanceTXRepository:
|
||||
async def create(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
amount: int,
|
||||
tx_type: BalanceTxType,
|
||||
balance_before: int,
|
||||
balance_after: int,
|
||||
description: str,
|
||||
) -> BalanceTransaction:
|
||||
created_at = datetime.now(timezone.UTC)
|
||||
tx = BalanceTransaction(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
tx_type=tx_type,
|
||||
balance_before=balance_before,
|
||||
balance_after=balance_after,
|
||||
description=description,
|
||||
created_at=created_at,
|
||||
)
|
||||
session.add(tx)
|
||||
await session.commit()
|
||||
|
||||
return tx
|
||||
|
||||
async def get_transactions_by_id(
|
||||
self, session: AsyncSession, user_id: int, limit: int = 50
|
||||
) -> list[BalanceTransaction]:
|
||||
stmt = select(BalanceTransaction).where(BalanceTransaction.user_id == user_id).limit(limit)
|
||||
res = await session.execute(stmt)
|
||||
|
||||
return list(res.scalars().all())
|
||||
@@ -1,7 +1,10 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import User
|
||||
from db.models.transactions import BalanceTransaction, BalanceTxType
|
||||
from schemas.users import NewUserDTO
|
||||
|
||||
|
||||
@@ -61,12 +64,60 @@ class UserRepository:
|
||||
return user
|
||||
|
||||
async def increase_user_balance(
|
||||
self, session: AsyncSession, user_id: int, amount: int
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
amount: int,
|
||||
tx_type: BalanceTxType,
|
||||
description: str,
|
||||
) -> User | None:
|
||||
user = await self.get_user_by_id(session, user_id=user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
created_at = datetime.datetime.now(datetime.UTC)
|
||||
transaction = BalanceTransaction(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
tx_type=tx_type,
|
||||
balance_before=user.balance,
|
||||
balance_after=user.balance + amount,
|
||||
description=description,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
user.balance += amount
|
||||
|
||||
session.add(transaction)
|
||||
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
async def decrease_user_balance(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
amount: int,
|
||||
tx_type: BalanceTxType,
|
||||
description: str,
|
||||
) -> User | None:
|
||||
user = await self.get_user_by_id(session, user_id=user_id)
|
||||
if not user or user.balance < amount:
|
||||
return None
|
||||
|
||||
created_at = datetime.datetime.now(datetime.timezone.UTC)
|
||||
transaction = BalanceTransaction(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
tx_type=tx_type,
|
||||
balance_before=user.balance,
|
||||
balance_after=user.balance + amount,
|
||||
description=description,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
user.balance -= amount
|
||||
session.add(transaction)
|
||||
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
@@ -7,4 +7,7 @@ pydantic-settings>=2.7.0
|
||||
python-dotenv
|
||||
aiohttp-socks
|
||||
alembic
|
||||
remnawave>=2.6.1
|
||||
remnawave>=2.6.1
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
python-multipart
|
||||
@@ -4,6 +4,7 @@ from remnawave import RemnawaveSDK
|
||||
|
||||
from misc.pally import PallyClient
|
||||
from repositories.bills import BillsRepository
|
||||
from repositories.subscriptions import SubscriptionRepository
|
||||
from repositories.users import UserRepository
|
||||
from services.billing_service import BillingService
|
||||
from services.user_service import UserService
|
||||
@@ -13,7 +14,16 @@ from services.user_service import UserService
|
||||
class DependenciesDTO:
|
||||
user_repository: UserRepository
|
||||
user_service: UserService
|
||||
rw_sdk: RemnawaveSDK
|
||||
bills_repository: BillsRepository
|
||||
billing_service: BillingService
|
||||
subscriptions_repository: SubscriptionRepository
|
||||
pally_client: PallyClient
|
||||
rw_sdk: RemnawaveSDK
|
||||
|
||||
|
||||
@dataclass
|
||||
class APIDependenciesDTO:
|
||||
user_repository: UserRepository
|
||||
user_service: UserService
|
||||
bills_repository: BillsRepository
|
||||
billing_service: BillingService
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from enum import Enum
|
||||
from enum import Enum, StrEnum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -17,3 +17,13 @@ class SubscriptionPlan(BaseModel):
|
||||
duration: SubscriptionDuration = SubscriptionDuration.MONTH
|
||||
whitelists: bool = False
|
||||
discount: float = 1
|
||||
|
||||
|
||||
class SubscriptionStatus(StrEnum):
|
||||
EXPIRED = "expired"
|
||||
ACTIVE = "active"
|
||||
|
||||
|
||||
class InternalSquads(StrEnum):
|
||||
WHITELISTS = settings.whitelist_squad
|
||||
DEFAULT = settings.general_squad
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from remnawave import RemnawaveSDK
|
||||
from remnawave.models import UpdateUserRequestDto
|
||||
from remnawave.models import CreateUserRequestDto, UpdateUserRequestDto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -319,3 +319,33 @@ async def update_squads(
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def create_user(
|
||||
sdk: RemnawaveSDK,
|
||||
username: str,
|
||||
telegram_id: int,
|
||||
expire_at: datetime,
|
||||
hwid_device_limit: int | None,
|
||||
squad_uuids: list[str],
|
||||
) -> RWUserInfo | None:
|
||||
"""
|
||||
Создать нового пользователя в Remnawave.
|
||||
hwid_device_limit=None → без ограничений.
|
||||
Возвращает RWUserInfo при успехе, None при ошибке.
|
||||
"""
|
||||
try:
|
||||
dto = CreateUserRequestDto(
|
||||
username=username,
|
||||
telegram_id=telegram_id,
|
||||
expire_at=expire_at,
|
||||
hwid_device_limit=hwid_device_limit,
|
||||
active_internal_squads=[UUID(s) for s in squad_uuids] if squad_uuids else None,
|
||||
)
|
||||
result = await sdk.users.create_user(dto)
|
||||
if result is None:
|
||||
return None
|
||||
return _parse_user(result)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to create user username=%s: %s", username, exc)
|
||||
return None
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from aiogram.types import Message
|
||||
from aiogram.types import Message, User as TelegramUser
|
||||
from remnawave import RemnawaveSDK
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models.users import User
|
||||
from misc import utils
|
||||
from repositories import UserRepository
|
||||
from schemas.users import NewUserDTO
|
||||
from services.rw import RWUserInfo, get_user_by_telegram_id
|
||||
from texts import (
|
||||
from bot.texts import (
|
||||
NO_SUBSCRIPTION,
|
||||
NOTHING_PLACEHOLDER,
|
||||
SUBSCRIPTION_INFORMATION,
|
||||
)
|
||||
from db.models.users import User
|
||||
from misc import utils
|
||||
from repositories import UserRepository
|
||||
from repositories.subscriptions import SubscriptionRepository
|
||||
from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus
|
||||
from schemas.users import NewUserDTO
|
||||
from services import rw
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,12 +25,15 @@ class UserServiceError(Exception): ...
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, user_repository: UserRepository) -> None:
|
||||
def __init__(
|
||||
self, user_repository: UserRepository, subscription_repository: SubscriptionRepository
|
||||
) -> None:
|
||||
self.user_repository = user_repository
|
||||
self.subscription_repository = subscription_repository
|
||||
|
||||
@staticmethod
|
||||
def format_subscription_message(
|
||||
rw_user: RWUserInfo | None,
|
||||
rw_user: rw.RWUserInfo | None,
|
||||
link: str | None = None,
|
||||
):
|
||||
if rw_user is None:
|
||||
@@ -62,7 +68,7 @@ class UserService:
|
||||
if user:
|
||||
return user
|
||||
|
||||
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
|
||||
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id)
|
||||
link = utils.get_subscription_link(rw_user.short_uuid) if rw_user else None
|
||||
referal = int(referal) if str(referal).isdigit() and user_id != int(referal) else None
|
||||
|
||||
@@ -72,7 +78,7 @@ class UserService:
|
||||
async def update_user_link(
|
||||
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
|
||||
) -> User | None:
|
||||
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
|
||||
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id)
|
||||
|
||||
user = await self.user_repository.get_user_by_id(session, user_id)
|
||||
|
||||
@@ -97,3 +103,98 @@ class UserService:
|
||||
logger.critical("failed to send a message to user %d.", user.id, exc_info=True)
|
||||
|
||||
return sent
|
||||
|
||||
async def buy_subscription(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
subscription: SubscriptionPlan,
|
||||
rw_sdk: RemnawaveSDK,
|
||||
user: TelegramUser,
|
||||
):
|
||||
duration = utils.convert_duration_to_timedelta(subscription.duration)
|
||||
squads = [InternalSquads.DEFAULT]
|
||||
if subscription.whitelists:
|
||||
squads.append(InternalSquads.WHITELISTS)
|
||||
|
||||
## If subscription exists, add.
|
||||
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id)
|
||||
if rw_user:
|
||||
db_subscription = await self.subscription_repository.get_subscription_by_user_id(
|
||||
session, user.id
|
||||
)
|
||||
if db_subscription:
|
||||
db_subscription = await self.subscription_repository.update_sub(
|
||||
session,
|
||||
user.id,
|
||||
end_date=rw_user.expire_at + duration,
|
||||
autorenew=False,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
devices=int(subscription.devices),
|
||||
whitelists=subscription.whitelists,
|
||||
plan_price_paid=utils.calculate_price(subscription),
|
||||
duration_days=duration.days,
|
||||
)
|
||||
else:
|
||||
db_subscription = await self.subscription_repository.create_subscription(
|
||||
session,
|
||||
user_id=user.id,
|
||||
end_date=rw_user.expire_at + duration,
|
||||
autorenew=False,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
devices=int(subscription.devices),
|
||||
whitelists=subscription.whitelists,
|
||||
plan_price_paid=utils.calculate_price(subscription),
|
||||
duration_days=duration.days,
|
||||
)
|
||||
|
||||
await rw.add_days(rw_sdk, rw_user.uuid, duration.days)
|
||||
await rw.update_squads(rw_sdk, rw_user.uuid, squads)
|
||||
await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices)
|
||||
|
||||
await self.update_user_link(session, user.id, rw_sdk)
|
||||
return db_subscription
|
||||
|
||||
# Create
|
||||
expire = datetime.datetime.now(datetime.UTC) + duration
|
||||
username = "tg_" + str(user.username or user.id)
|
||||
|
||||
rw_user = await rw.create_user(
|
||||
rw_sdk,
|
||||
username=username,
|
||||
telegram_id=user.id,
|
||||
expire_at=expire,
|
||||
hwid_device_limit=subscription.devices,
|
||||
squad_uuids=squads,
|
||||
)
|
||||
|
||||
db_subscription = await self.subscription_repository.get_subscription_by_user_id(
|
||||
session, user.id
|
||||
)
|
||||
if db_subscription:
|
||||
db_subscription = await self.subscription_repository.update_sub(
|
||||
session,
|
||||
user.id,
|
||||
end_date=expire,
|
||||
autorenew=False,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
devices=int(subscription.devices),
|
||||
whitelists=subscription.whitelists,
|
||||
plan_price_paid=utils.calculate_price(subscription),
|
||||
duration_days=duration.days,
|
||||
)
|
||||
else:
|
||||
db_subscription = await self.subscription_repository.create_subscription(
|
||||
session,
|
||||
user_id=user.id,
|
||||
end_date=expire,
|
||||
autorenew=False,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
devices=int(subscription.devices),
|
||||
whitelists=subscription.whitelists,
|
||||
plan_price_paid=utils.calculate_price(subscription),
|
||||
duration_days=duration.days,
|
||||
)
|
||||
|
||||
await self.update_user_link(session, user.id, rw_sdk)
|
||||
|
||||
return db_subscription
|
||||
|
||||
81
tests/send_pally_webhook.py
Normal file
81
tests/send_pally_webhook.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Симулятор вебхука Pally для локального тестирования.
|
||||
|
||||
Использование:
|
||||
python tests/send_test_webhook.py --user-id 123456789 --amount 300 --bill-id 1
|
||||
|
||||
Предварительно:
|
||||
- API должен быть запущен: uvicorn api.main:app --port 8000
|
||||
- Пользователь с --user-id должен существовать в БД
|
||||
- Счёт с --bill-id должен существовать в БД и принадлежать этому пользователю
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def calculate_signature(
|
||||
payment_id: str,
|
||||
status: str,
|
||||
req_amount: str,
|
||||
currency: str,
|
||||
order_id: str,
|
||||
pally_token: str,
|
||||
) -> str:
|
||||
raw = f"{payment_id}{status}{req_amount}{currency}{order_id}{pally_token}"
|
||||
md5 = hashlib.md5(raw.encode()).hexdigest()
|
||||
return hashlib.sha1(md5.encode()).hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--user-id", type=int, required=True)
|
||||
parser.add_argument("--amount", type=int, required=True)
|
||||
parser.add_argument("--bill-id", type=int, required=True)
|
||||
parser.add_argument("--host", default="http://127.0.0.1:8000")
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
default="SUCCESS",
|
||||
choices=["SUCCESS", "FAIL", "PROCESS"],
|
||||
help="Статус платежа (по умолчанию SUCCESS)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
pally_token = os.environ["PALLY_TOKEN"]
|
||||
|
||||
# Эти поля могут быть любыми строками — вебхук их не использует содержательно,
|
||||
# только включает в подпись. Главное чтобы совпадали с тем, что пойдёт в подпись.
|
||||
payment_id = "test_payment_001"
|
||||
bill_id = f"test_bill_{args.bill_id}"
|
||||
currency = "RUB"
|
||||
order_id = str(args.bill_id) # order_id == id счёта в нашей БД
|
||||
|
||||
signature = calculate_signature(
|
||||
payment_id, args.status, str(args.amount), currency, order_id, pally_token
|
||||
)
|
||||
|
||||
payload = {
|
||||
"id": payment_id,
|
||||
"bill_id": bill_id,
|
||||
"status": args.status,
|
||||
"req_amount": str(args.amount),
|
||||
"currency": currency,
|
||||
"order_id": order_id,
|
||||
"signature": signature,
|
||||
}
|
||||
|
||||
print(f"{args.host}/checkout/pally/callback")
|
||||
print(f"Отправляю вебхук: {payload}\n")
|
||||
response = httpx.post(f"{args.host}/checkout/pally/callback", data=payload)
|
||||
print(f"Статус ответа: {response.status_code}")
|
||||
print(f"Тело ответа: {response.text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user