From 9b85f3bb8d46fe003eda4e2105aa0a90bf057f5b Mon Sep 17 00:00:00 2001 From: Kobe Date: Thu, 26 Jun 2025 15:15:16 +0200 Subject: [PATCH] started implementing stripe --- __pycache__/models.cpython-313.pyc | Bin 49929 -> 53841 bytes ...98363f8c4f_add_foreign_key_to_customer_.py | 24 + ...9_replace_stripe_links_with_product_ids.py | 50 ++ migrations/versions/add_customer_table.py | 57 +++ ...03b4419053_add_foreign_key_to_customer_.py | 30 ++ models.py | 47 +- requirements.txt | 3 +- routes/__pycache__/admin.cpython-313.pyc | Bin 35205 -> 39910 bytes routes/__pycache__/main.cpython-313.pyc | Bin 120951 -> 134838 bytes routes/admin.py | 115 ++++- routes/main.py | 281 ++++++++++- static/css/instances.css | 11 + static/js/settings/connections.js | 89 ++++ static/js/settings/pricing.js | 5 +- templates/admin/customer_details_modal.html | 149 ++++++ templates/admin/customers.html | 178 +++++++ templates/admin/support_articles.html | 25 +- templates/checkout_success.html | 290 ++++++++++++ templates/common/base.html | 19 +- templates/components/pricing_section.html | 158 ++++++- templates/settings/settings.html | 2 +- templates/settings/tabs/connections.html | 63 ++- templates/settings/tabs/pricing.html | 88 ++-- utils/stripe_utils.py | 444 ++++++++++++++++++ 24 files changed, 2025 insertions(+), 103 deletions(-) create mode 100644 migrations/versions/3198363f8c4f_add_foreign_key_to_customer_.py create mode 100644 migrations/versions/421f02ac5f59_replace_stripe_links_with_product_ids.py create mode 100644 migrations/versions/add_customer_table.py create mode 100644 migrations/versions/cc03b4419053_add_foreign_key_to_customer_.py create mode 100644 templates/admin/customer_details_modal.html create mode 100644 templates/admin/customers.html create mode 100644 templates/checkout_success.html create mode 100644 utils/stripe_utils.py diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc index da12aed1a2251a46fecbe91828ebe400a13fb9db..2e32e86afb9a30cda212df58d5d44773853b7a92 100644 GIT binary patch delta 2391 zcmaJ??N3u@6z|g)Xel7j_m&olyj>84B90ma6eNPtL9a2x=qj|G-mSFVbK8xYMNktr zNfwaD1;oWxYSxN6%9>2~V#bnv*xZt}>6fNi%=`oPVNV$|jGjXKwHRQ>Hw z{8c^zHw>s{xBmK^iHh`wn(ysMsC(i2DOe?_t^iEh1B zye3Z=`r|@fN?#EeSsV$%uz$%Pm4(i%2^0E#aiKQ$;Ql;N+p?#XmH9d2@wt55C=>i+ z1?a3wfllla3IwnacR+I%lINIw5xyTGEFw2LcSVL;S_=zk5iOu4_*S+&M-`}hx|}#X zI9-TlO%n7sl`(rv3iQrcMjdKXA$eF!oIaf?MAsw|Am;*P0Vz7C%z=f;e0Ft+IqhaI>?!ROpTJb=T z_WiX$QqYe3i#)KJ^CUIYkYgs}i3cU8iRy6M52000`6Q(1GTgHbv4I;zJ-d5DM;oQj$m1L8`nv~*?bT>TwbdO_oCZw#j&9`UGaY|4woH@Np2xYhpta(RVLgVkOVhVxoeOD_Pzi z7iv2f(nth`jwf|( zaiKb;(#-qk{K2xYW=RuiGlUFDRZCnbPnF5%sX1z4Z}$DJ9!!=sOdTL^Fsac6G>e*` zDQsRcN9a&jQiH2k{#S{czw7gAj$S_+ExW7#LLcuQPPST-I+|2v+?uJ!v|?U1M=iH@ zCynN~(2%NcSdr|-pQft2jjN5x`mVUJCRJOXIeh+I>8dnY+Zh*b{T<&|_jLEd?r38| z+BMbpR0u>3*_}YTf@+D`zN-cGnKmIyB1e%X5_gW{FRjzj1&?4)yry{UTd~_~`}wuU i0VtiVny{o!bsAS6M*t6lP78zGI~vZ7%VzTn1h=wnB9`ONG*mn z$PXmMC_LHkgyiIRt2w;Y^cjQ2fpVI`5>gCBS~1$el2Qz&AkP3nkq(eAg~ZnlmIkWQ z3zh+r`oXe5(jZt4NE!yq7a47KT%*XySg<*HZ3H7@@#a12EE(A3v})h8cZC$?E| z12-dM?Pl4H-4TIcxhpX2$uOJ$Gv{G8#`dtT3K@B7lGLwmtEaBln(Q zN@JR=y+m&EY*DSr+54t!_S!#x*q0(k@gn}UGl diff --git a/migrations/versions/3198363f8c4f_add_foreign_key_to_customer_.py b/migrations/versions/3198363f8c4f_add_foreign_key_to_customer_.py new file mode 100644 index 0000000..d60065c --- /dev/null +++ b/migrations/versions/3198363f8c4f_add_foreign_key_to_customer_.py @@ -0,0 +1,24 @@ +"""add_foreign_key_to_customer_subscription_plan_id + +Revision ID: 3198363f8c4f +Revises: add_customer_table +Create Date: 2025-06-26 14:35:09.377247 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3198363f8c4f' +down_revision = 'add_customer_table' +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/migrations/versions/421f02ac5f59_replace_stripe_links_with_product_ids.py b/migrations/versions/421f02ac5f59_replace_stripe_links_with_product_ids.py new file mode 100644 index 0000000..56a5f63 --- /dev/null +++ b/migrations/versions/421f02ac5f59_replace_stripe_links_with_product_ids.py @@ -0,0 +1,50 @@ +"""replace_stripe_links_with_product_ids + +Revision ID: 421f02ac5f59 +Revises: add_stripe_payment_links +Create Date: 2025-06-26 13:49:45.124311 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + + +# revision identifiers, used by Alembic. +revision = '421f02ac5f59' +down_revision = 'add_stripe_payment_links' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # Check if new columns already exist + result = conn.execute(text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'pricing_plans' + AND column_name IN ('stripe_product_id', 'stripe_monthly_price_id', 'stripe_annual_price_id') + """)) + existing_columns = [row[0] for row in result.fetchall()] + + # Add new Stripe product/price ID columns if they don't exist + if 'stripe_product_id' not in existing_columns: + op.add_column('pricing_plans', sa.Column('stripe_product_id', sa.String(length=100), nullable=True)) + + if 'stripe_monthly_price_id' not in existing_columns: + op.add_column('pricing_plans', sa.Column('stripe_monthly_price_id', sa.String(length=100), nullable=True)) + + if 'stripe_annual_price_id' not in existing_columns: + op.add_column('pricing_plans', sa.Column('stripe_annual_price_id', sa.String(length=100), nullable=True)) + + # Note: We'll keep the old payment link columns for now to allow for a gradual migration + # They can be removed in a future migration after the new system is fully implemented + + +def downgrade(): + # Remove the new Stripe product/price ID columns + op.drop_column('pricing_plans', 'stripe_annual_price_id') + op.drop_column('pricing_plans', 'stripe_monthly_price_id') + op.drop_column('pricing_plans', 'stripe_product_id') diff --git a/migrations/versions/add_customer_table.py b/migrations/versions/add_customer_table.py new file mode 100644 index 0000000..02ffef1 --- /dev/null +++ b/migrations/versions/add_customer_table.py @@ -0,0 +1,57 @@ +"""add customer table for Stripe customers + +Revision ID: add_customer_table +Revises: 421f02ac5f59 +Create Date: 2025-06-27 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = 'add_customer_table' +down_revision = '421f02ac5f59' +branch_labels = None +depends_on = None + +def upgrade(): + conn = op.get_bind() + inspector = inspect(conn) + tables = inspector.get_table_names() + if 'customer' not in tables: + op.create_table( + 'customer', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')), + sa.Column('email', sa.String(150), nullable=False), + sa.Column('name', sa.String(150), nullable=True), + sa.Column('phone', sa.String(50), nullable=True), + sa.Column('billing_address_line1', sa.String(255), nullable=True), + sa.Column('billing_address_line2', sa.String(255), nullable=True), + sa.Column('billing_city', sa.String(100), nullable=True), + sa.Column('billing_state', sa.String(100), nullable=True), + sa.Column('billing_postal_code', sa.String(20), nullable=True), + sa.Column('billing_country', sa.String(100), nullable=True), + sa.Column('shipping_address_line1', sa.String(255), nullable=True), + sa.Column('shipping_address_line2', sa.String(255), nullable=True), + sa.Column('shipping_city', sa.String(100), nullable=True), + sa.Column('shipping_state', sa.String(100), nullable=True), + sa.Column('shipping_postal_code', sa.String(20), nullable=True), + sa.Column('shipping_country', sa.String(100), nullable=True), + sa.Column('tax_id_type', sa.String(50), nullable=True), + sa.Column('tax_id_value', sa.String(100), nullable=True), + sa.Column('stripe_customer_id', sa.String(255), nullable=True), + sa.Column('stripe_subscription_id', sa.String(255), nullable=True), + sa.Column('subscription_status', sa.String(50), nullable=True), + sa.Column('subscription_plan_id', sa.Integer, nullable=True), + sa.Column('subscription_billing_cycle', sa.String(20), nullable=True), + sa.Column('subscription_current_period_start', sa.DateTime, nullable=True), + sa.Column('subscription_current_period_end', sa.DateTime, nullable=True), + ) + op.create_index('idx_customer_email', 'customer', ['email']) + +def downgrade(): + op.drop_index('idx_customer_email', table_name='customer') + op.drop_table('customer') \ No newline at end of file diff --git a/migrations/versions/cc03b4419053_add_foreign_key_to_customer_.py b/migrations/versions/cc03b4419053_add_foreign_key_to_customer_.py new file mode 100644 index 0000000..04fc6a3 --- /dev/null +++ b/migrations/versions/cc03b4419053_add_foreign_key_to_customer_.py @@ -0,0 +1,30 @@ +"""add_foreign_key_to_customer_subscription_plan_id + +Revision ID: cc03b4419053 +Revises: 3198363f8c4f +Create Date: 2025-06-26 14:35:15.661164 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'cc03b4419053' +down_revision = '3198363f8c4f' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add foreign key constraint if it doesn't exist + op.create_foreign_key( + 'fk_customer_subscription_plan_id', + 'customer', 'pricing_plan', + ['subscription_plan_id'], ['id'], + ondelete='SET NULL' + ) + + +def downgrade(): + op.drop_constraint('fk_customer_subscription_plan_id', 'customer', type_='foreignkey') diff --git a/models.py b/models.py index e9a236c..145abf6 100644 --- a/models.py +++ b/models.py @@ -603,9 +603,13 @@ class PricingPlan(db.Model): is_custom = db.Column(db.Boolean, default=False) button_text = db.Column(db.String(50), default='Get Started') button_url = db.Column(db.String(200), default='#') - # Stripe payment links - monthly_stripe_link = db.Column(db.String(500), nullable=True) - annual_stripe_link = db.Column(db.String(500), nullable=True) + # Stripe integration fields + stripe_product_id = db.Column(db.String(100), nullable=True) + stripe_monthly_price_id = db.Column(db.String(100), nullable=True) + stripe_annual_price_id = db.Column(db.String(100), nullable=True) + # Deprecated: Stripe payment links (to be removed in a future migration) + monthly_stripe_link = db.Column(db.String(500), nullable=True) # DEPRECATED + annual_stripe_link = db.Column(db.String(500), nullable=True) # DEPRECATED order_index = db.Column(db.Integer, default=0) is_active = db.Column(db.Boolean, default=True) # Quota fields @@ -680,4 +684,39 @@ class PricingPlan(db.Model): return float('inf') if self.manager_quota == 0 else max(0, self.manager_quota - current_count) elif quota_type == 'admin_quota': return float('inf') if self.admin_quota == 0 else max(0, self.admin_quota - current_count) - return 0 \ No newline at end of file + return 0 + +class Customer(db.Model): + __tablename__ = 'customer' + id = db.Column(db.Integer, primary_key=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + email = db.Column(db.String(150), nullable=False, index=True) + name = db.Column(db.String(150)) + phone = db.Column(db.String(50)) + billing_address_line1 = db.Column(db.String(255)) + billing_address_line2 = db.Column(db.String(255)) + billing_city = db.Column(db.String(100)) + billing_state = db.Column(db.String(100)) + billing_postal_code = db.Column(db.String(20)) + billing_country = db.Column(db.String(100)) + shipping_address_line1 = db.Column(db.String(255)) + shipping_address_line2 = db.Column(db.String(255)) + shipping_city = db.Column(db.String(100)) + shipping_state = db.Column(db.String(100)) + shipping_postal_code = db.Column(db.String(20)) + shipping_country = db.Column(db.String(100)) + tax_id_type = db.Column(db.String(50)) + tax_id_value = db.Column(db.String(100)) + stripe_customer_id = db.Column(db.String(255)) + stripe_subscription_id = db.Column(db.String(255)) + subscription_status = db.Column(db.String(50)) + subscription_plan_id = db.Column(db.Integer, db.ForeignKey('pricing_plans.id')) + subscription_billing_cycle = db.Column(db.String(20)) + subscription_current_period_start = db.Column(db.DateTime) + subscription_current_period_end = db.Column(db.DateTime) + # Relationship to pricing plan + plan = db.relationship('PricingPlan', backref='customers') + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 73f3b4a..56c1ce9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,5 @@ psycopg2-binary==2.9.9 requests>=2.31.0 gunicorn==21.2.0 prometheus-client>=0.16.0 -PyJWT>=2.8.0 \ No newline at end of file +PyJWT>=2.8.0 +stripe>=7.0.0 \ No newline at end of file diff --git a/routes/__pycache__/admin.cpython-313.pyc b/routes/__pycache__/admin.cpython-313.pyc index 4ca6cc13c5505920a2f41499b5e7f847eb7f32ce..ddd71da9f6a23f49fc479e0d65ae6fb9755c3d3a 100644 GIT binary patch delta 11771 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 ej6R|y98qG9D6v{if~iPrO)$-jo>xqUGX5Wa&L') +@login_required +@require_password_change +def get_customer_details(customer_id): + """Get customer details for modal""" + if not current_user.is_admin: + return jsonify({'error': 'Access denied'}), 403 + + # Check if this is a MASTER instance + is_master = os.environ.get('MASTER', 'false').lower() == 'true' + if not is_master: + return jsonify({'error': 'Access denied'}), 403 + + try: + customer = Customer.query.get_or_404(customer_id) + + # Get the associated plan + plan = None + if customer.subscription_plan_id: + from models import PricingPlan + plan = PricingPlan.query.get(customer.subscription_plan_id) + + html = render_template('admin/customer_details_modal.html', customer=customer, plan=plan) + + return jsonify({ + 'success': True, + 'html': html + }) + except Exception as e: return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/routes/main.py b/routes/main.py index 6fbb3fd..1af0689 100644 --- a/routes/main.py +++ b/routes/main.py @@ -1,7 +1,8 @@ from flask import render_template, Blueprint, redirect, url_for, request, flash, Response, jsonify, session, current_app from flask_login import current_user, login_required -from models import User, db, Room, RoomFile, RoomMemberPermission, SiteSettings, Event, Conversation, Message, MessageAttachment, Notif, EmailTemplate, Mail, KeyValueSettings, DocuPulseSettings, PasswordSetupToken, Instance, ManagementAPIKey +from models import User, db, Room, RoomFile, RoomMemberPermission, SiteSettings, Event, Conversation, Message, MessageAttachment, Notif, EmailTemplate, Mail, KeyValueSettings, DocuPulseSettings, PasswordSetupToken, Instance, ManagementAPIKey, Customer, PricingPlan from routes.auth import require_password_change +from extensions import csrf import os from werkzeug.utils import secure_filename from sqlalchemy import func, case, literal_column, text @@ -20,6 +21,7 @@ import requests from functools import wraps import socket from urllib.parse import urlparse +import stripe # Set up logging to show in console logging.basicConfig( @@ -1350,6 +1352,7 @@ def init_routes(main_bp): nginx_settings = KeyValueSettings.get_value('nginx_settings') git_settings = KeyValueSettings.get_value('git_settings') cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings') + stripe_settings = KeyValueSettings.get_value('stripe_settings') # Get management API key for the connections tab management_api_key = ManagementAPIKey.query.filter_by(is_active=True).first() @@ -1427,6 +1430,7 @@ def init_routes(main_bp): nginx_settings=nginx_settings, git_settings=git_settings, cloudflare_settings=cloudflare_settings, + stripe_settings=stripe_settings, pricing_plans=pricing_plans, csrf_token=generate_csrf()) @@ -2125,13 +2129,12 @@ def init_routes(main_bp): email = data.get('email') api_key = data.get('api_key') zone_id = data.get('zone_id') - server_ip = data.get('server_ip') - if not email or not api_key or not zone_id or not server_ip: + if not email or not api_key or not zone_id: return jsonify({'error': 'Missing required fields'}), 400 try: - # Test Cloudflare connection by getting zone details + # Test Cloudflare connection headers = { 'X-Auth-Email': email, 'X-Auth-Key': api_key, @@ -2142,21 +2145,77 @@ def init_routes(main_bp): response = requests.get( f'https://api.cloudflare.com/client/v4/zones/{zone_id}', headers=headers, - timeout=10 + timeout=5 ) if response.status_code == 200: - zone_data = response.json() - if zone_data.get('success'): - return jsonify({'message': 'Connection successful'}) - else: - return jsonify({'error': f'API error: {zone_data.get("errors", [{}])[0].get("message", "Unknown error")}'}), 400 + return jsonify({'message': 'Connection successful'}) else: - return jsonify({'error': f'Connection failed: HTTP {response.status_code}'}), 400 + return jsonify({'error': f'Connection failed: {response.json().get("errors", [{}])[0].get("message", "Unknown error")}'}), 400 except Exception as e: return jsonify({'error': f'Connection failed: {str(e)}'}), 400 + @main_bp.route('/settings/save-stripe-connection', methods=['POST']) + @login_required + def save_stripe_connection(): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json() + publishable_key = data.get('publishable_key') + secret_key = data.get('secret_key') + webhook_secret = data.get('webhook_secret') + test_mode = data.get('test_mode', False) + customer_portal_url = data.get('customer_portal_url', '') + + if not publishable_key or not secret_key: + return jsonify({'error': 'Missing required fields'}), 400 + + try: + # Save Stripe settings + KeyValueSettings.set_value('stripe_settings', { + 'publishable_key': publishable_key, + 'secret_key': secret_key, + 'webhook_secret': webhook_secret, + 'test_mode': test_mode, + 'customer_portal_url': customer_portal_url + }) + + return jsonify({'message': 'Settings saved successfully'}) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @main_bp.route('/settings/test-stripe-connection', methods=['POST']) + @login_required + def test_stripe_connection(): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json() + secret_key = data.get('secret_key') + + if not secret_key: + return jsonify({'error': 'Missing required fields'}), 400 + + try: + # Test Stripe connection by making a simple API call + import stripe + stripe.api_key = secret_key + + # Try to get account information + account = stripe.Account.retrieve() + + return jsonify({'message': 'Connection successful'}) + + except stripe.error.AuthenticationError: + return jsonify({'error': 'Invalid API key'}), 400 + except stripe.error.StripeError as e: + return jsonify({'error': f'Stripe error: {str(e)}'}), 400 + except Exception as e: + return jsonify({'error': f'Connection failed: {str(e)}'}), 400 + @main_bp.route('/instances/launch-progress') @login_required @require_password_change @@ -2412,4 +2471,202 @@ def init_routes(main_bp): 'commit': commit, 'branch': branch, 'deployed_at': deployed_at - }) \ No newline at end of file + }) + + @main_bp.route('/api/create-checkout-session', methods=['POST']) + @csrf.exempt + def create_checkout_session(): + """Create a Stripe checkout session for a pricing plan""" + current_app.logger.info("=== CHECKOUT SESSION REQUEST START ===") + current_app.logger.info(f"Request method: {request.method}") + current_app.logger.info(f"Request headers: {dict(request.headers)}") + current_app.logger.info(f"Request data: {request.get_data()}") + + try: + from utils.stripe_utils import create_checkout_session + + data = request.get_json() + current_app.logger.info(f"Parsed JSON data: {data}") + + plan_id = data.get('plan_id') + billing_cycle = data.get('billing_cycle', 'monthly') + + current_app.logger.info(f"Plan ID: {plan_id}") + current_app.logger.info(f"Billing cycle: {billing_cycle}") + + if not plan_id: + current_app.logger.error("Plan ID is missing") + return jsonify({'error': 'Plan ID is required'}), 400 + + if billing_cycle not in ['monthly', 'annual']: + current_app.logger.error(f"Invalid billing cycle: {billing_cycle}") + return jsonify({'error': 'Invalid billing cycle'}), 400 + + current_app.logger.info("Calling create_checkout_session function...") + + # Create checkout session + checkout_url = create_checkout_session( + plan_id=plan_id, + billing_cycle=billing_cycle, + success_url=url_for('main.checkout_success', _external=True), + cancel_url=url_for('main.public_home', _external=True) + ) + + current_app.logger.info(f"Checkout URL created: {checkout_url}") + + response_data = { + 'success': True, + 'checkout_url': checkout_url + } + current_app.logger.info(f"Returning response: {response_data}") + + return jsonify(response_data) + + except Exception as e: + current_app.logger.error(f"Error creating checkout session: {str(e)}") + current_app.logger.error(f"Exception type: {type(e)}") + import traceback + current_app.logger.error(f"Traceback: {traceback.format_exc()}") + return jsonify({'error': str(e)}), 500 + finally: + current_app.logger.info("=== CHECKOUT SESSION REQUEST END ===") + + @main_bp.route('/api/checkout-success') + def checkout_success(): + """Handle successful checkout""" + session_id = request.args.get('session_id') + subscription_info = None + + # Get Stripe settings for customer portal link + stripe_settings = KeyValueSettings.get_value('stripe_settings') + + if session_id: + try: + from utils.stripe_utils import get_subscription_info + from models import Customer, PricingPlan + + subscription_info = get_subscription_info(session_id) + + # Log the subscription info for debugging + current_app.logger.info(f"Checkout success - Session ID: {session_id}") + current_app.logger.info(f"Subscription info: {subscription_info}") + + # Save or update customer information + if 'customer_details' in subscription_info: + customer_details = subscription_info['customer_details'] + current_app.logger.info(f"Customer details: {customer_details}") + + # Try to find existing customer by email + customer = Customer.query.filter_by(email=customer_details.get('email')).first() + + if customer: + # Update existing customer + current_app.logger.info(f"Updating existing customer: {customer.email}") + else: + # Create new customer + customer = Customer() + current_app.logger.info(f"Creating new customer: {customer_details.get('email')}") + + # Update customer information + customer.email = customer_details.get('email') + customer.name = customer_details.get('name') + customer.phone = customer_details.get('phone') + + # Update billing address + if customer_details.get('address'): + address = customer_details['address'] + customer.billing_address_line1 = address.get('line1') + customer.billing_address_line2 = address.get('line2') + customer.billing_city = address.get('city') + customer.billing_state = address.get('state') + customer.billing_postal_code = address.get('postal_code') + customer.billing_country = address.get('country') + + # Update shipping address + if customer_details.get('shipping'): + shipping = customer_details['shipping'] + customer.shipping_address_line1 = shipping.get('address', {}).get('line1') + customer.shipping_address_line2 = shipping.get('address', {}).get('line2') + customer.shipping_city = shipping.get('address', {}).get('city') + customer.shipping_state = shipping.get('address', {}).get('state') + customer.shipping_postal_code = shipping.get('address', {}).get('postal_code') + customer.shipping_country = shipping.get('address', {}).get('country') + + # Update tax information + if customer_details.get('tax_ids'): + tax_ids = customer_details['tax_ids'] + if tax_ids: + # Store the first tax ID (most common case) + customer.tax_id_type = tax_ids[0].get('type') + customer.tax_id_value = tax_ids[0].get('value') + + # Update Stripe and subscription information + customer.stripe_customer_id = subscription_info.get('customer_id') + customer.stripe_subscription_id = subscription_info.get('subscription_id') + customer.subscription_status = subscription_info.get('status') + customer.subscription_plan_id = subscription_info.get('plan_id') + customer.subscription_billing_cycle = subscription_info.get('billing_cycle') + customer.subscription_current_period_start = subscription_info.get('current_period_start') + customer.subscription_current_period_end = subscription_info.get('current_period_end') + + # Save to database + if not customer.id: + db.session.add(customer) + db.session.commit() + + current_app.logger.info(f"Customer saved successfully: {customer.email}") + + except Exception as e: + current_app.logger.error(f"Error processing checkout success: {str(e)}") + flash('Payment successful, but there was an issue processing your subscription. Please contact support.', 'warning') + + # Render the success page with subscription info and stripe settings + return render_template('checkout_success.html', subscription_info=subscription_info, stripe_settings=stripe_settings) + + @main_bp.route('/api/debug/pricing-plans') + @login_required + def debug_pricing_plans(): + """Debug endpoint to check pricing plans""" + try: + from models import PricingPlan + + plans = PricingPlan.query.all() + plans_data = [] + + for plan in plans: + plans_data.append({ + 'id': plan.id, + 'name': plan.name, + 'monthly_price': plan.monthly_price, + 'annual_price': plan.annual_price, + 'stripe_product_id': plan.stripe_product_id, + 'stripe_monthly_price_id': plan.stripe_monthly_price_id, + 'stripe_annual_price_id': plan.stripe_annual_price_id, + 'is_custom': plan.is_custom, + 'button_text': plan.button_text + }) + + return jsonify({ + 'success': True, + 'plans': plans_data, + 'count': len(plans_data) + }) + + except Exception as e: + current_app.logger.error(f"Error getting pricing plans: {str(e)}") + return jsonify({'error': str(e)}), 500 + + @main_bp.route('/preview-success') + def preview_success(): + """Preview the checkout success page with sample data""" + # Get Stripe settings for customer portal link + stripe_settings = KeyValueSettings.get_value('stripe_settings') + + sample_subscription_info = { + 'plan_name': 'Professional Plan', + 'billing_cycle': 'monthly', + 'status': 'active', + 'amount': 29.99, + 'currency': 'usd' + } + return render_template('checkout_success.html', subscription_info=sample_subscription_info, stripe_settings=stripe_settings) \ No newline at end of file diff --git a/static/css/instances.css b/static/css/instances.css index d865cae..fa72ec6 100644 --- a/static/css/instances.css +++ b/static/css/instances.css @@ -243,6 +243,17 @@ color: #000; } +.infrastructure-tools .btn-outline-purple { + color: #6f42c1; + border-color: #6f42c1; +} + +.infrastructure-tools .btn-outline-purple:hover { + background-color: #6f42c1; + border-color: #6f42c1; + color: #fff; +} + .infrastructure-tools .btn i { transition: transform 0.3s ease; } diff --git a/static/js/settings/connections.js b/static/js/settings/connections.js index f318a6c..e74a913 100644 --- a/static/js/settings/connections.js +++ b/static/js/settings/connections.js @@ -529,6 +529,95 @@ async function saveCloudflareConnection(event) { saveModal.show(); } +// Save Stripe Connection +async function saveStripeConnection(event) { + event.preventDefault(); + const saveModal = new bootstrap.Modal(document.getElementById('saveConnectionModal')); + const messageElement = document.getElementById('saveConnectionMessage'); + messageElement.textContent = ''; + messageElement.className = ''; + + try { + const publishableKey = document.getElementById('stripePublishableKey').value; + const secretKey = document.getElementById('stripeSecretKey').value; + const webhookSecret = document.getElementById('stripeWebhookSecret').value; + const customerPortalUrl = document.getElementById('stripeCustomerPortalUrl').value; + const testMode = document.getElementById('stripeTestMode').checked; + + if (!publishableKey || !secretKey) { + throw new Error('Please fill in all required fields'); + } + + const response = await fetch('/settings/save-stripe-connection', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify({ + publishable_key: publishableKey, + secret_key: secretKey, + webhook_secret: webhookSecret, + customer_portal_url: customerPortalUrl, + test_mode: testMode + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to save settings'); + } + + messageElement.textContent = 'Settings saved successfully!'; + messageElement.className = 'text-success'; + } catch (error) { + messageElement.textContent = error.message || 'Failed to save settings'; + messageElement.className = 'text-danger'; + } + + saveModal.show(); +} + +// Test Stripe Connection +async function testStripeConnection() { + const saveModal = new bootstrap.Modal(document.getElementById('saveConnectionModal')); + const messageElement = document.getElementById('saveConnectionMessage'); + messageElement.textContent = ''; + messageElement.className = ''; + + try { + const secretKey = document.getElementById('stripeSecretKey').value; + + if (!secretKey) { + throw new Error('Please enter your Stripe secret key first'); + } + + const response = await fetch('/settings/test-stripe-connection', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify({ + secret_key: secretKey + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Connection test failed'); + } + + messageElement.textContent = 'Connection test successful!'; + messageElement.className = 'text-success'; + } catch (error) { + messageElement.textContent = error.message || 'Connection test failed'; + messageElement.className = 'text-danger'; + } + + saveModal.show(); +} + // Initialize on page load document.addEventListener('DOMContentLoaded', function() { const gitSettings = JSON.parse(document.querySelector('meta[name="git-settings"]').getAttribute('content')); diff --git a/static/js/settings/pricing.js b/static/js/settings/pricing.js index 909d20f..d7f9ff7 100644 --- a/static/js/settings/pricing.js +++ b/static/js/settings/pricing.js @@ -164,8 +164,9 @@ function loadPlanForEdit(planId) { document.getElementById('editMonthlyPrice').value = plan.monthly_price; document.getElementById('editAnnualPrice').value = plan.annual_price; document.getElementById('editButtonText').value = plan.button_text; - document.getElementById('editMonthlyStripeLink').value = plan.monthly_stripe_link || ''; - document.getElementById('editAnnualStripeLink').value = plan.annual_stripe_link || ''; + document.getElementById('stripeProductId').value = plan.stripe_product_id || ''; + document.getElementById('stripeMonthlyPriceId').value = plan.stripe_monthly_price_id || ''; + document.getElementById('stripeAnnualPriceId').value = plan.stripe_annual_price_id || ''; document.getElementById('editIsPopular').checked = plan.is_popular; document.getElementById('editIsCustom').checked = plan.is_custom; document.getElementById('editIsActive').checked = plan.is_active; diff --git a/templates/admin/customer_details_modal.html b/templates/admin/customer_details_modal.html new file mode 100644 index 0000000..2987325 --- /dev/null +++ b/templates/admin/customer_details_modal.html @@ -0,0 +1,149 @@ +
+
+
Customer Information
+ + + + + + + + + + + + + + + + + +
Name:{{ customer.name or 'N/A' }}
Email:{{ customer.email }}
Phone:{{ customer.phone or 'N/A' }}
Created:{{ customer.created_at.strftime('%Y-%m-%d %H:%M') if customer.created_at else 'N/A' }}
+
+ +
+
Subscription Information
+ + + + + + + + + + + + + + + + + +
Plan: + {% if plan %} + {{ plan.name }} + {% else %} + Plan {{ customer.subscription_plan_id or 'N/A' }} + {% endif %} +
Status: + {% if customer.subscription_status %} + {% if customer.subscription_status == 'active' %} + Active + {% elif customer.subscription_status == 'canceled' %} + Canceled + {% elif customer.subscription_status == 'past_due' %} + Past Due + {% else %} + {{ customer.subscription_status }} + {% endif %} + {% else %} + No subscription + {% endif %} +
Billing Cycle: + {% if customer.subscription_billing_cycle %} + {{ customer.subscription_billing_cycle.title() }} + {% else %} + N/A + {% endif %} +
Current Period: + {% if customer.subscription_current_period_start and customer.subscription_current_period_end %} + {{ customer.subscription_current_period_start.strftime('%Y-%m-%d') }} to {{ customer.subscription_current_period_end.strftime('%Y-%m-%d') }} + {% else %} + N/A + {% endif %} +
+
+
+ +{% if customer.billing_address_line1 or customer.shipping_address_line1 %} +
+ {% if customer.billing_address_line1 %} +
+
Billing Address
+
+ {{ customer.billing_address_line1 }}
+ {% if customer.billing_address_line2 %}{{ customer.billing_address_line2 }}
{% endif %} + {% if customer.billing_city %}{{ customer.billing_city }}{% endif %} + {% if customer.billing_state %}, {{ customer.billing_state }}{% endif %} + {% if customer.billing_postal_code %} {{ customer.billing_postal_code }}{% endif %}
+ {% if customer.billing_country %}{{ customer.billing_country }}{% endif %} +
+
+ {% endif %} + + {% if customer.shipping_address_line1 %} +
+
Shipping Address
+
+ {{ customer.shipping_address_line1 }}
+ {% if customer.shipping_address_line2 %}{{ customer.shipping_address_line2 }}
{% endif %} + {% if customer.shipping_city %}{{ customer.shipping_city }}{% endif %} + {% if customer.shipping_state %}, {{ customer.shipping_state }}{% endif %} + {% if customer.shipping_postal_code %} {{ customer.shipping_postal_code }}{% endif %}
+ {% if customer.shipping_country %}{{ customer.shipping_country }}{% endif %} +
+
+ {% endif %} +
+{% endif %} + +{% if customer.tax_id_type and customer.tax_id_value %} +
+
+
Tax Information
+ + + + + + + + + +
Tax ID Type:{{ customer.tax_id_type }}
Tax ID Value:{{ customer.tax_id_value }}
+
+
+{% endif %} + +{% if customer.stripe_customer_id or customer.stripe_subscription_id %} +
+
+
Stripe Information
+ + {% if customer.stripe_customer_id %} + + + + + {% endif %} + {% if customer.stripe_subscription_id %} + + + + + {% endif %} +
Stripe Customer ID:{{ customer.stripe_customer_id }}
Stripe Subscription ID:{{ customer.stripe_subscription_id }}
+
+
+{% endif %} \ No newline at end of file diff --git a/templates/admin/customers.html b/templates/admin/customers.html new file mode 100644 index 0000000..c85bf1e --- /dev/null +++ b/templates/admin/customers.html @@ -0,0 +1,178 @@ +{% extends "common/base.html" %} +{% from "components/header.html" import header %} + +{% block title %}Customers - Admin{% endblock %} + +{% block content %} +{{ header( + title="Customers", + description="Manage customer information and subscriptions", + icon="fa-users" +) }} + +
+ {% if customers.items %} +
+
+
+ + + + + + + + + + + + + + + {% for customer in customers.items %} + + + + + + + + + + + {% endfor %} + +
NameEmailPhonePlanStatusBilling CycleCreatedActions
+
+
+
+ {{ customer.name[0] if customer.name else customer.email[0] }} +
+
+
+
{{ customer.name or 'N/A' }}
+
+
+
{{ customer.email }}{{ customer.phone or 'N/A' }} + {% if customer.subscription_plan_id %} + {% set plan = customer.plan %} + {% if plan %} + {{ plan.name }} + {% else %} + Plan {{ customer.subscription_plan_id }} + {% endif %} + {% else %} + No plan + {% endif %} + + {% if customer.subscription_status %} + {% if customer.subscription_status == 'active' %} + Active + {% elif customer.subscription_status == 'canceled' %} + Canceled + {% elif customer.subscription_status == 'past_due' %} + Past Due + {% else %} + {{ customer.subscription_status }} + {% endif %} + {% else %} + No subscription + {% endif %} + + {% if customer.subscription_billing_cycle %} + {{ customer.subscription_billing_cycle.title() }} + {% else %} + N/A + {% endif %} + {{ customer.created_at.strftime('%Y-%m-%d') if customer.created_at else 'N/A' }} + +
+
+ + + {% if customers.pages > 1 %} +
+ {% endif %} +
+
+ {% else %} +
+
+ +
No customers found
+

