|
|
|
@ -1,11 +1,12 @@ |
|
|
|
from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify |
|
|
|
from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify |
|
|
|
from db import get_db_connection |
|
|
|
from db import get_db_connection |
|
|
|
from utils import generate_client_code, generate_service_code |
|
|
|
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 zoneinfo import ZoneInfo |
|
|
|
from decimal import Decimal, InvalidOperation |
|
|
|
from decimal import Decimal, InvalidOperation |
|
|
|
from pathlib import Path |
|
|
|
from pathlib import Path |
|
|
|
from email.message import EmailMessage |
|
|
|
from email.message import EmailMessage |
|
|
|
|
|
|
|
from dateutil.relativedelta import relativedelta |
|
|
|
|
|
|
|
|
|
|
|
from io import BytesIO, StringIO |
|
|
|
from io import BytesIO, StringIO |
|
|
|
import csv |
|
|
|
import csv |
|
|
|
@ -193,6 +194,136 @@ def generate_invoice_number(): |
|
|
|
return f"INV-{number + 1:04d}" |
|
|
|
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 = { |
|
|
|
APP_SETTINGS_DEFAULTS = { |
|
|
|
"business_name": "OTB Billing", |
|
|
|
"business_name": "OTB Billing", |
|
|
|
"business_tagline": "By a contractor, for contractors", |
|
|
|
"business_tagline": "By a contractor, for contractors", |
|
|
|
@ -689,6 +820,253 @@ def email_accounting_package(): |
|
|
|
except Exception: |
|
|
|
except Exception: |
|
|
|
return redirect("/?pkg_email_failed=1") |
|
|
|
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("/") |
|
|
|
@app.route("/") |
|
|
|
def index(): |
|
|
|
def index(): |
|
|
|
refresh_overdue_invoices() |
|
|
|
refresh_overdue_invoices() |
|
|
|
@ -706,6 +1084,7 @@ def index(): |
|
|
|
SELECT COUNT(*) AS outstanding_invoices |
|
|
|
SELECT COUNT(*) AS outstanding_invoices |
|
|
|
FROM invoices |
|
|
|
FROM invoices |
|
|
|
WHERE status IN ('pending', 'partial', 'overdue') |
|
|
|
WHERE status IN ('pending', 'partial', 'overdue') |
|
|
|
|
|
|
|
AND (total_amount - amount_paid) > 0 |
|
|
|
""") |
|
|
|
""") |
|
|
|
outstanding_invoices = cursor.fetchone()["outstanding_invoices"] |
|
|
|
outstanding_invoices = cursor.fetchone()["outstanding_invoices"] |
|
|
|
|
|
|
|
|
|
|
|
@ -714,102 +1093,51 @@ def index(): |
|
|
|
FROM payments |
|
|
|
FROM payments |
|
|
|
WHERE payment_status = 'confirmed' |
|
|
|
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() |
|
|
|
conn.close() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app_settings = get_app_settings() |
|
|
|
|
|
|
|
|
|
|
|
return render_template( |
|
|
|
return render_template( |
|
|
|
"dashboard.html", |
|
|
|
"dashboard.html", |
|
|
|
total_clients=total_clients, |
|
|
|
total_clients=total_clients, |
|
|
|
active_services=active_services, |
|
|
|
active_services=active_services, |
|
|
|
outstanding_invoices=outstanding_invoices, |
|
|
|
outstanding_invoices=outstanding_invoices, |
|
|
|
|
|
|
|
outstanding_balance=outstanding_balance, |
|
|
|
revenue_received=revenue_received, |
|
|
|
revenue_received=revenue_received, |
|
|
|
|
|
|
|
app_settings=app_settings, |
|
|
|
) |
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
@app.route("/dbtest") |
|
|
|
@app.route("/clients") |
|
|
|
def dbtest(): |
|
|
|
def clients(): |
|
|
|
try: |
|
|
|
|
|
|
|
conn = get_db_connection() |
|
|
|
|
|
|
|
cursor = conn.cursor() |
|
|
|
|
|
|
|
cursor.execute("SELECT NOW()") |
|
|
|
|
|
|
|
result = cursor.fetchone() |
|
|
|
|
|
|
|
conn.close() |
|
|
|
|
|
|
|
return f""" |
|
|
|
|
|
|
|
<h1>OTB Billing v{APP_VERSION}</h1> |
|
|
|
|
|
|
|
<h2>Database OK</h2> |
|
|
|
|
|
|
|
<p><a href="/">Home</a></p> |
|
|
|
|
|
|
|
<p>DB server time (UTC): {result[0]}</p> |
|
|
|
|
|
|
|
<p>Displayed local time: {fmt_local(result[0])}</p> |
|
|
|
|
|
|
|
""" |
|
|
|
|
|
|
|
except Exception as e: |
|
|
|
|
|
|
|
return f"<h1>Database FAILED</h1><pre>{e}</pre>" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/clients/export.csv") |
|
|
|
|
|
|
|
def export_clients_csv(): |
|
|
|
|
|
|
|
conn = get_db_connection() |
|
|
|
conn = get_db_connection() |
|
|
|
cursor = conn.cursor(dictionary=True) |
|
|
|
cursor = conn.cursor(dictionary=True) |
|
|
|
|
|
|
|
|
|
|
|
cursor.execute(""" |
|
|
|
cursor.execute(""" |
|
|
|
SELECT |
|
|
|
SELECT |
|
|
|
id, |
|
|
|
c.*, |
|
|
|
client_code, |
|
|
|
COALESCE(( |
|
|
|
company_name, |
|
|
|
SELECT SUM(i.total_amount - i.amount_paid) |
|
|
|
contact_name, |
|
|
|
FROM invoices i |
|
|
|
email, |
|
|
|
WHERE i.client_id = c.id |
|
|
|
phone, |
|
|
|
AND i.status IN ('pending', 'partial', 'overdue') |
|
|
|
status, |
|
|
|
AND (i.total_amount - i.amount_paid) > 0 |
|
|
|
created_at, |
|
|
|
), 0) AS outstanding_balance |
|
|
|
updated_at |
|
|
|
FROM clients c |
|
|
|
FROM clients |
|
|
|
ORDER BY c.company_name |
|
|
|
ORDER BY id ASC |
|
|
|
|
|
|
|
""") |
|
|
|
""") |
|
|
|
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() |
|
|
|
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) |
|
|
|
return render_template("clients/list.html", clients=clients) |
|
|
|
|
|
|
|
|
|
|
|
@app.route("/clients/new", methods=["GET", "POST"]) |
|
|
|
@app.route("/clients/new", methods=["GET", "POST"]) |
|
|
|
|