Inventory that expires: modelling cutoff times
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) | |
|---|---|---|
| Framework | Next.js 16 (FE + API in one) | Fastify 5 + worker |
| Store | MongoDB / Mongoose 9 | PostgreSQL / Prisma 7 |
| Scheduling | per-minute cron sweep + Redis lock | BullMQ delayed job keyed by listing id |
| Queue | none | BullMQ |
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.