Customers will appear here once they complete a purchase.

+
+
+ {% endif %} +
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/support_articles.html b/templates/admin/support_articles.html index e5ba066..f4dd6ba 100644 --- a/templates/admin/support_articles.html +++ b/templates/admin/support_articles.html @@ -1,4 +1,5 @@ {% extends "common/base.html" %} +{% from "components/header.html" import header %} {% block title %}Support Articles - DocuPulse{% endblock %} @@ -69,18 +70,24 @@ {% endblock %} {% block content %} +{{ header( + title="Support Articles", + description="Create and manage help articles for users", + icon="fa-life-ring", + buttons=[ + { + 'text': 'Create New Article', + 'url': '#', + 'onclick': 'showCreateArticleModal()', + 'icon': 'fa-plus', + 'class': 'btn-primary' + } + ] +) }} +
-
-

- Support Articles -

- -
-
diff --git a/templates/checkout_success.html b/templates/checkout_success.html new file mode 100644 index 0000000..f7bc852 --- /dev/null +++ b/templates/checkout_success.html @@ -0,0 +1,290 @@ + + + + + + Payment Successful - DocuPulse + + + + + + + + + {% include 'components/header_nav.html' %} + + +
+
+
+
+
+ +
+

Payment Successful!

+

Your subscription has been activated and your DocuPulse instance is being set up.

