Browse Source

Release v0.3.0 - Portal onboarding flow complete (email link activation)

main
def 3 days ago
parent
commit
749490bc22
  1. 9
      PROJECT_STATE.md
  2. 5
      README.md
  3. 2
      VERSION
  4. 2591
      backend/app-backups/app.py.bak_accounting_builder
  5. 2527
      backend/app-backups/app.py.bak_email_log_safe
  6. 1224
      backend/app-backups/app.py.bak_payments_query_fix
  7. 1224
      backend/app-backups/app.py.bak_payments_route_exact_fix
  8. 1224
      backend/app-backups/app.py.bak_payments_route_fix2
  9. 2581
      backend/app-backups/app.py.bak_report_email_fix
  10. 1220
      backend/app-backups/app.py.bak_void_fix
  11. 340
      backend/app.py
  12. 59
      bump.sh
  13. 1278
      patch.sh
  14. 70
      patch1.sh
  15. 110
      scripts/invoice_reminder_worker.py.bak_20260313-035553
  16. 117
      scripts/invoice_reminder_worker.py.bak_20260313-035724
  17. 116
      scripts/invoice_reminder_worker.py.bak_20260313-041145
  18. 83
      templates/portal/terms.html
  19. 209
      templates/portal_invoice_detail.html.bak_20260314-020444
  20. 6
      templates/portal_login.html
  21. 46
      templates/portal_register.html
  22. 4
      templates/service_templates/edit.html
  23. 54
      templates/service_templates/list.html
  24. 5
      templates/service_templates/new.html
  25. 15
      templates/services/edit.html
  26. 82
      templates/services/list.html
  27. 28
      templates/services/new.html
  28. 173
      templates/settings.html.bak_logo_layout_fix

9
PROJECT_STATE.md

@ -83,3 +83,12 @@ Infrastructure:
- Portal domain: portal.outsidethebox.top
- Billing admin: otb-billing.outsidethebox.top
## v0.3.0 - 2026-05-03
- Portal onboarding flow upgraded
- Email invites now include clickable activation link
- /portal/set-password now supports direct email+code login
- Auto session creation from invite link
- Improved UX: no manual code entry required
- Portal onboarding now production-ready

5
README.md

@ -1,3 +1,8 @@
## v0.3.0 (2026-05-03)
- Clickable portal invite links
- Direct account activation from email
- Improved onboarding UX
## v0.6.2 - 2026-04-23
### Changes

2
VERSION

@ -1 +1 @@
v0.6.2
v0.3.0

2591
backend/app-backups/app.py.bak_accounting_builder

File diff suppressed because it is too large Load Diff

2527
backend/app-backups/app.py.bak_email_log_safe

File diff suppressed because it is too large Load Diff

1224
backend/app-backups/app.py.bak_payments_query_fix

File diff suppressed because it is too large Load Diff

1224
backend/app-backups/app.py.bak_payments_route_exact_fix

File diff suppressed because it is too large Load Diff

1224
backend/app-backups/app.py.bak_payments_route_fix2

File diff suppressed because it is too large Load Diff

2581
backend/app-backups/app.py.bak_report_email_fix

File diff suppressed because it is too large Load Diff

1220
backend/app-backups/app.py.bak_void_fix

File diff suppressed because it is too large Load Diff

340
backend/app.py

