Skip to content

Commit 4ae4303

Browse files
committed
feat: add payment module
1 parent faeaa98 commit 4ae4303

21 files changed

Lines changed: 1047 additions & 30 deletions

Makefile

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
.PHONY: help run stop test test-unit test-integration clean migrations
1+
.PHONY: help build run stop test test-unit test-integration clean migrations
22

33
help:
44
@echo "Available commands:"
5+
@echo " build - Build the docker images"
56
@echo " run - Run the application (development with auto-reload, debug mode)"
67
@echo " stop - Stop the application"
78
@echo " test - Run all tests"
@@ -10,8 +11,11 @@ help:
1011
@echo " clean - Clean up Docker containers and volumes"
1112
@echo " migrations msg - Generate alembic migration via Docker (e.g. make migrations msg='add cancelled status')"
1213

14+
build:
15+
docker compose build
16+
1317
run:
14-
docker compose up --build -d
18+
docker compose up -d
1519
@echo "Application running at http://localhost:8000"
1620
@echo "Swagger UI is available at http://localhost:8000/docs"
1721

@@ -25,8 +29,7 @@ test:
2529

2630
test-unit:
2731
@echo "Running unit tests..."
28-
docker build -t python-fastapi-example-oms-app:latest .
29-
docker run --rm --tty -e AUTH_JWT_SECRET_KEY=docker-unit-tests-key python-fastapi-example-oms-app:latest python -m pytest tests/unit/ -v
32+
docker run --rm --tty -e AUTH_JWT_SECRET_KEY=docker-unit-tests-key python-fastapi-example-oms-app:dev python -m pytest tests/unit/ -v
3033

3134
test-integration:
3235
$(MAKE) clean
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""add invoices table
2+
3+
Revision ID: 6e6b7e4f8a18
4+
Revises: 814107d68bb6
5+
Create Date: 2026-04-30 03:03:29.938373
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '6e6b7e4f8a18'
16+
down_revision: Union[str, Sequence[str], None] = '814107d68bb6'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
op.create_table(
24+
'invoices',
25+
sa.Column('id', sa.Integer(), primary_key=True, index=True),
26+
sa.Column('order_id', sa.Integer(), sa.ForeignKey('orders.id'), nullable=False, index=True),
27+
sa.Column('status', sa.Enum('PENDING', 'PAID', 'CANCELLED', name='invoicestatus'), nullable=False, default='PENDING'),
28+
sa.Column('external_invoice_id', sa.String(), nullable=True, index=True),
29+
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
30+
)
31+
32+
33+
def downgrade() -> None:
34+
"""Downgrade schema."""
35+
op.drop_table('invoices')
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""add unique constraint on invoices.order_id
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: 6e6b7e4f8a18
5+
Create Date: 2026-04-30 12:00:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = 'a1b2c3d4e5f6'
16+
down_revision: Union[str, Sequence[str], None] = '6e6b7e4f8a18'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
op.create_unique_constraint('uq_invoices_order_id', 'invoices', ['order_id'])
24+
25+
26+
def downgrade() -> None:
27+
"""Downgrade schema."""
28+
op.drop_constraint('uq_invoices_order_id', 'invoices', type_='unique')
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""add user_id to orders
2+
3+
Revision ID: b2c3d4e5f6a7
4+
Revises: a1b2c3d4e5f6
5+
Create Date: 2026-04-30 12:00:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = 'b2c3d4e5f6a7'
16+
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
op.add_column('orders', sa.Column('user_id', sa.Integer(), nullable=False, server_default='1'))
24+
op.create_foreign_key('fk_orders_user_id', 'orders', 'users', ['user_id'], ['id'])
25+
op.create_index(op.f('ix_orders_user_id'), 'orders', ['user_id'], unique=False)
26+
27+
28+
def downgrade() -> None:
29+
"""Downgrade schema."""
30+
op.drop_index(op.f('ix_orders_user_id'), table_name='orders')
31+
op.drop_constraint('fk_orders_user_id', 'orders', type_='foreignkey')
32+
op.drop_column('orders', 'user_id')

app/api/router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from fastapi import APIRouter
22
from app.modules.orders.routes import router as orders_router
33
from app.modules.users.routes import router as users_router
4+
from app.modules.payment.routes import router as payment_router
45

56
api_router = APIRouter()
67

78
api_router.include_router(orders_router, prefix="/orders", tags=["orders"])
89
api_router.include_router(users_router, prefix="/users", tags=["users"])
10+
api_router.include_router(payment_router, prefix="/payment", tags=["payment"])

