""" 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: paid-plan signups start a 14-day free trial (subscription_status = 'trial', trial_ends_at = now + 14 days); after the trial the billing gate redirects to /billing/subscribe. The Free plan is free-forever — it is provisioned with trial_days=0 (subscription_status = 'active', no expiry) so the billing gate never blocks it. See CLAUDE.md rule 86. """ 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 from app.utils.forms import strong_password bp = Blueprint('signup', __name__, url_prefix='/signup') logger = logging.getLogger(__name__) def _send_welcome_email(to_email, login_url, tenant_name, plan_code, trial_ends_at): """Fire a welcome email in a background thread — never blocks the request. The wording adapts to the plan: the Free plan has no trial/expiry, paid plans start a 14-day trial. `trial_note` carries the plan-appropriate line and `trial_ends_at` is blank for Free so the template hides the trial row. """ try: from app.billing.emails import send_billing_email is_free = (plan_code == 'free') ends_str = (trial_ends_at.strftime('%B %d, %Y') if hasattr(trial_ends_at, 'strftime') else str(trial_ends_at or '')) if is_free: trial_note = "You're on the Free plan — no credit card, no expiry." ends_str = '' else: trial_note = f'Your 14-day free trial runs through {ends_str}.' send_billing_email(to_email, 'welcome', { 'tenant_name': tenant_name, 'login_url': login_url, 'trial_days': 0 if is_free else 14, 'trial_ends_at': ends_str, 'trial_note': trial_note, 'plan_name': plan_code.title(), }) except Exception as exc: logger.error('SIGNUP | welcome_email_failed | to=%s err=%s', to_email, exc) _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(max=128), strong_password()]) 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 # Free plan is free forever — no trial, no expiry. Paid plans start a # 14-day trial. trial_days=0 makes create_tenant provision the tenant as # 'active' (see control/provision.py) so the billing gate never blocks it. trial_days = 0 if plan_code == 'free' else 14 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=trial_days, ) 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']) _send_welcome_email( to_email = email, login_url = login_url, tenant_name = company, plan_code = plan_code, trial_ends_at = info.get('trial_ends_at'), ) 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)