From 4c8cb42978e35db4fe3e7685f6c3ba8879e76aa7 Mon Sep 17 00:00:00 2001 From: swve Date: Wed, 16 Oct 2024 00:31:36 +0200 Subject: [PATCH] feat: products crud --- apps/api/src/db/payments/payments.py | 11 +- apps/api/src/db/payments/payments_courses.py | 14 ++ apps/api/src/db/payments/payments_products.py | 38 ++++ apps/api/src/db/payments/payments_users.py | 19 ++ apps/api/src/routers/ee/payments.py | 54 ++++++ .../services/payments/payments_products.py | 153 +++++++++++++++ apps/api/src/services/payments/products.py | 0 .../dash/payments/[subpage]/page.tsx | 52 +++-- .../components/Contexts/LHSessionContext.tsx | 6 +- .../Payments/PaymentsProductPage.tsx | 180 ++++++++++++++++++ .../SubComponents/CreateProductForm.tsx | 139 ++++++++++++++ apps/web/services/payments/products.ts | 50 +++++ 12 files changed, 677 insertions(+), 39 deletions(-) create mode 100644 apps/api/src/db/payments/payments_courses.py create mode 100644 apps/api/src/db/payments/payments_products.py create mode 100644 apps/api/src/db/payments/payments_users.py create mode 100644 apps/api/src/services/payments/payments_products.py create mode 100644 apps/api/src/services/payments/products.py create mode 100644 apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx create mode 100644 apps/web/components/Dashboard/Payments/SubComponents/CreateProductForm.tsx create mode 100644 apps/web/services/payments/products.ts 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 }) {
} - label="General" - isActive={selectedSubPage === 'general'} - onClick={() => setSelectedSubPage('general')} + href={getUriWithOrg(params.orgslug, '/dash/payments/customers')} + icon={} + label="Customers" + isActive={selectedSubPage === 'customers'} + onClick={() => setSelectedSubPage('customers')} /> } - label="Configuration" - isActive={selectedSubPage === 'configuration'} - onClick={() => setSelectedSubPage('configuration')} + href={getUriWithOrg(params.orgslug, '/dash/payments/paid-products')} + icon={} + label="One-time Products" + isActive={selectedSubPage === 'paid-products'} + onClick={() => setSelectedSubPage('paid-products')} /> setSelectedSubPage('subscriptions')} /> } - label="Paid Courses" - isActive={selectedSubPage === 'paid-courses'} - onClick={() => setSelectedSubPage('paid-courses')} - /> - } - label="Customers" - isActive={selectedSubPage === 'customers'} - onClick={() => setSelectedSubPage('customers')} + href={getUriWithOrg(params.orgslug, '/dash/payments/configuration')} + icon={} + label="Configuration" + isActive={selectedSubPage === 'configuration'} + onClick={() => setSelectedSubPage('configuration')} /> +
@@ -112,8 +107,8 @@ function PaymentsPage({ params }: { params: PaymentsParams }) { > {selectedSubPage === 'general' &&
General
} {selectedSubPage === 'configuration' && } + {selectedSubPage === 'paid-products' && } {selectedSubPage === 'subscriptions' &&
Subscriptions
} - {selectedSubPage === 'paid-courses' &&
Paid Courses
} {selectedSubPage === 'customers' &&
Customers
} @@ -124,9 +119,8 @@ const TabLink = ({ href, icon, label, isActive, onClick }: { href: string, icon:
{icon} diff --git a/apps/web/components/Contexts/LHSessionContext.tsx b/apps/web/components/Contexts/LHSessionContext.tsx index 90acb9d8..b7a2965d 100644 --- a/apps/web/components/Contexts/LHSessionContext.tsx +++ b/apps/web/components/Contexts/LHSessionContext.tsx @@ -1,17 +1,13 @@ 'use client' import PageLoading from '@components/Objects/Loaders/PageLoading'; import { useSession } from 'next-auth/react'; -import React, { useContext, createContext, useEffect } from 'react' +import React, { useContext, createContext } from 'react' export const SessionContext = createContext({}) as any function LHSessionProvider({ children }: { children: React.ReactNode }) { const session = useSession(); - useEffect(() => { - }, []) - - if (session && session.status == 'loading') { return } diff --git a/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx b/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx new file mode 100644 index 00000000..e7c88f3a --- /dev/null +++ b/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx @@ -0,0 +1,180 @@ +'use client'; +import React, { useState } from 'react' +import { useOrg } from '@components/Contexts/OrgContext'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import useSWR, { mutate } from 'swr'; +import { getProducts, deleteProduct, updateProduct } from '@services/payments/products'; +import CreateProductForm from '@components/Dashboard/Payments/SubComponents/CreateProductForm'; +import { Plus, Trash2, Pencil, DollarSign, Info } from 'lucide-react'; +import Modal from '@components/StyledElements/Modal/Modal'; +import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'; +import toast from 'react-hot-toast'; +import Link from 'next/link'; +import { getUriWithOrg } from '@services/config/config'; + +function PaymentsProductPage() { + const org = useOrg() as any; + const session = useLHSession() as any; + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingProductId, setEditingProductId] = useState(null); + + const { data: products, error } = useSWR( + () => org && session ? [`/payments/${org.id}/products`, session.data?.tokens?.access_token] : null, + ([url, token]) => getProducts(org.id, token) + ); + + const handleDeleteProduct = async (productId: string) => { + try { + await deleteProduct(org.id, productId, session.data?.tokens?.access_token); + mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]); + toast.success('Product deleted successfully'); + } catch (error) { + toast.error('Failed to delete product'); + } + } + + if (error) return
Failed to load products
; + if (!products) return
Loading...
; + + return ( +
+
+
+

Products

+ +
+ + setIsCreateModalOpen(false)} /> + } + /> + +
+ {products.data.map((product: any) => ( +
+ {editingProductId === product.id ? ( + setEditingProductId(null)} + onCancel={() => setEditingProductId(null)} + /> + ) : ( +
+
+

{product.name}

+
+ + + + + } + functionToExecute={() => handleDeleteProduct(product.id)} + status="warning" + /> +
+
+

{product.description}

+

${product.amount.toFixed(2)}

+

{product.product_type}

+ {product.benefits && ( +
+

Benefits:

+

{product.benefits}

+
+ )} +
+ )} +
+ ))} +
+ + {products.data.length === 0 && ( +
+ +

No products available. Create a new product to get started.

+
+ )} +
+
+ ) +} + +const EditProductForm = ({ product, onSuccess, onCancel }: { product: any, onSuccess: () => void, onCancel: () => void }) => { + const [name, setName] = useState(product.name); + const [description, setDescription] = useState(product.description); + const [amount, setAmount] = useState(product.amount); + const [benefits, setBenefits] = useState(product.benefits || ''); + const org = useOrg() as any; + const session = useLHSession() as any; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await updateProduct(org.id, product.id, { name, description, amount, benefits }, session.data?.tokens?.access_token); + mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]); + onSuccess(); + toast.success('Product updated successfully'); + } catch (error) { + toast.error('Failed to update product'); + } + }; + + return ( +
+ setName(e.target.value)} + className="w-full p-2 border rounded" + placeholder="Product Name" + /> +