Building a Better FuelPass
So, here's some context: Sri Lanka went through a pretty rough fuel crisis recently. The government brought back an old digital system called FuelPass to manage fuel quotas. The idea was simple — you register your vehicle, get a QR code, take it to a pump, and get your weekly quota.
Sounds reasonable, right?
Well, the system was originally built a few years ago, and when millions of people started using it again, things fell apart fast. I'm talking real, daily frustration for ordinary people just trying to fill their tanks.
I decided to build a better version from scratch. Not to replace the official one — just to explore how I'd actually solve these problems if I had the chance.
The problems
There were four things that really bugged me about the old system.
First, one phone number could only have one vehicle. If you owned a car and a bike, tough luck — you'd need two separate SIM cards. That's just... not great.
Second — and this was the worst one — vehicles were permanently locked to a phone number. So if you bought a used car, you literally couldn't register it because it was still tied to the previous owner's number. There was no transfer mechanism. Nothing.
Third, the app kept crashing. When you register a vehicle, the system needs to verify it with the Department of Motor Traffic (DMT). The old system did this synchronously — it just sat there, waiting for the government API to respond. When thousands of people hit it at once, the whole thing collapsed.
And finally, the QR codes had zero security. If someone snapped a photo of your printed QR code, they could walk into any station and steal your fuel quota. No PIN, no verification, nothing stopping them.
Sketching it out
Before I wrote a single line of code, I grabbed a pen and started writing things down.
I always do this. When a problem has a lot of moving parts, I find it really hard to hold everything in my head at once. Writing it out by hand — the flows, the rules, the edge cases — forces me to think through the system before I commit to any architecture.
Here are some of my early notes where I was working through how riders, vehicles, and QR codes should interact:

They're not pretty, but they helped me figure out the key constraint early on: multiple people can register the same vehicle, but only one QR should be active at a time. That one rule shaped pretty much everything else.
Rethinking vehicle ownership
Alright, let's start with the phone number problem, because this one required the most fundamental rethink.
The old system treated the phone-to-vehicle relationship as one-to-one. One phone, one car. Done. This is where most of the pain came from.
I flipped this to a many-to-many relationship. A rider (that's what I call vehicle owners in my system) can register multiple vehicles. And a vehicle can be registered by multiple riders.
But wait — if multiple people can register the same car, how do you prevent them all from filling up fuel with it?
This is where it gets interesting.
Each rider can generate a QR code for any of their vehicles. So yes, there can be multiple QR codes floating around for the same car. But here's the rule: only one QR code can be active per vehicle at any given time.
When you generate a new QR, it starts in a PENDING state. It's not usable yet. The first time you take it to a fuel station and the staff verifies it, that QR becomes ACTIVE — and every other QR code for that vehicle gets automatically revoked.
So if you sell your car, the new owner just registers it, generates their QR, visits a pump, and boom — your old QR is done. No admin intervention, no support tickets. It just works.
On the database side, I identify vehicles using a composite unique key — vehicle number, chassis number, fuel type, and vehicle type. When someone registers a vehicle that already exists, my code does an upsert: if the vehicle is found, it just links the new rider to it instead of creating a duplicate.
model Vehicle {
addedBy Rider[]
@@unique([vehicleNumber, chassisNumber, fuelType, vehicleType])
}const vehicle = await this.prisma.vehicle.upsert({
where: {
vehicleNumber_chassisNumber_fuelType_vehicleType: {
vehicleNumber, chassisNumber, fuelType, vehicleType,
},
},
create: { /* create new vehicle + connect rider */ },
update: { addedBy: { connect: { userId: riderId } } },
});This single design decision solved the two biggest user complaints — the phone lock-in and the ownership transfer problem.
Taming the DMT API
Now let's talk about the crashing problem.
The government's DMT API is... not fast. And that's being generous. When the old FuelPass called it synchronously during registration, users would stare at a loading screen for ages. During peak hours, the API would buckle under the load and the whole app would go down with it.
My solution was pretty straightforward: don't wait for it.
I set up a background job queue using BullMQ (backed by Redis). When a user registers a vehicle, the request goes into the queue immediately. The user gets an instant "we're verifying your vehicle" response, and the vehicle sits in a PENDING state while a worker handles the actual DMT call behind the scenes.
const rawData = `${vehicleNumber}|${chassisNumber}|${vehicleType}|${fuelType}`;
const jobId = createHash("md5").update(rawData).digest("hex");
await this.vehicleQueue.add("verify-vehicle", data, {
jobId,
attempts: 5,
backoff: { type: "exponential", delay: 5000 },
});A few things worth mentioning here:
Job deduplication. I hash the vehicle details with MD5 and use that as the job ID. If someone taps "register" three times in a row, the queue sees the same hash and just ignores the duplicates.
Exponential backoff. If the DMT API fails, the queue retries — but it waits longer each time. 5 seconds, then 10, then 20, then 40, then 80. This prevents us from hammering a server that's already struggling.
Rate limiting. The worker runs with a concurrency of 10 and a rate limit of 50 jobs per second. We want to verify vehicles as fast as possible, but not so fast that we become the reason the DMT API goes down.
Making QR codes actually secure
This one was simpler, but arguably the most important from a user trust perspective.
When a rider registers, the system generates a random 4-digit PIN. This PIN gets encrypted with AES-256-CBC before it's saved to the database.
export class CryptoService {
private readonly algorithm = "aes-256-cbc";
private readonly key: Buffer;
encrypt(text: string): string {
const iv = crypto.randomBytes(this.ivLength);
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
// ...
return `${iv.toString("hex")}:${encrypted}`;
}
}Now, when a rider goes to a fuel station, here's what happens:
- The staff scans the QR code.
- The rider tells the staff their PIN.
- The backend decrypts the stored PIN and compares.
- If it matches, the fuel session starts. If not, nothing happens.
A stolen QR code is now completely useless without knowing the PIN.
You might wonder — why encryption instead of hashing? Because riders need to be able to see their PIN inside the app. If I hashed it, I couldn't show it back to them. So I went with reversible encryption instead.
The JWT token system
Here's something that took some thought to get right.
My system has four completely different auth flows — login, OTP verification, registration, and fuel station sessions. Each one needs its own JWT with different payload fields and different expiry times.
I could have written the JWT signing and cookie logic four separate times. But that felt gross.
Instead, I used the behavioral design pattern called "Template Method" design pattern. I created an abstract base class called CookieToken:
export abstract class CookieToken {
static type: string;
static ttl: number;
abstract payload(): Record<string, unknown>;
build() {
const Ctor = this.constructor as typeof CookieToken;
return jwt.sign(
{ ...this.payload(), type: Ctor.type },
process.env.JWT_SECRET!,
{ expiresIn: Ctor.ttl / 1000 }
);
}
}The base class handles all the boring stuff — signing the JWT, configuring cookie options (HTTP-only, secure, signed), clearing cookies on logout. Each token type just extends it and defines what data it carries:
export class AuthzToken extends CookieToken {
static type = "authz_token";
static ttl = 7 * 60 * 60 * 1000;
private userid: string;
private roles: UserRole[];
payload() {
return { userId: this.userid, roles: this.roles };
}
}And I used the Builder pattern for construction, so creating tokens looks like this:
const token = new OtpToken()
.setMobile(mobile)
.setUserId(user.id)
.setRoles(user.roles.map(r => r.name))
.setHash(hashedOtp)
.build();When I later needed a FuelToken for the station-side flow, I just created a new file with a new subclass. Didn't touch the base class at all. That's the whole point.
OTP without Redis
This is probably my favourite design decision in the whole project.
Here's how OTP verification usually works: generate a code, store it in Redis with a TTL, and when the user sends it back, look it up in Redis and compare. Pretty standard.
I did something different.
Instead of storing the OTP anywhere, I hash it using HMAC-SHA256 with a secret key, and stick that hash inside the JWT cookie:
private hashOtp(identifier: string, otpCode: string) {
const payload = `${this.namespace}:${identifier}:${otpCode}`;
return crypto
.createHmac("sha256", this.otpSecret)
.update(payload)
.digest("hex");
}When the user sends the OTP back, I hash it the exact same way and compare the two hashes. If they match, the OTP is valid. I use crypto.timingSafeEqual() for the comparison to prevent timing attacks.
The JWT cookie already has an expiry time built in, so I get TTL for free. No Redis, no database lookups, no extra storage. The whole verification is stateless.
The OTP factory
One more thing I'm pretty happy with.
Riders verify their identity with SMS. Admins verify with email. But the OTP logic — generating the code, hashing it, verifying it — is identical in both cases.
So I made the OTP module configurable using a factory method:
@Module({})
export class OtpModule {
static register(options: OtpModuleOptions): DynamicModule {
return {
module: OtpModule,
providers: [
OtpService,
{ provide: OTP_SENDER_TOKEN, useValue: options.otpSender },
],
exports: [OtpService],
};
}
}Each auth module just calls it with the right sender:
// Riders get SMS
OtpModule.register({ namespace: "RIDER", otpSender: new MockSMSProvider() })
// Admins get Email
OtpModule.register({ namespace: "ADMIN", otpSender: new EmailOtpProvider() })Same logic, different delivery channel. If I ever need to add WhatsApp OTPs, it's just a new provider class. Nothing else changes.
Where things stand
The project has three major parts — Riders, Station Staff, and Admin. I gave myself a 5-day deadline to see how far I could get as a solo developer.
I completed the full Rider backend, the full Station Staff backend, and the Rider frontend. The Admin side and the remaining frontends are still on my list — I plan to come back to them.
The whole thing runs as a monorepo (pnpm + Turborepo) with NestJS on the backend, Next.js on the frontend, PostgreSQL + Prisma for the database, and BullMQ + Redis for background jobs. Everything's TypeScript, end to end.