diff --git a/PRICING_CONFIGURATION.md b/PRICING_CONFIGURATION.md new file mode 100644 index 0000000..2ff76a5 --- /dev/null +++ b/PRICING_CONFIGURATION.md @@ -0,0 +1,246 @@ +# Pricing Configuration Feature + +This document describes the new configurable pricing feature that allows MASTER instances to manage pricing plans through the admin interface. + +## Overview + +The pricing configuration feature allows administrators on MASTER instances to: +- Create, edit, and delete pricing plans +- Configure plan features, prices, and settings +- Set resource quotas for rooms, conversations, storage, and users +- Mark plans as "Most Popular" or "Custom" +- Control plan visibility and ordering +- Update pricing without code changes + +## Features + +### Pricing Plan Management +- **Plan Name**: Display name for the pricing plan +- **Description**: Optional description shown below the plan name +- **Pricing**: Monthly and annual prices (annual prices are typically 20% lower) +- **Features**: Dynamic list of features with checkmarks +- **Button Configuration**: Customizable button text and URL +- **Plan Types**: Regular plans with prices or custom plans +- **Popular Plans**: Mark one plan as "Most Popular" with special styling +- **Active/Inactive**: Toggle plan visibility +- **Ordering**: Control the display order of plans + +### Resource Quotas +- **Room Quota**: Maximum number of rooms allowed (0 = unlimited) +- **Conversation Quota**: Maximum number of conversations allowed (0 = unlimited) +- **Storage Quota**: Maximum storage in gigabytes (0 = unlimited) +- **Manager Quota**: Maximum number of manager users allowed (0 = unlimited) +- **Admin Quota**: Maximum number of admin users allowed (0 = unlimited) + +### Admin Interface +- **Pricing Tab**: New tab in admin settings (MASTER instances only) +- **Add/Edit Modals**: User-friendly forms for plan management +- **Real-time Updates**: Changes are reflected immediately +- **Feature Management**: Add/remove features dynamically +- **Quota Configuration**: Set resource limits for each plan +- **Status Toggles**: Quick switches for plan properties + +## Setup Instructions + +### 1. Database Migration +Run the migrations to create the pricing_plans table and add quota fields: + +```bash +# Apply the migrations +alembic upgrade head +``` + +### 2. Initialize Default Plans (Optional) +Run the initialization script to create default pricing plans: + +```bash +# Set MASTER environment variable +export MASTER=true + +# Run the initialization script +python init_pricing_plans.py +``` + +This will create four default plans with quotas: +- **Starter**: 5 rooms, 10 conversations, 10GB storage, 10 managers, 1 admin +- **Professional**: 25 rooms, 50 conversations, 100GB storage, 50 managers, 3 admins +- **Enterprise**: 100 rooms, 200 conversations, 500GB storage, 200 managers, 10 admins +- **Custom**: Unlimited everything + +### 3. Access Admin Interface +1. Log in as an admin user on a MASTER instance +2. Go to Settings +3. Click on the "Pricing" tab +4. Configure your pricing plans + +## Usage + +### Creating a New Plan +1. Click "Add New Plan" in the pricing tab +2. Fill in the plan details: + - **Name**: Plan display name + - **Description**: Optional description + - **Monthly Price**: Price per month + - **Annual Price**: Price per month when billed annually + - **Quotas**: Set resource limits (0 = unlimited) + - **Features**: Add features using the "Add Feature" button + - **Button Text**: Text for the call-to-action button + - **Button URL**: URL the button should link to + - **Options**: Check "Most Popular", "Custom Plan", or "Active" as needed +3. Click "Create Plan" + +### Editing a Plan +1. Click the "Edit" button on any plan card +2. Modify the plan details in the modal +3. Click "Update Plan" + +### Managing Plan Status +- **Active/Inactive**: Use the toggle switch in the plan header +- **Most Popular**: Check the "Most Popular" checkbox (only one plan can be popular) +- **Custom Plan**: Check "Custom Plan" for plans without fixed pricing + +### Deleting a Plan +1. Click the "Delete" button on a plan card +2. Confirm the deletion in the modal + +## Technical Details + +### Database Schema +The `pricing_plans` table includes: +- `id`: Primary key +- `name`: Plan name (required) +- `description`: Optional description +- `monthly_price`: Monthly price (float) +- `annual_price`: Annual price (float) +- `features`: JSON array of feature strings +- `button_text`: Button display text +- `button_url`: Button link URL +- `is_popular`: Boolean for "Most Popular" styling +- `is_custom`: Boolean for custom plans +- `is_active`: Boolean for plan visibility +- `order_index`: Integer for display ordering +- `room_quota`: Maximum rooms (0 = unlimited) +- `conversation_quota`: Maximum conversations (0 = unlimited) +- `storage_quota_gb`: Maximum storage in GB (0 = unlimited) +- `manager_quota`: Maximum managers (0 = unlimited) +- `admin_quota`: Maximum admins (0 = unlimited) +- `created_by`: Foreign key to user who created the plan +- `created_at`/`updated_at`: Timestamps + +### API Endpoints +- `POST /api/admin/pricing-plans` - Create new plan +- `GET /api/admin/pricing-plans/` - Get plan details +- `PUT /api/admin/pricing-plans/` - Update plan +- `DELETE /api/admin/pricing-plans/` - Delete plan +- `PATCH /api/admin/pricing-plans//status` - Update plan status + +### Template Integration +The pricing page automatically uses configured plans: +- Falls back to hardcoded plans if no plans are configured +- Supports dynamic feature lists +- Handles custom plans without pricing +- Shows/hides billing toggle based on plan types +- Displays quota information in plan cards + +### Quota Enforcement +The PricingPlan model includes utility methods for quota checking: +- `check_quota(quota_type, current_count)`: Returns True if quota allows the operation +- `get_quota_remaining(quota_type, current_count)`: Returns remaining quota +- `format_quota_display(quota_type)`: Formats quota for display +- `get_storage_quota_bytes()`: Converts GB to bytes for storage calculations + +Example usage in your application: +```python +# Check if user can create a new room +plan = PricingPlan.query.get(user_plan_id) +current_rooms = Room.query.filter_by(created_by=user.id).count() +if plan.check_quota('room_quota', current_rooms): + # Allow room creation + pass +else: + # Show upgrade message + pass +``` + +## Security + +- Only admin users can access pricing configuration +- Only MASTER instances can configure pricing +- All API endpoints require authentication and admin privileges +- CSRF protection is enabled for all forms + +## Customization + +### Styling +The pricing plans use the existing CSS variables: +- `--primary-color`: Main brand color +- `--secondary-color`: Secondary brand color +- `--shadow-color`: Card shadows + +### Button URLs +Configure button URLs to point to: +- Contact forms +- Payment processors +- Sales pages +- Custom landing pages + +### Features +Features can include: +- Storage limits +- User limits +- Feature availability +- Support levels +- Integration options + +### Quota Integration +To integrate quotas into your application: + +1. **User Plan Assignment**: Associate users with pricing plans +2. **Quota Checking**: Use the `check_quota()` method before operations +3. **Upgrade Prompts**: Show upgrade messages when quotas are exceeded +4. **Usage Tracking**: Track current usage for quota calculations + +## Troubleshooting + +### Common Issues + +1. **Pricing tab not visible** + - Ensure you're on a MASTER instance (`MASTER=true`) + - Ensure you're logged in as an admin user + +2. **Plans not showing on pricing page** + - Check that plans are marked as "Active" + - Verify the database migration was applied + - Check for JavaScript errors in browser console + +3. **Features not saving** + - Ensure at least one feature is provided + - Check that feature text is not empty + +4. **Quota fields not working** + - Verify the quota migration was applied + - Check that quota values are integers + - Ensure quota fields are included in form submissions + +5. **API errors** + - Verify CSRF token is included in requests + - Check that all required fields are provided + - Ensure proper JSON formatting for features + +### Debugging +- Check browser console for JavaScript errors +- Review server logs for API errors +- Verify database connectivity +- Test with default plans first + +## Future Enhancements + +Potential future improvements: +- Plan categories/tiers +- Regional pricing +- Currency support +- Promotional pricing +- Plan comparison features +- Analytics and usage tracking +- Automatic quota enforcement middleware +- Usage dashboard for quota monitoring \ No newline at end of file diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc index 70d7675..5ad7012 100644 Binary files a/__pycache__/models.cpython-313.pyc and b/__pycache__/models.cpython-313.pyc differ diff --git a/init_pricing_plans.py b/init_pricing_plans.py new file mode 100644 index 0000000..b353a04 --- /dev/null +++ b/init_pricing_plans.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +Script to initialize default pricing plans in the database. +This should be run on a MASTER instance only. +""" + +import os +import sys +from app import app, db +from models import PricingPlan, User + +def init_pricing_plans(): + """Initialize default pricing plans""" + + # Check if this is a MASTER instance + if os.environ.get('MASTER', 'false').lower() != 'true': + print("Error: This script should only be run on a MASTER instance.") + print("Set MASTER=true environment variable to run this script.") + sys.exit(1) + + with app.app_context(): + # Check if pricing plans already exist + existing_plans = PricingPlan.query.count() + if existing_plans > 0: + print(f"Found {existing_plans} existing pricing plans. Skipping initialization.") + return + + # Get the first admin user + admin_user = User.query.filter_by(is_admin=True).first() + if not admin_user: + print("Error: No admin user found. Please create an admin user first.") + sys.exit(1) + + # Default pricing plans + default_plans = [ + { + 'name': 'Starter', + 'description': 'Perfect for small teams getting started', + 'monthly_price': 29.0, + 'annual_price': 23.0, + 'features': [ + 'Up to 5 rooms', + 'Up to 10 conversations', + '10GB storage', + 'Up to 10 managers', + 'Email support' + ], + 'button_text': 'Get Started', + 'button_url': '#', + 'is_popular': False, + 'is_custom': False, + 'is_active': True, + 'order_index': 1, + 'room_quota': 5, + 'conversation_quota': 10, + 'storage_quota_gb': 10, + 'manager_quota': 10, + 'admin_quota': 1 + }, + { + 'name': 'Professional', + 'description': 'Ideal for growing businesses', + 'monthly_price': 99.0, + 'annual_price': 79.0, + 'features': [ + 'Up to 25 rooms', + 'Up to 50 conversations', + '100GB storage', + 'Up to 50 managers', + 'Priority support' + ], + 'button_text': 'Get Started', + 'button_url': '#', + 'is_popular': True, + 'is_custom': False, + 'is_active': True, + 'order_index': 2, + 'room_quota': 25, + 'conversation_quota': 50, + 'storage_quota_gb': 100, + 'manager_quota': 50, + 'admin_quota': 3 + }, + { + 'name': 'Enterprise', + 'description': 'For large organizations with advanced needs', + 'monthly_price': 299.0, + 'annual_price': 239.0, + 'features': [ + 'Up to 100 rooms', + 'Up to 200 conversations', + '500GB storage', + 'Up to 200 managers', + '24/7 dedicated support' + ], + 'button_text': 'Get Started', + 'button_url': '#', + 'is_popular': False, + 'is_custom': False, + 'is_active': True, + 'order_index': 3, + 'room_quota': 100, + 'conversation_quota': 200, + 'storage_quota_gb': 500, + 'manager_quota': 200, + 'admin_quota': 10 + }, + { + 'name': 'Custom', + 'description': 'Tailored solutions for enterprise customers', + 'monthly_price': 0.0, + 'annual_price': 0.0, + 'features': [ + 'Unlimited rooms', + 'Unlimited conversations', + 'Unlimited storage', + 'Unlimited users', + 'Custom integrations', + 'Dedicated account manager' + ], + 'button_text': 'Contact Sales', + 'button_url': '#', + 'is_popular': False, + 'is_custom': True, + 'is_active': True, + 'order_index': 4, + 'room_quota': 0, + 'conversation_quota': 0, + 'storage_quota_gb': 0, + 'manager_quota': 0, + 'admin_quota': 0 + } + ] + + # Create pricing plans + for plan_data in default_plans: + plan = PricingPlan( + name=plan_data['name'], + description=plan_data['description'], + monthly_price=plan_data['monthly_price'], + annual_price=plan_data['annual_price'], + features=plan_data['features'], + button_text=plan_data['button_text'], + button_url=plan_data['button_url'], + is_popular=plan_data['is_popular'], + is_custom=plan_data['is_custom'], + is_active=plan_data['is_active'], + order_index=plan_data['order_index'], + room_quota=plan_data['room_quota'], + conversation_quota=plan_data['conversation_quota'], + storage_quota_gb=plan_data['storage_quota_gb'], + manager_quota=plan_data['manager_quota'], + admin_quota=plan_data['admin_quota'], + created_by=admin_user.id + ) + db.session.add(plan) + + try: + db.session.commit() + print("Successfully created default pricing plans:") + for plan_data in default_plans: + print(f" - {plan_data['name']}: €{plan_data['monthly_price']}/month") + print("\nYou can now configure these plans in the admin settings.") + except Exception as e: + db.session.rollback() + print(f"Error creating pricing plans: {e}") + sys.exit(1) + +if __name__ == '__main__': + init_pricing_plans() \ No newline at end of file diff --git a/migrations/versions/add_pricing_plans_table.py b/migrations/versions/add_pricing_plans_table.py new file mode 100644 index 0000000..8abb32c --- /dev/null +++ b/migrations/versions/add_pricing_plans_table.py @@ -0,0 +1,62 @@ +"""add pricing plans table + +Revision ID: add_pricing_plans_table +Revises: add_help_articles_table +Create Date: 2024-12-19 11:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + + +# revision identifiers, used by Alembic. +revision = 'add_pricing_plans_table' +down_revision = 'add_help_articles_table' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + result = conn.execute(text(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'pricing_plans' + ); + """)) + exists = result.scalar() + if not exists: + op.create_table('pricing_plans', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('monthly_price', sa.Float(), nullable=False), + sa.Column('annual_price', sa.Float(), nullable=False), + sa.Column('features', sa.JSON(), nullable=False), + sa.Column('is_popular', sa.Boolean(), nullable=True, server_default='false'), + sa.Column('is_custom', sa.Boolean(), nullable=True, server_default='false'), + sa.Column('button_text', sa.String(length=50), nullable=True, server_default="'Get Started'"), + sa.Column('button_url', sa.String(length=200), nullable=True, server_default="'#'"), + sa.Column('order_index', sa.Integer(), nullable=True, server_default='0'), + sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + # Create indexes for better performance + op.create_index('idx_pricing_plans_active', 'pricing_plans', ['is_active']) + op.create_index('idx_pricing_plans_order', 'pricing_plans', ['order_index']) + op.create_index('idx_pricing_plans_popular', 'pricing_plans', ['is_popular']) + op.create_index('idx_pricing_plans_created_at', 'pricing_plans', ['created_at']) + + +def downgrade(): + op.drop_index('idx_pricing_plans_active', table_name='pricing_plans') + op.drop_index('idx_pricing_plans_order', table_name='pricing_plans') + op.drop_index('idx_pricing_plans_popular', table_name='pricing_plans') + op.drop_index('idx_pricing_plans_created_at', table_name='pricing_plans') + op.drop_table('pricing_plans') \ No newline at end of file diff --git a/migrations/versions/add_quota_fields_to_pricing_plans.py b/migrations/versions/add_quota_fields_to_pricing_plans.py new file mode 100644 index 0000000..b266fb3 --- /dev/null +++ b/migrations/versions/add_quota_fields_to_pricing_plans.py @@ -0,0 +1,55 @@ +"""add quota fields to pricing plans + +Revision ID: add_quota_fields +Revises: add_pricing_plans_table +Create Date: 2024-12-19 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + + +# revision identifiers, used by Alembic. +revision = 'add_quota_fields' +down_revision = 'add_pricing_plans_table' +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 ('room_quota', 'conversation_quota', 'storage_quota_gb', 'manager_quota', 'admin_quota') + """)) + existing_columns = [row[0] for row in result.fetchall()] + + # Add quota columns if they don't exist + if 'room_quota' not in existing_columns: + op.add_column('pricing_plans', sa.Column('room_quota', sa.Integer(), nullable=True, server_default='0')) + + if 'conversation_quota' not in existing_columns: + op.add_column('pricing_plans', sa.Column('conversation_quota', sa.Integer(), nullable=True, server_default='0')) + + if 'storage_quota_gb' not in existing_columns: + op.add_column('pricing_plans', sa.Column('storage_quota_gb', sa.Integer(), nullable=True, server_default='0')) + + if 'manager_quota' not in existing_columns: + op.add_column('pricing_plans', sa.Column('manager_quota', sa.Integer(), nullable=True, server_default='0')) + + if 'admin_quota' not in existing_columns: + op.add_column('pricing_plans', sa.Column('admin_quota', sa.Integer(), nullable=True, server_default='0')) + + +def downgrade(): + # Remove quota columns + op.drop_column('pricing_plans', 'admin_quota') + op.drop_column('pricing_plans', 'manager_quota') + op.drop_column('pricing_plans', 'storage_quota_gb') + op.drop_column('pricing_plans', 'conversation_quota') + op.drop_column('pricing_plans', 'room_quota') \ No newline at end of file diff --git a/models.py b/models.py index 2e39b4b..fff20a6 100644 --- a/models.py +++ b/models.py @@ -586,4 +586,92 @@ class HelpArticle(db.Model): if article.category not in grouped: grouped[article.category] = [] grouped[article.category].append(article) - return grouped \ No newline at end of file + return grouped + +class PricingPlan(db.Model): + __tablename__ = 'pricing_plans' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + description = db.Column(db.Text) + monthly_price = db.Column(db.Float, nullable=False) + annual_price = db.Column(db.Float, nullable=False) + features = db.Column(db.JSON, nullable=False) # List of feature strings + is_popular = db.Column(db.Boolean, default=False) + 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='#') + order_index = db.Column(db.Integer, default=0) + is_active = db.Column(db.Boolean, default=True) + # Quota fields + room_quota = db.Column(db.Integer, default=0) # 0 = unlimited + conversation_quota = db.Column(db.Integer, default=0) # 0 = unlimited + storage_quota_gb = db.Column(db.Integer, default=0) # 0 = unlimited, stored in GB + manager_quota = db.Column(db.Integer, default=0) # 0 = unlimited + admin_quota = db.Column(db.Integer, default=0) # 0 = unlimited + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + + # Relationships + creator = db.relationship('User', backref=db.backref('created_pricing_plans', cascade='all, delete-orphan'), foreign_keys=[created_by]) + + def __repr__(self): + return f'' + + @classmethod + def get_active_plans(cls): + """Get all active pricing plans ordered by order_index""" + return cls.query.filter_by(is_active=True).order_by(cls.order_index).all() + + @classmethod + def get_popular_plan(cls): + """Get the plan marked as most popular""" + return cls.query.filter_by(is_active=True, is_popular=True).first() + + def get_storage_quota_bytes(self): + """Get storage quota in bytes""" + if self.storage_quota_gb == 0: + return 0 # Unlimited + return self.storage_quota_gb * 1024 * 1024 * 1024 # Convert GB to bytes + + def format_quota_display(self, quota_type): + """Format quota for display""" + if quota_type == 'room_quota': + return 'Unlimited' if self.room_quota == 0 else f'{self.room_quota} rooms' + elif quota_type == 'conversation_quota': + return 'Unlimited' if self.conversation_quota == 0 else f'{self.conversation_quota} conversations' + elif quota_type == 'storage_quota_gb': + return 'Unlimited' if self.storage_quota_gb == 0 else f'{self.storage_quota_gb}GB' + elif quota_type == 'manager_quota': + return 'Unlimited' if self.manager_quota == 0 else f'{self.manager_quota} managers' + elif quota_type == 'admin_quota': + return 'Unlimited' if self.admin_quota == 0 else f'{self.admin_quota} admins' + return 'Unknown' + + def check_quota(self, quota_type, current_count): + """Check if a quota would be exceeded""" + if quota_type == 'room_quota': + return self.room_quota == 0 or current_count < self.room_quota + elif quota_type == 'conversation_quota': + return self.conversation_quota == 0 or current_count < self.conversation_quota + elif quota_type == 'storage_quota_gb': + return self.storage_quota_gb == 0 or current_count < self.storage_quota_gb + elif quota_type == 'manager_quota': + return self.manager_quota == 0 or current_count < self.manager_quota + elif quota_type == 'admin_quota': + return self.admin_quota == 0 or current_count < self.admin_quota + return True + + def get_quota_remaining(self, quota_type, current_count): + """Get remaining quota""" + if quota_type == 'room_quota': + return float('inf') if self.room_quota == 0 else max(0, self.room_quota - current_count) + elif quota_type == 'conversation_quota': + return float('inf') if self.conversation_quota == 0 else max(0, self.conversation_quota - current_count) + elif quota_type == 'storage_quota_gb': + return float('inf') if self.storage_quota_gb == 0 else max(0, self.storage_quota_gb - current_count) + elif quota_type == 'manager_quota': + return float('inf') if self.manager_quota == 0 else max(0, self.manager_quota - current_count) + elif quota_type == 'admin_quota': + return float('inf') if self.admin_quota == 0 else max(0, self.admin_quota - current_count) + return 0 \ No newline at end of file diff --git a/routes/__pycache__/admin.cpython-313.pyc b/routes/__pycache__/admin.cpython-313.pyc index 6b9b3b4..b96373d 100644 Binary files a/routes/__pycache__/admin.cpython-313.pyc and b/routes/__pycache__/admin.cpython-313.pyc differ diff --git a/routes/__pycache__/main.cpython-313.pyc b/routes/__pycache__/main.cpython-313.pyc index a91cf61..0aa3110 100644 Binary files a/routes/__pycache__/main.cpython-313.pyc and b/routes/__pycache__/main.cpython-313.pyc differ diff --git a/routes/admin.py b/routes/admin.py index f885eb0..4a90084 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -3,6 +3,7 @@ from flask_login import login_required, current_user from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle import os from datetime import datetime +import json admin = Blueprint('admin', __name__) @@ -438,4 +439,240 @@ def get_help_article(article_id): 'order_index': article.order_index } - return jsonify({'article': article_data}) \ No newline at end of file + return jsonify({'article': article_data}) + +@admin.route('/api/admin/pricing-plans', methods=['POST']) +@login_required +def create_pricing_plan(): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Check if this is a MASTER instance + is_master = os.environ.get('MASTER', 'false').lower() == 'true' + if not is_master: + return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403 + + try: + from models import PricingPlan + + # Get form data + name = request.form.get('name') + description = request.form.get('description') + monthly_price = float(request.form.get('monthly_price')) + 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 quota fields + room_quota = int(request.form.get('room_quota', 0)) + conversation_quota = int(request.form.get('conversation_quota', 0)) + storage_quota_gb = int(request.form.get('storage_quota_gb', 0)) + manager_quota = int(request.form.get('manager_quota', 0)) + admin_quota = int(request.form.get('admin_quota', 0)) + + # Validate required fields + if not name or not features: + return jsonify({'error': 'Name and features are required'}), 400 + + # Get the highest order index + max_order = db.session.query(db.func.max(PricingPlan.order_index)).scalar() or 0 + + # Create new pricing plan + plan = PricingPlan( + name=name, + description=description, + monthly_price=monthly_price, + annual_price=annual_price, + features=features, + button_text=button_text, + button_url=button_url, + is_popular=is_popular, + is_custom=is_custom, + is_active=is_active, + room_quota=room_quota, + conversation_quota=conversation_quota, + storage_quota_gb=storage_quota_gb, + manager_quota=manager_quota, + admin_quota=admin_quota, + order_index=max_order + 1, + created_by=current_user.id + ) + + db.session.add(plan) + db.session.commit() + + return jsonify({'success': True, 'message': 'Pricing plan created successfully'}) + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + +@admin.route('/api/admin/pricing-plans/', methods=['GET']) +@login_required +def get_pricing_plan(plan_id): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + try: + from models import PricingPlan + + plan = PricingPlan.query.get(plan_id) + if not plan: + return jsonify({'error': 'Pricing plan not found'}), 404 + + return jsonify({ + 'success': True, + 'plan': { + 'id': plan.id, + 'name': plan.name, + 'description': plan.description, + 'monthly_price': plan.monthly_price, + 'annual_price': plan.annual_price, + 'features': plan.features, + 'button_text': plan.button_text, + 'button_url': plan.button_url, + 'is_popular': plan.is_popular, + 'is_custom': plan.is_custom, + 'is_active': plan.is_active, + 'order_index': plan.order_index, + 'room_quota': plan.room_quota, + 'conversation_quota': plan.conversation_quota, + 'storage_quota_gb': plan.storage_quota_gb, + 'manager_quota': plan.manager_quota, + 'admin_quota': plan.admin_quota + } + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@admin.route('/api/admin/pricing-plans/', methods=['PUT']) +@login_required +def update_pricing_plan(plan_id): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Check if this is a MASTER instance + is_master = os.environ.get('MASTER', 'false').lower() == 'true' + if not is_master: + return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403 + + try: + from models import PricingPlan + + plan = PricingPlan.query.get(plan_id) + if not plan: + return jsonify({'error': 'Pricing plan not found'}), 404 + + # Get form data + name = request.form.get('name') + description = request.form.get('description') + monthly_price = float(request.form.get('monthly_price')) + 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 quota fields + room_quota = int(request.form.get('room_quota', 0)) + conversation_quota = int(request.form.get('conversation_quota', 0)) + storage_quota_gb = int(request.form.get('storage_quota_gb', 0)) + manager_quota = int(request.form.get('manager_quota', 0)) + admin_quota = int(request.form.get('admin_quota', 0)) + + # Validate required fields + if not name or not features: + return jsonify({'error': 'Name and features are required'}), 400 + + # Update plan + plan.name = name + plan.description = description + plan.monthly_price = monthly_price + plan.annual_price = annual_price + plan.features = features + plan.button_text = button_text + plan.button_url = button_url + plan.is_popular = is_popular + plan.is_custom = is_custom + plan.is_active = is_active + plan.room_quota = room_quota + plan.conversation_quota = conversation_quota + plan.storage_quota_gb = storage_quota_gb + plan.manager_quota = manager_quota + plan.admin_quota = admin_quota + + db.session.commit() + + return jsonify({'success': True, 'message': 'Pricing plan updated successfully'}) + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + +@admin.route('/api/admin/pricing-plans/', methods=['DELETE']) +@login_required +def delete_pricing_plan(plan_id): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Check if this is a MASTER instance + is_master = os.environ.get('MASTER', 'false').lower() == 'true' + if not is_master: + return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403 + + try: + from models import PricingPlan + + plan = PricingPlan.query.get(plan_id) + if not plan: + return jsonify({'error': 'Pricing plan not found'}), 404 + + db.session.delete(plan) + db.session.commit() + + return jsonify({'success': True, 'message': 'Pricing plan deleted successfully'}) + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + +@admin.route('/api/admin/pricing-plans//status', methods=['PATCH']) +@login_required +def update_pricing_plan_status(plan_id): + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + # Check if this is a MASTER instance + is_master = os.environ.get('MASTER', 'false').lower() == 'true' + if not is_master: + return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403 + + try: + from models import PricingPlan + + plan = PricingPlan.query.get(plan_id) + if not plan: + return jsonify({'error': 'Pricing plan not found'}), 404 + + data = request.get_json() + field = data.get('field') + value = data.get('value') + + if field not in ['is_active', 'is_popular', 'is_custom']: + return jsonify({'error': 'Invalid field'}), 400 + + setattr(plan, field, value) + db.session.commit() + + return jsonify({'success': True, 'message': 'Plan status updated successfully'}) + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/routes/main.py b/routes/main.py index b97bc07..0e79760 100644 --- a/routes/main.py +++ b/routes/main.py @@ -1122,7 +1122,7 @@ def init_routes(main_bp): active_tab = request.args.get('tab', 'colors') # Validate tab parameter - valid_tabs = ['colors', 'general', 'email_templates', 'mails', 'security', 'events', 'debugging', 'smtp', 'connections'] + valid_tabs = ['colors', 'general', 'email_templates', 'mails', 'security', 'events', 'debugging', 'smtp', 'connections', 'pricing'] if active_tab not in valid_tabs: active_tab = 'colors' @@ -1180,6 +1180,12 @@ def init_routes(main_bp): current_page = mails.page users = User.query.order_by(User.username).all() + # Get pricing plans for the pricing tab (only for MASTER instances) + pricing_plans = [] + if active_tab == 'pricing': + from models import PricingPlan + pricing_plans = PricingPlan.query.order_by(PricingPlan.order_index).all() + if request.method == 'GET': company_form.company_name.data = site_settings.company_name company_form.company_website.data = site_settings.company_website @@ -1210,6 +1216,7 @@ def init_routes(main_bp): nginx_settings=nginx_settings, git_settings=git_settings, cloudflare_settings=cloudflare_settings, + pricing_plans=pricing_plans, csrf_token=generate_csrf()) @main_bp.route('/settings/update-smtp', methods=['POST']) diff --git a/routes/public.py b/routes/public.py index 10ee477..bc1a276 100644 --- a/routes/public.py +++ b/routes/public.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, redirect, url_for, request -from models import SiteSettings, HelpArticle +from models import SiteSettings, HelpArticle, PricingPlan import os def init_public_routes(public_bp): @@ -8,6 +8,11 @@ def init_public_routes(public_bp): site_settings = SiteSettings.query.first() return dict(site_settings=site_settings) + @public_bp.context_processor + def inject_pricing_plans(): + """Make PricingPlan model available in templates""" + return dict(PricingPlan=PricingPlan) + @public_bp.route('/features') def features(): """Features page""" diff --git a/static/js/settings/pricing.js b/static/js/settings/pricing.js new file mode 100644 index 0000000..653b2b6 --- /dev/null +++ b/static/js/settings/pricing.js @@ -0,0 +1,310 @@ +document.addEventListener('DOMContentLoaded', function() { + // Feature management for add form + const addFeatureBtn = document.getElementById('addFeatureBtn'); + const featuresContainer = document.getElementById('featuresContainer'); + + if (addFeatureBtn) { + addFeatureBtn.addEventListener('click', function() { + addFeatureField(featuresContainer); + }); + } + + // Feature management for edit form + const editAddFeatureBtn = document.getElementById('editAddFeatureBtn'); + const editFeaturesContainer = document.getElementById('editFeaturesContainer'); + + if (editAddFeatureBtn) { + editAddFeatureBtn.addEventListener('click', function() { + addFeatureField(editFeaturesContainer); + }); + } + + // Remove feature buttons + document.addEventListener('click', function(e) { + if (e.target.classList.contains('remove-feature-btn')) { + e.target.closest('.input-group').remove(); + } + }); + + // Add Pricing Plan Form + const addPricingPlanForm = document.getElementById('addPricingPlanForm'); + if (addPricingPlanForm) { + addPricingPlanForm.addEventListener('submit', function(e) { + e.preventDefault(); + submitPricingPlanForm(this, 'POST', '/api/admin/pricing-plans'); + }); + } + + // Edit Pricing Plan Form + const editPricingPlanForm = document.getElementById('editPricingPlanForm'); + if (editPricingPlanForm) { + editPricingPlanForm.addEventListener('submit', function(e) { + e.preventDefault(); + const planId = document.getElementById('editPlanId').value; + submitPricingPlanForm(this, 'PUT', `/api/admin/pricing-plans/${planId}`); + }); + } + + // Edit Plan Buttons + document.addEventListener('click', function(e) { + if (e.target.classList.contains('edit-plan-btn')) { + const planId = e.target.getAttribute('data-plan-id'); + loadPlanForEdit(planId); + } + }); + + // Delete Plan Buttons + document.addEventListener('click', function(e) { + if (e.target.classList.contains('delete-plan-btn')) { + const planId = e.target.getAttribute('data-plan-id'); + const planName = e.target.closest('.card').querySelector('.card-header h5').textContent; + showDeleteConfirmation(planId, planName); + } + }); + + // Confirm Delete Button + const confirmDeleteBtn = document.getElementById('confirmDeletePlan'); + if (confirmDeleteBtn) { + confirmDeleteBtn.addEventListener('click', function() { + const planId = this.getAttribute('data-plan-id'); + deletePricingPlan(planId); + }); + } + + // Toggle switches + document.addEventListener('change', function(e) { + if (e.target.classList.contains('plan-status-toggle')) { + const planId = e.target.getAttribute('data-plan-id'); + const isActive = e.target.checked; + updatePlanStatus(planId, 'is_active', isActive); + } + + if (e.target.classList.contains('plan-popular-toggle')) { + const planId = e.target.getAttribute('data-plan-id'); + const isPopular = e.target.checked; + updatePlanStatus(planId, 'is_popular', isPopular); + } + + if (e.target.classList.contains('plan-custom-toggle')) { + const planId = e.target.getAttribute('data-plan-id'); + const isCustom = e.target.checked; + updatePlanStatus(planId, 'is_custom', isCustom); + } + }); +}); + +function addFeatureField(container) { + const featureGroup = document.createElement('div'); + featureGroup.className = 'input-group mb-2'; + featureGroup.innerHTML = ` + + + `; + container.appendChild(featureGroup); +} + +function submitPricingPlanForm(form, method, url) { + const formData = new FormData(form); + + // Convert features array + const features = []; + form.querySelectorAll('input[name="features[]"]').forEach(input => { + if (input.value.trim()) { + features.push(input.value.trim()); + } + }); + + // Remove features from formData and add as JSON + formData.delete('features[]'); + formData.append('features', JSON.stringify(features)); + + // Convert checkboxes to boolean values + const checkboxes = ['is_popular', 'is_custom', 'is_active']; + checkboxes.forEach(field => { + formData.set(field, formData.get(field) === 'on'); + }); + + fetch(url, { + method: method, + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification('Pricing plan saved successfully!', 'success'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + showNotification(data.error || 'Error saving pricing plan', 'error'); + } + }) + .catch(error => { + console.error('Error:', error); + showNotification('Error saving pricing plan', 'error'); + }); +} + +function loadPlanForEdit(planId) { + fetch(`/api/admin/pricing-plans/${planId}`) + .then(response => response.json()) + .then(data => { + if (data.success) { + const plan = data.plan; + + // Populate form fields + document.getElementById('editPlanId').value = plan.id; + document.getElementById('editPlanName').value = plan.name; + document.getElementById('editPlanDescription').value = plan.description || ''; + 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('editIsPopular').checked = plan.is_popular; + document.getElementById('editIsCustom').checked = plan.is_custom; + document.getElementById('editIsActive').checked = plan.is_active; + + // Populate quota fields + document.getElementById('editRoomQuota').value = plan.room_quota || 0; + document.getElementById('editConversationQuota').value = plan.conversation_quota || 0; + document.getElementById('editStorageQuota').value = plan.storage_quota_gb || 0; + document.getElementById('editManagerQuota').value = plan.manager_quota || 0; + document.getElementById('editAdminQuota').value = plan.admin_quota || 0; + + // Populate features + const editFeaturesContainer = document.getElementById('editFeaturesContainer'); + editFeaturesContainer.innerHTML = ''; + + plan.features.forEach(feature => { + const featureGroup = document.createElement('div'); + featureGroup.className = 'input-group mb-2'; + featureGroup.innerHTML = ` + + + `; + editFeaturesContainer.appendChild(featureGroup); + }); + + // Add one empty feature field if no features + if (plan.features.length === 0) { + addFeatureField(editFeaturesContainer); + } + } else { + showNotification(data.error || 'Error loading plan', 'error'); + } + }) + .catch(error => { + console.error('Error:', error); + showNotification('Error loading plan', 'error'); + }); +} + +function showDeleteConfirmation(planId, planName) { + document.getElementById('deletePlanName').textContent = planName; + document.getElementById('confirmDeletePlan').setAttribute('data-plan-id', planId); + + const deleteModal = new bootstrap.Modal(document.getElementById('deletePricingPlanModal')); + deleteModal.show(); +} + +function deletePricingPlan(planId) { + fetch(`/api/admin/pricing-plans/${planId}`, { + method: 'DELETE', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification('Pricing plan deleted successfully!', 'success'); + + // Close modal + const deleteModal = bootstrap.Modal.getInstance(document.getElementById('deletePricingPlanModal')); + deleteModal.hide(); + + // Remove from DOM + const planElement = document.querySelector(`[data-plan-id="${planId}"]`); + if (planElement) { + planElement.remove(); + } + + // Check if no plans left + const remainingPlans = document.querySelectorAll('[data-plan-id]'); + if (remainingPlans.length === 0) { + window.location.reload(); + } + } else { + showNotification(data.error || 'Error deleting plan', 'error'); + } + }) + .catch(error => { + console.error('Error:', error); + showNotification('Error deleting plan', 'error'); + }); +} + +function updatePlanStatus(planId, field, value) { + fetch(`/api/admin/pricing-plans/${planId}/status`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value + }, + body: JSON.stringify({ + field: field, + value: value + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification('Plan status updated successfully!', 'success'); + } else { + showNotification(data.error || 'Error updating plan status', 'error'); + // Revert the toggle + const toggle = document.querySelector(`[data-plan-id="${planId}"].${field.replace('is_', 'plan-')}-toggle`); + if (toggle) { + toggle.checked = !value; + } + } + }) + .catch(error => { + console.error('Error:', error); + showNotification('Error updating plan status', 'error'); + // Revert the toggle + const toggle = document.querySelector(`[data-plan-id="${planId}"].${field.replace('is_', 'plan-')}-toggle`); + if (toggle) { + toggle.checked = !value; + } + }); +} + +function showNotification(message, type) { + // Create notification element + const notification = document.createElement('div'); + notification.className = `alert alert-${type === 'success' ? 'success' : 'danger'} alert-dismissible fade show position-fixed`; + notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;'; + notification.innerHTML = ` + ${message} + + `; + + document.body.appendChild(notification); + + // Auto-remove after 5 seconds + setTimeout(() => { + if (notification.parentNode) { + notification.remove(); + } + }, 5000); +} \ No newline at end of file diff --git a/templates/components/pricing_section.html b/templates/components/pricing_section.html index a1fc72e..2f63494 100644 --- a/templates/components/pricing_section.html +++ b/templates/components/pricing_section.html @@ -5,6 +5,72 @@