@ -1213,11 +1213,9 @@ def append_square_webhook_log(entry):
pass
def generate_portal_access_code():
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
groups = []
for _ in range(3):
groups.append("".join(secrets.choice(alphabet) for _ in range(4)))
return "-".join(groups)
import uuid
raw = uuid.uuid4().hex.upper()
return f"{raw[0:6]}-{raw[6:12]}-{raw[12:18]}"
def refresh_overdue_invoices():
conn = get_db_connection()
@ -2702,17 +2700,63 @@ def services():
gate = admin_required()
if gate:
return gate
selected_type = (request.args.get("service_type") or "").strip()
selected_status = (request.args.get("status") or "").strip()
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT s.*, c.client_code, c.company_name
query = """
SELECT s.*, c.client_code, c.company_name, st.template_name
FROM services s
JOIN clients c ON s.client_id = c.id
ORDER BY s.id DESC
""")
LEFT JOIN service_templates st ON s.template_id = st.id
WHERE 1=1
"""
params = []
if selected_type:
query += " AND s.service_type = %s"
params.append(selected_type)
if selected_status:
query += " AND s.status = %s"
params.append(selected_status)
query += " ORDER BY s.id DESC"
cursor.execute(query, tuple(params))
services = cursor.fetchall()
cursor.execute("""
SELECT
service_type,
COUNT(*) AS service_count,
COALESCE(SUM(recurring_amount), 0) AS total_monthly
FROM services
WHERE status = 'active'
GROUP BY service_type
ORDER BY service_type
""")
summary_rows = cursor.fetchall()
active_totals = {
"service_count": sum(int(row["service_count"]) for row in summary_rows),
"total_monthly": sum(float(row["total_monthly"]) for row in summary_rows),
}
conn.close()
return render_template("services/list.html", services=services)
return render_template(
"services/list.html",
services=services,
selected_type=selected_type,
selected_status=selected_status,
total_count=len(services),
summary_rows=summary_rows,
active_totals=active_totals
)
@app.route("/services/new", methods=["GET", "POST"])
def new_service():
@ -2724,6 +2768,7 @@ def new_service():
if request.method == "POST":
client_id = request.form["client_id"]
template_id = (request.form.get("template_id") or "").strip() or None
service_name = request.form["service_name"]
service_type = request.form["service_type"]
billing_cycle = request.form["billing_cycle"]
@ -2744,6 +2789,7 @@ def new_service():
INSERT INTO services
(
client_id,
template_id,
service_code,
service_name,
service_type,
@ -2754,10 +2800,11 @@ def new_service():
start_date,
description
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
client_id,
template_id,
service_code,
service_name,
service_type,
@ -2774,6 +2821,8 @@ def new_service():
return redirect("/services")
preselect_template_id = (request.args.get("template_id") or "").strip()
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
clients = cursor.fetchall()
cursor.execute("""
@ -2784,7 +2833,12 @@ def new_service():
""")
templates = cursor.fetchall()
conn.close()
return render_template("services/new.html", clients=clients, templates=templates)
return render_template(
"services/new.html",
clients=clients,
templates=templates,
preselect_template_id=preselect_template_id
)
@app.route("/services/edit/<int:service_id>", methods=["GET", "POST"])
def edit_service(service_id):
@ -2796,6 +2850,7 @@ def edit_service(service_id):
if request.method == "POST":
client_id = request.form.get("client_id", "").strip()
template_id = (request.form.get("template_id") or "").strip() or None
service_name = request.form.get("service_name", "").strip()
service_type = request.form.get("service_type", "").strip()
billing_cycle = request.form.get("billing_cycle", "").strip()
@ -2856,6 +2911,7 @@ def edit_service(service_id):
update_cursor.execute("""
UPDATE services
SET client_id = %s,
template_id = %s,
service_name = %s,
service_type = %s,
billing_cycle = %s,
@ -2867,6 +2923,7 @@ def edit_service(service_id):
WHERE id = %s
""", (
client_id,
template_id,
service_name,
service_type,
billing_cycle,
@ -2912,17 +2969,47 @@ def service_templates():
if gate:
return gate
selected_type = (request.args.get("service_type") or "").strip()
selected_active = (request.args.get("is_active") or "").strip()
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT *
FROM service_templates
ORDER BY id DESC
""")
query = """
SELECT
st.*,
COUNT(s.id) AS usage_count,
COALESCE(SUM(CASE WHEN s.status = 'active' THEN 1 ELSE 0 END), 0) AS active_usage_count
FROM service_templates st
LEFT JOIN services s ON s.template_id = st.id
WHERE 1=1
"""
params = []
if selected_type:
query += " AND st.service_type = %s"
params.append(selected_type)
if selected_active in ("0", "1"):
query += " AND st.is_active = %s"
params.append(int(selected_active))
query += """
GROUP BY st.id
ORDER BY st.id DESC
"""
cursor.execute(query, tuple(params))
templates = cursor.fetchall()
conn.close()
return render_template("service_templates/list.html", templates=templates)
return render_template(
"service_templates/list.html",
templates=templates,
selected_type=selected_type,
selected_active=selected_active,
total_count=len(templates)
)
@app.route("/service-templates/new", methods=["GET", "POST"])
@ -4823,6 +4910,182 @@ def portal_index():
return redirect("/portal/dashboard")
return render_template("portal_login.html")
@app.route("/portal/register", methods=["GET", "POST"])
def portal_register():
if request.method == "GET":
return render_template("portal_register.html", error=None, message=None, form_email="", form_company="", form_contact="", form_note="")
email = (request.form.get("email") or "").strip().lower()
company_name = (request.form.get("company_name") or "").strip()
contact_name = (request.form.get("contact_name") or "").strip()
note = (request.form.get("note") or "").strip()
if not email or "@" not in email:
return render_template(
"portal_register.html",
error="A valid email address is required.",
message=None,
form_email=email,
form_company=company_name,
form_contact=contact_name,
form_note=note
)
access_code = generate_portal_access_code()
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT id, company_name, contact_name, email FROM clients WHERE LOWER(email) = %s LIMIT 1", (email,))
existing = cursor.fetchone()
if existing:
client_id = existing["id"]
update_cursor = conn.cursor()
update_cursor.execute("""
UPDATE clients
SET portal_enabled = 1,
portal_access_code = %s,
portal_access_code_created_at = UTC_TIMESTAMP(),
portal_password_hash = NULL,
portal_password_set_at = NULL,
portal_force_password_change = 1,
status = IF(status = 'inactive', 'lead', status),
notes = CONCAT(
COALESCE(notes, ''),
%s
)
WHERE id = %s
""", (
access_code,
"\n\n[Portal self-signup/request access] Existing client requested access. Note: " + (note or "(none)"),
client_id
))
conn.commit()
else:
cursor.execute("SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM clients")
row = cursor.fetchone()
next_id = int(row["next_id"] or 1)
client_code = f"WEB-{next_id:04d}"
cursor.execute("SELECT COUNT(*) AS c FROM clients WHERE client_code = %s", (client_code,))
while cursor.fetchone()["c"]:
next_id += 1
client_code = f"WEB-{next_id:04d}"
cursor.execute("SELECT COUNT(*) AS c FROM clients WHERE client_code = %s", (client_code,))
display_company = company_name or contact_name or email
insert_cursor = conn.cursor()
insert_cursor.execute("""
INSERT INTO clients
(
client_code,
company_name,
contact_name,
email,
status,
notes,
portal_enabled,
portal_access_code,
portal_access_code_created_at,
portal_password_hash,
portal_password_set_at,
portal_force_password_change
)
VALUES (%s, %s, %s, %s, 'lead', %s, 1, %s, UTC_TIMESTAMP(), NULL, NULL, 1)
""", (
client_code,
display_company,
contact_name or None,
email,
"Created by public portal self-signup/request access.\nNote: " + (note or "(none)"),
access_code
))
conn.commit()
client_id = insert_cursor.lastrowid
conn.close()
portal_url = "https://otb-billing.outsidethebox.top/portal"
subject = "Your OutsideTheBox portal access code"
body = f"""Your OutsideTheBox client portal access code is ready.
Portal:
{portal_url}
Email:
{email}
Access code:
{access_code}
After your first successful login, you will be asked to create your password.
Once your password is created, this access code is cleared and future logins use your email address and password.
If you did not request this, contact support@outsidethebox.top.
"""
try:
send_configured_email(
to_email=email,
subject=subject,
body=body,
email_type="portal_self_signup"
)
except Exception as exc:
print(f"[portal register] email send failed for client_id={client_id}: {exc}")
return render_template(
"portal_register.html",
error="Your account request was created, but the access email could not be sent. Please contact support.",
message=None,
form_email=email,
form_company=company_name,
form_contact=contact_name,
form_note=note
)
try:
admin_subject = f"New portal signup request: {email}"
admin_body = f"""A new OutsideTheBox portal signup/request-access form was submitted.
Client ID:
{client_id}
Email:
{email}
Company / Organization:
{company_name or "(not provided)"}
Contact Name:
{contact_name or "(not provided)"}
Request Note:
{note or "(none)"}
The user has been emailed a one-time access code and can complete first-login password setup through the portal.
"""
send_configured_email(
to_email="info@outsidethebox.top",
subject=admin_subject,
body=admin_body,
email_type="portal_self_signup_admin_notice"
)
except Exception as exc:
print(f"[portal register] admin notification failed for client_id={client_id}: {exc}")
return render_template(
"portal_register.html",
error=None,
message="Access code sent. Check your email, then return to the portal login page.",
form_email=email,
form_company="",
form_contact="",
form_note=""
)
@app.route("/portal/login", methods=["POST"])
def portal_login():
email = (request.form.get("email") or "").strip().lower()
@ -4889,6 +5152,30 @@ def portal_login():
@app.route("/portal/set-password", methods=["GET", "POST"])
def portal_set_password():
client = _portal_current_client()
# Allow direct email setup link:
# /portal/set-password?email=user@example.com&code=ABC123-DEF456-GHI789
if not client and request.method == "GET":
email = (request.args.get("email") or "").strip().lower()
code = (request.args.get("code") or "").strip()
if email and code:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT id, company_name, contact_name, email, portal_enabled, portal_access_code
FROM clients
WHERE LOWER(email) = %s
LIMIT 1
""", (email,))
row = cursor.fetchone()
conn.close()
if row and row.get("portal_enabled") and (row.get("portal_access_code") or "") == code:
session["portal_client_id"] = row["id"]
session["portal_email"] = row["email"]
client = row
if not client:
return redirect("/portal")
@ -4912,7 +5199,8 @@ def portal_set_password():
SET portal_password_hash = %s,
portal_password_set_at = UTC_TIMESTAMP(),
portal_force_password_change = 0,
portal_access_code = NULL
portal_access_code = NULL,
portal_last_login_at = UTC_TIMESTAMP()
WHERE id = %s
""", (generate_password_hash(password), client["id"]))
conn.commit()
@ -5772,7 +6060,12 @@ Portal URL:
Login email:
{portal_email}
Single-use access code:
Click the link below to activate your portal access:
https://portal.outsidethebox.top/portal/set-password?email={portal_email}&code={client.get("portal_access_code")}
If the link does not work, use this access code:
{client.get("portal_access_code")}
Important:
@ -5949,7 +6242,12 @@ Portal URL:
Login email:
{client.get("email")}
Single-use access code:
Click the link below to activate your portal access:
https://portal.outsidethebox.top/portal/set-password?email={portal_email}&code={client.get("portal_access_code")}
If the link does not work, use this access code:
{new_code}
Important:

