6 Commits

Author SHA1 Message Date
d0d436d116 Create timespent.py 2025-08-26 17:57:33 +02:00
36da0717a2 better launch management caching 2025-08-26 09:02:29 +02:00
8a622334d0 handle 502 errors on launch 2025-08-26 08:52:54 +02:00
b1da4977d3 Update instances.html 2025-06-26 15:24:40 +02:00
9b85f3bb8d started implementing stripe 2025-06-26 15:15:16 +02:00
3a0659b63b added payment links to prices 2025-06-25 17:14:03 +02:00
29 changed files with 2282 additions and 68 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -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

View File

@@ -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')

View 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')

View File

@@ -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')

View File

@@ -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')

View File

@@ -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}>'

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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);
@@ -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
@@ -2924,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');
@@ -2947,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;
@@ -3004,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');
@@ -3018,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;
}
@@ -3279,6 +3339,25 @@ async function updateStack(dockerComposeContent, stackId, port, instanceData = n
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;
@@ -3336,13 +3415,15 @@ async function updateStack(dockerComposeContent, stackId, port, instanceData = n
} 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');
@@ -3350,11 +3431,11 @@ async function updateStack(dockerComposeContent, stackId, port, instanceData = n
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;
}

View File

@@ -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'));

View File

@@ -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;

View 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 %}

View 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 %}

View File

@@ -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 -->

View 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>

View File

@@ -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') }}">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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
View 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 arent 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
View 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)}'
}