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
|
## 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 | — |
|
||||||
|
|||||||
@@ -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 8 — Billing Engine + Invoice PDF
|
Run: `/paul:plan` for Phase 9 — Email Dispatcher
|
||||||
Resume file: .paul/ROADMAP.md → Phase 8
|
Resume file: .paul/ROADMAP.md → Phase 9
|
||||||
|
|
||||||
## Repo
|
## 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."""
|
"""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
|
||||||
|
|||||||
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
|
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()
|
||||||
|
|||||||
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)
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user