feat: integrated pally webhook (draft, needs rewrite)

This commit is contained in:
2026-08-02 00:01:31 +07:00
parent 8fbd739697
commit cde8ce28f5
17 changed files with 536 additions and 10 deletions

3
.gitignore vendored
View File

@@ -179,4 +179,5 @@ cython_debug/
plans plans
dev.sh dev.sh
test_dummy.py test_dummy.py
*.pem *.pem
tests/

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -2,6 +2,7 @@ from .addons import Addon
from .invoice import Invoice from .invoice import Invoice
from .pricing import PricingConfig from .pricing import PricingConfig
from .sessions import Session from .sessions import Session
from .transactions import BalanceTransaction
from .users import User from .users import User
__all__ = ["Addon", "Invoice", "PricingConfig", "Session", "User"] __all__ = ["Addon", "BalanceTransaction", "Invoice", "PricingConfig", "Session", "User"]

View File

@@ -22,4 +22,4 @@ class Invoice(Base):
Enum(InvoiceStatus, name="invoicestatus"), nullable=False, default=InvoiceStatus.ACTIVE Enum(InvoiceStatus, name="invoicestatus"), nullable=False, default=InvoiceStatus.ACTIVE
) )
user: Mapped["User"] = relationship("User") creator: Mapped["User"] = relationship("User", lazy="selectin")

43
db/models/transactions.py Normal file
View File

@@ -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),
)

View File

@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base from db.base import Base
@@ -13,10 +13,17 @@ class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column( 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) username: Mapped[str] = mapped_column(TEXT, unique=True, nullable=True)
hashed_password: Mapped[str] = mapped_column(VARCHAR(255), nullable=True) hashed_password: Mapped[str] = mapped_column(VARCHAR(255), nullable=True)
telegram_id: Mapped[int] = mapped_column(BIGINT, unique=True, 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") sessions: Mapped[list["Session"]] = relationship(back_populates="user", lazy="selectin")
referal: Mapped["User | None"] = relationship(
"User",
remote_side=[id],
)

View File

@@ -32,3 +32,10 @@ class InvoiceRepository:
await self.session.commit() await self.session.commit()
return obj 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

View File

@@ -2,6 +2,7 @@ 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
class UserRepository: class UserRepository:
@@ -39,3 +40,25 @@ class UserRepository:
await self.session.commit() await self.session.commit()
return obj 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

View File

@@ -6,4 +6,5 @@ argon2-cffi>=25.1.0
pydantic-settings>=2.14.0 pydantic-settings>=2.14.0
asyncpg>=0.31.0 asyncpg>=0.31.0
alembic>=1.18.0 alembic>=1.18.0
aiohttp>=3.14.0 aiohttp>=3.14.0
python-multipart==0.0.32

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter
from .auth import router as auth_router from .auth import router as auth_router
from .orders import router as orders_router from .orders import router as orders_router
from .payments import payment_routers
from .plans import router as plans_router 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]

View File

@@ -0,0 +1,3 @@
from .pally import router as pally_router
payment_routers = [pally_router]

222
routes/payments/pally.py Normal file
View File

@@ -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"

View File

@@ -4,9 +4,9 @@ from schemas.providers import ProvidersType
class UserRegistration(BaseModel): class UserRegistration(BaseModel):
telegram_id: str | None = Field() telegram_id: str | None = Field(None)
username: str | None = Field() username: str | None = Field(None)
password: str | None = Field() password: str | None = Field(None)
provider: ProvidersType provider: ProvidersType