Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 127 additions & 9 deletions src/routes/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
PaymentHistoryResponse,
PaymentSchema,
)
from schemas.payment_types import StripePaymentMethod
from schemas.orders import (
MessageResponseSchema,
OrderItemResponseSchema,
Expand Down
48 changes: 48 additions & 0 deletions src/schemas/payment_types.py
Original file line number Diff line number Diff line change
@@ -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"
37 changes: 32 additions & 5 deletions src/services/payments.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import List, Optional

import stripe
from fastapi import Request
from sqlalchemy.orm import Session
Expand All @@ -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

Expand All @@ -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()
Expand All @@ -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
]
)

Expand All @@ -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": {
Expand Down