Simple, Transparent Pricing

Choose the plan that fits your organization's needs

+ + {% set pricing_plans = PricingPlan.get_active_plans() %} + {% if pricing_plans %} +
+ {% for plan in pricing_plans %} +
+ +
+ {% endfor %} +
+ + + {% set has_non_custom_plans = pricing_plans | selectattr('is_custom', 'equalto', false) | list | length > 0 %} + {% if has_non_custom_plans %} +
+ Monthly +
+ + +
+ Annual Save 20% +
+ {% endif %} + + {% else %} +
@@ -107,12 +173,15 @@
Annual Save 20%
+ {% endif %}
\ No newline at end of file diff --git a/templates/settings/settings.html b/templates/settings/settings.html index b5d4c0a..57536b9 100644 --- a/templates/settings/settings.html +++ b/templates/settings/settings.html @@ -9,6 +9,7 @@ {% from "settings/tabs/mails.html" import mails_tab %} {% from "settings/tabs/smtp_settings.html" import smtp_settings_tab %} {% from "settings/tabs/connections.html" import connections_tab %} +{% from "settings/tabs/pricing.html" import pricing_tab %} {% from "settings/components/reset_colors_modal.html" import reset_colors_modal %} {% block title %}Settings - DocuPulse{% endblock %} @@ -82,6 +83,11 @@ Connections + {% endif %} @@ -136,6 +142,11 @@
{{ connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings) }}
+ + +
+ {{ pricing_tab(pricing_plans, csrf_token) }} +
{% endif %} @@ -150,4 +161,7 @@ {% block extra_js %} +{% if is_master and active_tab == 'pricing' %} + +{% endif %} {% endblock %} \ No newline at end of file diff --git a/templates/settings/tabs/pricing.html b/templates/settings/tabs/pricing.html new file mode 100644 index 0000000..f5afabf --- /dev/null +++ b/templates/settings/tabs/pricing.html @@ -0,0 +1,427 @@ +{% macro pricing_tab(pricing_plans, csrf_token) %} +
+
+

