Ship a Flask SaaS starter with sign-up and Stripe

Build a small subscription app with accounts, a paid plan and a live URL, from an empty folder to production in one weekend.

48 ч intermediate FlaskSQLite → PostgreSQLStripe CheckoutRender

1 Friday evening: scope it and scaffold the project

Pick one job your app does for the customer and write it in one sentence ("Upload a CSV, get a cleaned CSV back"). Anything that is not needed for that sentence waits for version 2.

mkdir saas-starter && cd saas-starter
python -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install flask flask-login flask-sqlalchemy flask-wtf stripe python-dotenv gunicorn
pip freeze > requirements.txt
git init && echo ".venv/\n.env\n*.db" > .gitignore

Create the app factory so tests and production can build their own app:

# app/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager

db = SQLAlchemy()
login = LoginManager()

def create_app():
    app = Flask(__name__)
    app.config.from_prefixed_env()          # FLASK_SECRET_KEY, FLASK_SQLALCHEMY_DATABASE_URI
    app.config.setdefault("SQLALCHEMY_DATABASE_URI", "sqlite:///app.db")
    db.init_app(app)
    login.init_app(app)
    from .routes import bp
    app.register_blueprint(bp)
    return app

Done when: flask --app app run shows a "Hello" page and the first commit exists.

2 Saturday morning: accounts

Add a User model with a hashed password and an is_paid flag, then sign-up, login and logout.

from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin

class User(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password_hash = db.Column(db.String(255), nullable=False)
    is_paid = db.Column(db.Boolean, default=False)
    stripe_customer_id = db.Column(db.String(64))

    def set_password(self, raw):
        self.password_hash = generate_password_hash(raw)

    def check_password(self, raw):
        return check_password_hash(self.password_hash, raw)
  • Use Flask-WTF forms so every POST has a CSRF token.
  • Logout must be a POST form, never a link.
  • Protect the product page with @login_required.

Done when: you can sign up, log out, log back in, and a second browser can't see your page.

3 Saturday afternoon: the one feature

Build the feature from Friday's sentence, behind login. Keep it boring: one form, one result page. Free users get a limit (for example 3 runs per day); paid users get more.

@bp.route("/app", methods=["GET", "POST"])
@login_required
def product():
    if request.method == "POST" and not current_user.is_paid and runs_today(current_user) >= 3:
        flash("Free plan limit reached. Upgrade for unlimited runs.")
        return redirect(url_for("main.pricing"))
    ...

Done when: a free user hits the limit and is sent to the pricing page.

4 Saturday evening: Stripe Checkout + webhook

Create a product and a recurring price in the Stripe dashboard (test mode). Checkout creates the session; only the webhook marks a user as paid, because the success redirect can be faked.

@bp.post("/billing/checkout")
@login_required
def checkout():
    session = stripe.checkout.Session.create(
        mode="subscription",
        line_items=[{"price": os.environ["STRIPE_PRICE_ID"], "quantity": 1}],
        customer_email=current_user.email,
        client_reference_id=str(current_user.id),
        success_url=url_for("main.product", _external=True) + "?paid=1",
        cancel_url=url_for("main.pricing", _external=True),
    )
    return redirect(session.url, code=303)

@bp.post("/billing/webhook")
def webhook():
    event = stripe.Webhook.construct_event(
        request.data, request.headers.get("Stripe-Signature"), os.environ["STRIPE_WEBHOOK_SECRET"])
    if event["type"] == "checkout.session.completed":
        user = db.session.get(User, int(event["data"]["object"]["client_reference_id"]))
        user.is_paid = True
        db.session.commit()
    return "", 200

Exempt the webhook from CSRF, and test it locally with stripe listen --forward-to localhost:5000/billing/webhook.

Done when: a test card (4242 4242 4242 4242) upgrades the account and the limit disappears.

5 Sunday morning: deploy

  1. Push to GitHub.
  2. On Render: New → PostgreSQL, then New → Web Service from the repo.
  3. Build command pip install -r requirements.txt, start command gunicorn "app:create_app()".
  4. Environment: FLASK_SECRET_KEY (random 64 hex chars), FLASK_SQLALCHEMY_DATABASE_URI (the internal Postgres URL), STRIPE_SECRET_KEY, STRIPE_PRICE_ID, STRIPE_WEBHOOK_SECRET.
  5. Add the production webhook endpoint in Stripe and copy its signing secret.

Done when: the live URL works end to end with a test card.

6 Sunday afternoon: polish and launch

  • A pricing page with exactly two plans (Free, Pro).
  • A privacy page and a terms page (even short ones).
  • An error page for 404 and 500.
  • Tell 10 people who have the problem. Write down every question they ask: that is your version 2 backlog.

Done when: a stranger can sign up and pay without asking you anything.

Закончили проект? Отправьте ссылки на проверку. Отправить на проверку (50 баллов)

Углубиться с этими курсами

Другие гайды