Skip to content
Career & Projects⏱️ 16 min read·

Build a College Tuition Fee Reminder & Online Khalti Payment App in Next.js 14

Step-by-step practical project tutorial: Build an automated tuition tracking web application with SMS/Email reminders and Khalti ePayment v2 checkout for Nepali schools and colleges.

MN
Manoj NeupaneSoftware Developer & Programming Instructor
#Next.js 14#Khalti v2#Nepal Projects#Tailwind CSS#TypeScript#PostgreSQL
🎯 Who This Tutorial Is For

📋 Prerequisites
  • ✓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

1. Problem Statement & Architecture

In Nepal, academic institutions from secondary colleges in Kathmandu to regional IT colleges in Pokhara and Chitwan struggle with fragmented tuition fee collection. Parents often queue up at administrative counters or make unreferenced bank deposits that require hours of manual voucher reconciliation. This project solves this exact pain point by creating a modern, lightweight **Student Tuition Management & Khalti Online Payment Portal** using Next.js 14 App Router, Prisma ORM, and Khalti ePayment API v2. ### System Workflow: 1. **Admin / College Staff:** Uploads student fee quotas per semester/trimester (tuition, lab fee, library deposit). 2. **Scheduled Reminder:** Checks due dates and flags overdue student accounts with standard institutional late fees. 3. **Student / Parent Portal:** Displays balance breakdown in NPR, itemized discounts, and an instant **'Pay with Khalti'** button. 4. **Payment Flow:** Initiates a signed session on Khalti gateway; upon return, our server verifies payment status with Khalti's `/epayment/lookup/` API and issues a verifiable digital receipt.
2

2. Database Schema (Prisma / SQL)

Here is the clean database schema modeling colleges, students, fee invoices, and verified digital payment records:
prisma/schema.prisma
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

3. Due Date Calculation & Late Fee Engine

Nepali academic colleges typically charge a 5% to 10% penalty when tuition is unpaid past the 15th of the Nepali month (Baisakh, Jestha, etc.). We encapsulate this calculation in a reusable utility function:
src/lib/fee-engine.ts
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

4. Khalti ePayment v2 API Route Handler

When the student clicks **"Pay Now with Khalti"**, our frontend triggers a POST request to `/api/pay/khalti/initiate`. This Next.js Server Route Handler registers the payment intent with Khalti and returns the hosted payment URL.
src/app/api/pay/khalti/initiate/route.ts
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

5. Return URL & Server-Side Verification

Once the student pays via Khalti wallet, mobile banking, or SCT card, Khalti redirects back to our `return_url` with query parameters `?pidx=...&status=Completed`. Never trust the URL parameter alone—always verify using Khalti's `/epayment/lookup/` API.
src/app/api/pay/khalti/verify/route.ts
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

6. Interactive Student Payment Portal UI

Here is the clean Next.js client component that renders the student's tuition bill with breakdown, overdue indicators, and a one-click payment launcher:
src/components/tuition/InvoicePaymentCard.tsx
'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

  1. 1Launch Next.js development server and navigate to /tuition/student-portal
  2. 2Trigger payment initiation and verify redirect to Khalti sandbox checkout page
  3. 3Complete test payment using Khalti test wallet credentials (9800000000 / OTP 987654)
  4. 4Verify return_url callback triggers server lookup API and updates invoice to PAID

⚠️ Common Errors & How to Fix Them

❌Khalti initiate returns 400 'amount: Ensure this value is greater than or equal to 1000'

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.

❌CORS error when calling Khalti initiate directly from browser

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