Print ordering platform — shipped, live, and taking payments
Printish
Print ordering platform — customer checkout, order tracking, and an admin dispatch view. Built and launched solo.
Customers upload artwork, pay, and track delivery in one clear flow. Admins manage dispatch, catch stuck jobs early, and keep updates honest when vendor or courier signals go stale. Designed, built, and launched solo.
Live
printish.co.in
2
Customer and admin control surfaces
1
Production failure class fully closed
Solo
End-to-end build, launch, and ownership
Order journey
Upload
File validation gate before checkout opens
Pay
Hosted Razorpay checkout; secrets stay server-side only
Create order
State machine writes the first durable record
Fulfil
Admin dispatches to vendor after manual review
Deliver
Status stays honest when courier signal is stale
Payment path
Server-side Razorpay integration. Orders stored and tracked via Firebase.
What broke in production
Webhook retry caused duplicate order updates — inconsistent state.
Signal
Payment looked successful, but the order stayed pending.
Dangerous combination: the buyer sees success while the operational system has not yet secured the order state.
Root cause
Webhook retries raced ahead of a durable order commit.
Duplicate delivery was not hypothetical. The bug sat exactly at the boundary between payment acknowledgement and fulfillment state creation.
Fix
Processing became idempotent and commit ordering was hardened.
Provider event IDs became the guardrail, duplicate deliveries were ignored, and the write path completed before the handler reported success.
Why it matters
Trust in a commerce product is lost at the seams first.
Closing this failure class mattered more than adding another polished screen — it protected both revenue truth and customer confidence.
The fix
Idempotent handlers. Guarded transitions. No corrupted retries.
Idempotent handlers keyed by Razorpay payment event ID
State machine blocks invalid transitions at the write path
Retries silently no-op when the order record already exists
State machine
Explicit transition logic
Invalid moves throw immediately. No silent corruption on retry.
1export function advanceOrder(2 current: OrderStatus,3 event: FulfillmentEvent,4): OrderStatus {5 const next = transitions[current][event];6 if (!next) throw new InvalidTransition(current, event);7 return next;8}Guarded state transition: invalid moves throw immediately rather than silently corrupting order state.
Real constraint
Courier updates are unreliable. The system must reflect uncertainty without lying.
Third-party courier signals arrive late, arrive once, or sometimes don't arrive at all. The easy path is to show the last known state as current — which eventually looks wrong to the customer.
The harder path: surface uncertainty explicitly. If the signal is stale, the UI says so. No invented ETAs, no bluffed precision. This erodes trust slower than a confident lie does.
What didn't work
Three decisions I'd revisit, and why they happened.
Over-automated vendor assignment
First version assigned vendors without human review. Bad matches surfaced in production — real orders, wrong vendor, real complaints. Replaced with explicit admin dispatch. Slower, far fewer errors.
Two separate print flows
Maintained dual print configuration routes to ship faster. It worked during launch. But any future change now touches two surfaces. The correct call now would be consolidation before adding features.
Large uploads on mobile
File uploads on poor mobile connections have no resumption. The order stalls silently. A proper resumable upload or clear retry affordance is the obvious next thing to fix.
Checkout spine
Four boundaries: browser, Next.js + /api, Razorpay, and Firebase.
Client
Browser · React App
Server
Next.js + /api route handlers
secrets never leave this layer
Payments
Razorpay
Backend
Firebase
Client → /api/create-order → Razorpay (hosted checkout)
Razorpay → /api/verify-payment (server-side signature check)
Verified → Firestore order write · auth · file storage