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