From 0e0803e417b002e7b30107ef427ff5e953a62a67 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Mon, 16 Mar 2026 13:46:33 +0800 Subject: [PATCH] feat(phase-8): billing engine + invoice PDF + mark-paid + overdue escalation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - app/services/invoice_pdf.py: Jinja2+WeasyPrint PDF generation, saves to /app/data/invoices/{id}.pdf, updates Invoice.pdf_path - app/templates/invoice.html: professional branded A4 invoice template with school details, line items table, totals, payment instructions, paid receipt - routers/billing.py: GET /invoices/{id}/pdf (auto-generate on demand, FileResponse), POST /invoices/{id}/mark-paid (payment_method + reference → status=paid), POST /trigger-generate-invoices (manual trigger), school_name in invoice list - tasks/billing.py: fix missing func import in generate_monthly_invoices, new billing.generate_invoice_pdf Celery task, auto-send email after invoice creation, check_overdue upgraded with 7-day warning emails and 30-day school suspension + suspension email Frontend: - BillingPage.vue: full rewrite — status filter tabs, school names (not UUIDs), PDF download button, mail icon, Mark Paid modal with method/reference fields, overdue rows highlighted, pagination, Generate Invoices trigger button - api.ts: markInvoicePaid, downloadInvoicePdf, triggerGenerateInvoices --- .paul/ROADMAP.md | 6 +- .paul/STATE.md | 16 +- .paul/phases/08-billing-engine/README.md | 46 +++++ backend/app/routers/billing.py | 176 +++++++++++++++- backend/app/services/invoice_pdf.py | 70 +++++++ backend/app/tasks/billing.py | 208 ++++++++++++++++--- backend/app/templates/invoice.html | 226 +++++++++++++++++++++ frontend/src/lib/api.ts | 14 +- frontend/src/pages/BillingPage.vue | 243 ++++++++++++++++++++--- 9 files changed, 920 insertions(+), 85 deletions(-) create mode 100644 backend/app/services/invoice_pdf.py create mode 100644 backend/app/templates/invoice.html diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index 2eebb0e..7319bda 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -19,8 +19,8 @@ SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub s ## Current Milestone **v1.0 — Foundation & Core Services** -Status: Phase 7 complete — Phase 8 next -Phases: 7 of 15 complete +Status: Phase 8 complete — Phase 9 next +Phases: 8 of 15 complete --- @@ -35,7 +35,7 @@ Phases: 7 of 15 complete | 5 | On-Prem SMS Polling Agent | 1 | ✅ Complete | 2026-03-16 | | 6 | Super Admin Dashboard UI | 1 | ✅ Complete | 2026-03-16 | | 7 | School Admin Portal UI | 1 | ✅ Complete | 2026-03-16 | -| 8 | Billing Engine + Invoice PDF | TBD | Not started | — | +| 8 | Billing Engine + Invoice PDF | 1 | ✅ Complete | 2026-03-16 | | 9 | Email Dispatcher | TBD | Not started | — | | 10 | Support Ticket System | TBD | Not started | — | | 11 | Monthly Report Generation | TBD | Not started | — | diff --git a/.paul/STATE.md b/.paul/STATE.md index d4dd86d..66ef561 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -3,16 +3,16 @@ ## Current Position Milestone: v1.0 — Foundation & Core Services -Phase: 7 of 15 (School Admin Portal UI — complete) -Plan: Phase 7 complete — Phase 8 next -Status: **Phase 7 applied — ready to begin Phase 8** -Last activity: 2026-03-16 — Phase 7 complete (portal pages: suspension banner, credit meter, license countdown, SMS chart, delivery stats, top-up request form, ticket filter/pagination, school details) +Phase: 8 of 15 (Billing Engine + Invoice PDF — complete) +Plan: Phase 8 complete — Phase 9 next +Status: **Phase 8 applied — ready to begin Phase 9** +Last activity: 2026-03-16 — Phase 8 complete (invoice PDF via Jinja2+WeasyPrint, mark-paid modal, overdue escalation with 7d warning + 30d suspension, bug fix in billing task) ## Loop Position ``` PLAN ──▶ APPLY ──▶ UNIFY - · · · [No active plan — Phase 8 planning next] + · · · [No active plan — Phase 9 planning next] ``` ## Progress @@ -26,7 +26,7 @@ PLAN ──▶ APPLY ──▶ UNIFY - Phase 5 (On-Prem SMS Polling Agent): [██████████] 100% ✓ - Phase 6 (Super Admin Dashboard UI): [██████████] 100% ✓ - Phase 7 (School Admin Portal UI): [██████████] 100% ✓ -- Phase 8 (Billing Engine + Invoice PDF): [░░░░░░░░░░] 0% +- Phase 8 (Billing Engine + Invoice PDF): [██████████] 100% ✓ - Phase 9 (Email Dispatcher): [░░░░░░░░░░] 0% - Phase 10 (Support Ticket System): [░░░░░░░░░░] 0% - Phase 11 (Monthly Report Generation): [░░░░░░░░░░] 0% @@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY ## Next Action -Run: `/paul:plan` for Phase 8 — Billing Engine + Invoice PDF -Resume file: .paul/ROADMAP.md → Phase 8 +Run: `/paul:plan` for Phase 9 — Email Dispatcher +Resume file: .paul/ROADMAP.md → Phase 9 ## Repo diff --git a/.paul/phases/08-billing-engine/README.md b/.paul/phases/08-billing-engine/README.md index e69de29..1a09ce4 100644 --- a/.paul/phases/08-billing-engine/README.md +++ b/.paul/phases/08-billing-engine/README.md @@ -0,0 +1,46 @@ +# Phase 08: Billing Engine + Invoice PDF + +**Status:** Complete +**Completed:** 2026-03-16 + +## Goal + +Automated monthly invoice generation, PDF export, mark-as-paid workflow, and overdue escalation. + +## What was built + +### Backend + +**`billing/invoice_pdf.py`** (new service) +- `generate_invoice_pdf(invoice_id)` — renders Jinja2 HTML template → WeasyPrint PDF +- Stores at `/app/data/invoices/{invoice_id}.pdf` +- Sets `Invoice.pdf_path` in DB + +**`templates/invoice.html`** (new Jinja2 template) +- Branded HTML invoice with school name, billing period, line items table, totals, payment instructions + +**`routers/billing.py`** additions +- `GET /billing/invoices/{id}/pdf` — serve PDF (FileResponse), auto-generate if not yet created +- `POST /billing/invoices/{id}/mark-paid` — set paid_at, payment_method, payment_reference → status=paid +- Invoice list now includes `school_name` (joined) + +**`tasks/billing.py`** fixes + additions +- Fixed missing `func` import in `generate_monthly_invoices` +- `check_overdue` enhanced: + - 7-day overdue → send warning email to school billing contact + - 30-day overdue → suspend school (set status = suspended) +- `generate_monthly_invoices` auto-queues `send_invoice_email_task` after creating each invoice + +### Frontend + +**`BillingPage.vue`** — full rewrite +- Invoice table with school name (not raw UUID), status badges, PDF download button +- Mark-as-paid modal: payment method dropdown + reference field +- Status filter + pagination +- "Generate Invoices" quick action (triggers Celery task) +- Overdue invoices highlighted in red + +**`api.ts`** additions +- `downloadInvoicePdf(id)` — opens PDF in new tab +- `markInvoicePaid(id, data)` — PATCH to mark-paid endpoint +- `triggerGenerateInvoices()` — POST to trigger monthly invoice generation diff --git a/backend/app/routers/billing.py b/backend/app/routers/billing.py index 25795af..7468285 100644 --- a/backend/app/routers/billing.py +++ b/backend/app/routers/billing.py @@ -1,7 +1,10 @@ """Billing and invoice endpoints.""" +import os from datetime import date, datetime, timezone +from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import FileResponse from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, desc @@ -10,9 +13,15 @@ from app.auth.dependencies import require_super_admin, get_current_user from app.database import get_db from app.models.user import HubUser, UserRole from app.models.billing import Invoice, InvoiceStatus, InvoiceLineItem, SchoolSubscription, BillingCycle +from app.models.school import School router = APIRouter(prefix="/api/billing", tags=["billing"]) +PDF_DIR = Path(os.getenv("PDF_DIR", "/app/data/invoices")) + + +# ── Pydantic schemas ────────────────────────────────────────────────────────── + class InvoiceCreate(BaseModel): school_id: str billing_period_start: date @@ -31,15 +40,26 @@ class InvoiceUpdate(BaseModel): payment_reference: Optional[str] = None notes: Optional[str] = None +class MarkPaidBody(BaseModel): + payment_method: str = "bank_transfer" + payment_reference: Optional[str] = None + paid_at: Optional[datetime] = None + class SubscriptionUpsert(BaseModel): monthly_fee: float sms_cost_per_message: float = 1.0 cycle: BillingCycle = BillingCycle.monthly next_billing_date: Optional[date] = None -def _inv_out(inv: Invoice) -> dict: + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _inv_out(inv: Invoice, school_name: str | None = None) -> dict: return { - "id": inv.id, "school_id": inv.school_id, "invoice_number": inv.invoice_number, + "id": inv.id, + "school_id": inv.school_id, + "school_name": school_name, + "invoice_number": inv.invoice_number, "status": inv.status.value, "billing_period_start": inv.billing_period_start.isoformat(), "billing_period_end": inv.billing_period_end.isoformat(), @@ -53,14 +73,17 @@ def _inv_out(inv: Invoice) -> dict: "payment_method": inv.payment_method, "payment_reference": inv.payment_reference, "email_sent_at": inv.email_sent_at.isoformat() if inv.email_sent_at else None, + "pdf_path": inv.pdf_path, "created_at": inv.created_at.isoformat(), "notes": inv.notes, } def _next_invoice_number(existing_count: int) -> str: - from datetime import date return f"INV-{date.today().strftime('%Y%m')}-{existing_count + 1:04d}" + +# ── Invoice list / create / update ──────────────────────────────────────────── + @router.get("/invoices") async def list_invoices( school_id: Optional[str] = Query(None), @@ -77,9 +100,23 @@ async def list_invoices( stmt = stmt.where(Invoice.school_id == school_id) if status: stmt = stmt.where(Invoice.status == status) + total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() invoices = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all() - return {"items": [_inv_out(i) for i in invoices], "total": total, "page": page, "per_page": per_page} + + # Bulk-load school names + school_ids = {i.school_id for i in invoices} + school_map: dict[str, str] = {} + if school_ids: + schools = (await db.execute(select(School).where(School.id.in_(school_ids)))).scalars().all() + school_map = {s.id: s.name for s in schools} + + return { + "items": [_inv_out(i, school_map.get(i.school_id)) for i in invoices], + "total": total, + "page": page, + "per_page": per_page, + } @router.post("/invoices", status_code=201) async def create_invoice( @@ -129,6 +166,111 @@ async def update_invoice( await db.commit() return _inv_out(inv) + +# ── Mark as paid ────────────────────────────────────────────────────────────── + +@router.post("/invoices/{invoice_id}/mark-paid") +async def mark_invoice_paid( + invoice_id: str, + body: MarkPaidBody, + _admin: HubUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_db), +): + """Mark an invoice as paid with payment details.""" + inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none() + if not inv: + raise HTTPException(404, "Invoice not found") + if inv.status == InvoiceStatus.paid: + raise HTTPException(400, "Invoice is already marked as paid") + if inv.status == InvoiceStatus.cancelled: + raise HTTPException(400, "Cannot mark a cancelled invoice as paid") + + inv.status = InvoiceStatus.paid + inv.paid_at = body.paid_at or datetime.now(timezone.utc) + inv.payment_method = body.payment_method + inv.payment_reference = body.payment_reference + await db.commit() + return _inv_out(inv) + + +# ── Invoice PDF ─────────────────────────────────────────────────────────────── + +@router.get("/invoices/{invoice_id}/pdf") +async def get_invoice_pdf( + invoice_id: str, + current_user: HubUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Serve the invoice PDF. Generates it on demand if not yet created. + Super admins can access any invoice; school admins only their own. + """ + inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none() + if not inv: + raise HTTPException(404, "Invoice not found") + + # Authorization + if current_user.role != UserRole.super_admin and inv.school_id != current_user.school_id: + raise HTTPException(403, "Access denied") + + pdf_path = PDF_DIR / f"{invoice_id}.pdf" + + # Generate if not yet on disk + if not pdf_path.exists(): + try: + from app.tasks.billing import generate_invoice_pdf_task + # Run synchronously for the HTTP request (small invoice = fast) + # The task is also available as an async Celery task for batch use + _generate_pdf_sync(invoice_id) + # Update db record + inv_fresh = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none() + if inv_fresh: + inv_fresh.pdf_path = str(pdf_path) + await db.commit() + except Exception as e: + raise HTTPException(500, f"PDF generation failed: {e}") + + if not pdf_path.exists(): + raise HTTPException(500, "PDF generation failed — file not found after generation") + + school = (await db.execute(select(School).where(School.id == inv.school_id))).scalar_one_or_none() + school_slug = school.slug if school else invoice_id[:8] + filename = f"invoice-{inv.invoice_number}-{school_slug}.pdf" + + return FileResponse( + path=str(pdf_path), + media_type="application/pdf", + filename=filename, + ) + + +def _generate_pdf_sync(invoice_id: str) -> str: + """Synchronous PDF generation (for use from the HTTP request handler).""" + import os + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from app.services.invoice_pdf import generate_invoice_pdf + + db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub") + engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True) + Session = sessionmaker(bind=engine) + db = Session() + try: + path = generate_invoice_pdf(invoice_id, db) + # Update pdf_path in the record + from app.models.billing import Invoice + inv = db.get(Invoice, invoice_id) + if inv: + inv.pdf_path = path + db.commit() + return path + finally: + db.close() + engine.dispose() + + +# ── Email dispatch ──────────────────────────────────────────────────────────── + @router.post("/invoices/{invoice_id}/send-email") async def send_invoice_email( invoice_id: str, @@ -142,6 +284,21 @@ async def send_invoice_email( send_invoice_email_task.delay(invoice_id) return {"message": "Email queued"} + +# ── Trigger monthly invoice generation ─────────────────────────────────────── + +@router.post("/trigger-generate-invoices") +async def trigger_generate_invoices( + _admin: HubUser = Depends(require_super_admin), +): + """Manually trigger the monthly invoice generation Celery task.""" + from app.tasks.billing import generate_monthly_invoices + generate_monthly_invoices.delay() + return {"message": "Invoice generation task queued"} + + +# ── Subscriptions ───────────────────────────────────────────────────────────── + @router.get("/subscriptions/{school_id}") async def get_subscription( school_id: str, @@ -150,12 +307,15 @@ async def get_subscription( ): if current_user.role != UserRole.super_admin and current_user.school_id != school_id: raise HTTPException(403) - sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none() + sub = (await db.execute( + select(SchoolSubscription).where(SchoolSubscription.school_id == school_id) + )).scalar_one_or_none() if not sub: raise HTTPException(404, "No subscription found") return { "id": sub.id, "school_id": sub.school_id, "cycle": sub.cycle.value, - "monthly_fee": float(sub.monthly_fee), "sms_cost_per_message": float(sub.sms_cost_per_message), + "monthly_fee": float(sub.monthly_fee), + "sms_cost_per_message": float(sub.sms_cost_per_message), "next_billing_date": sub.next_billing_date.isoformat() if sub.next_billing_date else None, "is_active": sub.is_active, } @@ -167,7 +327,9 @@ async def upsert_subscription( _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): - sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none() + sub = (await db.execute( + select(SchoolSubscription).where(SchoolSubscription.school_id == school_id) + )).scalar_one_or_none() if sub: sub.monthly_fee = body.monthly_fee sub.sms_cost_per_message = body.sms_cost_per_message diff --git a/backend/app/services/invoice_pdf.py b/backend/app/services/invoice_pdf.py new file mode 100644 index 0000000..3d8523f --- /dev/null +++ b/backend/app/services/invoice_pdf.py @@ -0,0 +1,70 @@ +"""Invoice PDF generation using Jinja2 + WeasyPrint.""" +import logging +import os +from datetime import datetime, timezone +from pathlib import Path + +logger = logging.getLogger(__name__) + +# PDF output directory (mounted as a Docker volume in production) +PDF_DIR = Path(os.getenv("PDF_DIR", "/app/data/invoices")) + + +def generate_invoice_pdf(invoice_id: str, db_session) -> str: + """ + Render the invoice HTML template and convert to PDF via WeasyPrint. + + Args: + invoice_id: UUID of the Invoice to generate + db_session: synchronous SQLAlchemy session (used from Celery tasks) + + Returns: + Absolute path to the generated PDF file. + """ + from app.models.billing import Invoice, InvoiceLineItem + from app.models.school import School + from jinja2 import Environment, FileSystemLoader + from weasyprint import HTML + + # Load invoice + line items + school + inv = db_session.get(Invoice, invoice_id) + if not inv: + raise ValueError(f"Invoice {invoice_id} not found") + + school = db_session.get(School, inv.school_id) + line_items = db_session.query(InvoiceLineItem).filter_by(invoice_id=invoice_id).all() + + # Format dates + def _fmt(d) -> str: + if not d: + return "" + if hasattr(d, "strftime"): + return d.strftime("%B %d, %Y") + return str(d) + + context = { + "invoice": inv, + "school": school, + "line_items": line_items, + "issued_date": _fmt(inv.created_at), + "period_start": _fmt(inv.billing_period_start), + "period_end": _fmt(inv.billing_period_end), + "due_date": _fmt(inv.due_date), + "paid_date": _fmt(inv.paid_at), + "generated_date": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), + } + + # Render Jinja2 template + template_dir = Path(__file__).parent.parent / "templates" + env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=True) + template = env.get_template("invoice.html") + html_content = template.render(**context) + + # Generate PDF + PDF_DIR.mkdir(parents=True, exist_ok=True) + output_path = PDF_DIR / f"{invoice_id}.pdf" + + HTML(string=html_content).write_pdf(str(output_path)) + logger.info("Generated PDF: %s", output_path) + + return str(output_path) diff --git a/backend/app/tasks/billing.py b/backend/app/tasks/billing.py index 432817b..b870dbb 100644 --- a/backend/app/tasks/billing.py +++ b/backend/app/tasks/billing.py @@ -1,45 +1,71 @@ -"""Celery tasks: invoice generation, email, overdue checks.""" +"""Celery tasks: invoice generation, PDF, email, overdue escalation.""" import logging -from datetime import date, timedelta +from datetime import date, timedelta, datetime, timezone from app.worker import celery_app logger = logging.getLogger(__name__) + def _make_session(): import os from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub") - engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True) + engine = create_engine( + db_url.replace("postgresql+asyncpg://", "postgresql://"), + pool_pre_ping=True, + ) return sessionmaker(bind=engine)() + +def _hub_url() -> str: + import os + return os.getenv("HUB_BASE_URL", "http://localhost:8090") + + @celery_app.task(name="billing.generate_monthly_invoices") def generate_monthly_invoices(): """On the 1st: create draft invoices for all active schools with a subscription.""" - from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem, BillingCycle + from sqlalchemy import select, func + from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem from app.models.school import School, SchoolStatus - from sqlalchemy import select - from datetime import date db = _make_session() try: today = date.today() period_start = date(today.year, today.month, 1) - prev_month = (period_start - timedelta(days=1)) + prev_month = period_start - timedelta(days=1) billing_start = date(prev_month.year, prev_month.month, 1) billing_end = period_start - timedelta(days=1) - subs = db.execute(select(SchoolSubscription).where(SchoolSubscription.is_active == True)).scalars().all() + subs = db.execute( + select(SchoolSubscription).where(SchoolSubscription.is_active == True) + ).scalars().all() + count = db.execute(select(func.count()).select_from(Invoice)).scalar_one() + created = 0 for sub in subs: school = db.get(School, sub.school_id) if not school or school.status != SchoolStatus.active: continue + + # Avoid duplicate invoices for the same period + existing = db.execute( + select(Invoice).where( + Invoice.school_id == sub.school_id, + Invoice.billing_period_start == billing_start, + ) + ).scalar_one_or_none() + if existing: + continue + total = float(sub.monthly_fee) inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}" count += 1 + created += 1 + inv = Invoice( school_id=sub.school_id, invoice_number=inv_num, @@ -51,6 +77,7 @@ def generate_monthly_invoices(): ) db.add(inv) db.flush() + db.add(InvoiceLineItem( invoice_id=inv.id, description=f"Monthly subscription — {school.name}", @@ -58,21 +85,47 @@ def generate_monthly_invoices(): unit_price=float(sub.monthly_fee), amount=float(sub.monthly_fee), )) + + # Auto-send invoice email + send_invoice_email_task.delay(inv.id) + db.commit() - logger.info(f"Generated {len(subs)} invoices for {billing_start}") + logger.info("Generated %d invoices for %s", created, billing_start) except Exception as e: db.rollback() - logger.error(f"generate_monthly_invoices error: {e}") + logger.error("generate_monthly_invoices error: %s", e) finally: db.close() + +@celery_app.task(name="billing.generate_invoice_pdf") +def generate_invoice_pdf_task(invoice_id: str): + """Generate PDF for a single invoice and update the pdf_path field.""" + from app.services.invoice_pdf import generate_invoice_pdf + from app.models.billing import Invoice + + db = _make_session() + try: + path = generate_invoice_pdf(invoice_id, db) + inv = db.get(Invoice, invoice_id) + if inv: + inv.pdf_path = path + db.commit() + logger.info("Invoice PDF generated: %s", path) + return path + except Exception as e: + logger.error("generate_invoice_pdf_task error for %s: %s", invoice_id, e) + raise + finally: + db.close() + + @celery_app.task(name="billing.send_invoice_email") def send_invoice_email_task(invoice_id: str): - """Send invoice email to school billing contact.""" + """Send invoice email to school billing contact and set status to 'sent'.""" from app.models.billing import Invoice, InvoiceStatus from app.models.school import School from app.services.email import send_email - from sqlalchemy import select db = _make_session() try: @@ -82,44 +135,135 @@ def send_invoice_email_task(invoice_id: str): school = db.get(School, inv.school_id) if not school or not school.billing_email: return - body = f"""Dear {school.contact_name or school.name}, -Please find your invoice {inv.invoice_number} for the period {inv.billing_period_start} to {inv.billing_period_end}. - -Amount Due: PHP {float(inv.total_amount):,.2f} -Due Date: {inv.due_date} - -Please log in to your TapTrack Hub portal to view and pay your invoice. - -Thank you, -TapTrack Hub Team -""" - send_email(to=school.billing_email, subject=f"Invoice {inv.invoice_number} — TapTrack Hub", body=body) - from datetime import datetime, timezone + body = ( + f"Dear {school.contact_name or school.name},\n\n" + f"Please find your invoice {inv.invoice_number} for the period " + f"{inv.billing_period_start} to {inv.billing_period_end}.\n\n" + f"Amount Due: PHP {float(inv.total_amount):,.2f}\n" + f"Due Date: {inv.due_date or 'Upon receipt'}\n\n" + f"Please log in to your TapTrack Hub school portal to view and download your invoice:\n" + f"{_hub_url()}/portal/billing\n\n" + f"Thank you,\nTapTrack Hub Team" + ) + send_email( + to=school.billing_email, + subject=f"Invoice {inv.invoice_number} — TapTrack Hub", + body=body, + ) inv.email_sent_at = datetime.now(timezone.utc) - if inv.status.value == "draft": + if inv.status == InvoiceStatus.draft: inv.status = InvoiceStatus.sent db.commit() finally: db.close() + @celery_app.task(name="billing.check_overdue") def check_overdue(): - """Mark overdue invoices and send warning emails.""" - from app.models.billing import Invoice, InvoiceStatus + """ + Daily task: mark overdue invoices and escalate. + - sent + past due_date → overdue + - overdue 7+ days → warning email + - overdue 30+ days → suspend school + suspension email + """ from sqlalchemy import select, and_ + from app.models.billing import Invoice, InvoiceStatus + from app.models.school import School, SchoolStatus + from app.services.email import send_email db = _make_session() try: today = date.today() - overdue = db.execute( + + # 1. Mark newly overdue + newly_overdue = db.execute( select(Invoice).where( - and_(Invoice.status == InvoiceStatus.sent, Invoice.due_date < today, Invoice.due_date != None) + and_( + Invoice.status == InvoiceStatus.sent, + Invoice.due_date != None, + Invoice.due_date < today, + ) ) ).scalars().all() - for inv in overdue: + for inv in newly_overdue: inv.status = InvoiceStatus.overdue db.commit() - logger.info(f"Marked {len(overdue)} invoices as overdue") + logger.info("Marked %d invoices as overdue", len(newly_overdue)) + + # 2. Warning email: overdue 7+ days (but not yet 30) + warn_cutoff = today - timedelta(days=7) + suspend_cutoff = today - timedelta(days=30) + + warn_invoices = db.execute( + select(Invoice).where( + and_( + Invoice.status == InvoiceStatus.overdue, + Invoice.due_date != None, + Invoice.due_date <= warn_cutoff, + Invoice.due_date > suspend_cutoff, + ) + ) + ).scalars().all() + + for inv in warn_invoices: + school = db.get(School, inv.school_id) + if school and school.billing_email: + send_email( + to=school.billing_email, + subject=f"[TapTrack Hub] Overdue Invoice — {inv.invoice_number}", + body=( + f"Dear {school.contact_name or school.name},\n\n" + f"Your invoice {inv.invoice_number} for PHP {float(inv.total_amount):,.2f} " + f"was due on {inv.due_date} and is now overdue.\n\n" + f"Please settle this invoice immediately to avoid account suspension.\n\n" + f"Log in to your portal: {_hub_url()}/portal/billing\n\n" + f"TapTrack Hub Team" + ), + ) + logger.info("Sent %d overdue warning emails", len(warn_invoices)) + + # 3. Suspend: overdue 30+ days + suspend_invoices = db.execute( + select(Invoice).where( + and_( + Invoice.status == InvoiceStatus.overdue, + Invoice.due_date != None, + Invoice.due_date <= suspend_cutoff, + ) + ) + ).scalars().all() + + suspended = 0 + for inv in suspend_invoices: + school = db.get(School, inv.school_id) + if school and school.status == SchoolStatus.active: + school.status = SchoolStatus.suspended + suspended += 1 + logger.warning( + "Suspended school %s — overdue invoice %s (30+ days)", + school.name, inv.invoice_number, + ) + if school.billing_email: + send_email( + to=school.billing_email, + subject=f"[TapTrack Hub] Account Suspended — Invoice {inv.invoice_number}", + body=( + f"Dear {school.contact_name or school.name},\n\n" + f"Your TapTrack account has been suspended due to unpaid invoice " + f"{inv.invoice_number} (PHP {float(inv.total_amount):,.2f}), " + f"which was due on {inv.due_date}.\n\n" + f"SMS notifications and automated reports are now disabled.\n\n" + f"To restore service, please contact support@taptrack.io immediately.\n\n" + f"TapTrack Hub Team" + ), + ) + + db.commit() + logger.info("Suspended %d schools for non-payment", suspended) + + except Exception as e: + db.rollback() + logger.error("check_overdue error: %s", e) finally: db.close() diff --git a/backend/app/templates/invoice.html b/backend/app/templates/invoice.html new file mode 100644 index 0000000..4f1652f --- /dev/null +++ b/backend/app/templates/invoice.html @@ -0,0 +1,226 @@ + + + + + + + + + +
+
+

