Build comprehensive SaaS invoicing application with FastAPI

- Implemented complete authentication system with JWT tokens
- Created user management with registration and profile endpoints
- Built client management with full CRUD operations
- Developed invoice system with line items and automatic calculations
- Set up SQLite database with proper migrations using Alembic
- Added health monitoring and API documentation
- Configured CORS for cross-origin requests
- Included comprehensive README with usage examples
This commit is contained in:
Automated Action 2025-06-23 14:56:50 +00:00
parent c177c064ad
commit 0fc3927871
31 changed files with 1096 additions and 2 deletions

181
README.md
View File

@ -1,3 +1,180 @@
# FastAPI Application
# SaaS Invoicing Application
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A comprehensive invoicing solution for businesses built with FastAPI and SQLite.
## Features
- **User Authentication**: JWT-based authentication system
- **Client Management**: Create, read, update, and delete clients
- **Invoice Management**: Full CRUD operations for invoices with line items
- **Health Monitoring**: Built-in health check endpoint
- **API Documentation**: Interactive API docs with Swagger UI
## Tech Stack
- **Backend**: FastAPI
- **Database**: SQLite with SQLAlchemy ORM
- **Authentication**: JWT tokens with bcrypt password hashing
- **Migrations**: Alembic
- **Code Quality**: Ruff for linting and formatting
## Project Structure
```
├── main.py # FastAPI application entry point
├── requirements.txt # Python dependencies
├── alembic.ini # Alembic configuration
├── migrations/ # Database migrations
├── app/
│ ├── core/ # Core functionality (auth, config)
│ ├── db/ # Database configuration
│ ├── models/ # SQLAlchemy models
│ ├── routers/ # API route handlers
│ └── schemas/ # Pydantic schemas
```
## Installation
1. Install dependencies:
```bash
pip install -r requirements.txt
```
2. Set up environment variables:
```bash
export SECRET_KEY="your-secret-key-here"
```
3. Run database migrations:
```bash
alembic upgrade head
```
4. Start the application:
```bash
uvicorn main:app --reload
```
## Environment Variables
- `SECRET_KEY`: JWT secret key for token signing (required)
## API Endpoints
### Authentication
- `POST /auth/login` - User login
### Users
- `POST /users/register` - Register new user
- `GET /users/me` - Get current user profile
### Clients
- `POST /clients/` - Create new client
- `GET /clients/` - List all clients
- `GET /clients/{id}` - Get specific client
- `PUT /clients/{id}` - Update client
- `DELETE /clients/{id}` - Delete client
### Invoices
- `POST /invoices/` - Create new invoice
- `GET /invoices/` - List all invoices
- `GET /invoices/{id}` - Get specific invoice
- `PUT /invoices/{id}` - Update invoice
- `DELETE /invoices/{id}` - Delete invoice
### Health
- `GET /health` - Application health check
## API Documentation
Once the application is running, you can access:
- Interactive API docs: http://localhost:8000/docs
- ReDoc documentation: http://localhost:8000/redoc
- OpenAPI JSON: http://localhost:8000/openapi.json
## Database
The application uses SQLite database stored at `/app/storage/db/db.sqlite`. The database includes tables for:
- Users (authentication and profile data)
- Clients (customer information)
- Invoices (invoice headers)
- Invoice Items (line items for invoices)
## Development
### Code Quality
```bash
# Run linting and formatting
ruff check .
ruff format .
```
### Database Migrations
```bash
# Create new migration
alembic revision --autogenerate -m "Description"
# Apply migrations
alembic upgrade head
```
## Usage Example
1. Register a new user:
```bash
curl -X POST "http://localhost:8000/users/register" \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "password123",
"full_name": "John Doe",
"company_name": "ACME Corp"
}'
```
2. Login to get access token:
```bash
curl -X POST "http://localhost:8000/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "password123"
}'
```
3. Create a client (use the token from step 2):
```bash
curl -X POST "http://localhost:8000/clients/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"name": "Client Company",
"email": "client@example.com",
"phone": "+1234567890",
"address": "123 Main St, City, State 12345"
}'
```
4. Create an invoice:
```bash
curl -X POST "http://localhost:8000/invoices/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"invoice_number": "INV-001",
"client_id": 1,
"due_date": "2024-02-01T00:00:00",
"tax_rate": 8.5,
"items": [
{
"description": "Web Development Services",
"quantity": 40,
"unit_price": 75.00
}
]
}'
```
## License
This project is licensed under the MIT License.

