started implementing stripe
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
@@ -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')
|
||||
57
migrations/versions/add_customer_table.py
Normal file
57
migrations/versions/add_customer_table.py
Normal file
@@ -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')
|
||||
@@ -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')
|
||||
45
models.py
45
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
|
||||
@@ -681,3 +685,38 @@ class PricingPlan(db.Model):
|
||||
elif quota_type == 'admin_quota':
|
||||
return float('inf') if self.admin_quota == 0 else max(0, self.admin_quota - current_count)
|
||||
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'<Customer {self.email}>'
|
||||
@@ -14,3 +14,4 @@ requests>=2.31.0
|
||||
gunicorn==21.2.0
|
||||
prometheus-client>=0.16.0
|
||||
PyJWT>=2.8.0
|
||||
stripe>=7.0.0
|
||||
Binary file not shown.
Binary file not shown.
115
routes/admin.py
115
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:
|
||||
@@ -737,3 +775,60 @@ def get_pricing_plans():
|
||||
|
||||
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/<int:customer_id>')
|
||||
@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
|
||||
279
routes/main.py
279
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
|
||||
@@ -2413,3 +2472,201 @@ def init_routes(main_bp):
|
||||
'branch': branch,
|
||||
'deployed_at': deployed_at
|
||||
})
|
||||
|
||||
@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)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
149
templates/admin/customer_details_modal.html
Normal file
149
templates/admin/customer_details_modal.html
Normal file
@@ -0,0 +1,149 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="mb-3">Customer Information</h6>
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><strong>Name:</strong></td>
|
||||
<td>{{ customer.name or 'N/A' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Email:</strong></td>
|
||||
<td>{{ customer.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Phone:</strong></td>
|
||||
<td>{{ customer.phone or 'N/A' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Created:</strong></td>
|
||||
<td>{{ customer.created_at.strftime('%Y-%m-%d %H:%M') if customer.created_at else 'N/A' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h6 class="mb-3">Subscription Information</h6>
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><strong>Plan:</strong></td>
|
||||
<td>
|
||||
{% if plan %}
|
||||
<span class="badge bg-primary">{{ plan.name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">Plan {{ customer.subscription_plan_id or 'N/A' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Status:</strong></td>
|
||||
<td>
|
||||
{% if customer.subscription_status %}
|
||||
{% if customer.subscription_status == 'active' %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% elif customer.subscription_status == 'canceled' %}
|
||||
<span class="badge bg-danger">Canceled</span>
|
||||
{% elif customer.subscription_status == 'past_due' %}
|
||||
<span class="badge bg-warning">Past Due</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ customer.subscription_status }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">No subscription</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Billing Cycle:</strong></td>
|
||||
<td>
|
||||
{% if customer.subscription_billing_cycle %}
|
||||
<span class="badge bg-info">{{ customer.subscription_billing_cycle.title() }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Current Period:</strong></td>
|
||||
<td>
|
||||
{% 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 %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if customer.billing_address_line1 or customer.shipping_address_line1 %}
|
||||
<div class="row mt-4">
|
||||
{% if customer.billing_address_line1 %}
|
||||
<div class="col-md-6">
|
||||
<h6 class="mb-3">Billing Address</h6>
|
||||
<address class="mb-0">
|
||||
{{ customer.billing_address_line1 }}<br>
|
||||
{% if customer.billing_address_line2 %}{{ customer.billing_address_line2 }}<br>{% 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 %}<br>
|
||||
{% if customer.billing_country %}{{ customer.billing_country }}{% endif %}
|
||||
</address>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customer.shipping_address_line1 %}
|
||||
<div class="col-md-6">
|
||||
<h6 class="mb-3">Shipping Address</h6>
|
||||
<address class="mb-0">
|
||||
{{ customer.shipping_address_line1 }}<br>
|
||||
{% if customer.shipping_address_line2 %}{{ customer.shipping_address_line2 }}<br>{% 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 %}<br>
|
||||
{% if customer.shipping_country %}{{ customer.shipping_country }}{% endif %}
|
||||
</address>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customer.tax_id_type and customer.tax_id_value %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<h6 class="mb-3">Tax Information</h6>
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><strong>Tax ID Type:</strong></td>
|
||||
<td>{{ customer.tax_id_type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Tax ID Value:</strong></td>
|
||||
<td>{{ customer.tax_id_value }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customer.stripe_customer_id or customer.stripe_subscription_id %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<h6 class="mb-3">Stripe Information</h6>
|
||||
<table class="table table-sm">
|
||||
{% if customer.stripe_customer_id %}
|
||||
<tr>
|
||||
<td><strong>Stripe Customer ID:</strong></td>
|
||||
<td><code>{{ customer.stripe_customer_id }}</code></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if customer.stripe_subscription_id %}
|
||||
<tr>
|
||||
<td><strong>Stripe Subscription ID:</strong></td>
|
||||
<td><code>{{ customer.stripe_subscription_id }}</code></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
178
templates/admin/customers.html
Normal file
178
templates/admin/customers.html
Normal file
@@ -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"
|
||||
) }}
|
||||
|
||||
<div class="container-fluid">
|
||||
{% if customers.items %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Plan</th>
|
||||
<th>Status</th>
|
||||
<th>Billing Cycle</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm me-3">
|
||||
<div class="avatar-title bg-primary rounded-circle">
|
||||
{{ customer.name[0] if customer.name else customer.email[0] }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-0">{{ customer.name or 'N/A' }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ customer.email }}</td>
|
||||
<td>{{ customer.phone or 'N/A' }}</td>
|
||||
<td>
|
||||
{% if customer.subscription_plan_id %}
|
||||
{% set plan = customer.plan %}
|
||||
{% if plan %}
|
||||
<span class="badge bg-primary">{{ plan.name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">Plan {{ customer.subscription_plan_id }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">No plan</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if customer.subscription_status %}
|
||||
{% if customer.subscription_status == 'active' %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% elif customer.subscription_status == 'canceled' %}
|
||||
<span class="badge bg-danger">Canceled</span>
|
||||
{% elif customer.subscription_status == 'past_due' %}
|
||||
<span class="badge bg-warning">Past Due</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ customer.subscription_status }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">No subscription</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if customer.subscription_billing_cycle %}
|
||||
<span class="badge bg-info">{{ customer.subscription_billing_cycle.title() }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ customer.created_at.strftime('%Y-%m-%d') if customer.created_at else 'N/A' }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
onclick="viewCustomerDetails({{ customer.id }})">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if customers.pages > 1 %}
|
||||
<nav aria-label="Customer pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if customers.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.customers', page=customers.prev_num) }}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in customers.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != customers.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.customers', page=page_num) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if customers.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.customers', page=customers.next_num) }}">Next</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No customers found</h5>
|
||||
<p class="text-muted">Customers will appear here once they complete a purchase.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Customer Details Modal -->
|
||||
<div class="modal fade" id="customerDetailsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Customer Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="customerDetailsContent">
|
||||
<!-- Content will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function viewCustomerDetails(customerId) {
|
||||
// Load customer details via AJAX
|
||||
fetch(`/admin/customers/${customerId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('customerDetailsContent').innerHTML = data.html;
|
||||
new bootstrap.Modal(document.getElementById('customerDetailsModal')).show();
|
||||
} else {
|
||||
alert('Failed to load customer details');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to load customer details');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
) }}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-color);">
|
||||
<i class="fas fa-life-ring me-2"></i>Support Articles
|
||||
</h1>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createArticleModal">
|
||||
<i class="fas fa-plus me-2"></i>Create New Article
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Articles List -->
|
||||
<div class="row" id="articlesList">
|
||||
<!-- Articles will be loaded here via AJAX -->
|
||||
|
||||
290
templates/checkout_success.html
Normal file
290
templates/checkout_success.html
Normal file
@@ -0,0 +1,290 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Successful - DocuPulse</title>
|
||||
<meta name="description" content="Your DocuPulse subscription has been activated successfully. Welcome to the future of document management.">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
padding: 120px 0 80px 0;
|
||||
}
|
||||
.success-card {
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
background: var(--white);
|
||||
overflow: hidden;
|
||||
}
|
||||
.success-card .card-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.success-card .card-body {
|
||||
padding: 2rem;
|
||||
}
|
||||
.subscription-detail {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.subscription-detail:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.subscription-detail strong {
|
||||
color: var(--text-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.next-step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.next-step-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.next-step-item i {
|
||||
width: 24px;
|
||||
margin-right: 0.75rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.next-step-item a {
|
||||
color: var(--text-dark);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.next-step-item a:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.feature-item {
|
||||
text-align: center;
|
||||
padding: 1.25rem;
|
||||
background: var(--bg-color);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.feature-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
margin: 0 auto 1rem;
|
||||
font-size: 1.25rem;
|
||||
box-shadow: 0 4px 12px rgba(var(--primary-color-rgb), 0.3);
|
||||
}
|
||||
.feature-item h6 {
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.feature-item p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
padding: 12px 30px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.btn-outline-primary {
|
||||
border: 2px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
border-radius: 25px;
|
||||
padding: 12px 30px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.btn-outline-primary:hover {
|
||||
background: rgba(var(--primary-color-rgb), 0.05);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||
}
|
||||
.section-title {
|
||||
color: var(--text-dark);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.section-title i {
|
||||
margin-right: 0.75rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% include 'components/header_nav.html' %}
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero-section">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center text-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-check-circle" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
<h1 class="display-4 fw-bold mb-3">Payment Successful!</h1>
|
||||
<p class="lead mb-4">Your subscription has been activated and your DocuPulse instance is being set up.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Success Details Section -->
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card success-card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
Payment Confirmation
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if subscription_info %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h5 class="section-title">
|
||||
<i class="fas fa-receipt"></i>
|
||||
Subscription Details
|
||||
</h5>
|
||||
<div class="subscription-detail">
|
||||
<strong>Plan:</strong> {{ subscription_info.plan_name }}
|
||||
</div>
|
||||
<div class="subscription-detail">
|
||||
<strong>Billing Cycle:</strong> {{ subscription_info.billing_cycle.title() }}
|
||||
</div>
|
||||
<div class="subscription-detail">
|
||||
<strong>Status:</strong>
|
||||
<span class="badge bg-success">{{ subscription_info.status.title() }}</span>
|
||||
</div>
|
||||
<div class="subscription-detail">
|
||||
<strong>Amount:</strong> ${{ "%.2f"|format(subscription_info.amount) }} {{ subscription_info.currency.upper() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<h5 class="section-title">
|
||||
<i class="fas fa-rocket"></i>
|
||||
Next Steps
|
||||
</h5>
|
||||
<div class="next-step-item">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<span>Check your email for login credentials</span>
|
||||
</div>
|
||||
<div class="next-step-item">
|
||||
<i class="fas fa-book"></i>
|
||||
<a href="{{ url_for('public.help_center') }}">Read our getting started guide</a>
|
||||
</div>
|
||||
{% if stripe_settings and stripe_settings.customer_portal_url %}
|
||||
<div class="next-step-item">
|
||||
<i class="fas fa-credit-card"></i>
|
||||
<a href="{{ stripe_settings.customer_portal_url }}" target="_blank">Manage your subscription & billing</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="next-step-item">
|
||||
<i class="fas fa-headset"></i>
|
||||
<a href="{{ url_for('public.contact') }}">Contact support if you need help</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center">
|
||||
<h5 class="mb-3">Thank you for your purchase!</h5>
|
||||
<p class="text-muted">Your payment has been processed successfully.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card success-card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
What happens next?
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="feature-grid">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-rocket"></i>
|
||||
</div>
|
||||
<h6>Instance Setup</h6>
|
||||
<p>Your DocuPulse instance will be automatically provisioned within the next few minutes.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</div>
|
||||
<h6>Welcome Email</h6>
|
||||
<p>You'll receive an email with your login credentials and setup instructions.</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-headset"></i>
|
||||
</div>
|
||||
<h6>Support Available</h6>
|
||||
<p>Our support team is ready to help you get started with DocuPulse.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="text-center mt-4">
|
||||
<a href="{{ url_for('public.help_center') }}" class="btn btn-primary btn-lg me-3">
|
||||
<i class="fas fa-question-circle me-2"></i>Get Help
|
||||
</a>
|
||||
<a href="{{ url_for('public.contact') }}" class="btn btn-outline-primary btn-lg">
|
||||
<i class="fas fa-envelope me-2"></i>Contact Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include 'components/footer_nav.html' %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -63,11 +63,9 @@
|
||||
<li><a class="dropdown-item" href="{{ url_for('main.settings') }}"><i class="fas fa-cog"></i> Settings</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% else %}
|
||||
{% if not is_master %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('main.profile') }}"><i class="fas fa-user"></i> Profile</a></li>
|
||||
{% if current_user.is_admin %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('main.settings') }}"><i class="fas fa-cog"></i> Settings</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -105,6 +103,11 @@
|
||||
<i class="fas fa-life-ring"></i> Support Articles
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.customers' %}active{% endif %}" href="{{ url_for('admin.customers') }}">
|
||||
<i class="fas fa-users"></i> Customers
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}">
|
||||
@@ -153,6 +156,18 @@
|
||||
<i class="fas fa-user"></i> Profile
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item d-lg-none">
|
||||
<hr class="my-2">
|
||||
<a class="nav-link" href="{{ url_for('main.settings') }}">
|
||||
<i class="fas fa-cog"></i> Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item d-lg-none">
|
||||
<a class="nav-link" href="{{ url_for('main.profile') }}">
|
||||
<i class="fas fa-user"></i> Profile
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item d-lg-none">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
|
||||
{% set pricing_plans = PricingPlan.get_active_plans() %}
|
||||
{% if pricing_plans %}
|
||||
<!-- Debug info -->
|
||||
<div style="display: none;" id="pricing-debug">
|
||||
<h4>Debug: Pricing Plans Found</h4>
|
||||
{% for plan in pricing_plans %}
|
||||
<div>
|
||||
Plan: {{ plan.name }} (ID: {{ plan.id }})
|
||||
- Monthly Price ID: {{ plan.stripe_monthly_price_id or 'None' }}
|
||||
- Annual Price ID: {{ plan.stripe_annual_price_id or 'None' }}
|
||||
- Is Custom: {{ plan.is_custom }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="row g-4 justify-content-center">
|
||||
{% for plan in pricing_plans %}
|
||||
<div class="col-md-3">
|
||||
@@ -53,15 +66,16 @@
|
||||
{{ plan.button_text }}
|
||||
</a>
|
||||
{% else %}
|
||||
{% if plan.monthly_stripe_link or plan.annual_stripe_link %}
|
||||
<a href="{{ plan.monthly_stripe_link or plan.annual_stripe_link }}"
|
||||
class="btn {% if plan.is_popular %}btn-primary{% else %}btn-outline-primary{% endif %} btn-lg w-100 mt-auto px-4 py-3 payment-button"
|
||||
data-plan-id="{{ plan.id }}"
|
||||
data-monthly-link="{{ plan.monthly_stripe_link or '' }}"
|
||||
data-annual-link="{{ plan.annual_stripe_link or '' }}"
|
||||
target="_blank">
|
||||
{% if plan.stripe_monthly_price_id or plan.stripe_annual_price_id %}
|
||||
<button class="btn {% if plan.is_popular %}btn-primary{% else %}btn-outline-primary{% endif %} btn-lg w-100 mt-auto px-4 py-3 checkout-button"
|
||||
data-plan-id="{{ plan.id }}"
|
||||
data-monthly-product-id="{{ plan.stripe_monthly_price_id or '' }}"
|
||||
data-annual-product-id="{{ plan.stripe_annual_price_id or '' }}"
|
||||
data-plan-name="{{ plan.name }}"
|
||||
data-monthly-price="{{ plan.monthly_price or 0 }}"
|
||||
data-annual-price="{{ plan.annual_price or 0 }}">
|
||||
{{ plan.button_text }}
|
||||
</a>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{{ contact_url }}" class="btn {% if plan.is_popular %}btn-primary{% else %}btn-outline-primary{% endif %} btn-lg w-100 mt-auto px-4 py-3">
|
||||
{{ plan.button_text }}
|
||||
@@ -98,7 +112,7 @@
|
||||
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
||||
<span class="monthly-price">€29</span>
|
||||
<span class="annual-price" style="display: none;">€23</span>
|
||||
<span class="fs-6 text-muted">/month</span>
|
||||
<span class="fs-6 text-muted price-period">/month</span>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-4">
|
||||
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 5 rooms</li>
|
||||
@@ -125,7 +139,7 @@
|
||||
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
||||
<span class="monthly-price">€99</span>
|
||||
<span class="annual-price" style="display: none;">€79</span>
|
||||
<span class="fs-6 text-muted">/month</span>
|
||||
<span class="fs-6 text-muted price-period">/month</span>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-4">
|
||||
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 25 rooms</li>
|
||||
@@ -147,7 +161,7 @@
|
||||
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
||||
<span class="monthly-price">€299</span>
|
||||
<span class="annual-price" style="display: none;">€239</span>
|
||||
<span class="fs-6 text-muted">/month</span>
|
||||
<span class="fs-6 text-muted price-period">/month</span>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-4">
|
||||
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 100 rooms</li>
|
||||
@@ -197,11 +211,31 @@
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Debug: Log pricing plans info
|
||||
console.log('=== PRICING DEBUG INFO ===');
|
||||
const checkoutButtons = document.querySelectorAll('.checkout-button');
|
||||
console.log('Found checkout buttons:', checkoutButtons.length);
|
||||
|
||||
checkoutButtons.forEach((button, index) => {
|
||||
console.log(`Button ${index + 1}:`, {
|
||||
planId: button.getAttribute('data-plan-id'),
|
||||
monthlyProductId: button.getAttribute('data-monthly-product-id'),
|
||||
annualProductId: button.getAttribute('data-annual-product-id'),
|
||||
planName: button.getAttribute('data-plan-name'),
|
||||
monthlyPrice: button.getAttribute('data-monthly-price'),
|
||||
annualPrice: button.getAttribute('data-annual-price')
|
||||
});
|
||||
});
|
||||
|
||||
// Show debug info if needed (uncomment to show)
|
||||
// document.getElementById('pricing-debug').style.display = 'block';
|
||||
|
||||
const billingToggle = document.getElementById('annualBilling');
|
||||
if (!billingToggle) return;
|
||||
|
||||
const monthlyPrices = document.querySelectorAll('.monthly-price');
|
||||
const annualPrices = document.querySelectorAll('.annual-price');
|
||||
const pricePeriods = document.querySelectorAll('.price-period');
|
||||
|
||||
// Add CSS for switch styling
|
||||
const style = document.createElement('style');
|
||||
@@ -247,6 +281,89 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
requestAnimationFrame(updateNumber);
|
||||
}
|
||||
|
||||
// Function to handle Stripe checkout
|
||||
async function handleCheckout(planId, billingCycle) {
|
||||
console.log('handleCheckout called with:', { planId, billingCycle });
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
plan_id: planId,
|
||||
billing_cycle: billingCycle
|
||||
};
|
||||
console.log('Sending request to /api/create-checkout-session with body:', requestBody);
|
||||
|
||||
const response = await fetch('/api/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
console.log('Response status:', response.status);
|
||||
console.log('Response ok:', response.ok);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Response error text:', errorText);
|
||||
throw new Error(`Failed to create checkout session: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Response data:', data);
|
||||
|
||||
if (data.checkout_url) {
|
||||
console.log('Redirecting to checkout URL:', data.checkout_url);
|
||||
window.location.href = data.checkout_url;
|
||||
} else {
|
||||
console.error('No checkout URL received in response');
|
||||
alert('Failed to create checkout session. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error);
|
||||
alert('Failed to start checkout. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Add click handlers for checkout buttons
|
||||
document.querySelectorAll('.checkout-button').forEach(button => {
|
||||
console.log('Adding click handler to checkout button:', button);
|
||||
button.addEventListener('click', function() {
|
||||
console.log('Checkout button clicked!');
|
||||
console.log('Button data attributes:', {
|
||||
planId: this.getAttribute('data-plan-id'),
|
||||
monthlyProductId: this.getAttribute('data-monthly-product-id'),
|
||||
annualProductId: this.getAttribute('data-annual-product-id'),
|
||||
planName: this.getAttribute('data-plan-name'),
|
||||
monthlyPrice: this.getAttribute('data-monthly-price'),
|
||||
annualPrice: this.getAttribute('data-annual-price')
|
||||
});
|
||||
|
||||
const planId = this.getAttribute('data-plan-id');
|
||||
const monthlyProductId = this.getAttribute('data-monthly-product-id');
|
||||
const annualProductId = this.getAttribute('data-annual-product-id');
|
||||
const planName = this.getAttribute('data-plan-name');
|
||||
const monthlyPrice = parseFloat(this.getAttribute('data-monthly-price'));
|
||||
const annualPrice = parseFloat(this.getAttribute('data-annual-price'));
|
||||
|
||||
// Determine which billing cycle to use based on billing toggle
|
||||
const isAnnual = billingToggle.checked;
|
||||
const billingCycle = isAnnual ? 'annual' : 'monthly';
|
||||
|
||||
console.log('Billing toggle state:', { isAnnual, billingCycle });
|
||||
console.log('Plan ID:', planId);
|
||||
|
||||
if (planId) {
|
||||
console.log('Calling handleCheckout with planId and billingCycle');
|
||||
handleCheckout(planId, billingCycle);
|
||||
} else {
|
||||
console.log('No plan ID found, redirecting to contact form');
|
||||
// Fallback to contact form if no plan configured
|
||||
window.location.href = '{{ contact_url }}';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
billingToggle.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
// Switch to annual prices with animation
|
||||
@@ -261,11 +378,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
animateNumber(price, monthlyValue, annualValue);
|
||||
});
|
||||
|
||||
// Update payment links to annual
|
||||
document.querySelectorAll('.payment-button').forEach(button => {
|
||||
const annualLink = button.getAttribute('data-annual-link');
|
||||
if (annualLink) {
|
||||
button.href = annualLink;
|
||||
// Update price periods to show "/year"
|
||||
document.querySelectorAll('.fs-6.text-muted, .price-period').forEach(period => {
|
||||
if (period.textContent.includes('/month')) {
|
||||
period.textContent = '/year';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -278,15 +394,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
animateNumber(price, currentValue, originalMonthlyValue);
|
||||
});
|
||||
|
||||
// Update payment links to monthly
|
||||
document.querySelectorAll('.payment-button').forEach(button => {
|
||||
const monthlyLink = button.getAttribute('data-monthly-link');
|
||||
if (monthlyLink) {
|
||||
button.href = monthlyLink;
|
||||
// Update price periods to show "/month"
|
||||
document.querySelectorAll('.fs-6.text-muted, .price-period').forEach(period => {
|
||||
if (period.textContent.includes('/year')) {
|
||||
period.textContent = '/month';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
@@ -140,7 +140,7 @@
|
||||
{% if is_master %}
|
||||
<!-- Connections Tab -->
|
||||
<div class="tab-pane fade {% if active_tab == 'connections' %}show active{% endif %}" id="connections" role="tabpanel" aria-labelledby="connections-tab">
|
||||
{{ connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings) }}
|
||||
{{ connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings, stripe_settings) }}
|
||||
</div>
|
||||
|
||||
<!-- Pricing Tab -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% from "settings/components/connection_modals.html" import connection_modals %}
|
||||
|
||||
{% macro connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings) %}
|
||||
{% macro connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings, stripe_settings) %}
|
||||
<!-- Meta tags for JavaScript -->
|
||||
<meta name="management-api-key" content="{{ site_settings.management_api_key }}">
|
||||
<meta name="git-settings" content="{{ git_settings|tojson|safe }}">
|
||||
@@ -212,6 +212,67 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stripe Connection Card -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fab fa-stripe me-2"></i>Stripe Connection
|
||||
</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="testStripeConnection()">
|
||||
<i class="fas fa-plug me-1"></i>Test Connection
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="stripeForm" onsubmit="saveStripeConnection(event)">
|
||||
<div class="mb-3">
|
||||
<label for="stripePublishableKey" class="form-label">Publishable Key</label>
|
||||
<input type="text" class="form-control" id="stripePublishableKey" name="stripePublishableKey"
|
||||
placeholder="pk_test_..." required
|
||||
value="{{ stripe_settings.publishable_key if stripe_settings and stripe_settings.publishable_key else '' }}">
|
||||
<div class="form-text">Your Stripe publishable key (starts with pk_test_ or pk_live_)</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="stripeSecretKey" class="form-label">Secret Key</label>
|
||||
<input type="password" class="form-control" id="stripeSecretKey" name="stripeSecretKey"
|
||||
placeholder="sk_test_..." required
|
||||
value="{{ stripe_settings.secret_key if stripe_settings and stripe_settings.secret_key else '' }}">
|
||||
<div class="form-text">Your Stripe secret key (starts with sk_test_ or sk_live_)</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="stripeWebhookSecret" class="form-label">Webhook Secret (Optional)</label>
|
||||
<input type="password" class="form-control" id="stripeWebhookSecret" name="stripeWebhookSecret"
|
||||
placeholder="whsec_..."
|
||||
value="{{ stripe_settings.webhook_secret if stripe_settings and stripe_settings.webhook_secret else '' }}">
|
||||
<div class="form-text">Webhook endpoint secret for secure event handling</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="stripeCustomerPortalUrl" class="form-label">Customer Portal URL</label>
|
||||
<input type="url" class="form-control" id="stripeCustomerPortalUrl" name="stripeCustomerPortalUrl"
|
||||
placeholder="https://billing.stripe.com/p/login/..."
|
||||
value="{{ stripe_settings.customer_portal_url if stripe_settings and stripe_settings.customer_portal_url else '' }}">
|
||||
<div class="form-text">URL for customers to manage their subscriptions and billing</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="stripeTestMode" name="stripeTestMode"
|
||||
{% if stripe_settings and stripe_settings.test_mode %}checked{% endif %}>
|
||||
<label class="form-check-label" for="stripeTestMode">
|
||||
Test Mode (Use test keys)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">Enable this to use Stripe test mode for development</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-1"></i>Save Stripe Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Connection Modal -->
|
||||
|
||||
@@ -53,28 +53,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stripe Payment Links -->
|
||||
{% if plan.monthly_stripe_link or plan.annual_stripe_link %}
|
||||
<!-- Stripe Integration Info -->
|
||||
{% if plan.stripe_product_id or plan.stripe_monthly_price_id or plan.stripe_annual_price_id %}
|
||||
<div class="mb-3">
|
||||
<strong>Payment Links:</strong>
|
||||
<strong>Stripe Integration:</strong>
|
||||
<div class="mt-2">
|
||||
{% if plan.monthly_stripe_link %}
|
||||
{% if plan.stripe_product_id %}
|
||||
<div class="mb-1">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-credit-card me-1"></i>Monthly:
|
||||
<a href="{{ plan.monthly_stripe_link }}" target="_blank" class="text-primary">
|
||||
Stripe Payment Link
|
||||
</a>
|
||||
<i class="fas fa-tag me-1"></i>Product ID:
|
||||
<code class="text-primary">{{ plan.stripe_product_id }}</code>
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plan.annual_stripe_link %}
|
||||
{% if plan.stripe_monthly_price_id %}
|
||||
<div class="mb-1">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-credit-card me-1"></i>Annual:
|
||||
<a href="{{ plan.annual_stripe_link }}" target="_blank" class="text-primary">
|
||||
Stripe Payment Link
|
||||
</a>
|
||||
<i class="fas fa-credit-card me-1"></i>Monthly Price ID:
|
||||
<code class="text-primary">{{ plan.stripe_monthly_price_id }}</code>
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plan.stripe_annual_price_id %}
|
||||
<div class="mb-1">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-credit-card me-1"></i>Annual Price ID:
|
||||
<code class="text-primary">{{ plan.stripe_annual_price_id }}</code>
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -91,10 +95,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Button:</strong> {{ plan.button_text }} → {{ plan.button_url }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input plan-popular-toggle" type="checkbox"
|
||||
@@ -251,22 +251,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stripe Payment Links Section -->
|
||||
<!-- Stripe Product/Price IDs Section -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="monthlyStripeLink" class="form-label">Monthly Stripe Payment Link</label>
|
||||
<input type="url" class="form-control" id="monthlyStripeLink" name="monthly_stripe_link"
|
||||
placeholder="https://buy.stripe.com/...">
|
||||
<small class="text-muted">Stripe payment link for monthly billing</small>
|
||||
<label for="stripeProductId" class="form-label">Stripe Product ID</label>
|
||||
<input type="text" class="form-control" id="stripeProductId" name="stripe_product_id" placeholder="prod_xxx">
|
||||
<small class="text-muted">The Stripe Product ID for this plan</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="annualStripeLink" class="form-label">Annual Stripe Payment Link</label>
|
||||
<input type="url" class="form-control" id="annualStripeLink" name="annual_stripe_link"
|
||||
placeholder="https://buy.stripe.com/...">
|
||||
<small class="text-muted">Stripe payment link for annual billing</small>
|
||||
<label for="stripeMonthlyPriceId" class="form-label">Stripe Monthly Price ID</label>
|
||||
<input type="text" class="form-control" id="stripeMonthlyPriceId" name="stripe_monthly_price_id" placeholder="price_xxx">
|
||||
<small class="text-muted">The Stripe Price ID for monthly billing</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="stripeAnnualPriceId" class="form-label">Stripe Annual Price ID</label>
|
||||
<input type="text" class="form-control" id="stripeAnnualPriceId" name="stripe_annual_price_id" placeholder="price_xxx">
|
||||
<small class="text-muted">The Stripe Price ID for annual billing</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,22 +416,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stripe Payment Links Section -->
|
||||
<!-- Stripe Product/Price IDs Section -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="editMonthlyStripeLink" class="form-label">Monthly Stripe Payment Link</label>
|
||||
<input type="url" class="form-control" id="editMonthlyStripeLink" name="monthly_stripe_link"
|
||||
placeholder="https://buy.stripe.com/...">
|
||||
<small class="text-muted">Stripe payment link for monthly billing</small>
|
||||
<label for="stripeProductId" class="form-label">Stripe Product ID</label>
|
||||
<input type="text" class="form-control" id="stripeProductId" name="stripe_product_id" placeholder="prod_xxx">
|
||||
<small class="text-muted">The Stripe Product ID for this plan</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="editAnnualStripeLink" class="form-label">Annual Stripe Payment Link</label>
|
||||
<input type="url" class="form-control" id="editAnnualStripeLink" name="annual_stripe_link"
|
||||
placeholder="https://buy.stripe.com/...">
|
||||
<small class="text-muted">Stripe payment link for annual billing</small>
|
||||
<label for="stripeMonthlyPriceId" class="form-label">Stripe Monthly Price ID</label>
|
||||
<input type="text" class="form-control" id="stripeMonthlyPriceId" name="stripe_monthly_price_id" placeholder="price_xxx">
|
||||
<small class="text-muted">The Stripe Price ID for monthly billing</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="stripeAnnualPriceId" class="form-label">Stripe Annual Price ID</label>
|
||||
<input type="text" class="form-control" id="stripeAnnualPriceId" name="stripe_annual_price_id" placeholder="price_xxx">
|
||||
<small class="text-muted">The Stripe Price ID for annual billing</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
444
utils/stripe_utils.py
Normal file
444
utils/stripe_utils.py
Normal file
@@ -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)}'
|
||||
}
|
||||
Reference in New Issue
Block a user