From 416c3a4afc38e0dd4106220f948294e110b6ea00 Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 19 Oct 2024 01:10:26 +0200 Subject: [PATCH] feat: init stripe utils --- apps/api/poetry.lock | 19 +++ apps/api/pyproject.toml | 1 + .../services/payments/payments_products.py | 17 +- apps/api/src/services/payments/stripe.py | 150 ++++++++++++++++++ .../Payments/PaymentsConfigurationPage.tsx | 114 ++++++++++--- .../Payments/PaymentsProductPage.tsx | 49 ++++-- apps/web/services/payments/payments.ts | 4 +- apps/web/services/payments/products.ts | 2 +- 8 files changed, 315 insertions(+), 41 deletions(-) create mode 100644 apps/api/src/services/payments/stripe.py diff --git a/apps/api/poetry.lock b/apps/api/poetry.lock index 66b11e5a..627c91b9 100644 --- a/apps/api/poetry.lock +++ b/apps/api/poetry.lock @@ -3513,6 +3513,21 @@ anyio = ">=3.4.0,<5" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +[[package]] +name = "stripe" +version = "11.1.1" +description = "Python bindings for the Stripe API" +optional = false +python-versions = ">=3.6" +files = [ + {file = "stripe-11.1.1-py2.py3-none-any.whl", hash = "sha256:e79e02238d0ec7c89a64986af941dcae41e4857489b7cc83497acce9def356e5"}, + {file = "stripe-11.1.1.tar.gz", hash = "sha256:0bbdfe54a09728fc54db6bb099b2f440ffc111d07d9674b0f04bfd0d3c1cbdcf"}, +] + +[package.dependencies] +requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} + [[package]] name = "sympy" version = "1.13.3" @@ -4281,4 +4296,8 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.12" +<<<<<<< HEAD content-hash = "f833ec3787697499d05e2aafb89bcb275b0d7468a6a4a33eb20cd139a21880d8" +======= +content-hash = "5d2f7ddfb277f39999b7798b9659c5bd2c2751ad667dbcab76a9d83fd6bdfa33" +>>>>>>> 59f348e (feat: init stripe utils) diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 7cb1b5a9..a7fbb3d5 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -41,6 +41,7 @@ chromadb = "^0.5.13" alembic = "^1.13.2" alembic-postgresql-enum = "^1.2.0" sqlalchemy-utils = "^0.41.2" +stripe = "^11.1.1" [build-system] build-backend = "poetry.core.masonry.api" diff --git a/apps/api/src/services/payments/payments_products.py b/apps/api/src/services/payments/payments_products.py index f26e2c01..1daab629 100644 --- a/apps/api/src/services/payments/payments_products.py +++ b/apps/api/src/services/payments/payments_products.py @@ -13,6 +13,8 @@ from src.services.orgs.orgs import rbac_check from datetime import datetime from uuid import uuid4 +from src.services.payments.stripe import archive_stripe_product, create_stripe_product, update_stripe_product + async def create_payments_product( request: Request, org_id: int, @@ -40,9 +42,14 @@ async def create_payments_product( new_product.creation_date = datetime.now() new_product.update_date = datetime.now() + # Create product in Stripe + stripe_product = await create_stripe_product(request, org_id, new_product, current_user, db_session) + new_product.provider_product_id = stripe_product.id + + # Save to DB db_session.add(new_product) db_session.commit() - db_session.refresh(new_product) + db_session.refresh(new_product) return PaymentsProductRead.model_validate(new_product) @@ -103,6 +110,9 @@ async def update_payments_product( db_session.commit() db_session.refresh(product) + # Update product in Stripe + await update_stripe_product(request, org_id, product.provider_product_id, product, current_user, db_session) + return PaymentsProductRead.model_validate(product) async def delete_payments_product( @@ -126,6 +136,9 @@ async def delete_payments_product( product = db_session.exec(statement).first() if not product: raise HTTPException(status_code=404, detail="Payments product not found") + + # Archive product in Stripe + await archive_stripe_product(request, org_id, product.provider_product_id, current_user, db_session) # Delete product db_session.delete(product) @@ -147,7 +160,7 @@ async def list_payments_products( 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()) + statement = select(PaymentsProduct).where(PaymentsProduct.org_id == org_id).order_by(PaymentsProduct.id.desc()) # type: ignore products = db_session.exec(statement).all() return [PaymentsProductRead.model_validate(product) for product in products] diff --git a/apps/api/src/services/payments/stripe.py b/apps/api/src/services/payments/stripe.py new file mode 100644 index 00000000..bf17db1c --- /dev/null +++ b/apps/api/src/services/payments/stripe.py @@ -0,0 +1,150 @@ +from email.policy import default +from fastapi import HTTPException, Request +from sqlmodel import Session +import stripe +from src.db.payments.payments_products import PaymentProductTypeEnum, PaymentsProduct, PaymentsProductCreate +from src.db.users import AnonymousUser, PublicUser +from src.services.payments.payments import get_payments_config + + +async def get_stripe_credentials( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser, + 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') + + ## Create product + + # Interval or one time + if product_data.product_type == PaymentProductTypeEnum.SUBSCRIPTION: + interval = "month" + else: + interval = None + + # Prepare default_price_data + default_price_data = { + "currency": product_data.currency, + "unit_amount": int(product_data.amount * 100) # Convert to cents + } + + if interval: + default_price_data["recurring"] = {"interval": interval} + + 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: + + # Always create a new price + 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) + + # Set the new price as the default price for the product + updated_product = stripe.Product.modify(product_id, default_price=new_price.id) + + return updated_product + except stripe.StripeError as e: + raise HTTPException(status_code=400, detail=f"Error updating Stripe product: {str(e)}") + + + + + diff --git a/apps/web/components/Dashboard/Payments/PaymentsConfigurationPage.tsx b/apps/web/components/Dashboard/Payments/PaymentsConfigurationPage.tsx index 82629669..25561ef2 100644 --- a/apps/web/components/Dashboard/Payments/PaymentsConfigurationPage.tsx +++ b/apps/web/components/Dashboard/Payments/PaymentsConfigurationPage.tsx @@ -1,13 +1,16 @@ -import React, { useState } from 'react'; +'use client'; +import React, { useState, useEffect } from 'react'; import { useOrg } from '@components/Contexts/OrgContext'; import { SiStripe } from '@icons-pack/react-simple-icons' import { useLHSession } from '@components/Contexts/LHSessionContext'; -import { getPaymentConfigs, createPaymentConfig, updatePaymentConfig } from '@services/payments/payments'; +import { getPaymentConfigs, createPaymentConfig, updatePaymentConfig, deletePaymentConfig } from '@services/payments/payments'; import FormLayout, { ButtonBlack, Input, Textarea, FormField, FormLabelAndMessage, Flex } from '@components/StyledElements/Form/Form'; -import { Check, Edit } from 'lucide-react'; +import { Check, Edit, Trash2 } from 'lucide-react'; import toast from 'react-hot-toast'; import useSWR, { mutate } from 'swr'; import Modal from '@components/StyledElements/Modal/Modal'; +import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'; +import { Button } from '@components/ui/button'; const PaymentsConfigurationPage: React.FC = () => { const org = useOrg() as any; @@ -37,6 +40,17 @@ const PaymentsConfigurationPage: React.FC = () => { setIsModalOpen(true); }; + const deleteConfig = async () => { + try { + await deletePaymentConfig(org.id, stripeConfig.id, access_token); + toast.success('Stripe configuration deleted successfully'); + mutate([`/payments/${org.id}/config`, access_token]); + } catch (error) { + console.error('Error deleting Stripe configuration:', error); + toast.error('Failed to delete Stripe configuration'); + } + }; + if (isLoading) { return
Loading...
; } @@ -52,23 +66,44 @@ const PaymentsConfigurationPage: React.FC = () => {

Payments Configuration

Manage your organization payments configuration

-
+
{stripeConfig ? ( -
+
- - Stripe is enabled + + Stripe is enabled +
+
+ + + + Delete Configuration + + } + functionToExecute={deleteConfig} + status="warning" + />
- - - Edit Configuration -
) : ( - - - Enable Stripe - + )}
@@ -94,14 +129,36 @@ interface EditStripeConfigModalProps { } const EditStripeConfigModal: React.FC = ({ orgId, configId, accessToken, isOpen, onClose }) => { - const [stripeKey, setStripeKey] = useState(''); + const [stripePublishableKey, setStripePublishableKey] = useState(''); const [stripeSecretKey, setStripeSecretKey] = useState(''); const [stripeWebhookSecret, setStripeWebhookSecret] = useState(''); + // Add this useEffect hook to fetch and set the existing configuration + useEffect(() => { + const fetchConfig = async () => { + try { + const config = await getPaymentConfigs(orgId, accessToken); + const stripeConfig = config.find((c: any) => c.id === configId); + if (stripeConfig && stripeConfig.provider_config) { + setStripePublishableKey(stripeConfig.provider_config.stripe_publishable_key || ''); + setStripeSecretKey(stripeConfig.provider_config.stripe_secret_key || ''); + setStripeWebhookSecret(stripeConfig.provider_config.stripe_webhook_secret || ''); + } + } catch (error) { + console.error('Error fetching Stripe configuration:', error); + toast.error('Failed to load existing configuration'); + } + }; + + if (isOpen) { + fetchConfig(); + } + }, [isOpen, orgId, configId, accessToken]); + const handleSubmit = async () => { try { const stripe_config = { - stripe_key: stripeKey, + stripe_publishable_key: stripePublishableKey, stripe_secret_key: stripeSecretKey, stripe_webhook_secret: stripeWebhookSecret, }; @@ -123,16 +180,31 @@ const EditStripeConfigModal: React.FC = ({ orgId, co dialogContent={ - - setStripeKey(e.target.value)} /> + + setStripePublishableKey(e.target.value)} + placeholder="pk_test_..." + /> - setStripeSecretKey(e.target.value)} /> + setStripeSecretKey(e.target.value)} + placeholder="sk_test_..." + /> - setStripeWebhookSecret(e.target.value)} /> + setStripeWebhookSecret(e.target.value)} + placeholder="whsec_..." + /> diff --git a/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx b/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx index 31c18733..667334d5 100644 --- a/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx +++ b/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx @@ -4,9 +4,9 @@ import currencyCodes from 'currency-codes'; 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 { getProducts, updateProduct, archiveProduct } from '@services/payments/products'; import CreateProductForm from '@components/Dashboard/Payments/SubComponents/CreateProductForm'; -import { Plus, Trash2, Pencil, Info, RefreshCcw, SquareCheck, ChevronDown, ChevronUp } from 'lucide-react'; +import { Plus, Trash2, Pencil, Info, RefreshCcw, SquareCheck, ChevronDown, ChevronUp, Archive } from 'lucide-react'; import Modal from '@components/StyledElements/Modal/Modal'; import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'; import toast from 'react-hot-toast'; @@ -18,6 +18,7 @@ import { Formik, Form, Field, ErrorMessage } from 'formik'; import * as Yup from 'yup'; import { Label } from '@components/ui/label'; import { Badge } from '@components/ui/badge'; +import { getPaymentConfigs } from '@services/payments/payments'; const validationSchema = Yup.object().shape({ name: Yup.string().required('Name is required'), @@ -33,19 +34,32 @@ function PaymentsProductPage() { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [editingProductId, setEditingProductId] = useState(null); const [expandedProducts, setExpandedProducts] = useState<{ [key: string]: boolean }>({}); + const [isStripeEnabled, setIsStripeEnabled] = useState(false); 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) => { + const { data: paymentConfigs, error: paymentConfigError } = useSWR( + () => org && session ? [`/payments/${org.id}/config`, session.data?.tokens?.access_token] : null, + ([url, token]) => getPaymentConfigs(org.id, token) + ); + + useEffect(() => { + if (paymentConfigs) { + const stripeConfig = paymentConfigs.find((config: any) => config.provider === 'stripe'); + setIsStripeEnabled(!!stripeConfig); + } + }, [paymentConfigs]); + + const handleArchiveProduct = async (productId: string) => { try { - await deleteProduct(org.id, productId, session.data?.tokens?.access_token); + await archiveProduct(org.id, productId, session.data?.tokens?.access_token); mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]); - toast.success('Product deleted successfully'); + toast.success('Product archived successfully'); } catch (error) { - toast.error('Failed to delete product'); + toast.error('Failed to archive product'); } } @@ -86,30 +100,31 @@ function PaymentsProductPage() { ) : (
-
+
{product.product_type === 'subscription' ? : } - {product.product_type} + {product.product_type === 'subscription' ? 'Subscription' : 'One-time payment'}

{product.name}

- + } - functionToExecute={() => handleDeleteProduct(product.id)} + functionToExecute={() => handleArchiveProduct(product.id)} status="warning" />
@@ -164,10 +179,14 @@ function PaymentsProductPage() {

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

)} +