From 23757fd50b1263076474e4b7eb1799db2912f67f Mon Sep 17 00:00:00 2001 From: level09 Date: Mon, 29 Dec 2025 18:22:12 +0100 Subject: [PATCH 1/9] Add BILLING_PROVIDER config with Chargebee settings - Add BILLING_PROVIDER env var (defaults to 'stripe') - Add Chargebee config: SITE, API_KEY, PRO_ITEM_PRICE_ID - Add Chargebee webhook Basic Auth: USERNAME, PASSWORD --- enferno/settings.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/enferno/settings.py b/enferno/settings.py index b45d1d2..4465aa9 100644 --- a/enferno/settings.py +++ b/enferno/settings.py @@ -146,12 +146,22 @@ class Config: GITHUB_OAUTH_CLIENT_ID = os.environ.get("GITHUB_OAUTH_CLIENT_ID") GITHUB_OAUTH_CLIENT_SECRET = os.environ.get("GITHUB_OAUTH_CLIENT_SECRET") - # Stripe Settings + # Billing Provider: 'stripe' or 'chargebee' + BILLING_PROVIDER = os.environ.get("BILLING_PROVIDER", "stripe") + + # Stripe Settings (if BILLING_PROVIDER=stripe) STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY") STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY") STRIPE_PRO_PRICE_ID = os.environ.get("STRIPE_PRO_PRICE_ID") STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET") - # Pricing Display (update when Stripe price changes) + # Chargebee Settings (if BILLING_PROVIDER=chargebee) + CHARGEBEE_SITE = os.environ.get("CHARGEBEE_SITE") + CHARGEBEE_API_KEY = os.environ.get("CHARGEBEE_API_KEY") + CHARGEBEE_PRO_ITEM_PRICE_ID = os.environ.get("CHARGEBEE_PRO_ITEM_PRICE_ID") + CHARGEBEE_WEBHOOK_USERNAME = os.environ.get("CHARGEBEE_WEBHOOK_USERNAME") + CHARGEBEE_WEBHOOK_PASSWORD = os.environ.get("CHARGEBEE_WEBHOOK_PASSWORD") + + # Pricing Display (update when price changes) PRO_PRICE_DISPLAY = os.environ.get("PRO_PRICE_DISPLAY", "$29") PRO_PRICE_INTERVAL = os.environ.get("PRO_PRICE_INTERVAL", "month") From 4a1dbfa1baeb3873512c1a83bce72e61933fc91a Mon Sep 17 00:00:00 2001 From: level09 Date: Mon, 29 Dec 2025 18:58:32 +0100 Subject: [PATCH 2/9] Rename StripeEvent to BillingEvent with provider field - Rename class for provider-agnostic billing - Add provider column to track event source - Update webhooks.py import and usage --- enferno/api/webhooks.py | 4 ++-- enferno/user/models.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/enferno/api/webhooks.py b/enferno/api/webhooks.py index 1a074a9..41b685c 100644 --- a/enferno/api/webhooks.py +++ b/enferno/api/webhooks.py @@ -3,7 +3,7 @@ from sqlalchemy.exc import IntegrityError from enferno.extensions import db -from enferno.user.models import StripeEvent +from enferno.user.models import BillingEvent webhooks_bp = Blueprint("webhooks", __name__) @@ -27,7 +27,7 @@ def stripe_webhook(): # Skip duplicate events event_id = event.get("id") try: - db.session.add(StripeEvent(event_id=event_id, event_type=event.get("type"))) + db.session.add(BillingEvent(event_id=event_id, event_type=event.get("type"), provider="stripe")) db.session.commit() except IntegrityError: db.session.rollback() diff --git a/enferno/user/models.py b/enferno/user/models.py index ee14c2a..3cc31a4 100644 --- a/enferno/user/models.py +++ b/enferno/user/models.py @@ -235,12 +235,13 @@ def register(cls, user_id, action, data=None, workspace_id=None): return activity -class StripeEvent(db.Model, BaseMixin): - """Durable store for processed Stripe webhook events (idempotency).""" +class BillingEvent(db.Model, BaseMixin): + """Durable store for processed billing webhook events (idempotency).""" id = db.Column(db.Integer, primary_key=True) event_id = db.Column(db.String(255), unique=True, nullable=False) event_type = db.Column(db.String(128), nullable=True) + provider = db.Column(db.String(20), nullable=True) # 'stripe' or 'chargebee' class Workspace(db.Model, BaseMixin): From 8702061ad233cfcd0909ba80e4d2018ea92b4f63 Mon Sep 17 00:00:00 2001 From: level09 Date: Mon, 29 Dec 2025 19:02:06 +0100 Subject: [PATCH 3/9] Rename stripe_customer_id to billing_customer_id - Provider-agnostic field name for multi-provider support - Updated all references in models, billing, portal, webhooks --- enferno/api/webhooks.py | 4 ++-- enferno/portal/views.py | 8 ++++---- enferno/services/billing.py | 2 +- enferno/user/models.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/enferno/api/webhooks.py b/enferno/api/webhooks.py index 41b685c..ff9b63c 100644 --- a/enferno/api/webhooks.py +++ b/enferno/api/webhooks.py @@ -51,7 +51,7 @@ def stripe_webhook(): from enferno.user.models import Workspace workspace = db.session.execute( - db.select(Workspace).where(Workspace.stripe_customer_id == customer_id) + db.select(Workspace).where(Workspace.billing_customer_id == customer_id) ).scalar_one_or_none() if workspace: @@ -69,7 +69,7 @@ def stripe_webhook(): from enferno.user.models import Workspace workspace = db.session.execute( - db.select(Workspace).where(Workspace.stripe_customer_id == customer_id) + db.select(Workspace).where(Workspace.billing_customer_id == customer_id) ).scalar_one_or_none() if workspace and workspace.plan == "pro": diff --git a/enferno/portal/views.py b/enferno/portal/views.py index 99860d6..d2f1da6 100644 --- a/enferno/portal/views.py +++ b/enferno/portal/views.py @@ -433,7 +433,7 @@ def upgrade_workspace(workspace_id): workspace = g.current_workspace current_app.logger.debug( - f"Current workspace plan: {workspace.plan}, stripe_customer_id: {workspace.stripe_customer_id}" + f"Current workspace plan: {workspace.plan}, billing_customer_id: {workspace.billing_customer_id}" ) # Prevent duplicate subscriptions - redirect to billing portal if already Pro @@ -442,7 +442,7 @@ def upgrade_workspace(workspace_id): f"Workspace {workspace_id} already on Pro plan, redirecting to billing portal" ) # Only redirect if we have a customer ID, otherwise show settings - if workspace.stripe_customer_id: + if workspace.billing_customer_id: return redirect(url_for("portal.billing_portal", workspace_id=workspace_id)) else: # Pro workspace without Stripe customer (manual upgrade, legacy) @@ -472,10 +472,10 @@ def upgrade_workspace(workspace_id): def billing_portal(workspace_id): """Redirect to Stripe Customer Portal - fully hosted""" workspace = g.current_workspace - if workspace.stripe_customer_id: + if workspace.billing_customer_id: try: session = HostedBilling.create_portal_session( - workspace.stripe_customer_id, workspace_id, request.url_root + workspace.billing_customer_id, workspace_id, request.url_root ) return redirect(session.url) except Exception as e: diff --git a/enferno/services/billing.py b/enferno/services/billing.py index 32cb480..e13b3e3 100644 --- a/enferno/services/billing.py +++ b/enferno/services/billing.py @@ -102,7 +102,7 @@ def handle_successful_payment(session_id: str) -> int: # Upgrade workspace try: workspace.plan = "pro" - workspace.stripe_customer_id = session.customer + workspace.billing_customer_id = session.customer workspace.upgraded_at = datetime.utcnow() db.session.commit() return workspace.id diff --git a/enferno/user/models.py b/enferno/user/models.py index 3cc31a4..0912f59 100644 --- a/enferno/user/models.py +++ b/enferno/user/models.py @@ -254,7 +254,7 @@ class Workspace(db.Model, BaseMixin): # Billing fields plan = db.Column(db.String(10), default="free") # 'free' or 'pro' - stripe_customer_id = db.Column(db.String(100), nullable=True) + billing_customer_id = db.Column(db.String(100), nullable=True) upgraded_at = db.Column(db.DateTime, nullable=True) owner = relationship( From aa939a458004fc6d14b1a28ce170c4ecfc507c6c Mon Sep 17 00:00:00 2001 From: level09 Date: Mon, 29 Dec 2025 19:36:13 +0100 Subject: [PATCH 4/9] Add Chargebee billing provider support - Provider conditional in billing.py (Stripe or Chargebee) - Chargebee: HostedPage checkout, PortalSession, webhook handling - Success handler accepts both session_id (Stripe) and id (Chargebee) - Add chargebee>=3.0.0 dependency --- enferno/portal/views.py | 5 +- enferno/services/billing.py | 283 ++++++++++++++++++++++++------------ pyproject.toml | 1 + 3 files changed, 192 insertions(+), 97 deletions(-) diff --git a/enferno/portal/views.py b/enferno/portal/views.py index d2f1da6..c317f2d 100644 --- a/enferno/portal/views.py +++ b/enferno/portal/views.py @@ -510,8 +510,9 @@ def billing_portal(workspace_id): @portal.get("/billing/success") @auth_required("session") def billing_success(): - """Handle successful Stripe checkout - validate and upgrade workspace""" - session_id = request.args.get("session_id") + """Handle successful checkout - validate and upgrade workspace""" + # Stripe uses ?session_id=, Chargebee uses ?id= + session_id = request.args.get("session_id") or request.args.get("id") if not session_id: return redirect("/dashboard") diff --git a/enferno/services/billing.py b/enferno/services/billing.py index e13b3e3..9e8cc37 100644 --- a/enferno/services/billing.py +++ b/enferno/services/billing.py @@ -1,115 +1,208 @@ """ -Ultra-minimal Stripe billing service using hosted pages. +Billing service with provider support (Stripe or Chargebee). +Uses hosted pages - no custom checkout UI. """ +import json +import os from datetime import datetime from functools import wraps from typing import Any -import stripe from flask import current_app, jsonify, redirect, request, url_for from enferno.extensions import db from enferno.services.workspace import get_current_workspace from enferno.user.models import Workspace - -def _init_stripe(): - """Initialize Stripe API key from config""" - secret = current_app.config.get("STRIPE_SECRET_KEY") - if not secret: - raise RuntimeError("Stripe is not configured") - stripe.api_key = secret - - -class HostedBilling: - """Minimal billing service using Stripe's hosted pages.""" - - @staticmethod - def create_upgrade_session( - workspace_id: int, user_email: str, base_url: str - ) -> Any: - """Create Stripe Checkout session for workspace upgrade.""" - _init_stripe() - price_id = current_app.config.get("STRIPE_PRO_PRICE_ID") - if not price_id: - raise RuntimeError("Stripe price not configured") - - session = stripe.checkout.Session.create( - customer_email=user_email, - line_items=[{"price": price_id, "quantity": 1}], - mode="subscription", - success_url=f"{base_url}billing/success?session_id={{CHECKOUT_SESSION_ID}}", - cancel_url=f"{base_url}dashboard", - metadata={"workspace_id": str(workspace_id)}, - ) - current_app.logger.info(f"Created Stripe Checkout session: {session.id}") - return session - - @staticmethod - def create_portal_session( - customer_id: str, workspace_id: int, base_url: str - ) -> Any: - """Create Stripe Customer Portal session for billing management.""" - _init_stripe() - session = stripe.billing_portal.Session.create( - customer=customer_id, - return_url=f"{base_url}workspace/{workspace_id}/settings", - ) - current_app.logger.info(f"Created Stripe Portal session: {session.id}") - return session - - @staticmethod - def handle_successful_payment(session_id: str) -> int: - """Handle successful Stripe payment by upgrading the workspace. - - Security: session_id is the security token - validated via Stripe API. - - Returns: - Workspace ID if successful, None otherwise - """ - _init_stripe() - session = stripe.checkout.Session.retrieve(session_id) - current_app.logger.info( - f"Processing checkout: {session.id} status={session.status} payment={session.payment_status}" - ) - - # Verify payment actually completed - if session.status != "complete": - current_app.logger.warning( - f"Checkout session not complete: {session.id} status={session.status}" +PROVIDER = os.environ.get("BILLING_PROVIDER", "stripe") + +if PROVIDER == "stripe": + import stripe + + def _init_stripe(): + """Initialize Stripe API key from config""" + secret = current_app.config.get("STRIPE_SECRET_KEY") + if not secret: + raise RuntimeError("Stripe is not configured") + stripe.api_key = secret + + class HostedBilling: + """Billing service using Stripe's hosted pages.""" + + @staticmethod + def create_upgrade_session( + workspace_id: int, user_email: str, base_url: str + ) -> Any: + """Create Stripe Checkout session for workspace upgrade.""" + _init_stripe() + price_id = current_app.config.get("STRIPE_PRO_PRICE_ID") + if not price_id: + raise RuntimeError("Stripe price not configured") + + session = stripe.checkout.Session.create( + customer_email=user_email, + line_items=[{"price": price_id, "quantity": 1}], + mode="subscription", + success_url=f"{base_url}billing/success?session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=f"{base_url}dashboard", + metadata={"workspace_id": str(workspace_id)}, ) - return None - - if session.payment_status not in {"paid", "no_payment_required"}: - current_app.logger.warning( - f"Payment not confirmed: {session.id} payment_status={session.payment_status}" + current_app.logger.info(f"Created Stripe Checkout session: {session.id}") + return session + + @staticmethod + def create_portal_session( + customer_id: str, workspace_id: int, base_url: str + ) -> Any: + """Create Stripe Customer Portal session for billing management.""" + _init_stripe() + session = stripe.billing_portal.Session.create( + customer=customer_id, + return_url=f"{base_url}workspace/{workspace_id}/settings", + ) + current_app.logger.info(f"Created Stripe Portal session: {session.id}") + return session + + @staticmethod + def handle_successful_payment(session_id: str) -> int: + """Handle successful Stripe payment by upgrading the workspace.""" + _init_stripe() + session = stripe.checkout.Session.retrieve(session_id) + current_app.logger.info( + f"Processing checkout: {session.id} status={session.status} payment={session.payment_status}" ) - return None - workspace_id = session.metadata.get("workspace_id") - if not workspace_id: - return None + if session.status != "complete": + current_app.logger.warning( + f"Checkout session not complete: {session.id} status={session.status}" + ) + return None + + if session.payment_status not in {"paid", "no_payment_required"}: + current_app.logger.warning( + f"Payment not confirmed: {session.id} payment_status={session.payment_status}" + ) + return None + + workspace_id = session.metadata.get("workspace_id") + if not workspace_id: + return None + + workspace = db.session.get(Workspace, int(workspace_id)) + if not workspace: + return None + + if workspace.is_pro: + return workspace.id + + try: + workspace.plan = "pro" + workspace.billing_customer_id = session.customer + workspace.upgraded_at = datetime.utcnow() + db.session.commit() + return workspace.id + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Failed to upgrade workspace {workspace_id}: {e}") + return None + +elif PROVIDER == "chargebee": + from chargebee import Chargebee + + _cb_client = None + + def _init_chargebee(): + """Initialize Chargebee client from config""" + global _cb_client + if _cb_client is None: + api_key = current_app.config.get("CHARGEBEE_API_KEY") + site = current_app.config.get("CHARGEBEE_SITE") + if not api_key or not site: + raise RuntimeError("Chargebee is not configured") + _cb_client = Chargebee(api_key=api_key, site=site) + return _cb_client + + class HostedBilling: + """Billing service using Chargebee's hosted pages.""" + + @staticmethod + def create_upgrade_session( + workspace_id: int, user_email: str, base_url: str + ) -> Any: + """Create Chargebee Checkout session for workspace upgrade.""" + cb = _init_chargebee() + item_price_id = current_app.config.get("CHARGEBEE_PRO_ITEM_PRICE_ID") + if not item_price_id: + raise RuntimeError("Chargebee item price not configured") + + result = cb.HostedPage.checkout_new_for_items({ + "subscription_items": [{"item_price_id": item_price_id}], + "customer": {"email": user_email}, + "redirect_url": f"{base_url}billing/success", # Chargebee appends ?id=xxx&state=yyy + "cancel_url": f"{base_url}dashboard", + "pass_thru_content": json.dumps({"workspace_id": str(workspace_id)}), + }) + hosted_page = result.hosted_page + current_app.logger.info(f"Created Chargebee Checkout: {hosted_page.id}") + return hosted_page + + @staticmethod + def create_portal_session( + customer_id: str, workspace_id: int, base_url: str + ) -> Any: + """Create Chargebee Portal session for billing management.""" + cb = _init_chargebee() + result = cb.PortalSession.create({ + "customer": {"id": customer_id}, + "redirect_url": f"{base_url}workspace/{workspace_id}/settings", + }) + portal_session = result.portal_session + current_app.logger.info(f"Created Chargebee Portal session: {portal_session.id}") + return portal_session + + @staticmethod + def handle_successful_payment(hosted_page_id: str) -> int: + """Handle successful Chargebee payment by upgrading the workspace.""" + cb = _init_chargebee() + result = cb.HostedPage.retrieve(hosted_page_id) + hosted_page = result.hosted_page + current_app.logger.info( + f"Processing Chargebee checkout: {hosted_page.id} state={hosted_page.state}" + ) - workspace = db.session.get(Workspace, int(workspace_id)) - if not workspace: - return None - - # Idempotent: if already pro, just return success - if workspace.is_pro: - return workspace.id - - # Upgrade workspace - try: - workspace.plan = "pro" - workspace.billing_customer_id = session.customer - workspace.upgraded_at = datetime.utcnow() - db.session.commit() - return workspace.id - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Failed to upgrade workspace {workspace_id}: {e}") - return None + if hosted_page.state != "succeeded": + current_app.logger.warning( + f"Checkout not succeeded: {hosted_page.id} state={hosted_page.state}" + ) + return None + + pass_thru = json.loads(hosted_page.pass_thru_content or "{}") + workspace_id = pass_thru.get("workspace_id") + if not workspace_id: + return None + + workspace = db.session.get(Workspace, int(workspace_id)) + if not workspace: + return None + + if workspace.is_pro: + return workspace.id + + try: + workspace.plan = "pro" + # Chargebee content is dict-like: content["customer"]["id"] + workspace.billing_customer_id = hosted_page.content["customer"]["id"] + workspace.upgraded_at = datetime.utcnow() + db.session.commit() + return workspace.id + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Failed to upgrade workspace {workspace_id}: {e}") + return None + +else: + raise RuntimeError(f"Unknown BILLING_PROVIDER: {PROVIDER}") def requires_pro_plan(f): diff --git a/pyproject.toml b/pyproject.toml index fbb6207..a5efc9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ dependencies = [ "sluggi>=0.1.1", # Payments and billing "stripe>=12.0.0", + "chargebee>=3.0.0", ] [project.optional-dependencies] From 7036b0a516e0ced5ddf73bb94eed0dbb4525be75 Mon Sep 17 00:00:00 2001 From: level09 Date: Mon, 29 Dec 2025 19:41:44 +0100 Subject: [PATCH 5/9] Add Chargebee webhook handler with provider conditional - Stripe: /stripe/webhook with signature verification - Chargebee: /chargebee/webhook with Basic Auth - Events: subscription_created/cancelled, payment_failed --- enferno/api/webhooks.py | 216 +++++++++++++++++++++++++++++----------- 1 file changed, 157 insertions(+), 59 deletions(-) diff --git a/enferno/api/webhooks.py b/enferno/api/webhooks.py index ff9b63c..e3d2320 100644 --- a/enferno/api/webhooks.py +++ b/enferno/api/webhooks.py @@ -1,4 +1,5 @@ -import stripe +import os + from flask import Blueprint, current_app, request from sqlalchemy.exc import IntegrityError @@ -7,76 +8,173 @@ webhooks_bp = Blueprint("webhooks", __name__) +PROVIDER = os.environ.get("BILLING_PROVIDER", "stripe") + +if PROVIDER == "stripe": + import stripe + + @webhooks_bp.route("/stripe/webhook", methods=["POST"]) + def stripe_webhook(): + payload = request.get_data() + sig_header = request.headers.get("Stripe-Signature") + secret = current_app.config.get("STRIPE_WEBHOOK_SECRET") + + if not secret: + current_app.logger.error("Stripe webhook secret not configured") + return "Webhook secret not configured", 500 + + try: + event = stripe.Webhook.construct_event(payload, sig_header, secret) + except (ValueError, stripe.error.SignatureVerificationError) as e: + current_app.logger.error(f"Webhook error: {e}") + return "Invalid request", 400 + + # Skip duplicate events + event_id = event.get("id") + try: + db.session.add(BillingEvent(event_id=event_id, event_type=event.get("type"), provider="stripe")) + db.session.commit() + except IntegrityError: + db.session.rollback() + return "OK", 200 + + # Handle checkout completion + if event["type"] == "checkout.session.completed": + session = event["data"]["object"] + session_id = session.get("id") + + from enferno.services.billing import HostedBilling + + HostedBilling.handle_successful_payment(session_id) + current_app.logger.info(f"Processed checkout: {session_id}") + + # Handle subscription cancellation (downgrade to free) + elif event["type"] == "customer.subscription.deleted": + subscription = event["data"]["object"] + customer_id = subscription.get("customer") + + from enferno.user.models import Workspace + + workspace = db.session.execute( + db.select(Workspace).where(Workspace.billing_customer_id == customer_id) + ).scalar_one_or_none() + + if workspace: + workspace.plan = "free" + db.session.commit() + current_app.logger.info( + f"Downgraded workspace {workspace.id} to free (subscription cancelled)" + ) + + # Handle payment failure (downgrade to free) + elif event["type"] == "invoice.payment_failed": + invoice = event["data"]["object"] + customer_id = invoice.get("customer") + + from enferno.user.models import Workspace + + workspace = db.session.execute( + db.select(Workspace).where(Workspace.billing_customer_id == customer_id) + ).scalar_one_or_none() + + if workspace and workspace.plan == "pro": + workspace.plan = "free" + db.session.commit() + current_app.logger.warning( + f"Downgraded workspace {workspace.id} to free (payment failed)" + ) -@webhooks_bp.route("/stripe/webhook", methods=["POST"]) -def stripe_webhook(): - payload = request.get_data() - sig_header = request.headers.get("Stripe-Signature") - secret = current_app.config.get("STRIPE_WEBHOOK_SECRET") - - if not secret: - current_app.logger.error("Stripe webhook secret not configured") - return "Webhook secret not configured", 500 - - try: - event = stripe.Webhook.construct_event(payload, sig_header, secret) - except (ValueError, stripe.error.SignatureVerificationError) as e: - current_app.logger.error(f"Webhook error: {e}") - return "Invalid request", 400 - - # Skip duplicate events - event_id = event.get("id") - try: - db.session.add(BillingEvent(event_id=event_id, event_type=event.get("type"), provider="stripe")) - db.session.commit() - except IntegrityError: - db.session.rollback() return "OK", 200 - # Handle checkout completion - if event["type"] == "checkout.session.completed": - session = event["data"]["object"] - session_id = session.get("id") +elif PROVIDER == "chargebee": + + def _verify_chargebee_auth(): + """Verify Chargebee webhook using Basic Auth.""" + username = current_app.config.get("CHARGEBEE_WEBHOOK_USERNAME") + password = current_app.config.get("CHARGEBEE_WEBHOOK_PASSWORD") - from enferno.services.billing import HostedBilling + if not username or not password: + return True # No auth configured, allow (dev mode) - HostedBilling.handle_successful_payment(session_id) - current_app.logger.info(f"Processed checkout: {session_id}") + auth = request.authorization + if not auth or auth.username != username or auth.password != password: + return False + return True - # Handle subscription cancellation (downgrade to free) - elif event["type"] == "customer.subscription.deleted": - subscription = event["data"]["object"] - customer_id = subscription.get("customer") + @webhooks_bp.route("/chargebee/webhook", methods=["POST"]) + def chargebee_webhook(): + if not _verify_chargebee_auth(): + current_app.logger.error("Chargebee webhook auth failed") + return "Unauthorized", 401 - from enferno.user.models import Workspace + event = request.get_json() + if not event: + return "Invalid request", 400 - workspace = db.session.execute( - db.select(Workspace).where(Workspace.billing_customer_id == customer_id) - ).scalar_one_or_none() + event_type = event.get("event_type") + event_id = event.get("id") - if workspace: - workspace.plan = "free" + # Skip duplicate events + try: + db.session.add(BillingEvent(event_id=event_id, event_type=event_type, provider="chargebee")) db.session.commit() - current_app.logger.info( - f"Downgraded workspace {workspace.id} to free (subscription cancelled)" - ) + except IntegrityError: + db.session.rollback() + return "OK", 200 - # Handle payment failure (downgrade to free) - elif event["type"] == "invoice.payment_failed": - invoice = event["data"]["object"] - customer_id = invoice.get("customer") + content = event.get("content", {}) - from enferno.user.models import Workspace + # Handle subscription created (upgrade to pro) + if event_type == "subscription_created": + customer = content.get("customer", {}) + customer_id = customer.get("id") - workspace = db.session.execute( - db.select(Workspace).where(Workspace.billing_customer_id == customer_id) - ).scalar_one_or_none() + from enferno.user.models import Workspace - if workspace and workspace.plan == "pro": - workspace.plan = "free" - db.session.commit() - current_app.logger.warning( - f"Downgraded workspace {workspace.id} to free (payment failed)" - ) + # Find workspace by customer_id (set during checkout) + workspace = db.session.execute( + db.select(Workspace).where(Workspace.billing_customer_id == customer_id) + ).scalar_one_or_none() + + if workspace and not workspace.is_pro: + workspace.plan = "pro" + db.session.commit() + current_app.logger.info(f"Upgraded workspace {workspace.id} to pro") + + # Handle subscription cancellation (downgrade to free) + elif event_type == "subscription_cancelled": + customer = content.get("customer", {}) + customer_id = customer.get("id") + + from enferno.user.models import Workspace - return "OK", 200 + workspace = db.session.execute( + db.select(Workspace).where(Workspace.billing_customer_id == customer_id) + ).scalar_one_or_none() + + if workspace: + workspace.plan = "free" + db.session.commit() + current_app.logger.info( + f"Downgraded workspace {workspace.id} to free (subscription cancelled)" + ) + + # Handle payment failure (downgrade to free) + elif event_type == "payment_failed": + customer = content.get("customer", {}) + customer_id = customer.get("id") + + from enferno.user.models import Workspace + + workspace = db.session.execute( + db.select(Workspace).where(Workspace.billing_customer_id == customer_id) + ).scalar_one_or_none() + + if workspace and workspace.plan == "pro": + workspace.plan = "free" + db.session.commit() + current_app.logger.warning( + f"Downgraded workspace {workspace.id} to free (payment failed)" + ) + + return "OK", 200 From 6391ee9af0fb70922f9e835dbebebd92a250c676 Mon Sep 17 00:00:00 2001 From: level09 Date: Thu, 22 Jan 2026 01:26:33 +0100 Subject: [PATCH 6/9] Simplify Chargebee webhook: remove upgrade handler, require auth in prod - Remove subscription_created handler (upgrades handled via redirect flow) - Require Basic Auth credentials in production (reject if missing) - Allow unauthenticated webhooks only in debug mode for local testing - Clean up imports, move Workspace import to module level - Add PortalSessionWrapper for consistent .url interface with Stripe --- enferno/api/webhooks.py | 51 +++++++++++++------------------------ enferno/services/billing.py | 47 +++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 48 deletions(-) diff --git a/enferno/api/webhooks.py b/enferno/api/webhooks.py index e3d2320..4104b97 100644 --- a/enferno/api/webhooks.py +++ b/enferno/api/webhooks.py @@ -4,7 +4,7 @@ from sqlalchemy.exc import IntegrityError from enferno.extensions import db -from enferno.user.models import BillingEvent +from enferno.user.models import BillingEvent, Workspace webhooks_bp = Blueprint("webhooks", __name__) @@ -32,7 +32,11 @@ def stripe_webhook(): # Skip duplicate events event_id = event.get("id") try: - db.session.add(BillingEvent(event_id=event_id, event_type=event.get("type"), provider="stripe")) + db.session.add( + BillingEvent( + event_id=event_id, event_type=event.get("type"), provider="stripe" + ) + ) db.session.commit() except IntegrityError: db.session.rollback() @@ -43,8 +47,6 @@ def stripe_webhook(): session = event["data"]["object"] session_id = session.get("id") - from enferno.services.billing import HostedBilling - HostedBilling.handle_successful_payment(session_id) current_app.logger.info(f"Processed checkout: {session_id}") @@ -53,8 +55,6 @@ def stripe_webhook(): subscription = event["data"]["object"] customer_id = subscription.get("customer") - from enferno.user.models import Workspace - workspace = db.session.execute( db.select(Workspace).where(Workspace.billing_customer_id == customer_id) ).scalar_one_or_none() @@ -71,8 +71,6 @@ def stripe_webhook(): invoice = event["data"]["object"] customer_id = invoice.get("customer") - from enferno.user.models import Workspace - workspace = db.session.execute( db.select(Workspace).where(Workspace.billing_customer_id == customer_id) ).scalar_one_or_none() @@ -89,12 +87,15 @@ def stripe_webhook(): elif PROVIDER == "chargebee": def _verify_chargebee_auth(): - """Verify Chargebee webhook using Basic Auth.""" + """Verify Chargebee webhook using Basic Auth (required in production).""" username = current_app.config.get("CHARGEBEE_WEBHOOK_USERNAME") password = current_app.config.get("CHARGEBEE_WEBHOOK_PASSWORD") if not username or not password: - return True # No auth configured, allow (dev mode) + if not current_app.debug: + current_app.logger.error("Chargebee webhook credentials not configured") + return False + return True # Allow in debug mode for local testing auth = request.authorization if not auth or auth.username != username or auth.password != password: @@ -116,7 +117,11 @@ def chargebee_webhook(): # Skip duplicate events try: - db.session.add(BillingEvent(event_id=event_id, event_type=event_type, provider="chargebee")) + db.session.add( + BillingEvent( + event_id=event_id, event_type=event_type, provider="chargebee" + ) + ) db.session.commit() except IntegrityError: db.session.rollback() @@ -124,30 +129,12 @@ def chargebee_webhook(): content = event.get("content", {}) - # Handle subscription created (upgrade to pro) - if event_type == "subscription_created": - customer = content.get("customer", {}) - customer_id = customer.get("id") - - from enferno.user.models import Workspace - - # Find workspace by customer_id (set during checkout) - workspace = db.session.execute( - db.select(Workspace).where(Workspace.billing_customer_id == customer_id) - ).scalar_one_or_none() - - if workspace and not workspace.is_pro: - workspace.plan = "pro" - db.session.commit() - current_app.logger.info(f"Upgraded workspace {workspace.id} to pro") - # Handle subscription cancellation (downgrade to free) - elif event_type == "subscription_cancelled": + # Note: Upgrades handled via redirect flow, not webhook + if event_type == "subscription_cancelled": customer = content.get("customer", {}) customer_id = customer.get("id") - from enferno.user.models import Workspace - workspace = db.session.execute( db.select(Workspace).where(Workspace.billing_customer_id == customer_id) ).scalar_one_or_none() @@ -164,8 +151,6 @@ def chargebee_webhook(): customer = content.get("customer", {}) customer_id = customer.get("id") - from enferno.user.models import Workspace - workspace = db.session.execute( db.select(Workspace).where(Workspace.billing_customer_id == customer_id) ).scalar_one_or_none() diff --git a/enferno/services/billing.py b/enferno/services/billing.py index 9e8cc37..b08f7ec 100644 --- a/enferno/services/billing.py +++ b/enferno/services/billing.py @@ -104,7 +104,9 @@ def handle_successful_payment(session_id: str) -> int: return workspace.id except Exception as e: db.session.rollback() - current_app.logger.error(f"Failed to upgrade workspace {workspace_id}: {e}") + current_app.logger.error( + f"Failed to upgrade workspace {workspace_id}: {e}" + ) return None elif PROVIDER == "chargebee": @@ -136,13 +138,15 @@ def create_upgrade_session( if not item_price_id: raise RuntimeError("Chargebee item price not configured") - result = cb.HostedPage.checkout_new_for_items({ - "subscription_items": [{"item_price_id": item_price_id}], - "customer": {"email": user_email}, - "redirect_url": f"{base_url}billing/success", # Chargebee appends ?id=xxx&state=yyy - "cancel_url": f"{base_url}dashboard", - "pass_thru_content": json.dumps({"workspace_id": str(workspace_id)}), - }) + result = cb.HostedPage.checkout_new_for_items( + { + "subscription_items": [{"item_price_id": item_price_id}], + "customer": {"email": user_email}, + "redirect_url": f"{base_url}billing/success", + "cancel_url": f"{base_url}dashboard", + "pass_thru_content": json.dumps({"workspace_id": str(workspace_id)}), + } + ) hosted_page = result.hosted_page current_app.logger.info(f"Created Chargebee Checkout: {hosted_page.id}") return hosted_page @@ -153,13 +157,24 @@ def create_portal_session( ) -> Any: """Create Chargebee Portal session for billing management.""" cb = _init_chargebee() - result = cb.PortalSession.create({ - "customer": {"id": customer_id}, - "redirect_url": f"{base_url}workspace/{workspace_id}/settings", - }) + result = cb.PortalSession.create( + { + "customer": {"id": customer_id}, + "redirect_url": f"{base_url}workspace/{workspace_id}/settings", + } + ) portal_session = result.portal_session - current_app.logger.info(f"Created Chargebee Portal session: {portal_session.id}") - return portal_session + current_app.logger.info( + f"Created Chargebee Portal session: {portal_session.id}" + ) + + # Wrap to provide consistent .url interface (Chargebee uses access_url) + class PortalSessionWrapper: + def __init__(self, session): + self.id = session.id + self.url = session.access_url + + return PortalSessionWrapper(portal_session) @staticmethod def handle_successful_payment(hosted_page_id: str) -> int: @@ -198,7 +213,9 @@ def handle_successful_payment(hosted_page_id: str) -> int: return workspace.id except Exception as e: db.session.rollback() - current_app.logger.error(f"Failed to upgrade workspace {workspace_id}: {e}") + current_app.logger.error( + f"Failed to upgrade workspace {workspace_id}: {e}" + ) return None else: From 87cd22dc710e03c7507a3ce1bb8b268e586d09d7 Mon Sep 17 00:00:00 2001 From: level09 Date: Thu, 22 Jan 2026 01:31:45 +0100 Subject: [PATCH 7/9] Fix missing HostedBilling import in Stripe webhook handler --- enferno/api/webhooks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/enferno/api/webhooks.py b/enferno/api/webhooks.py index 4104b97..0ff3bd4 100644 --- a/enferno/api/webhooks.py +++ b/enferno/api/webhooks.py @@ -13,6 +13,8 @@ if PROVIDER == "stripe": import stripe + from enferno.services.billing import HostedBilling + @webhooks_bp.route("/stripe/webhook", methods=["POST"]) def stripe_webhook(): payload = request.get_data() From b4b58d57ab919dd571cd3c0675ab711182d75068 Mon Sep 17 00:00:00 2001 From: level09 Date: Thu, 22 Jan 2026 13:00:31 +0100 Subject: [PATCH 8/9] Update billing docs for Stripe/Chargebee provider support - Add warning about choosing provider before production - Document Chargebee setup and configuration - Update field names (billing_customer_id, BillingEvent) - Add provider comparison table - Document webhook differences between providers --- docs/billing.md | 138 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 120 insertions(+), 18 deletions(-) diff --git a/docs/billing.md b/docs/billing.md index 2763ae6..8c36aa4 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -1,10 +1,21 @@ # Billing -Stripe integration for subscriptions and payments. +Subscription billing with Stripe or Chargebee. ## Overview -ReadyKit uses Stripe's hosted pages for billing - no custom checkout UI to build or maintain. Users upgrade via Stripe Checkout and manage subscriptions through the Stripe Customer Portal. +ReadyKit uses hosted payment pages - no custom checkout UI to build or maintain. Users upgrade via the provider's checkout page and manage subscriptions through their portal. + +::: warning Choose Your Provider First +Select your billing provider before going to production. Switching providers after users have subscribed requires manual migration of customer data. Set `BILLING_PROVIDER` in your environment and stick with it. +::: + +## Supported Providers + +| Provider | Best For | +|----------|----------| +| **Stripe** | Most SaaS apps, US/EU focus, extensive API | +| **Chargebee** | Complex billing needs, subscription management, international | ## Plans @@ -31,6 +42,8 @@ From [Stripe Dashboard](https://dashboard.stripe.com): ```bash # .env +BILLING_PROVIDER=stripe + STRIPE_SECRET_KEY=sk_test_... STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_PRO_PRICE_ID=price_... @@ -45,7 +58,7 @@ PRO_PRICE_INTERVAL=month In Stripe Dashboard → Webhooks → Add endpoint: -- **URL**: `https://yourdomain.com/api/webhooks/stripe/webhook` +- **URL**: `https://yourdomain.com/stripe/webhook` - **Events to listen for**: - `checkout.session.completed` - `customer.subscription.deleted` @@ -53,17 +66,60 @@ In Stripe Dashboard → Webhooks → Add endpoint: Copy the signing secret to `STRIPE_WEBHOOK_SECRET`. +## Chargebee Setup + +### 1. Get Your Credentials + +From [Chargebee Dashboard](https://app.chargebee.com): + +1. **Settings → API Keys** → Copy your API key +2. **Product Catalog → Items** → Create an item with a price +3. **Settings → Webhooks** → Add endpoint (see below) + +### 2. Configure Environment + +```bash +# .env +BILLING_PROVIDER=chargebee + +CHARGEBEE_SITE=your-site # e.g., "acme" for acme.chargebee.com +CHARGEBEE_API_KEY=your_api_key +CHARGEBEE_PRO_ITEM_PRICE_ID=Pro-Plan-USD-Monthly + +# Webhook authentication (required in production) +CHARGEBEE_WEBHOOK_USERNAME=webhook_user +CHARGEBEE_WEBHOOK_PASSWORD=your_secure_password + +# Display values (shown in UI) +PRO_PRICE_DISPLAY=$29 +PRO_PRICE_INTERVAL=month +``` + +### 3. Set Up Webhook + +In Chargebee Dashboard → Settings → Webhooks → Add webhook: + +- **URL**: `https://yourdomain.com/chargebee/webhook` +- **Authentication**: Basic Auth with your configured username/password +- **Events to listen for**: + - `subscription_cancelled` + - `payment_failed` + +::: info Chargebee Webhook Security +Chargebee uses HTTP Basic Auth for webhook verification (not HMAC signatures like Stripe). Always configure `CHARGEBEE_WEBHOOK_USERNAME` and `CHARGEBEE_WEBHOOK_PASSWORD` in production. Unauthenticated webhooks are only allowed in debug mode for local testing. +::: + ## How Billing Works ### Upgrade Flow ``` User clicks "Upgrade" - → Create Stripe Checkout session - → Redirect to Stripe + → Create checkout session + → Redirect to provider's hosted page → User completes payment - → Stripe redirects to success URL - → Validate session_id + → Provider redirects to success URL + → Validate session → Upgrade workspace to Pro ``` @@ -83,12 +139,13 @@ def upgrade(workspace_id): ### Success Callback -The success URL includes a `session_id` that's validated server-side: +The success URL includes a session ID that's validated server-side: ```python @app.route("/billing/success") def billing_success(): - session_id = request.args.get("session_id") + # Works with both Stripe (session_id) and Chargebee (id) + session_id = request.args.get("session_id") or request.args.get("id") workspace_id = HostedBilling.handle_successful_payment(session_id) if workspace_id: @@ -100,12 +157,12 @@ def billing_success(): ``` ::: info -The `session_id` is the security token. Always validate it via Stripe API before upgrading - never trust URL parameters directly. +The session ID is the security token. Always validate it via the provider's API before upgrading - never trust URL parameters directly. ::: ### Manage Billing (Customer Portal) -Existing Pro users can manage their subscription through Stripe's Customer Portal: +Existing Pro users can manage their subscription through the provider's portal: ```python @app.route("/workspace//billing/") @@ -113,11 +170,11 @@ Existing Pro users can manage their subscription through Stripe's Customer Porta def manage_billing(workspace_id): workspace = g.current_workspace - if not workspace.stripe_customer_id: + if not workspace.billing_customer_id: return redirect(url_for("portal.upgrade", workspace_id=workspace_id)) session = HostedBilling.create_portal_session( - customer_id=workspace.stripe_customer_id, + customer_id=workspace.billing_customer_id, workspace_id=workspace_id, base_url=request.host_url ) @@ -128,20 +185,37 @@ def manage_billing(workspace_id): Webhooks update workspace status automatically when billing changes: +### Stripe Events + | Event | Action | |-------|--------| | `checkout.session.completed` | Upgrade workspace to Pro, save customer_id | | `customer.subscription.deleted` | Downgrade workspace to Free | | `invoice.payment_failed` | Downgrade workspace to Free | +### Chargebee Events + +| Event | Action | +|-------|--------| +| `subscription_cancelled` | Downgrade workspace to Free | +| `payment_failed` | Downgrade workspace to Free | + +::: tip Chargebee Upgrades +Chargebee upgrades are handled via the redirect flow only (not webhooks). This is intentional - the webhook would arrive after the redirect in most cases anyway. +::: + ### Idempotency -Webhooks are idempotent - duplicate events are safely ignored using the `StripeEvent` model: +Webhooks are idempotent - duplicate events are safely ignored using the `BillingEvent` model: ```python # Duplicate events are caught by unique constraint try: - db.session.add(StripeEvent(event_id=event_id, event_type=event.type)) + db.session.add(BillingEvent( + event_id=event_id, + event_type=event_type, + provider="stripe" # or "chargebee" + )) db.session.commit() except IntegrityError: db.session.rollback() @@ -173,6 +247,8 @@ For web pages, it redirects to the upgrade page. ## Testing Locally +### Stripe + Use [Stripe CLI](https://stripe.com/docs/stripe-cli) to test webhooks locally: ```bash @@ -183,13 +259,29 @@ brew install stripe/stripe-cli/stripe stripe login # Forward webhooks to local server -stripe listen --forward-to localhost:5000/api/webhooks/stripe/webhook +stripe listen --forward-to localhost:5000/stripe/webhook # In another terminal, trigger test events stripe trigger checkout.session.completed stripe trigger customer.subscription.deleted ``` +### Chargebee + +Use [ngrok](https://ngrok.com) to expose your local server: + +```bash +# Start ngrok +ngrok http 5000 + +# In Chargebee Dashboard: +# 1. Add webhook URL: https://your-ngrok-url.ngrok.io/chargebee/webhook +# 2. Set Basic Auth credentials matching your .env +# 3. Trigger test events from the webhook settings page +``` + +For local testing without authentication, set `FLASK_DEBUG=1` - webhooks will be accepted without Basic Auth in debug mode. + ## Checking Plan Status ```python @@ -217,10 +309,20 @@ if workspace.is_pro: class Workspace(db.Model): # Billing fields plan = db.Column(db.String(20), default="free") # "free" or "pro" - stripe_customer_id = db.Column(db.String(255)) # Stripe customer ID - upgraded_at = db.Column(db.DateTime) # When they upgraded + billing_customer_id = db.Column(db.String(255)) # Provider customer ID + upgraded_at = db.Column(db.DateTime) # When they upgraded @property def is_pro(self): return self.plan == "pro" ``` + +## Provider Comparison + +| Feature | Stripe | Chargebee | +|---------|--------|-----------| +| Webhook auth | HMAC signature | Basic Auth | +| Upgrade via | Redirect + Webhook | Redirect only | +| Portal URL field | `.url` | `.access_url` (wrapped) | +| Session param | `session_id` | `id` | +| Customer ID location | `session.customer` | `hosted_page.content["customer"]["id"]` | From 2776e84f55cb3001bafcbe758ad18e32efd44d8e Mon Sep 17 00:00:00 2001 From: level09 Date: Thu, 22 Jan 2026 13:21:26 +0100 Subject: [PATCH 9/9] Clean up billing views for provider-agnostic support - Remove verbose debug logging from upgrade flow - Update docstrings and comments to be provider-neutral - Simplify billing portal error handling - Update uv.lock with chargebee dependency --- enferno/portal/views.py | 57 +++++++++--------------------------- uv.lock | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 43 deletions(-) diff --git a/enferno/portal/views.py b/enferno/portal/views.py index c317f2d..251c77b 100644 --- a/enferno/portal/views.py +++ b/enferno/portal/views.py @@ -1,4 +1,3 @@ -import time from datetime import datetime from flask import ( @@ -426,26 +425,15 @@ def admin_users(): @portal.get("/workspace//upgrade") @require_workspace_access("admin") def upgrade_workspace(workspace_id): - """Redirect to Stripe Checkout - fully hosted""" - current_app.logger.info( - f"NEW UPGRADE REQUEST - workspace {workspace_id} for user {current_user.email} at {time.time()}" - ) - + """Redirect to hosted checkout for workspace upgrade""" workspace = g.current_workspace - current_app.logger.debug( - f"Current workspace plan: {workspace.plan}, billing_customer_id: {workspace.billing_customer_id}" - ) # Prevent duplicate subscriptions - redirect to billing portal if already Pro if workspace.is_pro: - current_app.logger.info( - f"Workspace {workspace_id} already on Pro plan, redirecting to billing portal" - ) - # Only redirect if we have a customer ID, otherwise show settings if workspace.billing_customer_id: return redirect(url_for("portal.billing_portal", workspace_id=workspace_id)) else: - # Pro workspace without Stripe customer (manual upgrade, legacy) + # Pro workspace without billing customer (manual upgrade, legacy) return redirect( url_for("portal.workspace_settings", workspace_id=workspace_id) ) @@ -454,14 +442,9 @@ def upgrade_workspace(workspace_id): session = HostedBilling.create_upgrade_session( workspace_id, current_user.email, request.url_root ) - current_app.logger.info(f"Created NEW session {session.id}") - current_app.logger.debug(f"Session URL: {session.url}") - current_app.logger.debug(f"Session status: {session.status}") return redirect(session.url) except Exception as e: - current_app.logger.exception( - f"FAILED - Error creating checkout session: {type(e).__name__}: {e}" - ) + current_app.logger.error(f"Checkout session error: {e}") return jsonify( {"error": "Failed to create checkout session", "details": str(e)} ), 500 @@ -470,7 +453,7 @@ def upgrade_workspace(workspace_id): @portal.get("/workspace//billing") @require_workspace_access("admin") def billing_portal(workspace_id): - """Redirect to Stripe Customer Portal - fully hosted""" + """Redirect to billing provider's customer portal""" workspace = g.current_workspace if workspace.billing_customer_id: try: @@ -480,28 +463,16 @@ def billing_portal(workspace_id): return redirect(session.url) except Exception as e: error_msg = str(e) - - # Handle specific Stripe Customer Portal configuration error - if "No configuration provided" in error_msg: - return render_template( - "billing_error.html", - error_type="portal_config", - title="Customer Portal Not Configured", - message="The billing portal needs to be set up in Stripe.", - action_text="Contact Support", - action_url="/dashboard", - details="Please contact support to enable billing management features.", - ) - else: - return render_template( - "billing_error.html", - error_type="portal_error", - title="Billing Portal Error", - message="Unable to access billing portal at this time.", - action_text="Try Again Later", - action_url=f"/workspace/{workspace_id}/settings", - details=error_msg, - ) + current_app.logger.error(f"Billing portal error: {error_msg}") + return render_template( + "billing_error.html", + error_type="portal_error", + title="Billing Portal Error", + message="Unable to access billing portal at this time.", + action_text="Try Again Later", + action_url=f"/workspace/{workspace_id}/settings", + details=error_msg, + ) else: # No customer ID, redirect to upgrade return redirect(url_for("portal.upgrade_workspace", workspace_id=workspace_id)) diff --git a/uv.lock b/uv.lock index 742bf68..6123103 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944 }, ] +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, +] + [[package]] name = "argon2-cffi" version = "25.1.0" @@ -353,6 +366,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, ] +[[package]] +name = "chargebee" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/85/d47840d85e02c4bed5727d1f57d8751d4187ff2a256314941b752529bee4/chargebee-3.16.0.tar.gz", hash = "sha256:c9c2cdcdc68195745b86b060f6fbfb23b425654f7eaf98598649b52ecfee486f", size = 260361 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/9f/680b10a10228d0530101cf26cef0987eb4d5c21eaa97549efd83affcf8f6/chargebee-3.16.0-py3-none-any.whl", hash = "sha256:c4c993113b11b743a3131aea92a99333c4b24feafeb34c34bd0ea19294411fbe", size = 357170 }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -859,6 +884,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + [[package]] name = "identify" version = "2.6.15" @@ -1588,6 +1650,7 @@ dependencies = [ { name = "bleach" }, { name = "blinker" }, { name = "cffi" }, + { name = "chargebee" }, { name = "click" }, { name = "cryptography" }, { name = "email-validator" }, @@ -1663,6 +1726,7 @@ requires-dist = [ { name = "blinker", specifier = ">=1.9.0" }, { name = "celery", marker = "extra == 'full'", specifier = ">=5.5.3" }, { name = "cffi", specifier = ">=1.17.1" }, + { name = "chargebee", specifier = ">=3.0.0" }, { name = "click", specifier = ">=8.2.1" }, { name = "cryptography", specifier = ">=45.0.5" }, { name = "email-validator", specifier = ">=2.2.0" },