Implement SaaS invoicing application with FastAPI and SQLite

This commit is contained in:
Automated Action 2025-06-05 11:12:51 +00:00
parent 02f717fccd
commit 715c0ab0cf
52 changed files with 2886 additions and 2 deletions

157
README.md
View File

@ -1,3 +1,156 @@
# FastAPI Application
# SaaS Invoicing Application
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A SaaS invoicing application backend built with FastAPI and SQLite. This application provides a complete solution for managing customers, products/services, invoices, and payments with user authentication and authorization.
## Features
- **User Management**: Registration, authentication, and profile management
- **Customer Management**: Store and manage customer information
- **Product/Service Catalog**: Manage your products and services with pricing
- **Invoice Management**: Create, update, and manage invoices
- **PDF Invoice Generation**: Generate professional PDF invoices
- **Payment Tracking**: Record and track payments for invoices
- **Multi-tenant Architecture**: Each user has their own isolated data
## Technology Stack
- **Backend**: FastAPI
- **Database**: SQLite with SQLAlchemy ORM
- **Authentication**: JWT tokens
- **PDF Generation**: WeasyPrint
- **Migrations**: Alembic
- **Validation**: Pydantic
## Getting Started
### Prerequisites
- Python 3.8+
- Pip package manager
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd saasinvoicingapplication
```
2. Set up a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Set up environment variables (or create a `.env` file):
```
SECRET_KEY=your-secret-key # Change this in production!
```
5. Run database migrations:
```bash
alembic upgrade head
```
### Running the Application
Start the application with Uvicorn:
```bash
uvicorn main:app --reload
```
The API will be available at [http://localhost:8000](http://localhost:8000), and the interactive documentation at [http://localhost:8000/docs](http://localhost:8000/docs).
## API Documentation
The API is fully documented with OpenAPI and is available at the `/docs` endpoint. The main endpoints include:
- **Authentication**: `/api/v1/auth/*`
- Register: POST `/api/v1/auth/register`
- Login: POST `/api/v1/auth/login`
- JSON Login: POST `/api/v1/auth/login/json`
- **Users**: `/api/v1/users/*`
- Get current user: GET `/api/v1/users/me`
- Update current user: PATCH `/api/v1/users/me`
- **Customers**: `/api/v1/customers/*`
- List customers: GET `/api/v1/customers`
- Create customer: POST `/api/v1/customers`
- Get customer: GET `/api/v1/customers/{customer_id}`
- Update customer: PATCH `/api/v1/customers/{customer_id}`
- Delete customer: DELETE `/api/v1/customers/{customer_id}`
- **Products**: `/api/v1/products/*`
- List products: GET `/api/v1/products`
- Create product: POST `/api/v1/products`
- Get product: GET `/api/v1/products/{product_id}`
- Update product: PATCH `/api/v1/products/{product_id}`
- Delete product: DELETE `/api/v1/products/{product_id}`
- **Invoices**: `/api/v1/invoices/*`
- List invoices: GET `/api/v1/invoices`
- Create invoice: POST `/api/v1/invoices`
- Get invoice: GET `/api/v1/invoices/{invoice_id}`
- Update invoice: PATCH `/api/v1/invoices/{invoice_id}`
- Delete invoice: DELETE `/api/v1/invoices/{invoice_id}`
- Update status: PATCH `/api/v1/invoices/{invoice_id}/status`
- Generate PDF: GET `/api/v1/invoices/{invoice_id}/pdf`
- **Payments**: `/api/v1/payments/*`
- List payments: GET `/api/v1/payments`
- List invoice payments: GET `/api/v1/payments/invoice/{invoice_id}`
- Create payment: POST `/api/v1/payments`
- Get payment: GET `/api/v1/payments/{payment_id}`
- Update payment: PATCH `/api/v1/payments/{payment_id}`
- Delete payment: DELETE `/api/v1/payments/{payment_id}`
## Environment Variables
The following environment variables can be configured:
- `SECRET_KEY`: Secret key for JWT token encryption (required)
- `ACCESS_TOKEN_EXPIRE_MINUTES`: Token expiration time in minutes (default: 60 * 24 * 7)
- `PROJECT_NAME`: Application name (default: "SaaS Invoicing Application")
- `BACKEND_CORS_ORIGINS`: CORS origins allowed (default: "*")
## Project Structure
```
├── alembic.ini # Alembic configuration
├── app/ # Application package
│ ├── api/ # API endpoints
│ │ └── v1/ # API version 1
│ ├── core/ # Core functionality
│ ├── crud/ # CRUD operations
│ ├── db/ # Database setup
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ ├── services/ # Business logic services
│ ├── templates/ # HTML templates for PDF generation
│ └── utils/ # Utility functions
├── migrations/ # Alembic migrations
│ ├── versions/ # Migration scripts
│ └── env.py # Migration environment
├── main.py # Application entry point
└── requirements.txt # Project dependencies
```
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.

85
alembic.ini Normal file
View File

@ -0,0 +1,85 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# SQLite URL - using absolute path as required
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks=black
# black.type=console_scripts
# black.entrypoint=black
# black.options=-l 79
# Logging configuration
[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/api/__init__.py Normal file
View File

0
app/api/v1/__init__.py Normal file
View File

13
app/api/v1/api.py Normal file
View File

@ -0,0 +1,13 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, customers, products, invoices, payments
api_router = APIRouter()
# Include all routers
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(customers.router, prefix="/customers", tags=["Customers"])
api_router.include_router(products.router, prefix="/products", tags=["Products"])
api_router.include_router(invoices.router, prefix="/invoices", tags=["Invoices"])
api_router.include_router(payments.router, prefix="/payments", tags=["Payments"])

View File

View File

@ -0,0 +1,88 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.auth import authenticate_user
from app.core.config import settings
from app.core.security import create_access_token
from app.crud import user as crud_user
from app.db.session import get_db
from app.schemas.auth import Token, Login
from app.schemas.user import User, UserCreate
router = APIRouter()
@router.post("/register", response_model=User, status_code=status.HTTP_201_CREATED)
def register(*, db: Session = Depends(get_db), user_in: UserCreate) -> Any:
"""
Register a new user.
"""
# Check if user with this email already exists
user = crud_user.get_user_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists",
)
# Create new user
user = crud_user.create_user(db, user_in=user_in)
return user
@router.post("/login", response_model=Token)
def login(
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = authenticate_user(db, email=form_data.username, password=form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
elif not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user"
)
# Create access token
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
subject=user.id, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/login/json", response_model=Token)
def login_json(*, db: Session = Depends(get_db), login_in: Login) -> Any:
"""
JSON compatible login, get an access token for future requests.
"""
user = authenticate_user(db, email=login_in.email, password=login_in.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
elif not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user"
)
# Create access token
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
subject=user.id, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

View File

@ -0,0 +1,114 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.auth import get_current_active_user
from app.crud import customer as crud_customer
from app.db.session import get_db
from app.models.user import User
from app.schemas.customer import Customer, CustomerCreate, CustomerUpdate
router = APIRouter()
@router.get("", response_model=List[Customer])
def read_customers(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Retrieve customers for the current user.
"""
customers = crud_customer.get_customers(
db, user_id=current_user.id, skip=skip, limit=limit
)
return customers
@router.post("", response_model=Customer, status_code=status.HTTP_201_CREATED)
def create_customer(
*,
db: Session = Depends(get_db),
customer_in: CustomerCreate,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Create new customer for the current user.
"""
customer = crud_customer.create_customer(
db, user_id=current_user.id, customer_in=customer_in
)
return customer
@router.get("/{customer_id}", response_model=Customer)
def read_customer(
*,
db: Session = Depends(get_db),
customer_id: int,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Get customer by ID.
"""
customer = crud_customer.get_customer(
db, user_id=current_user.id, customer_id=customer_id
)
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found",
)
return customer
@router.patch("/{customer_id}", response_model=Customer)
def update_customer(
*,
db: Session = Depends(get_db),
customer_id: int,
customer_in: CustomerUpdate,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Update a customer.
"""
customer = crud_customer.get_customer(
db, user_id=current_user.id, customer_id=customer_id
)
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found",
)
customer = crud_customer.update_customer(
db, user_id=current_user.id, db_customer=customer, customer_in=customer_in
)
return customer
@router.delete("/{customer_id}", response_model=Customer)
def delete_customer(
*,
db: Session = Depends(get_db),
customer_id: int,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Delete a customer.
"""
customer = crud_customer.get_customer(
db, user_id=current_user.id, customer_id=customer_id
)
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found",
)
customer = crud_customer.delete_customer(
db, user_id=current_user.id, customer_id=customer_id
)
return customer

View File

@ -0,0 +1,180 @@
import os
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.core.auth import get_current_active_user
from app.crud import invoice as crud_invoice
from app.db.session import get_db
from app.models.invoice import InvoiceStatus
from app.models.user import User
from app.schemas.invoice import Invoice, InvoiceCreate, InvoiceUpdate
from app.services.invoice_generator import InvoiceGenerator
router = APIRouter()
@router.get("", response_model=List[Invoice])
def read_invoices(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
status: Optional[InvoiceStatus] = None,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Retrieve invoices for the current user.
"""
invoices = crud_invoice.get_invoices(
db, user_id=current_user.id, skip=skip, limit=limit, status=status
)
return invoices
@router.post("", response_model=Invoice, status_code=status.HTTP_201_CREATED)
def create_invoice(
*,
db: Session = Depends(get_db),
invoice_in: InvoiceCreate,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Create new invoice for the current user.
"""
invoice = crud_invoice.create_invoice(
db, user_id=current_user.id, invoice_in=invoice_in
)
return invoice
@router.get("/{invoice_id}", response_model=Invoice)
def read_invoice(
*,
db: Session = Depends(get_db),
invoice_id: int,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Get invoice by ID.
"""
invoice = crud_invoice.get_invoice(
db, user_id=current_user.id, invoice_id=invoice_id
)
if not invoice:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
return invoice
@router.patch("/{invoice_id}", response_model=Invoice)
def update_invoice(
*,
db: Session = Depends(get_db),
invoice_id: int,
invoice_in: InvoiceUpdate,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Update an invoice.
"""
invoice = crud_invoice.get_invoice(
db, user_id=current_user.id, invoice_id=invoice_id
)
if not invoice:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
invoice = crud_invoice.update_invoice(
db, user_id=current_user.id, db_invoice=invoice, invoice_in=invoice_in
)
return invoice
@router.delete("/{invoice_id}", response_model=Invoice)
def delete_invoice(
*,
db: Session = Depends(get_db),
invoice_id: int,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Delete an invoice.
"""
invoice = crud_invoice.get_invoice(
db, user_id=current_user.id, invoice_id=invoice_id
)
if not invoice:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
invoice = crud_invoice.delete_invoice(
db, user_id=current_user.id, invoice_id=invoice_id
)
return invoice
@router.patch("/{invoice_id}/status", response_model=Invoice)
def update_invoice_status(
*,
db: Session = Depends(get_db),
invoice_id: int,
status: InvoiceStatus,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Update an invoice status.
"""
invoice = crud_invoice.get_invoice(
db, user_id=current_user.id, invoice_id=invoice_id
)
if not invoice:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
invoice = crud_invoice.update_invoice_status(
db, user_id=current_user.id, invoice_id=invoice_id, status=status
)
return invoice
@router.get("/{invoice_id}/pdf")
def generate_invoice_pdf(
*,
db: Session = Depends(get_db),
invoice_id: int,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Generate a PDF for an invoice.
"""
invoice = crud_invoice.get_invoice(
db, user_id=current_user.id, invoice_id=invoice_id
)
if not invoice:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
# Generate PDF
generator = InvoiceGenerator()
pdf_path = generator.generate_invoice_pdf(invoice)
if not pdf_path or not os.path.exists(pdf_path):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate invoice PDF",
)
# Return the PDF as a file
return FileResponse(
pdf_path,
media_type="application/pdf",
filename=f"invoice_{invoice.invoice_number}.pdf"
)

View File

@ -0,0 +1,138 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.auth import get_current_active_user
from app.crud import payment as crud_payment
from app.db.session import get_db
from app.models.user import User
from app.schemas.payment import Payment, PaymentCreate, PaymentUpdate
router = APIRouter()
@router.get("", response_model=List[Payment])
def read_payments(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Retrieve payments for the current user.
"""
payments = crud_payment.get_payments(
db, user_id=current_user.id, skip=skip, limit=limit
)
return payments
@router.get("/invoice/{invoice_id}", response_model=List[Payment])
def read_payments_for_invoice(
*,
db: Session = Depends(get_db),
invoice_id: int,
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Retrieve payments for a specific invoice.
"""
payments = crud_payment.get_payments_for_invoice(
db, user_id=current_user.id, invoice_id=invoice_id, skip=skip, limit=limit
)
return payments
@router.post("", response_model=Payment, status_code=status.HTTP_201_CREATED)
def create_payment(
*,
db: Session = Depends(get_db),
payment_in: PaymentCreate,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Create new payment for the current user.
"""
try:
payment = crud_payment.create_payment(
db, user_id=current_user.id, payment_in=payment_in
)
return payment
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.get("/{payment_id}", response_model=Payment)
def read_payment(
*,
db: Session = Depends(get_db),
payment_id: int,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Get payment by ID.
"""
payment = crud_payment.get_payment(
db, user_id=current_user.id, payment_id=payment_id
)
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found",
)
return payment
@router.patch("/{payment_id}", response_model=Payment)
def update_payment(
*,
db: Session = Depends(get_db),
payment_id: int,
payment_in: PaymentUpdate,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Update a payment.
"""
payment = crud_payment.get_payment(
db, user_id=current_user.id, payment_id=payment_id
)
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found",
)
payment = crud_payment.update_payment(
db, user_id=current_user.id, db_payment=payment, payment_in=payment_in
)
return payment
@router.delete("/{payment_id}", response_model=Payment)
def delete_payment(
*,
db: Session = Depends(get_db),
payment_id: int,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Delete a payment.
"""
payment = crud_payment.get_payment(
db, user_id=current_user.id, payment_id=payment_id
)
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found",
)
payment = crud_payment.delete_payment(
db, user_id=current_user.id, payment_id=payment_id
)
return payment

View File

@ -0,0 +1,114 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.auth import get_current_active_user
from app.crud import product as crud_product
from app.db.session import get_db
from app.models.user import User
from app.schemas.product import Product, ProductCreate, ProductUpdate
router = APIRouter()
@router.get("", response_model=List[Product])
def read_products(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Retrieve products for the current user.
"""
products = crud_product.get_products(
db, user_id=current_user.id, skip=skip, limit=limit
)
return products
@router.post("", response_model=Product, status_code=status.HTTP_201_CREATED)
def create_product(
*,
db: Session = Depends(get_db),
product_in: ProductCreate,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Create new product for the current user.
"""
product = crud_product.create_product(
db, user_id=current_user.id, product_in=product_in
)
return product
@router.get("/{product_id}", response_model=Product)
def read_product(
*,
db: Session = Depends(get_db),
product_id: int,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Get product by ID.
"""
product = crud_product.get_product(
db, user_id=current_user.id, product_id=product_id
)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
return product
@router.patch("/{product_id}", response_model=Product)
def update_product(
*,
db: Session = Depends(get_db),
product_id: int,
product_in: ProductUpdate,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Update a product.
"""
product = crud_product.get_product(
db, user_id=current_user.id, product_id=product_id
)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
product = crud_product.update_product(
db, user_id=current_user.id, db_product=product, product_in=product_in
)
return product
@router.delete("/{product_id}", response_model=Product)
def delete_product(
*,
db: Session = Depends(get_db),
product_id: int,
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
Delete a product.
"""
product = crud_product.get_product(
db, user_id=current_user.id, product_id=product_id
)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
product = crud_product.delete_product(
db, user_id=current_user.id, product_id=product_id
)
return product

View File

@ -0,0 +1,134 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.auth import get_current_active_user, get_current_active_superuser
from app.crud import user as crud_user
from app.db.session import get_db
from app.models.user import User as UserModel
from app.schemas.user import User, UserCreate, UserUpdate
router = APIRouter()
@router.get("/me", response_model=User)
def read_user_me(current_user: UserModel = Depends(get_current_active_user)) -> Any:
"""
Get current user.
"""
return current_user
@router.patch("/me", response_model=User)
def update_user_me(
*,
db: Session = Depends(get_db),
user_in: UserUpdate,
current_user: UserModel = Depends(get_current_active_user)
) -> Any:
"""
Update current user.
"""
user = crud_user.update_user(db, db_user=current_user, user_in=user_in)
return user
@router.get("", response_model=List[User])
def read_users(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: UserModel = Depends(get_current_active_superuser)
) -> Any:
"""
Retrieve users. Only for superusers.
"""
users = crud_user.get_users(db, skip=skip, limit=limit)
return users
@router.post("", response_model=User, status_code=status.HTTP_201_CREATED)
def create_user(
*,
db: Session = Depends(get_db),
user_in: UserCreate,
current_user: UserModel = Depends(get_current_active_superuser)
) -> Any:
"""
Create new user. Only for superusers.
"""
user = crud_user.get_user_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists",
)
user = crud_user.create_user(db, user_in=user_in)
return user
@router.get("/{user_id}", response_model=User)
def read_user_by_id(
user_id: int,
db: Session = Depends(get_db),
current_user: UserModel = Depends(get_current_active_user)
) -> Any:
"""
Get a specific user by id.
Regular users can only get their own user.
Superusers can get any user.
"""
user = crud_user.get_user(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if user.id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges",
)
return user
@router.patch("/{user_id}", response_model=User)
def update_user(
*,
db: Session = Depends(get_db),
user_id: int,
user_in: UserUpdate,
current_user: UserModel = Depends(get_current_active_superuser)
) -> Any:
"""
Update a user. Only for superusers.
"""
user = crud_user.get_user(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user = crud_user.update_user(db, db_user=user, user_in=user_in)
return user
@router.delete("/{user_id}", response_model=User)
def delete_user(
*,
db: Session = Depends(get_db),
user_id: int,
current_user: UserModel = Depends(get_current_active_superuser)
) -> Any:
"""
Delete a user. Only for superusers.
"""
user = crud_user.get_user(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user = crud_user.delete_user(db, user_id=user_id)
return user

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

74
app/core/auth.py Normal file
View File

@ -0,0 +1,74 @@
from datetime import datetime
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import verify_password
from app.crud.user import get_user_by_email
from app.db.session import get_db
from app.models.user import User
from app.schemas.auth import TokenPayload
# OAuth2 scheme for token authentication
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
"""Authenticate a user by email and password."""
user = get_user_by_email(db, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> User:
"""Get the current authenticated user from the token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
token_data = TokenPayload(**payload)
if token_data.sub is None:
raise credentials_exception
# Check token expiration
if datetime.fromtimestamp(payload.get("exp")) < datetime.now():
raise credentials_exception
except (JWTError, ValidationError):
raise credentials_exception
user = db.query(User).filter(User.id == token_data.sub).first()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
"""Get the current active user."""
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def get_current_active_superuser(current_user: User = Depends(get_current_user)) -> User:
"""Get the current active superuser."""
if not current_user.is_superuser:
raise HTTPException(
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user

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

@ -0,0 +1,43 @@
from typing import List, Union
from pathlib import Path
from pydantic import AnyHttpUrl, validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Base project settings
PROJECT_NAME: str = "SaaS Invoicing Application"
PROJECT_DESCRIPTION: str = "A SaaS invoicing application backend API"
PROJECT_VERSION: str = "0.1.0"
API_V1_STR: str = "/api/v1"
# CORS settings
BACKEND_CORS_ORIGINS: List[Union[str, AnyHttpUrl]] = ["*"]
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
# Security settings
SECRET_KEY: str = "CHANGE_ME_IN_PRODUCTION" # For JWT token generation
ALGORITHM: str = "HS256" # For JWT token algorithm
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# Database settings
DB_DIR: Path = Path("/app") / "storage" / "db"
# Set default values for env variables
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()
# Ensure database directory exists
settings.DB_DIR.mkdir(parents=True, exist_ok=True)

70
app/core/exceptions.py Normal file
View File

@ -0,0 +1,70 @@
from typing import Any, Dict, Optional
from fastapi import HTTPException, status
class InvoiceAppException(HTTPException):
"""Base exception for the invoicing application."""
def __init__(
self,
status_code: int,
detail: Any = None,
headers: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(status_code=status_code, detail=detail, headers=headers)
class NotFoundException(InvoiceAppException):
"""Exception raised when a resource is not found."""
def __init__(
self,
detail: Any = "Resource not found",
headers: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
headers=headers,
)
class UnauthorizedException(InvoiceAppException):
"""Exception raised when user is not authenticated."""
def __init__(
self,
detail: Any = "Not authenticated",
headers: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers=headers or {"WWW-Authenticate": "Bearer"},
)
class ForbiddenException(InvoiceAppException):
"""Exception raised when user does not have permission."""
def __init__(
self,
detail: Any = "Not enough permissions",
headers: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=detail,
headers=headers,
)
class BadRequestException(InvoiceAppException):
"""Exception raised for bad requests."""
def __init__(
self,
detail: Any = "Bad request",
headers: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail,
headers=headers,
)

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

@ -0,0 +1,45 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Token creation and verification
def create_access_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
"""
Create a JWT access token for authentication.
Args:
subject: Subject to encode in the token (usually user ID)
expires_delta: Token expiration time
Returns:
JWT token as string
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify that the plain password matches the hashed one."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password for storing."""
return pwd_context.hash(password)

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

69
app/crud/customer.py Normal file
View File

@ -0,0 +1,69 @@
from typing import Any, Dict, List, Optional, Union
from sqlalchemy.orm import Session
from app.models.customer import Customer
from app.schemas.customer import CustomerCreate, CustomerUpdate
def get_customer(db: Session, user_id: int, customer_id: int) -> Optional[Customer]:
"""Get a customer by ID for a specific user."""
return db.query(Customer).filter(
Customer.id == customer_id, Customer.user_id == user_id
).first()
def get_customers(
db: Session, user_id: int, skip: int = 0, limit: int = 100
) -> List[Customer]:
"""Get a list of customers for a specific user."""
return db.query(Customer).filter(Customer.user_id == user_id).offset(skip).limit(limit).all()
def create_customer(db: Session, user_id: int, customer_in: CustomerCreate) -> Customer:
"""Create a new customer for a specific user."""
customer_data = customer_in.model_dump()
db_customer = Customer(**customer_data, user_id=user_id)
db.add(db_customer)
db.commit()
db.refresh(db_customer)
return db_customer
def update_customer(
db: Session,
user_id: int,
db_customer: Customer,
customer_in: Union[CustomerUpdate, Dict[str, Any]]
) -> Customer:
"""Update a customer."""
customer_data = db_customer.to_dict()
if isinstance(customer_in, dict):
update_data = customer_in
else:
update_data = customer_in.model_dump(exclude_unset=True)
# Update customer fields
for field in customer_data:
if field in update_data:
setattr(db_customer, field, update_data[field])
db.add(db_customer)
db.commit()
db.refresh(db_customer)
return db_customer
def delete_customer(db: Session, user_id: int, customer_id: int) -> Optional[Customer]:
"""Delete a customer."""
customer = db.query(Customer).filter(
Customer.id == customer_id, Customer.user_id == user_id
).first()
if not customer:
return None
db.delete(customer)
db.commit()
return customer

160
app/crud/invoice.py Normal file
View File

@ -0,0 +1,160 @@
from typing import Any, Dict, List, Optional, Union
from decimal import Decimal
from sqlalchemy.orm import Session, joinedload
from app.models.invoice import Invoice, InvoiceItem, InvoiceStatus
from app.models.product import Product
from app.schemas.invoice import InvoiceCreate, InvoiceUpdate, InvoiceItemCreate
def get_invoice(db: Session, user_id: int, invoice_id: int) -> Optional[Invoice]:
"""Get an invoice by ID for a specific user."""
return db.query(Invoice).filter(
Invoice.id == invoice_id, Invoice.user_id == user_id
).options(
joinedload(Invoice.items)
).first()
def get_invoices(
db: Session, user_id: int, skip: int = 0, limit: int = 100, status: Optional[InvoiceStatus] = None
) -> List[Invoice]:
"""Get a list of invoices for a specific user."""
query = db.query(Invoice).filter(Invoice.user_id == user_id)
if status:
query = query.filter(Invoice.status == status)
return query.order_by(Invoice.created_at.desc()).offset(skip).limit(limit).all()
def _calculate_invoice_item(item_in: InvoiceItemCreate, product: Optional[Product] = None) -> Dict[str, Any]:
"""Calculate invoice item totals."""
unit_price = item_in.unit_price
tax_rate = item_in.tax_rate
# If a product is specified, use its price and tax rate
if product:
unit_price = product.price
tax_rate = product.tax_rate
# Calculate subtotal
subtotal = unit_price * item_in.quantity
# Calculate tax amount
tax_amount = subtotal * (tax_rate / 100)
# Calculate total
total = subtotal + tax_amount
return {
"description": item_in.description,
"quantity": item_in.quantity,
"unit_price": unit_price,
"tax_rate": tax_rate,
"tax_amount": tax_amount,
"subtotal": subtotal,
"total": total,
"product_id": item_in.product_id
}
def create_invoice(db: Session, user_id: int, invoice_in: InvoiceCreate) -> Invoice:
"""Create a new invoice for a specific user."""
# Get base invoice data
invoice_data = invoice_in.model_dump(exclude={"items"})
# Create invoice
db_invoice = Invoice(**invoice_data, user_id=user_id, subtotal=0, tax_amount=0, total=0)
db.add(db_invoice)
db.flush() # Flush to get the invoice ID
# Create invoice items
subtotal = Decimal("0.0")
tax_amount = Decimal("0.0")
total = Decimal("0.0")
for item_in in invoice_in.items:
# Get product if specified
product = None
if item_in.product_id:
product = db.query(Product).filter(
Product.id == item_in.product_id, Product.user_id == user_id
).first()
# Calculate item totals
item_data = _calculate_invoice_item(item_in, product)
# Add to invoice totals
subtotal += item_data["subtotal"]
tax_amount += item_data["tax_amount"]
total += item_data["total"]
# Create invoice item
db_item = InvoiceItem(**item_data, invoice_id=db_invoice.id)
db.add(db_item)
# Update invoice totals
db_invoice.subtotal = subtotal
db_invoice.tax_amount = tax_amount
db_invoice.total = total
db.commit()
db.refresh(db_invoice)
return db_invoice
def update_invoice(
db: Session,
user_id: int,
db_invoice: Invoice,
invoice_in: Union[InvoiceUpdate, Dict[str, Any]]
) -> Invoice:
"""Update an invoice."""
invoice_data = db_invoice.to_dict()
if isinstance(invoice_in, dict):
update_data = invoice_in
else:
update_data = invoice_in.model_dump(exclude_unset=True)
# Update invoice fields
for field in invoice_data:
if field in update_data:
setattr(db_invoice, field, update_data[field])
db.add(db_invoice)
db.commit()
db.refresh(db_invoice)
return db_invoice
def delete_invoice(db: Session, user_id: int, invoice_id: int) -> Optional[Invoice]:
"""Delete an invoice."""
invoice = db.query(Invoice).filter(
Invoice.id == invoice_id, Invoice.user_id == user_id
).first()
if not invoice:
return None
db.delete(invoice)
db.commit()
return invoice
def update_invoice_status(
db: Session, user_id: int, invoice_id: int, status: InvoiceStatus
) -> Optional[Invoice]:
"""Update an invoice status."""
invoice = db.query(Invoice).filter(
Invoice.id == invoice_id, Invoice.user_id == user_id
).first()
if not invoice:
return None
invoice.status = status
db.add(invoice)
db.commit()
db.refresh(invoice)
return invoice

136
app/crud/payment.py Normal file
View File

@ -0,0 +1,136 @@
from typing import Any, Dict, List, Optional, Union
from sqlalchemy.orm import Session
from app.models.payment import Payment
from app.models.invoice import Invoice, InvoiceStatus
from app.schemas.payment import PaymentCreate, PaymentUpdate
def get_payment(db: Session, user_id: int, payment_id: int) -> Optional[Payment]:
"""Get a payment by ID for a specific user."""
return db.query(Payment).join(Invoice).filter(
Payment.id == payment_id, Invoice.user_id == user_id
).first()
def get_payments_for_invoice(
db: Session, user_id: int, invoice_id: int, skip: int = 0, limit: int = 100
) -> List[Payment]:
"""Get a list of payments for a specific invoice."""
return db.query(Payment).join(Invoice).filter(
Payment.invoice_id == invoice_id, Invoice.user_id == user_id
).offset(skip).limit(limit).all()
def get_payments(
db: Session, user_id: int, skip: int = 0, limit: int = 100
) -> List[Payment]:
"""Get a list of all payments for a user."""
return db.query(Payment).join(Invoice).filter(
Invoice.user_id == user_id
).order_by(Payment.payment_date.desc()).offset(skip).limit(limit).all()
def create_payment(db: Session, user_id: int, payment_in: PaymentCreate) -> Payment:
"""Create a new payment."""
# Get the invoice to ensure it belongs to the user
invoice = db.query(Invoice).filter(
Invoice.id == payment_in.invoice_id, Invoice.user_id == user_id
).first()
if not invoice:
raise ValueError("Invoice not found or does not belong to the user")
# Create payment
payment_data = payment_in.model_dump()
db_payment = Payment(**payment_data)
db.add(db_payment)
# Calculate total paid amount for the invoice
existing_payments = db.query(Payment).filter(Payment.invoice_id == invoice.id).all()
total_paid = sum(payment.amount for payment in existing_payments) + payment_in.amount
# Update invoice status if fully paid
if total_paid >= invoice.total:
invoice.status = InvoiceStatus.PAID
elif invoice.status == InvoiceStatus.DRAFT:
# If invoice is in draft, move to sent when payment is received
invoice.status = InvoiceStatus.SENT
db.commit()
db.refresh(db_payment)
return db_payment
def update_payment(
db: Session,
user_id: int,
db_payment: Payment,
payment_in: Union[PaymentUpdate, Dict[str, Any]]
) -> Payment:
"""Update a payment."""
payment_data = db_payment.to_dict()
if isinstance(payment_in, dict):
update_data = payment_in
else:
update_data = payment_in.model_dump(exclude_unset=True)
# Update payment fields
for field in payment_data:
if field in update_data:
setattr(db_payment, field, update_data[field])
db.add(db_payment)
# Get the invoice
invoice = db.query(Invoice).filter(Invoice.id == db_payment.invoice_id).first()
# Calculate total paid amount for the invoice
existing_payments = db.query(Payment).filter(Payment.invoice_id == invoice.id).all()
total_paid = sum(payment.amount for payment in existing_payments)
# Update invoice status based on payment
if total_paid >= invoice.total:
invoice.status = InvoiceStatus.PAID
elif invoice.status == InvoiceStatus.PAID:
# If previously fully paid but now partially paid
invoice.status = InvoiceStatus.SENT
db.commit()
db.refresh(db_payment)
return db_payment
def delete_payment(db: Session, user_id: int, payment_id: int) -> Optional[Payment]:
"""Delete a payment."""
payment = db.query(Payment).join(Invoice).filter(
Payment.id == payment_id, Invoice.user_id == user_id
).first()
if not payment:
return None
# Get the invoice
invoice = db.query(Invoice).filter(Invoice.id == payment.invoice_id).first()
# Delete the payment
db.delete(payment)
# Calculate total paid amount for the invoice after deletion
existing_payments = db.query(Payment).filter(
Payment.invoice_id == invoice.id, Payment.id != payment_id
).all()
total_paid = sum(payment.amount for payment in existing_payments)
# Update invoice status based on payment
if total_paid >= invoice.total:
invoice.status = InvoiceStatus.PAID
elif invoice.status == InvoiceStatus.PAID:
# If previously fully paid but now partially paid
invoice.status = InvoiceStatus.SENT
db.commit()
return payment

69
app/crud/product.py Normal file
View File

@ -0,0 +1,69 @@
from typing import Any, Dict, List, Optional, Union
from sqlalchemy.orm import Session
from app.models.product import Product
from app.schemas.product import ProductCreate, ProductUpdate
def get_product(db: Session, user_id: int, product_id: int) -> Optional[Product]:
"""Get a product by ID for a specific user."""
return db.query(Product).filter(
Product.id == product_id, Product.user_id == user_id
).first()
def get_products(
db: Session, user_id: int, skip: int = 0, limit: int = 100
) -> List[Product]:
"""Get a list of products for a specific user."""
return db.query(Product).filter(Product.user_id == user_id).offset(skip).limit(limit).all()
def create_product(db: Session, user_id: int, product_in: ProductCreate) -> Product:
"""Create a new product for a specific user."""
product_data = product_in.model_dump()
db_product = Product(**product_data, user_id=user_id)
db.add(db_product)
db.commit()
db.refresh(db_product)
return db_product
def update_product(
db: Session,
user_id: int,
db_product: Product,
product_in: Union[ProductUpdate, Dict[str, Any]]
) -> Product:
"""Update a product."""
product_data = db_product.to_dict()
if isinstance(product_in, dict):
update_data = product_in
else:
update_data = product_in.model_dump(exclude_unset=True)
# Update product fields
for field in product_data:
if field in update_data:
setattr(db_product, field, update_data[field])
db.add(db_product)
db.commit()
db.refresh(db_product)
return db_product
def delete_product(db: Session, user_id: int, product_id: int) -> Optional[Product]:
"""Delete a product."""
product = db.query(Product).filter(
Product.id == product_id, Product.user_id == user_id
).first()
if not product:
return None
db.delete(product)
db.commit()
return product

78
app/crud/user.py Normal file
View File

@ -0,0 +1,78 @@
from typing import Any, Dict, Optional, Union
from sqlalchemy.orm import Session
from app.core.security import get_password_hash
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
def get_user(db: Session, user_id: int) -> Optional[User]:
"""Get a user by ID."""
return db.query(User).filter(User.id == user_id).first()
def get_user_by_email(db: Session, email: str) -> Optional[User]:
"""Get a user by email."""
return db.query(User).filter(User.email == email).first()
def get_users(db: Session, skip: int = 0, limit: int = 100) -> list[User]:
"""Get a list of users."""
return db.query(User).offset(skip).limit(limit).all()
def create_user(db: Session, user_in: UserCreate) -> User:
"""Create a new user."""
db_user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
full_name=user_in.full_name,
company_name=user_in.company_name,
address=user_in.address,
phone_number=user_in.phone_number,
is_active=user_in.is_active,
is_superuser=user_in.is_superuser,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def update_user(
db: Session, *, db_user: User, user_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
"""Update a user."""
user_data = db_user.to_dict()
if isinstance(user_in, dict):
update_data = user_in
else:
update_data = user_in.model_dump(exclude_unset=True)
# Handle password update
if "password" in update_data and update_data["password"]:
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
# Update user fields
for field in user_data:
if field in update_data:
setattr(db_user, field, update_data[field])
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def delete_user(db: Session, *, user_id: int) -> Optional[User]:
"""Delete a user."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return None
db.delete(user)
db.commit()
return user

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

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

@ -0,0 +1,28 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create database URL
SQLALCHEMY_DATABASE_URL = f"sqlite:///{settings.DB_DIR}/db.sqlite"
# Create SQLAlchemy engine
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} # Needed for SQLite
)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create base class for database models
Base = declarative_base()
# Dependency for getting database session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,16 @@
from app.models.user import User as User
from app.models.customer import Customer as Customer
from app.models.product import Product as Product
from app.models.invoice import Invoice as Invoice, InvoiceItem as InvoiceItem, InvoiceStatus as InvoiceStatus
from app.models.payment import Payment as Payment, PaymentMethod as PaymentMethod
__all__ = [
"User",
"Customer",
"Product",
"Invoice",
"InvoiceItem",
"InvoiceStatus",
"Payment",
"PaymentMethod",
]

25
app/models/base.py Normal file
View File

@ -0,0 +1,25 @@
from datetime import datetime
from typing import Any, Dict
from sqlalchemy import Column, DateTime, Integer
from sqlalchemy.ext.declarative import declared_attr
class Base:
"""Base class for all database models."""
@declared_attr
def __tablename__(cls) -> str:
"""Generate __tablename__ automatically from class name."""
return cls.__name__.lower()
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def to_dict(self) -> Dict[str, Any]:
"""Convert model instance to dictionary."""
return {
column.name: getattr(self, column.name)
for column in self.__table__.columns
}

23
app/models/customer.py Normal file
View File

@ -0,0 +1,23 @@
from sqlalchemy import Column, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.db.session import Base
from app.models.base import Base as CustomBase
class Customer(Base, CustomBase):
"""Customer model for managing client information."""
name = Column(String(255), nullable=False)
email = Column(String(255), nullable=True)
phone = Column(String(50), nullable=True)
address = Column(Text, nullable=True)
tax_id = Column(String(50), nullable=True)
notes = Column(Text, nullable=True)
# Foreign keys
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
# Relationships
user = relationship("User", back_populates="customers")
invoices = relationship("Invoice", back_populates="customer", cascade="all, delete-orphan")

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

@ -0,0 +1,61 @@
from datetime import date
from sqlalchemy import Column, Date, ForeignKey, Integer, Numeric, String, Text, Enum
from sqlalchemy.orm import relationship
import enum
from app.db.session import Base
from app.models.base import Base as CustomBase
class InvoiceStatus(str, enum.Enum):
"""Enum for invoice statuses."""
DRAFT = "draft"
SENT = "sent"
PAID = "paid"
OVERDUE = "overdue"
CANCELLED = "cancelled"
class Invoice(Base, CustomBase):
"""Invoice model for managing invoices."""
invoice_number = Column(String(50), nullable=False, index=True)
status = Column(Enum(InvoiceStatus), default=InvoiceStatus.DRAFT)
issue_date = Column(Date, nullable=False, default=date.today)
due_date = Column(Date, nullable=False)
subtotal = Column(Numeric(10, 2), nullable=False, default=0.00)
tax_amount = Column(Numeric(10, 2), nullable=False, default=0.00)
discount = Column(Numeric(10, 2), nullable=False, default=0.00)
total = Column(Numeric(10, 2), nullable=False, default=0.00)
notes = Column(Text, nullable=True)
terms = Column(Text, nullable=True)
# Foreign keys
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
customer_id = Column(Integer, ForeignKey("customer.id"), nullable=False)
# Relationships
user = relationship("User", back_populates="invoices")
customer = relationship("Customer", back_populates="invoices")
items = relationship("InvoiceItem", back_populates="invoice", cascade="all, delete-orphan")
payments = relationship("Payment", back_populates="invoice", cascade="all, delete-orphan")
class InvoiceItem(Base, CustomBase):
"""Model for items within an invoice."""
description = Column(String(255), nullable=False)
quantity = Column(Numeric(10, 2), nullable=False, default=1.00)
unit_price = Column(Numeric(10, 2), nullable=False)
tax_rate = Column(Numeric(5, 2), nullable=False, default=0.00)
tax_amount = Column(Numeric(10, 2), nullable=False, default=0.00)
subtotal = Column(Numeric(10, 2), nullable=False)
total = Column(Numeric(10, 2), nullable=False)
# Foreign keys
invoice_id = Column(Integer, ForeignKey("invoice.id"), nullable=False)
product_id = Column(Integer, ForeignKey("product.id"), nullable=True)
# Relationships
invoice = relationship("Invoice", back_populates="items")
product = relationship("Product", back_populates="invoice_items")

33
app/models/payment.py Normal file
View File

@ -0,0 +1,33 @@
from datetime import date
from sqlalchemy import Column, Date, ForeignKey, Integer, Numeric, String, Text, Enum
from sqlalchemy.orm import relationship
import enum
from app.db.session import Base
from app.models.base import Base as CustomBase
class PaymentMethod(str, enum.Enum):
"""Enum for payment methods."""
CREDIT_CARD = "credit_card"
BANK_TRANSFER = "bank_transfer"
CASH = "cash"
CHECK = "check"
PAYPAL = "paypal"
OTHER = "other"
class Payment(Base, CustomBase):
"""Payment model for tracking invoice payments."""
amount = Column(Numeric(10, 2), nullable=False)
payment_date = Column(Date, nullable=False, default=date.today)
payment_method = Column(Enum(PaymentMethod), nullable=False)
reference = Column(String(255), nullable=True)
notes = Column(Text, nullable=True)
# Foreign keys
invoice_id = Column(Integer, ForeignKey("invoice.id"), nullable=False)
# Relationships
invoice = relationship("Invoice", back_populates="payments")

23
app/models/product.py Normal file
View File

@ -0,0 +1,23 @@
from sqlalchemy import Column, ForeignKey, Integer, Numeric, String, Text, Boolean
from sqlalchemy.orm import relationship
from app.db.session import Base
from app.models.base import Base as CustomBase
class Product(Base, CustomBase):
"""Product model for managing products and services."""
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
price = Column(Numeric(10, 2), nullable=False)
sku = Column(String(50), nullable=True)
is_service = Column(Boolean, default=False)
tax_rate = Column(Numeric(5, 2), default=0.00)
# Foreign keys
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
# Relationships
user = relationship("User", back_populates="products")
invoice_items = relationship("InvoiceItem", back_populates="product", cascade="all, delete-orphan")

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

@ -0,0 +1,23 @@
from sqlalchemy import Boolean, Column, String, Text
from sqlalchemy.orm import relationship
from app.db.session import Base
from app.models.base import Base as CustomBase
class User(Base, CustomBase):
"""User model for authentication and authorization."""
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(255), nullable=True)
company_name = Column(String(255), nullable=True)
address = Column(Text, nullable=True)
phone_number = Column(String(50), nullable=True)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
# Relationships
customers = relationship("Customer", back_populates="user", cascade="all, delete-orphan")
products = relationship("Product", back_populates="user", cascade="all, delete-orphan")
invoices = relationship("Invoice", back_populates="user", cascade="all, delete-orphan")

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

@ -0,0 +1,38 @@
from app.schemas.auth import Token as Token, TokenPayload as TokenPayload, Login as Login
from app.schemas.user import User as User, UserCreate as UserCreate, UserUpdate as UserUpdate, UserInDB as UserInDB
from app.schemas.customer import Customer as Customer, CustomerCreate as CustomerCreate, CustomerUpdate as CustomerUpdate
from app.schemas.product import Product as Product, ProductCreate as ProductCreate, ProductUpdate as ProductUpdate
from app.schemas.invoice import (
Invoice as Invoice,
InvoiceCreate as InvoiceCreate,
InvoiceUpdate as InvoiceUpdate,
InvoiceItem as InvoiceItem,
InvoiceItemCreate as InvoiceItemCreate,
InvoiceItemUpdate as InvoiceItemUpdate
)
from app.schemas.payment import Payment as Payment, PaymentCreate as PaymentCreate, PaymentUpdate as PaymentUpdate
__all__ = [
"Token",
"TokenPayload",
"Login",
"User",
"UserCreate",
"UserUpdate",
"UserInDB",
"Customer",
"CustomerCreate",
"CustomerUpdate",
"Product",
"ProductCreate",
"ProductUpdate",
"Invoice",
"InvoiceCreate",
"InvoiceUpdate",
"InvoiceItem",
"InvoiceItemCreate",
"InvoiceItemUpdate",
"Payment",
"PaymentCreate",
"PaymentUpdate",
]

20
app/schemas/auth.py Normal file
View File

@ -0,0 +1,20 @@
from typing import Optional
from pydantic import BaseModel, EmailStr, Field
class Token(BaseModel):
"""Schema for authentication token."""
access_token: str
token_type: str
class TokenPayload(BaseModel):
"""Schema for token payload."""
sub: Optional[int] = None
class Login(BaseModel):
"""Schema for login credentials."""
email: EmailStr
password: str = Field(..., min_length=8, max_length=100)

19
app/schemas/base.py Normal file
View File

@ -0,0 +1,19 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class BaseSchema(BaseModel):
"""Base schema with common configurations."""
model_config = ConfigDict(from_attributes=True)
class BaseSchemaID(BaseSchema):
"""Base schema with ID."""
id: int
class BaseSchemaIDTimestamps(BaseSchemaID):
"""Base schema with ID and timestamps."""
created_at: datetime
updated_at: datetime

40
app/schemas/customer.py Normal file
View File

@ -0,0 +1,40 @@
from typing import Optional
from pydantic import EmailStr
from app.schemas.base import BaseSchema, BaseSchemaIDTimestamps
class CustomerBase(BaseSchema):
"""Base schema for customer."""
name: str
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
tax_id: Optional[str] = None
notes: Optional[str] = None
class CustomerCreate(CustomerBase):
"""Schema for creating a customer."""
pass
class CustomerUpdate(BaseSchema):
"""Schema for updating a customer."""
name: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
tax_id: Optional[str] = None
notes: Optional[str] = None
class CustomerInDBBase(CustomerBase, BaseSchemaIDTimestamps):
"""Base schema for customer in database."""
user_id: int
class Customer(CustomerInDBBase):
"""Schema for customer."""
pass

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

@ -0,0 +1,84 @@
from datetime import date
from typing import List, Optional
from pydantic import Field, condecimal
from app.models.invoice import InvoiceStatus
from app.schemas.base import BaseSchema, BaseSchemaIDTimestamps
class InvoiceItemBase(BaseSchema):
"""Base schema for invoice item."""
description: str
quantity: condecimal(ge=0, decimal_places=2) = Field(1, description="Quantity")
unit_price: condecimal(ge=0, decimal_places=2) = Field(..., description="Unit price")
tax_rate: condecimal(ge=0, decimal_places=2) = Field(0, description="Tax rate in percentage")
product_id: Optional[int] = None
class InvoiceItemCreate(InvoiceItemBase):
"""Schema for creating an invoice item."""
pass
class InvoiceItemUpdate(BaseSchema):
"""Schema for updating an invoice item."""
description: Optional[str] = None
quantity: Optional[condecimal(ge=0, decimal_places=2)] = None
unit_price: Optional[condecimal(ge=0, decimal_places=2)] = None
tax_rate: Optional[condecimal(ge=0, decimal_places=2)] = None
product_id: Optional[int] = None
class InvoiceItemInDBBase(InvoiceItemBase, BaseSchemaIDTimestamps):
"""Base schema for invoice item in database."""
invoice_id: int
tax_amount: condecimal(ge=0, decimal_places=2)
subtotal: condecimal(ge=0, decimal_places=2)
total: condecimal(ge=0, decimal_places=2)
class InvoiceItem(InvoiceItemInDBBase):
"""Schema for invoice item."""
pass
class InvoiceBase(BaseSchema):
"""Base schema for invoice."""
invoice_number: str
status: InvoiceStatus = InvoiceStatus.DRAFT
issue_date: date = Field(default_factory=date.today)
due_date: date
notes: Optional[str] = None
terms: Optional[str] = None
customer_id: int
class InvoiceCreate(InvoiceBase):
"""Schema for creating an invoice."""
items: List[InvoiceItemCreate]
class InvoiceUpdate(BaseSchema):
"""Schema for updating an invoice."""
invoice_number: Optional[str] = None
status: Optional[InvoiceStatus] = None
issue_date: Optional[date] = None
due_date: Optional[date] = None
notes: Optional[str] = None
terms: Optional[str] = None
customer_id: Optional[int] = None
class InvoiceInDBBase(InvoiceBase, BaseSchemaIDTimestamps):
"""Base schema for invoice in database."""
user_id: int
subtotal: condecimal(ge=0, decimal_places=2)
tax_amount: condecimal(ge=0, decimal_places=2)
discount: condecimal(ge=0, decimal_places=2) = 0
total: condecimal(ge=0, decimal_places=2)
class Invoice(InvoiceInDBBase):
"""Schema for invoice."""
items: List[InvoiceItem]

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

@ -0,0 +1,41 @@
from datetime import date
from typing import Optional
from pydantic import Field, condecimal
from app.models.payment import PaymentMethod
from app.schemas.base import BaseSchema, BaseSchemaIDTimestamps
class PaymentBase(BaseSchema):
"""Base schema for payment."""
amount: condecimal(ge=0, decimal_places=2) = Field(..., description="Payment amount")
payment_date: date = Field(default_factory=date.today)
payment_method: PaymentMethod
reference: Optional[str] = None
notes: Optional[str] = None
invoice_id: int
class PaymentCreate(PaymentBase):
"""Schema for creating a payment."""
pass
class PaymentUpdate(BaseSchema):
"""Schema for updating a payment."""
amount: Optional[condecimal(ge=0, decimal_places=2)] = None
payment_date: Optional[date] = None
payment_method: Optional[PaymentMethod] = None
reference: Optional[str] = None
notes: Optional[str] = None
class PaymentInDBBase(PaymentBase, BaseSchemaIDTimestamps):
"""Base schema for payment in database."""
pass
class Payment(PaymentInDBBase):
"""Schema for payment."""
pass

40
app/schemas/product.py Normal file
View File

@ -0,0 +1,40 @@
from typing import Optional
from pydantic import Field, condecimal
from app.schemas.base import BaseSchema, BaseSchemaIDTimestamps
class ProductBase(BaseSchema):
"""Base schema for product."""
name: str
description: Optional[str] = None
price: condecimal(ge=0, decimal_places=2) = Field(..., description="Product price")
sku: Optional[str] = None
is_service: bool = False
tax_rate: condecimal(ge=0, decimal_places=2) = Field(0, description="Tax rate in percentage")
class ProductCreate(ProductBase):
"""Schema for creating a product."""
pass
class ProductUpdate(BaseSchema):
"""Schema for updating a product."""
name: Optional[str] = None
description: Optional[str] = None
price: Optional[condecimal(ge=0, decimal_places=2)] = None
sku: Optional[str] = None
is_service: Optional[bool] = None
tax_rate: Optional[condecimal(ge=0, decimal_places=2)] = None
class ProductInDBBase(ProductBase, BaseSchemaIDTimestamps):
"""Base schema for product in database."""
user_id: int
class Product(ProductInDBBase):
"""Schema for product."""
pass

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

@ -0,0 +1,48 @@
from typing import Optional
from pydantic import EmailStr, Field
from app.schemas.base import BaseSchema, BaseSchemaIDTimestamps
class UserBase(BaseSchema):
"""Base schema for user."""
email: EmailStr
full_name: Optional[str] = None
company_name: Optional[str] = None
address: Optional[str] = None
phone_number: Optional[str] = None
is_active: bool = True
is_superuser: bool = False
class UserCreate(UserBase):
"""Schema for creating a user."""
password: str = Field(..., min_length=8, max_length=100)
class UserUpdate(BaseSchema):
"""Schema for updating a user."""
email: Optional[EmailStr] = None
full_name: Optional[str] = None
company_name: Optional[str] = None
address: Optional[str] = None
phone_number: Optional[str] = None
password: Optional[str] = Field(None, min_length=8, max_length=100)
is_active: Optional[bool] = None
is_superuser: Optional[bool] = None
class UserInDBBase(UserBase, BaseSchemaIDTimestamps):
"""Base schema for user in database."""
pass
class User(UserInDBBase):
"""Schema for user without sensitive data."""
pass
class UserInDB(UserInDBBase):
"""Schema for user in database with hashed password."""
hashed_password: str

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

View File

@ -0,0 +1,232 @@
from datetime import datetime
from pathlib import Path
from typing import Optional
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
from app.models.invoice import Invoice
class InvoiceGenerator:
"""Service for generating invoice PDFs."""
def __init__(self):
# Set up templates directory
self.templates_dir = Path(__file__).parent.parent / "templates"
self.templates_dir.mkdir(exist_ok=True)
# Set up output directory
self.output_dir = Path("/app/storage/invoices")
self.output_dir.mkdir(parents=True, exist_ok=True)
# Set up Jinja2 environment
self.env = Environment(
loader=FileSystemLoader(str(self.templates_dir)),
autoescape=True
)
def _ensure_template_exists(self):
"""Ensure the invoice template exists."""
template_path = self.templates_dir / "invoice.html"
if not template_path.exists():
# Create a simple invoice template
template_content = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Invoice {{ invoice.invoice_number }}</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
color: #333;
}
.invoice-header {
margin-bottom: 30px;
}
.invoice-header h1 {
color: #2c3e50;
}
.row {
display: flex;
margin-bottom: 20px;
}
.col {
flex: 1;
}
.invoice-details {
margin-bottom: 30px;
}
.invoice-details h3 {
margin-bottom: 10px;
color: #2c3e50;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f5f5f5;
}
.text-right {
text-align: right;
}
.totals {
margin-left: auto;
width: 300px;
}
.totals table {
margin-bottom: 0;
}
.footer {
margin-top: 50px;
text-align: center;
color: #7f8c8d;
font-size: 12px;
}
</style>
</head>
<body>
<div class="invoice-header">
<h1>INVOICE</h1>
</div>
<div class="row">
<div class="col">
<div class="invoice-details">
<h3>From</h3>
<p>{{ user.company_name or user.full_name }}</p>
<p>{{ user.address }}</p>
<p>{{ user.email }}</p>
<p>{{ user.phone_number }}</p>
</div>
</div>
<div class="col">
<div class="invoice-details">
<h3>To</h3>
<p>{{ customer.name }}</p>
<p>{{ customer.address }}</p>
<p>{{ customer.email }}</p>
<p>{{ customer.phone }}</p>
</div>
</div>
<div class="col">
<div class="invoice-details">
<h3>Invoice Details</h3>
<p><strong>Invoice Number:</strong> {{ invoice.invoice_number }}</p>
<p><strong>Issue Date:</strong> {{ invoice.issue_date.strftime('%d %b %Y') }}</p>
<p><strong>Due Date:</strong> {{ invoice.due_date.strftime('%d %b %Y') }}</p>
<p><strong>Status:</strong> {{ invoice.status.value.upper() }}</p>
</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Tax Rate</th>
<th>Tax Amount</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for item in invoice.items %}
<tr>
<td>{{ item.description }}</td>
<td>{{ item.quantity }}</td>
<td>${{ "%.2f"|format(item.unit_price) }}</td>
<td>{{ "%.2f"|format(item.tax_rate) }}%</td>
<td>${{ "%.2f"|format(item.tax_amount) }}</td>
<td>${{ "%.2f"|format(item.total) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="totals">
<table>
<tr>
<td>Subtotal</td>
<td class="text-right">${{ "%.2f"|format(invoice.subtotal) }}</td>
</tr>
<tr>
<td>Tax</td>
<td class="text-right">${{ "%.2f"|format(invoice.tax_amount) }}</td>
</tr>
<tr>
<td>Discount</td>
<td class="text-right">${{ "%.2f"|format(invoice.discount) }}</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td class="text-right"><strong>${{ "%.2f"|format(invoice.total) }}</strong></td>
</tr>
</table>
</div>
{% if invoice.notes %}
<div class="notes">
<h3>Notes</h3>
<p>{{ invoice.notes }}</p>
</div>
{% endif %}
{% if invoice.terms %}
<div class="terms">
<h3>Terms & Conditions</h3>
<p>{{ invoice.terms }}</p>
</div>
{% endif %}
<div class="footer">
<p>Invoice generated on {{ now.strftime('%d %b %Y %H:%M:%S') }}</p>
</div>
</body>
</html>
"""
with open(template_path, "w") as f:
f.write(template_content)
def generate_invoice_pdf(self, invoice: Invoice) -> Optional[str]:
"""Generate a PDF invoice."""
try:
self._ensure_template_exists()
# Get template
template = self.env.get_template("invoice.html")
# Prepare context
context = {
"invoice": invoice,
"user": invoice.user,
"customer": invoice.customer,
"now": datetime.now()
}
# Render HTML
html_content = template.render(**context)
# Generate PDF filename
filename = f"invoice_{invoice.invoice_number}_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf"
output_path = self.output_dir / filename
# Generate PDF
HTML(string=html_content).write_pdf(str(output_path))
return str(output_path)
except Exception as e:
# Log error
print(f"Error generating invoice PDF: {e}")
return None

View File

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

52
main.py Normal file
View File

@ -0,0 +1,52 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from app.api.v1.api import api_router
from app.core.config import settings
app = FastAPI(
title=settings.PROJECT_NAME,
description=settings.PROJECT_DESCRIPTION,
version=settings.PROJECT_VERSION,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
)
# Set up CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(api_router, prefix="/api/v1")
# Health check endpoint
@app.get("/health", tags=["Health"])
async def health_check():
return {"status": "healthy"}
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=settings.PROJECT_NAME,
version=settings.PROJECT_VERSION,
description=settings.PROJECT_DESCRIPTION,
routes=app.routes,
)
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

0
migrations/__init__.py Normal file
View File

79
migrations/env.py Normal file
View File

@ -0,0 +1,79 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Import all models for Alembic to autogenerate migrations
from app.db.session import Base
# This is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the target metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True, # For SQLite migrations
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
is_sqlite = connection.dialect.name == 'sqlite'
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite, # For SQLite migrations
)
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,154 @@
"""Initial migration
Revision ID: 1cb4a14e57e9
Revises:
Create Date: 2023-12-14 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1cb4a14e57e9'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create user table
op.create_table(
'user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=True),
sa.Column('company_name', sa.String(length=255), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('phone_number', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_superuser', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
# Create customer table
op.create_table(
'customer',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('tax_id', sa.String(length=50), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_customer_id'), 'customer', ['id'], unique=False)
# Create product table
op.create_table(
'product',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('sku', sa.String(length=50), nullable=True),
sa.Column('is_service', sa.Boolean(), nullable=True),
sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_product_id'), 'product', ['id'], unique=False)
# Create invoice table
op.create_table(
'invoice',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('invoice_number', sa.String(length=50), nullable=False),
sa.Column('status', sa.Enum('DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED', name='invoicestatus'), nullable=True),
sa.Column('issue_date', sa.Date(), nullable=False),
sa.Column('due_date', sa.Date(), nullable=False),
sa.Column('subtotal', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('tax_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('discount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('total', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('terms', sa.Text(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('customer_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['customer_id'], ['customer.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_invoice_id'), 'invoice', ['id'], unique=False)
op.create_index(op.f('ix_invoice_invoice_number'), 'invoice', ['invoice_number'], unique=False)
# Create invoice_item table
op.create_table(
'invoiceitem',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=False),
sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('unit_price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column('tax_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('subtotal', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('total', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('invoice_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['invoice_id'], ['invoice.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_invoiceitem_id'), 'invoiceitem', ['id'], unique=False)
# Create payment table
op.create_table(
'payment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('payment_date', sa.Date(), nullable=False),
sa.Column('payment_method', sa.Enum('CREDIT_CARD', 'BANK_TRANSFER', 'CASH', 'CHECK', 'PAYPAL', 'OTHER', name='paymentmethod'), nullable=False),
sa.Column('reference', sa.String(length=255), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('invoice_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['invoice_id'], ['invoice.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_payment_id'), 'payment', ['id'], unique=False)
def downgrade() -> None:
# Drop tables in reverse order of creation
op.drop_index(op.f('ix_payment_id'), table_name='payment')
op.drop_table('payment')
op.drop_index(op.f('ix_invoiceitem_id'), table_name='invoiceitem')
op.drop_table('invoiceitem')
op.drop_index(op.f('ix_invoice_invoice_number'), table_name='invoice')
op.drop_index(op.f('ix_invoice_id'), table_name='invoice')
op.drop_table('invoice')
op.drop_index(op.f('ix_product_id'), table_name='product')
op.drop_table('product')
op.drop_index(op.f('ix_customer_id'), table_name='customer')
op.drop_table('customer')
op.drop_index(op.f('ix_user_id'), table_name='user')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')

View File

18
requirements.txt Normal file
View File

@ -0,0 +1,18 @@
fastapi>=0.105.0
uvicorn>=0.25.0
sqlalchemy>=2.0.23
alembic>=1.13.0
pydantic>=2.5.2
pydantic-settings>=2.1.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
email-validator>=2.1.0.post1
python-dateutil>=2.8.2
ruff>=0.1.6
httpx>=0.25.2
pytest>=7.4.3
jinja2>=3.1.2
weasyprint>=60.2
python-dotenv>=1.0.0
tenacity>=8.2.3