AZ
All writing
4 min read

Inventory that expires: modelling cutoff times

nextjsmongodbpaystackbullmqcronmarketplace
Part of the projectPrechopA Nigerian campus food pre-order marketplace: dated listings with cutoff times, Paystack split prepay, and atomic Redis slot reservations. Shipped on Next.js + Mongo (a Fastify/Prisma twin exists).View project

Prechop's tagline is "order before they cook," and that preposition is the entire product. A vendor posts a dated listing with a cutoff time; students pre-order and prepay via Paystack; the kitchen cooks to demand it can actually see. The engineering question is deceptively small: is this listing still orderable?

Two backends, told honestly

The repo carries two implementations of the same domain, and it's worth being clear about which ships:

prechop/ (live, shipped)prechop-api/ (earlier twin)
FrameworkNext.js 16 (FE + API in one)Fastify 5 + worker
StoreMongoDB / Mongoose 9PostgreSQL / Prisma 7
Schedulingper-minute cron sweep + Redis lockBullMQ delayed job keyed by listing id
QueuenoneBullMQ

The Prisma schema is the cleanest expression of the data model (money as integer kobo, cuid ids, listing → item → order → payment), so it's worth reading — but it is not the live store. I'll draw the model from prechop-api and the shipped scheduling from the Next.js app.

Three time-states

A listing is a window. It is not-yet-open while scheduledDate / availableFrom is in the future, orderable between open and cutoffTime while ACTIVE, and closed once the cutoff passes. The read-time guard on every order attempt is the same in both backends — reject a too-early order, throw a cutoff-passed error past the deadline — so no scheduler race can let a late order slip through even if a sweep runs late.

Two ways to close a listing at its cutoff

This is the interesting divergence. prechop-api enqueues a BullMQ delayed job keyed by listing id that fires exactly at the cutoff; the jobId makes it self-deduping and idempotent, and a re-publish removes and re-adds it:

const existingJob = await cutoffEnforceQueue.getJob(dailyOrderId);
if (existingJob) await existingJob.remove();
const delay = Math.max(0, cutoffTime.getTime() - Date.now());
await cutoffEnforceQueue.add("close-daily-order", { dailyOrderId },
  { jobId: dailyOrderId, delay, removeOnComplete: true, removeOnFail: true });

The live app instead runs a per-minute cron sweep, wrapping each job in a helper that takes a Redis lock so only one instance per tick does the work under horizontal scaling:

const key = `cron:lock:${DB_NAME}:${job}`;
const got = await acquireLock(key, INSTANCE_ID, ttlSeconds);
if (!got) return;
try { await fn(); } finally { await releaseLock(key, INSTANCE_ID); }

Several jobs pass a Lagos timezone as a load-bearing argument: cron schedules in the server's local time, so on a UTC host the nightly sold-out reset would fire at 01:00 Lagos and leave sold-out items dark through the first trading hour.

Cutoff isn't just "stop new orders"

When the window closes, orders the vendor took money for but never confirmed are auto-cancelled and refunded through Paystack — closing the listing and sweeping stale paid orders are two different jobs, and the enforce job's lock TTL deliberately outlives a slow batch of Paystack round trips so the next tick can't start an overlapping sweep.

The 30-minute pre-cutoff warning is the subtle one. It runs every minute inside a 30-minute window, so a naive implementation sends the same buyer 30 messages. There's no warnedAt column, so the dedupe is a Redis SET NX per listing whose TTL outlives the window: the first tick to claim a listing is the only one that notifies, the key is never released because expiry is the reset, and a lost lock (Redis down) yields no warning rather than a duplicate — the safe direction for a notification that costs money to send. A quieter fix hides in the same service: the warning query asks the operational question (status + cutoffTime) instead of borrowing a marketplace-visibility query that also filtered isPublic and vendor-open flags — because whether a listing is browsable has nothing to do with whether its buyers deserve a warning.

Money and slots are server-authoritative

Pricing, item resolution, and add-on ownership are all computed server-side — the client sends only ids. Paystack runs on split subaccounts (the platform absorbs the processing fee), and the webhook verifies an HMAC-SHA512 signature on the raw body with a timing-safe compare before doing anything, then checks idempotency and that the paid amount matches the record. Finite maxQuantity slots are guarded separately with atomic Redis reservations that roll back cleanly under contention:

const reservedAfter = await Redis.incrby(key, item.quantity);
await Redis.expire(key, ttlSeconds);
acquired.push({ id: item.dailyOrderItemId, qty: item.quantity });
if (item.committed + reservedAfter > item.maxQuantity) {
  for (const a of acquired) await decrReserved(a.id, a.qty); // roll back all
  return { ok: false, failedItemId: item.dailyOrderItemId };
}

Availability is maxQuantity − committed − reserved, so two buyers racing for the last portion can't both slip past.

Back to the projectPrechopSee the full case study and related write-ups.View project
Inventory that expires: modelling cutoff times in Prechop · Abdullah Zakariyya