diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2b0aa5d..01a93062 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,9 +29,6 @@ env: ONSHAPE_API: ${{ secrets.ONSHAPE_API }} ONSHAPE_ACCESS_KEY: ${{ secrets.ONSHAPE_ACCESS_KEY }} ONSHAPE_SECRET_KEY: ${{ secrets.ONSHAPE_SECRET_KEY }} - STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} - STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} - STRIPE_CONNECT_WEBHOOK_SECRET: ${{ secrets.STRIPE_CONNECT_WEBHOOK_SECRET }} jobs: run-tests: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07674007..46a02129 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ To get started, clone the repository and check out the [Project Tracker](https://github.com/orgs/kscalelabs/projects/8/views/1). -You can contribute to the K-Scale Store project in various ways, such as reporting bugs, submitting pull requests, raising issues, or creating suggestions. +You can contribute to the K-Scale WWW project in various ways, such as reporting bugs, submitting pull requests, raising issues, or creating suggestions. > [!IMPORTANT] > You **MUST** access the locally run website through `127.0.0.1:3000` and **NOT** `localhost:3000`. This is because the CORS policy is configured to only allow requests from the exact domain `127.0.0.1:3000`. @@ -29,7 +29,6 @@ You can contribute to the K-Scale Store project in various ways, such as reporti 4. [Syncing Frontend and Backend](#syncing-frontend-and-backend) 5. [React Setup](#react-setup) 6. [Testing](#testing) -7. [Stripe Setup](#stripe-setup) --- @@ -37,7 +36,7 @@ You can contribute to the K-Scale Store project in various ways, such as reporti ### Configuration -Backend settings are located in the `store/settings/` directory. You can specify which settings to use by setting the `ENVIRONMENT` variable to the corresponding config file stem in `store/settings/configs/`. For local development, this should typically be set to `local`. +Backend settings are located in the `www/settings/` directory. You can specify which settings to use by setting the `ENVIRONMENT` variable to the corresponding config file stem in `www/settings/configs/`. For local development, this should typically be set to `local`. Place the required environment variables in `env.sh` or `.env.local`. @@ -80,12 +79,6 @@ export VITE_GOOGLE_CLIENT_ID='' # For OnShape export ONSHAPE_ACCESS_KEY='' export ONSHAPE_SECRET_KEY='' - -# For Stripe -export VITE_STRIPE_PUBLISHABLE_KEY='' -export STRIPE_SECRET_KEY='' -export STRIPE_WEBHOOK_SECRET='' -export STRIPE_CONNECT_WEBHOOK_SECRET='' ``` ### Google OAuth Configuration @@ -188,7 +181,7 @@ pip install -e '.[dev]' # Using vanilla pip If additional packages are missing, try: ```bash -uv pip install -r store/requirements.txt -r store/requirements-dev.txt # Using uv +uv pip install -r www/requirements.txt -r www/requirements-dev.txt # Using uv ``` ### Initializing the Test Databases @@ -196,7 +189,7 @@ uv pip install -r store/requirements.txt -r store/requirements-dev.txt # Using Initialize the test databases with: ```bash -python -m store.app.db create +python -m www.app.db create ``` ### Running the FastAPI Application @@ -204,7 +197,7 @@ python -m store.app.db create Serve the FastAPI application in development mode: ```bash -make start-backend +make start ``` ## Syncing Frontend and Backend @@ -253,28 +246,8 @@ To run tests, use the following commands: ```bash make test -make test-frontend # Run only the frontend tests -make test-backend # Run only the backend tests -``` - -## Stripe Setup - -Run this to recieve stripe webhooks locally: - -```bash -stripe listen --forward-to localhost:8080/stripe/webhook ``` -Run this to recieve stripe connect webhooks locally: - -```bash -stripe listen --forward-connect-to localhost:8080/stripe/connect/webhook -``` - -Make sure to set the `STRIPE_WEBHOOK_SECRET` and `STRIPE_CONNECT_WEBHOOK_SECRET` environment variables to the values -shown in the terminal and source it to the terminal you are running -`make start-backend` in. - ## Optional Install pre-commit from [here](https://pre-commit.com/) to run the formatting and static checks automatically when you commit. diff --git a/env.sh.example b/env.sh.example index 1c5c0034..96da07a5 100644 --- a/env.sh.example +++ b/env.sh.example @@ -29,9 +29,3 @@ export GOOGLE_CLIENT_SECRET='' # For OnShape export ONSHAPE_ACCESS_KEY='' export ONSHAPE_SECRET_KEY='' - -# For Stripe developer mode. -export VITE_STRIPE_PUBLISHABLE_KEY='' -export STRIPE_SECRET_KEY='' -export STRIPE_WEBHOOK_SECRET='' -export STRIPE_CONNECT_WEBHOOK_SECRET='' diff --git a/tests/test_images.py b/tests/test_images.py index 8b64629a..2de8eda5 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -23,7 +23,6 @@ def test_user_auth_functions(test_client: TestClient, tmpdir: Path) -> None: "child_ids": "", "slug": "test-listing", "username": "testuser", - "stripe_link": "", }, headers=auth_headers, ) diff --git a/tests/test_krec.py b/tests/test_krec.py index 19224495..9fb843b7 100644 --- a/tests/test_krec.py +++ b/tests/test_krec.py @@ -24,7 +24,6 @@ async def test_krec_upload(test_client: TestClient, tmpdir: Path) -> None: "child_ids": "", "slug": "test-listing", "username": "testuser", - "stripe_link": "", }, headers=auth_headers, ) diff --git a/tests/test_listings.py b/tests/test_listings.py index 06232a89..84ce3a93 100644 --- a/tests/test_listings.py +++ b/tests/test_listings.py @@ -25,7 +25,6 @@ def test_listings(test_client: TestClient, tmpdir: Path) -> None: "child_ids": "", "slug": "test-listing", "username": "testuser", - "stripe_link": "", }, headers=auth_headers, ) diff --git a/www/app/crud/listings.py b/www/app/crud/listings.py index 2901b710..fdc766a5 100644 --- a/www/app/crud/listings.py +++ b/www/app/crud/listings.py @@ -28,7 +28,7 @@ class ListingsCrud(ArtifactsCrud, BaseCrud): @classmethod def get_gsis(cls) -> set[str]: - return super().get_gsis().union({"listing_id", "name", "stripe_product_id"}) + return super().get_gsis().union({"listing_id", "name"}) @overload async def get_listing(self, listing_id: str, throw_if_missing: Literal[True]) -> Listing: ... @@ -158,14 +158,6 @@ async def edit_listing( tags: list[str] | None = None, onshape_url: str | None = None, slug: str | None = None, - price_amount: int | None = None, - stripe_product_id: str | None = None, - stripe_price_id: str | None = None, - preorder_release_date: int | None = None, - preorder_deposit_amount: int | None = None, - stripe_preorder_deposit_id: str | None = None, - inventory_type: str | None = None, - inventory_quantity: int | None = None, ) -> None: listing = await self.get_listing(listing_id) if listing is None: @@ -180,22 +172,6 @@ async def edit_listing( updates["description"] = description if slug is not None: updates["slug"] = slug - if price_amount is not None: - updates["price_amount"] = price_amount - if stripe_product_id is not None: - updates["stripe_product_id"] = stripe_product_id - if stripe_price_id is not None: - updates["stripe_price_id"] = stripe_price_id - if preorder_release_date is not None: - updates["preorder_release_date"] = preorder_release_date - if preorder_deposit_amount is not None: - updates["preorder_deposit_amount"] = preorder_deposit_amount - if stripe_preorder_deposit_id is not None: - updates["stripe_preorder_deposit_id"] = stripe_preorder_deposit_id - if inventory_type is not None: - updates["inventory_type"] = inventory_type - if inventory_quantity is not None: - updates["inventory_quantity"] = inventory_quantity coroutines = [] if tags is not None: @@ -412,11 +388,3 @@ async def set_featured_listings(self, listing_ids: list[str]) -> None: "updated_at": int(time.time()), } ) - - async def get_listing_by_stripe_product_id(self, stripe_product_id: str) -> Listing | None: - """Get a listing by its stripe product ID.""" - listings = await self._get_items_from_secondary_index( - secondary_index_name="stripe_product_id", secondary_index_value=stripe_product_id, item_class=Listing - ) - # stripe_product_id should be unique, return the first item if it exists - return listings[0] if listings else None diff --git a/www/app/crud/orders.py b/www/app/crud/orders.py deleted file mode 100644 index 39c8bf6b..00000000 --- a/www/app/crud/orders.py +++ /dev/null @@ -1,130 +0,0 @@ -"""This module provides CRUD operations for orders.""" - -import logging -from typing import NotRequired, TypedDict - -from pydantic import ValidationError - -from www.app.crud.base import BaseCrud, ItemNotFoundError -from www.app.model import InventoryType, Order, OrderStatus - -logger = logging.getLogger(__name__) - - -class OrderDataCreate(TypedDict): - user_id: str - listing_id: str - user_email: str - quantity: int - price_amount: int - currency: str - stripe_checkout_session_id: str - stripe_product_id: str - stripe_price_id: str - stripe_connect_account_id: str - stripe_payment_intent_id: str - preorder_release_date: NotRequired[int | None] - preorder_deposit_amount: NotRequired[int | None] - stripe_preorder_deposit_id: NotRequired[str | None] - status: NotRequired[OrderStatus] - inventory_type: NotRequired[InventoryType] - shipping_name: NotRequired[str] - shipping_address_line1: NotRequired[str] - shipping_address_line2: NotRequired[str] - shipping_city: NotRequired[str] - shipping_state: NotRequired[str] - shipping_postal_code: NotRequired[str] - shipping_country: NotRequired[str] - - -class OrderDataUpdate(TypedDict): - updated_at: NotRequired[int] - status: NotRequired[OrderStatus] - stripe_connect_account_id: NotRequired[str] - stripe_checkout_session_id: NotRequired[str] - final_payment_checkout_session_id: NotRequired[str] - final_payment_intent_id: NotRequired[str] - final_payment_date: NotRequired[int] - stripe_refund_id: NotRequired[str] - shipping_name: NotRequired[str] - shipping_address_line1: NotRequired[str] - shipping_address_line2: NotRequired[str | None] - shipping_city: NotRequired[str] - shipping_state: NotRequired[str] - shipping_postal_code: NotRequired[str] - shipping_country: NotRequired[str] - - -class ProcessPreorderData(TypedDict): - stripe_connect_account_id: str - stripe_checkout_session_id: str - final_payment_checkout_session_id: str - status: OrderStatus - updated_at: int - - -class OrdersNotFoundError(ItemNotFoundError): - """Raised when no orders are found for a user.""" - - pass - - -class OrdersCrud(BaseCrud): - """CRUD operations for Orders.""" - - async def create_order(self, order_data: OrderDataCreate) -> Order: - order = Order.create(**order_data) - await self._add_item(order) - return order - - async def get_order(self, order_id: str) -> Order | None: - return await self._get_item(order_id, Order, throw_if_missing=False) - - async def get_orders_by_user_id(self, user_id: str) -> list[Order]: - orders = await self._get_items_from_secondary_index("user_id", user_id, Order) - if not orders: - raise OrdersNotFoundError(f"No orders found for user {user_id}") - return orders - - async def update_order(self, order_id: str, update_data: OrderDataUpdate) -> Order: - order = await self.get_order(order_id) - if not order: - raise ItemNotFoundError("Order not found") - - current_data = order.model_dump() - update_dict = dict(update_data) - current_data.update(update_dict) - - try: - Order(**current_data) - except ValidationError as e: - raise ValueError(f"Invalid update data: {str(e)}") - - await self._update_item(order_id, Order, update_dict) - updated_order = await self.get_order(order_id) - if not updated_order: - raise ItemNotFoundError("Updated order not found") - return updated_order - - async def process_preorder(self, order_id: str, update_data: ProcessPreorderData) -> Order: - order = await self.get_order(order_id) - if not order: - raise ItemNotFoundError("Order not found") - - current_data = order.model_dump() - update_dict = dict(update_data) - current_data.update(update_dict) - - try: - Order(**current_data) - except ValidationError as e: - raise ValueError(f"Invalid update data: {str(e)}") - - await self._update_item(order_id, Order, update_dict) - updated_order = await self.get_order(order_id) - if not updated_order: - raise ItemNotFoundError("Updated order not found") - return updated_order - - async def dump_orders(self) -> list[Order]: - return await self._list_items(Order) diff --git a/www/app/crud/robots.py b/www/app/crud/robots.py index 7d7f8141..a5683337 100644 --- a/www/app/crud/robots.py +++ b/www/app/crud/robots.py @@ -4,7 +4,7 @@ from typing import NotRequired, TypedDict, Unpack from www.app.crud.base import BaseCrud, ItemNotFoundError -from www.app.model import Listing, Order, Robot +from www.app.model import Listing, Robot class RobotData(TypedDict): @@ -12,7 +12,6 @@ class RobotData(TypedDict): listing_id: str name: str description: NotRequired[str | None] - order_id: NotRequired[str | None] updated_at: NotRequired[int] @@ -21,7 +20,7 @@ class RobotsCrud(BaseCrud): @classmethod def get_gsis(cls) -> set[str]: - return super().get_gsis().union({"listing_id", "order_id"}) + return super().get_gsis().union({"listing_id"}) async def create_robot(self, **robot_data: Unpack[RobotData]) -> Robot: # Verify listing exists @@ -29,20 +28,11 @@ async def create_robot(self, **robot_data: Unpack[RobotData]) -> Robot: if not listing: raise ItemNotFoundError(f"Listing with ID {robot_data['listing_id']} not found") - # Verify order exists if order_id is provided - if order_id := robot_data.get("order_id"): - order = await self._get_item(order_id, Order) - if not order: - raise ItemNotFoundError(f"Order with ID {order_id} not found") - if order.user_id != robot_data["user_id"]: - raise ItemNotFoundError(f"Order with ID {order_id} does not belong to this user") - robot = Robot.create( user_id=robot_data["user_id"], listing_id=robot_data["listing_id"], name=robot_data["name"], description=robot_data.get("description"), - order_id=robot_data.get("order_id"), ) await self._add_item(robot) return robot @@ -66,14 +56,6 @@ async def update_robot(self, robot_id: str, update_data: RobotData) -> Robot: if not robot: raise ItemNotFoundError("Robot not found") - # Verify order exists if order_id is being updated - if order_id := update_data.get("order_id"): - order = await self._get_item(order_id, Order) - if not order: - raise ItemNotFoundError(f"Order with ID {order_id} not found") - if order.user_id != robot.user_id: - raise ItemNotFoundError(f"Order with ID {order_id} does not belong to this user") - update_data["updated_at"] = int(time.time()) # Convert TypedDict to regular dict update_dict = dict(update_data) @@ -84,7 +66,3 @@ async def update_robot(self, robot_id: str, update_data: RobotData) -> Robot: async def delete_robot(self, robot: Robot) -> None: await self._delete_item(robot) - - async def get_robot_by_order_id(self, order_id: str) -> Robot | None: - robots = await self._get_items_from_secondary_index("order_id", order_id, Robot) - return robots[0] if robots else None diff --git a/www/app/crud/users.py b/www/app/crud/users.py index 4945db24..49e30b52 100644 --- a/www/app/crud/users.py +++ b/www/app/crud/users.py @@ -4,7 +4,6 @@ import logging import random import string -import time import warnings from typing import Any, Literal, Optional, overload @@ -21,7 +20,6 @@ OAuthKey, User, UserPermission, - UserStripeConnect, ) from www.settings import settings from www.utils import cache_async_result @@ -59,7 +57,6 @@ class UserPublic(BaseModel): last_name: str | None = None name: str | None = None bio: str | None = None - stripe_connect: UserStripeConnect | None = None class UserCrud(BaseCrud): @@ -292,17 +289,6 @@ async def generate_unique_username(self, base: str) -> str: username = f"{base}{random_suffix}" return username - async def update_stripe_connect_status(self, user_id: str, account_id: str, is_completed: bool) -> User: - stripe_connect = UserStripeConnect( - account_id=account_id, - onboarding_completed=is_completed, - ) - updates = { - "stripe_connect": stripe_connect.model_dump(), - "updated_at": int(time.time()), - } - return await self.update_user(user_id, updates) - async def set_content_manager(self, user_id: str, is_content_manager: bool) -> User: user = await self.get_user(user_id, throw_if_missing=True) if user.permissions is None: diff --git a/www/app/db.py b/www/app/db.py index 1f3e00ca..436deecd 100644 --- a/www/app/db.py +++ b/www/app/db.py @@ -11,7 +11,6 @@ from www.app.crud.krecs import KRecsCrud from www.app.crud.listings import ListingsCrud from www.app.crud.onshape import OnshapeCrud -from www.app.crud.orders import OrdersCrud from www.app.crud.robots import RobotsCrud from www.app.crud.teleop import TeleopCrud from www.app.crud.users import UserCrud @@ -23,7 +22,6 @@ class Crud( UserCrud, ListingsCrud, ArtifactsCrud, - OrdersCrud, KRecsCrud, RobotsCrud, TeleopCrud, diff --git a/www/app/main.py b/www/app/main.py index 58cb62ec..0ff7c5d2 100644 --- a/www/app/main.py +++ b/www/app/main.py @@ -24,9 +24,7 @@ from www.app.routers.krecs import router as krecs_router from www.app.routers.listings import router as listings_router from www.app.routers.onshape import router as onshape_router -from www.app.routers.orders import router as orders_router from www.app.routers.robots import router as robots_router -from www.app.routers.stripe import router as stripe_router from www.app.routers.teleop import router as teleop_router from www.app.routers.users import router as users_router from www.utils import get_cors_origins @@ -130,9 +128,7 @@ async def validate_auth_token(auth_token: str = Depends(api_key_header)) -> str: app.include_router(keys_router, prefix="/keys", tags=["keys"]) app.include_router(listings_router, prefix="/listings", tags=["listings"]) app.include_router(onshape_router, prefix="/onshape", tags=["onshape"]) -app.include_router(orders_router, prefix="/orders", tags=["orders"]) app.include_router(robots_router, prefix="/robots", tags=["robots"]) -app.include_router(stripe_router, prefix="/stripe", tags=["stripe"]) app.include_router(users_router, prefix="/users", tags=["users"]) app.include_router(teleop_router, prefix="/teleop", tags=["teleop"]) app.include_router(krecs_router, prefix="/krecs", tags=["krecs"]) diff --git a/www/app/model.py b/www/app/model.py index 1f744d6a..a38a378c 100644 --- a/www/app/model.py +++ b/www/app/model.py @@ -31,13 +31,6 @@ class StoreBaseModel(BaseModel): UserPermission = Literal["is_admin", "is_mod", "is_content_manager", "is_verified_member"] -class UserStripeConnect(BaseModel): - """Defines information for the user's Stripe Connect account.""" - - account_id: str - onboarding_completed: bool - - class User(StoreBaseModel): """Defines the user model for the API. @@ -58,7 +51,6 @@ class User(StoreBaseModel): last_name: str | None = None name: str | None = None bio: str | None = None - stripe_connect: UserStripeConnect | None = None @classmethod def create( @@ -100,13 +92,6 @@ def set_username(self, new_username: str) -> None: self.username = new_username self.update_timestamp() - def set_stripe_connect(self, account_id: str, onboarding_completed: bool) -> None: - self.stripe_connect = UserStripeConnect( - account_id=account_id, - onboarding_completed=onboarding_completed, - ) - self.update_timestamp() - class EmailSignUpToken(StoreBaseModel): """Object created when user attempts to sign up with email. @@ -372,15 +357,6 @@ class Listing(StoreBaseModel): onshape_url: str | None = None views: int = 0 score: int = 0 - price_amount: int | None = None # in cents - currency: str = "usd" - stripe_product_id: str | None = None - stripe_price_id: str | None = None - preorder_deposit_amount: int | None = None # in cents - stripe_preorder_deposit_id: str | None = None - inventory_type: Literal["finite", "preorder"] = "finite" - inventory_quantity: int | None = None - preorder_release_date: int | None = None @classmethod def create( @@ -391,15 +367,6 @@ def create( child_ids: list[str], description: str | None = None, onshape_url: str | None = None, - price_amount: int | None = None, - currency: str = "usd", - stripe_product_id: str | None = None, - stripe_price_id: str | None = None, - preorder_deposit_amount: int | None = None, - stripe_preorder_deposit_id: str | None = None, - inventory_type: Literal["finite", "preorder"] = "finite", - inventory_quantity: int | None = None, - preorder_release_date: int | None = None, ) -> Self: return cls( id=new_uuid(), @@ -413,15 +380,6 @@ def create( onshape_url=onshape_url, views=0, score=0, - price_amount=price_amount, - currency=currency, - stripe_product_id=stripe_product_id, - stripe_price_id=stripe_price_id, - preorder_deposit_amount=preorder_deposit_amount, - stripe_preorder_deposit_id=stripe_preorder_deposit_id, - inventory_type=inventory_type, - inventory_quantity=inventory_quantity, - preorder_release_date=preorder_release_date, ) @@ -557,116 +515,6 @@ def create(cls, user_id: str, listing_id: str, is_upvote: bool) -> Self: ) -OrderStatus = Literal[ - "processing", - "in_development", - "being_assembled", - "shipped", - "delivered", - "preorder_placed", - "awaiting_final_payment", - "cancelled", - "refunded", -] - -InventoryType = Literal["finite", "preorder"] - - -class Order(StoreBaseModel): - """Tracks completed user orders through Stripe.""" - - user_id: str - listing_id: str - user_email: str - created_at: int - updated_at: int - status: OrderStatus - price_amount: int # in cents - currency: str - quantity: int - stripe_checkout_session_id: str - stripe_connect_account_id: str - stripe_product_id: str - stripe_price_id: str - stripe_payment_intent_id: str - preorder_release_date: int | None = None - preorder_deposit_amount: int | None = None - stripe_preorder_deposit_id: str | None = None - inventory_type: Literal["finite", "preorder"] - final_payment_checkout_session_id: str | None = None - final_payment_intent_id: str | None = None - final_payment_date: int | None = None - shipping_name: str | None = None - shipping_address_line1: str | None = None - shipping_address_line2: str | None = None - shipping_city: str | None = None - shipping_state: str | None = None - shipping_postal_code: str | None = None - shipping_country: str | None = None - shipped_date: int | None = None - stripe_refund_id: str | None = None - delivered_date: int | None = None - cancelled_date: int | None = None - refunded_date: int | None = None - - @classmethod - def create( - cls, - user_id: str, - user_email: str, - listing_id: str, - stripe_checkout_session_id: str, - stripe_product_id: str, - stripe_price_id: str, - stripe_connect_account_id: str, - quantity: int, - price_amount: int, - currency: str, - stripe_payment_intent_id: str, - preorder_release_date: int | None = None, - preorder_deposit_amount: int | None = None, - stripe_preorder_deposit_id: str | None = None, - status: OrderStatus = "processing", - inventory_type: Literal["finite", "preorder"] = "finite", - shipping_name: str | None = None, - shipping_address_line1: str | None = None, - shipping_address_line2: str | None = None, - shipping_city: str | None = None, - shipping_state: str | None = None, - shipping_postal_code: str | None = None, - shipping_country: str | None = None, - ) -> Self: - now = int(time.time()) - return cls( - id=new_uuid(), - user_id=user_id, - listing_id=listing_id, - user_email=user_email, - created_at=now, - updated_at=now, - status=status, - price_amount=price_amount, - currency=currency, - quantity=quantity, - stripe_checkout_session_id=stripe_checkout_session_id, - stripe_product_id=stripe_product_id, - stripe_price_id=stripe_price_id, - stripe_connect_account_id=stripe_connect_account_id, - stripe_payment_intent_id=stripe_payment_intent_id, - preorder_release_date=preorder_release_date, - preorder_deposit_amount=preorder_deposit_amount, - stripe_preorder_deposit_id=stripe_preorder_deposit_id, - inventory_type=inventory_type, - shipping_name=shipping_name, - shipping_address_line1=shipping_address_line1, - shipping_address_line2=shipping_address_line2, - shipping_city=shipping_city, - shipping_state=shipping_state, - shipping_postal_code=shipping_postal_code, - shipping_country=shipping_country, - ) - - class Robot(StoreBaseModel): """User registered robots. Associated with a robot listing. diff --git a/www/app/routers/listings.py b/www/app/routers/listings.py index 54e381c9..187accda 100644 --- a/www/app/routers/listings.py +++ b/www/app/routers/listings.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Annotated, Literal +from typing import Annotated from fastapi import ( APIRouter, @@ -20,7 +20,6 @@ from www.app.db import Crud from www.app.model import Listing, User, can_write_listing from www.app.routers.artifacts import SingleArtifactResponse -from www.app.routers.stripe import create_listing_product from www.app.security.user import ( get_session_user_with_read_permission, get_session_user_with_write_permission, @@ -146,10 +145,6 @@ class ListingInfoResponse(BaseModel): views: int score: int user_vote: bool | None - price_amount: int | None - currency: str | None - inventory_type: Literal["finite", "preorder"] | None - inventory_quantity: int | None class GetBatchListingsResponse(BaseModel): @@ -210,10 +205,6 @@ async def get_batch_listing_info( views=listing.views, score=listing.score, user_vote=user_votes.get(listing.id), - price_amount=listing.price_amount, - currency=listing.currency, - inventory_type=listing.inventory_type, - inventory_quantity=listing.inventory_quantity, ) listing_responses.append(listing_response) except Exception as e: @@ -277,46 +268,11 @@ async def add_listing( description: str | None = Form(None), child_ids: str = Form(""), slug: str = Form(...), - price_amount: str | None = Form(None), - currency: str = Form("usd"), - inventory_type: Literal["finite", "preorder"] = Form("finite"), - inventory_quantity: str | None = Form(None), - preorder_deposit_amount: str | None = Form(None), - preorder_release_date: str | None = Form(None), photos: list[UploadFile] = File(None), ) -> NewListingResponse: try: logger.info("Starting to process add listing request") - # Convert string values to appropriate types - price_amount_int = int(price_amount) if price_amount else None - inventory_quantity_int = int(inventory_quantity) if inventory_quantity else None - preorder_release_date_int = int(float(preorder_release_date)) if preorder_release_date else None - preorder_deposit_amount_int = int(preorder_deposit_amount) if preorder_deposit_amount else None - - # Initialize Stripe-related variables - stripe_product_id = None - stripe_price_id = None - stripe_preorder_deposit_id = None - - # Create Stripe product if price is set and user has Stripe Connect setup - if price_amount_int is not None and user.stripe_connect and user.stripe_connect.account_id: - stripe_product = await create_listing_product( - name=name, - description=description or "", - price_amount=price_amount_int, - currency=currency, - inventory_type=inventory_type, - inventory_quantity=inventory_quantity_int, - preorder_release_date=preorder_release_date_int, - preorder_deposit_amount=preorder_deposit_amount_int, - user_id=user.id, - stripe_connect_account_id=user.stripe_connect.account_id, - ) - stripe_product_id = stripe_product.stripe_product_id - stripe_price_id = stripe_product.stripe_price_id - stripe_preorder_deposit_id = stripe_product.stripe_preorder_deposit_id - # Create the listing listing = Listing.create( name=name, @@ -324,15 +280,6 @@ async def add_listing( child_ids=child_ids.split(",") if child_ids else [], slug=slug, user_id=user.id, - price_amount=price_amount_int, - currency=currency, - inventory_type=inventory_type, - inventory_quantity=inventory_quantity_int, - preorder_release_date=preorder_release_date_int, - preorder_deposit_amount=preorder_deposit_amount_int, - stripe_product_id=stripe_product_id, - stripe_price_id=stripe_price_id, - stripe_preorder_deposit_id=stripe_preorder_deposit_id, ) await crud.add_listing(listing) @@ -386,15 +333,6 @@ class UpdateListingRequest(BaseModel): tags: list[str] | None = None onshape_url: str | None = None slug: str | None = None - stripe_product_id: str | None = None - stripe_price_id: str | None = None - stripe_deposit_price_id: str | None = None - price_amount: int | None = None - preorder_release_date: int | None = None - preorder_deposit_amount: int | None = None - stripe_preorder_deposit_id: str | None = None - inventory_type: Literal["finite", "preorder"] | None = None - inventory_quantity: int | None = None @router.put("/edit/{id}", response_model=bool) @@ -435,14 +373,6 @@ async def edit_listing( tags=listing.tags, onshape_url=listing.onshape_url, slug=listing.slug, - stripe_product_id=listing.stripe_product_id, - stripe_price_id=listing.stripe_price_id, - price_amount=listing.price_amount, - inventory_type=listing.inventory_type, - inventory_quantity=listing.inventory_quantity, - preorder_release_date=listing.preorder_release_date, - preorder_deposit_amount=listing.preorder_deposit_amount, - stripe_preorder_deposit_id=listing.stripe_preorder_deposit_id, ) return True @@ -483,15 +413,6 @@ class GetListingResponse(BaseModel): user_vote: bool | None onshape_url: str | None is_featured: bool - currency: str | None = None - price_amount: int | None = None - stripe_product_id: str | None = None - stripe_price_id: str | None = None - preorder_deposit_amount: int | None = None - stripe_preorder_deposit_id: str | None = None - preorder_release_date: int | None = None - inventory_type: str | None = None - inventory_quantity: int | None = None async def get_listing_common( @@ -541,15 +462,6 @@ async def get_listing_common( can_edit=user is not None and await can_write_listing(user, listing), user_vote=user_vote, onshape_url=listing.onshape_url, - price_amount=listing.price_amount, - currency=listing.currency, - stripe_product_id=listing.stripe_product_id, - stripe_price_id=listing.stripe_price_id, - preorder_release_date=listing.preorder_release_date, - preorder_deposit_amount=listing.preorder_deposit_amount, - stripe_preorder_deposit_id=listing.stripe_preorder_deposit_id, - inventory_type=listing.inventory_type, - inventory_quantity=listing.inventory_quantity, is_featured=is_featured, score=listing.score, ) diff --git a/www/app/routers/orders.py b/www/app/routers/orders.py deleted file mode 100644 index 5835529d..00000000 --- a/www/app/routers/orders.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Defines the router endpoints for handling Orders.""" - -import asyncio -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel - -from www.app.crud.orders import OrderDataUpdate, OrdersNotFoundError -from www.app.db import Crud -from www.app.model import Order, OrderStatus, User -from www.app.routers import stripe -from www.app.security.user import ( - get_session_user_with_admin_permission, - get_session_user_with_write_permission, -) - -router = APIRouter() - -logger = logging.getLogger(__name__) - - -class ProductInfo(BaseModel): - id: str - name: str - description: str | None - images: list[str] - metadata: dict[str, str] - active: bool - - -class OrderWithProduct(BaseModel): - order: Order - product: ProductInfo | None - - -@router.get("/me", response_model=list[OrderWithProduct]) -async def get_user_orders( - user: Annotated[User, Depends(get_session_user_with_write_permission)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> list[OrderWithProduct]: - async def get_product_info(order: Order) -> OrderWithProduct: - if not order.stripe_product_id: - return OrderWithProduct(order=order, product=None) - - try: - product = await stripe.get_product(order.stripe_product_id, crud) - product_info = ProductInfo( - id=product.id, - name=product.name, - description=product.description, - images=product.images, - metadata=product.metadata, - active=product.active, - ) - return OrderWithProduct(order=order, product=product_info) - except Exception as e: - logger.error( - "Error getting product info for order", - extra={"order_id": order.id, "error": str(e), "user_id": user.id}, - ) - return OrderWithProduct(order=order, product=None) - - try: - async with crud: - orders = await crud.get_orders_by_user_id(user.id) - results = await asyncio.gather(*[get_product_info(order) for order in orders]) - return results - - except OrdersNotFoundError: - logger.info("No orders found for user: %s", user.id) - return [] - except Exception as e: - logger.exception("Error fetching orders: %s", e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error fetching orders: {str(e)}" - ) - - -@router.get("/{order_id}", response_model=OrderWithProduct) -async def get_order( - order_id: str, - user: Annotated[User, Depends(get_session_user_with_write_permission)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> OrderWithProduct: - async with crud: - order = await crud.get_order(order_id) - if not order or order.user_id != user.id: - raise HTTPException(status_code=404, detail="Order not found") - - product = await stripe.get_product(order.stripe_product_id, crud) - - # Convert ProductResponse to ProductInfo - product_info = ProductInfo( - id=product.id, - name=product.name, - description=product.description, - images=product.images, - metadata=product.metadata, - active=product.active, - ) - - return OrderWithProduct(order=order, product=product_info) - - -class UpdateOrderAddressRequest(BaseModel): - shipping_name: str - shipping_address_line1: str - shipping_address_line2: str | None - shipping_city: str - shipping_state: str - shipping_postal_code: str - shipping_country: str - - -@router.put("/shipping-address/{order_id}", response_model=Order) -async def update_order_shipping_address( - order_id: str, - address_update: UpdateOrderAddressRequest, - user: Annotated[User, Depends(get_session_user_with_write_permission)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> Order: - order = await crud.get_order(order_id) - if order is None or order.user_id != user.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found") - - # Create OrderDataUpdate with shipping fields - update_dict = OrderDataUpdate( - shipping_name=address_update.shipping_name, - shipping_address_line1=address_update.shipping_address_line1, - shipping_address_line2=address_update.shipping_address_line2 if address_update.shipping_address_line2 else None, - shipping_city=address_update.shipping_city, - shipping_state=address_update.shipping_state, - shipping_postal_code=address_update.shipping_postal_code, - shipping_country=address_update.shipping_country, - ) - - updated_order = await crud.update_order(order_id, update_dict) - return updated_order - - -class AdminOrdersResponse(BaseModel): - orders: list[OrderWithProduct] - - -@router.get("/admin/all", response_model=AdminOrdersResponse) -async def get_all_orders( - user: Annotated[User, Depends(get_session_user_with_admin_permission)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> AdminOrdersResponse: - """Get all orders (admin only).""" - orders = await crud.dump_orders() - - orders_with_products = [] - for order in orders: - try: - product = None - if order.stripe_product_id: - product = await stripe.get_product(order.stripe_product_id, crud) - product_info = ProductInfo( - id=product.id, - name=product.name, - description=product.description, - images=product.images, - metadata=product.metadata, - active=product.active, - ) - orders_with_products.append(OrderWithProduct(order=order, product=product_info)) - except Exception as e: - logger.exception("Error getting product info for order %s: %s", order.id, e) - orders_with_products.append(OrderWithProduct(order=order, product=None)) - - return AdminOrdersResponse(orders=orders_with_products) - - -class UpdateOrderStatusRequest(BaseModel): - status: OrderStatus - - -@router.put("/admin/status/{order_id}", response_model=Order) -async def update_order_status( - order_id: str, - status_update: UpdateOrderStatusRequest, - user: Annotated[User, Depends(get_session_user_with_admin_permission)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> Order: - order = await crud.get_order(order_id) - if order is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found") - - update_dict = OrderDataUpdate(status=status_update.status) - updated_order = await crud.update_order(order_id, update_dict) - return updated_order diff --git a/www/app/routers/robots.py b/www/app/routers/robots.py index 862e3151..15c41659 100644 --- a/www/app/routers/robots.py +++ b/www/app/routers/robots.py @@ -23,7 +23,6 @@ class CreateRobotRequest(BaseModel): listing_id: str name: str description: str | None = None - order_id: str | None = None class CreateRobotResponse(BaseModel): @@ -43,7 +42,6 @@ async def create_robot( listing_id=request.listing_id, name=request.name, description=request.description, - order_id=request.order_id, ) return CreateRobotResponse(robot_id=robot.id) except ItemNotFoundError as e: @@ -78,7 +76,6 @@ class SingleRobotResponse(BaseModel): username: str slug: str description: str | None - order_id: str | None created_at: int is_deleted: bool = False @@ -98,7 +95,6 @@ async def from_robot( username=creator.username if creator else "Deleted User", slug=listing.slug if listing else "deleted-listing", description=robot.description, - order_id=robot.order_id, created_at=robot.created_at, is_deleted=creator is None or listing is None or creator.id == "deleted", ) @@ -164,7 +160,6 @@ async def get_robot_response(robot: Robot) -> SingleRobotResponse: class UpdateRobotRequest(BaseModel): name: str | None = None description: str | None = None - order_id: str | None = None @router.put("/update/{robot_id}", response_model=Robot) @@ -188,8 +183,6 @@ async def update_robot( } if update_data.description is not None: update_dict["description"] = update_data.description - if update_data.order_id is not None: - update_dict["order_id"] = update_data.order_id updated_robot = await crud.update_robot(robot_id, update_dict) return updated_robot @@ -216,26 +209,6 @@ async def delete_robot( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Robot not found") -@router.get("/check-order/{order_id}", response_model=Robot | None) -async def check_order_robot( - order_id: str, - user: User = Depends(get_session_user_with_read_permission), - crud: Crud = Depends(Crud.get), -) -> Robot | None: - """Check if an order has an associated robot.""" - try: - # First verify the order belongs to the user - order = await crud.get_order(order_id) - if not order or order.user_id != user.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found") - - # Then check for an associated robot - robot = await crud.get_robot_by_order_id(order_id) - return robot - except ItemNotFoundError: - return None - - class RobotURDFResponse(BaseModel): urdf_url: str | None diff --git a/www/app/routers/stripe.py b/www/app/routers/stripe.py deleted file mode 100644 index 8110a96d..00000000 --- a/www/app/routers/stripe.py +++ /dev/null @@ -1,788 +0,0 @@ -"""Stripe integration router for handling payments and webhooks.""" - -import logging -import time -from datetime import datetime -from enum import Enum -from typing import Annotated, Any, Literal - -import stripe -from fastapi import APIRouter, Body, Depends, HTTPException, Request, status -from pydantic import BaseModel - -from www.app.crud.orders import OrderDataCreate, OrderDataUpdate, ProcessPreorderData -from www.app.db import Crud -from www.app.model import Listing, Order, User, UserStripeConnect -from www.app.security.user import ( - get_session_user_with_admin_permission, - get_session_user_with_read_permission, - get_session_user_with_write_permission, -) -from www.settings import settings - -STRIPE_CONNECT_CHECKOUT_SUCCESS_URL = f"{settings.site.homepage}/order/success?session_id={{CHECKOUT_SESSION_ID}}" - -STRIPE_CONNECT_FINAL_PAYMENT_SUCCESS_URL = ( - f"{settings.site.homepage}/order/final-payment/success?session_id={{CHECKOUT_SESSION_ID}}" -) - -STRIPE_CONNECT_FINAL_PAYMENT_CANCEL_URL = ( - f"{settings.site.homepage}/order/final-payment/cancel?session_id={{CHECKOUT_SESSION_ID}}" -) - -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) - - -if not logger.handlers: - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) - formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - console_handler.setFormatter(formatter) - logger.addHandler(console_handler) - - -router = APIRouter() -stripe.api_key = settings.stripe.secret_key - - -class ConnectAccountStatus(str, Enum): - NOT_CREATED = "not_created" - INCOMPLETE = "incomplete" - COMPLETE = "complete" - - -class CancelReason(BaseModel): - reason: str - details: str - - -class CreateRefundsRequest(BaseModel): - payment_intent_id: str - cancel_reason: CancelReason - amount: int - - -@router.put("/refunds/{order_id}", response_model=Order, include_in_schema=False) -async def refund_payment_intent( - order_id: str, - refund_request: CreateRefundsRequest, - user: User = Depends(get_session_user_with_read_permission), - crud: Crud = Depends(), -) -> Order: - async with crud: - try: - order = await crud.get_order(order_id) - if order is None or order.user_id != user.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found") - logger.info("Found order id: %s", order.id) - - if not order.stripe_connect_account_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Order doesn't have associated Stripe Connect account", - ) - - amount = refund_request.amount - payment_intent_id = refund_request.payment_intent_id - customer_reason = ( - refund_request.cancel_reason.details - if (refund_request.cancel_reason.reason == "Other" and refund_request.cancel_reason.details) - else refund_request.cancel_reason.reason - ) - - refund = stripe.Refund.create( - payment_intent=payment_intent_id, - amount=amount, - reason="requested_by_customer", - metadata={"customer_reason": customer_reason}, - stripe_account=order.stripe_connect_account_id, - ) - logger.info("Refund created: %s", refund.id) - - order_data: OrderDataUpdate = { - "stripe_refund_id": refund.id, - "status": ("refunded" if (refund.status and refund.status == "succeeded") else order.status), - } - - updated_order = await crud.update_order(order_id, order_data) - - logger.info("Updated order with status: %s", refund.status) - return updated_order - except stripe.StripeError as e: - logger.error("Error processing refund: %s", str(e)) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Stripe error: {str(e)}") - except Exception as e: - logger.error("Error processing refund: %s", str(e)) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) - - -@router.post("/webhook", include_in_schema=False) -async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> dict[str, str]: - payload = await request.body() - sig_header = request.headers.get("stripe-signature") - - try: - event = stripe.Webhook.construct_event(payload, sig_header, settings.stripe.webhook_secret) - logger.info("Direct webhook event type: %s", event["type"]) - - # Handle direct account events - if event["type"] == "checkout.session.completed": - session = event["data"]["object"] - await handle_checkout_session_completed(session, crud) - elif event["type"] == "payment_intent.succeeded": - payment_intent = event["data"]["object"] - logger.info("Payment intent succeeded: %s", payment_intent["id"]) - - return {"status": "success"} - except Exception as e: - logger.error("Error processing direct webhook: %s", str(e)) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/connect/webhook", include_in_schema=False) -async def stripe_connect_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> dict[str, str]: - payload = await request.body() - sig_header = request.headers.get("stripe-signature") - - try: - event = stripe.Webhook.construct_event(payload, sig_header, settings.stripe.connect_webhook_secret) - logger.info("Connect webhook event type: %s", event["type"]) - - connected_account_id = event.get("account") - if not connected_account_id: - logger.warning("No connected account ID in webhook event") - return {"status": "skipped"} - - if event["type"] == "account.updated": - account = event["data"]["object"] - capabilities = account.get("capabilities", {}) - is_fully_onboarded = bool( - account.get("details_submitted") - and account.get("payouts_enabled") - and account.get("charges_enabled") - and capabilities.get("card_payments") == "active" - and capabilities.get("transfers") == "active" - ) - - if is_fully_onboarded: - try: - user_id = account.get("metadata", {}).get("user_id") - if user_id: - await crud.update_stripe_connect_status(user_id, account["id"], is_completed=True) - logger.info("Updated Connect status for user %s", user_id) - else: - logger.warning("No user_id in metadata for Connect account: %s", account["id"]) - except Exception as e: - logger.error("Error updating user Connect status: %s", e) - - elif event["type"] == "checkout.session.completed": - session = event["data"]["object"] - - # Check if this is a final payment for a preorder - if session["metadata"].get("is_final_payment") == "true": - order_id = session["metadata"].get("order_id") - if order_id: - try: - order = await crud.get_order(order_id) - if order: - # Update order status and payment details - await crud.update_order( - order_id, - { - "status": "processing", - "final_payment_intent_id": session.get("payment_intent"), - "final_payment_date": int(time.time()), - "updated_at": int(time.time()), - }, - ) - logger.info("Final payment processed for order %s", order_id) - except Exception as e: - logger.error("Error processing final payment webhook: %s", e) - raise - else: - # Handle regular checkout completion - await handle_checkout_session_completed(session, crud) - - return {"status": "success"} - except Exception as e: - logger.error("Error processing Connect webhook: %s", str(e)) - raise HTTPException(status_code=500, detail=str(e)) - - -async def handle_checkout_session_completed(session: dict[str, Any], crud: Crud) -> None: - logger.info("Processing checkout session: %s", session["id"]) - try: - # Retrieve full session details from the connected account - seller_connect_account_id = session.get("metadata", {}).get("seller_connect_account_id") - if seller_connect_account_id: - session = stripe.checkout.Session.retrieve( - session["id"], stripe_account=seller_connect_account_id, expand=["payment_intent"] - ) - - shipping_details = session.get("shipping_details", {}) - shipping_address = shipping_details.get("address", {}) - - # Get the line items to extract the quantity - line_items = stripe.checkout.Session.list_line_items(session["id"], stripe_account=seller_connect_account_id) - quantity = line_items.data[0].quantity if line_items.data else 1 - if quantity is None: - quantity = 1 - - # Determine if this is a preorder and get the correct price amount - is_preorder = session["metadata"].get("is_preorder") == "true" - price_amount = ( - int(session["metadata"].get("full_price_amount")) # Use full price for preorders - if is_preorder - else session["amount_total"] # Use session amount for regular orders - ) - - # Get payment intent ID safely - payment_intent_id = str( - session.payment_intent.id if hasattr(session, "payment_intent") and session.payment_intent else "" - ) - - # Create the order - order_data: OrderDataCreate = { - "user_id": session["metadata"].get("user_id"), - "user_email": session["customer_details"]["email"], - "listing_id": session["metadata"].get("listing_id"), - "stripe_checkout_session_id": session["id"], - "stripe_product_id": session["metadata"].get("stripe_product_id"), - "stripe_connect_account_id": session["metadata"].get("seller_connect_account_id"), - "stripe_payment_intent_id": payment_intent_id, - "price_amount": price_amount, - "currency": session["currency"], - "status": "preorder_placed" if is_preorder else "processing", - "quantity": quantity, - "preorder_deposit_amount": ( - int(session["amount_total"]) if is_preorder and session.get("amount_total") else None - ), - "preorder_release_date": ( - int(session["metadata"]["preorder_release_date"]) - if is_preorder and session["metadata"].get("preorder_release_date") - else None - ), - "stripe_price_id": session["metadata"].get("stripe_price_id"), - "shipping_name": shipping_details.get("name"), - "shipping_address_line1": shipping_address.get("line1"), - "shipping_address_line2": shipping_address.get("line2"), - "shipping_city": shipping_address.get("city"), - "shipping_state": shipping_address.get("state"), - "shipping_postal_code": shipping_address.get("postal_code"), - "shipping_country": shipping_address.get("country"), - } - - listing = await crud.get_listing(session["metadata"].get("listing_id")) - if listing: - if listing.inventory_type == "finite" and listing.inventory_quantity is not None and quantity is not None: - new_quantity = max(0, listing.inventory_quantity - quantity) - await crud.edit_listing(listing_id=listing.id, inventory_quantity=new_quantity) - - await crud.create_order(order_data) - - except Exception as e: - logger.error("Error processing checkout session: %s", str(e)) - raise - - -def support_affirm_payment(listing: Listing) -> bool: - if listing.price_amount is None: - return False - return listing.price_amount >= 5000 and listing.inventory_type != "preorder" - - -class CreateCheckoutSessionRequest(BaseModel): - listing_id: str - stripe_product_id: str - cancel_url: str - - -class CreateCheckoutSessionResponse(BaseModel): - session_id: str - stripe_connect_account_id: str - - -@router.post("/checkout-session", response_model=CreateCheckoutSessionResponse, include_in_schema=False) -async def create_checkout_session( - request: CreateCheckoutSessionRequest, - user: Annotated[User, Depends(get_session_user_with_read_permission)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> CreateCheckoutSessionResponse: - async with crud: - try: - listing = await crud.get_listing(request.listing_id) - if not listing or not listing.price_amount: - logger.error("Listing not found or has no price: %s", request.listing_id) - raise HTTPException(status_code=404, detail="Listing not found or has no price") - - seller = await crud.get_user(listing.user_id) - if not seller or not seller.stripe_connect or not seller.stripe_connect.onboarding_completed: - raise HTTPException(status_code=400, detail="Seller not found or not connected to Stripe") - - if seller.id == user.id: - raise HTTPException(status_code=400, detail="You cannot purchase your own listing") - - max_quantity = 10 - if listing.inventory_type == "finite" and listing.inventory_quantity is not None: - max_quantity = min(listing.inventory_quantity, 10) - - metadata: dict[str, str] = { - "user_id": user.id, - "user_email": user.email, - "listing_id": listing.id, - "product_id": listing.stripe_product_id or "", - "listing_type": listing.inventory_type, - "seller_connect_account_id": seller.stripe_connect.account_id, - } - - if listing.stripe_product_id: - metadata["stripe_product_id"] = listing.stripe_product_id - - # Base checkout session parameters - checkout_params: stripe.checkout.Session.CreateParams = { - "mode": "payment", - "payment_method_types": ["card", "affirm"] if support_affirm_payment(listing) else ["card"], - "success_url": STRIPE_CONNECT_CHECKOUT_SUCCESS_URL, - "cancel_url": f"{settings.site.homepage}{request.cancel_url}", - "client_reference_id": user.id, - "shipping_address_collection": {"allowed_countries": ["US"]}, - "metadata": metadata, - "stripe_account": seller.stripe_connect.account_id, - } - - # For preorders, use payment mode with deposit amount - if listing.inventory_type == "preorder" and listing.preorder_deposit_amount: - if listing.stripe_preorder_deposit_id is None: - raise HTTPException(status_code=400, detail="Preorder deposit price not configured") - if listing.stripe_price_id is None: - raise HTTPException(status_code=400, detail="Preorder full price not configured") - if listing.price_amount is None: - raise HTTPException(status_code=400, detail="Preorder full price amount not configured") - - checkout_params.update( - { - "line_items": [ - { - "price": listing.stripe_preorder_deposit_id, - "quantity": 1, - } - ], - "payment_intent_data": { - "setup_future_usage": "off_session", # Save card for future charge - "metadata": { - "is_preorder": "true", - "stripe_price_id": listing.stripe_price_id, - "remaining_amount": str(listing.price_amount - listing.preorder_deposit_amount), - "listing_id": listing.id, - }, - }, - "metadata": { - **metadata, - "is_preorder": "true", - "stripe_price_id": listing.stripe_price_id, - "full_price_amount": str(listing.price_amount), - }, - } - ) - - # Custom text for preorder explanation - if listing.preorder_release_date: - formatted_date = datetime.fromtimestamp(listing.preorder_release_date).strftime("%B %d, %Y") - remaining_amount = (listing.price_amount - listing.preorder_deposit_amount) / 100 - checkout_params["custom_text"] = { - "submit": { - "message": ( - f"By paying you understand that you are placing a pre-order with a " - f"refundable deposit of ${listing.preorder_deposit_amount/100:,.2f}. " - f"You will be contacted to pay the remaining ${remaining_amount:,.2f} when your " - f"robot is ready to ship (estimated {formatted_date}). By continuing, you agree " - "to save your payment method for the future charge." - ) - } - } - - else: - # Regular payment mode - if not listing.stripe_price_id and listing.stripe_product_id: - price = stripe.Price.create( - unit_amount=listing.price_amount, - currency=listing.currency or "usd", - product=listing.stripe_product_id, - metadata={"listing_id": listing.id}, - stripe_account=seller.stripe_connect.account_id, - ) - await crud.edit_listing(listing_id=listing.id, stripe_price_id=price.id) - listing.stripe_price_id = price.id - - if listing.stripe_price_id is None: - raise HTTPException(status_code=400, detail="Price not configured") - - application_fee = int(listing.price_amount * 0.02) # 2% fee - - checkout_params.update( - { - "line_items": [ - { - "price": listing.stripe_price_id, - "quantity": 1, - **( - {"adjustable_quantity": {"enabled": True, "minimum": 1, "maximum": max_quantity}} - if max_quantity > 1 - else {} - ), - } - ], - "payment_intent_data": { - "application_fee_amount": application_fee, - }, - "stripe_account": seller.stripe_connect.account_id, - "metadata": {**metadata, "stripe_price_id": listing.stripe_price_id}, - } - ) - - # Create the checkout session on the connected account - checkout_session = stripe.checkout.Session.create(**checkout_params) - - return CreateCheckoutSessionResponse( - session_id=checkout_session.id, - stripe_connect_account_id=seller.stripe_connect.account_id, - ) - - except stripe.StripeError as e: - logger.exception("Stripe error: %s", e) - raise HTTPException(status_code=400, detail=str(e)) - - -class ProductResponse(BaseModel): - id: str - name: str - description: str | None - images: list[str] - metadata: dict[str, str] - active: bool - - -@router.get("/get-product/{product_id}", response_model=ProductResponse, include_in_schema=False) -async def get_product(product_id: str, crud: Annotated[Crud, Depends(Crud.get)]) -> ProductResponse: - try: - listing = await crud.get_listing_by_stripe_product_id(product_id) - if not listing: - raise HTTPException(status_code=404, detail="Listing not found") - - seller = await crud.get_user(listing.user_id) - if not seller or not seller.stripe_connect: - raise HTTPException(status_code=400, detail="Seller not found or not connected to Stripe") - - # Retrieve the product using the seller's connected account - product = stripe.Product.retrieve(product_id, stripe_account=seller.stripe_connect.account_id) - - return ProductResponse( - id=product.id, - name=product.name, - description=product.description, - images=product.images, - metadata=product.metadata, - active=product.active, - ) - except stripe.StripeError as e: - logger.exception("Stripe error retrieving product: %s", e) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.exception("Error retrieving product: %s", e) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - - -class CreateConnectAccountResponse(BaseModel): - account_id: str - - -@router.post("/connect/account", response_model=CreateConnectAccountResponse, include_in_schema=False) -async def create_connect_account( - user: Annotated[User, Depends(get_session_user_with_write_permission)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> CreateConnectAccountResponse: - try: - account = stripe.Account.create( - type="standard", - country="US", - email=user.email, - capabilities={ - "card_payments": {"requested": True}, - "transfers": {"requested": True}, - }, - business_type="individual", - metadata={"user_id": user.id}, - ) - - logger.info("Created Connect account %s for user %s", account.id, user.id) - - # Convert UserStripeConnect to a dictionary before updating - stripe_connect = UserStripeConnect( - account_id=account.id, - onboarding_completed=False, - ) - - # Update user with the dictionary representation of UserStripeConnect - await crud.update_user( - user.id, - { - "stripe_connect": stripe_connect.model_dump(), - }, - ) - - return CreateConnectAccountResponse(account_id=account.id) - except Exception as e: - logger.error("Error creating Connect account: %s", str(e)) - raise HTTPException(status_code=400, detail=str(e)) - - -@router.post("/connect/account/session", include_in_schema=False) -async def create_connect_account_session( - user: Annotated[User, Depends(get_session_user_with_read_permission)], - account_id: str = Body(..., embed=True), -) -> dict[str, str]: - try: - logger.info("Creating account session for account: %s", account_id) - - if not account_id: - logger.error("No account ID provided in request body") - raise HTTPException(status_code=400, detail="No account ID provided") - - if not user.stripe_connect or user.stripe_connect.account_id != account_id: - logger.error( - "Account ID mismatch. User: %s, Requested: %s", - user.stripe_connect.account_id if user.stripe_connect else None, - account_id, - ) - raise HTTPException(status_code=400, detail="Account ID does not match user's connected account") - - account_session = stripe.AccountSession.create( - account=account_id, - components={ - "account_onboarding": {"enabled": True}, - }, - ) - - logger.info("Successfully created account session for account: %s", account_id) - return {"client_secret": account_session.client_secret} - except Exception as e: - logger.exception("Error creating account session: %s", e) - raise - - -class DeleteTestAccountsResponse(BaseModel): - success: bool - deleted_accounts: list[str] - count: int - - -@router.post("/connect/delete/accounts", response_model=DeleteTestAccountsResponse, include_in_schema=False) -async def delete_test_accounts( - user: Annotated[User, Depends(get_session_user_with_read_permission)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> DeleteTestAccountsResponse: - if not user.permissions or "is_admin" not in user.permissions: - raise HTTPException(status_code=403, detail="Admin permission required to delete accounts") - - try: - deleted_accounts = [] - accounts = stripe.Account.list(limit=100) - - for account in accounts: - try: - stripe.Account.delete(account.id) - deleted_accounts.append(account.id) - except Exception as e: - logger.error("Failed to delete account %s: %s", account.id, str(e)) - - return DeleteTestAccountsResponse(success=True, deleted_accounts=deleted_accounts, count=len(deleted_accounts)) - except Exception as e: - logger.error("Error deleting test accounts: %s", str(e)) - raise HTTPException(status_code=400, detail=str(e)) - - -class CreateListingProductResponse(BaseModel): - stripe_product_id: str - stripe_price_id: str - stripe_preorder_deposit_id: str | None - - -async def create_listing_product( - name: str, - description: str, - price_amount: int, - currency: str, - inventory_type: Literal["finite", "preorder"], - inventory_quantity: int | None, - preorder_release_date: int | None, - preorder_deposit_amount: int | None, - user_id: str, - stripe_connect_account_id: str, -) -> CreateListingProductResponse: - """Create Stripe product and associated prices for a new listing.""" - try: - # Create the product - product = stripe.Product.create( - name=name, - description=description, - metadata={ - "user_id": user_id, - }, - stripe_account=stripe_connect_account_id, - ) - - # Create the main price - price = stripe.Price.create( - product=product.id, - currency=currency, - unit_amount=price_amount, - metadata={ - "price_type": "primary", - "inventory_quantity": str(inventory_quantity) if inventory_type == "finite" else "", - "preorder_release_date": str(preorder_release_date) if inventory_type == "preorder" else "", - }, - stripe_account=stripe_connect_account_id, - ) - - # Create preorder deposit price if applicable - preorder_deposit_id = None - if inventory_type == "preorder" and preorder_deposit_amount: - preorder_deposit_price = stripe.Price.create( - product=product.id, - currency=currency, - unit_amount=preorder_deposit_amount, - metadata={ - "price_type": "deposit", - "is_deposit": "true", - "full_price_amount": str(price_amount), - "preorder_release_date": str(preorder_release_date) if preorder_release_date else "", - }, - stripe_account=stripe_connect_account_id, - ) - preorder_deposit_id = preorder_deposit_price.id - - return CreateListingProductResponse( - stripe_product_id=product.id, - stripe_price_id=price.id, - stripe_preorder_deposit_id=preorder_deposit_id, - ) - - except stripe.StripeError as e: - logger.exception("Stripe error creating listing product: %s", e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Error creating Stripe product: {str(e)}", - ) - - -class ProcessPreorderResponse(BaseModel): - status: str - checkout_session: dict[str, Any] - - -@router.post("/process/preorder/{order_id}", response_model=ProcessPreorderResponse, include_in_schema=False) -async def process_preorder( - order_id: str, - crud: Annotated[Crud, Depends(Crud.get)], - user: User = Depends(get_session_user_with_admin_permission), -) -> ProcessPreorderResponse: - async with crud: - try: - order = await crud.get_order(order_id) - if not order: - raise HTTPException(status_code=404, detail="Order not found") - - if order.inventory_type != "preorder": - raise HTTPException(status_code=400, detail="Order is not a preorder") - - if order.status != "preorder_placed": - raise HTTPException(status_code=400, detail="Order is not in preorder_placed status") - - # Calculate remaining amount (full price minus deposit) - remaining_amount = order.price_amount - (order.preorder_deposit_amount or 0) - - # Get the seller's connect account - seller = await crud.get_user(order.user_id) - if not seller or not seller.stripe_connect: - raise HTTPException(status_code=400, detail="Seller not found or not connected to Stripe") - - # Create a new checkout session for the final payment - checkout_params: stripe.checkout.Session.CreateParams = { - "mode": "payment", - "payment_method_types": ["card", "affirm"], - "success_url": STRIPE_CONNECT_FINAL_PAYMENT_SUCCESS_URL, - "cancel_url": STRIPE_CONNECT_FINAL_PAYMENT_CANCEL_URL, - "metadata": { - "order_id": order.id, - "is_final_payment": "true", - "original_checkout_session_id": order.stripe_checkout_session_id, - }, - "payment_intent_data": { - "metadata": { - "order_id": order.id, - "is_final_payment": "true", - }, - "application_fee_amount": int(remaining_amount * 0.02), # 2% platform fee - }, - "line_items": [ - { - "price_data": { - "currency": order.currency, - "unit_amount": remaining_amount, - "product": order.stripe_product_id, - "product_data": { - "name": "Final Payment", - "description": "Remaining balance for your preorder", - }, - }, - "quantity": 1, - } - ], - # Pre-fill shipping info from original order - "shipping_address_collection": {"allowed_countries": ["US"]}, - "shipping_options": [ - { - "shipping_rate_data": { - "type": "fixed_amount", - "display_name": "Free Shipping", - "fixed_amount": { - "amount": 0, - "currency": order.currency, - }, - "delivery_estimate": { - "minimum": {"unit": "month", "value": 1}, - "maximum": {"unit": "month", "value": 2}, - }, - }, - } - ], - "stripe_account": seller.stripe_connect.account_id, - } - - # Create checkout session on seller's connect account - checkout_session = stripe.checkout.Session.create(**checkout_params) - - if checkout_session.id: - # Update order with final payment checkout session - order_data: ProcessPreorderData = { - "stripe_connect_account_id": seller.stripe_connect.account_id, - "stripe_checkout_session_id": order.stripe_checkout_session_id, - "final_payment_checkout_session_id": checkout_session.id, - "status": "awaiting_final_payment", - "updated_at": int(time.time()), - } - - await crud.process_preorder(order_id, order_data) - - return ProcessPreorderResponse( - status="success", - checkout_session=checkout_session, - ) - - except stripe.StripeError as e: - logger.exception("Stripe error processing preorder final payment: %s", e) - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.exception("Error processing preorder final payment: %s", e) - raise HTTPException(status_code=500, detail=str(e)) diff --git a/www/app/routers/users.py b/www/app/routers/users.py index 4a79a459..6a03c455 100644 --- a/www/app/routers/users.py +++ b/www/app/routers/users.py @@ -11,7 +11,7 @@ from www.app.crud.users import UserPublic from www.app.db import Crud from www.app.errors import ItemNotFoundError -from www.app.model import User, UserPermission, UserStripeConnect +from www.app.model import User, UserPermission from www.app.security.requests import get_request_api_key_id from www.app.security.user import ( get_session_user_with_admin_permission, @@ -53,7 +53,6 @@ class MyUserInfoResponse(BaseModel): last_name: str | None name: str | None bio: str | None - stripe_connect: UserStripeConnect | None @classmethod def from_user(cls, user: User) -> Self: @@ -67,7 +66,6 @@ def from_user(cls, user: User) -> Self: last_name=user.last_name, name=user.name, bio=user.bio, - stripe_connect=user.stripe_connect, ) @@ -86,7 +84,6 @@ async def get_user_info_endpoint( last_name=user.last_name, name=user.name, bio=user.bio, - stripe_connect=user.stripe_connect, ) except ValueError: return None diff --git a/www/requirements.txt b/www/requirements.txt index e71c75ce..095d0bd5 100644 --- a/www/requirements.txt +++ b/www/requirements.txt @@ -5,7 +5,6 @@ omegaconf bson pydantic email-validator -stripe # AWS dependencies. aioboto3 diff --git a/www/settings/configs/development.yaml b/www/settings/configs/development.yaml new file mode 100644 index 00000000..ac629872 --- /dev/null +++ b/www/settings/configs/development.yaml @@ -0,0 +1,7 @@ +crypto: + jwt_secret: ${oc.env:JWT_SECRET} +dynamo: + table_name: kscale-www-staging-cluster +site: + homepage: https://staging.kscale.dev + artifact_base_url: https://stagingassets.kscale.dev/ diff --git a/www/settings/environment.py b/www/settings/environment.py index 17f3fe80..4fa6d484 100644 --- a/www/settings/environment.py +++ b/www/settings/environment.py @@ -64,14 +64,6 @@ class SiteSettings: artifact_base_url: str = field(default=MISSING) -@dataclass -class StripeSettings: - publishable_key: str = field(default=II("oc.env:VITE_STRIPE_PUBLISHABLE_KEY")) - secret_key: str = field(default=II("oc.env:STRIPE_SECRET_KEY")) - webhook_secret: str = field(default=II("oc.env:STRIPE_WEBHOOK_SECRET")) - connect_webhook_secret: str = field(default=II("oc.env:STRIPE_CONNECT_WEBHOOK_SECRET")) - - @dataclass class CloudFrontSettings: domain: str = field(default=II("oc.env:CLOUDFRONT_DOMAIN")) @@ -91,5 +83,4 @@ class EnvironmentSettings: site: SiteSettings = field(default_factory=SiteSettings) cloudfront: CloudFrontSettings = field(default_factory=CloudFrontSettings) debug: bool = field(default=False) - stripe: StripeSettings = field(default_factory=StripeSettings) environment: str = field(default="local")