Pricing Plans Configuration

+ +
+ + +
+ {% for plan in pricing_plans %} +
+
+
+
{{ plan.name }}
+
+ +
+
+
+
+ Monthly Price: €{{ "%.2f"|format(plan.monthly_price) }}
+ Annual Price: €{{ "%.2f"|format(plan.annual_price) }} +
+ + {% if plan.description %} +
+ Description:
+ {{ plan.description }} +
+ {% endif %} + + +
+ Quotas: +
+
+ + Rooms: {{ plan.format_quota_display('room_quota') }}
+ Conversations: {{ plan.format_quota_display('conversation_quota') }}
+ Storage: {{ plan.format_quota_display('storage_quota_gb') }} +
+
+
+ + Managers: {{ plan.format_quota_display('manager_quota') }}
+ Admins: {{ plan.format_quota_display('admin_quota') }} +
+
+
+
+ +
+ Features: +
    + {% for feature in plan.features %} +
  • {{ feature }}
  • + {% endfor %} +
+
+ +
+ Button: {{ plan.button_text }} → {{ plan.button_url }} +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ {% else %} +
+
+ +
No pricing plans configured
+

Click "Add New Plan" to create your first pricing plan.

+
+
+ {% endfor %} +
+
+ + + + + + + + + +{% endmacro %} \ No newline at end of file