41
alembic.ini Normal file
View File

@ -0,0 +1,41 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

0
app/__init__.py Normal file
View File

0
app/core/__init__.py Normal file
View File

12
app/core/config.py Normal file
View File

@ -0,0 +1,12 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here-change-in-production")
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ALGORITHM: str = "HS256"
class Config:
env_file = ".env"
settings = Settings()

59
app/core/security.py Normal file
View File

@ -0,0 +1,59 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_token(token: str) -> Optional[str]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
email: str = payload.get("sub")
if email is None:
return None
return email
except JWTError:
return None
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
email = verify_token(credentials.credentials)
if email is None:
raise credentials_exception
user = db.query(User).filter(User.email == email).first()
if user is None:
raise credentials_exception
return user

0
app/db/__init__.py Normal file
View File

3
app/db/base.py Normal file
View File

@ -0,0 +1,3 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

22
app/db/session.py Normal file
View File

@ -0,0 +1,22 @@
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

4
app/models/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from .user import User
from .client import Client
from .invoice import Invoice
from .invoice_item import InvoiceItem

20
app/models/client.py Normal file
View File

@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime
from app.db.base import Base
class Client(Base):
__tablename__ = "clients"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
email = Column(String, nullable=False)
phone = Column(String)
address = Column(Text)
tax_number = Column(String)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
owner = relationship("User", back_populates="clients")
invoices = relationship("Invoice", back_populates="client")

35
app/models/invoice.py Normal file
View File

@ -0,0 +1,35 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Text, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
from enum import Enum as PyEnum
from app.db.base import Base
class InvoiceStatus(PyEnum):
DRAFT = "draft"
SENT = "sent"
PAID = "paid"
OVERDUE = "overdue"
CANCELLED = "cancelled"
class Invoice(Base):
__tablename__ = "invoices"
id = Column(Integer, primary_key=True, index=True)
invoice_number = Column(String, unique=True, nullable=False, index=True)
client_id = Column(Integer, ForeignKey("clients.id"), nullable=False)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
issue_date = Column(DateTime, default=datetime.utcnow)
due_date = Column(DateTime, nullable=False)
subtotal = Column(Float, default=0.0)
tax_rate = Column(Float, default=0.0)
tax_amount = Column(Float, default=0.0)
total_amount = Column(Float, default=0.0)
status = Column(Enum(InvoiceStatus), default=InvoiceStatus.DRAFT)
notes = Column(Text)
payment_terms = Column(String, default="Net 30")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
client = relationship("Client", back_populates="invoices")
owner = relationship("User", back_populates="invoices")
items = relationship("InvoiceItem", back_populates="invoice", cascade="all, delete-orphan")

View File

@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, Float, ForeignKey, Text
from sqlalchemy.orm import relationship
from app.db.base import Base
class InvoiceItem(Base):
__tablename__ = "invoice_items"
id = Column(Integer, primary_key=True, index=True)
invoice_id = Column(Integer, ForeignKey("invoices.id"), nullable=False)
description = Column(String, nullable=False)
quantity = Column(Float, nullable=False, default=1.0)
unit_price = Column(Float, nullable=False)
total_price = Column(Float, nullable=False)
notes = Column(Text)
invoice = relationship("Invoice", back_populates="items")

19
app/models/user.py Normal file
View File

@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, nullable=False)
company_name = Column(String)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
clients = relationship("Client", back_populates="owner")
invoices = relationship("Invoice", back_populates="owner")

0
app/routers/__init__.py Normal file
View File

36
app/routers/auth.py Normal file
View File

