commit b93d41c1aea220d32bfdd255c8e4588cb0a600c0 Author: Edwin Eames Date: Sat Jan 17 15:21:41 2026 -0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c87328 --- /dev/null +++ b/.gitignore @@ -0,0 +1,122 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.idea +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +*.iml +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +db.sqlite3 + +# Flask stuff: + +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +venvwindows/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +app/package.json +app/package-lock.json +.vscode/ + + +instance/config.py +.idea/ +/passwords.py + +getnewitems.py +helperfunctions/ +test.py +tools/ +nginx.txt +app/node_modules/ \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..d501bc1 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,19 @@ +FROM python:3.12-bullseye + +ENV PYTHONFAULTHANDLER=1 + +ENV PYTHONUNBUFFERED=1 + +ENV MODE="DEVELOPMENT" + +RUN mkdir -p /app + +COPY requirements.txt /app + +WORKDIR /app + +RUN pip3 install -r requirements.txt + +EXPOSE 8000 + +COPY . /app \ No newline at end of file diff --git a/Dockerfile.local b/Dockerfile.local new file mode 100644 index 0000000..386dd58 --- /dev/null +++ b/Dockerfile.local @@ -0,0 +1,19 @@ +FROM python:3.12-bullseye + +ENV PYTHONFAULTHANDLER=1 + +ENV PYTHONUNBUFFERED=1 + +ENV MODE="LOCAL" + +RUN mkdir -p /app + +COPY requirements.txt /app + +WORKDIR /app + +RUN pip3 install -r requirements.txt + +EXPOSE 8000 + +COPY . /app \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..5b8351d --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,21 @@ +FROM python:3.12-bullseye + +ENV PYTHONFAULTHANDLER=1 + +ENV PYTHONUNBUFFERED=1 + +ENV MODE="PRODUCTION" + +RUN mkdir -p /app + +COPY requirements.txt /app + +WORKDIR /app + +RUN pip3 install -r requirements.txt + +EXPOSE 80 + +COPY . /app + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3684bb --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# API \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config.py b/config.py new file mode 100644 index 0000000..53a5b01 --- /dev/null +++ b/config.py @@ -0,0 +1,29 @@ +import os + +def load_config(mode=os.environ.get('MODE')): + """ + Load application configuration from environment variables. + The MODE environment variable determines which default CORS origins to use, + but all secrets are loaded from environment variables. + """ + from settings import ApplicationConfig + + print(f"Mode is {mode or 'not set (defaulting to DEVELOPMENT)'}") + + # Validate required configuration + try: + ApplicationConfig.validate_required() + except ValueError as e: + print(f"\033[91mConfiguration Error: {e}\033[0m") + raise + + # Print appropriate warning based on database + if ApplicationConfig.POSTGRES_DBNAME00 == 'eamco': + print("\033[92mTest database confirmed\033[0m") + else: + print("\033[91mPRODUCTION DATABASE\033[0m") + + if mode == 'PRODUCTION': + print("\033[91mPRODUCTION MODE\033[0m") + + return ApplicationConfig diff --git a/database.py b/database.py new file mode 100644 index 0000000..5536ab5 --- /dev/null +++ b/database.py @@ -0,0 +1,36 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.engine import URL +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import declarative_base + + +from config import load_config + + +ApplicationConfig = load_config() + + +url = URL.create( + drivername="postgresql+asyncpg", + username=ApplicationConfig.POSTGRES_USERNAME, + password=ApplicationConfig.POSTGRES_PW, + host=ApplicationConfig.POSTGRES_SERVER, + database=ApplicationConfig.POSTGRES_DBNAME00, + port=ApplicationConfig.POSTGRES_PORT +) + +engine = create_async_engine(url, echo=False) + +AsyncSessionLocal = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False +) + +Base = declarative_base() + +# Dependency to get DB session +async def get_db() -> AsyncSession: + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() diff --git a/main.py b/main.py new file mode 100644 index 0000000..520b537 --- /dev/null +++ b/main.py @@ -0,0 +1,43 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager +from routes import auth, info, order, payment +import os +from config import load_config +from database import engine, Base + + +ApplicationConfig = load_config() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: Create database tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + # Shutdown: cleanup if needed + +app = FastAPI(title="Oil Customer Gateway API", version="1.0.0", lifespan=lifespan) + +app.include_router(auth, prefix="/auth", tags=["auth"]) +app.include_router(info, prefix="/info", tags=["info"]) +app.include_router(order, prefix="/order", tags=["order"]) +app.include_router(payment, prefix="/payment", tags=["payment"]) + +@app.get("/") +async def root(): + return {"message": "Oil Customer Gateway API", "status": "Api is online"} + + +app.add_middleware( + CORSMiddleware, + allow_origins=ApplicationConfig.origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount static files for uploaded images +app.mount("/images", StaticFiles(directory="/images"), name="images") diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..13e11f7 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,15 @@ +from sqlalchemy.ext.declarative import declarative_base +from datetime import datetime +import uuid + +def get_uuid(): + return str(uuid.uuid4()) + +Base = declarative_base() + +from .customer import Customer_Customer, Customer_Description, Customer_Tank_Inspection, Customer_Stats +from .delivery import Delivery_Delivery +from .pricing import Pricing_Oil_Oil +from .company import Company_Company +from .account import Account_User +from .card import Card, Transaction diff --git a/models/account.py b/models/account.py new file mode 100644 index 0000000..8a0bf9e --- /dev/null +++ b/models/account.py @@ -0,0 +1,62 @@ +from sqlalchemy import Column, Integer, String, TIMESTAMP, TEXT, VARCHAR +from datetime import datetime, timezone +from . import Base + +class Account_User(Base): + __tablename__ = 'portal_user' + __table_args__ = {"schema": "public"} + + id = Column(Integer, autoincrement=True, primary_key=True, unique=True) + username = Column(String(50)) + account_number = Column(String(32)) + house_number = Column(String(32)) + email = Column(VARCHAR(350)) + password_hash = Column(TEXT) + member_since = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc)) + last_seen = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc)) + password_reset_token = Column(TEXT, nullable=True) + password_reset_expires = Column(TIMESTAMP(timezone=True), nullable=True) + admin = Column(Integer) + admin_role = Column(Integer) + confirmed = Column(Integer) + active = Column(Integer, default=1) + user_id = Column(Integer, nullable=True) # References Customer_Customer.id + + def __init__(self, + username, + account_number, + house_number, + password_hash, + member_since, + email, + last_seen, + admin, + admin_role, + confirmed, + active=1, + user_id=None, + ): + self.username = username + self.account_number = account_number + self.house_number = house_number + self.password_hash = password_hash + self.member_since = member_since + self.email = email + self.last_seen = last_seen + self.admin = admin + self.admin_role = admin_role + self.confirmed = confirmed + self.active = active + self.user_id = user_id + + def is_authenticated(self): + return True + + def is_active(self): + return True + + def is_anonymous(self): + return False + + def get_id(self): + return str(self.id) diff --git a/models/card.py b/models/card.py new file mode 100644 index 0000000..20eecb5 --- /dev/null +++ b/models/card.py @@ -0,0 +1,46 @@ +from sqlalchemy import Column, Integer, String, TIMESTAMP, TEXT, VARCHAR, Numeric, DateTime, Boolean +from datetime import datetime, timezone +from . import Base + + +class Card(Base): + __tablename__ = "card_card" + + id = Column(Integer, primary_key=True, index=True) + date_added = Column(DateTime, default=datetime.utcnow) + user_id = Column(Integer, nullable=False) + + # This stores the payment profile ID for this specific card from Authorize.Net's CIM. + auth_net_payment_profile_id = Column(String, nullable=True) + + # Columns to store non-sensitive card info for display purposes + card_number = Column(String(50), nullable=True) + last_four_digits = Column(Integer, nullable=False) + name_on_card = Column(String(500), nullable=True) + expiration_month = Column(String(20), nullable=False) + expiration_year = Column(String(20), nullable=False) + type_of_card = Column(String(50), nullable=True) + security_number = Column(String(10), nullable=True) + accepted_or_declined = Column(Integer, nullable=True) + main_card = Column(Boolean, nullable=True) + zip_code = Column(String(20), nullable=True) + + +class Transaction(Base): + __tablename__ = "transactions" + + id = Column(Integer, primary_key=True, index=True) + # Recommended change: Use Numeric for precision + preauthorize_amount = Column(Numeric(10, 2), nullable=True) + charge_amount = Column(Numeric(10, 2), nullable=True) + customer_id = Column(Integer) + transaction_type = Column(Integer)# 0 = charge, 1 = auth, 2 = capture + status = Column(Integer) + auth_net_transaction_id = Column(String, unique=True, index=True, nullable=True) + service_id = Column(Integer, nullable=True) + delivery_id = Column(Integer, nullable=True) + auto_id = Column(Integer, nullable=True) + card_id = Column(Integer, nullable=True) + payment_gateway = Column(Integer, default=1) + rejection_reason = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) diff --git a/models/company.py b/models/company.py new file mode 100644 index 0000000..03af309 --- /dev/null +++ b/models/company.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String, Boolean, DECIMAL, TIMESTAMP, DATE, TEXT, VARCHAR +from . import Base + +class Company_Company(Base): + __tablename__ = 'company_company' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True, unique=False) + + company_dba_name = Column(VARCHAR(250)) + company_llc_name = Column(VARCHAR(250)) + company_town = Column(VARCHAR(140)) + company_state = Column(Integer) + company_zip = Column(VARCHAR(25)) diff --git a/models/customer.py b/models/customer.py new file mode 100644 index 0000000..f2cde8d --- /dev/null +++ b/models/customer.py @@ -0,0 +1,65 @@ +from sqlalchemy import Column, Integer, String, Boolean, DECIMAL, TIMESTAMP, DATE, TEXT, VARCHAR, JSON +from . import Base + +class Customer_Customer(Base): + __tablename__ = 'customer_customer' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True, unique=False) + auth_net_profile_id = Column(String, unique=True, index=True, nullable=True) + account_number = Column(VARCHAR(25)) + customer_last_name = Column(VARCHAR(250)) + customer_first_name = Column(VARCHAR(250)) + customer_town = Column(VARCHAR(140)) + customer_state = Column(Integer) + customer_zip = Column(VARCHAR(25)) + customer_first_call = Column(TIMESTAMP()) + customer_email = Column(VARCHAR(500)) + customer_automatic = Column(Integer) + customer_phone_number = Column(VARCHAR(25)) + customer_home_type = Column(Integer) + customer_apt = Column(VARCHAR(140)) + customer_address = Column(VARCHAR(1000)) + company_id = Column(Integer) + customer_latitude = Column(VARCHAR(250)) + customer_longitude = Column(VARCHAR(250)) + correct_address = Column(Boolean) + +class Customer_Description(Base): + __tablename__ = 'customer_description' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True, unique=False) + customer_id = Column(Integer) + account_number = Column(VARCHAR(25)) + company_id = Column(Integer) + fill_location = Column(Integer) + description = Column(VARCHAR(2000)) + +class Customer_Tank_Inspection(Base): + __tablename__ = 'customer_tank' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True, unique=False) + customer_id = Column(Integer) + last_tank_inspection = Column(DATE) + tank_status = Column(Boolean) + outside_or_inside = Column(Boolean) + tank_size = Column(Integer) + tank_images = Column(Integer, default=0) # Number of image sets uploaded (each set = 3 images) + tank_image_upload_dates = Column(JSON, default=list) # List of upload dates for each set + +class Customer_Stats(Base): + __tablename__ = 'stats_customer' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True, unique=False) + customer_id = Column(Integer) + total_calls = Column(Integer, default=0) + service_calls_total = Column(Integer, default=0) + service_calls_total_spent = Column(DECIMAL(6, 2), default=0.00) + service_calls_total_profit = Column(DECIMAL(6, 2), default=0.00) + oil_deliveries = Column(Integer, default=0) + oil_total_gallons = Column(DECIMAL(6, 2), default=0.00) + oil_total_spent = Column(DECIMAL(6, 2), default=0.00) + oil_total_profit = Column(DECIMAL(6, 2), default=0.00) diff --git a/models/delivery.py b/models/delivery.py new file mode 100644 index 0000000..1eb0b35 --- /dev/null +++ b/models/delivery.py @@ -0,0 +1,77 @@ +from sqlalchemy import Column, Integer, String, Boolean, DECIMAL, TIMESTAMP, DATE, TEXT, VARCHAR +from . import Base + +class Delivery_Delivery(Base): + __tablename__ = 'delivery_delivery' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True, unique=False) + + customer_id = Column(Integer) + customer_name = Column(VARCHAR(1000)) + customer_address = Column(VARCHAR(1000)) + customer_town = Column(VARCHAR(140)) + customer_state = Column(VARCHAR(140)) + customer_zip = Column(Integer) + # how many gallons ordered + gallons_ordered = Column(Integer) + # if customer asked for a fill + customer_asked_for_fill = Column(Integer) + # integer value if delivered, waiting, cancelled etc + gallons_delivered = Column(DECIMAL(6, 2)) + # if customer has a full tank + customer_filled = Column(Integer) + # integer value if delivered, waiting, cancelled etc + # waiting = 0 + # cancelled = 1 + # out for delivery = 2 + # tommorrow = 3 + # issue = 5 + # finalized = 10 + + delivery_status = Column(Integer) + + # when the call to order took place + when_ordered = Column(DATE(), default=None) + # when the delivery date happened + when_delivered = Column(DATE(), default=None) + # when the delivery is expected ie what day + expected_delivery_date = Column(DATE(), default=None) + # automatic delivery + automatic = Column(Integer) + automatic_id = Column(Integer) + # OIL info and id from table + oil_id = Column(Integer) + supplier_price = Column(DECIMAL(6, 2)) + customer_price = Column(DECIMAL(6, 2)) + # weather + customer_temperature = Column(DECIMAL(6, 2)) + + dispatcher_notes = Column(TEXT()) + + + prime = Column(Integer) + same_day = Column(Integer) + emergency = Column(Integer) + + # cash = 0 + # credit = 1 + # credit/cash = 2 + # check = 3 + # other = 4 + payment_type = Column(Integer) + payment_card_id = Column(Integer) + cash_recieved = Column(DECIMAL(6, 2)) + + driver_employee_id = Column(Integer) + driver_first_name = Column(VARCHAR(140)) + driver_last_name = Column(VARCHAR(140)) + + pre_charge_amount = Column(DECIMAL(6, 2)) + total_price = Column(DECIMAL(6, 2)) + final_price = Column(DECIMAL(6, 2)) + check_number = Column(VARCHAR(20)) + + + promo_id = Column(Integer) + promo_money_discount = Column(DECIMAL(6, 2)) diff --git a/models/pricing.py b/models/pricing.py new file mode 100644 index 0000000..b0e7814 --- /dev/null +++ b/models/pricing.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String, Boolean, DECIMAL, TIMESTAMP, DATE, TEXT, VARCHAR +from datetime import datetime +from . import Base + +class Pricing_Oil_Oil(Base): + __tablename__ = 'pricing_oil_oil' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True, unique=False) + + price_from_supplier = Column(DECIMAL(6, 2)) + price_for_customer = Column(DECIMAL(6, 2)) + price_for_employee = Column(DECIMAL(6, 2)) + price_same_day = Column(DECIMAL(6, 2)) + price_prime = Column(DECIMAL(6, 2)) + price_emergency = Column(DECIMAL(6, 2)) + date = Column(TIMESTAMP(), default=datetime.utcnow()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b4f345e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +alembic==1.13.1 +pydantic==2.5.0 +python-multipart==0.0.6 +asyncpg==0.29.0 +psycopg2-binary==2.9.9 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +Pillow==10.1.0 +requests==2.31.0 diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..dc2730b --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1,4 @@ +from .auth import router as auth +from .info import router as info +from .order import router as order +from .payment import router as payment diff --git a/routes/auth/__init__.py b/routes/auth/__init__.py new file mode 100644 index 0000000..74cc9f1 --- /dev/null +++ b/routes/auth/__init__.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter +from .login import router as login_router +from .register import router as register_router +from .new import router as new_router +from .current_user import router as current_user_router, oauth2_scheme +from .lost_password import router as lost_password_router + +router = APIRouter() +router.include_router(login_router) +router.include_router(register_router) +router.include_router(new_router) +router.include_router(current_user_router) +router.include_router(lost_password_router) diff --git a/routes/auth/current_user.py b/routes/auth/current_user.py new file mode 100644 index 0000000..f9509bf --- /dev/null +++ b/routes/auth/current_user.py @@ -0,0 +1,191 @@ +from fastapi import Depends, HTTPException, status, APIRouter +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +import logging +from database import get_db +from models import Account_User, Customer_Customer, Customer_Description, Card +from schemas import TokenData, CustomerUpdate +from jose import JWTError, jwt +from config import load_config + +logger = logging.getLogger(__name__) + +# Load JWT configuration from environment +ApplicationConfig = load_config() +SECRET_KEY = ApplicationConfig.JWT_SECRET_KEY +ALGORITHM = ApplicationConfig.JWT_ALGORITHM + +async def get_current_user(token: str = Depends(OAuth2PasswordBearer(tokenUrl="auth/login")), db: AsyncSession = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except JWTError: + raise credentials_exception + user = await db.execute(select(Account_User).where(Account_User.username == token_data.username)) + user = user.scalar_one_or_none() + if user is None: + raise credentials_exception + return user + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") + +router = APIRouter() + +@router.get("/me") +async def read_users_me(current_user: Account_User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + # Get customer details for the current user using user_id + customer = None + if current_user.user_id: + customer_result = await db.execute(select(Customer_Customer).where(Customer_Customer.id == current_user.user_id)) + customer = customer_result.scalar_one_or_none() + + # Get house description if exists + house_description = None + if customer: + description_record = await db.execute( + select(Customer_Description).where(Customer_Description.customer_id == customer.id) + ) + description_record = description_record.scalar_one_or_none() + if description_record: + house_description = description_record.description + + # Map state code to name + state_mapping = {0: "MA", 1: "NH"} # Add more as needed + state_name = state_mapping.get(customer.customer_state, str(customer.customer_state)) if customer else None + + return { + "id": current_user.id, + "username": current_user.username, + "email": current_user.email, + "account_number": current_user.account_number, + "customer_first_name": customer.customer_first_name if customer else None, + "customer_last_name": customer.customer_last_name if customer else None, + "customer_address": customer.customer_address if customer else None, + "customer_apt": customer.customer_apt if customer else None, + "customer_town": customer.customer_town if customer else None, + "customer_state": state_name, + "customer_zip": customer.customer_zip if customer else None, + "customer_phone_number": customer.customer_phone_number if customer else None, + "customer_home_type": customer.customer_home_type if customer else None, + "customer_email": customer.customer_email if customer else None, + "house_description": house_description, + "member_since": current_user.member_since, + "last_seen": current_user.last_seen + } + +@router.put("/update-customer") +async def update_customer_info( + customer_data: CustomerUpdate, + current_user: Account_User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + if not current_user.user_id: + raise HTTPException(status_code=404, detail="Customer not found") + + try: + # Update Customer_Customer table + customer_update_data = customer_data.dict(exclude_unset=True) + customer_fields = { + 'customer_first_name', 'customer_last_name', 'customer_address', 'customer_apt', + 'customer_town', 'customer_state', 'customer_zip', 'customer_phone_number', + 'customer_home_type', 'customer_email' + } + + customer_update_dict = {k: v for k, v in customer_update_data.items() if k in customer_fields} + + if customer_update_dict: + await db.execute( + update(Customer_Customer) + .where(Customer_Customer.id == current_user.user_id) + .values(**customer_update_dict) + ) + + # Update house description if provided + if 'house_description' in customer_update_data and customer_update_data['house_description']: + # Get customer record + customer = await db.execute( + select(Customer_Customer).where(Customer_Customer.id == current_user.user_id) + ) + customer = customer.scalar_one_or_none() + + if customer: + # Check if description record exists + description_record = await db.execute( + select(Customer_Description).where(Customer_Description.customer_id == customer.id) + ) + description_record = description_record.scalar_one_or_none() + + if description_record: + # Update existing + await db.execute( + update(Customer_Description) + .where(Customer_Description.customer_id == customer.id) + .values(description=customer_update_data['house_description']) + ) + else: + # Create new + new_description = Customer_Description( + customer_id=customer.id, + account_number=current_user.account_number, + company_id=customer.company_id or 1, + fill_location=0, + description=customer_update_data['house_description'] + ) + db.add(new_description) + + await db.commit() + + # Sync billing info to Authorize.net if address-related fields were updated + billing_fields = { + 'customer_first_name', 'customer_last_name', 'customer_address', + 'customer_town', 'customer_state', 'customer_zip', 'customer_phone_number', + 'customer_email' + } + if any(k in customer_update_dict for k in billing_fields): + # Late import to avoid circular dependency + from routes.payment.routes import update_customer_profile, update_payment_profile_billing + + # Refetch the updated customer + customer_result = await db.execute( + select(Customer_Customer).where(Customer_Customer.id == current_user.user_id) + ) + updated_customer = customer_result.scalar_one_or_none() + + if updated_customer and updated_customer.auth_net_profile_id: + # Update customer profile in Authorize.net + update_customer_profile(updated_customer.auth_net_profile_id, updated_customer) + + # Update all saved payment profiles with new billing info + cards_result = await db.execute( + select(Card).where(Card.user_id == updated_customer.id) + ) + cards = cards_result.scalars().all() + + for card in cards: + if card.auth_net_payment_profile_id: + update_payment_profile_billing( + customer_profile_id=updated_customer.auth_net_profile_id, + payment_profile_id=card.auth_net_payment_profile_id, + customer=updated_customer, + card=card + ) + + logger.info(f"Synced billing info to Authorize.net for customer {updated_customer.id}") + + return {"message": "Customer information updated successfully"} + + except Exception as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update customer information: {str(e)}" + ) diff --git a/routes/auth/login.py b/routes/auth/login.py new file mode 100644 index 0000000..3353ad0 --- /dev/null +++ b/routes/auth/login.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from database import get_db +from models import Account_User +from schemas import UserLogin, Token +from passlib.context import CryptContext +from jose import jwt +from datetime import datetime, timedelta +from config import load_config + +pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + +# Load JWT configuration from environment +ApplicationConfig = load_config() +SECRET_KEY = ApplicationConfig.JWT_SECRET_KEY +ALGORITHM = ApplicationConfig.JWT_ALGORITHM +ACCESS_TOKEN_EXPIRE_MINUTES = ApplicationConfig.JWT_ACCESS_TOKEN_EXPIRE_MINUTES + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def create_access_token(data: dict, expires_delta = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +async def authenticate_user(db: AsyncSession, email: str, password: str): + user = await db.execute(select(Account_User).where(Account_User.email == email)) + user = user.scalar_one_or_none() + if not user: + return False + if not verify_password(password, user.password_hash): + return False + # Update last_seen + user.last_seen = datetime.utcnow() + await db.commit() + return user + +router = APIRouter() + +@router.post("/login", response_model=Token) +async def login(user: UserLogin, db: AsyncSession = Depends(get_db)): + user_obj = await authenticate_user(db, user.email, user.password) + if not user_obj: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user_obj.username}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} diff --git a/routes/auth/lost_password.py b/routes/auth/lost_password.py new file mode 100644 index 0000000..71d7f40 --- /dev/null +++ b/routes/auth/lost_password.py @@ -0,0 +1,126 @@ +import secrets +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from database import get_db +from models import Account_User +from schemas import ForgotPasswordRequest, ResetPasswordRequest +from passlib.context import CryptContext +from config import load_config + +pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + +# Load configuration from environment +ApplicationConfig = load_config() + +# Email configuration from environment variables +SMTP_SERVER = ApplicationConfig.SMTP_SERVER +SMTP_PORT = ApplicationConfig.SMTP_PORT +SMTP_USERNAME = ApplicationConfig.SMTP_USERNAME +SMTP_PASSWORD = ApplicationConfig.SMTP_PASSWORD +FROM_EMAIL = ApplicationConfig.SMTP_FROM_EMAIL + +router = APIRouter() + +def get_password_hash(password): + return pwd_context.hash(password) + +def generate_reset_token(): + return secrets.token_urlsafe(32) + +def send_reset_email(to_email: str, reset_token: str): + """Send password reset email with link""" + reset_url = f"{ApplicationConfig.FRONTEND_URL}/reset-password?token={reset_token}" + + msg = MIMEMultipart() + msg['From'] = FROM_EMAIL + msg['To'] = to_email + msg['Subject'] = "Password Reset - Oil Customer Gateway" + + body = f""" + You have requested a password reset for your Oil Customer Gateway account. + + Click the following link to reset your password: + {reset_url} + + This link will expire in 24 hours. + + If you didn't request this reset, please ignore this email. + + Best regards, + Oil Customer Gateway Team + """ + + msg.attach(MIMEText(body, 'plain')) + + try: + server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT) + server.starttls() + server.login(SMTP_USERNAME, SMTP_PASSWORD) + text = msg.as_string() + server.sendmail(FROM_EMAIL, to_email, text) + server.quit() + return True + except Exception as e: + print(f"Email sending failed: {e}") + return False + +@router.post("/forgot-password") +async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)): + """Request password reset - sends email with reset link""" + # Find user by email + result = await db.execute(select(Account_User).where(Account_User.email == request.email)) + user = result.scalar_one_or_none() + + if not user: + # Don't reveal if email exists or not for security + return {"message": "If an account with that email exists, a password reset link has been sent."} + + # Generate reset token and expiry (24 hours) + reset_token = generate_reset_token() + reset_expires = datetime.utcnow() + timedelta(hours=24) + + # Update user with reset token + user.password_reset_token = reset_token + user.password_reset_expires = reset_expires + await db.commit() + + # Send email + if send_reset_email(user.email, reset_token): + return {"message": "Password reset link sent to your email."} + else: + raise HTTPException(status_code=500, detail="Failed to send reset email") + +@router.post("/reset-password") +async def reset_password(request: ResetPasswordRequest, db: AsyncSession = Depends(get_db)): + """Reset password using token""" + # Verify passwords match + if request.password != request.confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match") + + # Find user by reset token + result = await db.execute( + select(Account_User).where( + Account_User.password_reset_token == request.token, + Account_User.password_reset_expires > datetime.utcnow() + ) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + + # Update password + hashed_password = get_password_hash(request.password) + user.password_hash = hashed_password + user.password_reset_token = None + user.password_reset_expires = None + user.last_seen = datetime.utcnow() + + await db.commit() + + return {"message": "Password reset successfully"} \ No newline at end of file diff --git a/routes/auth/new.py b/routes/auth/new.py new file mode 100644 index 0000000..9c302b3 --- /dev/null +++ b/routes/auth/new.py @@ -0,0 +1,588 @@ +import random +import string +import os +import shutil +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from database import get_db +from models import Customer_Customer, Customer_Description, Customer_Tank_Inspection, Customer_Stats, Account_User +from schemas import NewCustomerCreate, UserResponse, CustomerCreateStep1, CustomerAccountCreate +from passlib.context import CryptContext +from datetime import datetime +from PIL import Image +import io + +pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + +def generate_random_number_string(length): + if length < 1: + raise ValueError("Length must be at least 1") + random_number = ''.join(random.choices(string.digits, k=length)) + return random_number + +def get_password_hash(password): + # Truncate password to 72 bytes max for bcrypt compatibility + truncated_password = password.encode('utf-8')[:72].decode('utf-8', errors='ignore') + return pwd_context.hash(truncated_password) + +def resize_image(image_data, max_size=(1024, 1024), quality=85): + """ + Resize image to fit within max_size while maintaining aspect ratio. + Convert to JPEG format and strip metadata for security. + """ + try: + # Open image from bytes + img = Image.open(io.BytesIO(image_data)) + + # Convert to RGB if necessary (for PNG with transparency, etc.) + if img.mode in ('RGBA', 'LA', 'P'): + img = img.convert('RGB') + + # Resize if larger than max_size + if img.width > max_size[0] or img.height > max_size[1]: + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + # Save as JPEG with specified quality + output = io.BytesIO() + img.save(output, format='JPEG', quality=quality, optimize=True) + output.seek(0) + + return output.getvalue() + + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid image file: {str(e)}") + +router = APIRouter() + +@router.post("/new", response_model=UserResponse) +async def register_new_customer(customer: NewCustomerCreate, db: AsyncSession = Depends(get_db)): + # Verify passwords match + if customer.password != customer.confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match") + + # Check if email already registered + result = await db.execute(select(Account_User).where(Account_User.email == customer.customer_email)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + while True: + random_part = generate_random_number_string(6) + account_number = 'AO-' + random_part + result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_number)) + existing_customer = result.scalar_one_or_none() + if not existing_customer: + break + + # Create customer + customer_data = customer.model_dump() + customer_data.pop('password') + customer_data.pop('confirm_password') + customer_data.update({ + 'account_number': account_number, + 'customer_state': 1, # Default + 'customer_automatic': 0, + 'company_id': 1, # Default + 'customer_latitude': '0', + 'customer_longitude': '0', + 'correct_address': True, + 'customer_first_call': datetime.utcnow() + }) + db_customer = Customer_Customer(**customer_data) + db.add(db_customer) + await db.commit() + await db.refresh(db_customer) + + # Extract house number from customer address (first part before space) + house_number = customer.customer_address.split()[0] if customer.customer_address else '' + + # Create account user + username = account_number + hashed_password = get_password_hash(customer.password) + db_user = Account_User( + username=username, + account_number=account_number, + house_number=house_number, + password_hash=hashed_password, + member_since=datetime.utcnow(), + email=customer.customer_email, + last_seen=datetime.utcnow(), + admin=0, + admin_role=0, + confirmed=1, + active=1, + user_id=db_customer.id + ) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + return db_user + +@router.post("/step3") +async def upload_tank_images( + account_number: str = Form(...), + tank_image_1: UploadFile = File(...), + tank_image_2: UploadFile = File(...), + tank_image_3: UploadFile = File(...), + db: AsyncSession = Depends(get_db) +): + print("=== STEP3 DEBUG START ===") + print(f"Account number received: '{account_number}'") + + # Debug: Check all parameters received + images = [tank_image_1, tank_image_2, tank_image_3] + for i, image in enumerate(images, 1): + print(f"Image {i}: filename='{image.filename}', content_type='{image.content_type}', size={image.size}") + + # Validate account number + if not account_number: + print("ERROR: Account number is empty") + raise HTTPException(status_code=400, detail="Account number is required") + + # Get customer info for description record + customer_result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_number)) + customer = customer_result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=400, detail="Customer not found") + + print(f"Creating directory: /images/{account_number}") + # Create directory for account number in the mounted images volume + account_dir = f"/images/{account_number}" + os.makedirs(account_dir, exist_ok=True) + + current_datetime = datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S") # YYYY-MM-DD_HH-MM-SS format + + # Track if images were uploaded successfully + images_uploaded_successfully = False + + # Validate and save images + saved_files = [] + + try: + for i, image in enumerate(images, 1): + print(f"Processing image {i}...") + # Read image data + image_data = await image.read() + print(f"Image {i} data read: {len(image_data)} bytes") + + # Validate file size (max 20MB before processing) + if len(image_data) > 20 * 1024 * 1024: + print(f"ERROR: Image {i} too large: {len(image_data)} bytes") + raise HTTPException(status_code=400, detail=f"File {i} is too large (max 20MB)") + + # Resize and process image (this also validates it's a valid image) + print(f"Resizing image {i}...") + processed_image_data = resize_image(image_data, max_size=(1024, 1024), quality=85) + print(f"Image {i} resized: {len(processed_image_data)} bytes") + + # Save processed image with datetime-based filename + filename = f"{current_datetime}-{i}.jpg" + file_path = os.path.join(account_dir, filename) + print(f"Saving image {i} to: {file_path}") + + with open(file_path, "wb") as buffer: + buffer.write(processed_image_data) + + saved_files.append(filename) + print(f"Image {i} saved successfully") + + images_uploaded_successfully = True + print(f"All images processed successfully. Saved files: {saved_files}") + + except Exception as e: + print(f"ERROR processing images: {str(e)}") + # Don't raise exception - we want to track the failure in the database + images_uploaded_successfully = False + + # Update or create customer tank inspection record with tank_images status + print("Updating customer tank inspection record...") + tank_result = await db.execute( + select(Customer_Tank_Inspection).where(Customer_Tank_Inspection.customer_id == customer.id) + ) + existing_tank = tank_result.scalar_one_or_none() + + if existing_tank: + # Update existing record + if images_uploaded_successfully: + existing_tank.tank_images += 1 # Increment count of image sets + # Append current datetime to upload dates list + current_dates = existing_tank.tank_image_upload_dates or [] + current_dates.append(current_datetime) + existing_tank.tank_image_upload_dates = current_dates + await db.commit() + print(f"Updated existing tank inspection record with tank_images = {existing_tank.tank_images}, dates = {existing_tank.tank_image_upload_dates}") + else: + # Create new record + new_tank = Customer_Tank_Inspection( + customer_id=customer.id, + tank_images=1 if images_uploaded_successfully else 0, + tank_image_upload_dates=[current_datetime] if images_uploaded_successfully else [] + # Other fields will be null/None initially + ) + db.add(new_tank) + await db.commit() + print(f"Created new tank inspection record with tank_images = {new_tank.tank_images}, dates = {new_tank.tank_image_upload_dates}") + + print("=== STEP3 DEBUG END ===") + + if images_uploaded_successfully: + return { + "message": "Tank images uploaded successfully", + "account_number": account_number, + "uploaded_files": saved_files + } + else: + return { + "message": "Tank images upload skipped or failed", + "account_number": account_number, + "uploaded_files": [] + } + +@router.post("/upload-tank-images") +async def upload_additional_tank_images( + account_number: str = Form(...), + tank_image_1: UploadFile = File(...), + tank_image_2: UploadFile = File(...), + tank_image_3: UploadFile = File(...), + db: AsyncSession = Depends(get_db) +): + """ + Endpoint for customers to upload additional sets of 3 tank images after registration. + Similar to step3 but for existing customers. + """ + print("=== UPLOAD ADDITIONAL TANK IMAGES DEBUG START ===") + print(f"Account number received: '{account_number}'") + + # Debug: Check all parameters received + images = [tank_image_1, tank_image_2, tank_image_3] + for i, image in enumerate(images, 1): + print(f"Image {i}: filename='{image.filename}', content_type='{image.content_type}', size={image.size}") + + # Validate account number + if not account_number: + print("ERROR: Account number is empty") + raise HTTPException(status_code=400, detail="Account number is required") + + # Get customer info + customer_result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_number)) + customer = customer_result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=400, detail="Customer not found") + + print(f"Creating directory: /images/{account_number}") + # Create directory for account number in the mounted images volume + account_dir = f"/images/{account_number}" + os.makedirs(account_dir, exist_ok=True) + + current_datetime = datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S") # YYYY-MM-DD_HH-MM-SS format + + # Track if images were uploaded successfully + images_uploaded_successfully = False + + # Validate and save images + saved_files = [] + + try: + for i, image in enumerate(images, 1): + print(f"Processing image {i}...") + # Read image data + image_data = await image.read() + print(f"Image {i} data read: {len(image_data)} bytes") + + # Validate file size (max 20MB before processing) + if len(image_data) > 20 * 1024 * 1024: + print(f"ERROR: Image {i} too large: {len(image_data)} bytes") + raise HTTPException(status_code=400, detail=f"File {i} is too large (max 20MB)") + + # Resize and process image (this also validates it's a valid image) + print(f"Resizing image {i}...") + processed_image_data = resize_image(image_data, max_size=(1024, 1024), quality=85) + print(f"Image {i} resized: {len(processed_image_data)} bytes") + + # Save processed image with datetime-based filename + filename = f"{current_datetime}-{i}.jpg" + file_path = os.path.join(account_dir, filename) + print(f"Saving image {i} to: {file_path}") + + with open(file_path, "wb") as buffer: + buffer.write(processed_image_data) + + saved_files.append(filename) + print(f"Image {i} saved successfully") + + images_uploaded_successfully = True + print(f"All images processed successfully. Saved files: {saved_files}") + + except Exception as e: + print(f"ERROR processing images: {str(e)}") + # Don't raise exception - we want to track the failure in the database + images_uploaded_successfully = False + + # Update customer tank inspection record + print("Updating customer tank inspection record...") + tank_result = await db.execute( + select(Customer_Tank_Inspection).where(Customer_Tank_Inspection.customer_id == customer.id) + ) + existing_tank = tank_result.scalar_one_or_none() + + if existing_tank: + # Update existing record + if images_uploaded_successfully: + existing_tank.tank_images += 1 # Increment count of image sets + # Append current datetime to upload dates list + current_dates = existing_tank.tank_image_upload_dates or [] + current_dates.append(current_datetime) + existing_tank.tank_image_upload_dates = current_dates + await db.commit() + print(f"Updated existing tank inspection record with tank_images = {existing_tank.tank_images}, dates = {existing_tank.tank_image_upload_dates}") + else: + # This shouldn't happen for additional uploads, but handle it just in case + new_tank = Customer_Tank_Inspection( + customer_id=customer.id, + tank_images=1 if images_uploaded_successfully else 0, + tank_image_upload_dates=[current_datetime] if images_uploaded_successfully else [] + ) + db.add(new_tank) + await db.commit() + print(f"Created new tank inspection record with tank_images = {new_tank.tank_images}, dates = {new_tank.tank_image_upload_dates}") + + print("=== UPLOAD ADDITIONAL TANK IMAGES DEBUG END ===") + + if images_uploaded_successfully: + return { + "message": "Additional tank images uploaded successfully", + "account_number": account_number, + "uploaded_files": saved_files, + "upload_date": current_datetime + } + else: + raise HTTPException(status_code=400, detail="Failed to upload tank images") + +@router.get("/tank-images/{account_number}") +async def get_tank_images(account_number: str, db: AsyncSession = Depends(get_db)): + """ + Get tank images information for a customer including upload dates. + """ + # Get customer info + customer_result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_number)) + customer = customer_result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get tank inspection record + tank_result = await db.execute( + select(Customer_Tank_Inspection).where(Customer_Tank_Inspection.customer_id == customer.id) + ) + tank_record = tank_result.scalar_one_or_none() + + # Build image sets - first from database, then scan for any additional files + image_sets = [] + upload_dates = tank_record.tank_image_upload_dates or [] if tank_record else [] + + # Add sets from database + for i, upload_date in enumerate(upload_dates): + # Handle backward compatibility: old format used date-only strings and tank_*.jpg files + # New format uses datetime strings and {datetime}-*.jpg files + if "_" in upload_date: + # New datetime format (YYYY-MM-DD_HH-MM-SS) + image_set = { + "date": upload_date, + "images": [ + f"/images/{account_number}/{upload_date}-1.jpg", + f"/images/{account_number}/{upload_date}-2.jpg", + f"/images/{account_number}/{upload_date}-3.jpg" + ] + } + else: + # Old date-only format (YYYY-MM-DD) - uses tank_*.jpg files + image_set = { + "date": upload_date, + "images": [ + f"/images/{account_number}/tank_1.jpg", + f"/images/{account_number}/tank_2.jpg", + f"/images/{account_number}/tank_3.jpg" + ] + } + image_sets.append(image_set) + + # Scan for any additional image sets that might not be in database + account_dir = f"/images/{account_number}" + if os.path.exists(account_dir): + # Find all image files + all_files = os.listdir(account_dir) + image_files = [f for f in all_files if f.endswith('.jpg')] + + # Group by datetime prefix + datetime_groups = {} + for file in image_files: + if file.startswith('tank_'): + # Old tank_*.jpg files - already handled above + continue + elif '_' in file and file.endswith('.jpg'): # datetime format like 2026-01-08_23-25-31-1.jpg + # Extract datetime prefix by removing the image number and .jpg + # Example: 2026-01-08_23-25-31-1.jpg -> 2026-01-08_23-25-31 + datetime_prefix = file.rsplit('-', 1)[0] # Remove everything after last dash + if datetime_prefix not in datetime_groups: + datetime_groups[datetime_prefix] = [] + datetime_groups[datetime_prefix].append(file) + + # Add any datetime groups not already in database + for datetime_prefix, files in datetime_groups.items(): + if datetime_prefix not in upload_dates and len(files) >= 3: + # Sort files to ensure correct order + sorted_files = sorted(files) + image_set = { + "date": datetime_prefix, + "images": [ + f"/images/{account_number}/{sorted_files[0]}", + f"/images/{account_number}/{sorted_files[1]}", + f"/images/{account_number}/{sorted_files[2]}" + ] + } + image_sets.append(image_set) + + # Also check for date-prefixed files (like 2026-01-08-1.jpg) + date_groups = {} + for file in image_files: + if file.startswith('tank_') or '_' in file: + continue # Skip old format and datetime format + parts = file.split('-') + if len(parts) == 4 and parts[3] in ['1.jpg', '2.jpg', '3.jpg']: + date_prefix = '-'.join(parts[:3]) # 2026-01-08 + if date_prefix not in date_groups: + date_groups[date_prefix] = [] + date_groups[date_prefix].append(file) + + # Add date groups not already in database + for date_prefix, files in date_groups.items(): + if date_prefix not in upload_dates and len(files) >= 3: + # Sort files to ensure correct order + sorted_files = sorted(files) + image_set = { + "date": date_prefix, + "images": [ + f"/images/{account_number}/{sorted_files[0]}", + f"/images/{account_number}/{sorted_files[1]}", + f"/images/{account_number}/{sorted_files[2]}" + ] + } + image_sets.append(image_set) + + # Sort image sets by date descending (newest first) + def sort_key(item): + date_str = item['date'] + try: + if '_' in date_str: + return datetime.strptime(date_str, "%Y-%m-%d_%H-%M-%S") + else: + return datetime.strptime(date_str, "%Y-%m-%d") + except: + return datetime.min + + image_sets.sort(key=sort_key, reverse=True) + + return { + "account_number": account_number, + "image_sets": image_sets + } + +@router.post("/step1") +async def create_customer_step1(customer: CustomerCreateStep1, db: AsyncSession = Depends(get_db)): + while True: + random_part = generate_random_number_string(6) + account_number = 'AO-' + random_part + result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_number)) + existing_customer = result.scalar_one_or_none() + if not existing_customer: + break + + # Extract house_description for separate table + house_description = customer.house_description + + # Create customer + customer_data = customer.model_dump() + customer_data.pop('house_description') # Remove from customer data + customer_data.update({ + 'account_number': account_number, + 'customer_state': 1, # Default + 'customer_automatic': 0, + 'company_id': 1, # Default + 'customer_latitude': '0', + 'customer_longitude': '0', + 'correct_address': True, + 'customer_first_call': datetime.utcnow() + }) + db_customer = Customer_Customer(**customer_data) + db.add(db_customer) + await db.commit() + await db.refresh(db_customer) + + # Create customer description if house_description provided + if house_description: + db_description = Customer_Description( + customer_id=db_customer.id, + account_number=account_number, + company_id=1, # Default + fill_location=None, # Will work on this later + description=house_description + # tank_images is now tracked in customer_tank table + ) + db.add(db_description) + await db.commit() + + # Create customer stats record for tracking metrics + db_stats = Customer_Stats( + customer_id=db_customer.id + # All other fields default to 0/0.00 as defined in the model + ) + db.add(db_stats) + await db.commit() + + return {"account_number": account_number} + +@router.post("/step2", response_model=UserResponse) +async def create_customer_account(account_data: CustomerAccountCreate, db: AsyncSession = Depends(get_db)): + # Verify passwords match + if account_data.password != account_data.confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match") + + # Check if customer exists + result = await db.execute(select(Customer_Customer).where(Customer_Customer.account_number == account_data.account_number)) + customer = result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=400, detail="Customer not found") + + # Check if email already registered + result = await db.execute(select(Account_User).where(Account_User.email == account_data.customer_email)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + # Update customer email + customer.customer_email = account_data.customer_email + db.add(customer) + await db.commit() + + # Extract house number from customer address (first part before space) + house_number = customer.customer_address.split()[0] if customer.customer_address else '' + + # Create account user + username = account_data.account_number + hashed_password = get_password_hash(account_data.password) + db_user = Account_User( + username=username, + account_number=account_data.account_number, + house_number=house_number, + password_hash=hashed_password, + member_since=datetime.utcnow(), + email=account_data.customer_email, + last_seen=datetime.utcnow(), + admin=0, + admin_role=0, + confirmed=1, + active=1, + user_id=customer.id + ) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + return db_user diff --git a/routes/auth/register.py b/routes/auth/register.py new file mode 100644 index 0000000..c434b12 --- /dev/null +++ b/routes/auth/register.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from database import get_db +from models import Account_User, Customer_Customer +from schemas import UserCreate, UserResponse +from passlib.context import CryptContext +from datetime import datetime + +pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + +def get_password_hash(password): + return pwd_context.hash(password) + + +def escape_like_pattern(value: str) -> str: + """Escape special characters for SQL LIKE patterns. + + Escapes %, _, and \ which have special meaning in LIKE clauses + to prevent SQL injection via wildcards. + """ + # Escape backslash first, then the wildcards + return value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + + +router = APIRouter() + +@router.post("/register", response_model=UserResponse) +async def register(user: UserCreate, db: AsyncSession = Depends(get_db)): + # Verify passwords match + if user.password != user.confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match") + + # Check if customer exists in Customer_Customer table + # Escape SQL LIKE wildcards to prevent injection attacks + escaped_house_number = escape_like_pattern(user.house_number) + customer_result = await db.execute( + select(Customer_Customer).where( + (Customer_Customer.account_number == user.account_number) & + (Customer_Customer.customer_address.like(f'{escaped_house_number} %', escape='\\')) + ) + ) + customer = customer_result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=400, detail="Customer not found with provided account and house number") + + # Check if email already registered + result = await db.execute(select(Account_User).where(Account_User.email == user.email)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + username = f"{user.account_number}-{user.house_number}" + hashed_password = get_password_hash(user.password) + db_user = Account_User( + username=username, + account_number=user.account_number, + house_number=user.house_number, + password_hash=hashed_password, + member_since=datetime.utcnow(), + email=user.email, + last_seen=datetime.utcnow(), + admin=0, + admin_role=0, + confirmed=1, + active=1, + user_id=customer.id + ) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + return db_user diff --git a/routes/info/__init__.py b/routes/info/__init__.py new file mode 100644 index 0000000..10ccabc --- /dev/null +++ b/routes/info/__init__.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter +from .deliveries import router as deliveries_router +from .pricing import router as pricing_router + +router = APIRouter() +router.include_router(deliveries_router, tags=["info"]) +router.include_router(pricing_router, prefix="/pricing", tags=["pricing"]) diff --git a/routes/info/deliveries.py b/routes/info/deliveries.py new file mode 100644 index 0000000..70ae027 --- /dev/null +++ b/routes/info/deliveries.py @@ -0,0 +1,77 @@ +from fastapi import Depends, HTTPException, APIRouter +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc, func +from database import get_db +from models import Delivery_Delivery, Customer_Customer +from routes.auth.current_user import get_current_user +from models import Account_User + +router = APIRouter() + +STATUS_MAPPING = { + 0: "Waiting", + 1: "Cancelled", + 2: "Today", + 3: "Tomorrow_Delivery", + 4: "Partial_Delivery", + 5: "Issue_Delivery", + 9: "Pending", + 10: "Finalized" +} + +PAYMENT_MAPPING = { + 0: "Cash", + 1: "Credit", + 2: "Credit/Cash", + 3: "Check", + 4: "Other" +} + +@router.get("/deliveries") +async def get_user_deliveries( + current_user: Account_User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + if not current_user.user_id: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get customer from user_id + customer = await db.execute( + select(Customer_Customer).where(Customer_Customer.id == current_user.user_id) + ) + customer = customer.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get last 20 deliveries ordered by delivery date (when_delivered or when_ordered) desc + deliveries_query = await db.execute( + select(Delivery_Delivery) + .where(Delivery_Delivery.customer_id == customer.id) + .order_by(desc(func.coalesce(Delivery_Delivery.when_delivered, Delivery_Delivery.when_ordered))) + .limit(20) + ) + deliveries = deliveries_query.scalars().all() + + # Format deliveries + result = [] + for delivery in deliveries: + # Use when_delivered if available, otherwise when_ordered + delivery_date = delivery.when_delivered or delivery.when_ordered + # Use gallons_delivered if available, otherwise gallons_ordered + gallons = delivery.gallons_delivered if delivery.gallons_delivered is not None else delivery.gallons_ordered + + result.append({ + "id": delivery.id, + "when_ordered": delivery.when_ordered.isoformat() if delivery.when_ordered else None, + "when_delivered": delivery.when_delivered.isoformat() if delivery.when_delivered else None, + "delivery_date": delivery_date.isoformat() if delivery_date else None, + "gallons_ordered": delivery.gallons_ordered, + "gallons_delivered": float(delivery.gallons_delivered) if delivery.gallons_delivered else None, + "gallons": float(gallons) if gallons is not None else None, + "customer_price": float(delivery.customer_price) if delivery.customer_price else None, + "delivery_status": STATUS_MAPPING.get(delivery.delivery_status, f"Unknown ({delivery.delivery_status})"), + "payment_type": PAYMENT_MAPPING.get(delivery.payment_type, f"Unknown ({delivery.payment_type})"), + "dispatcher_notes": delivery.dispatcher_notes + }) + + return result diff --git a/routes/info/pricing.py b/routes/info/pricing.py new file mode 100644 index 0000000..c64ce24 --- /dev/null +++ b/routes/info/pricing.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from database import get_db +from models import Pricing_Oil_Oil + +router = APIRouter() + +@router.get("/current") +async def get_current_pricing(db: AsyncSession = Depends(get_db)): + # Get the latest pricing record + pricing = await db.execute( + select(Pricing_Oil_Oil).order_by(Pricing_Oil_Oil.id.desc()).limit(1) + ) + pricing = pricing.scalar_one_or_none() + + if not pricing: + return {"price_per_gallon": 0.00, "message": "No pricing available"} + + return { + "price_per_gallon": float(pricing.price_for_customer), + "date": pricing.date.isoformat() if pricing.date else None + } diff --git a/routes/order/__init__.py b/routes/order/__init__.py new file mode 100644 index 0000000..c9e67fc --- /dev/null +++ b/routes/order/__init__.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter +from . import deliveries + +router = APIRouter() +router.include_router(deliveries.router, prefix="/deliveries", tags=["deliveries"]) diff --git a/routes/order/deliveries.py b/routes/order/deliveries.py new file mode 100644 index 0000000..5614c40 --- /dev/null +++ b/routes/order/deliveries.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from datetime import date, datetime +from database import get_db +from models import Delivery_Delivery, Customer_Customer, Pricing_Oil_Oil +from schemas import DeliveryCreate +from routes.auth.current_user import get_current_user +from models import Account_User + +router = APIRouter() + +# State mapping for converting integer codes to string abbreviations +state_mapping = {0: "MA", 1: "NH"} # Add more as needed + +@router.post("/") +async def create_delivery( + delivery_data: DeliveryCreate, + current_user: Account_User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + if not current_user.user_id: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get customer details + customer = await db.execute( + select(Customer_Customer).where(Customer_Customer.id == current_user.user_id) + ) + customer = customer.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + # Validate gallons_ordered >= 100 + if delivery_data.gallons_ordered < 100: + raise HTTPException(status_code=400, detail="Gallons ordered must be at least 100") + + # Validate expected_delivery_date >= today + today = date.today() + if delivery_data.expected_delivery_date < today: + raise HTTPException(status_code=400, detail="Expected delivery date cannot be in the past") + + # Get latest oil pricing + oil = await db.execute( + select(Pricing_Oil_Oil).order_by(Pricing_Oil_Oil.id.desc()).limit(1) + ) + oil = oil.scalar_one_or_none() + if not oil: + raise HTTPException(status_code=500, detail="No oil pricing available") + + # Create delivery + new_delivery = Delivery_Delivery( + customer_id=customer.id, + customer_name=f"{customer.customer_first_name} {customer.customer_last_name}", + customer_address=customer.customer_address, + customer_town=customer.customer_town, + customer_state=str(customer.customer_state), + customer_zip=int(customer.customer_zip), + gallons_ordered=delivery_data.gallons_ordered, + gallons_delivered=None, + customer_filled=None, + delivery_status=0, # waiting + when_ordered=today, + when_delivered=None, + expected_delivery_date=delivery_data.expected_delivery_date, + automatic=None, + automatic_id=None, + oil_id=oil.id, + supplier_price=oil.price_for_customer, # assuming this is the price + customer_price=oil.price_for_customer, + dispatcher_notes=delivery_data.dispatcher_notes, + driver_employee_id=None, + driver_first_name=None, + driver_last_name=None, + promo_id=None, + promo_money_discount=None, + payment_type=delivery_data.payment_type, + # Set other fields to None or defaults + customer_asked_for_fill=0, + customer_temperature=None, + prime=delivery_data.prime, + same_day=delivery_data.same_day, + emergency=delivery_data.emergency, + payment_card_id=None, + cash_recieved=None, + pre_charge_amount=delivery_data.pre_charge_amount, + total_price=None, + final_price=None, + check_number=None, + ) + + db.add(new_delivery) + await db.commit() + await db.refresh(new_delivery) + + return {"message": "Delivery created successfully", "delivery_id": new_delivery.id} diff --git a/routes/payment/__init__.py b/routes/payment/__init__.py new file mode 100644 index 0000000..7efc711 --- /dev/null +++ b/routes/payment/__init__.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter +from . import routes + +router = APIRouter() +router.include_router(routes.router, tags=["payment"]) diff --git a/routes/payment/routes.py b/routes/payment/routes.py new file mode 100644 index 0000000..ab4290c --- /dev/null +++ b/routes/payment/routes.py @@ -0,0 +1,1003 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from decimal import Decimal +import requests +import logging +from typing import Dict, Any, Optional, Tuple + +from database import get_db +from models import Card, Transaction, Customer_Customer, Account_User +from schemas import PaymentCreate, PaymentResponse, CardCreate, CardResponse, CardUpdate +from routes.auth.current_user import get_current_user +from config import load_config + +router = APIRouter() +logger = logging.getLogger(__name__) + +# State code to abbreviation mapping +STATE_MAPPING = {0: "MA", 1: "NH"} # Add more states as needed + +# Authorize.net API endpoints +AUTHNET_SANDBOX_URL = "https://apitest.authorize.net/xml/v1/request.api" +AUTHNET_PRODUCTION_URL = "https://api.authorize.net/xml/v1/request.api" + + +def get_auth_net_credentials() -> Tuple[str, str]: + """Get Authorize.net API credentials from config.""" + config = load_config() + return config.AUTH_NET_API_LOGIN_ID, config.AUTH_NET_TRANSACTION_KEY + + +def get_authnet_url() -> str: + """Get the appropriate Authorize.net URL based on environment.""" + config = load_config() + # Use sandbox for development, production for prod + if hasattr(config, 'CURRENT_SETTINGS') and config.CURRENT_SETTINGS == 'PRODUCTION': + return AUTHNET_PRODUCTION_URL + return AUTHNET_SANDBOX_URL + + +def validate_payment_credentials(): + """Validate that payment credentials are configured.""" + config = load_config() + config.validate_payment_config() + + +def get_merchant_auth() -> Dict[str, str]: + """Get merchantAuthentication block for API requests.""" + api_login_id, transaction_key = get_auth_net_credentials() + return { + "name": api_login_id, + "transactionKey": transaction_key + } + + +def make_auth_net_request(request_type: str, request_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Make a request to Authorize.net API. + + Args: + request_type: The API request type (e.g., 'createCustomerProfileRequest') + request_data: The request-specific data (merchantAuthentication will be added) + + Returns: + The API response as a dictionary + + Raises: + HTTPException: If the API returns an error + """ + # Validate credentials are configured + try: + validate_payment_credentials() + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) + + url = get_authnet_url() + + # Build the full request payload with merchant authentication + payload = { + request_type: { + "merchantAuthentication": get_merchant_auth(), + **request_data + } + } + + headers = {"Content-Type": "application/json"} + + try: + response = requests.post(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + except requests.exceptions.Timeout: + logger.error("Authorize.net API timeout") + raise HTTPException(status_code=504, detail="Payment gateway timeout") + except requests.exceptions.RequestException as e: + logger.error(f"Authorize.net API request failed: {e}") + raise HTTPException(status_code=500, detail="Payment gateway error") + + # Parse response - Authorize.net returns JSON with a BOM sometimes + response_text = response.text.lstrip('\ufeff') + try: + result = requests.compat.json.loads(response_text) + except ValueError: + logger.error(f"Invalid JSON response from Authorize.net: {response_text[:200]}") + raise HTTPException(status_code=500, detail="Invalid payment gateway response") + + # Check for API-level errors + messages = result.get("messages", {}) + if messages.get("resultCode") == "Error": + error_messages = messages.get("message", []) + if error_messages: + error_code = error_messages[0].get("code", "Unknown") + error_text = error_messages[0].get("text", "Unknown error") + logger.error(f"Authorize.net error {error_code}: {error_text}") + raise HTTPException(status_code=400, detail=f"Payment error: {error_text}") + raise HTTPException(status_code=400, detail="Payment processing failed") + + return result + + +def build_bill_to_from_customer(customer: Customer_Customer) -> Dict[str, str]: + """ + Build the billTo object from customer data for Authorize.net API requests. + + Args: + customer: The customer record with address information + + Returns: + Dictionary with billing address fields for Authorize.net + """ + # Map state integer to abbreviation + state_abbrev = STATE_MAPPING.get(customer.customer_state, "") + + return { + "firstName": customer.customer_first_name or "", + "lastName": customer.customer_last_name or "", + "company": "", # No company field in customer model currently + "address": customer.customer_address or "", + "city": customer.customer_town or "", + "state": state_abbrev, + "zip": customer.customer_zip or "", + "country": "USA", + "phoneNumber": customer.customer_phone_number or "" + } + + +async def get_or_create_customer_profile( + db: AsyncSession, + customer: Customer_Customer +) -> str: + """ + Get existing customer profile ID or create a new one in Authorize.net CIM. + + Args: + db: Database session + customer: The customer record + + Returns: + The Authorize.net customer profile ID + """ + # Return existing profile if we have one + if customer.auth_net_profile_id: + return customer.auth_net_profile_id + + # Build email - use customer email or generate a placeholder + email = customer.customer_email or f"no-email-{customer.id}@example.com" + + # Create new customer profile with full address information + # Element order matters for Authorize.net XML schema: + # merchantCustomerId, description, email, paymentProfiles, shipToList, profileType + request_data = { + "profile": { + "merchantCustomerId": str(customer.id), + "description": f"{customer.customer_first_name} {customer.customer_last_name}".strip(), + "email": email, + "paymentProfiles": [], + "shipToList": [ + { + "firstName": customer.customer_first_name or "", + "lastName": customer.customer_last_name or "", + "address": customer.customer_address or "", + "city": customer.customer_town or "", + "state": STATE_MAPPING.get(customer.customer_state, ""), + "zip": customer.customer_zip or "", + "country": "USA", + "phoneNumber": customer.customer_phone_number or "" + } + ] + } + } + + response = make_auth_net_request("createCustomerProfileRequest", request_data) + + customer_profile_id = response.get("customerProfileId") + if not customer_profile_id: + raise HTTPException(status_code=500, detail="Failed to create customer profile") + + # Save the profile ID to the customer record + customer.auth_net_profile_id = customer_profile_id + await db.commit() + + logger.info(f"Created Authorize.net customer profile {customer_profile_id} for customer {customer.id}") + return customer_profile_id + + +async def create_payment_profile( + db: AsyncSession, + customer_profile_id: str, + card_details: CardCreate, + customer: Customer_Customer, +) -> Card: + """ + Create a payment profile (tokenize card) in Authorize.net CIM. + + Args: + db: Database session + customer_profile_id: The Authorize.net customer profile ID + card_details: The card information + customer: The customer record + + Returns: + The saved Card record with the payment profile ID + """ + # Build the billTo from customer data, with fallback to card name + bill_to = build_bill_to_from_customer(customer) + + # Override firstName/lastName from card if name_on_card is provided + if card_details.name_on_card: + name_parts = card_details.name_on_card.split() + bill_to["firstName"] = name_parts[0] if name_parts else bill_to["firstName"] + bill_to["lastName"] = " ".join(name_parts[1:]) if len(name_parts) > 1 else bill_to["lastName"] + + # Override zip from card details if provided (card billing zip may differ from service address) + if card_details.zip_code: + bill_to["zip"] = card_details.zip_code + + # Build the payment profile request + request_data = { + "customerProfileId": customer_profile_id, + "paymentProfile": { + "billTo": bill_to, + "payment": { + "creditCard": { + "cardNumber": card_details.card_number, + "expirationDate": f"{card_details.expiration_year}-{card_details.expiration_month.zfill(2)}", + "cardCode": card_details.security_number + } + } + }, + "validationMode": "testMode" # Use "liveMode" in production for $0 auth validation + } + + response = make_auth_net_request("createCustomerPaymentProfileRequest", request_data) + + payment_profile_id = response.get("customerPaymentProfileId") + if not payment_profile_id: + raise HTTPException(status_code=500, detail="Failed to create payment profile") + + # Extract last four digits + digits = ''.join(filter(str.isdigit, card_details.card_number)) + if len(digits) < 4: + raise HTTPException(status_code=400, detail="Invalid card number") + last_four = int(digits[-4:]) + + # Determine card type from first digit + card_type = None + if digits.startswith('4'): + card_type = 'Visa' + elif digits.startswith(('51', '52', '53', '54', '55')) or digits.startswith(('22', '23', '24', '25', '26', '27')): + card_type = 'Mastercard' + elif digits.startswith(('34', '37')): + card_type = 'American Express' + elif digits.startswith('6011') or digits.startswith('65'): + card_type = 'Discover' + + # Save the card record + saved_card = Card( + user_id=customer.id, + auth_net_payment_profile_id=payment_profile_id, + last_four_digits=last_four, + name_on_card=card_details.name_on_card, + expiration_month=card_details.expiration_month, + expiration_year=card_details.expiration_year, + zip_code=card_details.zip_code, + type_of_card=card_type, + accepted_or_declined=1, + ) + db.add(saved_card) + await db.commit() + await db.refresh(saved_card) + + logger.info(f"Created payment profile {payment_profile_id} for customer {customer.id}") + return saved_card + + +def update_customer_profile( + customer_profile_id: str, + customer: Customer_Customer +) -> bool: + """ + Update the customer profile in Authorize.net with current customer data. + + Args: + customer_profile_id: The Authorize.net customer profile ID + customer: The customer record with updated information + + Returns: + True if update was successful, False otherwise + """ + try: + email = customer.customer_email or f"no-email-{customer.id}@example.com" + + # Element order matters for Authorize.net XML schema + # For updateCustomerProfileRequest, only these fields can be updated + request_data = { + "profile": { + "merchantCustomerId": str(customer.id), + "description": f"{customer.customer_first_name} {customer.customer_last_name}".strip(), + "email": email, + "customerProfileId": customer_profile_id + } + } + + make_auth_net_request("updateCustomerProfileRequest", request_data) + print(f"[PAYMENT] Updated customer profile {customer_profile_id}") + return True + except Exception as e: + print(f"[PAYMENT] Failed to update customer profile {customer_profile_id}: {e}") + return False + + +def update_payment_profile_billing( + customer_profile_id: str, + payment_profile_id: str, + customer: Customer_Customer, + card: Card +) -> bool: + """ + Update the billing address on an existing payment profile. + + Args: + customer_profile_id: The Authorize.net customer profile ID + payment_profile_id: The Authorize.net payment profile ID + customer: The customer record with billing information + card: The card record with last four digits and expiration info + + Returns: + True if update was successful, False otherwise + """ + try: + bill_to = build_bill_to_from_customer(customer) + + # Override zip from card if it has a billing zip + if card.zip_code: + bill_to["zip"] = card.zip_code + + # Zero-pad last four digits (e.g., 2 -> "0002") + last_four_str = str(card.last_four_digits).zfill(4) + + print(f"[PAYMENT] Updating payment profile {payment_profile_id} with billTo: {bill_to}") + + # Element order matters for Authorize.net XML schema: + # paymentProfile must contain: billTo, payment, customerPaymentProfileId (in that order) + request_data = { + "customerProfileId": customer_profile_id, + "paymentProfile": { + "billTo": bill_to, + "payment": { + "creditCard": { + "cardNumber": f"XXXX{last_four_str}", + "expirationDate": f"{card.expiration_year}-{card.expiration_month.zfill(2)}" + } + }, + "customerPaymentProfileId": payment_profile_id + } + } + + make_auth_net_request("updateCustomerPaymentProfileRequest", request_data) + print(f"[PAYMENT] Updated billing info for payment profile {payment_profile_id}") + return True + except Exception as e: + print(f"[PAYMENT] Failed to update billing info for payment profile {payment_profile_id}: {e}") + return False + + +def charge_customer_profile( + customer_profile_id: str, + payment_profile_id: str, + amount: Decimal, + customer: Optional[Customer_Customer] = None, + order_description: Optional[str] = None +) -> Dict[str, Any]: + """ + Charge a saved card using customer and payment profile IDs. + + Args: + customer_profile_id: The Authorize.net customer profile ID + payment_profile_id: The Authorize.net payment profile ID + amount: The amount to charge + customer: Optional customer record for billing information + order_description: Optional order description + + Returns: + The transaction response + """ + request_data = { + "transactionRequest": { + "transactionType": "authOnlyTransaction", + "amount": str(amount), + "profile": { + "customerProfileId": customer_profile_id, + "paymentProfile": { + "paymentProfileId": payment_profile_id + } + }, + "tax": { + "amount": "0", + "name": "Tax Exempt", + "description": "Charity organization - tax exempt" + }, + "taxExempt": "true" + } + } + + # Add order description + if order_description: + request_data["transactionRequest"]["order"] = { + "description": order_description[:255] + } + + # Add customer info for transaction records (billTo cannot be sent with payment profile - + # billing address is already stored in the payment profile itself) + if customer: + request_data["transactionRequest"]["customer"] = { + "id": str(customer.id), + "email": customer.customer_email or f"no-email-{customer.id}@example.com" + } + + return make_auth_net_request("createTransactionRequest", request_data) + + +def charge_card_direct( + card_details: CardCreate, + amount: Decimal, + customer: Optional[Customer_Customer] = None, + order_description: Optional[str] = None +) -> Dict[str, Any]: + """ + Charge a card directly without saving it. + + Args: + card_details: The card information + amount: The amount to charge + customer: Optional customer record for billing information + order_description: Optional order description + + Returns: + The transaction response + """ + request_data = { + "transactionRequest": { + "transactionType": "authOnlyTransaction", + "amount": str(amount), + "payment": { + "creditCard": { + "cardNumber": card_details.card_number, + "expirationDate": f"{card_details.expiration_year}-{card_details.expiration_month.zfill(2)}", + "cardCode": card_details.security_number + } + }, + "tax": { + "amount": "0", + "name": "Tax Exempt", + "description": "Charity organization - tax exempt" + }, + "taxExempt": "true" + } + } + + # Add order description + if order_description: + request_data["transactionRequest"]["order"] = { + "description": order_description[:255] + } + + # Add customer and billing information (customer must come before billTo in schema) + if customer: + request_data["transactionRequest"]["customer"] = { + "id": str(customer.id), + "email": customer.customer_email or f"no-email-{customer.id}@example.com" + } + bill_to = build_bill_to_from_customer(customer) + # Override firstName/lastName from card if name_on_card is provided + if card_details.name_on_card: + name_parts = card_details.name_on_card.split() + bill_to["firstName"] = name_parts[0] if name_parts else bill_to["firstName"] + bill_to["lastName"] = " ".join(name_parts[1:]) if len(name_parts) > 1 else bill_to["lastName"] + # Override zip from card details if provided + if card_details.zip_code: + bill_to["zip"] = card_details.zip_code + request_data["transactionRequest"]["billTo"] = bill_to + + return make_auth_net_request("createTransactionRequest", request_data) + +@router.post("/process", response_model=PaymentResponse) +async def process_payment( + payment_data: PaymentCreate, + current_user: Account_User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Process a payment using Authorize.net. + + Supports two modes: + 1. New card: Provide card_details. Optionally set save_card=True to tokenize. + 2. Saved card: Provide card_id to charge an existing saved card. + """ + if not current_user.user_id: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get customer info + customer_result = await db.execute( + select(Customer_Customer).where(Customer_Customer.id == current_user.user_id) + ) + customer = customer_result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + auth_net_transaction_id = None + saved_card = None + card_id_used = payment_data.card_id + + try: + if payment_data.card_id: + # Charge a saved card using CIM profile + card_result = await db.execute( + select(Card).where( + Card.id == payment_data.card_id, + Card.user_id == current_user.user_id + ) + ) + saved_card = card_result.scalar_one_or_none() + if not saved_card: + raise HTTPException(status_code=404, detail="Saved card not found") + + if not saved_card.auth_net_payment_profile_id: + raise HTTPException(status_code=400, detail="Card is not properly tokenized") + + if not customer.auth_net_profile_id: + raise HTTPException(status_code=400, detail="Customer profile not found") + + # Log customer data for debugging + print(f"[PAYMENT] Processing payment for customer {customer.id}: " + f"name={customer.customer_first_name} {customer.customer_last_name}, " + f"address={customer.customer_address}, town={customer.customer_town}, " + f"state={customer.customer_state}, zip={customer.customer_zip}, " + f"phone={customer.customer_phone_number}") + + # Update customer profile with current customer data + update_customer_profile(customer.auth_net_profile_id, customer) + + # Update billing info on the payment profile (ensures existing cards have current address) + update_payment_profile_billing( + customer_profile_id=customer.auth_net_profile_id, + payment_profile_id=saved_card.auth_net_payment_profile_id, + customer=customer, + card=saved_card + ) + + # Charge using the saved profile + response = charge_customer_profile( + customer_profile_id=customer.auth_net_profile_id, + payment_profile_id=saved_card.auth_net_payment_profile_id, + amount=payment_data.amount, + customer=customer, + order_description=f"Delivery #{payment_data.delivery_id}" if payment_data.delivery_id else None + ) + + # Extract transaction ID from response + trans_response = response.get("transactionResponse", {}) + auth_net_transaction_id = trans_response.get("transId") + + elif payment_data.card_details: + # Process with new card details + if payment_data.card_details.save_card: + # Create/get customer profile and save the card first + customer_profile_id = await get_or_create_customer_profile(db, customer) + + # Create payment profile (tokenize the card) + saved_card = await create_payment_profile( + db=db, + customer_profile_id=customer_profile_id, + card_details=payment_data.card_details, + customer=customer, + ) + card_id_used = saved_card.id + + # Now charge using the newly created profile + response = charge_customer_profile( + customer_profile_id=customer_profile_id, + payment_profile_id=saved_card.auth_net_payment_profile_id, + amount=payment_data.amount, + customer=customer, + order_description=f"Delivery #{payment_data.delivery_id}" if payment_data.delivery_id else None + ) + + trans_response = response.get("transactionResponse", {}) + auth_net_transaction_id = trans_response.get("transId") + + else: + # One-time charge without saving the card + response = charge_card_direct( + card_details=payment_data.card_details, + amount=payment_data.amount, + customer=customer, + order_description=f"Delivery #{payment_data.delivery_id}" if payment_data.delivery_id else None + ) + + trans_response = response.get("transactionResponse", {}) + auth_net_transaction_id = trans_response.get("transId") + + else: + raise HTTPException(status_code=400, detail="Must provide either card_id or card_details") + + # Verify we got a transaction ID + if not auth_net_transaction_id: + raise HTTPException(status_code=500, detail="Transaction failed - no transaction ID returned") + + # Check transaction response code + trans_response = response.get("transactionResponse", {}) + response_code = trans_response.get("responseCode") + + # Response codes: 1 = Approved, 2 = Declined, 3 = Error, 4 = Held for Review + if response_code == "2": + # Declined + errors = trans_response.get("errors", []) + error_text = errors[0].get("errorText", "Transaction declined") if errors else "Transaction declined" + raise HTTPException(status_code=400, detail=f"Payment declined: {error_text}") + elif response_code == "3": + # Error + errors = trans_response.get("errors", []) + error_text = errors[0].get("errorText", "Transaction error") if errors else "Transaction error" + raise HTTPException(status_code=400, detail=f"Payment error: {error_text}") + elif response_code == "4": + # Held for review - still save the transaction but mark status differently + logger.warning(f"Transaction {auth_net_transaction_id} held for review") + + # Determine transaction status + status = 1 if response_code == "1" else (2 if response_code == "4" else 0) + + # Save transaction record + transaction_record = Transaction( + preauthorize_amount=payment_data.amount, + charge_amount=None, + customer_id=customer.id, + transaction_type=1, # pre-authorization + status=status, + auth_net_transaction_id=auth_net_transaction_id, + delivery_id=payment_data.delivery_id, + card_id=card_id_used, + payment_gateway=1, # Authorize.net + ) + db.add(transaction_record) + await db.commit() + + return PaymentResponse( + transaction_id=str(transaction_record.id), + status="approved" if response_code == "1" else "held_for_review", + amount=payment_data.amount, + auth_net_transaction_id=auth_net_transaction_id + ) + + except HTTPException: + await db.rollback() + raise + except Exception as e: + await db.rollback() + logger.error(f"Payment processing error: {e}") + raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}") + +@router.get("/cards", response_model=list[CardResponse]) +async def get_saved_cards( + current_user: Account_User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get all saved cards for the current customer.""" + if not current_user.user_id: + return [] + + cards = await db.execute( + select(Card).where(Card.user_id == current_user.user_id) + ) + return cards.scalars().all() + + +@router.post("/cards", response_model=CardResponse) +async def save_card( + card_details: CardCreate, + current_user: Account_User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Save a card without charging it (tokenize only). + + Creates a customer profile in Authorize.net CIM if one doesn't exist, + then creates a payment profile for the card. + """ + if not current_user.user_id: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get customer info + customer_result = await db.execute( + select(Customer_Customer).where(Customer_Customer.id == current_user.user_id) + ) + customer = customer_result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + try: + # Create/get customer profile + customer_profile_id = await get_or_create_customer_profile(db, customer) + + # Create payment profile (tokenize the card) + saved_card = await create_payment_profile( + db=db, + customer_profile_id=customer_profile_id, + card_details=card_details, + customer=customer, + ) + + return saved_card + + except HTTPException: + await db.rollback() + raise + except Exception as e: + await db.rollback() + logger.error(f"Error saving card: {e}") + raise HTTPException(status_code=500, detail=f"Error saving card: {str(e)}") + + +@router.delete("/cards/{card_id}") +async def delete_card( + card_id: int, + current_user: Account_User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Delete a saved card. + + Removes the payment profile from Authorize.net CIM and deletes the local record. + """ + if not current_user.user_id: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get the card (verify it belongs to this customer) + card_result = await db.execute( + select(Card).where( + Card.id == card_id, + Card.user_id == current_user.user_id + ) + ) + card = card_result.scalar_one_or_none() + if not card: + raise HTTPException(status_code=404, detail="Card not found") + + # Get customer for the Authorize.net profile ID + customer_result = await db.execute( + select(Customer_Customer).where(Customer_Customer.id == current_user.user_id) + ) + customer = customer_result.scalar_one_or_none() + + try: + # Delete from Authorize.net if we have the profile IDs + if card.auth_net_payment_profile_id and customer and customer.auth_net_profile_id: + request_data = { + "customerProfileId": customer.auth_net_profile_id, + "customerPaymentProfileId": card.auth_net_payment_profile_id + } + + try: + make_auth_net_request("deleteCustomerPaymentProfileRequest", request_data) + logger.info(f"Deleted payment profile {card.auth_net_payment_profile_id} from Authorize.net") + except HTTPException as e: + # Log but don't fail if Authorize.net deletion fails + # The profile might already be deleted or invalid + logger.warning(f"Failed to delete payment profile from Authorize.net: {e.detail}") + + # Delete the local record + await db.delete(card) + await db.commit() + + return {"message": "Card deleted successfully"} + + except Exception as e: + await db.rollback() + logger.error(f"Error deleting card: {e}") + raise HTTPException(status_code=500, detail=f"Error deleting card: {str(e)}") + + +@router.post("/cards/{card_id}/set-default") +async def set_default_card( + card_id: int, + current_user: Account_User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Set a card as the default payment method.""" + if not current_user.user_id: + raise HTTPException(status_code=404, detail="Customer not found") + + # Verify the card belongs to the customer + card_result = await db.execute( + select(Card).where( + Card.id == card_id, + Card.user_id == current_user.user_id + ) + ) + card = card_result.scalar_one_or_none() + if not card: + raise HTTPException(status_code=404, detail="Card not found") + + # Unset any existing default for this customer + all_cards = await db.execute( + select(Card).where(Card.user_id == current_user.user_id) + ) + for c in all_cards.scalars(): + c.main_card = False + + # Set this card as default + card.main_card = True + await db.commit() + + return {"message": "Default card updated successfully"} + + +@router.put("/cards/{card_id}", response_model=CardResponse) +async def update_card( + card_id: int, + card_update: CardUpdate, + current_user: Account_User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Update a saved card's details. + + Updates both the local database and syncs to Authorize.Net CIM. + Only name_on_card, expiration_month, expiration_year, and zip_code can be updated. + """ + if not current_user.user_id: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get the card (verify it belongs to this customer) + card_result = await db.execute( + select(Card).where( + Card.id == card_id, + Card.user_id == current_user.user_id + ) + ) + card = card_result.scalar_one_or_none() + if not card: + raise HTTPException(status_code=404, detail="Card not found") + + # Get customer for Authorize.net profile ID + customer_result = await db.execute( + select(Customer_Customer).where(Customer_Customer.id == current_user.user_id) + ) + customer = customer_result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + try: + # Update Authorize.Net payment profile if we have the profile IDs + if card.auth_net_payment_profile_id and customer.auth_net_profile_id: + # Build billTo from customer data + bill_to = build_bill_to_from_customer(customer) + + # Override firstName/lastName from card name if provided + name_on_card = card_update.name_on_card if card_update.name_on_card else card.name_on_card + if name_on_card: + name_parts = name_on_card.split() + bill_to["firstName"] = name_parts[0] if name_parts else bill_to["firstName"] + bill_to["lastName"] = " ".join(name_parts[1:]) if len(name_parts) > 1 else bill_to["lastName"] + + exp_month = card_update.expiration_month if card_update.expiration_month else card.expiration_month + exp_year = card_update.expiration_year if card_update.expiration_year else card.expiration_year + + # Override zip from card details if provided + if card_update.zip_code: + bill_to["zip"] = card_update.zip_code + elif card.zip_code: + bill_to["zip"] = card.zip_code + + # Zero-pad last four digits (e.g., 2 -> "0002") + last_four_str = str(card.last_four_digits).zfill(4) + + # Element order matters for Authorize.net XML schema + request_data = { + "customerProfileId": customer.auth_net_profile_id, + "paymentProfile": { + "billTo": bill_to, + "payment": { + "creditCard": { + "cardNumber": f"XXXX{last_four_str}", + "expirationDate": f"{exp_year}-{exp_month.zfill(2)}" + } + }, + "customerPaymentProfileId": card.auth_net_payment_profile_id + } + } + + try: + make_auth_net_request("updateCustomerPaymentProfileRequest", request_data) + print(f"[PAYMENT] Updated payment profile {card.auth_net_payment_profile_id} in Authorize.net") + except HTTPException as e: + print(f"[PAYMENT] Failed to update payment profile in Authorize.net: {e.detail}") + # Continue with local update even if Authorize.net update fails + + # Update local database fields + if card_update.name_on_card is not None: + card.name_on_card = card_update.name_on_card + if card_update.expiration_month is not None: + card.expiration_month = card_update.expiration_month + if card_update.expiration_year is not None: + card.expiration_year = card_update.expiration_year + if card_update.zip_code is not None: + card.zip_code = card_update.zip_code + + await db.commit() + await db.refresh(card) + + return card + + except HTTPException: + await db.rollback() + raise + except Exception as e: + await db.rollback() + logger.error(f"Error updating card: {e}") + raise HTTPException(status_code=500, detail=f"Error updating card: {str(e)}") + + +@router.post("/sync-billing-info") +async def sync_billing_info( + current_user: Account_User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Sync customer billing information to Authorize.net. + + Updates the customer profile and all payment profiles with current customer data. + Call this after updating customer address or contact information. + """ + if not current_user.user_id: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get customer info + customer_result = await db.execute( + select(Customer_Customer).where(Customer_Customer.id == current_user.user_id) + ) + customer = customer_result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + if not customer.auth_net_profile_id: + return {"message": "No Authorize.net profile to update", "updated_cards": 0} + + results = { + "customer_profile_updated": False, + "cards_updated": 0, + "cards_failed": 0 + } + + try: + # Update customer profile + if update_customer_profile(customer.auth_net_profile_id, customer): + results["customer_profile_updated"] = True + + # Get all saved cards for the customer + cards_result = await db.execute( + select(Card).where(Card.user_id == current_user.user_id) + ) + cards = cards_result.scalars().all() + + # Update billing info on each payment profile + for card in cards: + if card.auth_net_payment_profile_id: + if update_payment_profile_billing( + customer_profile_id=customer.auth_net_profile_id, + payment_profile_id=card.auth_net_payment_profile_id, + customer=customer, + card=card + ): + results["cards_updated"] += 1 + else: + results["cards_failed"] += 1 + + return { + "message": "Billing info sync completed", + **results + } + + except Exception as e: + logger.error(f"Error syncing billing info: {e}") + raise HTTPException(status_code=500, detail=f"Error syncing billing info: {str(e)}") diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..ff4f5f5 --- /dev/null +++ b/schemas.py @@ -0,0 +1,278 @@ +from pydantic import BaseModel, field_validator +from typing import Optional +from datetime import datetime, date +from decimal import Decimal + +# Customer schemas +class CustomerBase(BaseModel): + auth_net_profile_id: Optional[str] = None + account_number: str + customer_last_name: str + customer_first_name: str + customer_town: str + customer_state: int + customer_zip: str + customer_first_call: Optional[datetime] = None + customer_email: str + customer_automatic: int + customer_phone_number: str + customer_home_type: int + customer_apt: str + customer_address: str + company_id: int + customer_latitude: str + customer_longitude: str + correct_address: bool + +class CustomerCreate(BaseModel): + customer_first_name: str + customer_last_name: str + customer_phone_number: str + customer_email: str + customer_address: str + customer_apt: Optional[str] = None + customer_town: str + customer_zip: str + customer_home_type: int = 0 + personal_notes: Optional[str] = None + house_description: Optional[str] = None + +class CustomerCreateStep1(BaseModel): + customer_first_name: str + customer_last_name: str + customer_phone_number: str # Now accepts formatted: (123) 456-7890 + customer_address: str + customer_apt: Optional[str] = None + customer_town: str + customer_zip: str + customer_home_type: int = 0 + house_description: Optional[str] = None + +class CustomerAccountCreate(BaseModel): + account_number: str + password: str + confirm_password: str + customer_email: str + +class NewCustomerCreate(CustomerCreate): + password: str + confirm_password: str + +class CustomerUpdate(BaseModel): + auth_net_profile_id: Optional[str] = None + account_number: Optional[str] = None + customer_last_name: Optional[str] = None + customer_first_name: Optional[str] = None + customer_town: Optional[str] = None + customer_state: Optional[int] = None + customer_zip: Optional[str] = None + customer_first_call: Optional[datetime] = None + customer_email: Optional[str] = None + customer_automatic: Optional[int] = None + customer_phone_number: Optional[str] = None + customer_home_type: Optional[int] = None + customer_apt: Optional[str] = None + customer_address: Optional[str] = None + company_id: Optional[int] = None + customer_latitude: Optional[str] = None + customer_longitude: Optional[str] = None + correct_address: Optional[bool] = None + +class CustomerResponse(CustomerBase): + id: int + +# Order schemas (Delivery_Delivery) +class OrderBase(BaseModel): + customer_id: int + customer_name: str + customer_address: str + customer_town: str + customer_state: str + customer_zip: int + gallons_ordered: int + customer_asked_for_fill: int + gallons_delivered: Decimal + customer_filled: int + delivery_status: int + when_ordered: Optional[date] = None + when_delivered: Optional[date] = None + expected_delivery_date: Optional[date] = None + automatic: int + automatic_id: int + oil_id: int + supplier_price: Decimal + customer_price: Decimal + customer_temperature: Decimal + dispatcher_notes: str + prime: int + same_day: int + emergency: int + payment_type: int + payment_card_id: int + cash_recieved: Decimal + driver_employee_id: int + driver_first_name: str + driver_last_name: str + pre_charge_amount: Decimal + total_price: Decimal + final_price: Decimal + check_number: str + promo_id: int + promo_money_discount: Decimal + +class OrderCreate(OrderBase): + pass + +class OrderUpdate(BaseModel): + customer_id: Optional[int] = None + customer_name: Optional[str] = None + customer_address: Optional[str] = None + customer_town: Optional[str] = None + customer_state: Optional[str] = None + customer_zip: Optional[int] = None + gallons_ordered: Optional[int] = None + customer_asked_for_fill: Optional[int] = None + gallons_delivered: Optional[Decimal] = None + customer_filled: Optional[int] = None + delivery_status: Optional[int] = None + when_ordered: Optional[date] = None + when_delivered: Optional[date] = None + expected_delivery_date: Optional[date] = None + automatic: Optional[int] = None + automatic_id: Optional[int] = None + oil_id: Optional[int] = None + supplier_price: Optional[Decimal] = None + customer_price: Optional[Decimal] = None + customer_temperature: Optional[Decimal] = None + dispatcher_notes: Optional[str] = None + prime: Optional[int] = None + same_day: Optional[int] = None + emergency: Optional[int] = None + payment_type: Optional[int] = None + payment_card_id: Optional[int] = None + cash_recieved: Optional[Decimal] = None + driver_employee_id: Optional[int] = None + driver_first_name: Optional[str] = None + driver_last_name: Optional[str] = None + pre_charge_amount: Optional[Decimal] = None + total_price: Optional[Decimal] = None + final_price: Optional[Decimal] = None + check_number: Optional[str] = None + promo_id: Optional[int] = None + promo_money_discount: Optional[Decimal] = None + +class OrderResponse(OrderBase): + id: int + +class DeliveryCreate(BaseModel): + gallons_ordered: int + expected_delivery_date: date + dispatcher_notes: Optional[str] = None + payment_type: int # 0=cash, 1=credit + prime: Optional[int] = 0 + same_day: Optional[int] = 0 + emergency: Optional[int] = 0 + pre_charge_amount: Optional[float] = None + +# Card schemas +class CardBase(BaseModel): + auth_net_payment_profile_id: Optional[str] = None + card_number: Optional[str] = None + last_four_digits: int + name_on_card: Optional[str] = None + expiration_month: str + expiration_year: str + type_of_card: Optional[str] = None + security_number: Optional[str] = None + accepted_or_declined: Optional[int] = None + main_card: Optional[bool] = None + zip_code: Optional[str] = None + +class CardCreate(BaseModel): + card_number: str + name_on_card: str + expiration_month: str + expiration_year: str + security_number: str + zip_code: str + save_card: bool = False + +class CardResponse(CardBase): + id: int + date_added: datetime + user_id: int + +class CardUpdate(BaseModel): + name_on_card: Optional[str] = None + expiration_month: Optional[str] = None + expiration_year: Optional[str] = None + zip_code: Optional[str] = None + +# Payment schemas +class PaymentCreate(BaseModel): + amount: Decimal + card_id: Optional[int] = None # For saved cards + card_details: Optional[CardCreate] = None # For new cards + delivery_id: Optional[int] = None + +class PaymentResponse(BaseModel): + transaction_id: str + status: str + amount: Decimal + auth_net_transaction_id: str + +# Auth schemas +class UserCreate(BaseModel): + account_number: str + house_number: str + email: str + password: str + confirm_password: str + + @field_validator('house_number') + @classmethod + def validate_house_number(cls, v: str) -> str: + """Validate house_number contains only safe characters. + + Allows alphanumeric characters, spaces, hyphens, and forward slashes + (for addresses like "123-A" or "45 1/2"). + """ + import re + if not v or not v.strip(): + raise ValueError('House number cannot be empty') + # Allow alphanumeric, spaces, hyphens, forward slashes + if not re.match(r'^[a-zA-Z0-9\s\-/]+$', v): + raise ValueError('House number contains invalid characters') + if len(v) > 20: + raise ValueError('House number is too long') + return v.strip() + +class UserLogin(BaseModel): + email: str + password: str + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + username: Optional[str] = None + +class UserResponse(BaseModel): + id: int + username: str + email: str + member_since: datetime + last_seen: datetime + admin: int + admin_role: int + confirmed: int + active: int + +class ForgotPasswordRequest(BaseModel): + email: str + +class ResetPasswordRequest(BaseModel): + token: str + password: str + confirm_password: str diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..1af7d18 --- /dev/null +++ b/settings.py @@ -0,0 +1,154 @@ +""" +Unified configuration that reads secrets from environment variables. +Non-secret configuration (like CORS origins) can have defaults per environment. +""" +import os + + +class ApplicationConfig: + """ + Application configuration loaded from environment variables. + All secrets MUST be provided via environment variables. + """ + + # Current environment mode + CURRENT_SETTINGS = os.environ.get('MODE', 'DEVELOPMENT') + + # =========================================== + # DATABASE CONFIGURATION (Required) + # =========================================== + POSTGRES_USERNAME = os.environ.get('POSTGRES_USERNAME') + POSTGRES_PW = os.environ.get('POSTGRES_PASSWORD') + POSTGRES_SERVER = os.environ.get('POSTGRES_SERVER') + POSTGRES_PORT = os.environ.get('POSTGRES_PORT', '5432') + POSTGRES_DBNAME00 = os.environ.get('POSTGRES_DBNAME') + + # =========================================== + # JWT CONFIGURATION (Required) + # =========================================== + JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') + JWT_ALGORITHM = os.environ.get('JWT_ALGORITHM', 'HS256') + # Token expiry in minutes (default: 30 min for prod, can override for dev) + JWT_ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRE_MINUTES', '30')) + + # =========================================== + # AUTHORIZE.NET CONFIGURATION (Required for payments) + # =========================================== + AUTH_NET_API_LOGIN_ID = os.environ.get('AUTH_NET_API_LOGIN_ID') + AUTH_NET_TRANSACTION_KEY = os.environ.get('AUTH_NET_TRANSACTION_KEY') + + # =========================================== + # SMTP CONFIGURATION (Required for password reset) + # =========================================== + SMTP_SERVER = os.environ.get('SMTP_SERVER', 'smtp.gmail.com') + SMTP_PORT = int(os.environ.get('SMTP_PORT', '587')) + SMTP_USERNAME = os.environ.get('SMTP_USERNAME') + SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD') + SMTP_FROM_EMAIL = os.environ.get('SMTP_FROM_EMAIL') + + # =========================================== + # FRONTEND URL (Required for password reset links) + # =========================================== + FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000') + + # =========================================== + # CORS ORIGINS - Environment specific defaults + # =========================================== + _origins_env = os.environ.get('CORS_ORIGINS', '') + + if _origins_env: + # Use comma-separated list from environment + origins = [origin.strip() for origin in _origins_env.split(',') if origin.strip()] + else: + # Default origins based on MODE + _mode = os.environ.get('MODE', 'DEVELOPMENT') + if _mode == 'PRODUCTION': + origins = [ + "https://oil.edwineames.com", + "https://apiauto.edwineames.com", + "https://portal.auburnoil.com", + ] + elif _mode == 'LOCAL': + origins = [ + "http://192.168.1.204:9000", + "http://192.168.1.204:9613", + "http://192.168.1.204:9614", + "http://192.168.1.204:9612", + "http://192.168.1.204:9611", + ] + else: # DEVELOPMENT + origins = [ + "http://localhost:9000", + "https://localhost:9513", + "http://localhost:9514", + "http://localhost:9512", + "http://localhost:9511", + "http://localhost:5173", + "http://localhost:8000", + ] + + # Legacy compatibility - build SQLAlchemy URI if all parts provided + if all([POSTGRES_USERNAME, POSTGRES_PW, POSTGRES_SERVER, POSTGRES_DBNAME00]): + SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://{}:{}@{}:{}/{}".format( + POSTGRES_USERNAME, + POSTGRES_PW, + POSTGRES_SERVER, + POSTGRES_PORT, + POSTGRES_DBNAME00 + ) + SQLALCHEMY_BINDS = {POSTGRES_DBNAME00: SQLALCHEMY_DATABASE_URI} + else: + SQLALCHEMY_DATABASE_URI = None + SQLALCHEMY_BINDS = {} + + @classmethod + def validate_required(cls): + """Validate that all required environment variables are set.""" + required = { + 'POSTGRES_USERNAME': cls.POSTGRES_USERNAME, + 'POSTGRES_PASSWORD': cls.POSTGRES_PW, + 'POSTGRES_SERVER': cls.POSTGRES_SERVER, + 'POSTGRES_DBNAME': cls.POSTGRES_DBNAME00, + 'JWT_SECRET_KEY': cls.JWT_SECRET_KEY, + } + + missing = [key for key, value in required.items() if not value] + + if missing: + raise ValueError( + f"Missing required environment variables: {', '.join(missing)}\n" + "Please set these in your .env file or docker-compose environment." + ) + + # Warn about weak JWT secret in production + if cls.CURRENT_SETTINGS == 'PRODUCTION': + if cls.JWT_SECRET_KEY and len(cls.JWT_SECRET_KEY) < 32: + print("\033[93mWARNING: JWT_SECRET_KEY should be at least 32 characters for production\033[0m") + + return True + + @classmethod + def validate_payment_config(cls): + """Validate payment configuration (call before processing payments).""" + if not cls.AUTH_NET_API_LOGIN_ID or not cls.AUTH_NET_TRANSACTION_KEY: + raise ValueError( + "Payment processing requires AUTH_NET_API_LOGIN_ID and AUTH_NET_TRANSACTION_KEY" + ) + return True + + @classmethod + def validate_email_config(cls): + """Validate email configuration (call before sending emails).""" + required = { + 'SMTP_USERNAME': cls.SMTP_USERNAME, + 'SMTP_PASSWORD': cls.SMTP_PASSWORD, + 'SMTP_FROM_EMAIL': cls.SMTP_FROM_EMAIL, + } + + missing = [key for key, value in required.items() if not value] + + if missing: + raise ValueError( + f"Email sending requires: {', '.join(missing)}" + ) + return True diff --git a/settings_dev.py b/settings_dev.py new file mode 100644 index 0000000..6afd61a --- /dev/null +++ b/settings_dev.py @@ -0,0 +1,34 @@ + + +class ApplicationConfig: + """ + Basic Configuration for a generic User + """ + CURRENT_SETTINGS = 'DEVELOPMENT' + # databases info + POSTGRES_USERNAME = 'postgres' + POSTGRES_PW = 'password' + POSTGRES_SERVER = '192.168.1.204' + POSTGRES_PORT = '5432' + POSTGRES_DBNAME00 = 'eamco' + SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://{}:{}@{}/{}".format(POSTGRES_USERNAME, + POSTGRES_PW, + POSTGRES_SERVER, + POSTGRES_DBNAME00 + ) + SQLALCHEMY_BINDS = {'eamco': SQLALCHEMY_DATABASE_URI} + + origins = [ + "http://localhost:9000", + "https://localhost:9513", + "http://localhost:9514", + "http://localhost:9512", + "http://localhost:9511", + "http://localhost:5173", +] + + FRONTEND_URL = "http://localhost:3000" + + # Authorize.net credentials + AUTH_NET_API_LOGIN_ID = '9U6w96gZmX' + AUTH_NET_TRANSACTION_KEY = '94s6Qy458mMNJr7G' diff --git a/settings_local.py b/settings_local.py new file mode 100644 index 0000000..4da25e5 --- /dev/null +++ b/settings_local.py @@ -0,0 +1,31 @@ + + +class ApplicationConfig: + """ + Basic Configuration for a generic User + """ + CURRENT_SETTINGS = 'LOCAL' + # databases info + POSTGRES_USERNAME = 'postgres' + POSTGRES_PW = 'password' + POSTGRES_SERVER = '192.168.1.204' + POSTGRES_PORT = '5432' + POSTGRES_DBNAME00 = 'auburnoil' + SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://{}:{}@{}/{}".format(POSTGRES_USERNAME, + POSTGRES_PW, + POSTGRES_SERVER, + POSTGRES_DBNAME00 + ) + + SQLALCHEMY_BINDS = {'auburnoil': SQLALCHEMY_DATABASE_URI} + + + origins = [ + "http://192.168.1.204:9000", + "http://192.168.1.204:9613", + "http://192.168.1.204:9614", + "http://192.168.1.204:9612", + "http://192.168.1.204:9611", +] + + FRONTEND_URL = "http://localhost:3000" diff --git a/settings_prod.py b/settings_prod.py new file mode 100644 index 0000000..d72afac --- /dev/null +++ b/settings_prod.py @@ -0,0 +1,28 @@ +class ApplicationConfig: + """ + Basic Configuration for a generic User + """ + CURRENT_SETTINGS = 'PRODUCTION' + # databases info + POSTGRES_USERNAME = 'postgres' + POSTGRES_PW = 'password' + POSTGRES_SERVER = '192.168.1.204' + POSTGRES_PORT = '5432' + POSTGRES_DBNAME00 = 'auburnoil' + SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://{}:{}@{}/{}".format(POSTGRES_USERNAME, + POSTGRES_PW, + POSTGRES_SERVER, + POSTGRES_DBNAME00 + ) + SQLALCHEMY_BINDS = {'auburnoil': SQLALCHEMY_DATABASE_URI} + + origins = [ + "https://oil.edwineames.com", + "https://apiauto.edwineames.com", +] + + FRONTEND_URL = "https://portal.auburnoil.com" + + # Authorize.net credentials + AUTH_NET_API_LOGIN_ID = '4d2Mn6H23R' + AUTH_NET_TRANSACTION_KEY = '7B94d8xfTQXv37WS'