diff --git a/src/routes/payments.py b/src/routes/payments.py index b173c0a..d736f05 100644 --- a/src/routes/payments.py +++ b/src/routes/payments.py @@ -30,7 +30,43 @@ router = APIRouter() -@router.get("/", response_model=Page[PaymentHistoryResponse]) +@router.get( + "/", + response_model=Page[PaymentHistoryResponse], + summary="Read Payments", + description="Retrieve a paginated list of payments. " + "Admins can filter by user ID, start date, end date, and payment " + "status. Non-admin users can only view their own payments.", + responses={ + 200: { + "description": "Successful Response", + "content": { + "application/json": { + "example": { + "items": [ + { + "id": 1, + "user_id": 1, + "order_id": 1, + "amount": 100.0, + "status": "PENDING", + "created_at": "2023-01-01T00:00:00", + "updated_at": "2023-01-01T00:00:00" + } + ], + "total": 1, + "page": 1, + "size": 50 + } + } + } + }, + 400: {"description": "Bad Request"}, + 401: {"description": "Unauthorized"}, + 403: {"description": "Forbidden"}, + 404: {"description": "Not Found"}, + }, +) def read_payments( user_id: Optional[int] = None, start_date: Optional[datetime] = None, @@ -42,9 +78,6 @@ def read_payments( ) -> Page[PaymentHistoryResponse] | MessageResponseSchema: user = retrieve_user_from_token(db, token, jwt_manager) - if user.group.name != UserGroupEnum.ADMIN.value: - return paginate(db.query(Payment).filter_by(user_id=user.id)) - query = db.query(Payment) if user_id: @@ -56,10 +89,35 @@ def read_payments( if payment_status: query = query.filter_by(status=payment_status) + if user.group.name != UserGroupEnum.ADMIN.value: + return paginate(query.filter_by(user_id=user.id)) + return paginate(query) -@router.get("/success") +@router.get( + "/success", + response_model=MessageResponseSchema, + summary="Payment Success", + description="Handles the success of a payment session. " + "Verifies the session ID and updates the " + "payment and order status accordingly.", + responses={ + 200: { + "description": "Payment was successful", + "content": { + "application/json": { + "example": { + "message": "Payment with session_id {session_id}" + " was successful." + } + } + } + }, + 400: {"description": "Bad Request"}, + 404: {"description": "Not Found"}, + }, +) def payment_success( session_id: Annotated[str, Query(max_length=500)], db: Session = Depends(get_db), @@ -106,7 +164,26 @@ def payment_success( handle_stripe_error(e) -@router.get("/cancel") +@router.get( + "/cancel", + summary="Cancel Payment", + description="Cancels a payment session by its session ID. " + "Updates the payment and order status accordingly.", + responses={ + 200: { + "description": "Payment was cancelled successfully", + "content": { + "application/json": { + "example": { + "message": "Payment with session_id {session_id} was cancelled." + } + } + } + }, + 400: {"description": "Bad Request"}, + 404: {"description": "Not Found"}, + } +) def payment_cancel( session_id: Annotated[str, Query(max_length=500)], db: Session = Depends(get_db), @@ -162,7 +239,27 @@ def payment_cancel( ) -@router.post("/refund") +@router.post( + "/refund", + response_model=MessageResponseSchema, + summary="Refund Payment", + description="Refunds a payment for a given order ID. " + "Updates the payment and order status accordingly.", + responses={ + 200: { + "description": "Order was refunded successfully", + "content": { + "application/json": { + "example": { + "message": "Order with id {order_id} was refunded successfully." + } + } + } + }, + 400: {"description": "Bad Request"}, + 404: {"description": "Not Found"}, + } +) def payment_refund( order_id: int, db: Session = Depends(get_db), @@ -182,7 +279,8 @@ def payment_refund( if order.status != OrderStatusEnum.PAID: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Order was already cancelled." + detail="Order was already cancelled or" + " not paid, and cannot be refunded." ) payment = db.query(Payment).filter_by(order_id=order_id).first() @@ -213,7 +311,27 @@ def payment_refund( handle_stripe_error(e) -@router.post("/stripe-webhook") +@router.post( + "/stripe-webhook", + summary="Stripe Webhook", + description="Handles Stripe webhook events, specifically " + "the `checkout.session.completed` event. " + "Verifies the session ID, and sends a success email.", + responses={ + 200: { + "description": "Webhook handled successfully", + "content": { + "application/json": { + "example": { + "message": "Success" + } + } + } + }, + 400: {"description": "Bad Request"}, + 404: {"description": "Not Found"}, + } +) async def stripe_webhook( request: Request, background_tasks: BackgroundTasks, diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py index b6b79c4..a282d4b 100644 --- a/src/schemas/__init__.py +++ b/src/schemas/__init__.py @@ -3,6 +3,7 @@ PaymentHistoryResponse, PaymentSchema, ) +from schemas.payment_types import StripePaymentMethod from schemas.orders import ( MessageResponseSchema, OrderItemResponseSchema, diff --git a/src/schemas/payment_types.py b/src/schemas/payment_types.py new file mode 100644 index 0000000..f5399fc --- /dev/null +++ b/src/schemas/payment_types.py @@ -0,0 +1,48 @@ +from enum import Enum + + +class StripePaymentMethod(Enum): + ACSS_DEBIT = "acss_debit" + AFFIRM = "affirm" + AFTERPAY_CLEARPAY = "afterpay_clearpay" + ALIPAY = "alipay" + ALMA = "alma" + AMAZON_PAY = "amazon_pay" + AU_BECS_DEBIT = "au_becs_debit" + BACS_DEBIT = "bacs_debit" + BANCONTACT = "bancontact" + BLIK = "blik" + BOLETO = "boleto" + CARD = "card" + CASHAPP = "cashapp" + CUSTOMER_BALANCE = "customer_balance" + EPS = "eps" + FPX = "fpx" + GIROPAY = "giropay" + GRABPAY = "grabpay" + IDEAL = "ideal" + KAKAO_PAY = "kakao_pay" + KLARNA = "klarna" + KONBINI = "konbini" + KR_CARD = "kr_card" + LINK = "link" + MOBILEPAY = "mobilepay" + MULTIBANCO = "multibanco" + NAVER_PAY = "naver_pay" + OXXO = "oxxo" + P24 = "p24" + PAY_BY_BANK = "pay_by_bank" + PAYCO = "payco" + PAYNOW = "paynow" + PAYPAL = "paypal" + PIX = "pix" + PROMPTPAY = "promptpay" + REVOLUT_PAY = "revolut_pay" + SAMSUNG_PAY = "samsung_pay" + SEPA_DEBIT = "sepa_debit" + SOFORT = "sofort" + SWISH = "swish" + TWINT = "twint" + US_BANK_ACCOUNT = "us_bank_account" + WECHAT_PAY = "wechat_pay" + ZIP = "zip" diff --git a/src/services/payments.py b/src/services/payments.py index 0d57e35..650313d 100644 --- a/src/services/payments.py +++ b/src/services/payments.py @@ -1,3 +1,5 @@ +from typing import List, Optional + import stripe from fastapi import Request from sqlalchemy.orm import Session @@ -6,7 +8,7 @@ from database import Order, Payment, PaymentStatusEnum from database.crud import create_payment, create_payment_items from exceptions import handle_stripe_error -from schemas.payments import PaymentCreateSchema +from schemas import StripePaymentMethod, PaymentCreateSchema stripe.api_key = get_settings().STRIPE_SECRET_KEY @@ -15,8 +17,23 @@ def create_checkout_session( request: Request, order: Order, user_id: int, - db: Session -) -> str | None: + db: Session, + payment_methods: Optional[List[StripePaymentMethod]] = None, +) -> Optional[str]: + """ + Creates a Stripe checkout session for a given order. + + Args: + request (Request): The FastAPI request object. + order (Order): The order object containing order details. + user_id (int): The ID of the user making the payment. + db (Session): The SQLAlchemy database session. + payment_methods (List[StripePaymentMethod], optional): The list of + payment methods to be accepted. Defaults to None. + Returns: + str | None: The URL of the created Stripe checkout + session, or None if the session couldn't be created. + """ existing_payment = db.query(Payment).filter_by( order_id=order.id, status=PaymentStatusEnum.PENDING.value ).first() @@ -40,9 +57,13 @@ def create_checkout_session( if not total_amount: raise ValueError("Order total amount is invalid") + if not order.order_items: + raise ValueError("Order has no items") + product_data = " ".join( [ - f"{item.movie.name} x {item.price_at_order}" for item in order.order_items + f"|{item.movie.name} x {item.price_at_order}| " + for item in order.order_items ] ) @@ -55,8 +76,14 @@ def create_checkout_session( ) + "?session_id={CHECKOUT_SESSION_ID}" try: + if not payment_methods: + payment_method_types = ( + [method.value for method in payment_methods] + if payment_methods else ["card"] + ) + session = stripe.checkout.Session.create( - payment_method_types=["card"], + payment_method_types=payment_method_types, line_items=[ { "price_data": {