59
bump.sh

@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -e
VERSION="v0.3.0"
DATE="$(date +%Y-%m-%d)"
STAMP="$(date +%Y%m%d-%H%M%S)"
echo "===== CLEAN TEMP FILES ====="
find . -type f \( -name "*.bak*" -o -name "patch*.sh" \)
read -p "Delete these files? Type YES: " CONFIRM
if [ "$CONFIRM" = "YES" ]; then
find . -type f \( -name "*.bak*" -o -name "patch*.sh" \) -delete
fi
echo "===== SET VERSION ====="
echo "$VERSION" > VERSION
echo "===== UPDATE PROJECT_STATE.md ====="
cat <<STATE >> PROJECT_STATE.md
## $VERSION - $DATE
- Portal onboarding flow upgraded
- Email invites now include clickable activation link
- /portal/set-password now supports direct email+code login
- Auto session creation from invite link
- Improved UX: no manual code entry required
- Portal onboarding now production-ready
STATE
echo "===== UPDATE README.md ====="
sed -i "1i\\
## $VERSION ($DATE)\\
- Clickable portal invite links\\
- Direct account activation from email\\
- Improved onboarding UX\\
" README.md
echo "===== VERIFY PYTHON ====="
python3 -m py_compile backend/app.py
echo "===== CREATE FULL BACKUP ====="
zip -r "/home/def/backuphere/otb_billing-$VERSION-$STAMP.zip" . >/dev/null
echo "===== GIT ADD ====="
git add .
echo "===== GIT COMMIT ====="
git commit -m "Release $VERSION - Portal onboarding flow complete (email link activation)"
echo "===== GIT TAG ====="
git tag "$VERSION"
echo "===== GIT PUSH ====="
git push
git push origin "$VERSION"
echo "===== DONE ====="

1278
patch.sh

File diff suppressed because it is too large Load Diff

70
patch1.sh

@ -1,70 +0,0 @@
cd /home/def/otb_billing || exit 1
STAMP=$(date +%Y%m%d-%H%M%S)
NEWVER="v0.6.1"
echo "===== backup full project ====="
mkdir -p /home/def/backuphere
cd /home/def
tar -czf /home/def/backuphere/otb_billing-${NEWVER}-${STAMP}.tar.gz otb_billing
echo "===== update VERSION ====="
cd /home/def/otb_billing || exit 1
echo "${NEWVER}" > VERSION
echo "===== update README.md ====="
cp README.md /home/def/backuphere/README.md.${STAMP}.bak
cat > /tmp/readme_entry.txt <<EOF
## ${NEWVER} - $(date +%Y-%m-%d)
### Added
- Service Templates system (standalone pricing catalog)
- Admin UI for managing reusable service pricing
- Template selector on service create/edit pages (auto-fill fields)
### Notes
- Templates are not yet linked to services via template_id (planned)
- Setup amount stored in templates for future invoice integration
- Maintains compatibility with existing services table
---
EOF
cat /tmp/readme_entry.txt README.md > README.md.new
mv README.md.new README.md
echo "===== update PROJECT_STATE.md ====="
cp PROJECT_STATE.md /home/def/backuphere/PROJECT_STATE.md.${STAMP}.bak
cat > /tmp/state_entry.txt <<EOF
## ${NEWVER} - Service Templates Phase 1
- Added service_templates table
- Implemented admin CRUD routes in app.py
- Added templates UI pages
- Integrated template selection into services/new and services/edit
- Auto-fill JS implemented for template selection
Status: FUNCTIONAL
Next: link templates to services + invoice integration
---
EOF
cat /tmp/state_entry.txt PROJECT_STATE.md > PROJECT_STATE.md.new
mv PROJECT_STATE.md.new PROJECT_STATE.md
echo "===== git status ====="
git status
echo "===== commit ====="
git add .
git commit -m "bump ${NEWVER} - add service templates system"
echo "===== push ====="
git push
echo "===== done ====="
echo "Backup saved at:"
ls -lh /home/def/backuphere/otb_billing-${NEWVER}-${STAMP}.tar.gz

110
scripts/invoice_reminder_worker.py.bak_20260313-035553

