diff --git a/.gitignore b/.gitignore index 1f7cbc9..127cda6 100644 --- a/.gitignore +++ b/.gitignore @@ -179,4 +179,5 @@ cython_debug/ plans dev.sh test_dummy.py -*.pem \ No newline at end of file +*.pem +tests/ \ No newline at end of file diff --git a/alembic/versions/3d767875ec7d_transactions.py b/alembic/versions/3d767875ec7d_transactions.py new file mode 100644 index 0000000..8590979 --- /dev/null +++ b/alembic/versions/3d767875ec7d_transactions.py @@ -0,0 +1,45 @@ +"""+transactions + +Revision ID: 3d767875ec7d +Revises: 8686021972e9 +Create Date: 2026-08-01 23:40:43.069199 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3d767875ec7d' +down_revision: Union[str, Sequence[str], None] = '8686021972e9' +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', 'manual', 'autorenew', 'manual_renew', 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) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_balance_transactions_user_id'), table_name='balance_transactions') + op.drop_table('balance_transactions') + # ### end Alembic commands ### diff --git a/alembic/versions/8101e7820b1e_balance_float_2.py b/alembic/versions/8101e7820b1e_balance_float_2.py new file mode 100644 index 0000000..fabe616 --- /dev/null +++ b/alembic/versions/8101e7820b1e_balance_float_2.py @@ -0,0 +1,38 @@ +"""balance->float(2) + +Revision ID: 8101e7820b1e +Revises: f1bb7c46f6f6 +Create Date: 2026-08-01 23:16:59.561628 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8101e7820b1e' +down_revision: Union[str, Sequence[str], None] = 'f1bb7c46f6f6' +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.alter_column('users', 'balance', + existing_type=sa.REAL(), + type_=sa.FLOAT(precision=2), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'balance', + existing_type=sa.FLOAT(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/8686021972e9_balance_real_psql_tweaking.py b/alembic/versions/8686021972e9_balance_real_psql_tweaking.py new file mode 100644 index 0000000..12d35a1 --- /dev/null +++ b/alembic/versions/8686021972e9_balance_real_psql_tweaking.py @@ -0,0 +1,32 @@ +"""balance->REAL -- psql tweaking + +Revision ID: 8686021972e9 +Revises: 8101e7820b1e +Create Date: 2026-08-01 23:17:44.635690 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8686021972e9' +down_revision: Union[str, Sequence[str], None] = '8101e7820b1e' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/c58e44d15cca_user_balance.py b/alembic/versions/c58e44d15cca_user_balance.py new file mode 100644 index 0000000..30577c8 --- /dev/null +++ b/alembic/versions/c58e44d15cca_user_balance.py @@ -0,0 +1,34 @@ +"""+user.balance + +Revision ID: c58e44d15cca +Revises: e237f7c5cb9a +Create Date: 2026-08-01 22:59:28.814554 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c58e44d15cca' +down_revision: Union[str, Sequence[str], None] = 'e237f7c5cb9a' +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_unique_constraint(None, 'invoices', ['id']) + op.add_column('users', sa.Column('balance', sa.FLOAT(precision=2), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'balance') + op.drop_constraint(None, 'invoices', type_='unique') + # ### end Alembic commands ### diff --git a/alembic/versions/f1bb7c46f6f6_user_id_integer_user_referal_id.py b/alembic/versions/f1bb7c46f6f6_user_id_integer_user_referal_id.py new file mode 100644 index 0000000..660cab3 --- /dev/null +++ b/alembic/versions/f1bb7c46f6f6_user_id_integer_user_referal_id.py @@ -0,0 +1,68 @@ +"""user.id -> INTEGER, +user.referal_id + +Revision ID: f1bb7c46f6f6 +Revises: c58e44d15cca +Create Date: 2026-08-01 23:14:21.719693 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f1bb7c46f6f6' +down_revision: Union[str, Sequence[str], None] = 'c58e44d15cca' +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.alter_column('invoices', 'creator_id', + existing_type=sa.BIGINT(), + type_=sa.INTEGER(), + existing_nullable=False) + op.alter_column('sessions', 'user_id', + existing_type=sa.BIGINT(), + type_=sa.INTEGER(), + existing_nullable=False) + op.add_column('users', sa.Column('referal_id', sa.INTEGER(), nullable=True)) + op.alter_column('users', 'id', + existing_type=sa.BIGINT(), + type_=sa.INTEGER(), + existing_nullable=False, + autoincrement=True) + op.alter_column('users', 'balance', + existing_type=sa.REAL(), + type_=sa.FLOAT(precision=2), + existing_nullable=False) + op.create_foreign_key(None, 'users', 'users', ['referal_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'users', type_='foreignkey') + op.alter_column('users', 'balance', + existing_type=sa.FLOAT(precision=2), + type_=sa.REAL(), + existing_nullable=False) + op.alter_column('users', 'id', + existing_type=sa.INTEGER(), + type_=sa.BIGINT(), + existing_nullable=False, + autoincrement=True) + op.drop_column('users', 'referal_id') + op.alter_column('sessions', 'user_id', + existing_type=sa.INTEGER(), + type_=sa.BIGINT(), + existing_nullable=False) + op.alter_column('invoices', 'creator_id', + existing_type=sa.INTEGER(), + type_=sa.BIGINT(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/db/models/__init__.py b/db/models/__init__.py index 134986c..efbffdb 100644 --- a/db/models/__init__.py +++ b/db/models/__init__.py @@ -2,6 +2,7 @@ from .addons import Addon from .invoice import Invoice from .pricing import PricingConfig from .sessions import Session +from .transactions import BalanceTransaction from .users import User -__all__ = ["Addon", "Invoice", "PricingConfig", "Session", "User"] +__all__ = ["Addon", "BalanceTransaction", "Invoice", "PricingConfig", "Session", "User"] diff --git a/db/models/invoice.py b/db/models/invoice.py index 55cd6e8..4033a90 100644 --- a/db/models/invoice.py +++ b/db/models/invoice.py @@ -22,4 +22,4 @@ class Invoice(Base): Enum(InvoiceStatus, name="invoicestatus"), nullable=False, default=InvoiceStatus.ACTIVE ) - user: Mapped["User"] = relationship("User") + creator: Mapped["User"] = relationship("User", lazy="selectin") diff --git a/db/models/transactions.py b/db/models/transactions.py new file mode 100644 index 0000000..112f281 --- /dev/null +++ b/db/models/transactions.py @@ -0,0 +1,43 @@ +from datetime import UTC, 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 + +from db.base import Base + +if TYPE_CHECKING: + pass + + +class BalanceTxType(StrEnum): + DEPOSIT = "deposit" + PURCHASE = "purchase" + REFUND = "refund" + REFERRAL_BONUS = "referral" + MANUAL = "manual" + AUTORENEW = "autorenew" + MANUAL_RENEW = "manual_renew" + + +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(UTC), + ) diff --git a/db/models/users.py b/db/models/users.py index 8120ff9..4295f58 100644 --- a/db/models/users.py +++ b/db/models/users.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from sqlalchemy import BIGINT, TEXT, VARCHAR +from sqlalchemy import BIGINT, INTEGER, REAL, TEXT, VARCHAR, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from db.base import Base @@ -13,10 +13,17 @@ class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column( - BIGINT, unique=True, autoincrement=True, nullable=False, primary_key=True + INTEGER, unique=True, autoincrement=True, nullable=False, primary_key=True ) username: Mapped[str] = mapped_column(TEXT, unique=True, nullable=True) hashed_password: Mapped[str] = mapped_column(VARCHAR(255), nullable=True) telegram_id: Mapped[int] = mapped_column(BIGINT, unique=True, nullable=True) + referal_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=True) + + balance: Mapped[float] = mapped_column(REAL, nullable=False, default=0) sessions: Mapped[list["Session"]] = relationship(back_populates="user", lazy="selectin") + referal: Mapped["User | None"] = relationship( + "User", + remote_side=[id], + ) diff --git a/repositories/invoices.py b/repositories/invoices.py index 5635c64..fee85af 100644 --- a/repositories/invoices.py +++ b/repositories/invoices.py @@ -32,3 +32,10 @@ class InvoiceRepository: await self.session.commit() return obj + + async def update_status_by_id(self, invoice_id: int, status: InvoiceStatus) -> Invoice | None: + invoice = await self.get_by_id(invoice_id) + invoice.status = status + await self.session.commit() + + return invoice diff --git a/repositories/users.py b/repositories/users.py index dfc3612..f63299b 100644 --- a/repositories/users.py +++ b/repositories/users.py @@ -2,6 +2,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from db.models import User +from db.models.transactions import BalanceTransaction, BalanceTxType class UserRepository: @@ -39,3 +40,25 @@ class UserRepository: await self.session.commit() return obj + + async def increase_balance( + self, user_id: int, amount: float, tx_type: BalanceTxType, description: str + ): + user = await self.get_user_by_id(user_id) + + if not user: + return + + obj = BalanceTransaction( + user_id=user_id, + amount=amount, + tx_type=tx_type, + balance_before=user.balance, + balance_after=user.balance + amount, + description=description, + ) + self.session.add(obj) + + user.balance += amount + await self.session.commit() + return user diff --git a/requirements.txt b/requirements.txt index 0924785..692267d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ argon2-cffi>=25.1.0 pydantic-settings>=2.14.0 asyncpg>=0.31.0 alembic>=1.18.0 -aiohttp>=3.14.0 \ No newline at end of file +aiohttp>=3.14.0 +python-multipart==0.0.32 \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py index adeec58..0f8b57d 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -2,6 +2,7 @@ from fastapi import APIRouter from .auth import router as auth_router from .orders import router as orders_router +from .payments import payment_routers from .plans import router as plans_router -routers: list[APIRouter] = [auth_router, plans_router, orders_router] +routers: list[APIRouter] = [auth_router, plans_router, orders_router, *payment_routers] diff --git a/routes/payments/__init__.py b/routes/payments/__init__.py new file mode 100644 index 0000000..495f6e4 --- /dev/null +++ b/routes/payments/__init__.py @@ -0,0 +1,3 @@ +from .pally import router as pally_router + +payment_routers = [pally_router] diff --git a/routes/payments/pally.py b/routes/payments/pally.py new file mode 100644 index 0000000..3d6a82b --- /dev/null +++ b/routes/payments/pally.py @@ -0,0 +1,222 @@ +# ruff: noqa: N803 +import hashlib +import hmac +import logging +import math + +from fastapi import Depends, Form, HTTPException +from fastapi.routing import APIRouter +from sqlalchemy.ext.asyncio import AsyncSession + +from config import cfg +from core.deps import get_db +from db.models.transactions import BalanceTxType +from external.pally import BillStatus +from repositories.invoices import InvoiceRepository +from repositories.users import UserRepository +from schemas.invoices import InvoiceStatus + +router = APIRouter(prefix="/payments/pally") + +logger = logging.getLogger(__name__) + + +@router.post("/result") +async def pally_callback( # noqa: PLR0911 + *, + InvId: str = Form(...), + OutSum: str = Form(...), + Commission: str = Form(...), + TrsId: str = Form(...), + Status: str = Form(...), + CurrencyIn: str = Form(...), + custom: str | None = Form(None), + SignatureValue: str = Form(...), + # Optional fields for additional information + AccountType: str | None = Form(None), + AccountNumber: str | None = Form(None), + BalanceAmount: str | None = Form(None), + BalanceCurrency: str | None = Form(None), + PayerPhone: str | None = Form(None), + PayerEmail: str | None = Form(None), + PayerName: str | None = Form(None), + PayerComment: str | None = Form(None), + ErrorCode: int | None = Form(None), + ErrorMessage: str | None = Form(None), + session: AsyncSession = Depends(get_db), +): + users_repo = UserRepository(session) + invoice_repo = InvoiceRepository(session) + invoice_id_str = InvId + + logger.info( + "Pally webhook received - InvId: %s, OutSum: %s, Commission: %s, TrsId: %s, Status: %s, " + "CurrencyIn: %s, custom: %s, BalanceAmount: %s, SignatureValue: %s", + InvId, + OutSum, + Commission, + TrsId, + Status, + CurrencyIn, + custom, + BalanceAmount, + SignatureValue, + ) + + if Status != BillStatus.SUCCESS: + logger.warning( + "Non-success payment received - Status: %s, ErrorCode: %s, ErrorMessage: %s, TrsId: %s", + Status, + ErrorCode, + ErrorMessage, + TrsId, + ) + + # Validate signature + raw_string = f"{OutSum}:{InvId}:{cfg.pally_token}" + expected_signature = hashlib.md5(raw_string.encode("utf-8")).hexdigest().upper() + + logger.debug("Signature validation for TrsId %s", TrsId) + + if not hmac.compare_digest(SignatureValue, expected_signature): + logger.critical( + "SECURITY ALERT: Invalid signature for TrsId %s - Expected: %s, Received: %s", + TrsId, + expected_signature, + SignatureValue, + ) + raise HTTPException(403, detail="Invalid signature.") + + # Only process successful payments + if Status != BillStatus.SUCCESS: + logger.info("Bill %s skipped: status=%s", TrsId, Status) + return "OK" + + logger.info("Processing successfully paid bill %s", TrsId) + + # Validate bill ID (from InvId field, which contains the order_id from bill creation) + if not invoice_id_str or not invoice_id_str.isdigit(): + logger.critical( + "Invalid or non-numeric bill ID in InvId field for TrsId %s: '%s'", + TrsId, + invoice_id_str, + ) + return "OK" + + # Find bill in database + invoice = await invoice_repo.get_by_id(int(invoice_id_str)) + + if not invoice: + logger.critical("Bill %s not found in database for TrsId %s", invoice_id_str, TrsId) + return "OK" + + # Check if already processed + if invoice.status != InvoiceStatus.ACTIVE: + logger.warning( + "Bill %s (TrsId: %s) is already processed with status: %s", + invoice.id, + TrsId, + invoice.status, + ) + return "OK" + + try: + # Handle fee scenarios: use BalanceAmount if available (net amount after fees), + # otherwise use OutSum (gross amount paid by customer) + if BalanceAmount is not None: + # Customer pays fees - BalanceAmount is the net amount credited to merchant + credited_amount = int(float(BalanceAmount)) + gross_amount = int(float(OutSum)) + + logger.info( + "Customer-pays-fees payment: bill_id=%s, expected=%s, gross_paid=%s, net_credited=%s", + invoice.id, + invoice.amount, + gross_amount, + credited_amount, + ) + + # Validate that the net credited amount matches our bill amount + if invoice.amount != credited_amount: + logger.error( + "Net amount mismatch for bill %s (TrsId: %s) - Expected: %s, Net credited: %s, Gross paid: %s", + invoice.id, + TrsId, + invoice.amount, + credited_amount, + gross_amount, + ) + return "OK" + + amount = credited_amount # Credit the net amount (without fees) + + else: + # Standard payment - OutSum should match bill amount exactly + amount = int(float(OutSum)) + + logger.info( + "Standard payment: bill_id=%s, expected=%s, received=%s", + invoice.id, + invoice.amount, + amount, + ) + + if invoice.amount != amount: + logger.error( + "Amount mismatch for bill %s (TrsId: %s) - Expected: %s, Received: %s", + invoice.id, + TrsId, + invoice.amount, + amount, + ) + return "OK" + + logger.info( + "Processing payment: bill_id=%s, user_id=%s, amount=%s", + invoice.id, + invoice.creator_id, + amount, + ) + + # Credit user balance + await users_repo.increase_balance( + invoice.creator_id, + amount=amount, + tx_type=BalanceTxType.DEPOSIT, + description=f"payment via PALLY (TrsId: {TrsId})", + ) + + # Process referral bonus + user = invoice.creator + referal = user.referal + if referal is not None: + referal_amount = math.floor(amount * (cfg.referal_bonus / 100)) + await users_repo.increase_balance( + referal, + referal_amount, + tx_type=BalanceTxType.REFERRAL_BONUS, + description=f"referral reward for user {invoice.creator_id} (TrsId: {TrsId})", + ) + logger.info( + "Referral bonus processed: referrer_id=%s, amount=%s", referal, referal_amount + ) + + logger.info("Payment processing completed successfully for TrsId %s", TrsId) + + except Exception as e: + logger.exception( + "CRITICAL ERROR processing payment for TrsId %s, bill_id %s, user_id %s: %s", + TrsId, + invoice.id, + invoice.creator_id, + str(e), + ) + # Don't return early - still mark as success to prevent retries + # The balance operation might have partially succeeded + + # Update bill status to success + await invoice_repo.update_status_by_id(int(invoice.id), status=InvoiceStatus.PAID) + + logger.info("Bill %s marked as SUCCESS for TrsId %s", invoice.id, TrsId) + + return "OK" diff --git a/schemas/registration.py b/schemas/registration.py index dc7ca45..0a2a3eb 100644 --- a/schemas/registration.py +++ b/schemas/registration.py @@ -4,9 +4,9 @@ from schemas.providers import ProvidersType class UserRegistration(BaseModel): - telegram_id: str | None = Field() + telegram_id: str | None = Field(None) - username: str | None = Field() - password: str | None = Field() + username: str | None = Field(None) + password: str | None = Field(None) provider: ProvidersType