Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0d436d116 | |||
| 36da0717a2 | |||
| 8a622334d0 | |||
| b1da4977d3 | |||
| 9b85f3bb8d | |||
| 3a0659b63b | |||
| 5b598f2966 |
Binary file not shown.
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,43 @@
|
||||
"""add stripe payment links to pricing plans
|
||||
|
||||
Revision ID: add_stripe_payment_links
|
||||
Revises: 9206bf87bb8e
|
||||
Create Date: 2024-12-19 13:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_stripe_payment_links'
|
||||
down_revision = '9206bf87bb8e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
# Check if columns already exist
|
||||
result = conn.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'pricing_plans'
|
||||
AND column_name IN ('monthly_stripe_link', 'annual_stripe_link')
|
||||
"""))
|
||||
existing_columns = [row[0] for row in result.fetchall()]
|
||||
|
||||
# Add Stripe payment link columns if they don't exist
|
||||
if 'monthly_stripe_link' not in existing_columns:
|
||||
op.add_column('pricing_plans', sa.Column('monthly_stripe_link', sa.String(length=500), nullable=True))
|
||||
|
||||
if 'annual_stripe_link' not in existing_columns:
|
||||
op.add_column('pricing_plans', sa.Column('annual_stripe_link', sa.String(length=500), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Remove Stripe payment link columns
|
||||
op.drop_column('pricing_plans', 'annual_stripe_link')
|
||||
op.drop_column('pricing_plans', 'monthly_stripe_link')
|
||||
@@ -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')
|
||||
42
models.py
42
models.py
@@ -603,6 +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 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
|
||||
@@ -678,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.
111
routes/admin.py
111
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,11 +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')
|
||||
button_url = request.form.get('button_url', '#')
|
||||
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))
|
||||
@@ -496,7 +502,9 @@ def create_pricing_plan():
|
||||
annual_price=annual_price,
|
||||
features=features,
|
||||
button_text=button_text,
|
||||
button_url=button_url,
|
||||
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,
|
||||
@@ -512,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:
|
||||
@@ -542,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,
|
||||
@@ -570,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:
|
||||
@@ -582,11 +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')
|
||||
button_url = request.form.get('button_url', '#')
|
||||
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))
|
||||
@@ -605,7 +633,9 @@ def update_pricing_plan(plan_id):
|
||||
plan.annual_price = annual_price
|
||||
plan.features = features
|
||||
plan.button_text = button_text
|
||||
plan.button_url = button_url
|
||||
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
|
||||
@@ -617,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:
|
||||
@@ -733,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
|
||||
@@ -1870,7 +1870,7 @@ def copy_smtp_settings():
|
||||
def update_stack():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'stack_id' not in data or 'StackFileContent' not in data:
|
||||
if not data or 'stack_id' not in data:
|
||||
return jsonify({'error': 'Missing required fields'}), 400
|
||||
|
||||
# Get Portainer settings
|
||||
@@ -1924,7 +1924,7 @@ def update_stack():
|
||||
current_app.logger.info(f"Using endpoint ID: {endpoint_id}")
|
||||
current_app.logger.info(f"Using timeout: {stack_timeout} seconds")
|
||||
|
||||
# First, verify the stack exists
|
||||
# First, verify the stack exists and get its current configuration
|
||||
stack_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
|
||||
stack_response = requests.get(
|
||||
stack_url,
|
||||
@@ -1942,16 +1942,84 @@ def update_stack():
|
||||
stack_info = stack_response.json()
|
||||
current_app.logger.info(f"Found existing stack: {stack_info['Name']} (ID: {stack_info['Id']})")
|
||||
|
||||
# Get the current stack file content from Portainer
|
||||
stack_file_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}/file"
|
||||
stack_file_response = requests.get(
|
||||
stack_file_url,
|
||||
headers={
|
||||
'X-API-Key': portainer_settings['api_key'],
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
params={'endpointId': endpoint_id},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if not stack_file_response.ok:
|
||||
current_app.logger.error(f"Failed to get stack file: {stack_file_response.text}")
|
||||
return jsonify({'error': f'Failed to get existing stack file: {stack_file_response.text}'}), 500
|
||||
|
||||
stack_file_data = stack_file_response.json()
|
||||
current_stack_file_content = stack_file_data.get('StackFileContent')
|
||||
|
||||
if not current_stack_file_content:
|
||||
current_app.logger.error("No StackFileContent found in existing stack")
|
||||
return jsonify({'error': 'No existing stack file content found'}), 500
|
||||
|
||||
current_app.logger.info("Retrieved existing stack file content")
|
||||
|
||||
# Get existing environment variables from the stack
|
||||
existing_env_vars = stack_file_data.get('Env', [])
|
||||
current_app.logger.info(f"Retrieved {len(existing_env_vars)} existing environment variables")
|
||||
|
||||
# Create a dictionary of existing environment variables for easy lookup
|
||||
existing_env_dict = {env['name']: env['value'] for env in existing_env_vars}
|
||||
current_app.logger.info(f"Existing environment variables: {list(existing_env_dict.keys())}")
|
||||
|
||||
# Get new environment variables from the request
|
||||
new_env_vars = data.get('Env', [])
|
||||
current_app.logger.info(f"New environment variables to update: {[env['name'] for env in new_env_vars]}")
|
||||
|
||||
# Merge existing and new environment variables
|
||||
# Start with existing variables
|
||||
merged_env_vars = existing_env_vars.copy()
|
||||
|
||||
# Update with new variables (this will overwrite existing ones with the same name)
|
||||
for new_env in new_env_vars:
|
||||
# Find if this environment variable already exists
|
||||
existing_index = None
|
||||
for i, existing_env in enumerate(merged_env_vars):
|
||||
if existing_env['name'] == new_env['name']:
|
||||
existing_index = i
|
||||
break
|
||||
|
||||
if existing_index is not None:
|
||||
# Update existing variable
|
||||
merged_env_vars[existing_index]['value'] = new_env['value']
|
||||
current_app.logger.info(f"Updated environment variable: {new_env['name']} = {new_env['value']}")
|
||||
else:
|
||||
# Add new variable
|
||||
merged_env_vars.append(new_env)
|
||||
current_app.logger.info(f"Added new environment variable: {new_env['name']} = {new_env['value']}")
|
||||
|
||||
current_app.logger.info(f"Final merged environment variables: {[env['name'] for env in merged_env_vars]}")
|
||||
|
||||
# Update the stack using Portainer's update API
|
||||
update_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}/update"
|
||||
update_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
|
||||
current_app.logger.info(f"Making update request to: {update_url}")
|
||||
|
||||
# Prepare the request body for stack update
|
||||
request_body = {
|
||||
'StackFileContent': data['StackFileContent'],
|
||||
'Env': data.get('Env', [])
|
||||
'StackFileContent': current_stack_file_content, # Use existing stack file content
|
||||
'Env': merged_env_vars # Use merged environment variables
|
||||
}
|
||||
|
||||
# If new StackFileContent is provided, use it instead
|
||||
if 'StackFileContent' in data:
|
||||
request_body['StackFileContent'] = data['StackFileContent']
|
||||
current_app.logger.info("Using provided StackFileContent for update")
|
||||
else:
|
||||
current_app.logger.info("Using existing StackFileContent for update")
|
||||
|
||||
# Add endpointId as a query parameter
|
||||
params = {'endpointId': endpoint_id}
|
||||
|
||||
|
||||
288
routes/main.py
288
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
|
||||
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
|
||||
@@ -2267,6 +2326,13 @@ def init_routes(main_bp):
|
||||
@login_required
|
||||
@require_password_change
|
||||
def create_dns_records():
|
||||
"""
|
||||
Create or update DNS A records in Cloudflare.
|
||||
|
||||
Important: DNS records are created with proxied=False to avoid conflicts
|
||||
with NGINX Proxy Manager. This ensures direct DNS resolution without
|
||||
Cloudflare's proxy layer interfering with the NGINX configuration.
|
||||
"""
|
||||
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
@@ -2313,7 +2379,7 @@ def init_routes(main_bp):
|
||||
'name': domain,
|
||||
'content': cloudflare_settings['server_ip'],
|
||||
'ttl': 1, # Auto TTL
|
||||
'proxied': True
|
||||
'proxied': False # DNS only - no Cloudflare proxy to avoid conflicts with NGINX
|
||||
}
|
||||
|
||||
update_response = requests.put(
|
||||
@@ -2334,7 +2400,7 @@ def init_routes(main_bp):
|
||||
'name': domain,
|
||||
'content': cloudflare_settings['server_ip'],
|
||||
'ttl': 1, # Auto TTL
|
||||
'proxied': True
|
||||
'proxied': False # DNS only - no Cloudflare proxy to avoid conflicts with NGINX
|
||||
}
|
||||
|
||||
create_response = requests.post(
|
||||
@@ -2413,3 +2479,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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
/**
|
||||
* Launch Progress JavaScript
|
||||
*
|
||||
* This file handles the instance launch and update process, including:
|
||||
* - Step-by-step progress tracking
|
||||
* - Stack deployment via Portainer API
|
||||
* - Error handling for HTTP 502/504 responses
|
||||
*
|
||||
* Note: HTTP 502 (Bad Gateway) and 504 (Gateway Timeout) errors are treated as
|
||||
* potential success cases since they often occur when Portainer is busy but the
|
||||
* operation may still succeed. In these cases, the system continues monitoring
|
||||
* the stack status.
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check if this is an update operation
|
||||
if (window.isUpdate && window.updateInstanceId && window.updateRepoId && window.updateBranch) {
|
||||
@@ -561,6 +575,11 @@ async function startLaunch(data) {
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted" id="stackProgressText">Initiating stack deployment...</small>
|
||||
<div class="alert alert-info mt-2" style="font-size: 0.85em;">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
<strong>Note:</strong> Stack deployment can take several minutes. If you see HTTP 502 or 504 errors,
|
||||
the deployment may still be in progress and will be monitored automatically.
|
||||
</div>
|
||||
`;
|
||||
stackDeployStepElement.querySelector('.step-content').appendChild(stackProgressDiv);
|
||||
|
||||
@@ -1409,7 +1428,7 @@ async function startUpdate(data) {
|
||||
}
|
||||
|
||||
// Update the existing stack instead of creating a new one
|
||||
const stackResult = await updateStack(dockerComposeResult.content, instanceData.instance.portainer_stack_id, port);
|
||||
const stackResult = await updateStack(dockerComposeResult.content, instanceData.instance.portainer_stack_id, port, instanceData.instance);
|
||||
if (!stackResult.success) {
|
||||
throw new Error(`Failed to update stack: ${stackResult.error}`);
|
||||
}
|
||||
@@ -1811,6 +1830,13 @@ function updateStatus(step, message, type = 'info', details = '') {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NGINX proxy host for the specified domains.
|
||||
*
|
||||
* Important: Caching is disabled (caching_enabled: false) to ensure real-time
|
||||
* content delivery and avoid potential issues with cached responses interfering
|
||||
* with dynamic content or authentication.
|
||||
*/
|
||||
async function createProxyHost(domains, port, sslCertificateId) {
|
||||
try {
|
||||
// Get NGINX settings from the template
|
||||
@@ -2446,8 +2472,12 @@ async function checkInstanceHealth(instanceUrl) {
|
||||
|
||||
const data = await statusResponse.json();
|
||||
|
||||
// Update the health check step
|
||||
const healthStepElement = document.querySelectorAll('.step-item')[10]; // Adjust index based on your steps
|
||||
// Update the health check step - check if we're in update mode or launch mode
|
||||
const isUpdate = window.isUpdate;
|
||||
const healthStepIndex = isUpdate ? 4 : 10; // Different indices for update vs launch
|
||||
const healthStepElement = document.querySelectorAll('.step-item')[healthStepIndex];
|
||||
|
||||
if (healthStepElement) {
|
||||
healthStepElement.classList.remove('active');
|
||||
healthStepElement.classList.add('completed');
|
||||
const statusText = healthStepElement.querySelector('.step-status');
|
||||
@@ -2456,7 +2486,7 @@ async function checkInstanceHealth(instanceUrl) {
|
||||
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
||||
statusText.textContent = `Instance is healthy (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed)`;
|
||||
|
||||
// Update progress bar to 100%
|
||||
// Update progress bar to 100% if it exists
|
||||
const progressBar = document.getElementById('healthProgress');
|
||||
const progressText = document.getElementById('healthProgressText');
|
||||
if (progressBar && progressText) {
|
||||
@@ -2488,11 +2518,28 @@ async function checkInstanceHealth(instanceUrl) {
|
||||
} else {
|
||||
throw new Error('Instance is not healthy');
|
||||
}
|
||||
} else {
|
||||
// If step element doesn't exist, just log the status
|
||||
console.log(`Health check - Instance status: ${data.status}`);
|
||||
if (data.status === 'active') {
|
||||
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
||||
return {
|
||||
success: true,
|
||||
data: data,
|
||||
attempts: currentAttempt,
|
||||
elapsedTime: elapsedTime
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Health check attempt ${currentAttempt} failed:`, error);
|
||||
|
||||
// Update status to show current attempt and elapsed time
|
||||
const healthStepElement = document.querySelectorAll('.step-item')[10];
|
||||
// Update status to show current attempt and elapsed time - check if step element exists
|
||||
const isUpdate = window.isUpdate;
|
||||
const healthStepIndex = isUpdate ? 4 : 10;
|
||||
const healthStepElement = document.querySelectorAll('.step-item')[healthStepIndex];
|
||||
|
||||
if (healthStepElement) {
|
||||
const statusText = healthStepElement.querySelector('.step-status');
|
||||
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
||||
statusText.textContent = `Health check failed (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed): ${error.message}`;
|
||||
@@ -2506,10 +2553,14 @@ async function checkInstanceHealth(instanceUrl) {
|
||||
progressBar.textContent = `${Math.round(progressPercent)}%`;
|
||||
progressText.textContent = `Attempt ${currentAttempt}/${maxRetries} (${elapsedTime}s elapsed)`;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentAttempt === maxRetries || (Date.now() - startTime > maxTotalTime)) {
|
||||
// Update progress bar to show failure
|
||||
// Update progress bar to show failure if it exists
|
||||
const progressBar = document.getElementById('healthProgress');
|
||||
const progressText = document.getElementById('healthProgressText');
|
||||
if (progressBar && progressText) {
|
||||
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
||||
progressBar.classList.remove('progress-bar-animated');
|
||||
progressBar.classList.add('bg-danger');
|
||||
progressText.textContent = `Health check failed after ${currentAttempt} attempts (${elapsedTime}s)`;
|
||||
@@ -2517,7 +2568,7 @@ async function checkInstanceHealth(instanceUrl) {
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Health check failed after ${currentAttempt} attempts (${elapsedTime}s): ${error.message}`
|
||||
error: `Health check failed after ${currentAttempt} attempts (${Math.round((Date.now() - startTime) / 1000)}s): ${error.message}`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2525,7 +2576,8 @@ async function checkInstanceHealth(instanceUrl) {
|
||||
await new Promise(resolve => setTimeout(resolve, baseDelay));
|
||||
currentAttempt++;
|
||||
|
||||
// Update progress bar in real-time
|
||||
// Update progress bar in real-time if it exists
|
||||
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
||||
updateHealthProgress(currentAttempt, maxRetries, elapsedTime);
|
||||
}
|
||||
}
|
||||
@@ -2543,127 +2595,6 @@ function updateHealthProgress(currentAttempt, maxRetries, elapsedTime) {
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateInstance(instanceUrl, instanceId) {
|
||||
try {
|
||||
// First check if instance is already authenticated
|
||||
const instancesResponse = await fetch('/instances');
|
||||
const text = await instancesResponse.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(text, 'text/html');
|
||||
|
||||
// Find the instance with matching URL
|
||||
const instanceRow = Array.from(doc.querySelectorAll('table tbody tr')).find(row => {
|
||||
const urlCell = row.querySelector('td:nth-child(7) a'); // URL is in the 7th column
|
||||
return urlCell && urlCell.textContent.trim() === instanceUrl;
|
||||
});
|
||||
|
||||
if (!instanceRow) {
|
||||
throw new Error('Instance not found in database');
|
||||
}
|
||||
|
||||
// Get the instance ID from the status badge's data attribute
|
||||
const statusBadge = instanceRow.querySelector('[data-instance-id]');
|
||||
if (!statusBadge) {
|
||||
throw new Error('Could not find instance ID');
|
||||
}
|
||||
|
||||
const dbInstanceId = statusBadge.dataset.instanceId;
|
||||
|
||||
// Check if already authenticated
|
||||
const authStatusResponse = await fetch(`/instances/${dbInstanceId}/auth-status`);
|
||||
if (!authStatusResponse.ok) {
|
||||
throw new Error('Failed to check authentication status');
|
||||
}
|
||||
|
||||
const authStatus = await authStatusResponse.json();
|
||||
if (authStatus.authenticated) {
|
||||
console.log('Instance is already authenticated');
|
||||
return {
|
||||
success: true,
|
||||
message: 'Instance is already authenticated',
|
||||
alreadyAuthenticated: true
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Attempting login to:', `${instanceUrl}/api/admin/login`);
|
||||
|
||||
// First login to get token
|
||||
const loginResponse = await fetch(`${instanceUrl}/api/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: 'administrator@docupulse.com',
|
||||
password: 'changeme'
|
||||
})
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
const errorText = await loginResponse.text();
|
||||
throw new Error(`Login failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const loginData = await loginResponse.json();
|
||||
if (loginData.status !== 'success' || !loginData.token) {
|
||||
throw new Error('Login failed: Invalid response from server');
|
||||
}
|
||||
|
||||
const token = loginData.token;
|
||||
|
||||
// Then create management API key
|
||||
const keyResponse = await fetch(`${instanceUrl}/api/admin/management-api-key`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `Connection from ${window.location.hostname}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!keyResponse.ok) {
|
||||
const errorText = await keyResponse.text();
|
||||
throw new Error(`Failed to create API key: ${errorText}`);
|
||||
}
|
||||
|
||||
const keyData = await keyResponse.json();
|
||||
if (!keyData.api_key) {
|
||||
throw new Error('No API key received from server');
|
||||
}
|
||||
|
||||
// Save the token to our database
|
||||
const saveResponse = await fetch(`/instances/${dbInstanceId}/save-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({ token: keyData.api_key })
|
||||
});
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
const errorText = await saveResponse.text();
|
||||
throw new Error(`Failed to save token: ${errorText}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Successfully authenticated instance',
|
||||
alreadyAuthenticated: false
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCompanyInformation(instanceUrl, company) {
|
||||
try {
|
||||
console.log('Applying company information to:', instanceUrl);
|
||||
@@ -3019,6 +2950,15 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
||||
console.log('Response ok:', response.ok);
|
||||
console.log('Response headers:', Object.fromEntries(response.headers.entries()));
|
||||
|
||||
// Log additional response details for debugging
|
||||
if (response.status === 502) {
|
||||
console.log('HTTP 502 Bad Gateway detected - this usually means Portainer is busy or slow to respond');
|
||||
console.log('The stack deployment may still be in progress despite this error');
|
||||
} else if (response.status === 504) {
|
||||
console.log('HTTP 504 Gateway Timeout detected - the request took too long');
|
||||
console.log('This is expected for long-running stack deployments');
|
||||
}
|
||||
|
||||
// Handle 504 Gateway Timeout as successful initiation
|
||||
if (response.status === 504) {
|
||||
console.log('Received 504 Gateway Timeout - stack creation may still be in progress');
|
||||
@@ -3042,6 +2982,29 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
||||
let errorMessage = 'Failed to deploy stack';
|
||||
console.log('Response not ok, status:', response.status);
|
||||
|
||||
// Handle 502 Bad Gateway and 504 Gateway Timeout as potential success cases
|
||||
// These errors often occur when Portainer is busy or slow to respond, but the operation may still succeed
|
||||
if (response.status === 502 || response.status === 504) {
|
||||
console.log(`Received HTTP ${response.status} - stack creation may still be in progress`);
|
||||
console.log('HTTP 502 (Bad Gateway) typically means Portainer is busy or slow to respond');
|
||||
console.log('HTTP 504 (Gateway Timeout) means the request took too long');
|
||||
console.log('In both cases, we continue monitoring since the operation may still succeed');
|
||||
|
||||
// Update progress to show that we're now polling
|
||||
const progressBar = document.getElementById('launchProgress');
|
||||
const progressText = document.getElementById('stepDescription');
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = '25%';
|
||||
progressBar.textContent = '25%';
|
||||
progressText.textContent = `Stack creation initiated (HTTP ${response.status}, but continuing to monitor)...`;
|
||||
}
|
||||
|
||||
// Start polling immediately since the stack creation was initiated
|
||||
console.log(`Starting to poll for stack status after HTTP ${response.status}...`);
|
||||
const pollResult = await pollStackStatus(stackName, 15 * 60 * 1000); // 15 minutes max
|
||||
return pollResult;
|
||||
}
|
||||
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
@@ -3099,13 +3062,15 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
||||
} catch (error) {
|
||||
console.error('Error deploying stack:', error);
|
||||
|
||||
// Check if this is a 504 timeout error that should be handled as a success
|
||||
// Check if this is a 502 or 504 error that should be handled as a success
|
||||
if (error.message && (
|
||||
error.message.includes('504 Gateway Time-out') ||
|
||||
error.message.includes('504 Gateway Timeout') ||
|
||||
error.message.includes('timed out')
|
||||
error.message.includes('timed out') ||
|
||||
error.message.includes('502') ||
|
||||
error.message.includes('Bad Gateway')
|
||||
)) {
|
||||
console.log('Detected 504 timeout in catch block - treating as successful initiation');
|
||||
console.log('Detected HTTP 502/504 error in catch block - treating as successful initiation');
|
||||
|
||||
// Update progress to show that we're now polling
|
||||
const progressBar = document.getElementById('launchProgress');
|
||||
@@ -3113,11 +3078,11 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = '25%';
|
||||
progressBar.textContent = '25%';
|
||||
progressText.textContent = 'Stack creation initiated (timed out, but continuing to monitor)...';
|
||||
progressText.textContent = 'Stack creation initiated (HTTP error, but continuing to monitor)...';
|
||||
}
|
||||
|
||||
// Start polling immediately since the stack creation was initiated
|
||||
console.log('Starting to poll for stack status after 504 timeout from catch block...');
|
||||
console.log('Starting to poll for stack status after HTTP 502/504 error from catch block...');
|
||||
const pollResult = await pollStackStatus(stackName, 15 * 60 * 1000); // 15 minutes max
|
||||
return pollResult;
|
||||
}
|
||||
@@ -3303,30 +3268,16 @@ function generateStackName(port) {
|
||||
}
|
||||
|
||||
// Add new function to update existing stack
|
||||
async function updateStack(dockerComposeContent, stackId, port) {
|
||||
async function updateStack(dockerComposeContent, stackId, port, instanceData = null) {
|
||||
try {
|
||||
console.log('Updating existing stack:', stackId);
|
||||
console.log('Port:', port);
|
||||
console.log('Modified docker-compose content length:', dockerComposeContent.length);
|
||||
|
||||
const response = await fetch('/api/admin/update-stack', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stack_id: stackId,
|
||||
StackFileContent: dockerComposeContent,
|
||||
Env: [
|
||||
{
|
||||
name: 'PORT',
|
||||
value: port.toString()
|
||||
},
|
||||
{
|
||||
name: 'ISMASTER',
|
||||
value: 'false'
|
||||
},
|
||||
// For updates, we only need to update version-related environment variables
|
||||
// All other environment variables (pricing tiers, quotas, etc.) should be preserved
|
||||
// We also preserve the existing docker-compose configuration
|
||||
const envVars = [
|
||||
{
|
||||
name: 'APP_VERSION',
|
||||
value: window.currentDeploymentVersion || 'unknown'
|
||||
@@ -3343,7 +3294,21 @@ async function updateStack(dockerComposeContent, stackId, port) {
|
||||
name: 'DEPLOYED_AT',
|
||||
value: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
console.log('Updating stack with version environment variables:', envVars);
|
||||
console.log('Preserving existing docker-compose configuration');
|
||||
|
||||
const response = await fetch('/api/admin/update-stack', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stack_id: stackId,
|
||||
// Don't send StackFileContent during updates - preserve existing configuration
|
||||
Env: envVars
|
||||
})
|
||||
});
|
||||
|
||||
@@ -3374,6 +3339,25 @@ async function updateStack(dockerComposeContent, stackId, port) {
|
||||
let errorMessage = 'Failed to update stack';
|
||||
console.log('Response not ok, status:', response.status);
|
||||
|
||||
// Handle 502 Bad Gateway and 504 Gateway Timeout as potential success cases
|
||||
if (response.status === 502 || response.status === 504) {
|
||||
console.log(`Received HTTP ${response.status} - stack update may still be in progress`);
|
||||
|
||||
// Update progress to show that we're now polling
|
||||
const progressBar = document.getElementById('launchProgress');
|
||||
const progressText = document.getElementById('stepDescription');
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = '25%';
|
||||
progressBar.textContent = '25%';
|
||||
progressText.textContent = `Stack update initiated (HTTP ${response.status}, but continuing to monitor)...`;
|
||||
}
|
||||
|
||||
// Start polling immediately since the stack update was initiated
|
||||
console.log(`Starting to poll for stack status after HTTP ${response.status}...`);
|
||||
const pollResult = await pollStackStatus(stackId, 15 * 60 * 1000); // 15 minutes max
|
||||
return pollResult;
|
||||
}
|
||||
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
@@ -3431,13 +3415,15 @@ async function updateStack(dockerComposeContent, stackId, port) {
|
||||
} catch (error) {
|
||||
console.error('Error updating stack:', error);
|
||||
|
||||
// Check if this is a 504 timeout error that should be handled as a success
|
||||
// Check if this is a 502 or 504 error that should be handled as a success
|
||||
if (error.message && (
|
||||
error.message.includes('504 Gateway Time-out') ||
|
||||
error.message.includes('504 Gateway Timeout') ||
|
||||
error.message.includes('timed out')
|
||||
error.message.includes('timed out') ||
|
||||
error.message.includes('502') ||
|
||||
error.message.includes('Bad Gateway')
|
||||
)) {
|
||||
console.log('Detected 504 timeout in catch block - treating as successful initiation');
|
||||
console.log('Detected HTTP 502/504 error in catch block - treating as successful initiation');
|
||||
|
||||
// Update progress to show that we're now polling
|
||||
const progressBar = document.getElementById('launchProgress');
|
||||
@@ -3445,11 +3431,11 @@ async function updateStack(dockerComposeContent, stackId, port) {
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = '25%';
|
||||
progressBar.textContent = '25%';
|
||||
progressText.textContent = 'Stack update initiated (timed out, but continuing to monitor)...';
|
||||
progressText.textContent = 'Stack update initiated (HTTP error, but continuing to monitor)...';
|
||||
}
|
||||
|
||||
// Start polling immediately since the stack update was initiated
|
||||
console.log('Starting to poll for stack status after 504 timeout from catch block...');
|
||||
console.log('Starting to poll for stack status after HTTP 502/504 error from catch block...');
|
||||
const pollResult = await pollStackStatus(stackId, 15 * 60 * 1000); // 15 minutes max
|
||||
return pollResult;
|
||||
}
|
||||
@@ -3460,3 +3446,124 @@ async function updateStack(dockerComposeContent, stackId, port) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateInstance(instanceUrl, instanceId) {
|
||||
try {
|
||||
// First check if instance is already authenticated
|
||||
const instancesResponse = await fetch('/instances');
|
||||
const text = await instancesResponse.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(text, 'text/html');
|
||||
|
||||
// Find the instance with matching URL
|
||||
const instanceRow = Array.from(doc.querySelectorAll('table tbody tr')).find(row => {
|
||||
const urlCell = row.querySelector('td:nth-child(7) a'); // URL is in the 7th column
|
||||
return urlCell && urlCell.textContent.trim() === instanceUrl;
|
||||
});
|
||||
|
||||
if (!instanceRow) {
|
||||
throw new Error('Instance not found in database');
|
||||
}
|
||||
|
||||
// Get the instance ID from the status badge's data attribute
|
||||
const statusBadge = instanceRow.querySelector('[data-instance-id]');
|
||||
if (!statusBadge) {
|
||||
throw new Error('Could not find instance ID');
|
||||
}
|
||||
|
||||
const dbInstanceId = statusBadge.dataset.instanceId;
|
||||
|
||||
// Check if already authenticated
|
||||
const authStatusResponse = await fetch(`/instances/${dbInstanceId}/auth-status`);
|
||||
if (!authStatusResponse.ok) {
|
||||
throw new Error('Failed to check authentication status');
|
||||
}
|
||||
|
||||
const authStatus = await authStatusResponse.json();
|
||||
if (authStatus.authenticated) {
|
||||
console.log('Instance is already authenticated');
|
||||
return {
|
||||
success: true,
|
||||
message: 'Instance is already authenticated',
|
||||
alreadyAuthenticated: true
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Attempting login to:', `${instanceUrl}/api/admin/login`);
|
||||
|
||||
// First login to get token
|
||||
const loginResponse = await fetch(`${instanceUrl}/api/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: 'administrator@docupulse.com',
|
||||
password: 'changeme'
|
||||
})
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
const errorText = await loginResponse.text();
|
||||
throw new Error(`Login failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const loginData = await loginResponse.json();
|
||||
if (loginData.status !== 'success' || !loginData.token) {
|
||||
throw new Error('Login failed: Invalid response from server');
|
||||
}
|
||||
|
||||
const token = loginData.token;
|
||||
|
||||
// Then create management API key
|
||||
const keyResponse = await fetch(`${instanceUrl}/api/admin/management-api-key`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `Connection from ${window.location.hostname}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!keyResponse.ok) {
|
||||
const errorText = await keyResponse.text();
|
||||
throw new Error(`Failed to create API key: ${errorText}`);
|
||||
}
|
||||
|
||||
const keyData = await keyResponse.json();
|
||||
if (!keyData.api_key) {
|
||||
throw new Error('No API key received from server');
|
||||
}
|
||||
|
||||
// Save the token to our database
|
||||
const saveResponse = await fetch(`/instances/${dbInstanceId}/save-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({ token: keyData.api_key })
|
||||
});
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
const errorText = await saveResponse.text();
|
||||
throw new Error(`Failed to save token: ${errorText}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Successfully authenticated instance',
|
||||
alreadyAuthenticated: false
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,7 +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('editButtonUrl').value = plan.button_url;
|
||||
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">
|
||||
@@ -47,9 +60,28 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a href="{{ plan.button_url }}" class="btn {% if plan.is_popular %}btn-primary{% else %}btn-outline-primary{% endif %} btn-lg w-100 mt-auto px-4 py-3">
|
||||
<!-- Dynamic Payment Button -->
|
||||
{% if plan.is_custom %}
|
||||
<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 }}
|
||||
</a>
|
||||
{% else %}
|
||||
{% 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 }}
|
||||
</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 }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,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>
|
||||
@@ -107,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>
|
||||
@@ -129,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>
|
||||
@@ -179,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');
|
||||
@@ -229,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
|
||||
@@ -242,6 +377,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Simply animate the number change
|
||||
animateNumber(price, monthlyValue, annualValue);
|
||||
});
|
||||
|
||||
// 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 {
|
||||
// Switch to monthly prices with animation
|
||||
monthlyPrices.forEach((price, index) => {
|
||||
@@ -251,8 +393,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Simply animate the number change back to monthly
|
||||
animateNumber(price, currentValue, originalMonthlyValue);
|
||||
});
|
||||
|
||||
// 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>
|
||||
@@ -79,7 +79,7 @@
|
||||
Infrastructure Tools
|
||||
</h5>
|
||||
<div class="row g-3 infrastructure-tools">
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<a href="{{ portainer_settings.url if portainer_settings and portainer_settings.url else '#' }}"
|
||||
target="_blank"
|
||||
class="btn btn-outline-primary w-100 h-100 d-flex flex-column align-items-center justify-content-center p-3"
|
||||
@@ -90,7 +90,7 @@
|
||||
<small class="text-muted">Container Management</small>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<a href="{{ nginx_settings.url if nginx_settings and nginx_settings.url else '#' }}"
|
||||
target="_blank"
|
||||
class="btn btn-outline-success w-100 h-100 d-flex flex-column align-items-center justify-content-center p-3"
|
||||
@@ -101,7 +101,7 @@
|
||||
<small class="text-muted">Reverse Proxy & SSL</small>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<a href="{{ git_settings.url if git_settings and git_settings.url else '#' }}"
|
||||
target="_blank"
|
||||
class="btn btn-outline-info w-100 h-100 d-flex flex-column align-items-center justify-content-center p-3"
|
||||
@@ -112,7 +112,7 @@
|
||||
<small class="text-muted">Code Repository</small>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<a href="https://dash.cloudflare.com"
|
||||
target="_blank"
|
||||
class="btn btn-outline-warning w-100 h-100 d-flex flex-column align-items-center justify-content-center p-3"
|
||||
@@ -122,6 +122,16 @@
|
||||
<small class="text-muted">DNS & CDN</small>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<a href="https://dashboard.stripe.com"
|
||||
target="_blank"
|
||||
class="btn btn-outline-purple w-100 h-100 d-flex flex-column align-items-center justify-content-center p-3"
|
||||
aria-label="Open Stripe Dashboard">
|
||||
<i class="fab fa-stripe fa-2x mb-2"></i>
|
||||
<span class="fw-bold">Stripe</span>
|
||||
<small class="text-muted">Payment & Billing</small>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if not portainer_settings or not portainer_settings.url or not nginx_settings or not nginx_settings.url or not git_settings or not git_settings.url %}
|
||||
<div class="mt-3">
|
||||
|
||||
@@ -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,6 +53,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>Stripe Integration:</strong>
|
||||
<div class="mt-2">
|
||||
{% if plan.stripe_product_id %}
|
||||
<div class="mb-1">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-tag me-1"></i>Product ID:
|
||||
<code class="text-primary">{{ plan.stripe_product_id }}</code>
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plan.stripe_monthly_price_id %}
|
||||
<div class="mb-1">
|
||||
<small class="text-muted">
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Features:</strong>
|
||||
<ul class="list-unstyled mt-2">
|
||||
@@ -62,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"
|
||||
@@ -220,11 +249,29 @@
|
||||
value="Get Started">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
</div>
|
||||
|
||||
<!-- Stripe Product/Price IDs Section -->
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="buttonUrl" class="form-label">Button URL</label>
|
||||
<input type="text" class="form-control" id="buttonUrl" name="button_url"
|
||||
value="#">
|
||||
<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-4">
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
@@ -367,10 +414,29 @@
|
||||
<input type="text" class="form-control" id="editButtonText" name="button_text">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
</div>
|
||||
|
||||
<!-- Stripe Product/Price IDs Section -->
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="editButtonUrl" class="form-label">Button URL</label>
|
||||
<input type="text" class="form-control" id="editButtonUrl" name="button_url">
|
||||
<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-4">
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
|
||||
53
timespent.py
Normal file
53
timespent.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Run git log command
|
||||
log_output = subprocess.check_output(
|
||||
['git', 'log', '--pretty=format:%h %an %ad', '--date=iso'],
|
||||
text=True
|
||||
)
|
||||
|
||||
# Parse commit dates
|
||||
commit_times = []
|
||||
for line in log_output.splitlines():
|
||||
parts = line.strip().split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
# Commit hash, author, datetime string
|
||||
dt_str = " ".join(parts[2:4]) # "YYYY-MM-DD HH:MM:SS"
|
||||
try:
|
||||
dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S")
|
||||
commit_times.append(dt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Sort commits chronologically
|
||||
commit_times.sort()
|
||||
|
||||
# Session grouping (commits < 1 hour apart are same session)
|
||||
SESSION_GAP = timedelta(hours=1)
|
||||
sessions = []
|
||||
if commit_times:
|
||||
start = commit_times[0]
|
||||
prev = commit_times[0]
|
||||
|
||||
for t in commit_times[1:]:
|
||||
if t - prev > SESSION_GAP:
|
||||
# Close previous session
|
||||
sessions.append((start, prev))
|
||||
start = t
|
||||
prev = t
|
||||
sessions.append((start, prev)) # last session
|
||||
|
||||
# Estimate durations
|
||||
total_time = timedelta()
|
||||
for start, end in sessions:
|
||||
duration = end - start
|
||||
# Add a minimum session length (e.g. 30 min) so single commits aren’t near-zero
|
||||
if duration < timedelta(minutes=30):
|
||||
duration = timedelta(minutes=30)
|
||||
total_time += duration
|
||||
|
||||
print(f"Number of commits: {len(commit_times)}")
|
||||
print(f"Number of sessions: {len(sessions)}")
|
||||
print(f"Estimated total coding time: {total_time} (~{total_time.total_seconds()/3600:.1f} hours)")
|
||||
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