TapTrack Hub

+

Cloud Control Plane for TapTrack Deployments

+
+
+
Invoice
+
{{ invoice.invoice_number }}
+
Issued {{ issued_date }}
+
+ {{ invoice.status }} +
+
+
+ +
+ + +
+
+
Bill To
+
{{ school.name }}
+ {% if school.address %}

{{ school.address }}

{% endif %} + {% if school.city %}

{{ school.city }}

{% endif %} + {% if school.billing_email %}

{{ school.billing_email }}

{% endif %} + {% if school.contact_phone %}

{{ school.contact_phone }}

{% endif %} +
+
+
From
+
TapTrack Hub
+

Cloud Services

+

support@taptrack.io

+
+
+ + +
+
+
Billing Period
+
{{ period_start }} — {{ period_end }}
+
+
+
Due Date
+
{{ due_date if due_date else 'Upon receipt' }}
+
+
+
Currency
+
{{ invoice.currency }}
+
+
+ + + + + + + + + + + + + {% for item in line_items %} + + + + + + + {% endfor %} + {% if not line_items %} + {% if invoice.subscription_amount > 0 %} + + + + + + + {% endif %} + {% if invoice.sms_credit_amount > 0 %} + + + + + + + {% endif %} + {% if invoice.other_amount > 0 %} + + + + + + + {% endif %} + {% endif %} + +
DescriptionQtyUnit PriceAmount
{{ item.description }}{{ item.quantity }}{{ invoice.currency }} {{ "{:,.2f}".format(item.unit_price) }}{{ invoice.currency }} {{ "{:,.2f}".format(item.amount) }}
Monthly Subscription — {{ school.name }}1{{ invoice.currency }} {{ "{:,.2f}".format(invoice.subscription_amount) }}{{ invoice.currency }} {{ "{:,.2f}".format(invoice.subscription_amount) }}
SMS Credits1{{ invoice.currency }} {{ "{:,.2f}".format(invoice.sms_credit_amount) }}{{ invoice.currency }} {{ "{:,.2f}".format(invoice.sms_credit_amount) }}
Other Charges1{{ invoice.currency }} {{ "{:,.2f}".format(invoice.other_amount) }}{{ invoice.currency }} {{ "{:,.2f}".format(invoice.other_amount) }}
+ + +
+
+ + + + + + + + + + {% if invoice.paid_at %} + + + + + {% endif %} +
Subtotal{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}
Total Due{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}
+
+
+ +{% if invoice.paid_at %} +
+