+
+
+
+
+ + +
+
+
+
+
+
+
+
+ + Payment Confirmation +
+
+ {% if subscription_info %} +
+
+
+ + Subscription Details +
+
+ Plan: {{ subscription_info.plan_name }} +
+
+ Billing Cycle: {{ subscription_info.billing_cycle.title() }} +
+
+ Status: + {{ subscription_info.status.title() }} +
+
+ Amount: ${{ "%.2f"|format(subscription_info.amount) }} {{ subscription_info.currency.upper() }} +
+
+
+
+
+
+ + Next Steps +
+
+ + Check your email for login credentials +
+ + {% if stripe_settings and stripe_settings.customer_portal_url %} + + {% endif %} + +
+
+ {% else %} +
+
Thank you for your purchase!
+

Your payment has been processed successfully.

+
+ {% endif %} +
+
+
+ +
+
+
+ + What happens next? +
+
+
+
+
+ +
+
Instance Setup
+

Your DocuPulse instance will be automatically provisioned within the next few minutes.

+
+
+
+ +
+
Welcome Email
+

You'll receive an email with your login credentials and setup instructions.

+
+
+
+ +
+
Support Available
+

Our support team is ready to help you get started with DocuPulse.

+
+
+
+
+
+
+ + + +
+
+
+
+ + {% include 'components/footer_nav.html' %} + + + + \ No newline at end of file diff --git a/templates/common/base.html b/templates/common/base.html index 9d7d5a3..c9001cd 100644 --- a/templates/common/base.html +++ b/templates/common/base.html @@ -63,11 +63,9 @@
  • Settings
  • {% else %} - {% if not is_master %}
  • Profile
  • {% if current_user.is_admin %}
  • Settings
  • - {% endif %}
  • {% endif %} {% endif %} @@ -105,6 +103,11 @@ Support Articles + {% endif %} + {% else %} + + {% endif %}
    + + +
    +
    +
    +
    + Stripe Connection +
    + +
    +
    +
    +
    + + +
    Your Stripe publishable key (starts with pk_test_ or pk_live_)
    +
    +
    + + +
    Your Stripe secret key (starts with sk_test_ or sk_live_)
    +
    +
    + + +
    Webhook endpoint secret for secure event handling
    +
    +
    + + +
    URL for customers to manage their subscriptions and billing
    +
    +
    +
    + + +
    +
    Enable this to use Stripe test mode for development
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/templates/settings/tabs/pricing.html b/templates/settings/tabs/pricing.html index e26bed2..51270b3 100644 --- a/templates/settings/tabs/pricing.html +++ b/templates/settings/tabs/pricing.html @@ -53,28 +53,32 @@
    - - {% if plan.monthly_stripe_link or plan.annual_stripe_link %} + + {% if plan.stripe_product_id or plan.stripe_monthly_price_id or plan.stripe_annual_price_id %}
    - Payment Links: + Stripe Integration:
    - {% if plan.monthly_stripe_link %} + {% if plan.stripe_product_id %}
    - Monthly: - - Stripe Payment Link - + Product ID: + {{ plan.stripe_product_id }}
    {% endif %} - {% if plan.annual_stripe_link %} + {% if plan.stripe_monthly_price_id %}
    - Annual: - - Stripe Payment Link - + Monthly Price ID: + {{ plan.stripe_monthly_price_id }} + +
    + {% endif %} + {% if plan.stripe_annual_price_id %} +
    + + Annual Price ID: + {{ plan.stripe_annual_price_id }}
    {% endif %} @@ -91,10 +95,6 @@
    -
    - Button: {{ plan.button_text }} → {{ plan.button_url }} -
    -
    - +
    -
    +
    - - - Stripe payment link for monthly billing + + + The Stripe Product ID for this plan
    -
    +
    - - - Stripe payment link for annual billing + + + The Stripe Price ID for monthly billing +
    +
    +
    +
    + + + The Stripe Price ID for annual billing
    @@ -411,22 +416,27 @@
    - +
    -
    +
    - - - Stripe payment link for monthly billing + + + The Stripe Product ID for this plan
    -
    +
    - - - Stripe payment link for annual billing + + + The Stripe Price ID for monthly billing +
    +
    +
    +
    + + + The Stripe Price ID for annual billing
    diff --git a/utils/stripe_utils.py b/utils/stripe_utils.py new file mode 100644 index 0000000..9c9c7dd --- /dev/null +++ b/utils/stripe_utils.py @@ -0,0 +1,444 @@ +""" +Stripe utility functions for managing products, prices, and checkout sessions. +""" +import stripe +import os +from models import KeyValueSettings, PricingPlan +from flask import current_app, url_for +import logging + +logger = logging.getLogger(__name__) + +def get_stripe_settings(): + """Get Stripe settings from database""" + stripe_settings = KeyValueSettings.get_value('stripe_settings') + if not stripe_settings: + return None + return stripe_settings + +def configure_stripe(): + """Configure Stripe with API key from settings""" + stripe_settings = get_stripe_settings() + if not stripe_settings or not stripe_settings.get('secret_key'): + raise ValueError("Stripe secret key not configured") + + stripe.api_key = stripe_settings['secret_key'] + return stripe_settings + +def create_stripe_product(plan): + """ + Create a Stripe product and prices for a pricing plan + + Args: + plan: PricingPlan instance + + Returns: + dict: Contains product_id, monthly_price_id, annual_price_id + """ + try: + configure_stripe() + + # Create product + product = stripe.Product.create( + name=plan.name, + description=plan.description or f"DocuPulse {plan.name} Plan", + metadata={ + 'plan_id': plan.id, + 'plan_name': plan.name + } + ) + + # Create monthly price + monthly_price = stripe.Price.create( + product=product.id, + unit_amount=int(plan.monthly_price * 100), # Convert to cents + currency='eur', + recurring={'interval': 'month'}, + metadata={ + 'plan_id': plan.id, + 'billing_cycle': 'monthly', + 'plan_name': plan.name + } + ) + + # Create annual price + annual_price = stripe.Price.create( + product=product.id, + unit_amount=int(plan.annual_price * 100), # Convert to cents + currency='eur', + recurring={'interval': 'year'}, + metadata={ + 'plan_id': plan.id, + 'billing_cycle': 'annual', + 'plan_name': plan.name + } + ) + + return { + 'product_id': product.id, + 'monthly_price_id': monthly_price.id, + 'annual_price_id': annual_price.id + } + + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating product for plan {plan.name}: {str(e)}") + raise + except Exception as e: + logger.error(f"Error creating Stripe product for plan {plan.name}: {str(e)}") + raise + +def update_stripe_product(plan): + """ + Update an existing Stripe product and prices for a pricing plan + + Args: + plan: PricingPlan instance with existing Stripe IDs + + Returns: + dict: Updated product and price information + """ + try: + configure_stripe() + + if not plan.stripe_product_id: + # If no product ID exists, create new product + return create_stripe_product(plan) + + # Update product + product = stripe.Product.modify( + plan.stripe_product_id, + name=plan.name, + description=plan.description or f"DocuPulse {plan.name} Plan", + metadata={ + 'plan_id': plan.id, + 'plan_name': plan.name + } + ) + + # Archive old prices and create new ones + new_prices = {} + + # Handle monthly price + if plan.stripe_monthly_price_id: + try: + # Archive old monthly price + stripe.Price.modify(plan.stripe_monthly_price_id, active=False) + except stripe.error.StripeError: + pass # Price might not exist + + # Create new monthly price + monthly_price = stripe.Price.create( + product=product.id, + unit_amount=int(plan.monthly_price * 100), + currency='eur', + recurring={'interval': 'month'}, + metadata={ + 'plan_id': plan.id, + 'billing_cycle': 'monthly', + 'plan_name': plan.name + } + ) + new_prices['monthly_price_id'] = monthly_price.id + + # Handle annual price + if plan.stripe_annual_price_id: + try: + # Archive old annual price + stripe.Price.modify(plan.stripe_annual_price_id, active=False) + except stripe.error.StripeError: + pass # Price might not exist + + # Create new annual price + annual_price = stripe.Price.create( + product=product.id, + unit_amount=int(plan.annual_price * 100), + currency='eur', + recurring={'interval': 'year'}, + metadata={ + 'plan_id': plan.id, + 'billing_cycle': 'annual', + 'plan_name': plan.name + } + ) + new_prices['annual_price_id'] = annual_price.id + + return { + 'product_id': product.id, + 'monthly_price_id': new_prices['monthly_price_id'], + 'annual_price_id': new_prices['annual_price_id'] + } + + except stripe.error.StripeError as e: + logger.error(f"Stripe error updating product for plan {plan.name}: {str(e)}") + raise + except Exception as e: + logger.error(f"Error updating Stripe product for plan {plan.name}: {str(e)}") + raise + +def create_checkout_session(plan_id, billing_cycle='monthly', success_url=None, cancel_url=None): + """ + Create a Stripe checkout session for a pricing plan + + Args: + plan_id: ID of the PricingPlan + billing_cycle: 'monthly' or 'annual' + success_url: URL to redirect to on successful payment + cancel_url: URL to redirect to on cancellation + + Returns: + str: Checkout session URL + """ + logger.info(f"=== CREATE CHECKOUT SESSION START ===") + logger.info(f"Plan ID: {plan_id}") + logger.info(f"Billing cycle: {billing_cycle}") + logger.info(f"Success URL: {success_url}") + logger.info(f"Cancel URL: {cancel_url}") + + try: + configure_stripe() + logger.info("Stripe configured successfully") + + plan = PricingPlan.query.get(plan_id) + logger.info(f"Plan lookup result: {plan}") + + if not plan: + logger.error(f"Pricing plan with ID {plan_id} not found") + raise ValueError(f"Pricing plan with ID {plan_id} not found") + + logger.info(f"Plan found: {plan.name}") + logger.info(f"Plan stripe_monthly_price_id: {plan.stripe_monthly_price_id}") + logger.info(f"Plan stripe_annual_price_id: {plan.stripe_annual_price_id}") + + # Determine which price ID to use + if billing_cycle == 'monthly': + price_id = plan.stripe_monthly_price_id + if not price_id: + logger.error("Monthly price not configured for this plan") + raise ValueError("Monthly price not configured for this plan") + elif billing_cycle == 'annual': + price_id = plan.stripe_annual_price_id + if not price_id: + logger.error("Annual price not configured for this plan") + raise ValueError("Annual price not configured for this plan") + else: + logger.error(f"Invalid billing cycle: {billing_cycle}") + raise ValueError("Invalid billing cycle. Must be 'monthly' or 'annual'") + + logger.info(f"Using price ID: {price_id}") + + # Set default URLs if not provided + if not success_url: + success_url = url_for('main.dashboard', _external=True) + if not cancel_url: + cancel_url = url_for('main.public_home', _external=True) + + logger.info(f"Final success URL: {success_url}") + logger.info(f"Final cancel URL: {cancel_url}") + + # Create checkout session + session_data = { + 'payment_method_types': ['card'], + 'line_items': [{ + 'price': price_id, + 'quantity': 1, + }], + 'mode': 'subscription', + 'success_url': f"{success_url}?session_id={{CHECKOUT_SESSION_ID}}", + 'cancel_url': cancel_url, + 'metadata': { + 'plan_id': plan_id, + 'plan_name': plan.name, + 'billing_cycle': billing_cycle + }, + 'customer_email': None, # Will be collected during checkout + 'allow_promotion_codes': True, + 'billing_address_collection': 'required', + 'phone_number_collection': { + 'enabled': True + }, + 'automatic_tax': { + 'enabled': True + }, + 'tax_id_collection': { + 'enabled': True + } + } + + logger.info(f"Creating Stripe session with data: {session_data}") + + session = stripe.checkout.Session.create(**session_data) + + logger.info(f"Stripe session created successfully: {session.id}") + logger.info(f"Session URL: {session.url}") + + return session.url + + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating checkout session: {str(e)}") + raise + except Exception as e: + logger.error(f"Error creating checkout session: {str(e)}") + raise + finally: + logger.info("=== CREATE CHECKOUT SESSION END ===") + +def get_subscription_info(session_id): + """ + Get subscription information from a checkout session + + Args: + session_id: Stripe checkout session ID + + Returns: + dict: Subscription information + """ + try: + configure_stripe() + + session = stripe.checkout.Session.retrieve(session_id) + + if session.payment_status == 'paid': + subscription = stripe.Subscription.retrieve(session.subscription) + + # Get customer details + customer_details = {} + if session.customer_details: + customer_details = { + 'name': session.customer_details.name, + 'email': session.customer_details.email, + 'phone': session.customer_details.phone, + 'address': { + 'line1': session.customer_details.address.line1, + 'line2': session.customer_details.address.line2, + 'city': session.customer_details.address.city, + 'state': session.customer_details.address.state, + 'postal_code': session.customer_details.address.postal_code, + 'country': session.customer_details.address.country + } if session.customer_details.address else None, + 'shipping': { + 'name': session.customer_details.shipping.name, + 'address': { + 'line1': session.customer_details.shipping.address.line1, + 'line2': session.customer_details.shipping.address.line2, + 'city': session.customer_details.shipping.address.city, + 'state': session.customer_details.shipping.address.state, + 'postal_code': session.customer_details.shipping.address.postal_code, + 'country': session.customer_details.shipping.address.country + } + } if session.customer_details.shipping else None, + 'tax_ids': [ + { + 'type': tax_id.type, + 'value': tax_id.value + } for tax_id in session.customer_details.tax_ids + ] if session.customer_details.tax_ids else [] + } + + return { + 'session_id': session_id, + 'subscription_id': subscription.id, + 'customer_id': subscription.customer, + 'status': subscription.status, + 'plan_id': session.metadata.get('plan_id'), + 'plan_name': session.metadata.get('plan_name'), + 'billing_cycle': session.metadata.get('billing_cycle'), + 'current_period_start': subscription.current_period_start, + 'current_period_end': subscription.current_period_end, + 'amount': subscription.items.data[0].price.unit_amount / 100, # Convert from cents + 'currency': subscription.currency, + 'customer_details': customer_details + } + else: + return { + 'session_id': session_id, + 'payment_status': session.payment_status, + 'error': 'Payment not completed' + } + + except stripe.error.StripeError as e: + logger.error(f"Stripe error getting subscription info: {str(e)}") + raise + except Exception as e: + logger.error(f"Error getting subscription info: {str(e)}") + raise + +def cancel_subscription(subscription_id): + """ + Cancel a Stripe subscription + + Args: + subscription_id: Stripe subscription ID + + Returns: + dict: Cancellation information + """ + try: + configure_stripe() + + subscription = stripe.Subscription.modify( + subscription_id, + cancel_at_period_end=True + ) + + return { + 'subscription_id': subscription_id, + 'status': subscription.status, + 'cancel_at_period_end': subscription.cancel_at_period_end, + 'current_period_end': subscription.current_period_end + } + + except stripe.error.StripeError as e: + logger.error(f"Stripe error canceling subscription: {str(e)}") + raise + except Exception as e: + logger.error(f"Error canceling subscription: {str(e)}") + raise + +def validate_stripe_keys(): + """ + Validate that Stripe keys are properly configured + + Returns: + dict: Validation result with status and message + """ + try: + stripe_settings = get_stripe_settings() + if not stripe_settings: + return { + 'valid': False, + 'message': 'Stripe settings not configured' + } + + if not stripe_settings.get('secret_key'): + return { + 'valid': False, + 'message': 'Stripe secret key not configured' + } + + if not stripe_settings.get('publishable_key'): + return { + 'valid': False, + 'message': 'Stripe publishable key not configured' + } + + # Test the API key + configure_stripe() + account = stripe.Account.retrieve() + + return { + 'valid': True, + 'message': 'Stripe configuration is valid', + 'account_id': account.id, + 'test_mode': stripe_settings.get('test_mode', False) + } + + except stripe.error.AuthenticationError: + return { + 'valid': False, + 'message': 'Invalid Stripe API key' + } + except Exception as e: + return { + 'valid': False, + 'message': f'Error validating Stripe configuration: {str(e)}' + } \ No newline at end of file