diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc index da12aed..2e32e86 100644 Binary files a/__pycache__/models.cpython-313.pyc and b/__pycache__/models.cpython-313.pyc differ 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 4ca6cc1..ddd71da 100644 Binary files a/routes/__pycache__/admin.cpython-313.pyc and b/routes/__pycache__/admin.cpython-313.pyc differ diff --git a/routes/__pycache__/main.cpython-313.pyc b/routes/__pycache__/main.cpython-313.pyc index 91be6ce..e7344a8 100644 Binary files a/routes/__pycache__/main.cpython-313.pyc and b/routes/__pycache__/main.cpython-313.pyc differ diff --git a/routes/admin.py b/routes/admin.py index fbb43dc..7e9a04f 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -1,11 +1,12 @@ -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, render_template, flash, redirect, url_for, current_app from flask_login import login_required, current_user -from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle +from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle, Customer from extensions import csrf from utils.event_logger import log_event import os from datetime import datetime import json +from routes.auth import require_password_change admin = Blueprint('admin', __name__) @@ -461,6 +462,7 @@ def create_pricing_plan(): try: from models import PricingPlan + from utils.stripe_utils import create_stripe_product # Get form data name = request.form.get('name') @@ -469,12 +471,15 @@ def create_pricing_plan(): annual_price = float(request.form.get('annual_price')) features = json.loads(request.form.get('features', '[]')) button_text = request.form.get('button_text', 'Get Started') - monthly_stripe_link = request.form.get('monthly_stripe_link', '') - annual_stripe_link = request.form.get('annual_stripe_link', '') is_popular = request.form.get('is_popular') == 'true' is_custom = request.form.get('is_custom') == 'true' is_active = request.form.get('is_active') == 'true' + # Get Stripe ID fields + stripe_product_id = request.form.get('stripe_product_id', '') + stripe_monthly_price_id = request.form.get('stripe_monthly_price_id', '') + stripe_annual_price_id = request.form.get('stripe_annual_price_id', '') + # Get quota fields room_quota = int(request.form.get('room_quota', 0)) conversation_quota = int(request.form.get('conversation_quota', 0)) @@ -497,8 +502,9 @@ def create_pricing_plan(): annual_price=annual_price, features=features, button_text=button_text, - monthly_stripe_link=monthly_stripe_link, - annual_stripe_link=annual_stripe_link, + stripe_product_id=stripe_product_id, + stripe_monthly_price_id=stripe_monthly_price_id, + stripe_annual_price_id=stripe_annual_price_id, is_popular=is_popular, is_custom=is_custom, is_active=is_active, @@ -514,6 +520,18 @@ def create_pricing_plan(): db.session.add(plan) db.session.commit() + # If no Stripe IDs provided and plan is not custom, try to create Stripe product + if not is_custom and not stripe_product_id: + try: + stripe_data = create_stripe_product(plan) + plan.stripe_product_id = stripe_data['product_id'] + plan.stripe_monthly_price_id = stripe_data['monthly_price_id'] + plan.stripe_annual_price_id = stripe_data['annual_price_id'] + db.session.commit() + except Exception as stripe_error: + # Log the error but don't fail the plan creation + current_app.logger.warning(f"Failed to create Stripe product for plan {plan.name}: {str(stripe_error)}") + return jsonify({'success': True, 'message': 'Pricing plan created successfully'}) except Exception as e: @@ -544,6 +562,9 @@ def get_pricing_plan(plan_id): 'features': plan.features, 'button_text': plan.button_text, 'button_url': plan.button_url, + '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_popular': plan.is_popular, 'is_custom': plan.is_custom, 'is_active': plan.is_active, @@ -572,6 +593,7 @@ def update_pricing_plan(plan_id): try: from models import PricingPlan + from utils.stripe_utils import update_stripe_product plan = PricingPlan.query.get(plan_id) if not plan: @@ -584,12 +606,15 @@ def update_pricing_plan(plan_id): annual_price = float(request.form.get('annual_price')) features = json.loads(request.form.get('features', '[]')) button_text = request.form.get('button_text', 'Get Started') - monthly_stripe_link = request.form.get('monthly_stripe_link', '') - annual_stripe_link = request.form.get('annual_stripe_link', '') is_popular = request.form.get('is_popular') == 'true' is_custom = request.form.get('is_custom') == 'true' is_active = request.form.get('is_active') == 'true' + # Get Stripe ID fields + stripe_product_id = request.form.get('stripe_product_id', '') + stripe_monthly_price_id = request.form.get('stripe_monthly_price_id', '') + stripe_annual_price_id = request.form.get('stripe_annual_price_id', '') + # Get quota fields room_quota = int(request.form.get('room_quota', 0)) conversation_quota = int(request.form.get('conversation_quota', 0)) @@ -608,8 +633,9 @@ def update_pricing_plan(plan_id): plan.annual_price = annual_price plan.features = features plan.button_text = button_text - plan.monthly_stripe_link = monthly_stripe_link - plan.annual_stripe_link = annual_stripe_link + plan.stripe_product_id = stripe_product_id + plan.stripe_monthly_price_id = stripe_monthly_price_id + plan.stripe_annual_price_id = stripe_annual_price_id plan.is_popular = is_popular plan.is_custom = is_custom plan.is_active = is_active @@ -621,6 +647,18 @@ def update_pricing_plan(plan_id): db.session.commit() + # If plan has existing Stripe product and is not custom, try to update it + if not is_custom and plan.stripe_product_id: + try: + stripe_data = update_stripe_product(plan) + plan.stripe_product_id = stripe_data['product_id'] + plan.stripe_monthly_price_id = stripe_data['monthly_price_id'] + plan.stripe_annual_price_id = stripe_data['annual_price_id'] + db.session.commit() + except Exception as stripe_error: + # Log the error but don't fail the plan update + current_app.logger.warning(f"Failed to update Stripe product for plan {plan.name}: {str(stripe_error)}") + return jsonify({'success': True, 'message': 'Pricing plan updated successfully'}) except Exception as e: @@ -735,5 +773,62 @@ def get_pricing_plans(): 'plans': plans_data }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@admin.route('/customers') +@login_required +@require_password_change +def customers(): + """View all customers""" + if not current_user.is_admin: + flash('Access denied. Admin privileges required.', 'error') + return redirect(url_for('main.dashboard')) + + # Check if this is a MASTER instance + is_master = os.environ.get('MASTER', 'false').lower() == 'true' + if not is_master: + flash('Access denied. Master admin privileges required.', 'error') + return redirect(url_for('main.dashboard')) + + # Get all customers with pagination + page = request.args.get('page', 1, type=int) + per_page = 20 + + customers = Customer.query.order_by(Customer.created_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + return render_template('admin/customers.html', customers=customers) + +@admin.route('/customers/') +@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