cd /home/def/otb_billing || exit 1 cat > /tmp/otb_billing_service_templates_patch.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail APP_ROOT="/home/def/otb_billing" BACKUP_DIR="/home/def/backuphere" STAMP="$(date +%Y%m%d-%H%M%S)" mkdir -p "$BACKUP_DIR" echo "===== sanity =====" test -f "$APP_ROOT/backend/app.py" test -f "$APP_ROOT/templates/services/list.html" test -f "$APP_ROOT/templates/services/new.html" test -f "$APP_ROOT/templates/services/edit.html" echo "===== backups =====" cp "$APP_ROOT/backend/app.py" "$BACKUP_DIR/app.py.service-templates.${STAMP}.bak" cp "$APP_ROOT/templates/services/list.html" "$BACKUP_DIR/services-list.html.service-templates.${STAMP}.bak" cp "$APP_ROOT/templates/services/new.html" "$BACKUP_DIR/services-new.html.service-templates.${STAMP}.bak" cp "$APP_ROOT/templates/services/edit.html" "$BACKUP_DIR/services-edit.html.service-templates.${STAMP}.bak" cp "$APP_ROOT/templates/includes/site_nav.html" "$BACKUP_DIR/site_nav.html.service-templates.${STAMP}.bak" || true mkdir -p "$APP_ROOT/templates/service_templates" echo "===== create database table if missing =====" sudo mysql -D otb_billing <<'SQL' CREATE TABLE IF NOT EXISTS service_templates ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, template_name VARCHAR(255) NOT NULL, service_type ENUM('hosting','rpc','explorer','node','ipfs','consulting','crypto_infra','other') NOT NULL DEFAULT 'other', billing_cycle ENUM('one_time','monthly','quarterly','yearly','manual') NOT NULL DEFAULT 'monthly', currency_code VARCHAR(16) NOT NULL DEFAULT 'CAD', recurring_amount DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, setup_amount DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, description TEXT DEFAULT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT current_timestamp(), updated_at TIMESTAMP NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), KEY idx_service_templates_active (is_active), KEY idx_service_templates_name (template_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; SQL echo "===== patch backend/app.py =====" python3 <<'PY' from pathlib import Path path = Path("/home/def/otb_billing/backend/app.py") text = path.read_text() old_new_block = """@app.route("/services/new", methods=["GET", "POST"]) def new_service(): gate = admin_required() if gate: return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": client_id = request.form["client_id"] service_name = request.form["service_name"] service_type = request.form["service_type"] billing_cycle = request.form["billing_cycle"] currency_code = request.form["currency_code"] recurring_amount = request.form["recurring_amount"] status = request.form["status"] start_date = request.form["start_date"] or None description = request.form["description"] cursor.execute("SELECT MAX(id) AS last_id FROM services") result = cursor.fetchone() last_number = result["last_id"] if result["last_id"] else 0 service_code = generate_service_code(service_name, last_number) insert_cursor = conn.cursor() insert_cursor.execute( \"\"\" INSERT INTO services ( client_id, service_code, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date, description ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) \"\"\", ( client_id, service_code, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date, description ) ) conn.commit() conn.close() return redirect("/services") cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") clients = cursor.fetchall() conn.close() return render_template("services/new.html", clients=clients) """ new_new_block = """@app.route("/services/new", methods=["GET", "POST"]) def new_service(): gate = admin_required() if gate: return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": client_id = request.form["client_id"] service_name = request.form["service_name"] service_type = request.form["service_type"] billing_cycle = request.form["billing_cycle"] currency_code = request.form["currency_code"] recurring_amount = request.form["recurring_amount"] status = request.form["status"] start_date = request.form["start_date"] or None description = request.form["description"] cursor.execute("SELECT MAX(id) AS last_id FROM services") result = cursor.fetchone() last_number = result["last_id"] if result["last_id"] else 0 service_code = generate_service_code(service_name, last_number) insert_cursor = conn.cursor() insert_cursor.execute( \"\"\" INSERT INTO services ( client_id, service_code, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date, description ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) \"\"\", ( client_id, service_code, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date, description ) ) conn.commit() conn.close() return redirect("/services") cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") clients = cursor.fetchall() cursor.execute(\"\"\" SELECT id, template_name, service_type, billing_cycle, currency_code, recurring_amount, setup_amount, description FROM service_templates WHERE is_active = 1 ORDER BY template_name ASC \"\"\") templates = cursor.fetchall() conn.close() return render_template("services/new.html", clients=clients, templates=templates) """ if old_new_block not in text: raise SystemExit("Could not find /services/new block to replace.") text = text.replace(old_new_block, new_new_block) old_edit_block = """@app.route("/services/edit/", methods=["GET", "POST"]) def edit_service(service_id): gate = admin_required() if gate: return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": client_id = request.form.get("client_id", "").strip() service_name = request.form.get("service_name", "").strip() service_type = request.form.get("service_type", "").strip() billing_cycle = request.form.get("billing_cycle", "").strip() currency_code = request.form.get("currency_code", "").strip() recurring_amount = request.form.get("recurring_amount", "").strip() status = request.form.get("status", "").strip() start_date = request.form.get("start_date", "").strip() description = request.form.get("description", "").strip() errors = [] if not client_id: errors.append("Client is required.") if not service_name: errors.append("Service name is required.") if not service_type: errors.append("Service type is required.") if not billing_cycle: errors.append("Billing cycle is required.") if not currency_code: errors.append("Currency code is required.") if not recurring_amount: errors.append("Recurring amount is required.") if not status: errors.append("Status is required.") if not errors: try: recurring_amount_value = float(recurring_amount) if recurring_amount_value < 0: errors.append("Recurring amount cannot be negative.") except ValueError: errors.append("Recurring amount must be a valid number.") if errors: cursor.execute(\"\"\" SELECT s.*, c.company_name FROM services s LEFT JOIN clients c ON s.client_id = c.id WHERE s.id = %s \"\"\", (service_id,)) service = cursor.fetchone() cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") clients = cursor.fetchall() conn.close() return render_template("services/edit.html", service=service, clients=clients, errors=errors) update_cursor = conn.cursor() update_cursor.execute(\"\"\" UPDATE services SET client_id = %s, service_name = %s, service_type = %s, billing_cycle = %s, status = %s, currency_code = %s, recurring_amount = %s, start_date = %s, description = %s WHERE id = %s \"\"\", ( client_id, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date or None, description or None, service_id )) conn.commit() conn.close() return redirect("/services") cursor.execute(\"\"\" SELECT s.*, c.company_name FROM services s LEFT JOIN clients c ON s.client_id = c.id WHERE s.id = %s \"\"\", (service_id,)) service = cursor.fetchone() cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") clients = cursor.fetchall() conn.close() if not service: return "Service not found", 404 return render_template("services/edit.html", service=service, clients=clients, errors=[]) """ new_edit_block = """@app.route("/services/edit/", methods=["GET", "POST"]) def edit_service(service_id): gate = admin_required() if gate: return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": client_id = request.form.get("client_id", "").strip() service_name = request.form.get("service_name", "").strip() service_type = request.form.get("service_type", "").strip() billing_cycle = request.form.get("billing_cycle", "").strip() currency_code = request.form.get("currency_code", "").strip() recurring_amount = request.form.get("recurring_amount", "").strip() status = request.form.get("status", "").strip() start_date = request.form.get("start_date", "").strip() description = request.form.get("description", "").strip() errors = [] if not client_id: errors.append("Client is required.") if not service_name: errors.append("Service name is required.") if not service_type: errors.append("Service type is required.") if not billing_cycle: errors.append("Billing cycle is required.") if not currency_code: errors.append("Currency code is required.") if not recurring_amount: errors.append("Recurring amount is required.") if not status: errors.append("Status is required.") if not errors: try: recurring_amount_value = float(recurring_amount) if recurring_amount_value < 0: errors.append("Recurring amount cannot be negative.") except ValueError: errors.append("Recurring amount must be a valid number.") if errors: cursor.execute(\"\"\" SELECT s.*, c.company_name FROM services s LEFT JOIN clients c ON s.client_id = c.id WHERE s.id = %s \"\"\", (service_id,)) service = cursor.fetchone() cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") clients = cursor.fetchall() cursor.execute(\"\"\" SELECT id, template_name, service_type, billing_cycle, currency_code, recurring_amount, setup_amount, description FROM service_templates WHERE is_active = 1 ORDER BY template_name ASC \"\"\") templates = cursor.fetchall() conn.close() return render_template("services/edit.html", service=service, clients=clients, templates=templates, errors=errors) update_cursor = conn.cursor() update_cursor.execute(\"\"\" UPDATE services SET client_id = %s, service_name = %s, service_type = %s, billing_cycle = %s, status = %s, currency_code = %s, recurring_amount = %s, start_date = %s, description = %s WHERE id = %s \"\"\", ( client_id, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date or None, description or None, service_id )) conn.commit() conn.close() return redirect("/services") cursor.execute(\"\"\" SELECT s.*, c.company_name FROM services s LEFT JOIN clients c ON s.client_id = c.id WHERE s.id = %s \"\"\", (service_id,)) service = cursor.fetchone() cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") clients = cursor.fetchall() cursor.execute(\"\"\" SELECT id, template_name, service_type, billing_cycle, currency_code, recurring_amount, setup_amount, description FROM service_templates WHERE is_active = 1 ORDER BY template_name ASC \"\"\") templates = cursor.fetchall() conn.close() if not service: return "Service not found", 404 return render_template("services/edit.html", service=service, clients=clients, templates=templates, errors=[]) """ if old_edit_block not in text: raise SystemExit("Could not find /services/edit block to replace.") text = text.replace(old_edit_block, new_edit_block) insert_after = """ return render_template("services/edit.html", service=service, clients=clients, templates=templates, errors=[]) @app.route("/invoices/export.csv") """ new_routes = """ return render_template("services/edit.html", service=service, clients=clients, templates=templates, errors=[]) @app.route("/service-templates") def service_templates(): gate = admin_required() if gate: return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(\"\"\" SELECT * FROM service_templates ORDER BY id DESC \"\"\") templates = cursor.fetchall() conn.close() return render_template("service_templates/list.html", templates=templates) @app.route("/service-templates/new", methods=["GET", "POST"]) def new_service_template(): gate = admin_required() if gate: return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) errors = [] if request.method == "POST": template_name = request.form.get("template_name", "").strip() service_type = request.form.get("service_type", "").strip() billing_cycle = request.form.get("billing_cycle", "").strip() currency_code = request.form.get("currency_code", "").strip() recurring_amount = request.form.get("recurring_amount", "").strip() setup_amount = request.form.get("setup_amount", "").strip() description = request.form.get("description", "").strip() is_active = 1 if request.form.get("is_active") == "1" else 0 if not template_name: errors.append("Template name is required.") if not service_type: errors.append("Service type is required.") if not billing_cycle: errors.append("Billing cycle is required.") if not currency_code: errors.append("Currency code is required.") if recurring_amount == "": errors.append("Recurring amount is required.") if setup_amount == "": errors.append("Setup amount is required.") if not errors: try: recurring_amount_value = float(recurring_amount) setup_amount_value = float(setup_amount) if recurring_amount_value < 0: errors.append("Recurring amount cannot be negative.") if setup_amount_value < 0: errors.append("Setup amount cannot be negative.") except ValueError: errors.append("Amounts must be valid numbers.") if not errors: insert_cursor = conn.cursor() insert_cursor.execute(\"\"\" INSERT INTO service_templates ( template_name, service_type, billing_cycle, currency_code, recurring_amount, setup_amount, description, is_active ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) \"\"\", ( template_name, service_type, billing_cycle, currency_code, recurring_amount, setup_amount, description or None, is_active )) conn.commit() conn.close() return redirect("/service-templates") conn.close() return render_template("service_templates/new.html", errors=errors) @app.route("/service-templates/edit/", methods=["GET", "POST"]) def edit_service_template(template_id): gate = admin_required() if gate: return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": template_name = request.form.get("template_name", "").strip() service_type = request.form.get("service_type", "").strip() billing_cycle = request.form.get("billing_cycle", "").strip() currency_code = request.form.get("currency_code", "").strip() recurring_amount = request.form.get("recurring_amount", "").strip() setup_amount = request.form.get("setup_amount", "").strip() description = request.form.get("description", "").strip() is_active = 1 if request.form.get("is_active") == "1" else 0 errors = [] if not template_name: errors.append("Template name is required.") if not service_type: errors.append("Service type is required.") if not billing_cycle: errors.append("Billing cycle is required.") if not currency_code: errors.append("Currency code is required.") if recurring_amount == "": errors.append("Recurring amount is required.") if setup_amount == "": errors.append("Setup amount is required.") if not errors: try: recurring_amount_value = float(recurring_amount) setup_amount_value = float(setup_amount) if recurring_amount_value < 0: errors.append("Recurring amount cannot be negative.") if setup_amount_value < 0: errors.append("Setup amount cannot be negative.") except ValueError: errors.append("Amounts must be valid numbers.") if errors: cursor.execute("SELECT * FROM service_templates WHERE id = %s", (template_id,)) template = cursor.fetchone() conn.close() return render_template("service_templates/edit.html", template=template, errors=errors) update_cursor = conn.cursor() update_cursor.execute(\"\"\" UPDATE service_templates SET template_name = %s, service_type = %s, billing_cycle = %s, currency_code = %s, recurring_amount = %s, setup_amount = %s, description = %s, is_active = %s WHERE id = %s \"\"\", ( template_name, service_type, billing_cycle, currency_code, recurring_amount, setup_amount, description or None, is_active, template_id )) conn.commit() conn.close() return redirect("/service-templates") cursor.execute("SELECT * FROM service_templates WHERE id = %s", (template_id,)) template = cursor.fetchone() conn.close() if not template: return "Service template not found", 404 return render_template("service_templates/edit.html", template=template, errors=[]) @app.route("/invoices/export.csv") """ if insert_after not in text: raise SystemExit("Could not find insertion point before /invoices/export.csv.") text = text.replace(insert_after, new_routes) path.write_text(text) PY echo "===== rewrite templates/services/list.html =====" cat > "$APP_ROOT/templates/services/list.html" <<'HTML' Services

Services

Home

Add Service | Service Templates

{% for s in services %} {% endfor %}
ID Service Code Client Service Name Type Cycle Currency Amount Status Start Date Actions
{{ s.id }} {{ s.service_code }} {{ s.client_code }} - {{ s.company_name }} {{ s.service_name }} {{ s.service_type }} {{ s.billing_cycle }} {{ s.currency_code }} {{ s.recurring_amount|money(s.currency_code) }} {{ s.status }} {{ s.start_date }} Edit
{% include "footer.html" %} HTML echo "===== rewrite templates/services/new.html =====" cat > "$APP_ROOT/templates/services/new.html" <<'HTML' New Service

Add Service

Home

Back to Services | Service Templates

Client

Load from Template

Service Name

Service Type

Billing Cycle

Currency Code

Recurring Amount

Status

Start Date

Description

{% include "footer.html" %} HTML echo "===== rewrite templates/services/edit.html =====" cat > "$APP_ROOT/templates/services/edit.html" <<'HTML' Edit Service

Edit Service

Home

Back to Services | Service Templates

{% if errors %}
Please fix the following:
    {% for error in errors %}
  • {{ error }}
  • {% endfor %}
{% endif %}

Service Code

Client *

Load from Template

Service Name *

Service Type *

Billing Cycle *

Currency Code *

Recurring Amount *

Status *

Start Date

Description

{% include "footer.html" %} HTML echo "===== create templates/service_templates/list.html =====" cat > "$APP_ROOT/templates/service_templates/list.html" <<'HTML' Service Templates

Service Templates

Home

Back to Services | Add Service Template

{% for t in templates %} {% endfor %}
ID Template Name Type Cycle Currency Recurring Setup Active Actions
{{ t.id }} {{ t.template_name }} {{ t.service_type }} {{ t.billing_cycle }} {{ t.currency_code }} {{ t.recurring_amount|money(t.currency_code) }} {{ t.setup_amount|money(t.currency_code) }} {% if t.is_active %}yes{% else %}no{% endif %} Edit
{% include "footer.html" %} HTML echo "===== create templates/service_templates/new.html =====" cat > "$APP_ROOT/templates/service_templates/new.html" <<'HTML' New Service Template

Add Service Template

Home

Back to Service Templates

{% if errors %}
Please fix the following:
    {% for error in errors %}
  • {{ error }}
  • {% endfor %}
{% endif %}

Template Name *

Service Type *

Billing Cycle *

Currency Code *

Recurring Amount *

Setup Amount *

Description

Active

{% include "footer.html" %} HTML echo "===== create templates/service_templates/edit.html =====" cat > "$APP_ROOT/templates/service_templates/edit.html" <<'HTML' Edit Service Template

Edit Service Template

Home

Back to Service Templates

{% if errors %}
Please fix the following:
    {% for error in errors %}
  • {{ error }}
  • {% endfor %}
{% endif %}

Template Name *

Service Type *

Billing Cycle *

Currency Code *

Recurring Amount *

Setup Amount *

Description

Active

{% include "footer.html" %} HTML echo "===== verify syntax =====" python3 -m py_compile "$APP_ROOT/backend/app.py" echo "===== restart service =====" sudo systemctl restart otb_billing.service sleep 2 sudo systemctl --no-pager --full status otb_billing.service | sed -n '1,35p' echo "===== verify routes present =====" grep -nE '@app.route\("/service-templates"|def service_templates|def new_service_template|def edit_service_template' "$APP_ROOT/backend/app.py" echo "===== verify template table =====" sudo mysql -D otb_billing -e "SHOW TABLES LIKE 'service_templates';" sudo mysql -D otb_billing -e "DESCRIBE service_templates;" echo "===== completed =====" echo "Backups saved under: $BACKUP_DIR" EOF chmod +x /tmp/otb_billing_service_templates_patch.sh /tmp/otb_billing_service_templates_patch.sh