{"id":27629385,"url":"https://github.com/fareedkhan-dev/saas-payment-guide","last_synced_at":"2026-05-07T08:35:54.278Z","repository":{"id":289251957,"uuid":"970628280","full_name":"FareedKhan-dev/saas-payment-guide","owner":"FareedKhan-dev","description":"A Beginner's Guide to Monetizing Your Python AI Chatbot","archived":false,"fork":false,"pushed_at":"2025-04-22T10:28:40.000Z","size":37,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-04-23T15:16:46.274Z","etag":null,"topics":["ai-chatbot","flask","monetization","payment-gateway","python","sass"],"latest_commit_sha":null,"homepage":"https://medium.com/@fareedkhandev/building-a-subscription-plan-payment-system-for-ai-webapp-using-python-end-to-end-implementation-4dc2f6715805","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/FareedKhan-dev.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2025-04-22T09:47:02.000Z","updated_at":"2025-04-23T08:57:53.000Z","dependencies_parsed_at":"2025-04-22T11:17:54.646Z","dependency_job_id":null,"html_url":"https://github.com/FareedKhan-dev/saas-payment-guide","commit_stats":null,"previous_names":["fareedkhan-dev/saas-payment-guide"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/FareedKhan-dev%2Fsaas-payment-guide","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/FareedKhan-dev%2Fsaas-payment-guide/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/FareedKhan-dev%2Fsaas-payment-guide/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/FareedKhan-dev%2Fsaas-payment-guide/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/FareedKhan-dev","download_url":"https://codeload.github.com/FareedKhan-dev/saas-payment-guide/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":250457784,"owners_count":21433734,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ai-chatbot","flask","monetization","payment-gateway","python","sass"],"created_at":"2025-04-23T15:16:49.566Z","updated_at":"2026-05-07T08:35:54.223Z","avatar_url":"https://github.com/FareedKhan-dev.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003c!-- omit in toc --\u003e\n# Building a Subscription Plan Payment System for AI Webapp\n\nWe are going to create a simple AI Chatbot web app from scratch (Frontend + Backend + Payment System) that will include three subscription plans:\n\n*   Free Plan (2 messages/hour)\n*   Standard Plan (100 messages/month = 5 USD)\n*   Pro Plan (Unlimited messages/month = 20 USD)\n\nHere is the architecture of our web app:\n\n![Our Webapp Architecture (Created by Fareed Khan)](https://miro.medium.com/v2/resize:fit:1250/1*QFn583xhYUfs_RtbLBNLmA.png)\n\n#### Bored? Scroll to the final result: [link](#testing-webapp)\n\nWe will be using Supabase as the backend and the LemonSqueezy payment system API to integrate the payment gateway.\n\nLemonSqueezy is a free platform, but they charge (5% + a small fixed fee) for every transaction (amount transferred to your bank account).\n\nYou can use the Paddle platform, but they have recently started restricting AI products, so your web app might get rejected.\n\nHere is our complete stack:\n\n*   Flask (Python Web Framework)\n*   OpenAI / Nebius / Ollama for web chat with LLM\n*   Supabase (Free plan database for backend)\n*   LemonSqueezy (as the payment gateway for handling our subscription plans)\n*   ngrok (To test payment system)\n\n\u003c!-- omit in toc --\u003e\n## Table of Content\n\n- [Getting Started](#getting-started)\n- [SupaBase Database Setup](#supabase-database-setup)\n- [Lemon Squeezy Configuration](#lemon-squeezy-configuration)\n- [LLM API Configuration](#llm-api-configuration)\n- [Setup and Preparation](#setup-and-preparation)\n- [Creating a Lemon Squeezy Customer](#creating-a-lemon-squeezy-customer)\n- [User Signup Route](#user-signup-route)\n- [User Login Route](#user-login-route)\n- [User Logout Route](#user-logout-route)\n- [Data Fetching \\\u0026 Monthly Reset](#data-fetching--monthly-reset)\n- [Determine Plan Info \\\u0026 Limits](#determine-plan-info--limits)\n- [Processing Allowed Messages](#processing-allowed-messages)\n- [Checkout Link \\\u0026 Rendering](#checkout-link--rendering)\n- [Lemon Squeezy Webhook Handler](#lemon-squeezy-webhook-handler)\n- [Creating the Frontend Templates](#creating-the-frontend-templates)\n- [Creating Webhook Event](#creating-webhook-event)\n- [Testing Webapp](#testing-webapp)\n- [What’s Next](#whats-next)\n\n## Getting Started\n\nLet’s set up our project structure. Create a main folder (e.g., `simple-chatbot-complete`) and inside it:\n\n```bash\nsimple-chatbot-complete/\n├── templates/\n│   ├── home.html\n│   ├── login.html\n│   └── signup.html\n├── .env\n├── app.py\n└── requirements.txt\n```\n\nNow, create the `requirements.txt` file with our dependencies:\n\n```bash\n# requirements.txt\nFlask\nsupabase\npython-dotenv\nopenai\nwerkzeug\nrequests\n```\n\nOnce you create the requirements module file, let’s install them first.\n\n```bash\npip install -r requirements.txt\n```\n\nNext, we need to create `.env` file which contains all of our keys.\n\n```bash\n# .env\n\n# Supabase Credentials\nSUPABASE_URL='YOUR_SUPABASE_PROJECT_URL'\nSUPABASE_KEY='YOUR_SUPABASE_ANON_PUBLIC_KEY'\n\n# Nebius API Key (or your LLM provider's details)\nNEBIUS_API_KEY='YOUR_NEBIUS_API_KEY'\nNEBIUS_BASE_URL=\"https://api.studio.nebius.com/v1/\" # Or your provider's base URL\nNEBIUS_MODEL=\"microsoft/Phi-3-mini-4k-instruct\" # Or your chosen model\n\n# Lemon Squeezy Credentials\nLEMONSQUEEZY_API_KEY=\"YOUR_LEMONSQUEEZY_API_KEY\"\nLEMONSQUEEZY_STORE_ID='YOUR_LEMONSQUEEZY_STORE_ID'\nLEMONSQUEEZY_STANDARD_VARIANT_ID='YOUR_STANDARD_PLAN_VARIANT_ID'\nLEMONSQUEEZY_PRO_VARIANT_ID='YOUR_PRO_PLAN_VARIANT_ID'\nLEMONSQUEEZY_WEBHOOK_SECRET='YOUR_LEMONSQUEEZY_WEBHOOK_SIGNING_SECRET'\nLEMONSQUEEZY_CHECKOUT_LINK='YOUR_SINGLE_LEMONSQUEEZY_CHECKOUT_LINK'\n```\n\nWe will go through these environment variables one by one to obtain their values and understand their purpose.\n\n## SupaBase Database Setup\n\nLet’s create the necessary table in Supabase to store user data and track their plan status and usage.\n\n1.  Log in to your Supabase dashboard.\n2.  Select/Create your project.\n3.  Navigate to the **SQL Editor** in the left sidebar.\n4.  Paste the following SQL code and click **RUN**.\n\n![SupaBase Dashboard](https://miro.medium.com/v2/resize:fit:875/1*BY9yfhnNxvDfMKGGzgFXvQ.png)\n*SupaBase Dashboard*\n\n```sql\n-- Create the users table\ncreate table users (\n  id uuid primary key default gen_random_uuid(), -- Unique user ID (Primary Key)\n  email text unique not null,                   -- User's email (must be unique)\n  password_hash text not null,                  -- Hashed password\n  created_at timestamp with time zone default timezone('utc'::text, now()) not null, -- When the user signed up\n\n  -- Lemon Squeezy Integration\n  lemonsqueezy_customer_id text null,           -- Stores the corresponding customer ID from Lemon Squeezy\n\n  -- Plan Status Flags (Mutually Exclusive)\n  is_free_plan boolean default true not null,\n  is_standard_plan boolean default false not null,\n  is_pro_plan boolean default false not null,\n\n  -- Usage Tracking\n  message_count integer default 0 not null,         -- Lifetime message count (optional, good for stats)\n  messages_this_hour integer default 0 not null,    -- Counter for Free plan hourly limit\n  last_message_timestamp timestamptz null,        -- Timestamp of the last message (for Free plan hourly check)\n  messages_this_month integer default 0 not null,   -- Counter for Standard plan monthly limit\n  usage_reset_date date null                      -- Date (YYYY-MM-DD) when monthly usage should reset (for Standard/Pro)\n);\n```\n\nWhen you run this command, our database table will be created. Let’s take a look at each of the created fields and their purposes.\n\n\u003c!-- Note: The table iframe cannot be directly converted to Markdown. Representing the core information here. --\u003e\n**Database Table Columns:**\n*   **id**: uuid, primary key, default gen_random_uuid() - Unique user ID (Primary Key)\n*   **email**: text, unique, not null - User's email (must be unique)\n*   **password_hash**: text, not null - Hashed password\n*   **created_at**: timestamp with time zone, default timezone('utc'::text, now()), not null - When the user signed up\n*   **lemonsqueezy_customer_id**: text, null - Stores the corresponding customer ID from Lemon Squeezy\n*   **is_free_plan**: boolean, default true, not null - Flag for Free Plan\n*   **is_standard_plan**: boolean, default false, not null - Flag for Standard Plan\n*   **is_pro_plan**: boolean, default false, not null - Flag for Pro Plan\n*   **message_count**: integer, default 0, not null - Lifetime message count (optional, good for stats)\n*   **messages_this_hour**: integer, default 0, not null - Counter for Free plan hourly limit\n*   **last_message_timestamp**: timestamptz, null - Timestamp of the last message (for Free plan hourly check)\n*   **messages_this_month**: integer, default 0, not null - Counter for Standard plan monthly limit\n*   **usage_reset_date**: date, null - Date (YYYY-MM-DD) when monthly usage should reset (for Standard/Pro)\n\nExplanation of Columns:\n\n1.  Standard user fields: `id`, `email`, `password_hash`, `created_at`.\n2.  `lemonsqueezy_customer_id`: Links our user to their Lemon Squeezy profile.\n3.  `is_free_plan`, `is_standard_plan`, `is_pro_plan`: Boolean flags to easily check the user's current active plan. Only one should be true at a time.\n4.  `message_count`: Total messages sent by the user ever (useful for general stats).\n5.  `messages_this_hour` / `last_message_timestamp`: Used together to check the Free plan's 2-message-per-hour limit.\n6.  `messages_this_month`: Counts messages within the current billing cycle for the Standard plan.\n7.  `usage_reset_date`: Stores the date when `messages_this_month` should be reset to `0`. This is typically set based on the subscription renewal date provided by Lemon Squeezy.\n\n![Finding Supabase API Key](https://miro.medium.com/v2/resize:fit:1250/1*xvLjxj_QGv7Cg_zVMoFtRg.png)\n*Finding Supabase API Key*\n\nYou can find your Supabase project URL and API key in the project settings. Once you have them, replace the corresponding values in your environment variables.\n\n```bash\n# Supabase Credentials\nSUPABASE_URL='YOUR_SUPABASE_PROJECT_URL'\nSUPABASE_KEY='YOUR_SUPABASE_ANON_PUBLIC_KEY'\n```\n\nNow that our backend database has been configured correctly, next let’s configure the LemonSqueezy payment plans.\n\n## Lemon Squeezy Configuration\n\nBefore creating the subscription plan using LemonSqueezy, we need to understand how it works. So let’s visualize it first.\n\n![LemonSqueezy Basic Flow Diagram](https://miro.medium.com/v2/resize:fit:1250/1*eAFLbPFIdG--hHaa-x8GUA.png)\n*LemonSqueezy Basic Flow Diagram*\n\nA store represents a company. We can have multiple companies, but each needs to be approved by LemonSqueezy (you can follow the guide to get your store approved on their website).\n\nHowever, we can use an unapproved store in test mode to integrate the payment system.\n\nEach store can have multiple products. A product can either be a digital product with a set price or a subscription plan. Each product can have multiple variants for example, a subscription product can have:\n\n*   Variant 1: Standard Plan\n*   Variant 2: Pro Plan\n\nSimilarly, a digital product can have variants like:\n\n*   One variant with a limited set of files.\n*   Another variant with a different set of files access.\n\nSo we are going to create a product (Pricing Plans) and within that we can have multiple variants (Standard Plan, Pro Plan).\n\n1.  **Log in** to your Lemon Squeezy dashboard.\n2.  Go to created store -\u003e Products.\n3.  Click “+ New Product”.\n4.  Give it a name (e.g., “Pricing Plan”) and fill rest of the fields.\n\n![Making Pricing Plan](https://miro.medium.com/v2/resize:fit:1250/1*2Dn2hIyuUCD8SbT4lxy0sw.png)\n*Making Pricing Plan*\n\nI added two variants with the pricing currency set to my country (PKR) by default for test mode (you can set it to USD if preferred) and also make sure to set (Subscription — charge on fee) for each variant.\n\nOnce you’ve done that, save your changes, and the pricing plans will be displayed in the Products tab.\n\n![Products Info](https://miro.medium.com/v2/resize:fit:875/1*3QO-UKyEQmDqOvY0XyD75g.png)\n*Products Info*\n\nYou can get your Store ID and API Key under the Settings tab. If no API key is present, make sure to create one.\n\n![Getting store ID and API Key](https://miro.medium.com/v2/resize:fit:875/1*rt0tLGoWfZyohOjse2Qo2Q.png)\n*Getting store ID and API Key*\n\nYou also need to get both of your subscription plan variant IDs, which you can obtain by clicking the three dots and copying each variant ID.\n\n![Variant IDs](https://miro.medium.com/v2/resize:fit:875/1*x9RPDuEAhh_Ub_FsNKHleA.png)\n*Variant IDs*\n\nNext, to get a checkout page link for your product, click on the Share button of that product and copy the provided link.\n\n![Get Checkout link](https://miro.medium.com/v2/resize:fit:1250/1*gMKCUx6DuQLY4fO0vAxOWQ.png)\n*Get Checkout link*\n\nOnce you have them, replace the corresponding values in your environment variables.\n\n```bash\n# Lemon Squeezy Credentials\nLEMONSQUEEZY_API_KEY=\"YOUR_LEMONSQUEEZY_API_KEY\"\nLEMONSQUEEZY_STORE_ID='YOUR_LEMONSQUEEZY_STORE_ID'\nLEMONSQUEEZY_STANDARD_VARIANT_ID='YOUR_STANDARD_PLAN_VARIANT_ID'\nLEMONSQUEEZY_PRO_VARIANT_ID='YOUR_PRO_PLAN_VARIANT_ID'\nLEMONSQUEEZY_CHECKOUT_LINK='YOUR_SINGLE_LEMONSQUEEZY_CHECKOUT_LINK'\n\n# Set it to anything (will be use later)\nLEMONSQUEEZY_WEBHOOK_SECRET='YOUR_LEMONSQUEEZY_WEBHOOK_SIGNING_SECRET'\n```\n\nYou can set **webhook environment variable** of lemonSqueezy (testing transaction) can be set to any value which we will be using later.\n\n## LLM API Configuration\n\nI will be using the Nebius AI platform, which operates under the OpenAI module, similar to how other platforms like Together AI or Ollama function.\n\nThere’s not much to configure, you just need to set the base URL and specify the model name you want to integrate into the chatbot.\n\nYou can use any LLM provider as long as it supports the OpenAI module. Alternatively, you can use a locally downloaded Hugging Face LLM, but for that, you’ll need to make some adjustments in the code.\n\n```bash\n# Nebius API Key (or your LLM provider's details)\nNEBIUS_API_KEY='YOUR_NEBIUS_API_KEY'\nNEBIUS_BASE_URL=\"https://api.studio.nebius.com/v1/\" # Or your provider's base URL\nNEBIUS_MODEL=\"microsoft/Phi-3-mini-4k-instruct\" # Or your chosen model\n```\n\nSo now that we have configured everything, it’s time to start building our web app.\n\n## Setup and Preparation\n\nFirst, we need to make the app aware of our environment variables.\n\nLet’s import the necessary libraries and establish the connection between the Flask app and the environment variables:\n\n```python\n# app.py\nimport os\nimport requests\nimport json\nimport hmac\nimport hashlib\nfrom datetime import datetime, timedelta, timezone, date\nfrom flask import Flask, render_template, request, redirect, url_for, session, flash, abort\nfrom supabase import create_client, Client\nfrom dotenv import load_dotenv\nfrom werkzeug.security import generate_password_hash, check_password_hash\nfrom openai import OpenAI\n\n# Load environment variables from .env file\nload_dotenv()\n\napp = Flask(__name__)\n\n# --- Supabase Configuration ---\nsupabase_url = os.environ.get(\"SUPABASE_URL\")\nsupabase_key = os.environ.get(\"SUPABASE_KEY\")\n\nsupabase: Client = create_client(supabase_url, supabase_key)\n\n# --- Nebius/OpenAI Client Configuration ---\nNEBIUS_BASE_URL = os.environ.get(\"NEBIUS_BASE_URL\")\nNEBIUS_API_KEY = os.environ.get(\"NEBIUS_API_KEY\")\nNEBIUS_MODEL = os.environ.get(\"NEBIUS_MODEL\")\n\nnebius_client = OpenAI(\n            base_url=NEBIUS_BASE_URL,\n            api_key=NEBIUS_API_KEY\n        )\n\n# --- Lemon Squeezy Configuration ---\nLEMONSQUEEZY_API_KEY = os.environ.get(\"LEMONSQUEEZY_API_KEY\")\nLEMONSQUEEZY_STORE_ID = os.environ.get(\"LEMONSQUEEZY_STORE_ID\")\nLEMONSQUEEZY_STANDARD_VARIANT_ID = os.environ.get(\"LEMONSQUEEZY_STANDARD_VARIANT_ID\")\nLEMONSQUEEZY_PRO_VARIANT_ID = os.environ.get(\"LEMONSQUEEZY_PRO_VARIANT_ID\")\nLEMONSQUEEZY_WEBHOOK_SECRET = os.environ.get(\"LEMONSQUEEZY_WEBHOOK_SECRET\")\nLEMONSQUEEZY_API_URL = \"https://api.lemonsqueezy.com/v1\"\nLEMONSQUEEZY_CHECKOUT_LINK_BASE = os.environ.get(\"LEMONSQUEEZY_CHECKOUT_LINK\", \"YOUR_SINGLE_CHECKOUT_LINK_HERE\") # Load base link\n\n\n# --- Constants ---\nFREE_PLAN_HOURLY_LIMIT = 2\nSTANDARD_PLAN_MONTHLY_LIMIT = 100\n# Pro plan is effectively unlimited, checked by the is_pro_plan flag\n```\n\nSo …\n\n*   We import necessary libraries (`Flask`, `Supabase`, `OpenAI`, `requests`, `datetime`, etc.).\n*   `load_dotenv()` reads the variables from our `.env` file.\n*   We initialize the `Supabase` and `Nebius` clients using the environment variables. Error handling is included.\n*   We load all the `Lemon Squeezy` configuration variables, including the variant IDs and the single checkout link base URL.\n*   Plan limit constants are defined for clarity.\n\n## Creating a Lemon Squeezy Customer\n\nDuring signup, we want to create a corresponding customer record in Lemon Squeezy *before* we save the user to our own database.\n\nThis ensures we have a link for future subscription events. Let’s create a helper function for this API call.\n\n```python\n# app.py (continued)\n\n# --- Helper Function: Create Lemon Squeezy Customer ---\ndef create_lemon_squeezy_customer(email, name):\n    \"\"\"Creates a customer record in Lemon Squeezy via their API.\"\"\"\n\n    # Construct the API endpoint URL\n    customer_url = f\"{LEMONSQUEEZY_API_URL}/customers\"\n\n    # Set required headers, including authorization with our API key\n    headers = {\n        'Accept': 'application/vnd.api+json',\n        'Content-Type': 'application/vnd.api+json',\n        'Authorization': f'Bearer {LEMONSQUEEZY_API_KEY}'\n    }\n\n    # Define the data payload according to Lemon Squeezy API specs\n    payload = {\n        \"data\": {\n            \"type\": \"customers\",\n            \"attributes\": {\n                \"name\": name, # Name for the customer\n                \"email\": email, # Email for the customer\n            },\n            \"relationships\": {\n                \"store\": { # Link this customer to our specific store\n                    \"data\": {\n                        \"type\": \"stores\",\n                        \"id\": str(LEMONSQUEEZY_STORE_ID) # Store ID needs to be a string\n                    }\n                }\n            }\n        }\n    }\n\n    # Make the POST request to Lemon Squeezy API\n    print(f\"Attempting to create LS customer for: {email}\")\n    response = requests.post(customer_url, headers=headers, json=payload)\n\n    # Check if the API call was successful (status code 2xx)\n    if response.status_code == 201: # 201 Created is the expected success code\n        customer_data = response.json()\n        customer_id = customer_data.get(\"data\", {}).get(\"id\")\n        if customer_id:\n            print(f\"Successfully created LS customer ID: {customer_id}\")\n            return customer_id, None # Return the new customer ID and no error\n        else:\n            # API succeeded but response format was unexpected\n            print(f\"LS Success Response missing customer ID: {customer_data}\")\n            return None, \"Could not extract customer ID from successful API response.\"\n    else:\n        # API call failed (status code 4xx or 5xx)\n        print(f\"Error creating LS customer. Status: {response.status_code}, Response: {response.text}\")\n```\n\nSo …\n\n*   This function `create_lemon_squeezy_customer` takes the user's `email` and a `name` (we'll generate this from the `email`).\n*   It defines the correct API endpoint (`/v1/customers`) and the necessary headers, including our `LEMONSQUEEZY_API_KEY` for authorization.\n*   It constructs the payload containing the customer's details and links it to our `LEMONSQUEEZY_STORE_ID`.\n*   Using the `requests` library, it sends a `POST` request to the Lemon Squeezy API.\n*   It checks the `response.status_code`. A `201 Created` status means success.\n*   If successful, it extracts the `customer_id` from the JSON response and returns it.\n*   If there's an error (like a `4xx` or `5xx` status code), it attempts to extract a specific error message.\n\n## User Signup Route\n\nFor our web app, we need to create a signup route that allows users to create an account. The user data will be stored in our Supabase database.\n\n```python\n# app.py (continued)\n\n# --- Routes ---\n\n@app.route('/signup', methods=['GET', 'POST'])\ndef signup():\n    \"\"\"Handles user signup.\"\"\"\n    # If the request is a POST (user submitted the form)\n    if request.method == 'POST':\n        # Get email and password from the submitted form data\n        email = request.form.get('email')\n        password = request.form.get('password')\n\n        # Simple validation\n        if not email or not password:\n            flash('Email and password are required.', 'error')\n            return redirect(url_for('signup'))\n\n        # Query the 'users' table for an entry matching the email\n        existing_user_check = supabase.table('users').select('id', count='exact').eq('email', email).execute()\n\n        # If count \u003e 0, the email is already registered\n        if existing_user_check.count \u003e 0:\n            flash('Email address already registered.', 'error')\n            print(f\"Signup failed: {email} already exists.\")\n            return redirect(url_for('signup'))\n\n        # --- Create Lemon Squeezy Customer *Before* Supabase User ---\n        # Generate a simple name from the email for LS\n        name = email.split('@')[0].replace('.', '').replace('+', '')\n        ls_customer_id, ls_error = create_lemon_squeezy_customer(email, name)\n\n        # --- Create User in Supabase Database ---\n        # Hash the user's password securely before storing it\n        password_hash = generate_password_hash(password)\n\n        print(f\"Creating Supabase user for {email} linked to LS ID {ls_customer_id}\")\n        # Prepare the data for the new user row\n        user_data = {\n            'email': email,\n            'password_hash': password_hash,\n            'lemonsqueezy_customer_id': ls_customer_id,\n            'is_free_plan': True, # Default to Free plan\n            'is_standard_plan': False,\n            'is_pro_plan': False,\n            'message_count': 0,\n            'messages_this_hour': 0,\n            'last_message_timestamp': None,\n            'messages_this_month': 0,\n            'usage_reset_date': None\n        }\n        # Insert the new user data into the 'users' table\n        insert_result = supabase.table('users').insert(user_data).execute()\n\n    # If the request is GET (user just visiting the page), render the signup form\n    return render_template('signup.html')\n```\n\nSo the Signup Process works like this …\n\n*   The route listens on `/signup` for both `GET` (displaying the form) and `POST` (processing signup) requests. On `POST`, it gets the `email` and `password` from the form.\n*   It checks `Supabase` (`supabase.table('users').select(...)`) to see if a user with that email already exists.\n*   Crucially, it calls our `create_lemon_squeezy_customer` helper function next. We want to ensure the payment system profile is created before committing to our database.\n*   If the Lemon Squeezy step succeeds, it securely hashes the password using `generate_password_hash` from `Werkzeug`.\n*   It prepares a `user_data` dictionary containing all the necessary fields for our `Supabase` users table, defaulting the user to the `is_free_plan`.\n*   It inserts this data into Supabase using `supabase.table('users').insert(...)`. If the insert is successful, it redirects the user to the login page (`url_for('login')`) with a success message (`flash`).\n*   If any step fails, it shows an error message (`flash`) and redirects back to the signup page.\n*   On `GET`, it simply shows the `signup.html` template.\n\n## User Login Route\n\nNow that we have coded the signup route, which also creates a LemonSqueezy user and links it with the ID, next we need to create a login route for that user.\n\n```python\n# app.py (continued)\n\n@app.route('/login', methods=['GET', 'POST'])\ndef login():\n    \"\"\"Handles user login.\"\"\"\n    if request.method == 'POST':\n        email = request.form.get('email')\n        password = request.form.get('password')\n\n        if not email or not password:\n            flash('Email and password are required.', 'error')\n            return redirect(url_for('login'))\n\n        print(f\"Login attempt for: {email}\")\n        # --- Fetch User from Supabase ---\n        # Select the user matching the email, including their ID and hashed password\n        user_query = supabase.table('users').select('id, email, password_hash').eq('email', email).execute()\n\n        # Check if any user was found (user_query.data will be a list)\n        if user_query.data:\n            user = user_query.data[0] # Get the first (and should be only) user found\n\n            # --- Verify Password ---\n            # Compare the provided password with the securely stored hash\n            if check_password_hash(user['password_hash'], password):\n                # Password matches! Store user info in the session\n                session['user_email'] = user['email']\n                session['user_id'] = user['id'] # Store Supabase user ID\n                flash('Login successful!', 'success')\n                # Redirect to the main chatbot homepage\n                return redirect(url_for('home'))\n            else:\n                # Password does not match\n                flash('Invalid email or password.', 'error')\n                return redirect(url_for('login'))\n        else:\n            # No user found with that email\n            print(f\"Login failed for {email}: User not found\")\n            flash('Invalid email or password.', 'error') # Use generic error for security\n            return redirect(url_for('login'))\n\n    # Render the login page for GET requests\n    return render_template('login.html')\n```\n\nThe login process is pretty simple this is how it is working …\n\n*   Listens on `/login` for `GET` and `POST`.\n*   On `POST`, gets `email` and `password`.\n*   Queries `Supabase` for a user matching the `email`, retrieving their `id` and `password_hash`.\n*   If a user is found, it uses `check_password_hash` to safely compare the submitted password against the stored hash.\n*   If the password matches, it stores the `user_email` and `user_id` in the Flask session. The session securely remembers the user is logged in across different page requests.\n*   Redirects to the main home page (`url_for('home')`) on success.\n*   Shows error messages for incorrect credentials or if the user isn't found.\n*   On `GET`, it shows the `login.html` template.\n\n## User Logout Route\n\nWe also need a simple logout route that allows the user to log out of our web app.\n\n```python\n# app.py (continued)\n\n@app.route('/logout')\ndef logout():\n    \"\"\"Handles user logout.\"\"\"\n    user_email = session.get('user_email', 'Unknown User') # Get email for logging before removal\n    # Remove user information from the session\n    session.pop('user_email', None)\n    session.pop('user_id', None)\n    print(f\"User {user_email} logged out.\")\n    flash('You have been logged out.', 'success')\n    # Redirect the user back to the login page\n    return redirect(url_for('login'))\n```\n\n*   Listens on `/logout`.\n*   It uses `session.pop()` to remove the `user_email` and `user_id` keys from the session data, effectively logging the user out.\n*   Flashes a confirmation message.\n*   Redirects the user to the login page.\n\n## Data Fetching \u0026 Monthly Reset\n\nNow we need to code a primary route where users interact with the chatbot after logging in. It handles displaying the interface (`GET` request) and processing messages (`POST` request).\n\nFirst, we define the route and ensure the user is actually logged in by checking the session.\n\n```python\n# app.py (continued)\n\n@app.route('/', methods=['GET', 'POST'])\ndef home():\n    \"\"\"Displays the main chatbot page and handles message sending.\"\"\"\n\n    # --- 1. Check if user is logged in ---\n    # If 'user_id' is not found in the session, they aren't logged in\n    if 'user_id' not in session:\n        flash('Please login to access the chatbot.', 'error')\n        return redirect(url_for('login')) # Send them back to login\n\n    # Get the logged-in user's ID from the session\n    user_id = session['user_id']\n    print(f\"Accessing home page for User ID: {user_id}\")\n\n    # Initialize variables we'll need for the template later\n    bot_response = None\n    user_message = None\n    message_limit_reached = False\n    plan_name = \"Free\" # Sensible defaults\n    current_limit_info = f\"{FREE_PLAN_HOURLY_LIMIT}/hour limit\"\n\n    # --- 2. Fetch Current User Data from Supabase ---\n    # We need the user's email, plan status, and current usage counts\n    # Use .single() as we expect exactly one user row for the logged-in ID\n    user_data_query = supabase.table('users').select(\n        'email, message_count, is_free_plan, is_standard_plan, is_pro_plan, '\n        'messages_this_hour, last_message_timestamp, messages_this_month, usage_reset_date'\n    ).eq('id', user_id).single().execute()\n\n    # Handle case where user data might be missing (e.g., DB issue)\n    if not user_data_query.data:\n        flash('Error fetching your user data. Please login again.', 'error')\n        print(f\"Error: Could not fetch data for User ID: {user_id}\")\n        session.clear() # Log out user if their data is corrupt/missing\n        return redirect(url_for('login'))\n\n    user_data = user_data_query.data\n    # print(f\"User data fetched: {user_data}\") # Optional: Log fetched data (be careful with sensitive info)\n```\n\nThe route checks if the user is logged in via session, redirecting to the login page if not. It then queries the Supabase users table for the logged-in user’s plan and usage limits, handling errors if the query fails.\n\nNext, we assign the fetched data to local variables for easier access and convert data types where needed (like timestamp strings to datetime objects). We also implement the logic to automatically reset monthly message counts for paid plans.\n\n```python\n# app.py (continued from Part 1)\n\n    # --- 3. Assign local variables \u0026 Convert Types ---\n    # Assign data from the fetched dictionary to Python variables\n    user_email = user_data['email']\n    is_free_plan = user_data['is_free_plan']\n    is_standard_plan = user_data['is_standard_plan']\n    is_pro_plan = user_data['is_pro_plan']\n\n    # These counters might be modified by the reset logic or message sending\n    message_count = user_data['message_count']\n    messages_this_hour = user_data['messages_this_hour']\n    messages_this_month = user_data['messages_this_month']\n\n    # Get the string representations from the database\n    last_message_timestamp_str = user_data['last_message_timestamp']\n    usage_reset_date_str = user_data['usage_reset_date']\n    # Convert strings to actual datetime/date objects if they exist\n    last_message_timestamp = datetime.fromisoformat(last_message_timestamp_str) if last_message_timestamp_str else None\n    usage_reset_date = date.fromisoformat(usage_reset_date_str) if usage_reset_date_str else None\n\n    print(f\"User Plan Status: Free={is_free_plan}, Std={is_standard_plan}, Pro={is_pro_plan}\")\n    print(f\"Usage Counts: Hour={messages_this_hour}, Month={messages_this_month}, ResetDate={usage_reset_date}\")\n\n    # --- 4. Check and Perform Monthly Usage Reset ---\n    today = date.today() # Get today's date\n    # Check only if user is on Standard or Pro AND a reset date is set AND today is on or after that date\n    if (is_standard_plan or is_pro_plan) and usage_reset_date and today \u003e= usage_reset_date:\n        print(f\"--- Resetting monthly usage for User ID: {user_id} ---\")\n        print(f\"Today ({today}) \u003e= Reset Date ({usage_reset_date})\")\n        # Reset the counter in our local variable FIRST\n        messages_this_month = 0\n        # Prepare the database update\n        reset_update_payload = {'messages_this_month': 0}\n        # Update the database in the background\n        print(f\"Updating DB: Resetting messages_this_month to 0\")\n        update_reset = supabase.table('users').update(reset_update_payload).eq('id', user_id).execute()\n```\n\nThe `user_data` dictionary is unpacked, converting the `last_message_timestamp` and `usage_reset_date` into Python `datetime` and `date` objects for accurate comparisons.\n\nIf the user is on a paid plan, the reset date has passed, and conditions are met, the message counter is reset, with the next reset date set by the Lemon Squeezy webhook.\n\n## Determine Plan Info \u0026 Limits\n\nNow we determine user-friendly strings for the plan and limits based on the flags, and then we start handling the case where the user actually submitted a message (a POST request).\n\n```python\n# app.py (continued from Part 2)\n# --- 5. Determine Current Plan Info for Display ---\n# Set user-friendly strings based on the plan flags\nif is_pro_plan:\n    plan_name = \"Pro\"\n    current_limit_info = \"Unlimited messages\"\nelif is_standard_plan:\n    plan_name = \"Standard\"\n    # Use the 'messages_this_month' value (which might have just been reset)\n    current_limit_info = f\"{messages_this_month}/{STANDARD_PLAN_MONTHLY_LIMIT} messages this month\"\nelse: # Free plan (default if not Pro or Standard)\n    plan_name = \"Free\"\n    current_limit_info = f\"{FREE_PLAN_HOURLY_LIMIT}/hour message limit\"\nprint(f\"User ID {user_id} is on {plan_name} plan. UI Limit info: {current_limit_info}\")\n\n# --- 6. Handle Message Submission (POST Request) ---\nif request.method == 'POST':\n    # User submitted the chat form\n    user_message = request.form.get('user_input') # Get the text from the textarea\n    print(f\"User {user_id} ({plan_name}) submitted message: '{user_message[:50]}...'\")\n\n    # Proceed only if the message isn't empty and the AI client is ready\n    if user_message and nebius_client:\n        allow_message = True # Start by assuming the message is allowed\n        now_dt = datetime.now(timezone.utc) # Get the current time in UTC for comparisons\n        usage_update_payload = {} # Prepare an empty dictionary to hold DB updates needed for *this* message\n\n        # --- Check Plan Limits --- (This part will be detailed next)\n```\n\nBased on the user’s plan flag (`is_pro_plan`, `is_standard_plan`, `is_free_plan`), the `plan_name` and `current_limit_info` are set and displayed on the chat page, with the Standard plan showing the updated `messages_this_month`.\n\nIf the request method is `POST`, it processes the user's chat message, checking for a non-empty message and successful AI client initialization, then prepares for potential database updates.\n\nInside the POST handler, this is where we enforce the rules for each subscription plan.\n\n```python\n# app.py (continued inside the `if request.method == 'POST'` block)\n# --- Check Plan Limits ---\nif is_pro_plan:\n    # Pro plan users have no message limits\n    print(f\"User {user_id} (Pro): No limit check needed.\")\n    pass # allow_message remains True\n\nelif is_standard_plan:\n    # Check against the monthly limit\n    print(f\"User {user_id} (Standard): Checking monthly limit ({messages_this_month}/{STANDARD_PLAN_MONTHLY_LIMIT}).\")\n    if messages_this_month \u003e= STANDARD_PLAN_MONTHLY_LIMIT:\n        allow_message = False # Block the message\n        message_limit_reached = True # Set flag for the template\n        flash(f'Standard plan limit ({STANDARD_PLAN_MONTHLY_LIMIT}/month) reached.', 'warning')\n        print(f\"User {user_id} (Standard): Limit REACHED.\")\n\nelif is_free_plan:\n    # Check the hourly limit\n    print(f\"User {user_id} (Free): Checking hourly limit.\")\n    # Check if there *was* a last message and if it was less than 1 hour ago\n    if last_message_timestamp and (now_dt - last_message_timestamp \u003c timedelta(hours=1)):\n        print(f\"  Within 1 hour. Messages this hour: {messages_this_hour}\")\n        # Check if they've already sent the max allowed in this hour window\n        if messages_this_hour \u003e= FREE_PLAN_HOURLY_LIMIT:\n            allow_message = False # Block the message\n            message_limit_reached = True\n            flash(f'Free plan limit ({FREE_PLAN_HOURLY_LIMIT}/hour) reached.', 'warning')\n            print(f\"User {user_id} (Free): Limit REACHED this hour.\")\n        else:\n            # Allow message, increment the counter *for this specific hour*\n            messages_this_hour += 1\n            print(f\"  Incrementing hourly count to: {messages_this_hour}\")\n    else:\n        # It's either the very first message, or the last message was \u003e 1 hour ago\n        # Start a new hourly count\n        print(f\"  New hour window or first message. Setting hourly count to 1.\")\n        messages_this_hour = 1\n    # If the message is allowed, update the last message timestamp *locally*\n    # It will only be saved to DB if the AI call succeeds later\n    if allow_message:\n        last_message_timestamp = now_dt\n\n# --- Process Message if Allowed --- (This part will be detailed next)\n```\n\nInside the if `user_message` and `nebius_client` block, the logic checks the user's plan:\n\n*   **Pro Plan**: No changes, `allow_message` remains True.\n*   **Standard Plan**: Compares `messages_this_month` with the plan's limit and disables the message if the limit is reached.\n*   **Free Plan**: If the user has sent a message before and the last message was within the last hour, it checks if the hourly limit is reached; if so, blocks the message. Otherwise, it updates the message count and the timestamp.\n\n## Processing Allowed Messages\n\nIf the limit checks determine `allow_message` is still `True`, we proceed to call the AI and update the database.\n\n```python\n# app.py (continued inside the `if request.method == 'POST'` block)\n\n# --- Process Message if Allowed by Limits ---\nif allow_message:\n    print(f\"User {user_id}: Message allowed. Preparing usage update and calling AI.\")\n    # --- Prepare DB Update Payload ---\n    # Increment lifetime counter (always)\n    message_count += 1\n    usage_update_payload['message_count'] = message_count\n\n    # Increment plan-specific counters and add to payload\n    if is_standard_plan:\n        messages_this_month += 1 # Use the incremented local value\n        usage_update_payload['messages_this_month'] = messages_this_month\n        print(f\"  Incrementing monthly count to {messages_this_month}\")\n    elif is_free_plan:\n        # Add the updated hourly count and timestamp to the payload\n        usage_update_payload['messages_this_hour'] = messages_this_hour\n        usage_update_payload['last_message_timestamp'] = last_message_timestamp.isoformat() # Store as ISO string\n        print(f\"  Updating hourly count to {messages_this_hour} and timestamp.\")\n\n    # --- Call the AI Model ---\n    # Use a try-except block specifically for the external API call\n    try:\n        print(f\"  Calling Nebius API for user {user_id}...\")\n        response = nebius_client.chat.completions.create(\n            model=NEBIUS_MODEL,\n            messages=[{\"role\": \"user\", \"content\": user_message}]\n        )\n        # Extract the text response from the AI\n        bot_response = response.choices[0].message.content.strip()\n        print(f\"  Nebius API Success. Response: '{bot_response[:50]}...'\")\n\n        # --- Update Usage Counts in Supabase (Only if AI call succeeded) ---\n        # Use another try-except for the database update\n        try:\n            print(f\"  Updating Supabase usage for user {user_id} with payload: {usage_update_payload}\")\n            update_usage = supabase.table('users').update(usage_update_payload).eq('id', user_id).execute()\n            # Check if the update query itself failed\n            if not update_usage.data:\n                    print(f\"  Warning: Supabase update query failed for user {user_id} after AI call. Response: {update_usage}\")\n                    flash('Could not save message usage stats.', 'warning')\n            else:\n                    print(f\"  Successfully updated usage stats in Supabase.\")\n                    # Immediately update the display string if Standard plan count changed\n                    if is_standard_plan:\n                        current_limit_info = f\"{messages_this_month}/{STANDARD_PLAN_MONTHLY_LIMIT} messages this month\"\n\n        except Exception as e:\n            # Handle errors during the database update\n            print(f\"  ERROR updating Supabase usage for user {user_id} after AI call: {e}\")\n            flash('Error saving message usage progress.', 'error')\n            # Note: Message was SENT to user, but DB counts might be inconsistent now.\n\n    except Exception as e:\n        # Handle errors during the AI API call itself\n        print(f\"  ERROR calling Nebius API or processing response: {e}\")\n        bot_response = \"Sorry, there was an error communicating with the AI.\"\n        # IMPORTANT: If AI call fails, we DO NOT proceed to update Supabase counts.\n        # The usage_update_payload was prepared but never sent to DB.\n\nelse: # Message was blocked by limits\n        print(f\"User {user_id}: Message blocked due to plan limits. No AI call or DB update.\")\n\n# --- Handle cases where message was empty or AI client wasn't ready ---\nelif not user_message:\nflash(\"Please enter a message.\", \"info\")\nprint(f\"User {user_id} submitted empty message.\")\nelse: # nebius_client is None\nbot_response = \"Chatbot service is temporarily unavailable.\"\nprint(\"Attempted to send message but AI client is not configured.\")\n\n# --- Prepare Checkout Link --- (This part will be detailed next)\n# --- Render the Page --- (This part will be detailed next)\n\n```\n\nIf `allow_message` is True, the code increments local counters (message_count, messages_this_month, messages_this_hour) and adds the updated values to `usage_update_payload`.\n\nIt then sends the user's message to the AI using `nebius_client.chat.completions.create` within a try...except block to handle errors.\n\n*   If the AI call succeeds, it attempts to update the user's record in Supabase with the payload, also wrapped in a try...except block.\n*   If the AI call fails, it sets an error message and skips the database update; if the database update fails after the AI call, it logs a warning.\n\n## Checkout Link \u0026 Rendering\n\nFinally, still within the main `/` route function, we prepare the checkout link with the prefilled `email` and render the `home.html` template, passing all the necessary data.\n\n```python\n# app.py (continued from Part 5, now outside the `if request.method == 'POST'` block)\n# --- 7. Prepare THE SINGLE Checkout Link with Prefill ---\n# Default to '#' if the base link isn't configured properly in .env\nupgrade_checkout_link = \"#\"\n# Check if the base link is set and not the placeholder value\nif LEMONSQUEEZY_CHECKOUT_LINK_BASE and LEMONSQUEEZY_CHECKOUT_LINK_BASE != \"YOUR_SINGLE_CHECKOUT_LINK_HERE\":\n    # URL-encode the user's email to handle special characters safely in the URL\n    encoded_email = quote(user_email)\n    # Append the email as a query parameter for Lemon Squeezy prefill\n    upgrade_checkout_link = f\"{LEMONSQUEEZY_CHECKOUT_LINK_BASE}?checkout[email]={encoded_email}\"\n    print(f\"Generated checkout link: {upgrade_checkout_link}\")\nelse:\n    print(\"Warning: Lemon Squeezy checkout link not configured in .env. Upgrade buttons will not work correctly.\")\n\n# --- 8. Render the Page ---\n# Pass all computed and fetched data to the Jinja template\nprint(f\"Rendering home template for user {user_id}. Plan: {plan_name}. Limit info: '{current_limit_info}'\")\nreturn render_template('home.html',\n                        user_email=user_email,\n                        plan_name=plan_name,\n                        current_limit_info=current_limit_info, # The user-friendly limit string\n                        is_free_plan=is_free_plan,           # Pass boolean flags for template logic\n                        is_standard_plan=is_standard_plan,\n                        is_pro_plan=is_pro_plan,\n                        user_message=user_message,         # The user's most recent message (if POST)\n                        bot_response=bot_response,           # The AI's response (if POST and successful)\n                        message_limit_reached=message_limit_reached, # Boolean to disable Send button\n                        upgrade_checkout_link=upgrade_checkout_link, # The single prefilled checkout link\n                        STANDARD_PLAN_MONTHLY_LIMIT=STANDARD_PLAN_MONTHLY_LIMIT # Pass constant for display\n                        )\n```\n\nThe code first prepares a checkout link by encoding the user’s email using `urllib.parse.quote` and appending it to the base checkout URL (`LEMONSQUEEZY_CHECKOUT_LINK_BASE`).\n\nIf the link isn't properly configured, a warning is printed. Finally, it calls Flask's `render_template` function, passing `home.html` as the template and all necessary variables as keyword arguments, allowing Flask's Jinja engine to generate the HTML dynamically for the user's browser.\n\nThis completes the main application route (`/`).\n\n## Lemon Squeezy Webhook Handler\n\nThis route doesn’t interact directly with the user’s browser. Instead, it acts as a listener, waiting for notifications (webhooks) sent directly from the Lemon Squeezy platform whenever important events related to subscriptions occur in our store (like a new subscription starting, a payment succeeding, or a subscription being cancelled).\n\nThe *most critical* part of handling webhooks is ensuring they actually came from Lemon Squeezy and not from a malicious actor trying to fake an upgrade. This is done using a **signing secret**.\n\n```python\n# app.py (continued)\n\n# --- Lemon Squeezy Webhook Handler ---\n@app.route('/webhook/lemonsqueezy', methods=['POST'])\ndef lemonsqueezy_webhook():\n    \"\"\"Handles incoming webhooks from Lemon Squeezy.\"\"\"\n\n    # --- 1. Verify Webhook Signature (Security Check!) ---\n    print(\"--- Received Incoming Request on /webhook/lemonsqueezy ---\")\n    # Get the secret key we stored in our .env file\n    secret = LEMONSQUEEZY_WEBHOOK_SECRET\n\n    # Lemon Squeezy sends the signature in the 'X-Signature' header\n    signature = request.headers.get('X-Signature')\n    if not signature:\n        print(\"Webhook Error: X-Signature header missing from request.\")\n        # Abort with 400 Bad Request as the request is malformed\n        abort(400)\n\n    # We need the raw request body (as bytes) to calculate the signature correctly\n    payload = request.get_data()\n\n    # Calculate the expected signature using HMAC-SHA256 algorithm\n    # The secret key and the raw payload are used\n    computed_hash = hmac.new(secret.encode('utf-8'), payload, hashlib.sha256)\n    digest = computed_hash.hexdigest() # The resulting signature as a hex string\n\n    # If we reach here, the signature is valid!\n    print(\"Webhook signature verified successfully.\")\n\n    # --- 2. Process the Verified Payload --- (Detailed next)\n    # ... rest of the webhook logic ...\n```\n\nThe route listens for POST requests at `/webhook/lemonsqueezy` and retrieves the `LEMONSQUEEZY_WEBHOOK_SECRET` from environment variables.\n\nIf the secret is missing, it returns a 500 error. It checks for the `X-Signature` header; if absent, it returns a 400 error.\n\nThe raw request body is retrieved as bytes, and using Python’s `hmac` and `hashlib`, it calculates the expected HMAC-SHA256 signature with the secret key, ensuring the request is valid.\n\nOnce the signature is verified, we need to understand the content of the notification. We parse the JSON data and identify what kind of event occurred.\n\n```python\n# app.py (continued inside the lemonsqueezy_webhook function, after signature verification)\n\n# --- 2. Process the Verified Payload ---\n# Decode the raw payload (bytes) into a Python dictionary\nevent_data = json.loads(payload.decode('utf-8'))\nprint(f\"Webhook Payload successfully parsed as JSON.\")\n\n# Get event details from headers and payload metadata for logging/logic\nevent_name = request.headers.get('X-Event-Name', 'unknown_event')\nwebhook_id = event_data.get('meta', {}).get('webhook_id', 'N/A') # Useful for tracing\n# Get the main 'data' object which contains event-specific details\ndata_obj = event_data.get('data')\n\n# Get the 'attributes' dictionary within 'data'\nattributes = data_obj.get('attributes', {})\n\n# Get the Lemon Squeezy customer ID (needed to find our user)\nls_customer_id = attributes.get('customer_id')\nls_customer_id_str = str(ls_customer_id) # Ensure it's a string\n\n# --- 3. Handle Specific Subscription Events --- (Detailed next)\n# ... logic based on event_name ...\n```\n\nThe raw payload is parsed into a dictionary. The event name and `webhook_id` are extracted for processing or debugging. The `customer_id` is extracted, converted to a string, and used to find the corresponding user in Supabase.\n\nThen comes the core logic for when a user buys a plan or their subscription renews. We need to update their plan status in our database and reset their monthly usage.\n\n```python\n# app.py (continued inside the lemonsqueezy_webhook function)\n\n# --- 3. Handle Specific Subscription Events ---\ntry: # Add a try block for the event processing logic\n\n    # --- Event: Subscription Created or Updated (Became Active) ---\n    # Check if the event is one that signifies an active subscription starting or continuing\n    if event_name in ['subscription_created', 'subscription_updated']:\n        # Extract details specific to subscription events\n        variant_id = attributes.get('variant_id')\n        renews_at_str = attributes.get('renews_at') # Timestamp for the *next* renewal\n        status = attributes.get('status')         # Current status of the subscription\n\n        print(f\"Processing {event_name} for LS Customer {ls_customer_id_str}. Status: {status}\")\n\n        # We only want to grant access/reset limits if the subscription is currently 'active'\n        if status == 'active':\n            if not variant_id:\n                print(f\"Webhook Warning ({webhook_id}): 'variant_id' missing for active sub.\")\n                return 'Warning: Missing variant ID', 202\n\n            variant_id_str = str(variant_id) # Ensure string comparison\n\n            # --- Calculate Next Usage Reset Date ---\n            next_reset_date = None\n            if renews_at_str:\n                # Parse the ISO timestamp string from Lemon Squeezy (usually UTC, ending in 'Z')\n                # Convert to a timezone-aware datetime object, then get just the date part\n                next_reset_date = datetime.fromisoformat(renews_at_str.replace('Z', '+00:00')).astimezone(timezone.utc).date()\n                print(f\"  Calculated next reset date: {next_reset_date}\")\n            else:\n                # If 'renews_at' is missing (should be rare for active subs), create a fallback\n                next_reset_date = date.today() + timedelta(days=30) # Approx. 1 month\n                print(f\"  Warning: 'renews_at' missing. Using fallback reset date: {next_reset_date}\")\n\n            # --- Prepare Supabase Update Payload ---\n            # This dictionary holds the changes we want to make to the user's record\n            update_payload = {\n                'is_free_plan': False,           # They are on a paid plan\n                'messages_this_month': 0,        # RESET the monthly counter\n                'messages_this_hour': 0,         # Reset hourly counter (not used, but good practice)\n                'last_message_timestamp': None,  # Clear hourly timestamp\n                'usage_reset_date': next_reset_date.isoformat() if next_reset_date else None # Store YYYY-MM-DD\n            }\n\n            # --- Set Correct Plan Flag Based on Variant ID ---\n            # Compare the variant ID from the webhook with the IDs stored in our .env\n            if variant_id_str == str(LEMONSQUEEZY_STANDARD_VARIANT_ID):\n                print(f\"  Setting plan to Standard (Variant ID: {variant_id_str})\")\n                update_payload['is_standard_plan'] = True\n                update_payload['is_pro_plan'] = False\n            elif variant_id_str == str(LEMONSQUEEZY_PRO_VARIANT_ID):\n                print(f\"  Setting plan to Pro (Variant ID: {variant_id_str})\")\n                update_payload['is_standard_plan'] = False\n                update_payload['is_pro_plan'] = True\n            else:\n                # If the variant ID doesn't match our known plans\n                print(f\"  Ignoring unknown active variant ID: {variant_id_str}\")\n                return 'Event for unknown variant ignored', 200 # Acknowledge, but take no action\n\n            # --- Apply Update to Supabase ---\n            # Find the user by their lemonsqueezy_customer_id and apply the updates\n            print(f\"  Attempting Supabase update for LS Customer {ls_customer_id_str}\")\n            update_result = supabase.table('users').update(update_payload).eq('lemonsqueezy_customer_id', ls_customer_id_str).execute()\n\nexcept Exception as e: # Catch errors during the event processing logic\n    print(f\"Webhook Error ({webhook_id}): Unhandled exception processing event {event_name}: {e}\")\n    import traceback\n    traceback.print_exc() # Log the full error stack trace for debugging\n    abort(500) # Signal internal server error\n```\n\nThe event handling is wrapped in a try…except block to catch errors. It checks if the event is `subscription_created` or `subscription_updated` and extracts relevant details, including `variant_id`, `renews_at`, and status.\n\nOnly active subscriptions trigger access grants or limit resets. The `next_reset_date` is calculated from `renews_at`. The `update_payload` is prepared to reset usage data and set the correct plan based on `variant_id`.\n\nThe database is updated with this payload, and the result is checked to return appropriate status codes (200 for success, 202 for user not found, 500 for errors).\n\nFinally, add the standard code to run the Flask development server when you execute the script.\n\n```python\n# app.py (continued)\n\n# --- Run the Flask App ---\nif __name__ == '__main__':\n    # host='0.0.0.0' makes the server accessible on your local network\n    # debug=True enables auto-reload and provides detailed error pages (disable in production!)\n    print(\"--- Starting Flask Development Server ---\")\n    print(\"Access at: http://127.0.0.1:5000 (or your local IP)\")\n    app.run(debug=True, host='0.0.0.0', port=5000)\n```\n\nSo now that we have implemented all the backend logic, it’s time to start building the frontend of our web app.\n\n## Creating the Frontend Templates\n\nlet’s create a simple signup page first.\n\n```html\n\u003c!-- templates/signup.html --\u003e\n\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n    \u003cmeta charset=\"UTF-8\"\u003e\n    \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\u003e\n    \u003ctitle\u003eSign Up\u003c/title\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n    \u003ch1\u003eSign Up\u003c/h1\u003e\n    \u003c!-- Display flashed messages (like errors or success) --\u003e\n    {% with messages = get_flashed_messages(with_categories=true) %}\n      {% if messages %}\n        {% for category, message in messages %}\n          \u003cdiv class=\"flash {{ category }}\"\u003e{{ message }}\u003c/div\u003e\n        {% endfor %}\n      {% endif %}\n    {% endwith %}\n    \u003c!-- Signup Form --\u003e\n    \u003cform method=\"post\"\u003e\n        \u003clabel for=\"email\"\u003eEmail:\u003c/label\u003e\n        \u003cinput type=\"email\" id=\"email\" name=\"email\" required\u003e\n        \u003clabel for=\"password\"\u003ePassword:\u003c/label\u003e\n        \u003cinput type=\"password\" id=\"password\" name=\"password\" required\u003e\n        \u003cbutton type=\"submit\"\u003eSign Up\u003c/button\u003e\n    \u003c/form\u003e\n    \u003cp\u003eAlready have an account? \u003ca href=\"{{ url_for('login') }}\"\u003eLogin\u003c/a\u003e\u003c/p\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\nIt works in a very simple way: the user signs up and the account is created. After that, the user is redirected to the login page with a flash message saying “Account created successfully.”\n\nThe login page is also simple, but for login, we need to check if the user exists in the database. This requires a bit of logic to validate the user credentials before granting access.\n\n```html\n\u003c!-- templates/login.html --\u003e\n\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n    \u003cmeta charset=\"UTF-8\"\u003e\n    \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\u003e\n    \u003ctitle\u003eLogin\u003c/title\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n    \u003ch1\u003eLogin\u003c/h1\u003e\n    \u003c!-- Display flashed messages --\u003e\n    {% with messages = get_flashed_messages(with_categories=true) %}\n      {% if messages %}\n        {% for category, message in messages %}\n          \u003cdiv class=\"flash {{ category }}\"\u003e{{ message }}\u003c/div\u003e\n        {% endfor %}\n      {% endif %}\n    {% endwith %}\n    \u003c!-- Login Form --\u003e\n    \u003cform method=\"post\"\u003e\n        \u003clabel for=\"email\"\u003eEmail:\u003c/label\u003e\n        \u003cinput type=\"email\" id=\"email\" name=\"email\" required\u003e\n        \u003clabel for=\"password\"\u003ePassword:\u003c/label\u003e\n        \u003cinput type=\"password\" id=\"password\" name=\"password\" required\u003e\n        \u003cbutton type=\"submit\"\u003eLogin\u003c/button\u003e\n    \u003c/form\u003e\n    \u003cp\u003eDon't have an account? \u003ca href=\"{{ url_for('signup') }}\"\u003eSign Up\u003c/a\u003e\u003c/p\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\nYup, it’s also very simple a flash message appear when we no user was found in our database.\n\nNow finally, we need to code the HTML template for our chat app user interface. This will include the layout for the chat window, input field, send button, and any other elements needed for the chatbot interaction.\n\n```html\n\u003c!-- templates/home.html --\u003e\n\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n    \u003cmeta charset=\"UTF-8\"\u003e\n    \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\u003e\n    \u003ctitle\u003eChatbot Home\u003c/title\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n    \u003c!-- Header section displaying user info, plan, usage --\u003e\n    \u003cdiv class=\"header\"\u003e\n        \u003cspan\u003eWelcome, {{ user_email }}!\u003c/span\u003e\n        \u003cspan class=\"plan {{ plan_name.lower() }}\"\u003ePlan: {{ plan_name }}\u003c/span\u003e\n        \u003cspan class=\"usage-info\"\u003eUsage: {{ current_limit_info }}\u003c/span\u003e\n        \u003ca href=\"{{ url_for('logout') }}\" class=\"logout-btn\"\u003eLogout\u003c/a\u003e\n    \u003c/div\u003e\n\n    \u003c!-- Flash Messages Area --\u003e\n    {% with messages = get_flashed_messages(with_categories=true) %}\n      {% if messages %}\n        {% for category, message in messages %}\n          \u003cdiv class=\"flash {{ category }}\"\u003e{{ message }}\u003c/div\u003e\n        {% endfor %}\n      {% endif %}\n    {% endwith %}\n\n    \u003c!-- Upgrade Section - Shown only if on Free or Standard --\u003e\n    {% if is_free_plan or is_standard_plan %}\n    \u003cdiv class=\"upgrade-section\"\u003e\n        {% if is_free_plan %}\n            \u003cp\u003eUpgrade your plan for more messages!\u003c/p\u003e\n            \u003cdiv class=\"upgrade-options\"\u003e\n                \u003c!-- Both buttons use the SAME checkout link --\u003e\n                \u003ca href=\"{{ upgrade_checkout_link }}\" target=\"_blank\" class=\"upgrade-btn standard\"\u003e\n                    Upgrade to Standard ({{ STANDARD_PLAN_MONTHLY_LIMIT }}/month)\n                \u003c/a\u003e\n                \u003ca href=\"{{ upgrade_checkout_link }}\" target=\"_blank\" class=\"upgrade-btn pro\"\u003e\n                    Upgrade to Pro (Unlimited)\n                \u003c/a\u003e\n            \u003c/div\u003e\n            \u003cp style=\"font-size: 0.8em; margin-top: 10px; color: #6c757d;\"\u003e\n                Please ensure you use the email '{{ user_email }}' during checkout.\n            \u003c/p\u003e\n        {% elif is_standard_plan %}\n            \u003cp\u003eUpgrade to Pro for unlimited messages!\u003c/p\u003e\n            \u003cdiv class=\"upgrade-options\"\u003e\n                 \u003c!-- Only show Pro upgrade option if already on Standard --\u003e\n                \u003ca href=\"{{ upgrade_checkout_link }}\" target=\"_blank\" class=\"upgrade-btn pro\"\u003e\n                    Upgrade to Pro\n                \u003c/a\u003e\n            \u003c/div\u003e\n             \u003cp style=\"font-size: 0.8em; margin-top: 10px; color: #6c757d;\"\u003e\n                Please ensure you use the email '{{ user_email }}' during checkout.\n            \u003c/p\u003e\n        {% endif %}\n    \u003c/div\u003e\n    {% endif %}\n    \u003c!-- End Upgrade Section --\u003e\n\n    \u003ch1\u003eSimple Chatbot\u003c/h1\u003e\n\n    \u003c!-- Chat Input Form --\u003e\n    \u003cform method=\"post\"\u003e\n        \u003ctextarea name=\"user_input\"\n                  placeholder=\"Ask the bot something...\"\n                  required\n                  aria-label=\"Chat input\"\u003e{{ user_message or '' }}\u003c/textarea\u003e\n        {# Disable button if message limit reached #}\n        \u003cbutton type=\"submit\" {% if message_limit_reached %}disabled title=\"Message limit reached\"{% endif %}\u003e\n            Send\n        \u003c/button\u003e\n    \u003c/form\u003e\n\n    \u003c!-- Chat Display Area --\u003e\n    \u003cdiv class=\"chat-area\" aria-live=\"polite\"\u003e\n        {# Display previous user message if sent in this request #}\n        {% if user_message and not message_limit_reached %}\n            \u003cp\u003e\u003cspan class=\"user\"\u003eYou:\u003c/span\u003e {{ user_message | escape }}\u003c/p\u003e\n        {% endif %}\n        {# Display bot response if generated #}\n        {% if bot_response %}\n             \u003cp\u003e\u003cspan class=\"bot\"\u003eBot:\u003c/span\u003e {{ bot_response | escape }}\u003c/p\u003e\n        {% endif %}\n\n        {# Initial state message or limit reached message #}\n        {% if not user_message and not bot_response and not message_limit_reached %}\n             \u003cp\u003eAsk the chatbot a question above.\u003c/p\u003e\n        {% elif message_limit_reached %}\n             \u003cp style=\"color: red; font-weight: bold;\"\u003eYour message limit has been reached for the current plan/period.\u003c/p\u003e\n        {% endif %}\n    \u003c/div\u003e\n\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\nSo, our home page template will display the current active plan for the user, along with the message count for that user and some other basic information. This will give the user an overview of their subscription and usage.\n\nWe are very close to testing our app! The final step is to host our app using ngrok and create a webhook event link that will help make the transaction successful.\n\n## Creating Webhook Event\n\nLemon Squeezy webhooks need a publicly accessible URL to send notifications to your Flask app.\n\nWhile developing locally, your app is usually only available at `http://127.0.0.1:5000`, which Lemon Squeezy can't reach. Ngrok creates a secure tunnel to expose your local server to the internet, allowing you to test webhooks and other integrations with external services.\n\nGo to [ngrok.com/download](https://www.google.com/url?sa=E\u0026q=https%3A%2F%2Fngrok.com%2Fdownload) and download the version for your operating system. Unzip it and make it available via environment variables too, you can follow this [youtube video](https://www.youtube.com/watch?v=ZKnpP7QGjX8), in case you get stuck.\n\n![Ngrok download step](https://miro.medium.com/v2/resize:fit:875/1*PTO3_dq8oc9_NsgVSsbJ3w.png)\n*Ngrok download step*\n\nOnce you configure ngrok, start your Flask app using:\n\n```bash\npython app.py\n```\n\nThis will run your app locally, open a *new separate terminal window* (keep your Flask app terminal running). Navigate to where you downloaded ngrok (or ensure it’s in your system’s PATH) and run:\n\n```bash\nngrok http 5000\n```\n\n*(Replace 5000 if your Flask app runs on a different port).*\n\nThis will generate a public URL (e.g., `https://abcd1234.ngrok.io`) that you can use for webhooks and other external integrations.\n\n```bash\nSession Status                online\nAccount                       Your Name (Plan: Free)\nVersion                       x.x.x\n...\nForwarding                    https://xxxxxxxx.ngrok.io -\u003e http://localhost:5000\n```\n\nGo back to your Lemon Squeezy dashboard -\u003e Settings -\u003e Webhooks. Add a new webhook.\n\n![Creating a webhook](https://miro.medium.com/v2/resize:fit:1250/1*SIicT_clbUAUKYyS70Mnjg.png)\n*Creating a webhook*\n\nNow paste that ngrok URL into the webhook environment variable you created, and make sure to enable the `subscription_created` and `subscription_updated` events, these are the two features we’ve implemented.\n\nYou’ll see there’s a lot more we can do with additional webhook events and automation later on.\n\n![Webhook customization](https://miro.medium.com/v2/resize:fit:875/1*5y0LTTJZs11sKQ4vvmyfjw.png)\n*Webhook customization*\n\nSave that webhook, and one important point to remember is that if you run the app (Flask + ngrok) again the next day, you’ll need to update the ngrok URL, as it will be different with each new session.\n\n## Testing Webapp\n\nNow that we have coded everything its time to test our app. Let’s try to login first it must throws error because haven’t created the account yet right.\n\n![login error](https://miro.medium.com/v2/resize:fit:875/1*lzwP7vUTlctZ30qEoe3bng.png)\n*login error*\n\nso the login error works as expected let’s create an account and then login to see the user interface and test our free plan.\n\n![Free plan usage](https://miro.medium.com/v2/resize:fit:1250/1*FZSkTyUVj1Ug1STwY5tYFA.png)\n*Free plan usage*\n\nOur user interface is very minimal, and you can see that every new user login is assigned the default Free plan, which is working as expected (2 messages per hour).\n\nNow, let’s buy the Standard plan and see how it updates our user interface accordingly.\n\n![Buying standard plan](https://miro.medium.com/v2/resize:fit:875/1*IOFFr2LSkZbK8sw1WUWBDQ.png)\n*Buying standard plan*\n\nYou can see that when I purchase the Standard plan, the user interface updates to show my current plan, the number of remaining messages, and other relevant information.\n\n![Supabase + LemonSqueezy database table](https://miro.medium.com/v2/resize:fit:1250/1*k_RUVuS5Ce2tsE619Q8TSA.png)\n*Supabase + LemonSqueezy database table*\n\nYou can see that our database table, along with the payment data table, is updated with the user’s current subscription and related details.\n\n## What’s Next\n\nLemonSqueezy offers a wide range of features to help you build a complete, secure, end-to-end application. You can test transactions using dummy cards listed in their [documentation](https://docs.lemonsqueezy.com/).\n\nLemonSqueezy only charges fees when your business generates revenue, making it ideal for startups and developers.\n\nMake sure to thoroughly explore their documentation to understand and test each feature properly, and to ensure your app is secure and production-ready.\n\nHope this blog gives you a strong starting point.\n\n\u003e **Happy Reading!**","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffareedkhan-dev%2Fsaas-payment-guide","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffareedkhan-dev%2Fsaas-payment-guide","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffareedkhan-dev%2Fsaas-payment-guide/lists"}