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

@@ -68,11 +68,17 @@ export const getSmsHealth = () => api.get('/sms/health').then(r => r.data)
export const triggerSmsQueue = () => api.post('/sms/trigger-queue').then(r => r.data)
// ── Billing ───────────────────────────────────────────────────────────────────
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data)
export const createInvoice = (data: object) => api.post('/billing/invoices', data).then(r => r.data)
export const updateInvoice = (id: string, data: object) => api.put(`/billing/invoices/${id}`, data).then(r => r.data)
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data)
export const createInvoice = (data: object) => api.post('/billing/invoices', data).then(r => r.data)
export const updateInvoice = (id: string, data: object) => api.put(`/billing/invoices/${id}`, data).then(r => r.data)
export const markInvoicePaid = (id: string, data: { payment_method: string; payment_reference?: string }) =>
api.post(`/billing/invoices/${id}/mark-paid`, data).then(r => r.data)
export const sendInvoiceEmail = (id: string) => api.post(`/billing/invoices/${id}/send-email`).then(r => r.data)
export const getSubscription = (schoolId: string) => api.get(`/billing/subscriptions/${schoolId}`).then(r => r.data)
export const downloadInvoicePdf = (id: string) => {
window.open(`/api/billing/invoices/${id}/pdf`, '_blank')
}
export const triggerGenerateInvoices = () => api.post('/billing/trigger-generate-invoices').then(r => r.data)
export const getSubscription = (schoolId: string) => api.get(`/billing/subscriptions/${schoolId}`).then(r => r.data)
export const upsertSubscription = (schoolId: string, data: object) =>
api.put(`/billing/subscriptions/${schoolId}`, data).then(r => r.data)

View File

