diff --git a/README.md b/README.md index e8acfba..51ad8df 100644 --- a/README.md +++ b/README.md @@ -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 +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. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..4d65279 --- /dev/null +++ b/alembic.ini @@ -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 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..72581e2 --- /dev/null +++ b/app/api/v1/api.py @@ -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"]) \ No newline at end of file diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..4651a6e --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -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"} \ No newline at end of file diff --git a/app/api/v1/endpoints/customers.py b/app/api/v1/endpoints/customers.py new file mode 100644 index 0000000..2defa2d --- /dev/null +++ b/app/api/v1/endpoints/customers.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/invoices.py b/app/api/v1/endpoints/invoices.py new file mode 100644 index 0000000..dfa1c1d --- /dev/null +++ b/app/api/v1/endpoints/invoices.py @@ -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" + ) \ No newline at end of file diff --git a/app/api/v1/endpoints/payments.py b/app/api/v1/endpoints/payments.py new file mode 100644 index 0000000..a893605 --- /dev/null +++ b/app/api/v1/endpoints/payments.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/products.py b/app/api/v1/endpoints/products.py new file mode 100644 index 0000000..d1905ca --- /dev/null +++ b/app/api/v1/endpoints/products.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..08b2153 --- /dev/null +++ b/app/api/v1/endpoints/users.py @@ -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 \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..bcba494 --- /dev/null +++ b/app/core/auth.py @@ -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 \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..ce7b012 --- /dev/null +++ b/app/core/config.py @@ -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) \ No newline at end of file diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..72b51a4 --- /dev/null +++ b/app/core/exceptions.py @@ -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, + ) \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..e45d881 --- /dev/null +++ b/app/core/security.py @@ -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) \ No newline at end of file diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/customer.py b/app/crud/customer.py new file mode 100644 index 0000000..54d388a --- /dev/null +++ b/app/crud/customer.py @@ -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 \ No newline at end of file diff --git a/app/crud/invoice.py b/app/crud/invoice.py new file mode 100644 index 0000000..000b968 --- /dev/null +++ b/app/crud/invoice.py @@ -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 \ No newline at end of file diff --git a/app/crud/payment.py b/app/crud/payment.py new file mode 100644 index 0000000..65b5ce5 --- /dev/null +++ b/app/crud/payment.py @@ -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 \ No newline at end of file diff --git a/app/crud/product.py b/app/crud/product.py new file mode 100644 index 0000000..970e01e --- /dev/null +++ b/app/crud/product.py @@ -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 \ No newline at end of file diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..2f88051 --- /dev/null +++ b/app/crud/user.py @@ -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 \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..4ebc0fa --- /dev/null +++ b/app/db/session.py @@ -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() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..9bf89eb --- /dev/null +++ b/app/models/__init__.py @@ -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", +] \ No newline at end of file diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..05058c1 --- /dev/null +++ b/app/models/base.py @@ -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 + } \ No newline at end of file diff --git a/app/models/customer.py b/app/models/customer.py new file mode 100644 index 0000000..42b3aaf --- /dev/null +++ b/app/models/customer.py @@ -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") \ No newline at end of file diff --git a/app/models/invoice.py b/app/models/invoice.py new file mode 100644 index 0000000..c8c15af --- /dev/null +++ b/app/models/invoice.py @@ -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") \ No newline at end of file diff --git a/app/models/payment.py b/app/models/payment.py new file mode 100644 index 0000000..a02bb69 --- /dev/null +++ b/app/models/payment.py @@ -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") \ No newline at end of file diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..2890d4b --- /dev/null +++ b/app/models/product.py @@ -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") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..5e198cc --- /dev/null +++ b/app/models/user.py @@ -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") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..688c665 --- /dev/null +++ b/app/schemas/__init__.py @@ -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", +] \ No newline at end of file diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..c9c8588 --- /dev/null +++ b/app/schemas/auth.py @@ -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) \ No newline at end of file diff --git a/app/schemas/base.py b/app/schemas/base.py new file mode 100644 index 0000000..c7f09e1 --- /dev/null +++ b/app/schemas/base.py @@ -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 \ No newline at end of file diff --git a/app/schemas/customer.py b/app/schemas/customer.py new file mode 100644 index 0000000..0724208 --- /dev/null +++ b/app/schemas/customer.py @@ -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 \ No newline at end of file diff --git a/app/schemas/invoice.py b/app/schemas/invoice.py new file mode 100644 index 0000000..53e2103 --- /dev/null +++ b/app/schemas/invoice.py @@ -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] \ No newline at end of file diff --git a/app/schemas/payment.py b/app/schemas/payment.py new file mode 100644 index 0000000..209ffa3 --- /dev/null +++ b/app/schemas/payment.py @@ -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 \ No newline at end of file diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..abb0229 --- /dev/null +++ b/app/schemas/product.py @@ -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 \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..cc6866b --- /dev/null +++ b/app/schemas/user.py @@ -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 \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/invoice_generator.py b/app/services/invoice_generator.py new file mode 100644 index 0000000..36ae26c --- /dev/null +++ b/app/services/invoice_generator.py @@ -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 = """ + + + + + Invoice {{ invoice.invoice_number }} + + + +
+

INVOICE

+
+ +
+
+
+

From

+

{{ user.company_name or user.full_name }}

+

{{ user.address }}

+

{{ user.email }}

+

{{ user.phone_number }}

+
+
+
+
+

To

+

{{ customer.name }}

+

{{ customer.address }}

+

{{ customer.email }}

+

{{ customer.phone }}

+
+
+
+
+

Invoice Details

+

Invoice Number: {{ invoice.invoice_number }}

+

Issue Date: {{ invoice.issue_date.strftime('%d %b %Y') }}

+

Due Date: {{ invoice.due_date.strftime('%d %b %Y') }}

+

Status: {{ invoice.status.value.upper() }}

+
+
+
+ + + + + + + + + + + + + + {% for item in invoice.items %} + + + + + + + + + {% endfor %} + +
DescriptionQuantityUnit PriceTax RateTax AmountTotal
{{ item.description }}{{ item.quantity }}${{ "%.2f"|format(item.unit_price) }}{{ "%.2f"|format(item.tax_rate) }}%${{ "%.2f"|format(item.tax_amount) }}${{ "%.2f"|format(item.total) }}
+ +
+ + + + + + + + + + + + + + + + + +
Subtotal${{ "%.2f"|format(invoice.subtotal) }}
Tax${{ "%.2f"|format(invoice.tax_amount) }}
Discount${{ "%.2f"|format(invoice.discount) }}
Total${{ "%.2f"|format(invoice.total) }}
+
+ + {% if invoice.notes %} +
+

Notes

+

{{ invoice.notes }}

+
+ {% endif %} + + {% if invoice.terms %} +
+

Terms & Conditions

+

{{ invoice.terms }}

+
+ {% endif %} + + + + + """ + 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 \ No newline at end of file diff --git a/app/templates/__init__.py b/app/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..24c00ec --- /dev/null +++ b/main.py @@ -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) \ No newline at end of file diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..c21cea7 --- /dev/null +++ b/migrations/env.py @@ -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() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/migrations/script.py.mako @@ -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"} \ No newline at end of file diff --git a/migrations/versions/1cb4a14e57e9_initial_migration.py b/migrations/versions/1cb4a14e57e9_initial_migration.py new file mode 100644 index 0000000..3bfc973 --- /dev/null +++ b/migrations/versions/1cb4a14e57e9_initial_migration.py @@ -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') \ No newline at end of file diff --git a/migrations/versions/__init__.py b/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d62f5b2 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file