@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import timedelta
from app.db.session import get_db
from app.core.security import verify_password, create_access_token
from app.core.config import settings
from app.models.user import User
from app.schemas.user import Token, UserLogin
router = APIRouter()
@router.post("/login", response_model=Token)
async def login_for_access_token(
user_login: UserLogin,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.email == user_login.email).first()
if not user or not verify_password(user_login.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

76
app/routers/clients.py Normal file
View File

@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.db.session import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.client import Client
from app.schemas.client import ClientCreate, ClientUpdate, ClientResponse
router = APIRouter()
@router.post("/", response_model=ClientResponse, status_code=status.HTTP_201_CREATED)
async def create_client(
client: ClientCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
db_client = Client(**client.dict(), owner_id=current_user.id)
db.add(db_client)
db.commit()
db.refresh(db_client)
return db_client
@router.get("/", response_model=List[ClientResponse])
async def read_clients(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
clients = db.query(Client).filter(Client.owner_id == current_user.id).offset(skip).limit(limit).all()
return clients
@router.get("/{client_id}", response_model=ClientResponse)
async def read_client(
client_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
client = db.query(Client).filter(Client.id == client_id, Client.owner_id == current_user.id).first()
if client is None:
raise HTTPException(status_code=404, detail="Client not found")
return client
@router.put("/{client_id}", response_model=ClientResponse)
async def update_client(
client_id: int,
client_update: ClientUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
client = db.query(Client).filter(Client.id == client_id, Client.owner_id == current_user.id).first()
if client is None:
raise HTTPException(status_code=404, detail="Client not found")
update_data = client_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(client, field, value)
db.commit()
db.refresh(client)
return client
@router.delete("/{client_id}")
async def delete_client(
client_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
client = db.query(Client).filter(Client.id == client_id, Client.owner_id == current_user.id).first()
if client is None:
raise HTTPException(status_code=404, detail="Client not found")
db.delete(client)
db.commit()
return {"message": "Client deleted successfully"}

20
app/routers/health.py Normal file
View File

@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.db.session import get_db
router = APIRouter()
@router.get("/health")
async def health_check(db: Session = Depends(get_db)):
try:
db.execute(text("SELECT 1"))
database_status = "healthy"
except Exception:
database_status = "unhealthy"
return {
"status": "healthy" if database_status == "healthy" else "unhealthy",
"database": database_status,
"service": "SaaS Invoicing Application"
}

144
app/routers/invoices.py Normal file
View File

@ -0,0 +1,144 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
from app.db.session import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.client import Client
from app.models.invoice import Invoice
from app.models.invoice_item import InvoiceItem
from app.schemas.invoice import InvoiceCreate, InvoiceUpdate, InvoiceResponse
router = APIRouter()
def calculate_invoice_totals(items: List[InvoiceItem], tax_rate: float = 0.0):
subtotal = sum(item.total_price for item in items)
tax_amount = subtotal * (tax_rate / 100)
total_amount = subtotal + tax_amount
return subtotal, tax_amount, total_amount
@router.post("/", response_model=InvoiceResponse, status_code=status.HTTP_201_CREATED)
async def create_invoice(
invoice: InvoiceCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
client = db.query(Client).filter(Client.id == invoice.client_id, Client.owner_id == current_user.id).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
existing_invoice = db.query(Invoice).filter(Invoice.invoice_number == invoice.invoice_number).first()
if existing_invoice:
raise HTTPException(status_code=400, detail="Invoice number already exists")
db_invoice = Invoice(
**invoice.dict(exclude={'items'}),
owner_id=current_user.id
)
db.add(db_invoice)
db.flush()
invoice_items = []
for item_data in invoice.items:
total_price = item_data.quantity * item_data.unit_price
db_item = InvoiceItem(
**item_data.dict(),
invoice_id=db_invoice.id,
total_price=total_price
)
db.add(db_item)
invoice_items.append(db_item)
db.flush()
subtotal, tax_amount, total_amount = calculate_invoice_totals(invoice_items, invoice.tax_rate)
db_invoice.subtotal = subtotal
db_invoice.tax_amount = tax_amount
db_invoice.total_amount = total_amount
db.commit()
db.refresh(db_invoice)
return db_invoice
@router.get("/", response_model=List[InvoiceResponse])
async def read_invoices(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
invoices = db.query(Invoice).filter(Invoice.owner_id == current_user.id).offset(skip).limit(limit).all()
return invoices
@router.get("/{invoice_id}", response_model=InvoiceResponse)
async def read_invoice(
invoice_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
invoice = db.query(Invoice).filter(Invoice.id == invoice_id, Invoice.owner_id == current_user.id).first()
if invoice is None:
raise HTTPException(status_code=404, detail="Invoice not found")
return invoice
@router.put("/{invoice_id}", response_model=InvoiceResponse)
async def update_invoice(
invoice_id: int,
invoice_update: InvoiceUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
invoice = db.query(Invoice).filter(Invoice.id == invoice_id, Invoice.owner_id == current_user.id).first()
if invoice is None:
raise HTTPException(status_code=404, detail="Invoice not found")
update_data = invoice_update.dict(exclude_unset=True, exclude={'items'})
if invoice_update.client_id:
client = db.query(Client).filter(Client.id == invoice_update.client_id, Client.owner_id == current_user.id).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
for field, value in update_data.items():
setattr(invoice, field, value)
if invoice_update.items is not None:
db.query(InvoiceItem).filter(InvoiceItem.invoice_id == invoice_id).delete()
invoice_items = []
for item_data in invoice_update.items:
total_price = item_data.quantity * item_data.unit_price
db_item = InvoiceItem(
**item_data.dict(),
invoice_id=invoice.id,
total_price=total_price
)
db.add(db_item)
invoice_items.append(db_item)
db.flush()
subtotal, tax_amount, total_amount = calculate_invoice_totals(invoice_items, invoice.tax_rate)
invoice.subtotal = subtotal
invoice.tax_amount = tax_amount
invoice.total_amount = total_amount
invoice.updated_at = datetime.utcnow()
db.commit()
db.refresh(invoice)
return invoice
@router.delete("/{invoice_id}")
async def delete_invoice(
invoice_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
invoice = db.query(Invoice).filter(Invoice.id == invoice_id, Invoice.owner_id == current_user.id).first()
if invoice is None:
raise HTTPException(status_code=404, detail="Invoice not found")
db.delete(invoice)
db.commit()
return {"message": "Invoice deleted successfully"}

35
app/routers/users.py Normal file
View File

@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.security import get_password_hash, get_current_user
from app.models.user import User
from app.schemas.user import UserCreate, UserResponse
router = APIRouter()
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register_user(user: UserCreate, db: Session = Depends(get_db)):
db_user = db.query(User).filter(User.email == user.email).first()
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
hashed_password = get_password_hash(user.password)
db_user = User(
email=user.email,
hashed_password=hashed_password,
full_name=user.full_name,
company_name=user.company_name
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.get("/me", response_model=UserResponse)
async def read_users_me(current_user: User = Depends(get_current_user)):
return current_user

4
app/schemas/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from .user import UserCreate, UserResponse, UserLogin, Token
from .client import ClientCreate, ClientUpdate, ClientResponse
from .invoice import InvoiceCreate, InvoiceUpdate, InvoiceResponse
from .invoice_item import InvoiceItemCreate, InvoiceItemUpdate, InvoiceItemResponse

29
app/schemas/client.py Normal file
View File

@ -0,0 +1,29 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class ClientBase(BaseModel):
name: str
email: EmailStr
phone: Optional[str] = None
address: Optional[str] = None
tax_number: Optional[str] = None
class ClientCreate(ClientBase):
pass
class ClientUpdate(BaseModel):
name: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
tax_number: Optional[str] = None
class ClientResponse(ClientBase):
id: int
owner_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

41
app/schemas/invoice.py Normal file
View File

@ -0,0 +1,41 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from app.models.invoice import InvoiceStatus
from app.schemas.invoice_item import InvoiceItemResponse, InvoiceItemCreate
class InvoiceBase(BaseModel):
invoice_number: str
client_id: int
due_date: datetime
tax_rate: float = 0.0
notes: Optional[str] = None
payment_terms: str = "Net 30"
class InvoiceCreate(InvoiceBase):
items: List[InvoiceItemCreate] = []
class InvoiceUpdate(BaseModel):
invoice_number: Optional[str] = None
client_id: Optional[int] = None
due_date: Optional[datetime] = None
tax_rate: Optional[float] = None
status: Optional[InvoiceStatus] = None
notes: Optional[str] = None
payment_terms: Optional[str] = None
items: Optional[List[InvoiceItemCreate]] = None
class InvoiceResponse(InvoiceBase):
id: int
owner_id: int
issue_date: datetime
subtotal: float
tax_amount: float
total_amount: float
status: InvoiceStatus
created_at: datetime
updated_at: datetime
items: List[InvoiceItemResponse] = []
class Config:
from_attributes = True

View File

@ -0,0 +1,25 @@
from pydantic import BaseModel
from typing import Optional
class InvoiceItemBase(BaseModel):
description: str
quantity: float = 1.0
unit_price: float
notes: Optional[str] = None
class InvoiceItemCreate(InvoiceItemBase):
pass
class InvoiceItemUpdate(BaseModel):
description: Optional[str] = None
quantity: Optional[float] = None
unit_price: Optional[float] = None
notes: Optional[str] = None
class InvoiceItemResponse(InvoiceItemBase):
id: int
invoice_id: int
total_price: float
class Config:
from_attributes = True

28
app/schemas/user.py Normal file
View File

@ -0,0 +1,28 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
email: EmailStr
full_name: str
company_name: Optional[str] = None
class UserCreate(UserBase):
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserResponse(UserBase):
id: int
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str

36
main.py Normal file
View File

@ -0,0 +1,36 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import auth, users, clients, invoices, health
from app.db.session import engine
from app.db.base import Base
app = FastAPI(
title="SaaS Invoicing Application",
description="A comprehensive invoicing solution for businesses",
version="1.0.0",
openapi_url="/openapi.json"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Base.metadata.create_all(bind=engine)
app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
app.include_router(users.router, prefix="/users", tags=["Users"])
app.include_router(clients.router, prefix="/clients", tags=["Clients"])
app.include_router(invoices.router, prefix="/invoices", tags=["Invoices"])
app.include_router(health.router, tags=["Health"])
@app.get("/")
async def root():
return {
"title": "SaaS Invoicing Application",
"documentation": "/docs",
"health_check": "/health"
}

51
migrations/env.py Normal file
View File

@ -0,0 +1,51 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import sys
import os
from pathlib import Path
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
from app.db.base import Base
from app.models import user, client, invoice, invoice_item
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# 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() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,102 @@
"""Initial migration
Revision ID: 001
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create users table
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=False),
sa.Column('company_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
# Create clients table
op.create_table('clients',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('phone', sa.String(), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('tax_number', sa.String(), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_clients_id'), 'clients', ['id'], unique=False)
op.create_index(op.f('ix_clients_name'), 'clients', ['name'], unique=False)
# Create invoices table
op.create_table('invoices',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('invoice_number', sa.String(), nullable=False),
sa.Column('client_id', sa.Integer(), nullable=False),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('issue_date', sa.DateTime(), nullable=True),
sa.Column('due_date', sa.DateTime(), nullable=False),
sa.Column('subtotal', sa.Float(), nullable=True),
sa.Column('tax_rate', sa.Float(), nullable=True),
sa.Column('tax_amount', sa.Float(), nullable=True),
sa.Column('total_amount', sa.Float(), nullable=True),
sa.Column('status', sa.Enum('DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED', name='invoicestatus'), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('payment_terms', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_invoices_id'), 'invoices', ['id'], unique=False)
op.create_index(op.f('ix_invoices_invoice_number'), 'invoices', ['invoice_number'], unique=True)
# Create invoice_items table
op.create_table('invoice_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('invoice_id', sa.Integer(), nullable=False),
sa.Column('description', sa.String(), nullable=False),
sa.Column('quantity', sa.Float(), nullable=False),
sa.Column('unit_price', sa.Float(), nullable=False),
sa.Column('total_price', sa.Float(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_invoice_items_id'), 'invoice_items', ['id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_invoice_items_id'), table_name='invoice_items')
op.drop_table('invoice_items')
op.drop_index(op.f('ix_invoices_invoice_number'), table_name='invoices')
op.drop_index(op.f('ix_invoices_id'), table_name='invoices')
op.drop_table('invoices')
op.drop_index(op.f('ix_clients_name'), table_name='clients')
op.drop_index(op.f('ix_clients_id'), table_name='clients')
op.drop_table('clients')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')

25
pyproject.toml Normal file
View File

@ -0,0 +1,25 @@
[tool.ruff]
line-length = 88
target-version = "py39"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long, handled by black
"B008", # do not perform function calls in argument defaults
"C901", # too complex
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
[tool.ruff.lint.isort]
known-third-party = ["fastapi", "pydantic", "sqlalchemy"]

10
requirements.txt Normal file
View File

@ -0,0 +1,10 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
alembic==1.12.1
pydantic==2.5.0
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
ruff==0.1.6