app/modules/orders/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from sqlalchemy import Column, Integer, String, Enum
1+
from sqlalchemy import Column, Integer, String, ForeignKey, Enum
22
from app.db.session import Base
33
import enum
44

@@ -16,5 +16,6 @@ class Order(Base):
1616
__tablename__ = "orders"
1717

1818
id = Column(Integer, primary_key=True, index=True)
19+
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
1920
item = Column(String, nullable=False)
2021
status = Column(Enum(OrderStatus), default=OrderStatus.RECEIVED)

app/modules/orders/repository.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ class OrderRepository:
66
def __init__(self, db: Session):
77
self._db = db
88

9-
def create_order(self, item: str):
10-
order = models.Order(item=item)
9+
def create_order(self, user_id: int, item: str):
10+
order = models.Order(user_id=user_id, item=item)
1111
self._db.add(order)
1212
self._db.commit()
1313
self._db.refresh(order)
@@ -16,8 +16,18 @@ def create_order(self, item: str):
1616
def get_order(self, order_id: int):
1717
return self._db.query(models.Order).filter(models.Order.id == order_id).first()
1818

19-
def list_orders(self):
20-
return self._db.query(models.Order).all()
19+
def list_orders(self, user_id: int | None = None, status: str | None = None, search: str | None = None, page: int = 1, page_size: int = 20):
20+
query = self._db.query(models.Order)
21+
if user_id is not None:
22+
query = query.filter(models.Order.user_id == user_id)
23+
if status is not None:
24+
query = query.filter(models.Order.status == status)
25+
if search is not None:
26+
query = query.filter(models.Order.item.ilike(f"%{search}%"))
27+
total = query.count()
28+
offset = (page - 1) * page_size
29+
items = query.order_by(models.Order.id).offset(offset).limit(page_size).all()
30+
return items, total
2131

2232
def update_order(self, order_id: int, **kwargs):
2333
order = self._db.query(models.Order).filter(models.Order.id == order_id).first()

app/modules/orders/routes.py

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
from . import service, schemas, repository
66
from .service import InvalidOrderTransition
77
from app.core.auth import get_current_user
8+
from app.modules.payment import service as payment_service
9+
from app.modules.payment import repository as payment_repo
10+
from app.modules.payment.service import PaymentError
11+
from app.modules.users import repository as user_repo
812

913
router = APIRouter()
1014

@@ -13,57 +17,136 @@ def get_orders_repository(db: Session = Depends(get_db)):
1317
return repository.OrderRepository(db)
1418

1519

20+
def get_payment_repository(db: Session = Depends(get_db)):
21+
return payment_repo.InvoiceRepository(db)
22+
23+
24+
def get_user_repository(db: Session = Depends(get_db)):
25+
return user_repo.UserRepository(db)
26+
27+
1628
@router.post("/", response_model=schemas.OrderRead)
1729
def create_order(
1830
payload: schemas.OrderCreate,
1931
repo: repository.OrderRepository = Depends(get_orders_repository),
20-
_user: str = Depends(get_current_user),
32+
user_repo: user_repo.UserRepository = Depends(get_user_repository),
33+
username: str = Depends(get_current_user),
2134
):
22-
return service.create_order(repo, payload.item)
35+
user = user_repo.get_user_by_username(username)
36+
if not user:
37+
raise HTTPException(status_code=404, detail="User not found")
38+
return service.create_order(repo, user.id, payload.item)
2339

2440

2541
@router.get("/{order_id}", response_model=schemas.OrderRead)
2642
def get_order(
2743
order_id: int,
2844
repo: repository.OrderRepository = Depends(get_orders_repository),
29-
_user: str = Depends(get_current_user),
45+
user_repo: user_repo.UserRepository = Depends(get_user_repository),
46+
username: str = Depends(get_current_user),
3047
):
31-
try:
32-
return service.get_order(repo, order_id)
33-
except ValueError:
48+
order = repo.get_order(order_id)
49+
if not order:
50+
raise HTTPException(status_code=404, detail="Order not found")
51+
user = user_repo.get_user_by_username(username)
52+
if not user:
53+
raise HTTPException(status_code=404, detail="User not found")
54+
if order.user_id != user.id:
3455
raise HTTPException(status_code=404, detail="Order not found")
56+
return order
3557

3658