@ -1,110 +0,0 @@
#!/usr/bin/env python3
import sys
from datetime import datetime, timedelta
sys.path.append("/home/def/otb_billing/backend")
from app import get_db_connection, send_configured_email
REMINDER_DAYS = 7
OVERDUE_DAYS = 14
def main():
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
now = datetime.utcnow()
cursor.execute("""
SELECT
i.id,
i.invoice_number,
i.created_at,
i.total,
i.client_id,
c.email,
c.company_name,
c.contact_name
FROM invoices i
JOIN clients c ON c.id = i.client_id
WHERE i.status IN ('pending','sent')
""")
invoices = cursor.fetchall()
for inv in invoices:
age = (now - inv["created_at"]).days
email = inv["email"]
if not email:
continue
name = inv.get("contact_name") or inv.get("company_name") or "Client"
portal_url = f"https://portal.outsidethebox.top/portal/invoice/{inv['id']}"
if age >= OVERDUE_DAYS:
subject = f"Invoice {inv['invoice_number']} is overdue"
body = f"""
Hello {name},
Invoice {inv['invoice_number']} is now overdue.
Amount Due:
{inv['total']}
View invoice:
{portal_url}
Please arrange payment at your earliest convenience.
OutsideTheBox
"""
send_configured_email(
to_email=email,
subject=subject,
body=body,
attachments=None,
email_type="invoice_overdue",
invoice_id=inv["id"]
)
elif age >= REMINDER_DAYS:
subject = f"Invoice {inv['invoice_number']} reminder"
body = f"""
Hello {name},
This is a reminder that invoice {inv['invoice_number']} is still outstanding.
Amount Due:
{inv['total']}
View invoice:
{portal_url}
Thank you.
OutsideTheBox
"""
send_configured_email(
to_email=email,
subject=subject,
body=body,
attachments=None,
email_type="invoice_reminder",
invoice_id=inv["id"]
)
conn.close()
if __name__ == "__main__":
main()

117
scripts/invoice_reminder_worker.py.bak_20260313-035724

@ -1,117 +0,0 @@
#!/usr/bin/env python3
import sys
import os
from datetime import datetime, timedelta
from dotenv import load_dotenv
# load same environment config as Flask
load_dotenv("/home/def/otb_billing/.env")
sys.path.append("/home/def/otb_billing/backend")
from app import get_db_connection, send_configured_email
REMINDER_DAYS = 7
OVERDUE_DAYS = 14
def main():
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
now = datetime.utcnow()
cursor.execute("""
SELECT
i.id,
i.invoice_number,
i.created_at,
i.total,
i.client_id,
c.email,
c.company_name,
c.contact_name
FROM invoices i
JOIN clients c ON c.id = i.client_id
WHERE i.status IN ('pending','sent')
""")
invoices = cursor.fetchall()
for inv in invoices:
age = (now - inv["created_at"]).days
email = inv["email"]
if not email:
continue
name = inv.get("contact_name") or inv.get("company_name") or "Client"
portal_url = f"https://portal.outsidethebox.top/portal/invoice/{inv['id']}"
if age >= OVERDUE_DAYS:
subject = f"Invoice {inv['invoice_number']} is overdue"
body = f"""
Hello {name},
Invoice {inv['invoice_number']} is now overdue.
Amount Due:
{inv['total']}
View invoice:
{portal_url}
Please arrange payment at your earliest convenience.
OutsideTheBox
"""
send_configured_email(
to_email=email,
subject=subject,
body=body,
attachments=None,
email_type="invoice_overdue",
invoice_id=inv["id"]
)
elif age >= REMINDER_DAYS:
subject = f"Invoice {inv['invoice_number']} reminder"
body = f"""
Hello {name},
This is a reminder that invoice {inv['invoice_number']} is still outstanding.
Amount Due:
{inv['total']}
View invoice:
{portal_url}
Thank you.
OutsideTheBox
"""
send_configured_email(
to_email=email,
subject=subject,
body=body,
attachments=None,
email_type="invoice_reminder",
invoice_id=inv["id"]
)
conn.close()
if __name__ == "__main__":
main()

116
scripts/invoice_reminder_worker.py.bak_20260313-041145

@ -1,116 +0,0 @@
#!/usr/bin/env python3
import sys
import os
from datetime import datetime, timedelta
from dotenv import load_dotenv
# load same environment config as Flask
load_dotenv("/home/def/otb_billing/.env")
sys.path.append("/home/def/otb_billing/backend")
from app import get_db_connection, send_configured_email
REMINDER_DAYS = 7
OVERDUE_DAYS = 14
def main():
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
now = datetime.utcnow()
cursor.execute("""
SELECT
i.id,
i.invoice_number,
i.created_at,
i.client_id,
c.email,
c.company_name,
c.contact_name
FROM invoices i
JOIN clients c ON c.id = i.client_id
WHERE i.status IN ('pending','sent')
""")
invoices = cursor.fetchall()
for inv in invoices:
age = (now - inv["created_at"]).days
email = inv["email"]
if not email:
continue
name = inv.get("contact_name") or inv.get("company_name") or "Client"
portal_url = f"https://portal.outsidethebox.top/portal/invoice/{inv['id']}"
if age >= OVERDUE_DAYS:
subject = f"Invoice {inv['invoice_number']} is overdue"
body = f"""
Hello {name},
Invoice {inv['invoice_number']} is now overdue.
Amount Due:
Invoice amount available in portal
View invoice:
{portal_url}
Please arrange payment at your earliest convenience.
OutsideTheBox
"""
send_configured_email(
to_email=email,
subject=subject,
body=body,
attachments=None,
email_type="invoice_overdue",
invoice_id=inv["id"]
)
elif age >= REMINDER_DAYS:
subject = f"Invoice {inv['invoice_number']} reminder"
body = f"""
Hello {name},
This is a reminder that invoice {inv['invoice_number']} is still outstanding.
Amount Due:
Invoice amount available in portal
View invoice:
{portal_url}
Thank you.
OutsideTheBox
"""
send_configured_email(
to_email=email,
subject=subject,
body=body,
attachments=None,
email_type="invoice_reminder",
invoice_id=inv["id"]
)
conn.close()
if __name__ == "__main__":
main()

83
templates/portal/terms.html