Payment Received

+

Paid on {{ paid_date }} via {{ invoice.payment_method or 'N/A' }} + {% if invoice.payment_reference %} · Ref: {{ invoice.payment_reference }}{% endif %}

+
+{% else %} +
+

Payment Instructions

+

Please log in to your TapTrack Hub school portal and submit payment by {{ due_date if due_date else 'the due date' }}.

+

For questions, contact support@taptrack.io

+
+{% endif %} + +{% if invoice.notes %} +
+ Notes: +

{{ invoice.notes }}

+
+{% endif %} + + + + + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 772a72e..4f7f929 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -68,11 +68,17 @@ export const getSmsHealth = () => api.get('/sms/health').then(r => r.data) export const triggerSmsQueue = () => api.post('/sms/trigger-queue').then(r => r.data) // ── Billing ─────────────────────────────────────────────────────────────────── -export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data) -export const createInvoice = (data: object) => api.post('/billing/invoices', data).then(r => r.data) -export const updateInvoice = (id: string, data: object) => api.put(`/billing/invoices/${id}`, data).then(r => r.data) +export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data) +export const createInvoice = (data: object) => api.post('/billing/invoices', data).then(r => r.data) +export const updateInvoice = (id: string, data: object) => api.put(`/billing/invoices/${id}`, data).then(r => r.data) +export const markInvoicePaid = (id: string, data: { payment_method: string; payment_reference?: string }) => + api.post(`/billing/invoices/${id}/mark-paid`, data).then(r => r.data) export const sendInvoiceEmail = (id: string) => api.post(`/billing/invoices/${id}/send-email`).then(r => r.data) -export const getSubscription = (schoolId: string) => api.get(`/billing/subscriptions/${schoolId}`).then(r => r.data) +export const downloadInvoicePdf = (id: string) => { + window.open(`/api/billing/invoices/${id}/pdf`, '_blank') +} +export const triggerGenerateInvoices = () => api.post('/billing/trigger-generate-invoices').then(r => r.data) +export const getSubscription = (schoolId: string) => api.get(`/billing/subscriptions/${schoolId}`).then(r => r.data) export const upsertSubscription = (schoolId: string, data: object) => api.put(`/billing/subscriptions/${schoolId}`, data).then(r => r.data) diff --git a/frontend/src/pages/BillingPage.vue b/frontend/src/pages/BillingPage.vue index 4ed628a..fef7c09 100644 --- a/frontend/src/pages/BillingPage.vue +++ b/frontend/src/pages/BillingPage.vue @@ -1,24 +1,52 @@