feat: use stripe connect for payments

This commit is contained in:
swve 2024-11-09 16:54:43 +01:00
parent cdd893ca6f
commit a8ba053447
17 changed files with 835 additions and 364 deletions

View file

@ -71,6 +71,15 @@ class RedisConfig(BaseModel):
redis_connection_string: Optional[str] redis_connection_string: Optional[str]
class InternalStripeConfig(BaseModel):
stripe_secret_key: str | None
stripe_publishable_key: str | None
class InternalPaymentsConfig(BaseModel):
stripe: InternalStripeConfig
class LearnHouseConfig(BaseModel): class LearnHouseConfig(BaseModel):
site_name: str site_name: str
site_description: str site_description: str
@ -82,6 +91,7 @@ class LearnHouseConfig(BaseModel):
security_config: SecurityConfig security_config: SecurityConfig
ai_config: AIConfig ai_config: AIConfig
mailing_config: MailingConfig mailing_config: MailingConfig
payments_config: InternalPaymentsConfig
def get_learnhouse_config() -> LearnHouseConfig: def get_learnhouse_config() -> LearnHouseConfig:
@ -261,6 +271,18 @@ def get_learnhouse_config() -> LearnHouseConfig:
else: else:
sentry_config = None sentry_config = None
# Payments config
env_stripe_secret_key = os.environ.get("LEARNHOUSE_STRIPE_SECRET_KEY")
env_stripe_publishable_key = os.environ.get("LEARNHOUSE_STRIPE_PUBLISHABLE_KEY")
stripe_secret_key = env_stripe_secret_key or yaml_config.get("payments_config", {}).get(
"stripe", {}
).get("stripe_secret_key")
stripe_publishable_key = env_stripe_publishable_key or yaml_config.get("payments_config", {}).get(
"stripe", {}
).get("stripe_publishable_key")
# Create HostingConfig and DatabaseConfig objects # Create HostingConfig and DatabaseConfig objects
hosting_config = HostingConfig( hosting_config = HostingConfig(
domain=domain, domain=domain,
@ -303,6 +325,12 @@ def get_learnhouse_config() -> LearnHouseConfig:
mailing_config=MailingConfig( mailing_config=MailingConfig(
resend_api_key=resend_api_key, system_email_address=system_email_address resend_api_key=resend_api_key, system_email_address=system_email_address
), ),
payments_config=InternalPaymentsConfig(
stripe=InternalStripeConfig(
stripe_secret_key=stripe_secret_key,
stripe_publishable_key=stripe_publishable_key
)
)
) )
return config return config

View file

@ -37,6 +37,11 @@ database_config:
redis_config: redis_config:
redis_connection_string: redis://localhost:6379/learnhouse redis_connection_string: redis://localhost:6379/learnhouse
payments_config:
stripe:
stripe_secret_key: ""
stripe_publishable_key: ""
ai_config: ai_config:
chromadb_config: chromadb_config:
isSeparateDatabaseEnabled: True isSeparateDatabaseEnabled: True

View file

@ -1,22 +1,16 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from pydantic import BaseModel
from sqlalchemy import JSON from sqlalchemy import JSON
from sqlmodel import Field, SQLModel, Column, BigInteger, ForeignKey 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 # PaymentsConfig
class PaymentProviderEnum(str, Enum): class PaymentProviderEnum(str, Enum):
STRIPE = "stripe" STRIPE = "stripe"
class PaymentsConfigBase(SQLModel): class PaymentsConfigBase(SQLModel):
enabled: bool = True enabled: bool = True
active: bool = False
provider: PaymentProviderEnum = PaymentProviderEnum.STRIPE provider: PaymentProviderEnum = PaymentProviderEnum.STRIPE
provider_config: dict = Field(default={}, sa_column=Column(JSON)) provider_config: dict = Field(default={}, sa_column=Column(JSON))

View file

