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
| |