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:
kevin-asprec
2026-03-16 13:46:33 +08:00
parent e45e63903d
commit 0e0803e417
9 changed files with 920 additions and 85 deletions

View File

@@ -19,8 +19,8 @@ SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub s
## Current Milestone ## Current Milestone
**v1.0 — Foundation & Core Services** **v1.0 — Foundation & Core Services**
Status: Phase 7 complete — Phase 8 next Status: Phase 8 complete — Phase 9 next
Phases: 7 of 15 complete 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 | | 5 | On-Prem SMS Polling Agent | 1 | ✅ Complete | 2026-03-16 |
| 6 | Super Admin Dashboard UI | 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 | | 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 | — | | 9 | Email Dispatcher | TBD | Not started | — |
| 10 | Support Ticket System | TBD | Not started | — | | 10 | Support Ticket System | TBD | Not started | — |
| 11 | Monthly Report Generation | TBD | Not started | — | | 11 | Monthly Report Generation | TBD | Not started | — |

View File

@@ -3,16 +3,16 @@
## Current Position ## Current Position
Milestone: v1.0 — Foundation & Core Services Milestone: v1.0 — Foundation & Core Services
Phase: 7 of 15 (School Admin Portal UI — complete) Phase: 8 of 15 (Billing Engine + Invoice PDF — complete)
Plan: Phase 7 complete — Phase 8 next Plan: Phase 8 complete — Phase 9 next
Status: **Phase 7 applied — ready to begin Phase 8** Status: **Phase 8 applied — ready to begin Phase 9**
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) 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 ## Loop Position
``` ```
PLAN ──▶ APPLY ──▶ UNIFY PLAN ──▶ APPLY ──▶ UNIFY
· · · [No active plan — Phase 8 planning next] · · · [No active plan — Phase 9 planning next]
``` ```
## Progress ## Progress
@@ -26,7 +26,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
- Phase 5 (On-Prem SMS Polling Agent): [██████████] 100% ✓ - Phase 5 (On-Prem SMS Polling Agent): [██████████] 100% ✓
- Phase 6 (Super Admin Dashboard UI): [██████████] 100% ✓ - Phase 6 (Super Admin Dashboard UI): [██████████] 100% ✓
- Phase 7 (School Admin Portal 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 9 (Email Dispatcher): [░░░░░░░░░░] 0%
- Phase 10 (Support Ticket System): [░░░░░░░░░░] 0% - Phase 10 (Support Ticket System): [░░░░░░░░░░] 0%
- Phase 11 (Monthly Report Generation): [░░░░░░░░░░] 0% - Phase 11 (Monthly Report Generation): [░░░░░░░░░░] 0%
@@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY
## Next Action ## Next Action
Run: `/paul:plan` for Phase 8Billing Engine + Invoice PDF Run: `/paul:plan` for Phase 9Email Dispatcher
Resume file: .paul/ROADMAP.md → Phase 8 Resume file: .paul/ROADMAP.md → Phase 9
## Repo ## Repo

View File

@@ -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

View File

@@ -1,7 +1,10 @@
"""Billing and invoice endpoints.""" """Billing and invoice endpoints."""
import os
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc 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.database import get_db
from app.models.user import HubUser, UserRole from app.models.user import HubUser, UserRole
from app.models.billing import Invoice, InvoiceStatus, InvoiceLineItem, SchoolSubscription, BillingCycle from app.models.billing import Invoice, InvoiceStatus, InvoiceLineItem, SchoolSubscription, BillingCycle
from app.models.school import School
router = APIRouter(prefix="/api/billing", tags=["billing"]) router = APIRouter(prefix="/api/billing", tags=["billing"])
PDF_DIR = Path(os.getenv("PDF_DIR", "/app/data/invoices"))
# ── Pydantic schemas ──────────────────────────────────────────────────────────
class InvoiceCreate(BaseModel): class InvoiceCreate(BaseModel):
school_id: str school_id: str
billing_period_start: date billing_period_start: date
@@ -31,15 +40,26 @@ class InvoiceUpdate(BaseModel):
payment_reference: Optional[str] = None payment_reference: Optional[str] = None
notes: 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): class SubscriptionUpsert(BaseModel):
monthly_fee: float monthly_fee: float
sms_cost_per_message: float = 1.0 sms_cost_per_message: float = 1.0
cycle: BillingCycle = BillingCycle.monthly cycle: BillingCycle = BillingCycle.monthly
next_billing_date: Optional[date] = None 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 { 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, "status": inv.status.value,
"billing_period_start": inv.billing_period_start.isoformat(), "billing_period_start": inv.billing_period_start.isoformat(),
"billing_period_end": inv.billing_period_end.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_method": inv.payment_method,
"payment_reference": inv.payment_reference, "payment_reference": inv.payment_reference,
"email_sent_at": inv.email_sent_at.isoformat() if inv.email_sent_at else None, "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(), "created_at": inv.created_at.isoformat(),
"notes": inv.notes, "notes": inv.notes,
} }
def _next_invoice_number(existing_count: int) -> str: def _next_invoice_number(existing_count: int) -> str:
from datetime import date
return f"INV-{date.today().strftime('%Y%m')}-{existing_count + 1:04d}" return f"INV-{date.today().strftime('%Y%m')}-{existing_count + 1:04d}"
# ── Invoice list / create / update ────────────────────────────────────────────
@router.get("/invoices") @router.get("/invoices")
async def list_invoices( async def list_invoices(
school_id: Optional[str] = Query(None), school_id: Optional[str] = Query(None),
@@ -77,9 +100,23 @@ async def list_invoices(
stmt = stmt.where(Invoice.school_id == school_id) stmt = stmt.where(Invoice.school_id == school_id)
if status: if status:
stmt = stmt.where(Invoice.status == status) stmt = stmt.where(Invoice.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() 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() 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) @router.post("/invoices", status_code=201)
async def create_invoice( async def create_invoice(
@@ -129,6 +166,111 @@ async def update_invoice(
await db.commit() await db.commit()
return _inv_out(inv) 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") @router.post("/invoices/{invoice_id}/send-email")
async def send_invoice_email( async def send_invoice_email(
invoice_id: str, invoice_id: str,
@@ -142,6 +284,21 @@ async def send_invoice_email(
send_invoice_email_task.delay(invoice_id) send_invoice_email_task.delay(invoice_id)
return {"message": "Email queued"} 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}") @router.get("/subscriptions/{school_id}")
async def get_subscription( async def get_subscription(
school_id: str, 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: if current_user.role != UserRole.super_admin and current_user.school_id != school_id:
raise HTTPException(403) 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: if not sub:
raise HTTPException(404, "No subscription found") raise HTTPException(404, "No subscription found")
return { return {
"id": sub.id, "school_id": sub.school_id, "cycle": sub.cycle.value, "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, "next_billing_date": sub.next_billing_date.isoformat() if sub.next_billing_date else None,
"is_active": sub.is_active, "is_active": sub.is_active,
} }
@@ -167,7 +327,9 @@ async def upsert_subscription(
_admin: HubUser = Depends(require_super_admin), _admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db), 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: if sub:
sub.monthly_fee = body.monthly_fee sub.monthly_fee = body.monthly_fee
sub.sms_cost_per_message = body.sms_cost_per_message sub.sms_cost_per_message = body.sms_cost_per_message

View 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)

View File

@@ -1,45 +1,71 @@
"""Celery tasks: invoice generation, email, overdue checks.""" """Celery tasks: invoice generation, PDF, email, overdue escalation."""
import logging import logging
from datetime import date, timedelta from datetime import date, timedelta, datetime, timezone
from app.worker import celery_app from app.worker import celery_app
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _make_session(): def _make_session():
import os import os
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub") 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)() 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") @celery_app.task(name="billing.generate_monthly_invoices")
def generate_monthly_invoices(): def generate_monthly_invoices():
"""On the 1st: create draft invoices for all active schools with a subscription.""" """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 app.models.school import School, SchoolStatus
from sqlalchemy import select
from datetime import date
db = _make_session() db = _make_session()
try: try:
today = date.today() today = date.today()
period_start = date(today.year, today.month, 1) 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_start = date(prev_month.year, prev_month.month, 1)
billing_end = period_start - timedelta(days=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() count = db.execute(select(func.count()).select_from(Invoice)).scalar_one()
created = 0
for sub in subs: for sub in subs:
school = db.get(School, sub.school_id) school = db.get(School, sub.school_id)
if not school or school.status != SchoolStatus.active: if not school or school.status != SchoolStatus.active:
continue 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) total = float(sub.monthly_fee)
inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}" inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}"
count += 1 count += 1
created += 1
inv = Invoice( inv = Invoice(
school_id=sub.school_id, school_id=sub.school_id,
invoice_number=inv_num, invoice_number=inv_num,
@@ -51,6 +77,7 @@ def generate_monthly_invoices():
) )
db.add(inv) db.add(inv)
db.flush() db.flush()
db.add(InvoiceLineItem( db.add(InvoiceLineItem(
invoice_id=inv.id, invoice_id=inv.id,
description=f"Monthly subscription — {school.name}", description=f"Monthly subscription — {school.name}",
@@ -58,21 +85,47 @@ def generate_monthly_invoices():
unit_price=float(sub.monthly_fee), unit_price=float(sub.monthly_fee),
amount=float(sub.monthly_fee), amount=float(sub.monthly_fee),
)) ))
# Auto-send invoice email
send_invoice_email_task.delay(inv.id)
db.commit() 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: except Exception as e:
db.rollback() db.rollback()
logger.error(f"generate_monthly_invoices error: {e}") logger.error("generate_monthly_invoices error: %s", e)
finally: finally:
db.close() 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") @celery_app.task(name="billing.send_invoice_email")
def send_invoice_email_task(invoice_id: str): 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.billing import Invoice, InvoiceStatus
from app.models.school import School from app.models.school import School
from app.services.email import send_email from app.services.email import send_email
from sqlalchemy import select
db = _make_session() db = _make_session()
try: try:
@@ -82,44 +135,135 @@ def send_invoice_email_task(invoice_id: str):
school = db.get(School, inv.school_id) school = db.get(School, inv.school_id)
if not school or not school.billing_email: if not school or not school.billing_email:
return 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}. body = (
f"Dear {school.contact_name or school.name},\n\n"
Amount Due: PHP {float(inv.total_amount):,.2f} f"Please find your invoice {inv.invoice_number} for the period "
Due Date: {inv.due_date} f"{inv.billing_period_start} to {inv.billing_period_end}.\n\n"
f"Amount Due: PHP {float(inv.total_amount):,.2f}\n"
Please log in to your TapTrack Hub portal to view and pay your invoice. 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"
Thank you, f"{_hub_url()}/portal/billing\n\n"
TapTrack Hub Team f"Thank you,\nTapTrack Hub Team"
""" )
send_email(to=school.billing_email, subject=f"Invoice {inv.invoice_number} — TapTrack Hub", body=body) send_email(
from datetime import datetime, timezone to=school.billing_email,
subject=f"Invoice {inv.invoice_number} — TapTrack Hub",
body=body,
)
inv.email_sent_at = datetime.now(timezone.utc) inv.email_sent_at = datetime.now(timezone.utc)
if inv.status.value == "draft": if inv.status == InvoiceStatus.draft:
inv.status = InvoiceStatus.sent inv.status = InvoiceStatus.sent
db.commit() db.commit()
finally: finally:
db.close() db.close()
@celery_app.task(name="billing.check_overdue") @celery_app.task(name="billing.check_overdue")
def 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 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() db = _make_session()
try: try:
today = date.today() today = date.today()
overdue = db.execute(
# 1. Mark newly overdue
newly_overdue = db.execute(
select(Invoice).where( 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() ).scalars().all()
for inv in overdue: for inv in newly_overdue:
inv.status = InvoiceStatus.overdue inv.status = InvoiceStatus.overdue
db.commit() 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: finally:
db.close() db.close()

View 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>

View File

@@ -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) export const triggerSmsQueue = () => api.post('/sms/trigger-queue').then(r => r.data)
// ── Billing ─────────────────────────────────────────────────────────────────── // ── Billing ───────────────────────────────────────────────────────────────────
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).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 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 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 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) => export const upsertSubscription = (schoolId: string, data: object) =>
api.put(`/billing/subscriptions/${schoolId}`, data).then(r => r.data) api.put(`/billing/subscriptions/${schoolId}`, data).then(r => r.data)

View File

@@ -1,24 +1,52 @@
<template> <template>
<div class="space-y-6"> <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> <h1 class="text-2xl font-bold text-slate-900">Billing</h1>
<button @click="showCreate = true" <div class="flex items-center gap-3">
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"> <button @click="triggerInvoices" :disabled="triggering"
<Plus :size="16" /> New Invoice 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> </button>
</div> </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"> <!-- Invoice table -->
<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>
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A"> <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-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"> <table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100"> <thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide"> <tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
@@ -32,40 +60,151 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-50"> <tbody class="divide-y divide-slate-50">
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-slate-50"> <tr v-for="inv in invoices" :key="inv.id"
<td class="px-5 py-3 font-mono text-xs font-semibold">{{ inv.invoice_number }}</td> class="hover:bg-slate-50 transition-colors"
<td class="px-5 py-3 text-xs text-slate-600">{{ inv.school_id }}</td> :class="inv.status === 'overdue' ? 'bg-red-50/30' : ''">
<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-mono text-xs font-semibold text-blue-600">{{ inv.invoice_number }}</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>
<td class="px-5 py-3"> <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> </td>
</tr> </tr>
</tbody> </tbody>
</table> </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> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next' import { Plus, RefreshCw, Download, Mail, Receipt } from 'lucide-vue-next'
import { getInvoices, sendInvoiceEmail } from '@/lib/api' import {
getInvoices, sendInvoiceEmail, markInvoicePaid,
downloadInvoicePdf, triggerGenerateInvoices,
} from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue' import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
const toast = useToast() const toast = useToast()
const invoices = ref<any[]>([])
const loading = ref(false) const invoices = ref<any[]>([])
const statusFilter = ref('') const total = ref(0)
const showCreate = ref(false) 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() { async function fetchInvoices() {
loading.value = true loading.value = true
try { const r = await getInvoices({ status: statusFilter.value || undefined }); invoices.value = r.items } try {
finally { loading.value = false } 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) { async function sendEmail(id: string) {
@@ -73,6 +212,48 @@ async function sendEmail(id: string) {
catch { toast.error('Failed to send email') } 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) onMounted(fetchInvoices)
</script> </script>