diff --git a/app/__init__.py b/app/__init__.py index caf41ad..5b8b2d2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -166,23 +166,30 @@ def create_app(config_name='default'): """MT-8: push billing state into every template.""" try: from flask import g - billing_warning = getattr(g, 'billing_warning', None) - tenant = getattr(g, 'tenant', None) + from app.utils.time_utils import now_eastern + billing_warning = getattr(g, 'billing_warning', None) + tenant = getattr(g, 'tenant', None) subscription_status = tenant.subscription_status if tenant else None - trial_ends_at = tenant.trial_ends_at if tenant else None + trial_ends_at = tenant.trial_ends_at if tenant else None + trial_days_remaining = None + if trial_ends_at is not None: + delta = trial_ends_at - now_eastern() + trial_days_remaining = max(0, delta.days) return { - 'billing_enabled': app.config.get('BILLING_ENABLED', False), - 'billing_warning': billing_warning, - 'subscription_status': subscription_status, - 'trial_ends_at': trial_ends_at, + 'billing_enabled': app.config.get('BILLING_ENABLED', False), + 'billing_warning': billing_warning, + 'subscription_status': subscription_status, + 'trial_ends_at': trial_ends_at, + 'trial_days_remaining': trial_days_remaining, } except Exception: pass return { - 'billing_enabled': False, - 'billing_warning': None, - 'subscription_status': None, - 'trial_ends_at': None, + 'billing_enabled': False, + 'billing_warning': None, + 'subscription_status': None, + 'trial_ends_at': None, + 'trial_days_remaining': None, } os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) @@ -198,6 +205,7 @@ def create_app(config_name='default'): from app.routes import broadcast # Admin broadcast messages from app.routes import devices # Admin device management from app.routes import tenant_settings # MT-7 — tenant self-service + from app.routes import signup # MT-8+ — public self-service signup from app.billing import bp as billing_bp # MT-8 — Stripe billing app.register_blueprint(auth.bp) @@ -216,6 +224,7 @@ def create_app(config_name='default'): app.register_blueprint(broadcast.bp) app.register_blueprint(devices.bp) app.register_blueprint(tenant_settings.bp) + app.register_blueprint(signup.bp) # Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from # Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects # which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods). diff --git a/app/routes/signup.py b/app/routes/signup.py new file mode 100644 index 0000000..1cb2f37 --- /dev/null +++ b/app/routes/signup.py @@ -0,0 +1,129 @@ +""" +app/routes/signup.py +-------------------- +Public self-service tenant signup (MT-8+). + +Accessible at /signup on any domain — the path is hardcoded as exempt in the +tenant middleware so no tenant context is required. On success, a new tenant +database is provisioned (schema + first admin) and the user is redirected to +their subdomain's login page. + +Trial period: every signup starts a 14-day free trial (subscription_status = +'trial', trial_ends_at = now + 14 days). After the trial the billing gate +redirects to /billing/subscribe. +""" + +import re +import logging +import os + +from flask import Blueprint, render_template, request, flash, redirect, current_app +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SelectField +from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError + +bp = Blueprint('signup', __name__, url_prefix='/signup') +logger = logging.getLogger(__name__) + +_SLUG_RE = re.compile(r'^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$') + + +def _paid_plan_choices(): + """Return plan choices from the control DB, ordered by price.""" + try: + from control.base import control_session + from control.models import Plan + with control_session() as s: + plans = (s.query(Plan) + .order_by(Plan.price_cents.nullslast(), Plan.id) + .all()) + return [(p.code, f'{p.name} — ' + + (f'{p.max_users} users, {p.max_facilities} facilities' + if p.max_users else 'Unlimited')) + for p in plans] + except Exception: + return [ + ('free', 'Free — 3 users, 2 facilities'), + ('starter', 'Starter — 15 users, 10 facilities'), + ('pro', 'Pro — 50 users, 50 facilities'), + ('enterprise', 'Enterprise — Unlimited'), + ] + + +class SignupForm(FlaskForm): + company_name = StringField('Company Name', + validators=[DataRequired(), Length(max=150)]) + full_name = StringField('Your Full Name', + validators=[DataRequired(), Length(max=150)]) + email = StringField('Work Email', + validators=[DataRequired(), Email(), Length(max=255)]) + subdomain = StringField('Subdomain', + validators=[DataRequired(), Length(min=2, max=32)]) + plan = SelectField('Plan', choices=[]) # populated in view + password = PasswordField('Password', + validators=[DataRequired(), Length(min=8, max=128)]) + confirm = PasswordField('Confirm Password', + validators=[EqualTo('password', 'Passwords must match.')]) + + def validate_subdomain(self, field): + slug = field.data.lower().strip() + field.data = slug + if not _SLUG_RE.match(slug): + raise ValidationError( + 'Subdomain may only contain lowercase letters, numbers, and hyphens, ' + 'and must start and end with a letter or number.') + # Check uniqueness against control DB + try: + from control.base import control_session + from control.models import Tenant + with control_session() as s: + if s.query(Tenant).filter_by(slug=slug).first(): + raise ValidationError(f'"{slug}" is already taken. Please choose another.') + except ValidationError: + raise + except Exception: + pass # control DB unreachable — let provisioner surface the error + + +@bp.route('', methods=['GET', 'POST']) +def index(): + form = SignupForm() + form.plan.choices = _paid_plan_choices() + + if form.validate_on_submit(): + slug = form.subdomain.data + company = form.company_name.data.strip() + full_name = form.full_name.data.strip() + email = form.email.data.strip().lower() + plan_code = form.plan.data + password = form.password.data + + try: + from control.provision import create_tenant + info = create_tenant( + slug=slug, + name=company, + plan_code=plan_code, + admin_email=email, + admin_full_name=full_name, + admin_password=password, + trial_days=14, + ) + except ValueError as exc: + flash(str(exc), 'danger') + except Exception as exc: + logger.error('SIGNUP | provision_failed | slug=%s err=%s', slug, exc) + flash('Provisioning failed. Please try again or contact support.', 'danger') + else: + base_domain = os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app') + login_url = f'https://{slug}.{base_domain}/auth/login' + logger.info('SIGNUP | provisioned | slug=%s plan=%s tenant_id=%s', + slug, plan_code, info['tenant_id']) + return render_template('signup/success.html', + login_url=login_url, + slug=slug, + base_domain=base_domain, + trial_ends_at=info.get('trial_ends_at')) + + base_domain = os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app') + return render_template('signup/index.html', form=form, base_domain=base_domain) diff --git a/app/templates/billing/_billing_banner.html b/app/templates/billing/_billing_banner.html index c380900..6765652 100644 --- a/app/templates/billing/_billing_banner.html +++ b/app/templates/billing/_billing_banner.html @@ -9,4 +9,19 @@ +{% elif billing_warning == 'trial_ending' %} +
14-day free trial · No credit card required
++ Already have a workspace? + Sign in +
++ {{ slug }}.{{ base_domain }} has been provisioned. +
+ {% if trial_ends_at %} ++ Your 14-day free trial runs until + {{ trial_ends_at.strftime('%B %d, %Y') }}. +
+ {% endif %} + + Go to your workspace + +
+
+ Bookmark {{ slug }}.{{ base_domain }} — that's your permanent workspace URL.
+