@ -0,0 +1,83 @@
{% extends "portal_base.html" %}
{% block content %}
<div class="card">
<h2>Outsidethebox.top Service Agreement (v1.1)</h2>
<p>You must read and accept this agreement before using the portal.</p>
<h3>1. Nature of Services</h3>
<p>
Outsidethebox.top provides a range of digital services including, but not limited to:
</p>
<ul>
<li>Data storage and file backup services</li>
<li>Video and image processing and conversion</li>
<li>GPS and location-based tracking applications</li>
<li>Web hosting and infrastructure services</li>
<li>Custom tools and SaaS-style applications</li>
</ul>
<p>
These services may involve the collection, storage, processing, and transmission of user data as required for proper functionality.
</p>
<h3>2. Use of Services</h3>
<p>
Each service provided by Outsidethebox.top may have specific operational requirements, including data handling, processing, or tracking capabilities.
</p>
<p>
By using any service, you acknowledge and accept the requirements necessary for that service to function.
</p>
<p>
If you do not agree with the requirements of a specific service, you must not use that service.
</p>
<h3>3. User Responsibility</h3>
<p>
You are responsible for:
</p>
<ul>
<li>Understanding the purpose and function of each service you use</li>
<li>Ensuring you have appropriate authorization for any data you upload or process</li>
<li>Choosing not to use services that conflict with your privacy or operational preferences</li>
</ul>
<h3>4. Data Handling</h3>
<p>
Outsidethebox.top systems may store, process, and transmit data as required to deliver services. This may include files, metadata, and service-related information.
</p>
<h3>5. Service-Specific Agreements</h3>
<p>
Certain services may present additional agreements or notices before use. These must be accepted before accessing those services.
</p>
<h3>6. Acceptance</h3>
<p>
By proceeding, you confirm that:
</p>
<ul>
<li>You understand the nature of the services provided</li>
<li>You accept that services may require data processing or tracking to function</li>
<li>You will not use services whose requirements you do not accept</li>
</ul>
<form method="post">
<label>
<input type="checkbox" name="accept" required>
I understand and accept the Outsidethebox.top account and service requirements.
</label>
<br><br>
<button type="submit" class="btn btn-primary">Continue</button>
<a href="/logout" class="btn">Logout</a>
</form>
</div>
{% endblock %}

209
templates/portal_invoice_detail.html.bak_20260314-020444

@ -1,209 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice Detail - OutsideTheBox</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; }
.portal-top {
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap;
margin-bottom: 1rem;
}
.portal-actions a {
margin-left: 0.75rem;
text-decoration: underline;
}
.detail-grid {
display:grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap:1rem;
margin: 1rem 0 1.25rem 0;
}
.detail-card {
border: 1px solid rgba(255,255,255,0.16);
border-radius: 14px;
padding: 1rem;
background: rgba(255,255,255,0.03);
}
.detail-card h3 {
margin-top: 0;
margin-bottom: 0.4rem;
}
table.portal-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
table.portal-table th, table.portal-table td {
padding: 0.8rem;
border-bottom: 1px solid rgba(255,255,255,0.12);
text-align: left;
}
table.portal-table th {
background: #e9eef7;
color: #10203f;
}
.invoice-actions {
margin-top: 1rem;
}
.invoice-actions a {
margin-right: 1rem;
text-decoration: underline;
}
.status-badge {
display: inline-block;
padding: 0.18rem 0.55rem;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 700;
}
.status-paid {
background: rgba(34, 197, 94, 0.18);
color: #4ade80;
}
.status-pending {
background: rgba(245, 158, 11, 0.20);
color: #fbbf24;
}
.status-overdue {
background: rgba(239, 68, 68, 0.18);
color: #f87171;
}
.status-other {
background: rgba(148, 163, 184, 0.20);
color: #cbd5e1;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="portal-wrap">
<div class="portal-top">
<div>
<h1>Invoice Detail</h1>
<p>{{ client.company_name or client.contact_name or client.email }}</p>
</div>
<div class="portal-actions">
<a href="/portal/dashboard">Back to Dashboard</a>
<a href="https://outsidethebox.top/">Home</a>
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a>
<a href="/portal/logout">Logout</a>
</div>
</div>
<div class="detail-grid">
<div class="detail-card">
<h3>Invoice</h3>
<div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div>
</div>
<div class="detail-card">
<h3>Status</h3>
{% set s = (invoice.status or "")|lower %}
{% if s == "paid" %}
<span class="status-badge status-paid">{{ invoice.status }}</span>
{% elif s == "pending" %}
<span class="status-badge status-pending">{{ invoice.status }}</span>
{% elif s == "overdue" %}
<span class="status-badge status-overdue">{{ invoice.status }}</span>
{% else %}
<span class="status-badge status-other">{{ invoice.status }}</span>
{% endif %}
</div>
<div class="detail-card">
<h3>Created</h3>
<div>{{ invoice.created_at }}</div>
</div>
<div class="detail-card">
<h3>Total</h3>
<div>{{ invoice.total_amount }}</div>
</div>
<div class="detail-card">
<h3>Paid</h3>
<div>{{ invoice.amount_paid }}</div>
</div>
<div class="detail-card">
<h3>Outstanding</h3>
<div>{{ invoice.outstanding }}</div>
</div>
</div>
<h2>Invoice Items</h2>
<table class="portal-table">
<thead>
<tr>
<th>Description</th>
<th>Qty</th>
<th>Unit Price</th>
<th>Line Total</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.description }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.unit_price }}</td>
<td>{{ item.line_total }}</td>
</tr>
{% else %}
<tr>
<td colspan="4">No invoice line items found.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if pdf_url %}
<div class="invoice-actions">
<a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a>
</div>
{% endif %}
</div>
{% include "footer.html" %}
<hr>
<h3>Payment Instructions</h3>
<p><strong>Interac e-Transfer</strong><br>
Send payment to:<br>
payment@outsidethebox.top<br>
Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}
</p>
<p><strong>Credit Card (Square)</strong><br>
<br><br>
<p><strong>Credit Card (Square)</strong></p>
<a href="https://square.link/u/H0cimZku" target="_blank"
style="
display:inline-block;
padding:12px 18px;
background:#28a745;
color:white;
text-decoration:none;
border-radius:6px;
font-weight:bold;
">
Pay with Card (Square)
</a>
<p style="font-size:12px;color:#666;margin-top:6px;">
You will be redirected to Square's secure payment page.
Please include your invoice number in the payment note.
</p>
</p>
<p>
If you have questions please contact
<a href="mailto:support@outsidethebox.top">support@outsidethebox.top</a>
</p>
</body>
</html>

6
templates/portal_login.html

@ -36,6 +36,12 @@
</div>
</form>
<p>
Need an account?
<a href="/portal/register">Create portal access</a>
</p>
<div style="margin-top:15px;">
<a href="/portal/forgot-password">Forgot your password?</a>
</div>

46
templates/portal_register.html