37-
@router.get("/", response_model=list[schemas.OrderRead])
59+
@router.get("/", response_model=schemas.PaginatedOrders)
3860
def list_orders(
61+
page: int = 1,
62+
page_size: int = 20,
63+
status: str | None = None,
64+
search: str | None = None,
3965
repo: repository.OrderRepository = Depends(get_orders_repository),
40-
_user: str = Depends(get_current_user),
66+
user_repo: user_repo.UserRepository = Depends(get_user_repository),
67+
username: str = Depends(get_current_user),
4168
):
42-
return service.list_orders(repo)
69+
user = user_repo.get_user_by_username(username)
70+
if not user:
71+
raise HTTPException(status_code=404, detail="User not found")
72+
items, total = service.list_orders(repo, user_id=user.id, status=status, search=search, page=page, page_size=page_size)
73+
return {"items": items, "total": total, "page": page, "page_size": page_size}
4374

4475

4576
@router.put("/{order_id}", response_model=schemas.OrderRead)
4677
def update_order(
4778
order_id: int,
4879
payload: schemas.OrderUpdate,
4980
repo: repository.OrderRepository = Depends(get_orders_repository),
50-
_user: str = Depends(get_current_user),
81+
user_repo: user_repo.UserRepository = Depends(get_user_repository),
82+
username: str = Depends(get_current_user),
5183
):
84+
order = repo.get_order(order_id)
85+
if not order:
86+
raise HTTPException(status_code=404, detail="Order not found")
87+
user = user_repo.get_user_by_username(username)
88+
if not user:
89+
raise HTTPException(status_code=404, detail="User not found")
90+
if order.user_id != user.id:
91+
raise HTTPException(status_code=404, detail="Order not found")
5292
try:
5393
updates = {k: v for k, v in payload.model_dump(exclude_unset=True).items()}
5494
return service.update_order(repo, order_id, **updates)
55-
except ValueError:
56-
raise HTTPException(status_code=404, detail="Order not found")
5795
except InvalidOrderTransition as e:
5896
raise HTTPException(status_code=400, detail=str(e))
5997

6098

99+
@router.post("/{order_id}/cancel", response_model=schemas.OrderRead)
100+
def cancel_order(
101+
order_id: int,
102+
repo: repository.OrderRepository = Depends(get_orders_repository),
103+
user_repo: user_repo.UserRepository = Depends(get_user_repository),
104+
payment_repo: payment_repo.InvoiceRepository = Depends(get_payment_repository),
105+
username: str = Depends(get_current_user),
106+
):
107+
order = repo.get_order(order_id)
108+
if not order:
109+
raise HTTPException(status_code=404, detail="Order not found")
110+
user = user_repo.get_user_by_username(username)
111+
if not user:
112+
raise HTTPException(status_code=404, detail="User not found")
113+
if order.user_id != user.id:
114+
raise HTTPException(status_code=404, detail="Order not found")
115+
try:
116+
order = service.cancel_order(repo, order_id)
117+
except ValueError as e:
118+
detail = str(e)
119+
if "not found" in detail:
120+
raise HTTPException(status_code=404, detail=detail)
121+
if "already cancelled" in detail:
122+
raise HTTPException(status_code=400, detail=detail)
123+
if "Cannot cancel" in detail:
124+
raise HTTPException(status_code=400, detail=detail)
125+
raise HTTPException(status_code=400, detail=detail)
126+
127+
try:
128+
payment_service.cancel_invoice_by_order_id(payment_repo, order_id)
129+
except PaymentError as e:
130+
raise HTTPException(status_code=400, detail=str(e))
131+
132+
return order
133+
134+
61135
@router.delete("/{order_id}", status_code=204)
62136
def delete_order(
63137
order_id: int,
64138
repo: repository.OrderRepository = Depends(get_orders_repository),
65-
_user: str = Depends(get_current_user),
139+
user_repo: user_repo.UserRepository = Depends(get_user_repository),
140+
username: str = Depends(get_current_user),
66141
):
142+
order = repo.get_order(order_id)
143+
if not order:
144+
raise HTTPException(status_code=404, detail="Order not found")
145+
user = user_repo.get_user_by_username(username)
146+
if not user:
147+
raise HTTPException(status_code=404, detail="User not found")
148+
if order.user_id != user.id:
149+
raise HTTPException(status_code=404, detail="Order not found")
67150
try:
68151
service.delete_order(repo, order_id)
69152
except ValueError:

app/modules/orders/schemas.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ class OrderUpdate(BaseModel):
1414

1515
class OrderRead(BaseModel):
1616
id: int
17+
user_id: int
1718
item: str
1819
status: OrderStatus
1920

2021
model_config = ConfigDict(from_attributes=True)
22+
23+
24+
class PaginatedOrders(BaseModel):
25+
items: list[OrderRead]
26+
total: int
27+
page: int
28+
page_size: int

0 commit comments

Comments
 (0)