This commit is contained in:
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
|
||||
sys.path.append(SRC_DIR)
|
||||
|
||||
from app.db.session import Base # noqa
|
||||
from app import models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
22
services/payments/alembic/script.py.mako
Normal file
22
services/payments/alembic/script.py.mako
Normal file
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
38
services/payments/alembic/versions/6641523a6967_init.py
Normal file
38
services/payments/alembic/versions/6641523a6967_init.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""init
|
||||
|
||||
Revision ID: 6641523a6967
|
||||
Revises:
|
||||
Create Date: 2025-08-08 11:20:09.064584+00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6641523a6967'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('invoices',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('client_id', sa.UUID(), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('currency', sa.String(length=3), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('description', sa.String(length=500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_invoices_client_id'), 'invoices', ['client_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_invoices_client_id'), table_name='invoices')
|
||||
op.drop_table('invoices')
|
||||
# ### end Alembic commands ###
|
||||
@@ -8,3 +8,4 @@ pydantic-settings
|
||||
python-dotenv
|
||||
httpx>=0.27
|
||||
pytest
|
||||
PyJWT>=2.8
|
||||
|
||||
62
services/payments/src/app/api/routes/payments.py
Normal file
62
services/payments/src/app/api/routes/payments.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.core.security import get_current_user, require_roles, UserClaims
|
||||
from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
router = APIRouter(prefix="/v1/invoices", tags=["payments"])
|
||||
|
||||
@router.post("", response_model=InvoiceRead, status_code=201)
|
||||
def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db),
|
||||
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||||
return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True))
|
||||
|
||||
@router.get("", response_model=list[InvoiceRead])
|
||||
def list_invoices(client_id: str | None = None, status: str | None = None,
|
||||
offset: int = 0, limit: int = Query(50, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
user: UserClaims = Depends(get_current_user)):
|
||||
# Клиент видит только свои инвойсы, админ/матчмейкер — любые
|
||||
if user.role in ("ADMIN","MATCHMAKER"):
|
||||
return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit)
|
||||
else:
|
||||
return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit)
|
||||
|
||||
@router.get("/{inv_id}", response_model=InvoiceRead)
|
||||
def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||||
inv = PaymentService(db).get_invoice(inv_id)
|
||||
if not inv:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub:
|
||||
raise HTTPException(status_code=403, detail="Not allowed")
|
||||
return inv
|
||||
|
||||
@router.patch("/{inv_id}", response_model=InvoiceRead)
|
||||
def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db),
|
||||
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||||
svc = PaymentService(db)
|
||||
inv = svc.get_invoice(inv_id)
|
||||
if not inv:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return svc.update_invoice(inv, **payload.model_dump(exclude_none=True))
|
||||
|
||||
@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead)
|
||||
def mark_paid(inv_id: str, db: Session = Depends(get_db),
|
||||
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||||
svc = PaymentService(db)
|
||||
inv = svc.get_invoice(inv_id)
|
||||
if not inv:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return svc.mark_paid(inv)
|
||||
|
||||
@router.delete("/{inv_id}", status_code=204)
|
||||
def delete_invoice(inv_id: str, db: Session = Depends(get_db),
|
||||
_: UserClaims = Depends(require_roles("ADMIN"))):
|
||||
svc = PaymentService(db)
|
||||
inv = svc.get_invoice(inv_id)
|
||||
if not inv:
|
||||
return
|
||||
svc.delete_invoice(inv)
|
||||
40
services/payments/src/app/core/security.py
Normal file
40
services/payments/src/app/core/security.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Any, Callable
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from pydantic import BaseModel
|
||||
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
|
||||
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
||||
|
||||
class UserClaims(BaseModel):
|
||||
sub: str
|
||||
email: str
|
||||
role: str
|
||||
type: str
|
||||
exp: int
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
|
||||
|
||||
def decode_token(token: str) -> UserClaims:
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
return UserClaims(**payload)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
|
||||
return decode_token(token)
|
||||
|
||||
def require_roles(*roles: str):
|
||||
def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
|
||||
if roles and user.role not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
|
||||
return user
|
||||
return dep
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
from .api.routes.ping import router as ping_router
|
||||
from .api.routes.payments import router as payments_router
|
||||
|
||||
app = FastAPI(title="PAYMENTS Service")
|
||||
|
||||
@@ -7,5 +8,5 @@ app = FastAPI(title="PAYMENTS Service")
|
||||
def health():
|
||||
return {"status": "ok", "service": "payments"}
|
||||
|
||||
# v1 API
|
||||
app.include_router(ping_router, prefix="/v1")
|
||||
app.include_router(payments_router)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .payment import Invoice # noqa
|
||||
|
||||
20
services/payments/src/app/models/payment.py
Normal file
20
services/payments/src/app/models/payment.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Numeric
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
class Invoice(Base):
|
||||
__tablename__ = "invoices"
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||||
amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="USD")
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled
|
||||
description: Mapped[str | None] = mapped_column(String(500), default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
43
services/payments/src/app/repositories/payment_repository.py
Normal file
43
services/payments/src/app/repositories/payment_repository.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.payment import Invoice
|
||||
|
||||
class PaymentRepository:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_invoice(self, **fields) -> Invoice:
|
||||
obj = Invoice(**fields)
|
||||
self.db.add(obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(obj)
|
||||
return obj
|
||||
|
||||
def get_invoice(self, inv_id) -> Optional[Invoice]:
|
||||
return self.db.get(Invoice, inv_id)
|
||||
|
||||
def list_invoices(self, *, client_id: str | None = None, status: str | None = None,
|
||||
offset: int = 0, limit: int = 50) -> Sequence[Invoice]:
|
||||
stmt = select(Invoice)
|
||||
if client_id:
|
||||
stmt = stmt.where(Invoice.client_id == client_id)
|
||||
if status:
|
||||
stmt = stmt.where(Invoice.status == status)
|
||||
stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc())
|
||||
return self.db.execute(stmt).scalars().all()
|
||||
|
||||
def update_invoice(self, obj: Invoice, **fields) -> Invoice:
|
||||
for k, v in fields.items():
|
||||
if v is not None:
|
||||
setattr(obj, k, v)
|
||||
self.db.add(obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(obj)
|
||||
return obj
|
||||
|
||||
def delete_invoice(self, obj: Invoice) -> None:
|
||||
self.db.delete(obj)
|
||||
self.db.commit()
|
||||
24
services/payments/src/app/schemas/payment.py
Normal file
24
services/payments/src/app/schemas/payment.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
client_id: str
|
||||
amount: float
|
||||
currency: str = "USD"
|
||||
description: Optional[str] = None
|
||||
|
||||
class InvoiceUpdate(BaseModel):
|
||||
amount: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class InvoiceRead(BaseModel):
|
||||
id: str
|
||||
client_id: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
description: Optional[str] = None
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
27
services/payments/src/app/services/payment_service.py
Normal file
27
services/payments/src/app/services/payment_service.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from app.repositories.payment_repository import PaymentRepository
|
||||
from app.models.payment import Invoice
|
||||
|
||||
class PaymentService:
|
||||
def __init__(self, db: Session):
|
||||
self.repo = PaymentRepository(db)
|
||||
|
||||
def create_invoice(self, **fields) -> Invoice:
|
||||
return self.repo.create_invoice(**fields)
|
||||
|
||||
def get_invoice(self, inv_id) -> Invoice | None:
|
||||
return self.repo.get_invoice(inv_id)
|
||||
|
||||
def list_invoices(self, **filters):
|
||||
return self.repo.list_invoices(**filters)
|
||||
|
||||
def update_invoice(self, obj: Invoice, **fields) -> Invoice:
|
||||
return self.repo.update_invoice(obj, **fields)
|
||||
|
||||
def delete_invoice(self, obj: Invoice) -> None:
|
||||
return self.repo.delete_invoice(obj)
|
||||
|
||||
def mark_paid(self, obj: Invoice) -> Invoice:
|
||||
return self.repo.update_invoice(obj, status="paid")
|
||||
Reference in New Issue
Block a user