@@ -1,24 +1,52 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold text-slate-900">Billing</h1>
<button @click="showCreate = true"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
<Plus :size="16" /> New Invoice
<div class="flex items-center gap-3">
<button @click="triggerInvoices" :disabled="triggering"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-slate-200 bg-white text-sm font-medium text-slate-600 hover:bg-slate-50 disabled:opacity-50 transition-colors">
<RefreshCw :size="14" :class="{ 'animate-spin': triggering }" />
Generate Invoices
</button>
<button @click="showCreate = true"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors">
<Plus :size="16" />
New Invoice
</button>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-2">
<button v-for="tab in statusTabs" :key="tab.value"
@click="statusFilter = tab.value; page = 1; fetchInvoices()"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="statusFilter === tab.value
? 'bg-blue-600 text-white'
: 'bg-white text-slate-600 hover:bg-slate-50 border border-slate-200'">
{{ tab.label }}
</button>
</div>
<div class="flex gap-3">
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
</select>
</div>
<!-- Invoice table -->
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<h2 class="text-base font-semibold text-slate-900">Invoices</h2>
<span class="text-xs text-slate-400">{{ total }} invoice{{ total !== 1 ? 's' : '' }}</span>
</div>
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading</div>
<div v-else-if="invoices.length === 0" class="p-12 text-center text-slate-400">No invoices found</div>
<div v-else-if="invoices.length === 0"
class="flex flex-col items-center justify-center py-14 text-slate-400">
<Receipt :size="36" class="mb-3 opacity-30" />
<p class="font-medium text-sm">No invoices found</p>
<p v-if="statusFilter" class="text-xs mt-1">Try a different filter</p>
</div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
@@ -32,40 +60,151 @@
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-slate-50">
<td class="px-5 py-3 font-mono text-xs font-semibold">{{ inv.invoice_number }}</td>
<td class="px-5 py-3 text-xs text-slate-600">{{ inv.school_id }}</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.billing_period_start }} {{ inv.billing_period_end }}</td>
<td class="px-5 py-3 font-semibold">PHP {{ Number(inv.total_amount).toLocaleString() }}</td>
<td class="px-5 py-3"><StatusBadge :status="inv.status" /></td>
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.due_date || '—' }}</td>
<tr v-for="inv in invoices" :key="inv.id"
class="hover:bg-slate-50 transition-colors"
:class="inv.status === 'overdue' ? 'bg-red-50/30' : ''">
<td class="px-5 py-3 font-mono text-xs font-semibold text-blue-600">{{ inv.invoice_number }}</td>
<td class="px-5 py-3">
<button @click="sendEmail(inv.id)" class="text-xs text-blue-600 hover:underline">Send Email</button>
<span class="font-medium text-slate-900 text-xs">{{ inv.school_name || inv.school_id }}</span>
</td>
<td class="px-5 py-3 text-xs text-slate-500">
{{ fmtDate(inv.billing_period_start) }} {{ fmtDate(inv.billing_period_end) }}
</td>
<td class="px-5 py-3 font-semibold text-slate-900">
PHP {{ Number(inv.total_amount).toLocaleString() }}
</td>
<td class="px-5 py-3"><StatusBadge :status="inv.status" /></td>
<td class="px-5 py-3 text-xs"
:class="isOverdue(inv) ? 'text-red-500 font-semibold' : 'text-slate-500'">
{{ inv.due_date ? fmtDate(inv.due_date) : '—' }}
</td>
<td class="px-5 py-3">
<div class="flex items-center gap-3 flex-wrap">
<!-- PDF download -->
<button @click="downloadPdf(inv.id)"
class="flex items-center gap-1 text-xs text-slate-600 hover:text-blue-600 transition-colors"
title="Download PDF">
<Download :size="13" /> PDF
</button>
<!-- Send email -->
<button @click="sendEmail(inv.id)"
class="text-xs text-slate-600 hover:text-blue-600 transition-colors"
title="Send invoice email">
<Mail :size="13" />
</button>
<!-- Mark paid -->
<button v-if="['sent','overdue','draft'].includes(inv.status)"
@click="openMarkPaid(inv)"
class="text-xs text-emerald-600 hover:text-emerald-700 font-medium transition-colors">
Mark Paid
</button>
</div>
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="total > perPage" class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
<span class="text-xs text-slate-400">
Showing {{ (page - 1) * perPage + 1 }}{{ Math.min(page * perPage, total) }} of {{ total }}
</span>
<div class="flex gap-2">
<button :disabled="page <= 1" @click="page--; fetchInvoices()"
class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Prev</button>
<button :disabled="page * perPage >= total" @click="page++; fetchInvoices()"
class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Next</button>
</div>
</div>
</div>
<!-- Mark Paid modal -->
<div v-if="markPaidInvoice"
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
@click.self="markPaidInvoice = null">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6">
<h2 class="text-lg font-bold text-slate-900 mb-1">Mark Invoice Paid</h2>
<p class="text-sm text-slate-500 mb-5">
{{ markPaidInvoice.invoice_number }} · PHP {{ Number(markPaidInvoice.total_amount).toLocaleString() }}
</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Payment Method</label>
<select v-model="paidForm.payment_method"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="bank_transfer">Bank Transfer</option>
<option value="gcash">GCash</option>
<option value="cash">Cash</option>
<option value="check">Check</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Reference / Transaction ID</label>
<input v-model="paidForm.payment_reference" type="text"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Optional" />
</div>
</div>
<div class="flex gap-3 mt-6">
<button @click="markPaidInvoice = null"
class="flex-1 px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-50">
Cancel
</button>
<button @click="confirmMarkPaid" :disabled="markingPaid"
class="flex-1 px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 disabled:opacity-50">
{{ markingPaid ? 'Saving…' : 'Confirm Paid' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next'
import { getInvoices, sendInvoiceEmail } from '@/lib/api'
import { Plus, RefreshCw, Download, Mail, Receipt } from 'lucide-vue-next'
import {
getInvoices, sendInvoiceEmail, markInvoicePaid,
downloadInvoicePdf, triggerGenerateInvoices,
} from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const toast = useToast()
const invoices = ref<any[]>([])
const loading = ref(false)
const statusFilter = ref('')
const showCreate = ref(false)
const invoices = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const perPage = 25
const loading = ref(false)
const triggering = ref(false)
const statusFilter = ref('')
const showCreate = ref(false)
const markPaidInvoice = ref<any>(null)
const markingPaid = ref(false)
const paidForm = ref({ payment_method: 'bank_transfer', payment_reference: '' })
const statusTabs = [
{ label: 'All', value: '' },
{ label: 'Draft', value: 'draft' },
{ label: 'Sent', value: 'sent' },
{ label: 'Overdue', value: 'overdue' },
{ label: 'Paid', value: 'paid' },
]
async function fetchInvoices() {
loading.value = true
try { const r = await getInvoices({ status: statusFilter.value || undefined }); invoices.value = r.items }
finally { loading.value = false }
try {
const r = await getInvoices({
status: statusFilter.value || undefined,
page: page.value,
per_page: perPage,
})
invoices.value = r.items
total.value = r.total
} finally { loading.value = false }
}
async function sendEmail(id: string) {
@@ -73,6 +212,48 @@ async function sendEmail(id: string) {
catch { toast.error('Failed to send email') }
}
watch(statusFilter, fetchInvoices)
function downloadPdf(id: string) {
downloadInvoicePdf(id)
}
async function triggerInvoices() {
triggering.value = true
try {
await triggerGenerateInvoices()
toast.success('Invoice generation task queued')
setTimeout(fetchInvoices, 3000)
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to trigger')
} finally { triggering.value = false }
}
function openMarkPaid(inv: any) {
markPaidInvoice.value = inv
paidForm.value = { payment_method: 'bank_transfer', payment_reference: '' }
}
async function confirmMarkPaid() {
if (!markPaidInvoice.value) return
markingPaid.value = true
try {
await markInvoicePaid(markPaidInvoice.value.id, paidForm.value)
toast.success(`Invoice ${markPaidInvoice.value.invoice_number} marked as paid`)
markPaidInvoice.value = null
fetchInvoices()
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to mark as paid')
} finally { markingPaid.value = false }
}
function fmtDate(iso: string | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' })
}
function isOverdue(inv: any): boolean {
return inv.status === 'overdue' ||
(inv.status === 'sent' && inv.due_date && new Date(inv.due_date) < new Date())
}
onMounted(fetchInvoices)
</script>