diff --git a/apps/api/src/routers/ee/payments.py b/apps/api/src/routers/ee/payments.py index 3c452978..43d04038 100644 --- a/apps/api/src/routers/ee/payments.py +++ b/apps/api/src/routers/ee/payments.py @@ -20,6 +20,7 @@ from src.services.payments.payments_courses import ( from src.services.payments.payments_webhook import handle_stripe_webhook from src.services.payments.stripe import create_checkout_session from src.services.payments.payments_access import check_course_paid_access +from src.services.payments.payments_customers import get_customers router = APIRouter() @@ -205,3 +206,15 @@ async def api_check_course_paid_access( db_session=db_session ) } + +@router.get("/{org_id}/customers") +async def api_get_customers( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Get list of customers and their subscriptions for an organization + """ + return await get_customers(request, org_id, current_user, db_session) diff --git a/apps/api/src/services/payments/payments_customers.py b/apps/api/src/services/payments/payments_customers.py new file mode 100644 index 00000000..d87892c0 --- /dev/null +++ b/apps/api/src/services/payments/payments_customers.py @@ -0,0 +1,51 @@ +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from src.db.organizations import Organization +from src.db.users import PublicUser, AnonymousUser, User +from src.db.payments.payments_users import PaymentsUser +from src.db.payments.payments_products import PaymentsProduct +from src.services.orgs.orgs import rbac_check +from src.services.payments.payments_products import get_payments_product +from src.services.users.users import read_user_by_id + +async def get_customers( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # 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 all payment users for the organization + statement = select(PaymentsUser).where(PaymentsUser.org_id == org_id) + payment_users = db_session.exec(statement).all() + + customers_data = [] + + for payment_user in payment_users: + # Get user data + user = await read_user_by_id(request, db_session, current_user, payment_user.user_id) + + # Get product data + if org.id is None: + raise HTTPException(status_code=400, detail="Invalid organization ID") + product = await get_payments_product(request, org.id, payment_user.payment_product_id, current_user, db_session) + + customer_data = { + 'payment_user_id': payment_user.id, + 'user': user if user else None, + 'product': product if product else None, + 'status': payment_user.status, + 'creation_date': payment_user.creation_date, + 'update_date': payment_user.update_date + } + customers_data.append(customer_data) + + return customers_data \ No newline at end of file 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 b17f189b..93d9a156 100644 --- a/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx @@ -9,6 +9,7 @@ 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' +import PaymentsCustomersPage from '@components/Dashboard/Payments/PaymentsCustomersPage' @@ -101,7 +102,7 @@ function PaymentsPage({ params }: { params: PaymentsParams }) { {selectedSubPage === 'general' &&
General
} {selectedSubPage === 'configuration' && } {selectedSubPage === 'paid-products' && } - {selectedSubPage === 'customers' &&
Customers
} + {selectedSubPage === 'customers' && } ) diff --git a/apps/web/components/Dashboard/Payments/PaymentsCustomersPage.tsx b/apps/web/components/Dashboard/Payments/PaymentsCustomersPage.tsx new file mode 100644 index 00000000..b10b1e04 --- /dev/null +++ b/apps/web/components/Dashboard/Payments/PaymentsCustomersPage.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import { useOrg } from '@components/Contexts/OrgContext' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import useSWR from 'swr' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { getOrgCustomers } from '@services/payments/payments' +import { Badge } from '@/components/ui/badge' +import PageLoading from '@components/Objects/Loaders/PageLoading' +import { RefreshCcw, SquareCheck } from 'lucide-react' +import { getUserAvatarMediaDirectory } from '@services/media/media' +import UserAvatar from '@components/Objects/UserAvatar' + +interface PaymentUserData { + payment_user_id: number; + user: { + username: string; + first_name: string; + last_name: string; + email: string; + avatar_image: string; + user_uuid: string; + }; + product: { + name: string; + description: string; + product_type: string; + amount: number; + currency: string; + }; + status: string; + creation_date: string; +} + +function PaymentsUsersTable({ data }: { data: PaymentUserData[] }) { + return ( + + + + User + Product + Type + Amount + Status + Purchase Date + + + + {data.map((item) => ( + + +
+ +
+ + {item.user.first_name || item.user.username} + + {item.user.email} +
+
+
+ {item.product.name} + +
+ {item.product.product_type === 'subscription' ? ( + + + Subscription + + ) : ( + + + One-time + + )} +
+
+ + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: item.product.currency + }).format(item.product.amount)} + + + + {item.status} + + + + {new Date(item.creation_date).toLocaleDateString()} + +
+ ))} +
+
+ ); +} + +function PaymentsCustomersPage() { + const org = useOrg() as any + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token + + const { data: customers, error, isLoading } = useSWR( + org ? [`/payments/${org.id}/customers`, access_token] : null, + ([url, token]) => getOrgCustomers(org.id, token) + ) + + if (isLoading) return + if (error) return
Error loading customers
+ + return ( +
+
+

Customers

+

View and manage your customer information

+
+ + +
+ ) +} + +export default PaymentsCustomersPage \ No newline at end of file diff --git a/apps/web/components/ui/table.tsx b/apps/web/components/ui/table.tsx new file mode 100644 index 00000000..c0df655c --- /dev/null +++ b/apps/web/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/apps/web/package.json b/apps/web/package.json index 38c718b9..f6177871 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,6 +31,7 @@ "@sentry/nextjs": "^8.35.0", "@sentry/utils": "^8.35.0", "@stitches/react": "^1.2.8", + "@tanstack/react-table": "^8.20.5", "@tiptap/core": "^2.9.1", "@tiptap/extension-code-block-lowlight": "^2.9.1", "@tiptap/extension-collaboration": "^2.9.1", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index ce5a556a..dae8a39c 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@stitches/react': specifier: ^1.2.8 version: 1.2.8(react@18.3.1) + '@tanstack/react-table': + specifier: ^8.20.5 + version: 8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tiptap/core': specifier: ^2.9.1 version: 2.9.1(@tiptap/pm@2.9.1) @@ -1539,6 +1542,17 @@ packages: '@swc/helpers@0.5.5': resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + '@tanstack/react-table@8.20.5': + resolution: {integrity: sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.20.5': + resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} + engines: {node: '>=12'} + '@tiptap/core@2.9.1': resolution: {integrity: sha512-tifnLL/ARzQ6/FGEJjVwj9UT3v+pENdWHdk9x6F3X0mB1y0SeCjV21wpFLYESzwNdBPAj8NMp8Behv7dBnhIfw==} peerDependencies: @@ -5523,6 +5537,14 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.8.0 + '@tanstack/react-table@8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/table-core': 8.20.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/table-core@8.20.5': {} + '@tiptap/core@2.9.1(@tiptap/pm@2.9.1)': dependencies: '@tiptap/pm': 2.9.1 @@ -6486,7 +6508,7 @@ snapshots: '@typescript-eslint/parser': 8.12.2(eslint@8.57.1)(typescript@5.4.4) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.2(eslint@8.57.1) @@ -6506,13 +6528,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -6525,14 +6547,14 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.12.2(eslint@8.57.1)(typescript@5.4.4) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -6547,7 +6569,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 diff --git a/apps/web/services/payments/payments.ts b/apps/web/services/payments/payments.ts index 35d294ef..2b4e0bcb 100644 --- a/apps/web/services/payments/payments.ts +++ b/apps/web/services/payments/payments.ts @@ -45,3 +45,12 @@ export async function deletePaymentConfig(orgId: number, id: string, access_toke const res = await errorHandling(result); return res; } + +export async function getOrgCustomers(orgId: number, access_token: string) { + const result = await fetch( + `${getAPIUrl()}payments/${orgId}/customers`, + RequestBodyWithAuthHeader('GET', null, null, access_token) + ); + const res = await errorHandling(result); + return res; +} \ No newline at end of file