Building a Nepali Bill OCR with Gemini and Next.js
Résumé
Pixel shares a practical bill OCR design for Nepali businesses, using Next.js, Gemini, and Prisma to turn bill photos into reviewable, exportable data. The pipeline keeps AI focused on reading printed values, while tested code handles numeral conversion, PAN validation, Bikram Sambat dates, and CSV export—helping reduce manual entry errors.
DEV Community
Let the Model Read, Let the Code Convert: Building a Bill OCR for Nepal
How I turned photos of Nepali paper bills into reviewable, exportable data with Next.js, Gemini and Prisma 8. The problem In Nepal, many small businesses still receive printed or handwritten bills. An accountant then types each one into Excel or accounting software: vendor, PAN number, date, items, VAT, total. It is slow, and one wrong digit in a total or a PAN can cause mismatches at tax filing time. I wanted to build something people would actually use, so I picked this problem: take a photo of a bill, get structured data back, let a human check it, and export it. The result is Bill OCR Generic OCR tools don't fit well because Nepali bills mix Devanagari numerals (०१२३) with English digits, use Bikram Sambat (BS) dates like 2083/07/22, carry 9-digit PAN/VAT numbers, and come in every layout imaginable. The one design rule The model reads. The code decides. A vision model is excellent at looking at a messy photo and finding the vendor, the date and the total. It is much less trustworthy at arithmetic or calendar conversion, and you can't unit test it. So the model's only job is to copy what is printed. Everything after that (converting numerals, parsing amounts, validating PAN numbers, converting BS dates) is ordinary code with tests. The pipeline Upload
→ validate (type, size, file signature)
→ store the image
→ extract with Gemini (JSON, values exactly as printed)
→ validate the raw output with Zod
→ normalize in code and collect warnings
→ save as PENDING_REVIEW
→ human review → CONFIRMED → CSV export
Step 1: Extraction The prompt asks for JSON only, with fixed keys, and sets rules that matter more than the wording of the task: Copy values exactly as printed. Don't convert numerals or dates. If a field is missing or unreadable, return null. Never guess. Keep item names in their original language. "Never guess" is the important one. A null shows up in the review screen as an empty field. A confident wrong guess looks like a correct value. I validate the response with Zod, where every field is nullable, so one unreadable field doesn't throw away the whole bill: export const RawBillSchema = z.object({
vendor_name: z.string().nullable(),
vendor_pan: z.string().nullable(),
bill_number: z.string().nullable(),
bill_date: z.string().nullable(),
subtotal: z.string().nullable(),
vat: z.string().nullable(),
total: z.string().nullable(),
items: z.array(z.object({
name: z.string(),
qty: z.string().nullable(),
rate: z.string().nullable(),
amount: z.string().nullable(),
})),
});
Note that every value is a string, exactly as printed. Numbers come later. I built this on Gemini's free tier, which sometimes answers 503 "model is overloaded". So extractBill retries with short waits and then falls through a list of models read from an environment variable. A request timeout and a fallback model stop a busy model from hanging the upload for over a minute (I saw 87 seconds once). Step 2: Normalization Now the deterministic part. Nepali numerals are a one-line map: const DIGITS: Record = {
'०': '0', '१': '1', '२': '2', '३': '3', '४': '4',
'५': '5', '६': '6', '७': '7', '८': '8', '९': '9',
};
export const toEnglishDigits = (s: string) =>
s.replace(/[०-९]/g, (d) => DIGITS[d]);
Amounts like रु. ४,८००/- become 4800 after converting the digits and stripping currency markers and commas. Anything that doesn't parse as a number becomes null rather than NaN. PAN/VAT numbers must be exactly 9 digits or they are rejected. Dates were the most interesting part. A bill may carry a BS date (2083-07-22) or an AD date, and I need both stored. I convert with nepali-date-converter, then validate by converting back: const ad = new NepaliDate(year, month - 1, day).toJsDate();
// Round trip: if converting back gives a different BS date,
// the input was invalid (for example day 32 in a 30-day month)
if (new NepaliDate(ad).format('YYYY-MM-DD') !== bs) {
return { bs: null, ad: null };
}
Two...

Powered by Readybot.io