diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index a5a85e1..375c259 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -328,3 +328,23 @@ python3 backend/app.py
During active development, run in a visible terminal so logs stay visible.
Do not rely on hidden/background launch during normal debug workflow.
+
+=================================================
+Version: v0.4.0-dev
+Date: 2026-03-10
+=================================================
+
+Recurring billing foundation added.
+
+New recurring billing capabilities:
+- subscriptions table auto-created by app
+- subscriptions list page
+- new subscription creation page
+- billing interval support: monthly / quarterly / yearly
+- manual recurring billing run button
+- automatic invoice creation for due subscriptions
+- next_invoice_date auto-advances after invoice creation
+
+Notes:
+- first recurring billing slice is manual-run based
+- cron/systemd timer automation can be added next
diff --git a/VERSION b/VERSION
index 937cd78..ef9b4e2 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-v0.3.1
+v0.4.0-dev
diff --git a/backend/app.py b/backend/app.py
index bbf8543..7acf5fa 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -1,11 +1,12 @@
from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify
from db import get_db_connection
from utils import generate_client_code, generate_service_code
-from datetime import datetime, timezone
+from datetime import datetime, timezone, date, timedelta
from zoneinfo import ZoneInfo
from decimal import Decimal, InvalidOperation
from pathlib import Path
from email.message import EmailMessage
+from dateutil.relativedelta import relativedelta
from io import BytesIO, StringIO
import csv
@@ -193,6 +194,136 @@ def generate_invoice_number():
return f"INV-{number + 1:04d}"
+def ensure_subscriptions_table():
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS subscriptions (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ client_id INT UNSIGNED NOT NULL,
+ service_id INT UNSIGNED NULL,
+ subscription_name VARCHAR(255) NOT NULL,
+ billing_interval ENUM('monthly','quarterly','yearly') NOT NULL DEFAULT 'monthly',
+ price DECIMAL(18,8) NOT NULL DEFAULT 0.00000000,
+ currency_code VARCHAR(16) NOT NULL DEFAULT 'CAD',
+ start_date DATE NOT NULL,
+ next_invoice_date DATE NOT NULL,
+ status ENUM('active','paused','cancelled') NOT NULL DEFAULT 'active',
+ notes TEXT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ KEY idx_subscriptions_client_id (client_id),
+ KEY idx_subscriptions_service_id (service_id),
+ KEY idx_subscriptions_status (status),
+ KEY idx_subscriptions_next_invoice_date (next_invoice_date)
+ )
+ """)
+ conn.commit()
+ conn.close()
+
+
+def get_next_subscription_date(current_date, billing_interval):
+ if isinstance(current_date, str):
+ current_date = datetime.strptime(current_date, "%Y-%m-%d").date()
+
+ if billing_interval == "yearly":
+ return current_date + relativedelta(years=1)
+ if billing_interval == "quarterly":
+ return current_date + relativedelta(months=3)
+ return current_date + relativedelta(months=1)
+
+
+def generate_due_subscription_invoices(run_date=None):
+ ensure_subscriptions_table()
+
+ today = run_date or date.today()
+
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ cursor.execute("""
+ SELECT
+ s.*,
+ c.client_code,
+ c.company_name,
+ srv.service_code,
+ srv.service_name
+ FROM subscriptions s
+ JOIN clients c ON s.client_id = c.id
+ LEFT JOIN services srv ON s.service_id = srv.id
+ WHERE s.status = 'active'
+ AND s.next_invoice_date <= %s
+ ORDER BY s.next_invoice_date ASC, s.id ASC
+ """, (today,))
+ due_subscriptions = cursor.fetchall()
+
+ created_count = 0
+ created_invoice_numbers = []
+
+ for sub in due_subscriptions:
+ invoice_number = generate_invoice_number()
+ due_dt = datetime.combine(today + timedelta(days=14), datetime.min.time())
+
+ note_parts = [f"Recurring subscription: {sub['subscription_name']}"]
+ if sub.get("service_code"):
+ note_parts.append(f"Service: {sub['service_code']}")
+ if sub.get("service_name"):
+ note_parts.append(f"({sub['service_name']})")
+ if sub.get("notes"):
+ note_parts.append(f"Notes: {sub['notes']}")
+
+ note_text = " ".join(note_parts)
+
+ insert_cursor = conn.cursor()
+ insert_cursor.execute("""
+ INSERT INTO invoices
+ (
+ client_id,
+ service_id,
+ invoice_number,
+ currency_code,
+ total_amount,
+ subtotal_amount,
+ tax_amount,
+ issued_at,
+ due_at,
+ status,
+ notes
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, 0, UTC_TIMESTAMP(), %s, 'pending', %s)
+ """, (
+ sub["client_id"],
+ sub["service_id"],
+ invoice_number,
+ sub["currency_code"],
+ str(sub["price"]),
+ str(sub["price"]),
+ due_dt,
+ note_text,
+ ))
+
+ next_date = get_next_subscription_date(sub["next_invoice_date"], sub["billing_interval"])
+
+ update_cursor = conn.cursor()
+ update_cursor.execute("""
+ UPDATE subscriptions
+ SET next_invoice_date = %s
+ WHERE id = %s
+ """, (next_date, sub["id"]))
+
+ created_count += 1
+ created_invoice_numbers.append(invoice_number)
+
+ conn.commit()
+ conn.close()
+
+ return {
+ "created_count": created_count,
+ "invoice_numbers": created_invoice_numbers,
+ "run_date": str(today),
+ }
+
+
APP_SETTINGS_DEFAULTS = {
"business_name": "OTB Billing",
"business_tagline": "By a contractor, for contractors",
@@ -689,6 +820,253 @@ def email_accounting_package():
except Exception:
return redirect("/?pkg_email_failed=1")
+
+
+@app.route("/subscriptions")
+def subscriptions():
+ ensure_subscriptions_table()
+
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+ cursor.execute("""
+ SELECT
+ s.*,
+ c.client_code,
+ c.company_name,
+ srv.service_code,
+ srv.service_name
+ FROM subscriptions s
+ JOIN clients c ON s.client_id = c.id
+ LEFT JOIN services srv ON s.service_id = srv.id
+ ORDER BY s.id DESC
+ """)
+ subscriptions = cursor.fetchall()
+ conn.close()
+
+ return render_template("subscriptions/list.html", subscriptions=subscriptions)
+
+
+@app.route("/subscriptions/new", methods=["GET", "POST"])
+def new_subscription():
+ ensure_subscriptions_table()
+
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ if request.method == "POST":
+ client_id = request.form.get("client_id", "").strip()
+ service_id = request.form.get("service_id", "").strip()
+ subscription_name = request.form.get("subscription_name", "").strip()
+ billing_interval = request.form.get("billing_interval", "").strip()
+ price = request.form.get("price", "").strip()
+ currency_code = request.form.get("currency_code", "").strip()
+ start_date_value = request.form.get("start_date", "").strip()
+ next_invoice_date = request.form.get("next_invoice_date", "").strip()
+ status = request.form.get("status", "").strip()
+ notes = request.form.get("notes", "").strip()
+
+ errors = []
+
+ if not client_id:
+ errors.append("Client is required.")
+ if not subscription_name:
+ errors.append("Subscription name is required.")
+ if billing_interval not in {"monthly", "quarterly", "yearly"}:
+ errors.append("Billing interval is required.")
+ if not price:
+ errors.append("Price is required.")
+ if not currency_code:
+ errors.append("Currency is required.")
+ if not start_date_value:
+ errors.append("Start date is required.")
+ if not next_invoice_date:
+ errors.append("Next invoice date is required.")
+ if status not in {"active", "paused", "cancelled"}:
+ errors.append("Status is required.")
+
+ if not errors:
+ try:
+ price_value = Decimal(str(price))
+ if price_value <= Decimal("0"):
+ errors.append("Price must be greater than zero.")
+ except Exception:
+ errors.append("Price must be a valid number.")
+
+ if errors:
+ cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
+ clients = cursor.fetchall()
+ cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name")
+ services = cursor.fetchall()
+ conn.close()
+
+ return render_template(
+ "subscriptions/new.html",
+ clients=clients,
+ services=services,
+ errors=errors,
+ form_data={
+ "client_id": client_id,
+ "service_id": service_id,
+ "subscription_name": subscription_name,
+ "billing_interval": billing_interval,
+ "price": price,
+ "currency_code": currency_code,
+ "start_date": start_date_value,
+ "next_invoice_date": next_invoice_date,
+ "status": status,
+ "notes": notes,
+ },
+ )
+
+ insert_cursor = conn.cursor()
+ insert_cursor.execute("""
+ INSERT INTO subscriptions
+ (
+ client_id,
+ service_id,
+ subscription_name,
+ billing_interval,
+ price,
+ currency_code,
+ start_date,
+ next_invoice_date,
+ status,
+ notes
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ """, (
+ client_id,
+ service_id or None,
+ subscription_name,
+ billing_interval,
+ str(price_value),
+ currency_code,
+ start_date_value,
+ next_invoice_date,
+ status,
+ notes or None,
+ ))
+
+ conn.commit()
+ conn.close()
+ return redirect("/subscriptions")
+
+ cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
+ clients = cursor.fetchall()
+ cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name")
+ services = cursor.fetchall()
+ conn.close()
+
+ today_str = date.today().isoformat()
+
+ return render_template(
+ "subscriptions/new.html",
+ clients=clients,
+ services=services,
+ errors=[],
+ form_data={
+ "billing_interval": "monthly",
+ "currency_code": "CAD",
+ "start_date": today_str,
+ "next_invoice_date": today_str,
+ "status": "active",
+ },
+ )
+
+
+@app.route("/subscriptions/run", methods=["POST"])
+def run_subscriptions_now():
+ result = generate_due_subscription_invoices()
+ return redirect(f"/subscriptions?run_count={result['created_count']}")
+
+
+
+@app.route("/reports/aging")
+def report_aging():
+ refresh_overdue_invoices()
+
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ cursor.execute("""
+ SELECT
+ c.id AS client_id,
+ c.client_code,
+ c.company_name,
+ i.invoice_number,
+ i.due_at,
+ i.total_amount,
+ i.amount_paid,
+ (i.total_amount - i.amount_paid) AS remaining
+ FROM invoices i
+ JOIN clients c ON i.client_id = c.id
+ WHERE i.status IN ('pending', 'partial', 'overdue')
+ AND (i.total_amount - i.amount_paid) > 0
+ ORDER BY c.company_name, i.due_at
+ """)
+ rows = cursor.fetchall()
+ conn.close()
+
+ today = datetime.utcnow().date()
+ grouped = {}
+ totals = {
+ "current": Decimal("0"),
+ "d30": Decimal("0"),
+ "d60": Decimal("0"),
+ "d90": Decimal("0"),
+ "d90p": Decimal("0"),
+ "total": Decimal("0"),
+ }
+
+ for row in rows:
+ client_id = row["client_id"]
+ client_label = f"{row['client_code']} - {row['company_name']}"
+
+ if client_id not in grouped:
+ grouped[client_id] = {
+ "client": client_label,
+ "current": Decimal("0"),
+ "d30": Decimal("0"),
+ "d60": Decimal("0"),
+ "d90": Decimal("0"),
+ "d90p": Decimal("0"),
+ "total": Decimal("0"),
+ }
+
+ remaining = to_decimal(row["remaining"])
+
+ if row["due_at"]:
+ due_date = row["due_at"].date()
+ age_days = (today - due_date).days
+ else:
+ age_days = 0
+
+ if age_days <= 0:
+ bucket = "current"
+ elif age_days <= 30:
+ bucket = "d30"
+ elif age_days <= 60:
+ bucket = "d60"
+ elif age_days <= 90:
+ bucket = "d90"
+ else:
+ bucket = "d90p"
+
+ grouped[client_id][bucket] += remaining
+ grouped[client_id]["total"] += remaining
+
+ totals[bucket] += remaining
+ totals["total"] += remaining
+
+ aging_rows = list(grouped.values())
+
+ return render_template(
+ "reports/aging.html",
+ aging_rows=aging_rows,
+ totals=totals
+ )
+
+
@app.route("/")
def index():
refresh_overdue_invoices()
@@ -706,6 +1084,7 @@ def index():
SELECT COUNT(*) AS outstanding_invoices
FROM invoices
WHERE status IN ('pending', 'partial', 'overdue')
+ AND (total_amount - amount_paid) > 0
""")
outstanding_invoices = cursor.fetchone()["outstanding_invoices"]
@@ -714,102 +1093,51 @@ def index():
FROM payments
WHERE payment_status = 'confirmed'
""")
- revenue_received = cursor.fetchone()["revenue_received"]
+ revenue_received = to_decimal(cursor.fetchone()["revenue_received"])
+
+ cursor.execute("""
+ SELECT COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance
+ FROM invoices
+ WHERE status IN ('pending', 'partial', 'overdue')
+ AND (total_amount - amount_paid) > 0
+ """)
+ outstanding_balance = to_decimal(cursor.fetchone()["outstanding_balance"])
conn.close()
+ app_settings = get_app_settings()
+
return render_template(
"dashboard.html",
total_clients=total_clients,
active_services=active_services,
outstanding_invoices=outstanding_invoices,
+ outstanding_balance=outstanding_balance,
revenue_received=revenue_received,
+ app_settings=app_settings,
)
-@app.route("/dbtest")
-def dbtest():
- try:
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("SELECT NOW()")
- result = cursor.fetchone()
- conn.close()
- return f"""
-
OTB Billing v{APP_VERSION}
- Database OK
- Home
- DB server time (UTC): {result[0]}
- Displayed local time: {fmt_local(result[0])}
- """
- except Exception as e:
- return f"Database FAILED
{e}"
-
-
-
-@app.route("/clients/export.csv")
-def export_clients_csv():
+@app.route("/clients")
+def clients():
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
+
cursor.execute("""
SELECT
- id,
- client_code,
- company_name,
- contact_name,
- email,
- phone,
- status,
- created_at,
- updated_at
- FROM clients
- ORDER BY id ASC
+ c.*,
+ COALESCE((
+ SELECT SUM(i.total_amount - i.amount_paid)
+ FROM invoices i
+ WHERE i.client_id = c.id
+ AND i.status IN ('pending', 'partial', 'overdue')
+ AND (i.total_amount - i.amount_paid) > 0
+ ), 0) AS outstanding_balance
+ FROM clients c
+ ORDER BY c.company_name
""")
- rows = cursor.fetchall()
- conn.close()
-
- output = StringIO()
- writer = csv.writer(output)
- writer.writerow([
- "id",
- "client_code",
- "company_name",
- "contact_name",
- "email",
- "phone",
- "status",
- "created_at",
- "updated_at",
- ])
-
- for r in rows:
- writer.writerow([
- r.get("id", ""),
- r.get("client_code", ""),
- r.get("company_name", ""),
- r.get("contact_name", ""),
- r.get("email", ""),
- r.get("phone", ""),
- r.get("status", ""),
- r.get("created_at", ""),
- r.get("updated_at", ""),
- ])
-
- response = make_response(output.getvalue())
- response.headers["Content-Type"] = "text/csv; charset=utf-8"
- response.headers["Content-Disposition"] = "attachment; filename=clients.csv"
- return response
-
-@app.route("/clients")
-def clients():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("SELECT * FROM clients ORDER BY id DESC")
clients = cursor.fetchall()
- conn.close()
-
- for client in clients:
- client["credit_balance"] = get_client_credit_balance(client["id"])
+ conn.close()
return render_template("clients/list.html", clients=clients)
@app.route("/clients/new", methods=["GET", "POST"])
diff --git a/docs/test_feedback.md b/docs/test_feedback.md
index af0c8e8..a6d42dd 100644
--- a/docs/test_feedback.md
+++ b/docs/test_feedback.md
@@ -9,49 +9,58 @@ Use this file to collect tester feedback during sandbox / review cycles.
- Include screenshots or emailed notes separately if needed.
- Mark status as `open`, `reviewed`, `planned`, `fixed`, or `wontfix`.
----
+------------------------------------------------------------
## Feedback Entry Template
-### Entry ID: FB-0001
-- Date:
-- Tester:
-- Area:
- - clients
- - services
- - invoices
- - payments
- - subscriptions
- - reports
- - exports
- - email
- - settings
- - installer
- - other
-- Type:
- - bug
- - usability
- - bookkeeping
- - accounting
- - feature request
- - reporting
- - export
- - wording
- - other
-- Severity:
- - low
- - medium
- - high
-- Summary:
-- Steps to reproduce:
-- Expected result:
-- Actual result:
-- Suggested change:
-- Status: open
-- Notes:
-
----
+Entry ID: FB-0001
+Date:
+Tester:
+Area:
+ clients
+ services
+ invoices
+ payments
+ subscriptions
+ reports
+ exports
+ email
+ settings
+ installer
+ other
+
+Type:
+ bug
+ usability
+ bookkeeping
+ accounting
+ feature request
+ reporting
+ export
+ wording
+ other
+
+Severity:
+ low
+ medium
+ high
+
+Summary:
+
+Steps to reproduce:
+
+Expected result:
+
+Actual result:
+
+Suggested change:
+
+Status: open
+
+Notes:
+
+------------------------------------------------------------
## Active Feedback
-
+Copy the template above for each new item.
diff --git a/templates/clients/list.html b/templates/clients/list.html
index 2946db9..8bec526 100644
--- a/templates/clients/list.html
+++ b/templates/clients/list.html
@@ -2,44 +2,54 @@
Clients
+
-Clients
+
+
Clients
-
Home
-
Add Client
-
Export CSV
+
Home
+
Add Client
+
Export CSV
-
-
- | ID |
- Code |
- Company |
- Contact |
- Email |
- Phone |
- Status |
- Actions |
-
+
+
+ | ID |
+ Code |
+ Company |
+ Contact |
+ Email |
+ Phone |
+ Status |
+ Balance |
+ Actions |
+
-{% for c in clients %}
-
- | {{ c.id }} |
- {{ c.client_code }} |
- {{ c.company_name }} |
- {{ c.contact_name }} |
- {{ c.email }} |
- {{ c.phone }} |
- {{ c.status }} |
-
- Edit |
- Ledger
- |
-
-{% endfor %}
-
-
+ {% for c in clients %}
+
+ | {{ c.id }} |
+ {{ c.client_code }} |
+ {{ c.company_name }} |
+ {{ c.contact_name }} |
+ {{ c.email }} |
+ {{ c.phone }} |
+ {{ c.status }} |
+
+ {% if c.outstanding_balance and c.outstanding_balance > 0 %}
+ {{ c.outstanding_balance|money('CAD') }}
+ {% else %}
+ 0.00
+ {% endif %}
+ |
+
+ Edit |
+ Ledger
+ |
+
+ {% endfor %}
+
+
{% include "footer.html" %}
diff --git a/templates/dashboard.html b/templates/dashboard.html
index 6e6a662..2a1e5c0 100644
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -2,58 +2,67 @@
OTB Billing Dashboard
+
+
+ {% if app_settings.business_logo_url %}
+
+

+
+ {% endif %}
-{% if app_settings.business_logo_url %}
-
-

-
-{% endif %}
-
{{ app_settings.business_name or 'OTB Billing' }} Dashboard
-{% if request.args.get('pkg_email') == '1' %}
-
- Accounting package emailed successfully.
-
-{% endif %}
-{% if request.args.get('pkg_email_failed') == '1' %}
-
- Accounting package email failed. Check SMTP settings or server log.
-
-{% endif %}
-{% if request.args.get('pkg_email_failed') == '1' %}
-
- Accounting package email failed. Check SMTP settings or server log.
+
{{ app_settings.business_name or 'OTB Billing' }} Dashboard
+
+ {% if request.args.get('pkg_email') == '1' %}
+
+ Accounting package emailed successfully.
+
+ {% endif %}
+
+ {% if request.args.get('pkg_email_failed') == '1' %}
+
+ Accounting package email failed. Check SMTP settings or server log.
+
+ {% endif %}
+
+
+
+
+
+
+
+ | Total Clients |
+ Active Services |
+ Outstanding Invoices |
+ Outstanding Balance (CAD) |
+ Revenue Received (CAD) |
+
+
+ | {{ total_clients }} |
+ {{ active_services }} |
+ {{ outstanding_invoices }} |
+ {{ outstanding_balance|money('CAD') }} |
+ {{ revenue_received|money('CAD') }} |
+
+
+
+
Displayed times are shown in Eastern Time (Toronto).
-{% endif %}
-
-
Clients
-
Services
-
Invoices
-
Payments
-
Revenue Report
-
Monthly Accounting Package
-
-
Settings / Config
-
DB Test
-
-
-
- | Total Clients |
- Active Services |
- Outstanding Invoices |
- Revenue Received (CAD) |
-
-
- | {{ total_clients }} |
- {{ active_services }} |
- {{ outstanding_invoices }} |
- {{ revenue_received|money('CAD') }} |
-
-
-
-
Displayed times are shown in Eastern Time (Toronto).
{% include "footer.html" %}
diff --git a/templates/footer.html b/templates/footer.html
index 7fa0c0d..1d67be7 100644
--- a/templates/footer.html
+++ b/templates/footer.html
@@ -1,4 +1,376 @@
+
+
+
+ Theme
+
+
+
-
+
+
+
diff --git a/templates/reports/aging.html b/templates/reports/aging.html
new file mode 100644
index 0000000..eccdbef
--- /dev/null
+++ b/templates/reports/aging.html
@@ -0,0 +1,71 @@
+
+
+
+
Accounts Receivable Aging
+
+
+
+
+
Accounts Receivable Aging
+
+
Home
+
+
+
+ | Client |
+ Current |
+ 1–30 |
+ 31–60 |
+ 61–90 |
+ 90+ |
+ Total |
+
+
+ {% for row in aging_rows %}
+
+ | {{ row.client }} |
+ {{ row.current|money('CAD') }} |
+ {{ row.d30|money('CAD') }} |
+ {{ row.d60|money('CAD') }} |
+ {{ row.d90|money('CAD') }} |
+ {{ row.d90p|money('CAD') }} |
+ {{ row.total|money('CAD') }} |
+
+ {% endfor %}
+
+
+ | TOTAL |
+ {{ totals.current|money('CAD') }} |
+ {{ totals.d30|money('CAD') }} |
+ {{ totals.d60|money('CAD') }} |
+ {{ totals.d90|money('CAD') }} |
+ {{ totals.d90p|money('CAD') }} |
+ {{ totals.total|money('CAD') }} |
+
+
+
+{% include "footer.html" %}
+
+
diff --git a/templates/subscriptions/list.html b/templates/subscriptions/list.html
new file mode 100644
index 0000000..757208a
--- /dev/null
+++ b/templates/subscriptions/list.html
@@ -0,0 +1,62 @@
+
+
+
+
Subscriptions
+
+
+
+
+
Subscriptions
+
+
Home
+
Add Subscription
+
+
+
+{% if request.args.get('run_count') is not none %}
+
+ Recurring billing run completed. Invoices created: {{ request.args.get('run_count') }}
+
+{% endif %}
+
+
+
+ | ID |
+ Client |
+ Subscription |
+ Service |
+ Interval |
+ Price |
+ Next Invoice |
+ Status |
+
+
+{% for s in subscriptions %}
+
+ | {{ s.id }} |
+ {{ s.client_code }} - {{ s.company_name }} |
+ {{ s.subscription_name }} |
+
+ {% if s.service_code %}
+ {{ s.service_code }} - {{ s.service_name }}
+ {% else %}
+ -
+ {% endif %}
+ |
+ {{ s.billing_interval }} |
+ {{ s.price|money(s.currency_code) }} {{ s.currency_code }} |
+ {{ s.next_invoice_date }} |
+ {{ s.status }} |
+
+{% endfor %}
+
+
+{% include "footer.html" %}
+
+
diff --git a/templates/subscriptions/new.html b/templates/subscriptions/new.html
new file mode 100644
index 0000000..3ef4e6a
--- /dev/null
+++ b/templates/subscriptions/new.html
@@ -0,0 +1,112 @@
+
+
+
+
New Subscription
+
+
+
+
Add Subscription
+
+
Home
+
Back to Subscriptions
+
+{% if errors %}
+
+
Please fix the following:
+
+ {% for error in errors %}
+ - {{ error }}
+ {% endfor %}
+
+
+{% endif %}
+
+
+
+{% include "footer.html" %}
+
+