@ -1,13 +1,13 @@
from typing import Literal
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from sqlmodel import Session from sqlmodel import Session
from src.core.events.database import get_db_session from src.core.events.database import get_db_session
from src.db.payments.payments import PaymentsConfig, PaymentsConfigCreate, PaymentsConfigRead, PaymentsConfigUpdate from src.db.payments.payments import PaymentsConfig, PaymentsConfigRead
from src.db.users import PublicUser from src.db.users import PublicUser
from src.security.auth import get_current_user from src.security.auth import get_current_user
from src.services.payments.payments_config import ( from src.services.payments.payments_config import (
create_payments_config, init_payments_config,
get_payments_config, get_payments_config,
update_payments_config,
delete_payments_config, delete_payments_config,
) )
from src.db.payments.payments_products import PaymentsProductCreate, PaymentsProductRead, PaymentsProductUpdate from src.db.payments.payments_products import PaymentsProductCreate, PaymentsProductRead, PaymentsProductUpdate
@ -18,10 +18,11 @@ from src.services.payments.payments_courses import (
get_courses_by_product, get_courses_by_product,
) )
from src.services.payments.payments_users import get_owned_courses from src.services.payments.payments_users import get_owned_courses
from src.services.payments.payments_webhook import handle_stripe_webhook from src.services.payments.webhooks.payments_connected_webhook import handle_stripe_webhook
from src.services.payments.stripe import create_checkout_session from src.services.payments.payments_stripe import create_checkout_session, update_stripe_account_id
from src.services.payments.payments_access import check_course_paid_access from src.services.payments.payments_access import check_course_paid_access
from src.services.payments.payments_customers import get_customers from src.services.payments.payments_customers import get_customers
from src.services.payments.payments_stripe import generate_stripe_connect_link
router = APIRouter() router = APIRouter()
@ -30,11 +31,12 @@ router = APIRouter()
async def api_create_payments_config( async def api_create_payments_config(
request: Request, request: Request,
org_id: int, org_id: int,
payments_config: PaymentsConfigCreate, provider: Literal["stripe"],
current_user: PublicUser = Depends(get_current_user), current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session), db_session: Session = Depends(get_db_session),
) -> PaymentsConfig: ) -> PaymentsConfig:
return await create_payments_config(request, org_id, payments_config, current_user, db_session) return await init_payments_config(request, org_id, provider, current_user, db_session)
@router.get("/{org_id}/config") @router.get("/{org_id}/config")
async def api_get_payments_config( async def api_get_payments_config(
@ -45,16 +47,6 @@ async def api_get_payments_config(
) -> list[PaymentsConfigRead]: ) -> list[PaymentsConfigRead]:
return await get_payments_config(request, org_id, current_user, db_session) return await get_payments_config(request, org_id, current_user, db_session)
@router.put("/{org_id}/config")
async def api_update_payments_config(
request: Request,
org_id: int,
payments_config: PaymentsConfigUpdate,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> PaymentsConfig:
return await update_payments_config(request, org_id, payments_config, current_user, db_session)
@router.delete("/{org_id}/config") @router.delete("/{org_id}/config")
async def api_delete_payments_config( async def api_delete_payments_config(
request: Request, request: Request,
@ -228,3 +220,30 @@ async def api_get_owned_courses(
db_session: Session = Depends(get_db_session), db_session: Session = Depends(get_db_session),
): ):
return await get_owned_courses(request, current_user, db_session) return await get_owned_courses(request, current_user, db_session)
@router.put("/{org_id}/stripe/account")
async def api_update_stripe_account_id(
request: Request,
org_id: int,
stripe_account_id: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
return await update_stripe_account_id(
request, org_id, stripe_account_id, current_user, db_session
)
@router.post("/{org_id}/stripe/connect/link")
async def api_generate_stripe_connect_link(
request: Request,
org_id: int,
redirect_uri: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Generate a Stripe OAuth link for connecting a Stripe account
"""
return await generate_stripe_connect_link(
request, org_id, redirect_uri, current_user, db_session
)

View file

@ -1,8 +1,9 @@
from typing import Literal
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.payments.payments import ( from src.db.payments.payments import (
PaymentProviderEnum,
PaymentsConfig, PaymentsConfig,
PaymentsConfigCreate,
PaymentsConfigUpdate, PaymentsConfigUpdate,
PaymentsConfigRead, PaymentsConfigRead,
) )
@ -11,33 +12,45 @@ from src.db.organizations import Organization
from src.services.orgs.orgs import rbac_check from src.services.orgs.orgs import rbac_check
async def create_payments_config( async def init_payments_config(
request: Request, request: Request,
org_id: int, org_id: int,
payments_config: PaymentsConfigCreate, provider: Literal["stripe"],
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
db_session: Session, db_session: Session,
) -> PaymentsConfig: ) -> PaymentsConfig:
# Check if organization exists # Validate organization exists
statement = select(Organization).where(Organization.id == org_id) org = db_session.exec(
org = db_session.exec(statement).first() select(Organization).where(Organization.id == org_id)
).first()
if not org: if not org:
raise HTTPException(status_code=404, detail="Organization not found") raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check # Verify permissions
await rbac_check(request, org.org_uuid, current_user, "create", db_session) await rbac_check(request, org.org_uuid, current_user, "create", db_session)
# Check if payments config already exists for this organization # Check for existing config
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) existing_config = db_session.exec(
existing_config = db_session.exec(statement).first() select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
).first()
if existing_config: if existing_config:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail="Payments config already exists for this organization", detail="Payments config already exists for this organization"
) )
# Create new payments config # Initialize new config
new_config = PaymentsConfig(**payments_config.model_dump(), org_id=org_id) new_config = PaymentsConfig(
org_id=org_id,
provider=PaymentProviderEnum.STRIPE,
provider_config={
"onboarding_completed": False,
"stripe_account_id": ""
}
)
# Save to database
db_session.add(new_config) db_session.add(new_config)
db_session.commit() db_session.commit()
db_session.refresh(new_config) db_session.refresh(new_config)
@ -71,7 +84,7 @@ async def update_payments_config(
request: Request, request: Request,
org_id: int, org_id: int,
payments_config: PaymentsConfigUpdate, payments_config: PaymentsConfigUpdate,
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session, db_session: Session,
) -> PaymentsConfig: ) -> PaymentsConfig:
# Check if organization exists # Check if organization exists

View file

@ -15,7 +15,7 @@ from src.db.organizations import Organization
from src.services.orgs.orgs import rbac_check from src.services.orgs.orgs import rbac_check
from datetime import datetime from datetime import datetime
from src.services.payments.stripe import archive_stripe_product, create_stripe_product, update_stripe_product from src.services.payments.payments_stripe import archive_stripe_product, create_stripe_product, update_stripe_product
async def create_payments_product( async def create_payments_product(
request: Request, request: Request,
@ -33,12 +33,15 @@ async def create_payments_product(
# RBAC check # RBAC check
await rbac_check(request, org.org_uuid, current_user, "create", db_session) await rbac_check(request, org.org_uuid, current_user, "create", db_session)
# Check if payments config exists and has a valid id # Check if payments config exists, has a valid id, and is active
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
config = db_session.exec(statement).first() config = db_session.exec(statement).first()
if not config or config.id is None: if not config or config.id is None:
raise HTTPException(status_code=404, detail="Valid payments config not found") raise HTTPException(status_code=404, detail="Valid payments config not found")
if not config.active:
raise HTTPException(status_code=400, detail="Payments config is not active")
# Create new payments product # Create new payments product
new_product = PaymentsProduct(**payments_product.model_dump(), org_id=org_id, payments_config_id=config.id) new_product = PaymentsProduct(**payments_product.model_dump(), org_id=org_id, payments_config_id=config.id)
new_product.creation_date = datetime.now() new_product.creation_date = datetime.now()

View file

@ -0,0 +1,429 @@
import logging
from typing import Literal
from fastapi import HTTPException, Request
from sqlmodel import Session
import stripe
from config.config import get_learnhouse_config
from src.db.payments.payments import PaymentsConfigUpdate, PaymentsConfig
from src.db.payments.payments_products import (
PaymentPriceTypeEnum,
PaymentProductTypeEnum,
PaymentsProduct,
)
from src.db.payments.payments_users import PaymentStatusEnum
from src.db.users import AnonymousUser, InternalUser, PublicUser
from src.services.payments.payments_config import (
get_payments_config,
update_payments_config,
)
from sqlmodel import select
from src.services.payments.payments_users import (
create_payment_user,
delete_payment_user,
)
async def get_stripe_connected_account_id(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
# Get payments config
payments_config = await get_payments_config(request, org_id, current_user, db_session)
return payments_config[0].provider_config.get("stripe_account_id")
async def get_stripe_credentials(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
# Get payments config from config file
learnhouse_config = get_learnhouse_config()
if not learnhouse_config.payments_config.stripe.stripe_secret_key:
raise HTTPException(status_code=400, detail="Stripe secret key not configured")
if not learnhouse_config.payments_config.stripe.stripe_publishable_key:
raise HTTPException(
status_code=400, detail="Stripe publishable key not configured"
)
return {
"stripe_secret_key": learnhouse_config.payments_config.stripe.stripe_secret_key,
"stripe_publishable_key": learnhouse_config.payments_config.stripe.stripe_publishable_key,
}
async def create_stripe_product(
request: Request,
org_id: int,
product_data: PaymentsProduct,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
creds = await get_stripe_credentials(request, org_id, current_user, db_session)
# Set the Stripe API key using the credentials
stripe.api_key = creds.get("stripe_secret_key")
# Prepare default_price_data based on price_type
if product_data.price_type == PaymentPriceTypeEnum.CUSTOMER_CHOICE:
default_price_data = {
"currency": product_data.currency,
"custom_unit_amount": {
"enabled": True,
"minimum": int(product_data.amount * 100), # Convert to cents
},
}
else:
default_price_data = {
"currency": product_data.currency,
"unit_amount": int(product_data.amount * 100), # Convert to cents
}
if product_data.product_type == PaymentProductTypeEnum.SUBSCRIPTION:
default_price_data["recurring"] = {"interval": "month"}
stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session)
product = stripe.Product.create(
name=product_data.name,
description=product_data.description or "",
marketing_features=[
{"name": benefit.strip()}
for benefit in product_data.benefits.split(",")
if benefit.strip()
],
default_price_data=default_price_data, # type: ignore
stripe_account=stripe_acc_id,
)
return product
async def archive_stripe_product(
request: Request,
org_id: int,
product_id: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
creds = await get_stripe_credentials(request, org_id, current_user, db_session)
# Set the Stripe API key using the credentials
stripe.api_key = creds.get("stripe_secret_key")
try:
# Archive the product in Stripe
archived_product = stripe.Product.modify(product_id, active=False)
return archived_product
except stripe.StripeError as e:
print(f"Error archiving Stripe product: {str(e)}")
raise HTTPException(
status_code=400, detail=f"Error archiving Stripe product: {str(e)}"
)
async def update_stripe_product(
request: Request,
org_id: int,
product_id: str,
product_data: PaymentsProduct,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
creds = await get_stripe_credentials(request, org_id, current_user, db_session)
# Set the Stripe API key using the credentials
stripe.api_key = creds.get("stripe_secret_key")
try:
# Create new price based on price_type
if product_data.price_type == PaymentPriceTypeEnum.CUSTOMER_CHOICE:
new_price_data = {
"currency": product_data.currency,
"product": product_id,
"custom_unit_amount": {
"enabled": True,
"minimum": int(product_data.amount * 100), # Convert to cents
},
}
else:
new_price_data = {
"currency": product_data.currency,
"unit_amount": int(product_data.amount * 100), # Convert to cents
"product": product_id,
}
if product_data.product_type == PaymentProductTypeEnum.SUBSCRIPTION:
new_price_data["recurring"] = {"interval": "month"}
new_price = stripe.Price.create(**new_price_data)
# Prepare the update data
update_data = {
"name": product_data.name,
"description": product_data.description or "",
"metadata": {"benefits": product_data.benefits},
"marketing_features": [
{"name": benefit.strip()}
for benefit in product_data.benefits.split(",")
if benefit.strip()
],
"default_price": new_price.id,
}
# Update the product in Stripe
updated_product = stripe.Product.modify(product_id, **update_data)
# Archive all existing prices for the product
existing_prices = stripe.Price.list(product=product_id, active=True)
for price in existing_prices:
if price.id != new_price.id:
stripe.Price.modify(price.id, active=False)
return updated_product
except stripe.StripeError as e:
raise HTTPException(
status_code=400, detail=f"Error updating Stripe product: {str(e)}"
)
async def create_checkout_session(
request: Request,
org_id: int,
product_id: int,
redirect_uri: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Get Stripe credentials
creds = await get_stripe_credentials(request, org_id, current_user, db_session)
stripe.api_key = creds.get("stripe_secret_key")
# Get product details
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="Product not found")
success_url = redirect_uri
cancel_url = redirect_uri
# Get the default price for the product
stripe_product = stripe.Product.retrieve(product.provider_product_id)
line_items = [{"price": stripe_product.default_price, "quantity": 1}]
stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session)
# Create or retrieve Stripe customer
try:
customers = stripe.Customer.list(
email=current_user.email, stripe_account=stripe_acc_id
)
if customers.data:
customer = customers.data[0]
else:
customer = stripe.Customer.create(
email=current_user.email,
metadata={
"user_id": str(current_user.id),
"org_id": str(org_id),
},
stripe_account=stripe_acc_id,
)
# Create initial payment user with pending status
payment_user = await create_payment_user(
request=request,
org_id=org_id,
user_id=current_user.id,
product_id=product_id,
status=PaymentStatusEnum.PENDING,
provider_data=customer,
current_user=InternalUser(),
db_session=db_session,
)
if not payment_user:
raise HTTPException(status_code=400, detail="Error creating payment user")
except stripe.StripeError as e:
# Clean up payment user if customer creation fails
if payment_user and payment_user.id:
await delete_payment_user(
request, org_id, payment_user.id, InternalUser(), db_session
)
raise HTTPException(
status_code=400, detail=f"Error creating/retrieving customer: {str(e)}"
)
# Create checkout session with customer
try:
checkout_session_params = {
"success_url": success_url,
"cancel_url": cancel_url,
"mode": (
"payment"
if product.product_type == PaymentProductTypeEnum.ONE_TIME
else "subscription"
),
"line_items": line_items,
"customer": customer.id,
"metadata": {
"product_id": str(product.id),
"payment_user_id": str(payment_user.id),
},
"stripe_account": stripe_acc_id,
}
# Add payment_intent_data only for one-time payments
if product.product_type == PaymentProductTypeEnum.ONE_TIME:
checkout_session_params["payment_intent_data"] = {
"metadata": {
"product_id": str(product.id),
"payment_user_id": str(payment_user.id),
}
}
# Add subscription_data for subscription payments
else:
checkout_session_params["subscription_data"] = {
"metadata": {
"product_id": str(product.id),
"payment_user_id": str(payment_user.id),
}
}
checkout_session = stripe.checkout.Session.create(**checkout_session_params)
return {"checkout_url": checkout_session.url, "session_id": checkout_session.id}
except stripe.StripeError as e:
# Clean up payment user if checkout session creation fails
if payment_user and payment_user.id:
await delete_payment_user(
request, org_id, payment_user.id, InternalUser(), db_session
)
logging.error(f"Error creating checkout session: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
async def generate_stripe_connect_link(
request: Request,
org_id: int,
redirect_uri: str,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
"""
Generate a Stripe OAuth link for connecting a Stripe account
"""
# Get credentials
creds = await get_stripe_credentials(request, org_id, current_user, db_session)
stripe.api_key = creds.get("stripe_secret_key")
# Get config
learnhouse_config = get_learnhouse_config()
# Get client id
stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session)
if not stripe_acc_id:
raise HTTPException(status_code=400, detail="No Stripe account ID found for this organization")
# Generate OAuth link
connect_link = stripe.AccountLink.create(
account=stripe_acc_id,
type="account_onboarding",
return_url=redirect_uri,
refresh_url=redirect_uri,
)
return {"connect_url": connect_link.url}
async def create_stripe_account(
request: Request,
org_id: int,
type: Literal["standard"], # Only standard is supported for now, we'll see if we need express later
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
# Get credentials
creds = await get_stripe_credentials(request, org_id, current_user, db_session)
stripe.api_key = creds.get("stripe_secret_key")
# Get existing payments config
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
existing_config = db_session.exec(statement).first()
if existing_config and existing_config.provider_config.get("stripe_account_id"):
raise HTTPException(
status_code=400,
detail="A Stripe Express account is already linked to this organization"
)
# Create Stripe account
stripe_account = stripe.Account.create(
type="standard",
capabilities={
"card_payments": {"requested": True},
"transfers": {"requested": True},
},
)
# Update payments config for the org
await update_payments_config(
request,
org_id,
PaymentsConfigUpdate(
enabled=True,
provider_config={"stripe_account_id": stripe_account.id}
),
current_user,
db_session,
)
return stripe_account
async def update_stripe_account_id(
request: Request,
org_id: int,
stripe_account_id: str,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
"""
Update the Stripe account ID for an organization
"""
# Get existing payments config
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
existing_config = db_session.exec(statement).first()
if not existing_config:
raise HTTPException(
status_code=404,
detail="No payments configuration found for this organization"
)
# Update payments config with new stripe account id
await update_payments_config(
request,
org_id,
PaymentsConfigUpdate(
enabled=True,
provider_config={"stripe_account_id": stripe_account_id}
),
current_user,
db_session,
)
return {"message": "Stripe account ID updated successfully"}

View file

@ -1,272 +0,0 @@
import logging
from fastapi import HTTPException, Request
from sqlmodel import Session
import stripe
from src.db.payments.payments_products import PaymentPriceTypeEnum, PaymentProductTypeEnum, PaymentsProduct
from src.db.payments.payments_users import PaymentStatusEnum
from src.db.users import AnonymousUser, InternalUser, PublicUser
from src.services.payments.payments_config import get_payments_config
from sqlmodel import select
from src.services.payments.payments_users import create_payment_user, delete_payment_user
async def get_stripe_credentials(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
configs = await get_payments_config(request, org_id, current_user, db_session)
if len(configs) == 0:
raise HTTPException(status_code=404, detail="Payments config not found")
if len(configs) > 1:
raise HTTPException(
status_code=400, detail="Organization has multiple payments configs"
)
config = configs[0]
if config.provider != "stripe":
raise HTTPException(
status_code=400, detail="Payments config is not a Stripe config"
)
# Get provider config
credentials = config.provider_config
return credentials
async def create_stripe_product(
request: Request,
org_id: int,
product_data: PaymentsProduct,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
creds = await get_stripe_credentials(request, org_id, current_user, db_session)
# Set the Stripe API key using the credentials
stripe.api_key = creds.get('stripe_secret_key')
# Prepare default_price_data based on price_type
if product_data.price_type == PaymentPriceTypeEnum.CUSTOMER_CHOICE:
default_price_data = {
"currency": product_data.currency,
"custom_unit_amount": {
"enabled": True,
"minimum": int(product_data.amount * 100), # Convert to cents
}
}
else:
default_price_data = {
"currency": product_data.currency,
"unit_amount": int(product_data.amount * 100) # Convert to cents
}
if product_data.product_type == PaymentProductTypeEnum.SUBSCRIPTION:
default_price_data["recurring"] = {"interval": "month"}
product = stripe.Product.create(
name=product_data.name,
description=product_data.description or "",
marketing_features=[{"name": benefit.strip()} for benefit in product_data.benefits.split(",") if benefit.strip()],
default_price_data=default_price_data # type: ignore
)
return product
async def archive_stripe_product(
request: Request,
org_id: int,
product_id: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
creds = await get_stripe_credentials(request, org_id, current_user, db_session)
# Set the Stripe API key using the credentials
stripe.api_key = creds.get('stripe_secret_key')
try:
# Archive the product in Stripe
archived_product = stripe.Product.modify(product_id, active=False)
return archived_product
except stripe.StripeError as e:
print(f"Error archiving Stripe product: {str(e)}")
raise HTTPException(status_code=400, detail=f"Error archiving Stripe product: {str(e)}")
async def update_stripe_product(
request: Request,
org_id: int,
product_id: str,
product_data: PaymentsProduct,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
creds = await get_stripe_credentials(request, org_id, current_user, db_session)
# Set the Stripe API key using the credentials
stripe.api_key = creds.get('stripe_secret_key')
try:
# Create new price based on price_type
if product_data.price_type == PaymentPriceTypeEnum.CUSTOMER_CHOICE:
new_price_data = {
"currency": product_data.currency,
"product": product_id,
"custom_unit_amount": {
"enabled": True,
"minimum": int(product_data.amount * 100), # Convert to cents
}
}
else:
new_price_data = {
"currency": product_data.currency,
"unit_amount": int(product_data.amount * 100), # Convert to cents
"product": product_id,
}
if product_data.product_type == PaymentProductTypeEnum.SUBSCRIPTION:
new_price_data["recurring"] = {"interval": "month"}
new_price = stripe.Price.create(**new_price_data)
# Prepare the update data
update_data = {
"name": product_data.name,
"description": product_data.description or "",
"metadata": {"benefits": product_data.benefits},
"marketing_features": [{"name": benefit.strip()} for benefit in product_data.benefits.split(",") if benefit.strip()],
"default_price": new_price.id
}
# Update the product in Stripe
updated_product = stripe.Product.modify(product_id, **update_data)
# Archive all existing prices for the product
existing_prices = stripe.Price.list(product=product_id, active=True)
for price in existing_prices:
if price.id != new_price.id:
stripe.Price.modify(price.id, active=False)
return updated_product
except stripe.StripeError as e:
raise HTTPException(status_code=400, detail=f"Error updating Stripe product: {str(e)}")
async def create_checkout_session(
request: Request,
org_id: int,
product_id: int,
redirect_uri: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Get Stripe credentials
creds = await get_stripe_credentials(request, org_id, current_user, db_session)
stripe.api_key = creds.get('stripe_secret_key')
# Get product details
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="Product not found")
success_url = redirect_uri
cancel_url = redirect_uri
# Get the default price for the product
stripe_product = stripe.Product.retrieve(product.provider_product_id)
line_items = [{
"price": stripe_product.default_price,
"quantity": 1
}]
# Create or retrieve Stripe customer
try:
customers = stripe.Customer.list(email=current_user.email)
if customers.data:
customer = customers.data[0]
else:
customer = stripe.Customer.create(
email=current_user.email,
metadata={
"user_id": str(current_user.id),
"org_id": str(org_id),
}
)
# Create initial payment user with pending status
payment_user = await create_payment_user(
request=request,
org_id=org_id,
user_id=current_user.id,
product_id=product_id,
status=PaymentStatusEnum.PENDING,
provider_data=customer,
current_user=InternalUser(),
db_session=db_session
)
if not payment_user:
raise HTTPException(status_code=400, detail="Error creating payment user")
except stripe.StripeError as e:
# Clean up payment user if customer creation fails
if payment_user and payment_user.id:
await delete_payment_user(request, org_id, payment_user.id, InternalUser(), db_session)
raise HTTPException(status_code=400, detail=f"Error creating/retrieving customer: {str(e)}")
# Create checkout session with customer
try:
checkout_session_params = {
"success_url": success_url,
"cancel_url": cancel_url,
"mode": 'payment' if product.product_type == PaymentProductTypeEnum.ONE_TIME else 'subscription',
"line_items": line_items,
"customer": customer.id,
"metadata": {
"product_id": str(product.id),
"payment_user_id": str(payment_user.id)
}
}
# Add payment_intent_data only for one-time payments
if product.product_type == PaymentProductTypeEnum.ONE_TIME:
checkout_session_params["payment_intent_data"] = {
"metadata": {
"product_id": str(product.id),
"payment_user_id": str(payment_user.id)
}
}
# Add subscription_data for subscription payments
else:
checkout_session_params["subscription_data"] = {
"metadata": {
"product_id": str(product.id),
"payment_user_id": str(payment_user.id)
}
}
checkout_session = stripe.checkout.Session.create(**checkout_session_params)
return {
"checkout_url": checkout_session.url,
"session_id": checkout_session.id
}
except stripe.StripeError as e:
# Clean up payment user if checkout session creation fails
if payment_user and payment_user.id:
await delete_payment_user(request, org_id, payment_user.id, InternalUser(), db_session)
logging.error(f"Error creating checkout session: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))

View file

@ -7,7 +7,7 @@ from src.db.payments.payments_users import PaymentStatusEnum
from src.db.payments.payments_products import PaymentsProduct from src.db.payments.payments_products import PaymentsProduct
from src.db.users import InternalUser, User from src.db.users import InternalUser, User
from src.services.payments.payments_users import update_payment_user_status from src.services.payments.payments_users import update_payment_user_status
from src.services.payments.stripe import get_stripe_credentials from src.services.payments.payments_stripe import get_stripe_credentials
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -3,18 +3,21 @@ import React, { useState, useEffect } from 'react';
import { useOrg } from '@components/Contexts/OrgContext'; import { useOrg } from '@components/Contexts/OrgContext';
import { SiStripe } from '@icons-pack/react-simple-icons' import { SiStripe } from '@icons-pack/react-simple-icons'
import { useLHSession } from '@components/Contexts/LHSessionContext'; import { useLHSession } from '@components/Contexts/LHSessionContext';
import { getPaymentConfigs, createPaymentConfig, updatePaymentConfig, deletePaymentConfig } from '@services/payments/payments'; import { getPaymentConfigs, initializePaymentConfig, updatePaymentConfig, deletePaymentConfig, updateStripeAccountID, getStripeOnboardingLink } from '@services/payments/payments';
import FormLayout, { ButtonBlack, Input, Textarea, FormField, FormLabelAndMessage, Flex } from '@components/StyledElements/Form/Form'; import FormLayout, { ButtonBlack, Input, Textarea, FormField, FormLabelAndMessage, Flex } from '@components/StyledElements/Form/Form';
import { Check, Edit, Trash2 } from 'lucide-react'; import { AlertTriangle, BarChart2, Check, Coins, CreditCard, Edit, ExternalLink, Info, Loader2, RefreshCcw, Trash2 } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR, { mutate } from 'swr'; import useSWR, { mutate } from 'swr';
import Modal from '@components/StyledElements/Modal/Modal'; import Modal from '@components/StyledElements/Modal/Modal';
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'; import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal';
import { Button } from '@components/ui/button'; import { Button } from '@components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@components/ui/alert';
import { useRouter } from 'next/navigation';
const PaymentsConfigurationPage: React.FC = () => { const PaymentsConfigurationPage: React.FC = () => {
const org = useOrg() as any; const org = useOrg() as any;
const session = useLHSession() as any; const session = useLHSession() as any;
const router = useRouter();
const access_token = session?.data?.tokens?.access_token; const access_token = session?.data?.tokens?.access_token;
const { data: paymentConfigs, error, isLoading } = useSWR( const { data: paymentConfigs, error, isLoading } = useSWR(
() => (org && access_token ? [`/payments/${org.id}/config`, access_token] : null), () => (org && access_token ? [`/payments/${org.id}/config`, access_token] : null),
@ -23,16 +26,21 @@ const PaymentsConfigurationPage: React.FC = () => {
const stripeConfig = paymentConfigs?.find((config: any) => config.provider === 'stripe'); const stripeConfig = paymentConfigs?.find((config: any) => config.provider === 'stripe');
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isOnboarding, setIsOnboarding] = useState(false);
const [isOnboardingLoading, setIsOnboardingLoading] = useState(false);
const enableStripe = async () => { const enableStripe = async () => {
try { try {
setIsOnboarding(true);
const newConfig = { provider: 'stripe', enabled: true }; const newConfig = { provider: 'stripe', enabled: true };
const config = await createPaymentConfig(org.id, newConfig, access_token); const config = await initializePaymentConfig(org.id, newConfig, 'stripe', access_token);
toast.success('Stripe enabled successfully'); toast.success('Stripe enabled successfully');
mutate([`/payments/${org.id}/config`, access_token]); mutate([`/payments/${org.id}/config`, access_token]);
} catch (error) { } catch (error) {
console.error('Error enabling Stripe:', error); console.error('Error enabling Stripe:', error);
toast.error('Failed to enable Stripe'); toast.error('Failed to enable Stripe');
} finally {
setIsOnboarding(false);
} }
}; };
@ -51,6 +59,19 @@ const PaymentsConfigurationPage: React.FC = () => {
} }
}; };
const handleStripeOnboarding = async () => {
try {
setIsOnboardingLoading(true);
const { connect_url } = await getStripeOnboardingLink(org.id, access_token, window.location.href);
router.push(connect_url);
} catch (error) {
console.error('Error getting onboarding link:', error);
toast.error('Failed to start Stripe onboarding');
} finally {
setIsOnboardingLoading(false);
}
};
if (isLoading) { if (isLoading) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
@ -66,17 +87,88 @@ const PaymentsConfigurationPage: React.FC = () => {
<h1 className="font-bold text-xl text-gray-800">Payments Configuration</h1> <h1 className="font-bold text-xl text-gray-800">Payments Configuration</h1>
<h2 className="text-gray-500 text-md">Manage your organization payments configuration</h2> <h2 className="text-gray-500 text-md">Manage your organization payments configuration</h2>
</div> </div>
<Alert className="mb-3 p-6 border-2 border-blue-100 bg-blue-50/50">
<AlertTitle className="text-lg font-semibold mb-2 flex items-center space-x-2"> <Info className="h-5 w-5 " /> <span>About the Stripe Integration</span></AlertTitle>
<AlertDescription className="space-y-5">
<div className="pl-2">
<ul className="list-disc list-inside space-y-1 text-gray-600 pl-2">
<li className="flex items-center space-x-2">
<CreditCard className="h-4 w-4" />
<span>Accept payments for courses and subscriptions</span>
</li>
<li className="flex items-center space-x-2">
<RefreshCcw className="h-4 w-4" />
<span>Manage recurring billing and subscriptions</span>
</li>
<li className="flex items-center space-x-2">
<Coins className="h-4 w-4" />
<span>Handle multiple currencies and payment methods</span>
</li>
<li className="flex items-center space-x-2">
<BarChart2 className="h-4 w-4" />
<span>Access detailed payment analytics</span>
</li>
</ul>
</div>
<a
href="https://stripe.com/docs"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 inline-flex items-center font-medium transition-colors duration-200 pl-2"
>
Learn more about Stripe
<ExternalLink className="ml-1.5 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<div className="flex flex-col rounded-lg light-shadow"> <div className="flex flex-col rounded-lg light-shadow">
{stripeConfig ? ( {stripeConfig ? (
<div className="flex items-center justify-between bg-gradient-to-r from-indigo-500 to-purple-600 p-6 rounded-lg shadow-md"> <div className="flex items-center justify-between bg-gradient-to-r from-indigo-500 to-purple-600 p-6 rounded-lg shadow-md">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<SiStripe className="text-white" size={32} /> <SiStripe className="text-white" size={32} />
<span className="text-xl font-semibold text-white">Stripe is enabled</span> <div className="flex flex-col">
<div className="flex items-center space-x-2">
<span className="text-xl font-semibold text-white">Stripe</span>
{stripeConfig.provider_config?.stripe_account_id && stripeConfig.active ? (
<div className="flex items-center space-x-1 bg-green-500/20 px-2 py-0.5 rounded-full">
<div className="h-2 w-2 bg-green-500 rounded-full" />
<span className="text-xs text-green-100">Connected</span>
</div>
) : (
<div className="flex items-center space-x-1 bg-red-500/20 px-2 py-0.5 rounded-full">
<div className="h-2 w-2 bg-red-500 rounded-full" />
<span className="text-xs text-red-100">Not Connected</span>
</div>
)}
</div>
<span className="text-white/80 text-sm">
{stripeConfig.provider_config?.stripe_account_id ?
`Linked Account: ${stripeConfig.provider_config.stripe_account_id}` :
'Account ID not configured'}
</span>
</div>
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
{!stripeConfig.active && stripeConfig.provider_config?.stripe_account_id && (
<Button
onClick={handleStripeOnboarding}
className="flex items-center space-x-2 px-4 py-2 bg-yellow-500 text-white text-sm rounded-full hover:bg-yellow-600 transition duration-300 disabled:opacity-50 disabled:cursor-not-allowed border-2 border-yellow-400 shadow-md"
disabled={isOnboardingLoading}
>
{isOnboardingLoading ? (
<Loader2 className="animate-spin h-4 w-4" />
) : (
<AlertTriangle className="h-4 w-4" />
)}
<span className="font-semibold">Complete Onboarding</span>
</Button>
)}
<Button <Button
onClick={editConfig} onClick={editConfig}
className="flex items-center space-x-2 px-4 py-2 bg-white text-purple-700 text-sm rounded-full hover:bg-gray-100 transition duration-300" className="flex items-center space-x-2 px-4 py-2 bg-white text-purple-700 text-sm rounded-full hover:bg-gray-100 transition duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Edit size={16} /> <Edit size={16} />
<span>Edit Configuration</span> <span>Edit Configuration</span>
@ -86,7 +178,9 @@ const PaymentsConfigurationPage: React.FC = () => {
confirmationMessage="Are you sure you want to delete the Stripe configuration? This action cannot be undone." confirmationMessage="Are you sure you want to delete the Stripe configuration? This action cannot be undone."
dialogTitle="Delete Stripe Configuration" dialogTitle="Delete Stripe Configuration"
dialogTrigger={ dialogTrigger={
<Button className="flex items-center space-x-2 bg-red-500 text-white text-sm rounded-full hover:bg-red-600 transition duration-300"> <Button
className="flex items-center space-x-2 bg-red-500 text-white text-sm rounded-full hover:bg-red-600 transition duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 size={16} /> <Trash2 size={16} />
<span>Delete Configuration</span> <span>Delete Configuration</span>
</Button> </Button>
@ -99,10 +193,20 @@ const PaymentsConfigurationPage: React.FC = () => {
) : ( ) : (
<Button <Button
onClick={enableStripe} onClick={enableStripe}
className="flex items-center justify-center space-x-2 bg-gradient-to-r p-3 from-indigo-500 to-purple-600 text-white px-6 rounded-lg hover:from-indigo-600 hover:to-purple-700 transition duration-300 shadow-md" className="flex items-center justify-center space-x-2 bg-gradient-to-r p-3 from-indigo-500 to-purple-600 text-white px-6 rounded-lg hover:from-indigo-600 hover:to-purple-700 transition duration-300 shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isOnboarding}
> >
{isOnboarding ? (
<>
<Loader2 className="animate-spin" size={24} />
<span className="text-lg font-semibold">Connecting to Stripe...</span>
</>
) : (
<>
<SiStripe size={24} /> <SiStripe size={24} />
<span className="text-lg font-semibold">Enable Stripe</span> <span className="text-lg font-semibold">Enable Stripe</span>
</>
)}
</Button> </Button>
)} )}
</div> </div>
@ -129,20 +233,15 @@ interface EditStripeConfigModalProps {
} }
const EditStripeConfigModal: React.FC<EditStripeConfigModalProps> = ({ orgId, configId, accessToken, isOpen, onClose }) => { const EditStripeConfigModal: React.FC<EditStripeConfigModalProps> = ({ orgId, configId, accessToken, isOpen, onClose }) => {
const [stripePublishableKey, setStripePublishableKey] = useState(''); const [stripeAccountId, setStripeAccountId] = useState('');
const [stripeSecretKey, setStripeSecretKey] = useState('');
const [stripeWebhookSecret, setStripeWebhookSecret] = useState('');
// Add this useEffect hook to fetch and set the existing configuration
useEffect(() => { useEffect(() => {
const fetchConfig = async () => { const fetchConfig = async () => {
try { try {
const config = await getPaymentConfigs(orgId, accessToken); const config = await getPaymentConfigs(orgId, accessToken);
const stripeConfig = config.find((c: any) => c.id === configId); const stripeConfig = config.find((c: any) => c.id === configId);
if (stripeConfig && stripeConfig.provider_config) { if (stripeConfig && stripeConfig.provider_config) {
setStripePublishableKey(stripeConfig.provider_config.stripe_publishable_key || ''); setStripeAccountId(stripeConfig.provider_config.stripe_account_id || '');
setStripeSecretKey(stripeConfig.provider_config.stripe_secret_key || '');
setStripeWebhookSecret(stripeConfig.provider_config.stripe_webhook_secret || '');
} }
} catch (error) { } catch (error) {
console.error('Error fetching Stripe configuration:', error); console.error('Error fetching Stripe configuration:', error);
@ -158,14 +257,9 @@ const EditStripeConfigModal: React.FC<EditStripeConfigModalProps> = ({ orgId, co
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
const stripe_config = { const stripe_config = {
stripe_publishable_key: stripePublishableKey, stripe_account_id: stripeAccountId,
stripe_secret_key: stripeSecretKey,
stripe_webhook_secret: stripeWebhookSecret,
}; };
const updatedConfig = { await updateStripeAccountID(orgId, stripe_config, accessToken);
provider_config: stripe_config,
};
await updatePaymentConfig(orgId, configId, updatedConfig, accessToken);
toast.success('Configuration updated successfully'); toast.success('Configuration updated successfully');
mutate([`/payments/${orgId}/config`, accessToken]); mutate([`/payments/${orgId}/config`, accessToken]);
onClose(); onClose();
@ -179,31 +273,13 @@ const EditStripeConfigModal: React.FC<EditStripeConfigModalProps> = ({ orgId, co
<Modal isDialogOpen={isOpen} dialogTitle="Edit Stripe Configuration" dialogDescription='Edit your stripe configuration' onOpenChange={onClose} <Modal isDialogOpen={isOpen} dialogTitle="Edit Stripe Configuration" dialogDescription='Edit your stripe configuration' onOpenChange={onClose}
dialogContent={ dialogContent={
<FormLayout onSubmit={handleSubmit}> <FormLayout onSubmit={handleSubmit}>
<FormField name="stripe-key"> <FormField name="stripe-account-id">
<FormLabelAndMessage label="Stripe Publishable Key" /> <FormLabelAndMessage label="Stripe Account ID" />
<Input <Input
type="text" type="text"
value={stripePublishableKey} value={stripeAccountId}
onChange={(e) => setStripePublishableKey(e.target.value)} onChange={(e) => setStripeAccountId(e.target.value)}
placeholder="pk_test_..." placeholder="acct_..."
/>
</FormField>
<FormField name="stripe-secret-key">
<FormLabelAndMessage label="Stripe Secret Key" />
<Input
type="password"
value={stripeSecretKey}
onChange={(e) => setStripeSecretKey(e.target.value)}
placeholder="sk_test_..."
/>
</FormField>
<FormField name="stripe-webhook-secret">
<FormLabelAndMessage label="Stripe Webhook Secret" />
<Input
type="password"
value={stripeWebhookSecret}
onChange={(e) => setStripeWebhookSecret(e.target.value)}
placeholder="whsec_..."
/> />
</FormField> </FormField>
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}> <Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>

View file

@ -16,6 +16,11 @@ import PageLoading from '@components/Objects/Loaders/PageLoading'
import { RefreshCcw, SquareCheck } from 'lucide-react' import { RefreshCcw, SquareCheck } from 'lucide-react'
import { getUserAvatarMediaDirectory } from '@services/media/media' import { getUserAvatarMediaDirectory } from '@services/media/media'
import UserAvatar from '@components/Objects/UserAvatar' import UserAvatar from '@components/Objects/UserAvatar'
import { usePaymentsEnabled } from '@hooks/usePaymentsEnabled'
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { AlertTriangle, Settings, CreditCard, ShoppingCart, Users, ChevronRight } from 'lucide-react'
import Link from 'next/link'
import UnconfiguredPaymentsDisclaimer from '../../Pages/Payments/UnconfiguredPaymentsDisclaimer'
interface PaymentUserData { interface PaymentUserData {
payment_user_id: number; payment_user_id: number;
@ -113,12 +118,19 @@ function PaymentsCustomersPage() {
const org = useOrg() as any const org = useOrg() as any
const session = useLHSession() as any const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token const access_token = session?.data?.tokens?.access_token
const { isEnabled, isLoading } = usePaymentsEnabled()
const { data: customers, error, isLoading } = useSWR( const { data: customers, error, isLoading: customersLoading } = useSWR(
org ? [`/payments/${org.id}/customers`, access_token] : null, org ? [`/payments/${org.id}/customers`, access_token] : null,
([url, token]) => getOrgCustomers(org.id, token) ([url, token]) => getOrgCustomers(org.id, token)
) )
if (!isEnabled && !isLoading) {
return (
<UnconfiguredPaymentsDisclaimer />
)
}
if (isLoading) return <PageLoading /> if (isLoading) return <PageLoading />
if (error) return <div>Error loading customers</div> if (error) return <div>Error loading customers</div>

View file

@ -20,6 +20,11 @@ import { Label } from '@components/ui/label';
import { Badge } from '@components/ui/badge'; import { Badge } from '@components/ui/badge';
import { getPaymentConfigs } from '@services/payments/payments'; import { getPaymentConfigs } from '@services/payments/payments';
import ProductLinkedCourses from './SubComponents/ProductLinkedCourses'; import ProductLinkedCourses from './SubComponents/ProductLinkedCourses';
import { AlertTriangle, Settings, CreditCard, ShoppingCart, Users, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { usePaymentsEnabled } from '@hooks/usePaymentsEnabled';
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import UnconfiguredPaymentsDisclaimer from '../../Pages/Payments/UnconfiguredPaymentsDisclaimer';
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
name: Yup.string().required('Name is required'), name: Yup.string().required('Name is required'),
@ -36,6 +41,7 @@ function PaymentsProductPage() {
const [editingProductId, setEditingProductId] = useState<string | null>(null); const [editingProductId, setEditingProductId] = useState<string | null>(null);
const [expandedProducts, setExpandedProducts] = useState<{ [key: string]: boolean }>({}); const [expandedProducts, setExpandedProducts] = useState<{ [key: string]: boolean }>({});
const [isStripeEnabled, setIsStripeEnabled] = useState(false); const [isStripeEnabled, setIsStripeEnabled] = useState(false);
const { isEnabled, isLoading } = usePaymentsEnabled();
const { data: products, error } = useSWR( const { data: products, error } = useSWR(
() => org && session ? [`/payments/${org.id}/products`, session.data?.tokens?.access_token] : null, () => org && session ? [`/payments/${org.id}/products`, session.data?.tokens?.access_token] : null,
@ -71,6 +77,12 @@ function PaymentsProductPage() {
})); }));
}; };
if (!isEnabled && !isLoading) {
return (
<UnconfiguredPaymentsDisclaimer />
);
}
if (error) return <div>Failed to load products</div>; if (error) return <div>Failed to load products</div>;
if (!products) return <div>Loading...</div>; if (!products) return <div>Loading...</div>;

View file

@ -0,0 +1,26 @@
// hooks/usePaymentsEnabled.ts
import { useOrg } from '@components/Contexts/OrgContext';
import { useLHSession } from '@components/Contexts/LHSessionContext';
import useSWR from 'swr';
import { getPaymentConfigs } from '@services/payments/payments';
export function usePaymentsEnabled() {
const org = useOrg() as any;
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
const { data: paymentConfigs, error, isLoading } = useSWR(
org && access_token ? [`/payments/${org.id}/config`, access_token] : null,
([url, token]) => getPaymentConfigs(org.id, token)
);
const isStripeEnabled = paymentConfigs?.some(
(config: any) => config.provider === 'stripe' && config.active
);
return {
isEnabled: !!isStripeEnabled,
isLoading,
error
};
}

View file

@ -0,0 +1,48 @@
import { Settings, ChevronRight, CreditCard } from 'lucide-react'
import { Alert, AlertTitle, AlertDescription } from '@components/ui/alert'
import { AlertTriangle, ShoppingCart, Users } from 'lucide-react'
import React from 'react'
import Link from 'next/link'
function UnconfiguredPaymentsDisclaimer() {
return (
<div className="h-full w-full bg-[#f8f8f8]">
<div className="ml-10 mr-10 mx-auto">
<Alert className="mb-3 p-6 border-2 border-yellow-200 bg-yellow-100/50 light-shadow">
<AlertTitle className="text-lg font-semibold mb-2 flex items-center space-x-2">
<AlertTriangle className="h-5 w-5" />
<span>Payments not yet properly configured</span>
</AlertTitle>
<AlertDescription className="space-y-5">
<div className="pl-2">
<ul className="list-disc list-inside space-y-1 text-gray-600 pl-2">
<li className="flex items-center space-x-2">
<CreditCard className="h-4 w-4" />
<span>Configure Stripe to start accepting payments</span>
</li>
<li className="flex items-center space-x-2">
<ShoppingCart className="h-4 w-4" />
<span>Create and manage products</span>
</li>
<li className="flex items-center space-x-2">
<Users className="h-4 w-4" />
<span>Start selling to your customers</span>
</li>
</ul>
</div>
<Link
href="./configuration"
className="text-yellow-900 hover:text-yellow-700 inline-flex items-center font-medium transition-colors duration-200 pl-2"
>
<Settings className="h-4 w-4 mr-1.5" />
Go to Payment Configuration
<ChevronRight className="ml-1.5 h-4 w-4" />
</Link>
</AlertDescription>
</Alert>
</div>
</div>
)
}
export default UnconfiguredPaymentsDisclaimer

View file

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View file

@ -20,9 +20,9 @@ export async function checkPaidAccess(courseId: number, orgId: number, access_to
return res; return res;
} }
export async function createPaymentConfig(orgId: number, data: any, access_token: string) { export async function initializePaymentConfig(orgId: number, data: any, provider: string, access_token: string) {
const result = await fetch( const result = await fetch(
`${getAPIUrl()}payments/${orgId}/config`, `${getAPIUrl()}payments/${orgId}/config?provider=${provider}`,
RequestBodyWithAuthHeader('POST', data, null, access_token) RequestBodyWithAuthHeader('POST', data, null, access_token)
); );
const res = await errorHandling(result); const res = await errorHandling(result);
@ -38,6 +38,24 @@ export async function updatePaymentConfig(orgId: number, id: string, data: any,
return res; return res;
} }
export async function updateStripeAccountID(orgId: number, data: any, access_token: string) {
const result = await fetch(
`${getAPIUrl()}payments/${orgId}/stripe/account?stripe_account_id=${data.stripe_account_id}`,
RequestBodyWithAuthHeader('PUT', data, null, access_token)
);
const res = await errorHandling(result);
return res;
}
export async function getStripeOnboardingLink(orgId: number, access_token: string, redirect_uri: string) {
const result = await fetch(
`${getAPIUrl()}payments/${orgId}/stripe/connect/link?redirect_uri=${redirect_uri}`,
RequestBodyWithAuthHeader('POST', null, null, access_token)
);
const res = await errorHandling(result);
return res;
}
export async function deletePaymentConfig(orgId: number, id: string, access_token: string) { export async function deletePaymentConfig(orgId: number, id: string, access_token: string) {
const result = await fetch( const result = await fetch(
`${getAPIUrl()}payments/${orgId}/config?id=${id}`, `${getAPIUrl()}payments/${orgId}/config?id=${id}`,

View file

@ -26,6 +26,7 @@
"@styles/*": ["styles/*"], "@styles/*": ["styles/*"],
"@services/*": ["services/*"], "@services/*": ["services/*"],
"@editor/*": ["components/Objects/Editor/*"], "@editor/*": ["components/Objects/Editor/*"],
"@hooks/*": ["components/Hooks/*"],
"@/*": ["./*"] "@/*": ["./*"]
} }
}, },