feat(phase-8): billing engine + invoice PDF + mark-paid + overdue escalation
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
This commit is contained in:
@@ -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 | — |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
70
backend/app/services/invoice_pdf.py
Normal file
70
backend/app/services/invoice_pdf.py
Normal file
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
226
backend/app/templates/invoice.html
Normal file
226
backend/app/templates/invoice.html
Normal file
@@ -0,0 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 18mm 20mm 18mm; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 11pt; color: #1e293b; line-height: 1.5; }
|
||||
|
||||
/* Header */
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; }
|
||||
.brand { }
|
||||
.brand h1 { font-size: 20pt; font-weight: 700; color: #1e40af; letter-spacing: -0.5px; }
|
||||
.brand p { font-size: 9pt; color: #64748b; margin-top: 2px; }
|
||||
.invoice-meta { text-align: right; }
|
||||
.invoice-meta .invoice-number { font-size: 15pt; font-weight: 700; color: #1e293b; }
|
||||
.invoice-meta .invoice-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; }
|
||||
.invoice-meta .date { font-size: 10pt; color: #475569; margin-top: 4px; }
|
||||
|
||||
/* Divider */
|
||||
.divider { border: none; border-top: 2px solid #e2e8f0; margin: 20px 0; }
|
||||
.divider-thin { border: none; border-top: 1px solid #e2e8f0; margin: 12px 0; }
|
||||
|
||||
/* Parties */
|
||||
.parties { display: flex; gap: 40px; margin-bottom: 28px; }
|
||||
.party { flex: 1; }
|
||||
.party-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; margin-bottom: 6px; }
|
||||
.party-name { font-weight: 700; font-size: 12pt; color: #0f172a; }
|
||||
.party p { font-size: 9.5pt; color: #475569; margin-top: 1px; }
|
||||
|
||||
/* Status pill */
|
||||
.status-pill { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 8.5pt; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.status-draft { background: #f1f5f9; color: #64748b; }
|
||||
.status-sent { background: #eff6ff; color: #1d4ed8; }
|
||||
.status-paid { background: #f0fdf4; color: #15803d; }
|
||||
.status-overdue { background: #fef2f2; color: #dc2626; }
|
||||
.status-cancelled { background: #f1f5f9; color: #94a3b8; }
|
||||
|
||||
/* Invoice info grid */
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 28px; background: #f8fafc; border-radius: 8px; padding: 16px; }
|
||||
.info-cell .info-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; margin-bottom: 3px; }
|
||||
.info-cell .info-value { font-size: 10.5pt; font-weight: 600; color: #0f172a; }
|
||||
|
||||
/* Line items table */
|
||||
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||
.items-table thead th { background: #1e40af; color: white; font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.8px; padding: 9px 12px; text-align: left; }
|
||||
.items-table thead th:last-child,
|
||||
.items-table thead th:nth-child(3),
|
||||
.items-table thead th:nth-child(4) { text-align: right; }
|
||||
.items-table tbody tr:nth-child(even) { background: #f8fafc; }
|
||||
.items-table tbody td { padding: 10px 12px; font-size: 10pt; color: #334155; border-bottom: 1px solid #f1f5f9; }
|
||||
.items-table tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Totals */
|
||||
.totals { float: right; width: 260px; margin-bottom: 32px; }
|
||||
.totals table { width: 100%; border-collapse: collapse; }
|
||||
.totals td { padding: 5px 0; font-size: 10pt; color: #475569; }
|
||||
.totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.totals .total-row td { font-size: 13pt; font-weight: 700; color: #0f172a; border-top: 2px solid #e2e8f0; padding-top: 10px; margin-top: 4px; }
|
||||
.totals .paid-row td { color: #15803d; font-weight: 600; }
|
||||
.clearfix::after { content: ""; display: table; clear: both; }
|
||||
|
||||
/* Payment info */
|
||||
.payment-info { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 14px 16px; margin-bottom: 24px; }
|
||||
.payment-info h3 { font-size: 9.5pt; font-weight: 700; color: #1e40af; margin-bottom: 6px; }
|
||||
.payment-info p { font-size: 9.5pt; color: #1e3a8a; }
|
||||
.payment-info .ref { font-family: monospace; background: #dbeafe; padding: 2px 6px; border-radius: 3px; font-size: 9pt; }
|
||||
|
||||
/* Footer */
|
||||
.footer { margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }
|
||||
.footer p { font-size: 8.5pt; color: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="brand">
|
||||
<h1>TapTrack Hub</h1>
|
||||
<p>Cloud Control Plane for TapTrack Deployments</p>
|
||||
</div>
|
||||
<div class="invoice-meta">
|
||||
<div class="invoice-label">Invoice</div>
|
||||
<div class="invoice-number">{{ invoice.invoice_number }}</div>
|
||||
<div class="date">Issued {{ issued_date }}</div>
|
||||
<div style="margin-top:6px">
|
||||
<span class="status-pill status-{{ invoice.status }}">{{ invoice.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Bill To / From -->
|
||||
<div class="parties">
|
||||
<div class="party">
|
||||
<div class="party-label">Bill To</div>
|
||||
<div class="party-name">{{ school.name }}</div>
|
||||
{% if school.address %}<p>{{ school.address }}</p>{% endif %}
|
||||
{% if school.city %}<p>{{ school.city }}</p>{% endif %}
|
||||
{% if school.billing_email %}<p>{{ school.billing_email }}</p>{% endif %}
|
||||
{% if school.contact_phone %}<p>{{ school.contact_phone }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="party">
|
||||
<div class="party-label">From</div>
|
||||
<div class="party-name">TapTrack Hub</div>
|
||||
<p>Cloud Services</p>
|
||||
<p>support@taptrack.io</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice info -->
|
||||
<div class="info-grid">
|
||||
<div class="info-cell">
|
||||
<div class="info-label">Billing Period</div>
|
||||
<div class="info-value">{{ period_start }} — {{ period_end }}</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<div class="info-label">Due Date</div>
|
||||
<div class="info-value">{{ due_date if due_date else 'Upon receipt' }}</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<div class="info-label">Currency</div>
|
||||
<div class="info-value">{{ invoice.currency }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line items -->
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:55%">Description</th>
|
||||
<th style="width:15%">Qty</th>
|
||||
<th style="width:15%">Unit Price</th>
|
||||
<th style="width:15%">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in line_items %}
|
||||
<tr>
|
||||
<td>{{ item.description }}</td>
|
||||
<td class="num">{{ item.quantity }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(item.unit_price) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(item.amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not line_items %}
|
||||
{% if invoice.subscription_amount > 0 %}
|
||||
<tr>
|
||||
<td>Monthly Subscription — {{ school.name }}</td>
|
||||
<td class="num">1</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.subscription_amount) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.subscription_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if invoice.sms_credit_amount > 0 %}
|
||||
<tr>
|
||||
<td>SMS Credits</td>
|
||||
<td class="num">1</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.sms_credit_amount) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.sms_credit_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if invoice.other_amount > 0 %}
|
||||
<tr>
|
||||
<td>Other Charges</td>
|
||||
<td class="num">1</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.other_amount) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.other_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Totals + Payment info -->
|
||||
<div class="clearfix">
|
||||
<div class="totals">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Subtotal</td>
|
||||
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
|
||||
</tr>
|
||||
<tr class="total-row">
|
||||
<td>Total Due</td>
|
||||
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
|
||||
</tr>
|
||||
{% if invoice.paid_at %}
|
||||
<tr class="paid-row">
|
||||
<td>Paid</td>
|
||||
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if invoice.paid_at %}
|
||||
<div class="payment-info" style="background:#f0fdf4;border-color:#bbf7d0">
|
||||
<h3 style="color:#15803d">Payment Received</h3>
|
||||
<p>Paid on {{ paid_date }} via {{ invoice.payment_method or 'N/A' }}
|
||||
{% if invoice.payment_reference %} · Ref: <span class="ref">{{ invoice.payment_reference }}</span>{% endif %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="payment-info">
|
||||
<h3>Payment Instructions</h3>
|
||||
<p>Please log in to your TapTrack Hub school portal and submit payment by <strong>{{ due_date if due_date else 'the due date' }}</strong>.</p>
|
||||
<p style="margin-top:4px">For questions, contact <strong>support@taptrack.io</strong></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if invoice.notes %}
|
||||
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:12px 14px;margin-bottom:16px">
|
||||
<strong style="font-size:9pt;color:#92400e">Notes:</strong>
|
||||
<p style="font-size:9.5pt;color:#78350f;margin-top:3px">{{ invoice.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>TapTrack Hub · support@taptrack.io</p>
|
||||
<p>Generated {{ generated_date }} · {{ invoice.invoice_number }}</p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,24 +1,52 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 class="text-2xl font-bold text-slate-900">Billing</h1>
|
||||
<button @click="showCreate = true"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
|
||||
<Plus :size="16" /> New Invoice
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="triggerInvoices" :disabled="triggering"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-slate-200 bg-white text-sm font-medium text-slate-600 hover:bg-slate-50 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw :size="14" :class="{ 'animate-spin': triggering }" />
|
||||
Generate Invoices
|
||||
</button>
|
||||
<button @click="showCreate = true"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
<Plus :size="16" />
|
||||
New Invoice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-for="tab in statusTabs" :key="tab.value"
|
||||
@click="statusFilter = tab.value; page = 1; fetchInvoices()"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="statusFilter === tab.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-slate-600 hover:bg-slate-50 border border-slate-200'">
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Invoice table -->
|
||||
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||
<h2 class="text-base font-semibold text-slate-900">Invoices</h2>
|
||||
<span class="text-xs text-slate-400">{{ total }} invoice{{ total !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||
<div v-else-if="invoices.length === 0" class="p-12 text-center text-slate-400">No invoices found</div>
|
||||
|
||||
<div v-else-if="invoices.length === 0"
|
||||
class="flex flex-col items-center justify-center py-14 text-slate-400">
|
||||
<Receipt :size="36" class="mb-3 opacity-30" />
|
||||
<p class="font-medium text-sm">No invoices found</p>
|
||||
<p v-if="statusFilter" class="text-xs mt-1">Try a different filter</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="bg-slate-50 border-b border-slate-100">
|
||||
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||
@@ -32,40 +60,151 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-slate-50">
|
||||
<td class="px-5 py-3 font-mono text-xs font-semibold">{{ inv.invoice_number }}</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-600">{{ inv.school_id }}</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.billing_period_start }} — {{ inv.billing_period_end }}</td>
|
||||
<td class="px-5 py-3 font-semibold">PHP {{ Number(inv.total_amount).toLocaleString() }}</td>
|
||||
<td class="px-5 py-3"><StatusBadge :status="inv.status" /></td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.due_date || '—' }}</td>
|
||||
<tr v-for="inv in invoices" :key="inv.id"
|
||||
class="hover:bg-slate-50 transition-colors"
|
||||
:class="inv.status === 'overdue' ? 'bg-red-50/30' : ''">
|
||||
<td class="px-5 py-3 font-mono text-xs font-semibold text-blue-600">{{ inv.invoice_number }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<button @click="sendEmail(inv.id)" class="text-xs text-blue-600 hover:underline">Send Email</button>
|
||||
<span class="font-medium text-slate-900 text-xs">{{ inv.school_name || inv.school_id }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">
|
||||
{{ fmtDate(inv.billing_period_start) }} — {{ fmtDate(inv.billing_period_end) }}
|
||||
</td>
|
||||
<td class="px-5 py-3 font-semibold text-slate-900">
|
||||
PHP {{ Number(inv.total_amount).toLocaleString() }}
|
||||
</td>
|
||||
<td class="px-5 py-3"><StatusBadge :status="inv.status" /></td>
|
||||
<td class="px-5 py-3 text-xs"
|
||||
:class="isOverdue(inv) ? 'text-red-500 font-semibold' : 'text-slate-500'">
|
||||
{{ inv.due_date ? fmtDate(inv.due_date) : '—' }}
|
||||
</td>
|
||||
<td class="px-5 py-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- PDF download -->
|
||||
<button @click="downloadPdf(inv.id)"
|
||||
class="flex items-center gap-1 text-xs text-slate-600 hover:text-blue-600 transition-colors"
|
||||
title="Download PDF">
|
||||
<Download :size="13" /> PDF
|
||||
</button>
|
||||
<!-- Send email -->
|
||||
<button @click="sendEmail(inv.id)"
|
||||
class="text-xs text-slate-600 hover:text-blue-600 transition-colors"
|
||||
title="Send invoice email">
|
||||
<Mail :size="13" />
|
||||
</button>
|
||||
<!-- Mark paid -->
|
||||
<button v-if="['sent','overdue','draft'].includes(inv.status)"
|
||||
@click="openMarkPaid(inv)"
|
||||
class="text-xs text-emerald-600 hover:text-emerald-700 font-medium transition-colors">
|
||||
Mark Paid
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > perPage" class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
|
||||
<span class="text-xs text-slate-400">
|
||||
Showing {{ (page - 1) * perPage + 1 }}–{{ Math.min(page * perPage, total) }} of {{ total }}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button :disabled="page <= 1" @click="page--; fetchInvoices()"
|
||||
class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Prev</button>
|
||||
<button :disabled="page * perPage >= total" @click="page++; fetchInvoices()"
|
||||
class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mark Paid modal -->
|
||||
<div v-if="markPaidInvoice"
|
||||
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
@click.self="markPaidInvoice = null">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6">
|
||||
<h2 class="text-lg font-bold text-slate-900 mb-1">Mark Invoice Paid</h2>
|
||||
<p class="text-sm text-slate-500 mb-5">
|
||||
{{ markPaidInvoice.invoice_number }} · PHP {{ Number(markPaidInvoice.total_amount).toLocaleString() }}
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Payment Method</label>
|
||||
<select v-model="paidForm.payment_method"
|
||||
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="bank_transfer">Bank Transfer</option>
|
||||
<option value="gcash">GCash</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="check">Check</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Reference / Transaction ID</label>
|
||||
<input v-model="paidForm.payment_reference" type="text"
|
||||
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button @click="markPaidInvoice = null"
|
||||
class="flex-1 px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="confirmMarkPaid" :disabled="markingPaid"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 disabled:opacity-50">
|
||||
{{ markingPaid ? 'Saving…' : 'Confirm Paid' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { getInvoices, sendInvoiceEmail } from '@/lib/api'
|
||||
import { Plus, RefreshCw, Download, Mail, Receipt } from 'lucide-vue-next'
|
||||
import {
|
||||
getInvoices, sendInvoiceEmail, markInvoicePaid,
|
||||
downloadInvoicePdf, triggerGenerateInvoices,
|
||||
} from '@/lib/api'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
const invoices = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const showCreate = ref(false)
|
||||
|
||||
const invoices = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = 25
|
||||
const loading = ref(false)
|
||||
const triggering = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const showCreate = ref(false)
|
||||
const markPaidInvoice = ref<any>(null)
|
||||
const markingPaid = ref(false)
|
||||
const paidForm = ref({ payment_method: 'bank_transfer', payment_reference: '' })
|
||||
|
||||
const statusTabs = [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Sent', value: 'sent' },
|
||||
{ label: 'Overdue', value: 'overdue' },
|
||||
{ label: 'Paid', value: 'paid' },
|
||||
]
|
||||
|
||||
async function fetchInvoices() {
|
||||
loading.value = true
|
||||
try { const r = await getInvoices({ status: statusFilter.value || undefined }); invoices.value = r.items }
|
||||
finally { loading.value = false }
|
||||
try {
|
||||
const r = await getInvoices({
|
||||
status: statusFilter.value || undefined,
|
||||
page: page.value,
|
||||
per_page: perPage,
|
||||
})
|
||||
invoices.value = r.items
|
||||
total.value = r.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function sendEmail(id: string) {
|
||||
@@ -73,6 +212,48 @@ async function sendEmail(id: string) {
|
||||
catch { toast.error('Failed to send email') }
|
||||
}
|
||||
|
||||
watch(statusFilter, fetchInvoices)
|
||||
function downloadPdf(id: string) {
|
||||
downloadInvoicePdf(id)
|
||||
}
|
||||
|
||||
async function triggerInvoices() {
|
||||
triggering.value = true
|
||||
try {
|
||||
await triggerGenerateInvoices()
|
||||
toast.success('Invoice generation task queued')
|
||||
setTimeout(fetchInvoices, 3000)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.detail ?? 'Failed to trigger')
|
||||
} finally { triggering.value = false }
|
||||
}
|
||||
|
||||
function openMarkPaid(inv: any) {
|
||||
markPaidInvoice.value = inv
|
||||
paidForm.value = { payment_method: 'bank_transfer', payment_reference: '' }
|
||||
}
|
||||
|
||||
async function confirmMarkPaid() {
|
||||
if (!markPaidInvoice.value) return
|
||||
markingPaid.value = true
|
||||
try {
|
||||
await markInvoicePaid(markPaidInvoice.value.id, paidForm.value)
|
||||
toast.success(`Invoice ${markPaidInvoice.value.invoice_number} marked as paid`)
|
||||
markPaidInvoice.value = null
|
||||
fetchInvoices()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.detail ?? 'Failed to mark as paid')
|
||||
} finally { markingPaid.value = false }
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | undefined): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function isOverdue(inv: any): boolean {
|
||||
return inv.status === 'overdue' ||
|
||||
(inv.status === 'sent' && inv.due_date && new Date(inv.due_date) < new Date())
|
||||
}
|
||||
|
||||
onMounted(fetchInvoices)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user