diff --git a/apps/api/src/db/payments/payments.py b/apps/api/src/db/payments/payments.py index 282fbf59..bfecc7e6 100644 --- a/apps/api/src/db/payments/payments.py +++ b/apps/api/src/db/payments/payments.py @@ -1,21 +1,22 @@ from datetime import datetime from enum import Enum -from typing import Literal, Optional +from typing import Optional from pydantic import BaseModel from sqlalchemy import JSON from sqlmodel import Field, SQLModel, Column, BigInteger, ForeignKey - +# Stripe provider config class StripeProviderConfig(BaseModel): stripe_key: str = "" stripe_secret_key: str = "" stripe_webhook_secret: str = "" +# PaymentsConfig class PaymentProviderEnum(str, Enum): STRIPE = "stripe" class PaymentsConfigBase(SQLModel): - enabled: bool = False + enabled: bool = True provider: PaymentProviderEnum = PaymentProviderEnum.STRIPE provider_config: dict = Field(default={}, sa_column=Column(JSON)) @@ -34,7 +35,7 @@ class PaymentsConfigCreate(PaymentsConfigBase): class PaymentsConfigUpdate(PaymentsConfigBase): - enabled: Optional[bool] = False + enabled: Optional[bool] = True provider_config: Optional[dict] = None @@ -46,4 +47,4 @@ class PaymentsConfigRead(PaymentsConfigBase): class PaymentsConfigDelete(SQLModel): - id: int + id: int \ No newline at end of file diff --git a/apps/api/src/db/payments/payments_courses.py b/apps/api/src/db/payments/payments_courses.py new file mode 100644 index 00000000..e7624ef2 --- /dev/null +++ b/apps/api/src/db/payments/payments_courses.py @@ -0,0 +1,14 @@ +from enum import Enum +from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey, String, JSON +from typing import Optional +from datetime import datetime + +class PaymentCourseBase(SQLModel): + course_id: int = Field(sa_column=Column(BigInteger, ForeignKey("course.id", ondelete="CASCADE"))) + payment_product_id: int = Field(sa_column=Column(BigInteger, ForeignKey("paymentsproduct.id", ondelete="CASCADE"))) + org_id: int = Field(sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE"))) + +class PaymentCourse(PaymentCourseBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + creation_date: datetime = Field(default=datetime.now()) + update_date: datetime = Field(default=datetime.now()) \ No newline at end of file diff --git a/apps/api/src/db/payments/payments_products.py b/apps/api/src/db/payments/payments_products.py new file mode 100644 index 00000000..dd4ca872 --- /dev/null +++ b/apps/api/src/db/payments/payments_products.py @@ -0,0 +1,38 @@ +from enum import Enum +from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey, String, JSON +from typing import Optional +from datetime import datetime + +class PaymentProductTypeEnum(str, Enum): + SUBSCRIPTION = "subscription" + ONE_TIME = "one_time" + +class PaymentsProductBase(SQLModel): + name: str = "" + description: Optional[str] = "" + product_type: PaymentProductTypeEnum = PaymentProductTypeEnum.ONE_TIME + benefits: str = "" + amount: float = 0.0 + +class PaymentsProduct(PaymentsProductBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE")) + ) + payments_config_id: int = Field(sa_column=Column(BigInteger, ForeignKey("paymentsconfig.id", ondelete="CASCADE"))) + provider_product_id: str = Field(sa_column=Column(String)) + creation_date: datetime = Field(default=datetime.now()) + update_date: datetime = Field(default=datetime.now()) + +class PaymentsProductCreate(PaymentsProductBase): + pass + +class PaymentsProductUpdate(PaymentsProductBase): + pass + +class PaymentsProductRead(PaymentsProductBase): + id: int + org_id: int + payments_config_id: int + creation_date: datetime + update_date: datetime diff --git a/apps/api/src/db/payments/payments_users.py b/apps/api/src/db/payments/payments_users.py new file mode 100644 index 00000000..25a95277 --- /dev/null +++ b/apps/api/src/db/payments/payments_users.py @@ -0,0 +1,19 @@ +from enum import Enum +from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey, String, JSON +from typing import Optional +from datetime import datetime + +class PaymentUserStatusEnum(str, Enum): + ACTIVE = "active" + INACTIVE = "inactive" + +class PaymentsUserBase(SQLModel): + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("user.id", ondelete="CASCADE"))) + status: PaymentUserStatusEnum = PaymentUserStatusEnum.ACTIVE + payment_product_id: int = Field(sa_column=Column(BigInteger, ForeignKey("paymentsproduct.id", ondelete="CASCADE"))) + org_id: int = Field(sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE"))) + +class PaymentsUser(PaymentsUserBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + creation_date: datetime = Field(default=datetime.now()) + update_date: datetime = Field(default=datetime.now()) diff --git a/apps/api/src/routers/ee/payments.py b/apps/api/src/routers/ee/payments.py index d7eced09..d1d1f6c6 100644 --- a/apps/api/src/routers/ee/payments.py +++ b/apps/api/src/routers/ee/payments.py @@ -10,6 +10,9 @@ from src.services.payments.payments import ( update_payments_config, delete_payments_config, ) +from src.db.payments.payments_products import PaymentsProduct, PaymentsProductCreate, PaymentsProductRead, PaymentsProductUpdate +from src.services.payments.payments_products import create_payments_product, delete_payments_product, get_payments_product, list_payments_products, update_payments_product + router = APIRouter() @@ -51,3 +54,54 @@ async def api_delete_payments_config( ): await delete_payments_config(request, org_id, current_user, db_session) return {"message": "Payments config deleted successfully"} + +@router.post("/{org_id}/products") +async def api_create_payments_product( + request: Request, + org_id: int, + payments_product: PaymentsProductCreate, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> PaymentsProductRead: + return await create_payments_product(request, org_id, payments_product, current_user, db_session) + +@router.get("/{org_id}/products") +async def api_get_payments_products( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> list[PaymentsProductRead]: + return await list_payments_products(request, org_id, current_user, db_session) + +@router.get("/{org_id}/products/{product_id}") +async def api_get_payments_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> PaymentsProductRead: + return await get_payments_product(request, org_id, product_id, current_user, db_session) + +@router.put("/{org_id}/products/{product_id}") +async def api_update_payments_product( + request: Request, + org_id: int, + product_id: int, + payments_product: PaymentsProductUpdate, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> PaymentsProductRead: + return await update_payments_product(request, org_id, product_id, payments_product, current_user, db_session) + +@router.delete("/{org_id}/products/{product_id}") +async def api_delete_payments_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + await delete_payments_product(request, org_id, product_id, current_user, db_session) + return {"message": "Payments product deleted successfully"} diff --git a/apps/api/src/services/payments/payments_products.py b/apps/api/src/services/payments/payments_products.py new file mode 100644 index 00000000..f26e2c01 --- /dev/null +++ b/apps/api/src/services/payments/payments_products.py @@ -0,0 +1,153 @@ +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from src.db.payments.payments import PaymentsConfig +from src.db.payments.payments_products import ( + PaymentsProduct, + PaymentsProductCreate, + PaymentsProductUpdate, + PaymentsProductRead, +) +from src.db.users import PublicUser, AnonymousUser +from src.db.organizations import Organization +from src.services.orgs.orgs import rbac_check +from datetime import datetime +from uuid import uuid4 + +async def create_payments_product( + request: Request, + org_id: int, + payments_product: PaymentsProductCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> PaymentsProductRead: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "create", db_session) + + # Check if payments config exists and has a valid id + statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) + config = db_session.exec(statement).first() + if not config or config.id is None: + raise HTTPException(status_code=404, detail="Valid payments config not found") + + # Create new payments product + new_product = PaymentsProduct(**payments_product.model_dump(), org_id=org_id, payments_config_id=config.id) + new_product.creation_date = datetime.now() + new_product.update_date = datetime.now() + + db_session.add(new_product) + db_session.commit() + db_session.refresh(new_product) + + return PaymentsProductRead.model_validate(new_product) + +async def get_payments_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> PaymentsProductRead: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + # Get payments product + statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id) + product = db_session.exec(statement).first() + if not product: + raise HTTPException(status_code=404, detail="Payments product not found") + + return PaymentsProductRead.model_validate(product) + +async def update_payments_product( + request: Request, + org_id: int, + product_id: int, + payments_product: PaymentsProductUpdate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> PaymentsProductRead: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + + # Get existing payments product + statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id) + product = db_session.exec(statement).first() + if not product: + raise HTTPException(status_code=404, detail="Payments product not found") + + # Update product + for key, value in payments_product.model_dump().items(): + setattr(product, key, value) + + product.update_date = datetime.now() + + db_session.add(product) + db_session.commit() + db_session.refresh(product) + + return PaymentsProductRead.model_validate(product) + +async def delete_payments_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> None: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "delete", db_session) + + # Get existing payments product + statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id) + product = db_session.exec(statement).first() + if not product: + raise HTTPException(status_code=404, detail="Payments product not found") + + # Delete product + db_session.delete(product) + db_session.commit() + +async def list_payments_products( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> list[PaymentsProductRead]: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + # Get payments products ordered by id + statement = select(PaymentsProduct).where(PaymentsProduct.org_id == org_id).order_by(PaymentsProduct.id.desc()) + products = db_session.exec(statement).all() + + return [PaymentsProductRead.model_validate(product) for product in products] diff --git a/apps/api/src/services/payments/products.py b/apps/api/src/services/payments/products.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx index 3c8035e1..6276e2ca 100644 --- a/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx @@ -8,6 +8,7 @@ import { CreditCard, Settings, Repeat, BookOpen, Users, DollarSign } from 'lucid import { useLHSession } from '@components/Contexts/LHSessionContext' import { useOrg } from '@components/Contexts/OrgContext' import PaymentsConfigurationPage from '@components/Dashboard/Payments/PaymentsConfigurationPage' +import PaymentsProductPage from '@components/Dashboard/Payments/PaymentsProductPage' @@ -40,9 +41,9 @@ function PaymentsPage({ params }: { params: PaymentsParams }) { setH1Label('Subscriptions') setH2Label('Manage your subscription plans') } - if (selectedSubPage === 'paid-courses') { - setH1Label('Paid Courses') - setH2Label('Manage your paid courses and pricing') + if (selectedSubPage === 'paid-products') { + setH1Label('Paid Products') + setH2Label('Manage your paid products and pricing') } if (selectedSubPage === 'customers') { setH1Label('Customers') @@ -66,18 +67,18 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
{product.description}
+${product.amount.toFixed(2)}
+{product.product_type}
+ {product.benefits && ( +{product.benefits}
+No products available. Create a new product to get started.
+