@ -0,0 +1,46 @@
{% extends "portal_base.html" %}
{% block title %}Create Portal Access - OutsideTheBox{% endblock %}
{% block portal_content %}
<div class="portal-card">
<h1>Request Portal Access</h1>
<p>Enter your details and we will email you a one-time access code. After first login, you will set your password.</p>
{% if error %}
<div class="portal-alert portal-alert-error">{{ error }}</div>
{% endif %}
{% if message %}
<div class="portal-alert portal-alert-success">{{ message }}</div>
<p><a href="/portal">Return to portal login</a></p>
{% else %}
<form method="post" action="/portal/register">
<div style="margin-bottom: 14px;">
<label for="email">Email Address *</label><br>
<input id="email" type="email" name="email" value="{{ form_email or '' }}" required style="width: 100%; max-width: 640px;">
</div>
<div style="margin-bottom: 14px;">
<label for="company_name">Company / Organization</label><br>
<input id="company_name" type="text" name="company_name" value="{{ form_company or '' }}" style="width: 100%; max-width: 640px;">
</div>
<div style="margin-bottom: 14px;">
<label for="contact_name">Your Name</label><br>
<input id="contact_name" type="text" name="contact_name" value="{{ form_contact or '' }}" style="width: 100%; max-width: 640px;">
</div>
<div style="margin-bottom: 14px;">
<label for="note">What are you requesting?</label><br>
<textarea id="note" name="note" rows="5" style="width: 100%; max-width: 640px;">{{ form_note or '' }}</textarea>
</div>
<div class="portal-actions">
<button type="submit">Email My Access Code</button>
<a class="portal-button-secondary" href="/portal">Back to Login</a>
</div>
</form>
{% endif %}
</div>
{% endblock %}

4
templates/service_templates/edit.html

@ -33,10 +33,6 @@ Template Name *<br>
Service Type *<br>
<select name="service_type" required>
<option value="hosting" {% if template.service_type == 'hosting' %}selected{% endif %}>hosting</option>
<option value="rpc" {% if template.service_type == 'rpc' %}selected{% endif %}>rpc</option>
<option value="explorer" {% if template.service_type == 'explorer' %}selected{% endif %}>explorer</option>
<option value="node" {% if template.service_type == 'node' %}selected{% endif %}>node</option>
<option value="ipfs" {% if template.service_type == 'ipfs' %}selected{% endif %}>ipfs</option>
<option value="consulting" {% if template.service_type == 'consulting' %}selected{% endif %}>consulting</option>
<option value="crypto_infra" {% if template.service_type == 'crypto_infra' %}selected{% endif %}>Crypto Infra</option>
<option value="other" {% if template.service_type == 'other' %}selected{% endif %}>other</option>

54
templates/service_templates/list.html

@ -14,6 +14,43 @@
<a href="/service-templates/new">Add Service Template</a>
</p>
<form method="get" action="/service-templates" style="margin-bottom: 16px; padding: 10px; border: 1px solid #ccc;">
<p style="margin: 0 0 10px 0;"><strong>Filters</strong></p>
<p style="margin: 0 0 10px 0;">
Service Type<br>
<select name="service_type">
<option value="" {% if not selected_type %}selected{% endif %}>All</option>
<option value="hosting" {% if selected_type == 'hosting' %}selected{% endif %}>hosting</option>
<option value="crypto_infra" {% if selected_type == 'crypto_infra' %}selected{% endif %}>crypto_infra</option>
<option value="saas" {% if selected_type == 'saas' %}selected{% endif %}>saas</option>
<option value="consulting" {% if selected_type == 'consulting' %}selected{% endif %}>consulting</option>
<option value="other" {% if selected_type == 'other' %}selected{% endif %}>other</option>
</select>
</p>
<p style="margin: 0 0 10px 0;">
Active<br>
<select name="is_active">
<option value="" {% if not selected_active %}selected{% endif %}>All</option>
<option value="1" {% if selected_active == '1' %}selected{% endif %}>yes</option>
<option value="0" {% if selected_active == '0' %}selected{% endif %}>no</option>
</select>
</p>
<p style="margin: 0;">
<button type="submit">Apply Filters</button>
<a href="/service-templates" style="margin-left: 10px;">Clear</a>
</p>
</form>
<p>
Showing <strong>{{ total_count }}</strong> template{% if total_count != 1 %}s{% endif %}
{% if selected_type %} | Type: <strong>{{ selected_type }}</strong>{% endif %}
{% if selected_active == '1' %} | Active: <strong>yes</strong>{% endif %}
{% if selected_active == '0' %} | Active: <strong>no</strong>{% endif %}
</p>
<table border="1" cellpadding="6">
<tr>
<th>ID</th>
@ -23,6 +60,8 @@
<th>Currency</th>
<th>Recurring</th>
<th>Setup</th>
<th>Used By</th>
<th>Active Used By</th>
<th>Active</th>
<th>Actions</th>
</tr>
@ -36,11 +75,24 @@
<td>{{ t.currency_code }}</td>
<td>{{ t.recurring_amount|money(t.currency_code) }}</td>
<td>{{ t.setup_amount|money(t.currency_code) }}</td>
<td>{{ t.usage_count }}</td>
<td>{{ t.active_usage_count }}</td>
<td>{% if t.is_active %}yes{% else %}no{% endif %}</td>
<td><a href="/service-templates/edit/{{ t.id }}">Edit</a></td>
<td>
<a href="/service-templates/edit/{{ t.id }}">Edit</a>
{% if t.is_active %}
| <a href="/services/new?template_id={{ t.id }}">Create Service</a>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not templates %}
<tr>
<td colspan="11" style="text-align:center;">No service templates matched the selected filters.</td>
</tr>
{% endif %}
</table>
{% include "footer.html" %}

5
templates/service_templates/new.html

@ -33,12 +33,9 @@ Template Name *<br>
Service Type *<br>
<select name="service_type" required>
<option value="hosting">hosting</option>
<option value="rpc">rpc</option>
<option value="explorer">explorer</option>
<option value="node">node</option>
<option value="ipfs">ipfs</option>
<option value="consulting">consulting</option>
<option value="crypto_infra">Crypto Infra</option>
<option value="saas">SaaS</option>
<option value="other">other</option>
</select>
</p>

15
templates/services/edit.html

