- ✓Familiarity with Next.js 14 App Router and TypeScript
- ✓Node.js 18+ installed on your development machine
- ✓A free Khalti merchant test account (sandbox keys)
- ✓Basic understanding of SQL database relations (Prisma ORM or PostgreSQL)
🚀 What You Will Learn & Build
- ▸Design a real-world multi-tenant fee structure with automated late fine formulas
- ▸Implement server-to-server Khalti payment initiation using Next.js Route Handlers
- ▸Verify transaction authenticity using Khalti lookup API before updating fee records
- ▸Generate printable NPR payment receipts with QR verification for Nepali colleges
1. Problem Statement & Architecture
2. Database Schema (Prisma / SQL)
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum InvoiceStatus {
UNPAID
PARTIALLY_PAID
PAID
OVERDUE
}
model Student {
id String @id @default(cuid())
rollNumber String @unique
fullName String
email String @unique
phone String
faculty String // e.g. "BCA", "BSc.CSIT", "BBM"
semester Int // 1 to 8
invoices FeeInvoice[]
createdAt DateTime @default(now())
}
model FeeInvoice {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
title String // e.g. "4th Semester Tuition & Lab Fee"
amount Float // Original fee in NPR
lateFee Float @default(0.0)
totalDue Float // amount + lateFee
dueDate DateTime
status InvoiceStatus @default(UNPAID)
transactions PaymentTransaction[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PaymentTransaction {
id String @id @default(cuid())
invoiceId String
invoice FeeInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
gateway String @default("KHALTI")
pidx String @unique // Khalti payment session ID
transactionId String? // Khalti permanent transaction ID after success
amountPaisa Int // Amount sent to gateway in Paisa (NPR * 100)
status String // "Initiated", "Completed", "Refunded", "Failed"
verifiedAt DateTime?
rawResponse Json?
createdAt DateTime @default(now())
}3. Due Date Calculation & Late Fee Engine
export interface InvoiceCalculation {
baseAmount: number;
lateFee: number;
totalPayableNpr: number;
isOverdue: boolean;
daysLate: number;
}
/**
* Calculates penalty based on deadline rules
* Rule: 0 penalty before due date. 2% after 7 days, 5% after 15 days late.
*/
export function calculateLateFee(baseAmount: number, dueDate: Date): InvoiceCalculation {
const now = new Date();
const diffTime = now.getTime() - dueDate.getTime();
const daysLate = Math.max(0, Math.floor(diffTime / (1000 * 60 * 60 * 24)));
let lateFeePercentage = 0;
if (daysLate > 15) {
lateFeePercentage = 0.05; // 5% penalty
} else if (daysLate > 7) {
lateFeePercentage = 0.02; // 2% penalty
}
const lateFee = Math.round(baseAmount * lateFeePercentage);
const totalPayableNpr = baseAmount + lateFee;
return {
baseAmount,
lateFee,
totalPayableNpr,
isOverdue: daysLate > 0,
daysLate,
};
}4. Khalti ePayment v2 API Route Handler
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
const KHALTI_INITIATE_URL = "https://a.khalti.com/api/v2/epayment/initiate/";
export async function POST(req: NextRequest) {
try {
const { invoiceId } = await req.json();
const invoice = await prisma.feeInvoice.findUnique({
where: { id: invoiceId },
include: { student: true },
});
if (!invoice) {
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
}
if (invoice.status === "PAID") {
return NextResponse.json({ error: "This invoice is already paid." }, { status: 400 });
}
// Khalti accepts amount in Paisa (1 NPR = 100 Paisa)
const amountInPaisa = Math.round(invoice.totalDue * 100);
const purchaseOrderId = `INV-${invoice.id.slice(-6)}-${Date.now()}`;
const payload = {
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/payment/khalti-callback`,
website_url: process.env.NEXT_PUBLIC_APP_URL || "https://manoj-neupane.com.np",
amount: amountInPaisa,
purchase_order_id: purchaseOrderId,
purchase_order_name: invoice.title,
customer_info: {
name: invoice.student.fullName,
email: invoice.student.email,
phone: invoice.student.phone,
},
amount_breakdown: [
{ label: "Base Tuition Fee", amount: Math.round(invoice.amount * 100) },
{ label: "Late Fine Penalty", amount: Math.round(invoice.lateFee * 100) },
],
product_details: [
{
identity: invoice.id,
name: invoice.title,
total_price: amountInPaisa,
quantity: 1,
unit_price: amountInPaisa,
},
],
};
const response = await fetch(KHALTI_INITIATE_URL, {
method: "POST",
headers: {
"Authorization": `Key ${process.env.KHALTI_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
console.error("Khalti Initiation Failed:", data);
return NextResponse.json({ error: data.detail || "Khalti session error" }, { status: 400 });
}
// Save pending transaction record
await prisma.paymentTransaction.create({
data: {
invoiceId: invoice.id,
gateway: "KHALTI",
pidx: data.pidx,
amountPaisa: amountInPaisa,
status: "Initiated",
},
});
return NextResponse.json({ paymentUrl: data.payment_url, pidx: data.pidx });
} catch (error) {
console.error("Initiate error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}5. Return URL & Server-Side Verification
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
const KHALTI_LOOKUP_URL = "https://a.khalti.com/api/v2/epayment/lookup/";
export async function POST(req: NextRequest) {
try {
const { pidx } = await req.json();
if (!pidx) {
return NextResponse.json({ error: "pidx is required" }, { status: 400 });
}
// Call Khalti Lookup Server API
const khaltiRes = await fetch(KHALTI_LOOKUP_URL, {
method: "POST",
headers: {
"Authorization": `Key ${process.env.KHALTI_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ pidx }),
});
const statusData = await khaltiRes.json();
if (!khaltiRes.ok || statusData.status !== "Completed") {
return NextResponse.json({
success: false,
message: statusData.detail || "Payment has not been completed",
status: statusData.status,
}, { status: 400 });
}
// Check existing transaction in DB
const existingTx = await prisma.paymentTransaction.findUnique({
where: { pidx },
include: { invoice: true },
});
if (!existingTx) {
return NextResponse.json({ error: "Transaction record not found" }, { status: 404 });
}
// Idempotency: avoid double-marking
if (existingTx.status === "Completed") {
return NextResponse.json({
success: true,
message: "Payment already verified",
invoiceId: existingTx.invoiceId,
});
}
// Execute atomic update
await prisma.$transaction([
prisma.paymentTransaction.update({
where: { pidx },
data: {
status: "Completed",
transactionId: statusData.transaction_id,
verifiedAt: new Date(),
rawResponse: statusData,
},
}),
prisma.feeInvoice.update({
where: { id: existingTx.invoiceId },
data: {
status: "PAID",
},
}),
]);
return NextResponse.json({
success: true,
transactionId: statusData.transaction_id,
amount: statusData.total_amount / 100,
});
} catch (err) {
console.error("Verification error:", err);
return NextResponse.json({ error: "Verification server error" }, { status: 500 });
}
}6. Interactive Student Payment Portal UI
'use client';
import { useState } from "react";
import { Loader2, CheckCircle2, AlertCircle, CreditCard } from "lucide-react";
interface InvoiceProps {
invoice: {
id: string;
title: string;
amount: number;
lateFee: number;
totalDue: number;
dueDate: string;
status: string;
studentName: string;
rollNumber: string;
};
}
export function InvoicePaymentCard({ invoice }: InvoiceProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleKhaltiPay = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/pay/khalti/initiate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ invoiceId: invoice.id }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to initiate payment");
// Redirect student to Khalti secure checkout
window.location.href = data.paymentUrl;
} catch (err: any) {
setError(err.message);
setLoading(false);
}
};
const isPaid = invoice.status === "PAID";
return (
<div className="bg-[#F4F4F5] border border-[#E4E4E7] rounded-2xl p-6 text-white max-w-lg mx-auto shadow-xl">
<div className="flex justify-between items-start border-b border-[#E4E4E7] pb-4 mb-4">
<div>
<span className="text-xs font-semibold uppercase tracking-wider text-sky-400">Fee Invoice</span>
<h2 className="text-xl font-bold text-slate-100">{invoice.title}</h2>
<p className="text-xs text-slate-400 mt-1">{invoice.studentName} ({invoice.rollNumber})</p>
</div>
<span className={`px-3 py-1 text-xs font-bold rounded-full ${
isPaid ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30" : "bg-amber-500/20 text-amber-300 border border-amber-500/30"
}`}>
{invoice.status}
</span>
</div>
<div className="space-y-2 text-sm text-slate-300 mb-6">
<div className="flex justify-between">
<span className="text-slate-400">Base Tuition Fee:</span>
<span>Rs. {invoice.amount.toLocaleString("en-NP")}</span>
</div>
{invoice.lateFee > 0 && (
<div className="flex justify-between text-rose-400">
<span>Late Fine Penalty:</span>
<span>+ Rs. {invoice.lateFee.toLocaleString("en-NP")}</span>
</div>
)}
<div className="flex justify-between text-xs text-slate-400">
<span>Payment Deadline:</span>
<span>{new Date(invoice.dueDate).toLocaleDateString("en-NP", { month: "short", day: "numeric", year: "numeric" })}</span>
</div>
<div className="border-t border-[#E4E4E7] pt-2 flex justify-between font-bold text-base text-slate-100">
<span>Total Payable:</span>
<span className="text-sky-400 text-lg">Rs. {invoice.totalDue.toLocaleString("en-NP")}</span>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3 mb-4 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{isPaid ? (
<div className="flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-emerald-500/10 text-emerald-400 font-semibold border border-emerald-500/20">
<CheckCircle2 className="w-5 h-5" />
<span>Invoice Settled • Verified by Khalti</span>
</div>
) : (
<button
onClick={handleKhaltiPay}
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-[#5D2E8E] hover:bg-[#4E2479] disabled:opacity-50 text-white font-semibold py-3 px-4 rounded-xl transition-all shadow-lg shadow-purple-950/40"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>Redirecting to Khalti...</span>
</>
) : (
<>
<CreditCard className="w-5 h-5" />
<span>Pay Rs. {invoice.totalDue} with Khalti</span>
</>
)}
</button>
)}
</div>
);
}✨ Expected Output & Verification
A fully functional Next.js 14 tuition payment portal where students can view pending semester bills, calculate late penalties, and pay directly via Khalti sandbox.
🧪 How to Test the Implementation
- 1Launch Next.js development server and navigate to /tuition/student-portal
- 2Trigger payment initiation and verify redirect to Khalti sandbox checkout page
- 3Complete test payment using Khalti test wallet credentials (9800000000 / OTP 987654)
- 4Verify return_url callback triggers server lookup API and updates invoice to PAID
⚠️ Common Errors & How to Fix Them
Root Cause: Khalti minimum transaction amount is 10 NPR (1000 Paisa). Passing a smaller amount causes validation failure.
Fix: Ensure amount is at least 1000 paisa (Rs. 10). If testing small amounts, use at least Rs. 10 in your sandbox test invoice.
Root Cause: Calling Khalti initiate API directly from client-side React code exposes your Secret Key and triggers CORS browser blocks.
Fix: Never call Khalti initiate from the browser. Always route the request through your Next.js Server Route Handler (/api/pay/khalti/initiate).
🛡️ Security Considerations & Best Practices
- !Keep KHALTI_SECRET_KEY strictly in server environment variables (.env.local), never with NEXT_PUBLIC_ prefix.
- !Never trust the ?status=Completed URL redirect param; always call /epayment/lookup/ server-to-server.
- !Use Prisma transactions ($transaction) when updating payment logs and invoice status to prevent partial writes.
❓ Frequently Asked Questions (FAQ)
Yes! Khalti's ePayment v2 hosted checkout supports Khalti Wallet, ConnectIPS, e-Banking, and all major Nepali commercial bank mobile banking apps directly.
📌 Summary of Key Takeaways
Building a tuition fee tracker in Next.js 14 gives Nepali schools and colleges a reliable, automated collection pipeline with Khalti v2 ePayment, SMS due-date notifications, and automatic receipt generation.
Official References & Further Reading
Written by Manoj Neupane
AuthorSoftware developer and programming instructor, Nepal
Building software systems and SaaS platforms with Laravel, Next.js, and Node.js. Passionate about helping students and developers in Nepal master practical, real-world development skills.