feat: add subscription and transaction repositories
- Implemented SubscriptionRepository with methods to get, create, and update subscriptions. - Added BalanceTXRepository for creating and retrieving balance transactions. - Introduced a test script for simulating Pally webhook for local testing.
This commit is contained in:
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")
|
||||||
|
|
||||||
10
api/main.py
10
api/main.py
@@ -2,11 +2,13 @@ from collections.abc import AsyncGenerator
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from api.routes import routers
|
from api.routes import routers
|
||||||
from config import settings
|
from config import settings
|
||||||
from repositories.bills import BillsRepository
|
from repositories.bills import BillsRepository
|
||||||
|
from repositories.subscriptions import SubscriptionRepository
|
||||||
from repositories.users import UserRepository
|
from repositories.users import UserRepository
|
||||||
from schemas.di import APIDependenciesDTO
|
from schemas.di import APIDependenciesDTO
|
||||||
from services.billing_service import BillingService
|
from services.billing_service import BillingService
|
||||||
@@ -16,13 +18,15 @@ from services.user_service import UserService
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator:
|
async def lifespan(app: FastAPI) -> AsyncGenerator:
|
||||||
# Bot Init
|
# Bot Init
|
||||||
bot = Bot(token=settings.bot_token)
|
bot_default = DefaultBotProperties(parse_mode="HTML")
|
||||||
|
bot = Bot(token=settings.bot_token, default=bot_default)
|
||||||
app.state.bot = bot
|
app.state.bot = bot
|
||||||
|
|
||||||
user_repository = UserRepository()
|
user_repository = UserRepository()
|
||||||
user_service = UserService(user_repository)
|
|
||||||
|
|
||||||
bills_repository = BillsRepository()
|
bills_repository = BillsRepository()
|
||||||
|
subscription_repository = SubscriptionRepository()
|
||||||
|
|
||||||
|
user_service = UserService(user_repository, subscription_repository=subscription_repository)
|
||||||
bills_service = BillingService(
|
bills_service = BillingService(
|
||||||
user_repository=user_repository, bills_repository=bills_repository
|
user_repository=user_repository, bills_repository=bills_repository
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from api.core.deps import get_bot, get_db, get_deps
|
from api.core.deps import get_bot, get_db, get_deps
|
||||||
from api.core.notifications import successful_payment_notification
|
from api.core.notifications import successful_payment_notification
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from db.models.bills import DepositStatus
|
||||||
|
from db.models.transactions import BalanceTxType
|
||||||
from misc.pally import BillStatus
|
from misc.pally import BillStatus
|
||||||
from schemas.di import APIDependenciesDTO
|
from schemas.di import APIDependenciesDTO
|
||||||
|
|
||||||
@@ -56,16 +58,38 @@ async def pally_callback(
|
|||||||
logger.critical("Bill %s is not found in db", bill_id)
|
logger.critical("Bill %s is not found in db", bill_id)
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
if bill.status != DepositStatus.PENDING:
|
||||||
|
logger.warning("bill=%s is already paid according to the db", bill.id)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
amount = int(req_amount)
|
amount = int(req_amount)
|
||||||
|
|
||||||
await deps.user_repository.increase_user_balance(session, bill.creator_id, amount=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)
|
await successful_payment_notification(bot, bill.creator_id, amount)
|
||||||
|
|
||||||
referal = bill.user.referal_id
|
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:
|
if referal is not None:
|
||||||
referal_amount = math.floor(amount * (settings.referal_bonus / 100))
|
referal_amount = math.floor(amount * (settings.referal_bonus / 100))
|
||||||
await deps.user_repository.increase_user_balance(session, referal, referal_amount)
|
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)
|
await successful_payment_notification(bot, referal, referal_amount)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -75,4 +99,8 @@ async def pally_callback(
|
|||||||
bill.creator_id,
|
bill.creator_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await deps.bills_repository.update_bill_status(
|
||||||
|
session, int(bill.id), status=DepositStatus.SUCCESS
|
||||||
|
)
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from .ads import router as ads_router
|
|||||||
from .billing import router as billing_router
|
from .billing import router as billing_router
|
||||||
from .buy import router as buy_router
|
from .buy import router as buy_router
|
||||||
from .common import router as common_router
|
from .common import router as common_router
|
||||||
|
from .deposit import router as deposit_router
|
||||||
from .menus import router as menus_router
|
from .menus import router as menus_router
|
||||||
from .prehandling import router as prehandling_router
|
from .prehandling import router as prehandling_router
|
||||||
from .referals import router as referal_router
|
from .referals import router as referal_router
|
||||||
@@ -11,6 +12,7 @@ routers = [
|
|||||||
prehandling_router,
|
prehandling_router,
|
||||||
referal_router,
|
referal_router,
|
||||||
billing_router,
|
billing_router,
|
||||||
|
deposit_router,
|
||||||
menus_router,
|
menus_router,
|
||||||
ads_router,
|
ads_router,
|
||||||
support_router,
|
support_router,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ async def pally_init(
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
ctx.provider = BillingProviders.PALLY
|
ctx.provider = BillingProviders.PALLY
|
||||||
|
ctx.amount = int(ctx.amount)
|
||||||
|
|
||||||
await cb.message.edit_text(
|
await cb.message.edit_text(
|
||||||
BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title())
|
BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title())
|
||||||
|
|||||||
@@ -4,31 +4,29 @@ import math
|
|||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.types import CallbackQuery
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.keyboards.client import (
|
from bot.keyboards.client import deposit_kb, devices_selector, duration_selector, return_to_menu
|
||||||
devices_selector,
|
from bot.states.buy import SubscriptionStorage
|
||||||
duration_selector,
|
|
||||||
payment_gateways,
|
|
||||||
return_to_menu,
|
|
||||||
)
|
|
||||||
from bot.states.buy import BillingStorage, SubscriptionStorage
|
|
||||||
from bot.texts import (
|
from bot.texts import (
|
||||||
CALLBACK_FALLBACK,
|
CALLBACK_FALLBACK,
|
||||||
CHECKOUT_PAYMENT_CHOICE,
|
|
||||||
CONTEXT_REINITIATED,
|
CONTEXT_REINITIATED,
|
||||||
|
INSUFFICIENT_FUNDS,
|
||||||
NOTHING_PLACEHOLDER,
|
NOTHING_PLACEHOLDER,
|
||||||
STANDARD_FALLBACK,
|
STANDARD_FALLBACK,
|
||||||
SUBSCRIPTION_DEVICE_SELECTOR,
|
SUBSCRIPTION_DEVICE_SELECTOR,
|
||||||
SUBSCRIPTION_DURATION_SELECTOR,
|
SUBSCRIPTION_DURATION_SELECTOR,
|
||||||
|
SUCCESSFULLY_CREATED_SUBSCRIPTION,
|
||||||
)
|
)
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from db.models.transactions import BalanceTxType
|
||||||
from misc.utils import (
|
from misc.utils import (
|
||||||
calculate_price,
|
calculate_price,
|
||||||
convert_duration_to_int,
|
convert_duration_to_int,
|
||||||
convert_int_to_duration,
|
convert_int_to_duration,
|
||||||
get_discount,
|
get_discount,
|
||||||
)
|
)
|
||||||
from schemas.billing import BillingContext
|
from schemas.di import DependenciesDTO
|
||||||
from schemas.subscriptions import SubscriptionPlan
|
from schemas.subscriptions import SubscriptionPlan
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -157,7 +155,9 @@ async def select_duration(cb: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm")
|
@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()
|
data = await state.get_data()
|
||||||
await state.clear()
|
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)
|
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
|
||||||
return
|
return
|
||||||
|
|
||||||
await cb.message.edit_text(
|
amount = calculate_price(ctx)
|
||||||
CHECKOUT_PAYMENT_CHOICE,
|
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
|
||||||
reply_markup=payment_gateways,
|
|
||||||
) # FIXME: temp solution that's incredibly stupid
|
|
||||||
|
|
||||||
await state.set_state(BillingStorage.pending)
|
if user.balance < amount:
|
||||||
billing_context = BillingContext(amount=calculate_price(ctx))
|
await cb.message.edit_text(
|
||||||
await state.set_data({"ctx": billing_context})
|
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)
|
||||||
|
|||||||
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})
|
||||||
@@ -34,7 +34,9 @@ async def fetch_referal(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await msg.answer(
|
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,
|
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)
|
user = await deps.user_service.add_user(session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk)
|
||||||
|
|
||||||
await msg.answer(
|
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,
|
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)
|
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
|
||||||
await cb.message.edit_text(
|
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,
|
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)
|
user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
|
||||||
await cb.answer("✅")
|
await cb.answer("✅")
|
||||||
await cb.message.edit_text(
|
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,
|
reply_markup=main_menu,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ main_menu = InlineKeyboardBuilder(
|
|||||||
[
|
[
|
||||||
[InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")],
|
[InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")],
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(text="⚙️ Купить", callback_data="buy"),
|
InlineKeyboardButton(text="🛒 Купить", callback_data="buy"),
|
||||||
InlineKeyboardButton(text="⁉️ FAQ", callback_data="faq"),
|
InlineKeyboardButton(text="💸 Пополнить", callback_data="deposit"),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(text="📢 Наш Канал", url="https://t.me/maleniavpn"),
|
InlineKeyboardButton(text="📢 Наш Канал", url="https://t.me/maleniavpn"),
|
||||||
InlineKeyboardButton(text="🎧 Тех. Поддержка", url="https://t.me/maleniasupportbot"),
|
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")],
|
[InlineKeyboardButton(text="👤 Реферальная система", callback_data="referal")],
|
||||||
]
|
]
|
||||||
).as_markup()
|
).as_markup()
|
||||||
@@ -46,6 +49,10 @@ support_url_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
|||||||
]
|
]
|
||||||
).as_markup()
|
).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:
|
def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton:
|
||||||
return InlineKeyboardButton(text=text, callback_data=data)
|
return InlineKeyboardButton(text=text, callback_data=data)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from config import settings
|
|||||||
from db.session import async_session
|
from db.session import async_session
|
||||||
from misc.pally import PallyClient
|
from misc.pally import PallyClient
|
||||||
from repositories.bills import BillsRepository
|
from repositories.bills import BillsRepository
|
||||||
|
from repositories.subscriptions import SubscriptionRepository
|
||||||
from repositories.users import UserRepository
|
from repositories.users import UserRepository
|
||||||
from schemas.di import DependenciesDTO
|
from schemas.di import DependenciesDTO
|
||||||
from services.billing_service import BillingService
|
from services.billing_service import BillingService
|
||||||
@@ -26,9 +27,10 @@ async def main():
|
|||||||
pally_client = PallyClient(settings.pally_token)
|
pally_client = PallyClient(settings.pally_token)
|
||||||
|
|
||||||
user_repository = UserRepository()
|
user_repository = UserRepository()
|
||||||
user_service = UserService(user_repository)
|
|
||||||
|
|
||||||
bills_repository = BillsRepository()
|
bills_repository = BillsRepository()
|
||||||
|
subscriptions_repository = SubscriptionRepository()
|
||||||
|
|
||||||
|
user_service = UserService(user_repository, subscription_repository=subscriptions_repository)
|
||||||
billing_service = BillingService(
|
billing_service = BillingService(
|
||||||
user_repository=user_repository, bills_repository=bills_repository
|
user_repository=user_repository, bills_repository=bills_repository
|
||||||
)
|
)
|
||||||
@@ -45,6 +47,7 @@ async def main():
|
|||||||
pally_client=pally_client,
|
pally_client=pally_client,
|
||||||
bills_repository=bills_repository,
|
bills_repository=bills_repository,
|
||||||
billing_service=billing_service,
|
billing_service=billing_service,
|
||||||
|
subscriptions_repository=subscriptions_repository,
|
||||||
)
|
)
|
||||||
dp.update.middleware.register(DIMiddleware(deps, async_session))
|
dp.update.middleware.register(DIMiddleware(deps, async_session))
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
12
bot/texts.py
12
bot/texts.py
@@ -94,7 +94,8 @@ NOTHING_PLACEHOLDER = "∅"
|
|||||||
MAIN_MENU = (
|
MAIN_MENU = (
|
||||||
str(MALENIA_LOGO)
|
str(MALENIA_LOGO)
|
||||||
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
|
+ " <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>"
|
+ f"<b>{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code></b>"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ SUBSCRIPTION_DURATION_SELECTOR = (
|
|||||||
|
|
||||||
WHITELISTS_ALREADY_ON = "Белые списки уже активны."
|
WHITELISTS_ALREADY_ON = "Белые списки уже активны."
|
||||||
|
|
||||||
CHECKOUT_PAYMENT_CHOICE = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки."
|
CHECKOUT_PAYMENT_CHOICE = "<b>💳 Пополнение баланса:</b>\n\n<i>❓ Выберите способ оплаты:</i>"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# BILLING
|
# BILLING
|
||||||
@@ -180,6 +181,13 @@ BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING
|
|||||||
|
|
||||||
BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK
|
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>"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# ПОДДЕРЖКА
|
# ПОДДЕРЖКА
|
||||||
|
|||||||
@@ -29,5 +29,8 @@ class Settings(BaseSettings):
|
|||||||
whitelist_cost: int = Field(100, alias="WHITELIST_COST")
|
whitelist_cost: int = Field(100, alias="WHITELIST_COST")
|
||||||
whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD")
|
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
|
settings = Settings() # type: ignore
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from .bills import Bill
|
from .bills import Bill
|
||||||
|
from .subscriptions import Subscription
|
||||||
|
from .transactions import BalanceTransaction
|
||||||
from .users import User
|
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 typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import BIGINT, INTEGER, Enum, ForeignKey
|
from sqlalchemy import BIGINT, INTEGER, Enum, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from db.base import Base
|
from db.base import Base
|
||||||
from misc.pally import BillStatus
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from db.models.users import User
|
from db.models.users import User
|
||||||
|
|
||||||
|
|
||||||
|
class DepositStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
class Bill(Base):
|
class Bill(Base):
|
||||||
__tablename__ = "bills"
|
__tablename__ = "bills"
|
||||||
|
|
||||||
@@ -18,8 +25,10 @@ class Bill(Base):
|
|||||||
)
|
)
|
||||||
creator_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False)
|
creator_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False)
|
||||||
amount: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
amount: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
||||||
status: Mapped[BillStatus] = mapped_column(
|
status: Mapped[DepositStatus] = mapped_column(
|
||||||
Enum(BillStatus), nullable=False, default=BillStatus.NEW
|
Enum(DepositStatus, name="depositstatus", values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=False,
|
||||||
|
default=DepositStatus.PENDING,
|
||||||
)
|
)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(
|
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:
|
if TYPE_CHECKING:
|
||||||
from db.models.bills import Bill
|
from db.models.bills import Bill
|
||||||
|
from db.models.subscriptions import Subscription
|
||||||
|
from db.models.transactions import BalanceTransaction
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@@ -22,3 +24,9 @@ class User(Base):
|
|||||||
back_populates="user",
|
back_populates="user",
|
||||||
cascade="all, delete",
|
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
|
image: postgres:16-alpine
|
||||||
container_name: maleniabot_db
|
container_name: maleniabot_db
|
||||||
environment:
|
environment:
|
||||||
|
TZ: UTC
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-malenia}
|
POSTGRES_USER: ${POSTGRES_USER:-malenia}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-malenia_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-malenia_password}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-malenia_db}
|
POSTGRES_DB: ${POSTGRES_DB:-malenia_db}
|
||||||
@@ -28,6 +29,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
|
TZ: UTC
|
||||||
BOT_TOKEN: ${BOT_TOKEN}
|
BOT_TOKEN: ${BOT_TOKEN}
|
||||||
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
|
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
|
||||||
PROXY: ${PROXY:-}
|
PROXY: ${PROXY:-}
|
||||||
@@ -51,6 +53,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
|
TZ: UTC
|
||||||
BOT_TOKEN: ${BOT_TOKEN}
|
BOT_TOKEN: ${BOT_TOKEN}
|
||||||
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
|
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
|
||||||
PROXY: ${PROXY:-}
|
PROXY: ${PROXY:-}
|
||||||
|
|||||||
@@ -231,5 +231,5 @@ def get_subscription_link(short_uuid: str):
|
|||||||
return settings.subscription_url + "/" + short_uuid
|
return settings.subscription_url + "/" + short_uuid
|
||||||
|
|
||||||
|
|
||||||
def build_main_menu_text(link: str) -> str:
|
def build_main_menu_text(link: str, balance: int) -> str:
|
||||||
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link)
|
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
@@ -1,7 +1,7 @@
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from db.models.bills import Bill, BillStatus
|
from db.models.bills import Bill, DepositStatus
|
||||||
|
|
||||||
|
|
||||||
class BillsRepository:
|
class BillsRepository:
|
||||||
@@ -17,9 +17,15 @@ class BillsRepository:
|
|||||||
*,
|
*,
|
||||||
creator_id: int,
|
creator_id: int,
|
||||||
amount: int,
|
amount: int,
|
||||||
status: BillStatus | None = BillStatus.NEW,
|
status: DepositStatus | None = DepositStatus.PENDING,
|
||||||
) -> Bill:
|
) -> Bill:
|
||||||
bill = Bill(creator_id=creator_id, amount=amount, status=status)
|
bill = Bill(creator_id=creator_id, amount=amount, status=status)
|
||||||
session.add(bill)
|
session.add(bill)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return bill
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from db.models import User
|
from db.models import User
|
||||||
|
from db.models.transactions import BalanceTransaction, BalanceTxType
|
||||||
from schemas.users import NewUserDTO
|
from schemas.users import NewUserDTO
|
||||||
|
|
||||||
|
|
||||||
@@ -61,12 +64,60 @@ class UserRepository:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
async def increase_user_balance(
|
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 | None:
|
||||||
user = await self.get_user_by_id(session, user_id=user_id)
|
user = await self.get_user_by_id(session, user_id=user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
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
|
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()
|
await session.commit()
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from remnawave import RemnawaveSDK
|
|||||||
|
|
||||||
from misc.pally import PallyClient
|
from misc.pally import PallyClient
|
||||||
from repositories.bills import BillsRepository
|
from repositories.bills import BillsRepository
|
||||||
|
from repositories.subscriptions import SubscriptionRepository
|
||||||
from repositories.users import UserRepository
|
from repositories.users import UserRepository
|
||||||
from services.billing_service import BillingService
|
from services.billing_service import BillingService
|
||||||
from services.user_service import UserService
|
from services.user_service import UserService
|
||||||
@@ -13,10 +14,11 @@ from services.user_service import UserService
|
|||||||
class DependenciesDTO:
|
class DependenciesDTO:
|
||||||
user_repository: UserRepository
|
user_repository: UserRepository
|
||||||
user_service: UserService
|
user_service: UserService
|
||||||
rw_sdk: RemnawaveSDK
|
|
||||||
bills_repository: BillsRepository
|
bills_repository: BillsRepository
|
||||||
billing_service: BillingService
|
billing_service: BillingService
|
||||||
|
subscriptions_repository: SubscriptionRepository
|
||||||
pally_client: PallyClient
|
pally_client: PallyClient
|
||||||
|
rw_sdk: RemnawaveSDK
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from enum import Enum
|
from enum import Enum, StrEnum
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -17,3 +17,13 @@ class SubscriptionPlan(BaseModel):
|
|||||||
duration: SubscriptionDuration = SubscriptionDuration.MONTH
|
duration: SubscriptionDuration = SubscriptionDuration.MONTH
|
||||||
whitelists: bool = False
|
whitelists: bool = False
|
||||||
discount: float = 1
|
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 uuid import UUID
|
||||||
|
|
||||||
from remnawave import RemnawaveSDK
|
from remnawave import RemnawaveSDK
|
||||||
from remnawave.models import UpdateUserRequestDto
|
from remnawave.models import CreateUserRequestDto, UpdateUserRequestDto
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -319,3 +319,33 @@ async def update_squads(
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return False
|
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,6 +1,7 @@
|
|||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message, User as TelegramUser
|
||||||
from remnawave import RemnawaveSDK
|
from remnawave import RemnawaveSDK
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -12,8 +13,10 @@ from bot.texts import (
|
|||||||
from db.models.users import User
|
from db.models.users import User
|
||||||
from misc import utils
|
from misc import utils
|
||||||
from repositories import UserRepository
|
from repositories import UserRepository
|
||||||
|
from repositories.subscriptions import SubscriptionRepository
|
||||||
|
from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus
|
||||||
from schemas.users import NewUserDTO
|
from schemas.users import NewUserDTO
|
||||||
from services.rw import RWUserInfo, get_user_by_telegram_id
|
from services import rw
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,12 +25,15 @@ class UserServiceError(Exception): ...
|
|||||||
|
|
||||||
|
|
||||||
class UserService:
|
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.user_repository = user_repository
|
||||||
|
self.subscription_repository = subscription_repository
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_subscription_message(
|
def format_subscription_message(
|
||||||
rw_user: RWUserInfo | None,
|
rw_user: rw.RWUserInfo | None,
|
||||||
link: str | None = None,
|
link: str | None = None,
|
||||||
):
|
):
|
||||||
if rw_user is None:
|
if rw_user is None:
|
||||||
@@ -62,7 +68,7 @@ class UserService:
|
|||||||
if user:
|
if user:
|
||||||
return 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
|
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
|
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(
|
async def update_user_link(
|
||||||
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
|
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
|
||||||
) -> User | None:
|
) -> 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)
|
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)
|
logger.critical("failed to send a message to user %d.", user.id, exc_info=True)
|
||||||
|
|
||||||
return sent
|
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