@ -26,6 +26,7 @@
{% endif %}
<form method="post">
<input type="hidden" name="template_id" id="template_id" value="{{ service.template_id or '' }}">
<p>
Service Code<br>
@ -58,6 +59,7 @@ Load from Template<br>
data-recurring="{{ t.recurring_amount }}"
data-setup="{{ t.setup_amount }}"
data-description="{{ (t.description or '')|e }}"
{% if service.template_id == t.id %}selected{% endif %}
>
{{ t.template_name }} ({{ t.recurring_amount|money(t.currency_code) }}{% if t.setup_amount and t.setup_amount != 0 %}, setup {{ t.setup_amount|money(t.currency_code) }}{% endif %})
</option>
@ -74,10 +76,6 @@ Service Name *<br>
Service Type *<br>
<select name="service_type" required>
<option value="hosting" {% if service.service_type == 'hosting' %}selected{% endif %}>hosting</option>
<option value="rpc" {% if service.service_type == 'rpc' %}selected{% endif %}>rpc</option>
<option value="explorer" {% if service.service_type == 'explorer' %}selected{% endif %}>explorer</option>
<option value="node" {% if service.service_type == 'node' %}selected{% endif %}>node</option>
<option value="ipfs" {% if service.service_type == 'ipfs' %}selected{% endif %}>ipfs</option>
<option value="consulting" {% if service.service_type == 'consulting' %}selected{% endif %}>consulting</option>
<option value="crypto_infra" {% if service.service_type == 'crypto_infra' %}selected{% endif %}>Crypto Infra</option>
<option value="other" {% if service.service_type == 'other' %}selected{% endif %}>other</option>
@ -139,12 +137,17 @@ Description<br>
<script>
(function() {
var select = document.getElementById("template_select");
if (!select) return;
var hiddenTemplateId = document.getElementById("template_id");
if (!select || !hiddenTemplateId) return;
select.addEventListener("change", function() {
var opt = this.options[this.selectedIndex];
if (!opt || !opt.dataset || !opt.dataset.name) return;
if (!opt || !opt.dataset || !opt.dataset.name) {
hiddenTemplateId.value = "";
return;
}
hiddenTemplateId.value = opt.value || "";
document.querySelector('[name="service_name"]').value = opt.dataset.name || '';
document.querySelector('[name="service_type"]').value = opt.dataset.type || 'other';
document.querySelector('[name="billing_cycle"]').value = opt.dataset.cycle || 'monthly';

82
templates/services/list.html

@ -14,10 +14,85 @@
<a href="/service-templates">Service Templates</a>
</p>
<div style="margin-bottom: 16px; padding: 10px; border: 1px solid #ccc;">
<p style="margin: 0 0 10px 0;"><strong>Active Services Summary</strong></p>
<table border="1" cellpadding="6" style="margin-bottom: 10px;">
<tr>
<th>Type</th>
<th>Active Services</th>
<th>Monthly Total</th>
</tr>
{% for row in summary_rows %}
<tr>
<td>{{ row.service_type }}</td>
<td>{{ row.service_count }}</td>
<td>{{ row.total_monthly|money('CAD') }}</td>
</tr>
{% endfor %}
{% if not summary_rows %}
<tr>
<td colspan="3" style="text-align:center;">No active services found.</td>
</tr>
{% endif %}
<tr>
<th>Total</th>
<th>{{ active_totals.service_count }}</th>
<th>{{ active_totals.total_monthly|money('CAD') }}</th>
</tr>
</table>
<p style="margin: 0; font-size: 0.95em;">
Summary is based on <strong>active</strong> services and their recurring amounts.
</p>
</div>
<form method="get" action="/services" style="margin-bottom: 16px; padding: 10px; border: 1px solid #ccc;">
<p style="margin: 0 0 10px 0;"><strong>Filters</strong></p>
<p style="margin: 0 0 10px 0;">
Service Type<br>
<select name="service_type">
<option value="" {% if not selected_type %}selected{% endif %}>All</option>
<option value="hosting" {% if selected_type == 'hosting' %}selected{% endif %}>hosting</option>
<option value="crypto_infra" {% if selected_type == 'crypto_infra' %}selected{% endif %}>crypto_infra</option>
<option value="saas" {% if selected_type == 'saas' %}selected{% endif %}>saas</option>
<option value="consulting" {% if selected_type == 'consulting' %}selected{% endif %}>consulting</option>
<option value="other" {% if selected_type == 'other' %}selected{% endif %}>other</option>
</select>
</p>
<p style="margin: 0 0 10px 0;">
Status<br>
<select name="status">
<option value="" {% if not selected_status %}selected{% endif %}>All</option>
<option value="pending" {% if selected_status == 'pending' %}selected{% endif %}>pending</option>
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>active</option>
<option value="suspended" {% if selected_status == 'suspended' %}selected{% endif %}>suspended</option>
<option value="cancelled" {% if selected_status == 'cancelled' %}selected{% endif %}>cancelled</option>
</select>
</p>
<p style="margin: 0;">
<button type="submit">Apply Filters</button>
<a href="/services" style="margin-left: 10px;">Clear</a>
</p>
</form>
<p>
Showing <strong>{{ total_count }}</strong> service{% if total_count != 1 %}s{% endif %}
{% if selected_type %} | Type: <strong>{{ selected_type }}</strong>{% endif %}
{% if selected_status %} | Status: <strong>{{ selected_status }}</strong>{% endif %}
</p>
<table border="1" cellpadding="6">
<tr>
<th>ID</th>
<th>Service Code</th>
<th>Template</th>
<th>Client</th>
<th>Service Name</th>
<th>Type</th>
@ -33,6 +108,7 @@
<tr>
<td>{{ s.id }}</td>
<td>{{ s.service_code }}</td>
<td>{% if s.template_name %}{{ s.template_name }}{% else %}-{% endif %}</td>
<td>{{ s.client_code }} - {{ s.company_name }}</td>
<td>{{ s.service_name }}</td>
<td>{{ s.service_type }}</td>
@ -45,6 +121,12 @@
</tr>
{% endfor %}
{% if not services %}
<tr>
<td colspan="12" style="text-align:center;">No services matched the selected filters.</td>
</tr>
{% endif %}
</table>
{% include "footer.html" %}

28
templates/services/new.html

@ -15,6 +15,7 @@
</p>
<form method="post">
<input type="hidden" name="template_id" id="template_id" value="{{ preselect_template_id or '' }}">
<p>
Client<br>
@ -40,6 +41,7 @@ Load from Template<br>
data-recurring="{{ t.recurring_amount }}"
data-setup="{{ t.setup_amount }}"
data-description="{{ (t.description or '')|e }}"
{% if preselect_template_id and preselect_template_id|string == t.id|string %}selected{% endif %}
>
{{ t.template_name }} ({{ t.recurring_amount|money(t.currency_code) }}{% if t.setup_amount and t.setup_amount != 0 %}, setup {{ t.setup_amount|money(t.currency_code) }}{% endif %})
</option>
@ -56,12 +58,9 @@ Service Name<br>
Service Type<br>
<select name="service_type" required>
<option value="hosting">hosting</option>
<option value="rpc">rpc</option>
<option value="explorer">explorer</option>
<option value="node">node</option>
<option value="ipfs">ipfs</option>
<option value="consulting">consulting</option>
<option value="crypto_infra">Crypto Infra</option>
<option value="saas">SaaS</option>
<option value="other">other</option>
</select>
</p>
@ -121,19 +120,30 @@ Description<br>
<script>
(function() {
var select = document.getElementById("template_select");
if (!select) return;
var hiddenTemplateId = document.getElementById("template_id");
if (!select || !hiddenTemplateId) return;
select.addEventListener("change", function() {
var opt = this.options[this.selectedIndex];
if (!opt || !opt.dataset || !opt.dataset.name) return;
function applySelectedTemplate() {
var opt = select.options[select.selectedIndex];
if (!opt || !opt.dataset || !opt.dataset.name) {
hiddenTemplateId.value = "";
return;
}
hiddenTemplateId.value = opt.value || "";
document.querySelector('[name="service_name"]').value = opt.dataset.name || '';
document.querySelector('[name="service_type"]').value = opt.dataset.type || 'other';
document.querySelector('[name="billing_cycle"]').value = opt.dataset.cycle || 'monthly';
document.querySelector('[name="currency_code"]').value = opt.dataset.currency || 'CAD';
document.querySelector('[name="recurring_amount"]').value = opt.dataset.recurring || '0.00000000';
document.querySelector('[name="description"]').value = opt.dataset.description || '';
});
}
select.addEventListener("change", applySelectedTemplate);
if (hiddenTemplateId.value) {
applySelectedTemplate();
}
})();
</script>

173
templates/settings.html.bak_logo_layout_fix

@ -1,173 +0,0 @@
<!doctype html>
<html>
<head>
<title>Settings</title>
<style>
body { font-family: Arial, sans-serif; }
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
max-width: 1100px;
}
.card {
border: 1px solid #ccc;
padding: 16px;
}
.card h2 {
margin-top: 0;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
textarea,
select {
width: 100%;
box-sizing: border-box;
margin-top: 4px;
margin-bottom: 12px;
padding: 8px;
}
textarea { min-height: 90px; }
.checkbox-row {
margin: 8px 0 14px 0;
}
.save-row {
margin-top: 18px;
}
</style>
</head>
<body>
<h1>Settings / Config</h1>
<p><a href="/">Home</a></p>
<form method="post">
<div class="form-grid">
<div class="card">
<h2>Business Identity</h2>
Business Name<br>
Business Logo URL<br>
<input type="text" name="business_logo_url" value="{{ settings.business_logo_url }}"><br>
<small>Example: /static/logo.png or https://site.com/logo.png</small><br><br>
<input type="text" name="business_name" value="{{ settings.business_name }}"><br>
Slogan / Tagline<br>
<input type="text" name="business_tagline" value="{{ settings.business_tagline }}"><br>
Business Email<br>
<input type="email" name="business_email" value="{{ settings.business_email }}"><br>
Business Phone<br>
<input type="text" name="business_phone" value="{{ settings.business_phone }}"><br>
Business Address<br>
<textarea name="business_address">{{ settings.business_address }}</textarea><br>
Website<br>
<input type="text" name="business_website" value="{{ settings.business_website }}"><br>
Business Number / Registration Number<br>
<input type="text" name="business_number" value="{{ settings.business_number }}"><br>
Default Currency<br>
<select name="default_currency">
<option value="CAD" {% if settings.default_currency == 'CAD' %}selected{% endif %}>CAD</option>
<option value="USD" {% if settings.default_currency == 'USD' %}selected{% endif %}>USD</option>
<option value="ETHO" {% if settings.default_currency == 'ETHO' %}selected{% endif %}>ETHO</option>
<option value="EGAZ" {% if settings.default_currency == 'EGAZ' %}selected{% endif %}>EGAZ</option>
<option value="ALT" {% if settings.default_currency == 'ALT' %}selected{% endif %}>ALT</option>
</select>
</div>
<div class="card">
<h2>Tax Settings</h2>
Local Country<br>
<input type="text" name="local_country" value="{{ settings.local_country }}"><br>
Tax Label<br>
<input type="text" name="tax_label" value="{{ settings.tax_label }}"><br>
Tax Rate (%)<br>
<input type="number" step="0.01" name="tax_rate" value="{{ settings.tax_rate }}"><br>
Tax Number<br>
<input type="text" name="tax_number" value="{{ settings.tax_number }}"><br>
<div class="checkbox-row">
<label>
<input type="checkbox" name="apply_local_tax_only" value="1" {% if settings.apply_local_tax_only == '1' %}checked{% endif %}>
Apply tax only to local clients
</label>
</div>
Payment Terms<br>
<textarea name="payment_terms">{{ settings.payment_terms }}</textarea><br>
Invoice Footer<br>
<textarea name="invoice_footer">{{ settings.invoice_footer }}</textarea><br>
</div>
<div class="card">
<h2>Email / SMTP</h2>
SMTP Host<br>
<input type="text" name="smtp_host" value="{{ settings.smtp_host }}"><br>
SMTP Port<br>
<input type="number" name="smtp_port" value="{{ settings.smtp_port }}"><br>
SMTP Username<br>
<input type="text" name="smtp_user" value="{{ settings.smtp_user }}"><br>
SMTP Password<br>
<input type="password" name="smtp_pass" value="{{ settings.smtp_pass }}"><br>
From Email<br>
<input type="email" name="smtp_from_email" value="{{ settings.smtp_from_email }}"><br>
From Name<br>
<input type="text" name="smtp_from_name" value="{{ settings.smtp_from_name }}"><br>
<div class="checkbox-row">
<label>
<input type="checkbox" name="smtp_use_tls" value="1" {% if settings.smtp_use_tls == '1' %}checked{% endif %}>
Use TLS
</label>
</div>
<div class="checkbox-row">
<label>
<input type="checkbox" name="smtp_use_ssl" value="1" {% if settings.smtp_use_ssl == '1' %}checked{% endif %}>
Use SSL
</label>
</div>
</div>
<div class="card">
<h2>Notes</h2>
<p>
These settings become the identity and delivery configuration for this installation.
</p>
<p>
Email sending is not wired yet, but these SMTP settings are stored now so the next step can use them.
</p>
<p>
Tax settings are also stored now so invoice and automation logic can use them later.
</p>
</div>
</div>
<div class="save-row">
<button type="submit">Save Settings</button>
</div>
</form>
{% include "footer.html" %}
</body>
</html>
Loading…
Cancel
Save