Skip to content
01Web Development · Full-stack Development · 2025Featured

Labotopia E-commerce App

Summary

The online shop and back office of a materials-characterization lab: customers order characterization of their samples and buy lab equipment in one place. As sole developer I built both order flows as two separate lifecycles, and a checkout that holds stock for the buyer instead of selling it before payment. Live at labotopia.id.

Role
Sole Full-Stack Developer
Database to deployment
Timeline
Jan 2025 - Oct 2026
Freelance, remote
Client
Materials-characterization lab
Bandung, Indonesia
Stack
Next.js • MongoDB
Prisma • Midtrans • Vercel
Stack
  • Next.js
  • React
  • Prisma
  • TypeScript
  • Zod
  • MongoDB
  • Auth.js
  • Vercel
  • TailwindCSS
  • Zustand
  • Midtrans
www.labotopia.id
The storefront: characterization services and lab equipment in one place.
(01)

The problem

A characterization lab wanted to sell two very different things online: sample characterization, where the customer sends a sample in for analysis, and lab equipment, which is shipped out.

A characterization order is a laboratory workflow. The sample arrives, waits in the queue, gets analysed, and the results go back to the customer. A sample can turn out to be unusable, and results can be disputed and redone.

An equipment order is commerce: payment, packing, shipping, and after-sales complaints, returns and refunds. The equipment has limited stock, and most payments go through a gateway, where a customer can open the payment page and never come back.

Prices differ by customer type, a tax invoice can be requested at checkout, and the characterization results are confidential.

  • Catalogues for characterization services and lab equipment, with variants and add-ons
  • Separate carts and checkouts for characterization and for equipment
  • Payment through Midtrans, with discount vouchers
  • Prices by customer type (general or industry)
  • Tax invoices (faktur pajak) on request at checkout
  • An admin back office for orders, catalogue, customers, vouchers and news, with Excel exports
  • Characterization results that only the customer can download
(02)

How I solved it

I built the platform alone, from the database design to deployment, and kept maintaining it after launch. Three decisions shaped it.

01Stock is held, not sold, until the customer pays

The obvious design takes the stock off at checkout and puts it back when the order is cancelled. That assumes someone cancels. A customer who opens the Midtrans page and walks away never triggers anything, and the stock stays gone for good.

So checkout doesn't touch the stock. Each item gets a reservation with an expiry time, one day by default, and the available stock is always the stock minus the reservations still active. When the admin confirms the payment, the stock is taken off and the reservation removed; a cancelled order just removes its reservation. If nobody does anything, the reservation expires and simply stops counting. Releasing the stock depends on the clock, not on a customer remembering to cancel.

Stock is held, not sold, until the customer pays
Trade-off

Available stock is calculated on every read instead of being one stored number.

02The browser says what, the server says how much

At checkout the browser sends only IDs and quantities. The server reads the catalogue again and works out every price, discount and add-on itself, and an add-on only counts if it belongs to the product it was sent with. Whatever the page displayed, the order is charged what the database says.

Vouchers have a usage limit, so claiming one is a single conditional update: add one use, but only while the count is still under the limit. If no row was updated, someone else took the last use and the checkout stops there. The tax rate is saved with each order, so old orders keep the rate that applied when they were placed.

actions/product-order/utils.tsTypeScript
// claim one use of the voucher, but only while it is still under the limitconst updated = await tx.voucher.updateMany({  where: { id: voucherId, useCount: { lt: voucher.maxUses ?? Infinity } },  data: { useCount: { increment: 1 } },}); if (updated.count === 0) {  throw new Error("Voucher sudah habis digunakan"); // already used up}
Trade-off

Every checkout re-reads the catalogue, which costs a few more queries per order.

03Two order lifecycles, not one generic order

Characterization orders and equipment orders look alike, but they aren't. A characterization order has 11 states, including an unusable sample and results that are redone. An equipment order has 14, including shipping, returns and refunds. Neither needs the other's states.

One generic order would have been two things pretending to be one, with every branch asking which kind it really was. So they are separate all the way down: orders, items, add-ons, status history, reviews, carts and API routes each exist twice. Each order keeps a status history that records who made a change, an admin or the system itself.

Trade-off

More code: carts, orders, histories and reviews exist twice.

(03)

Results

registered members
300+
completed characterization orders
270+
samples characterized
900+

Labotopia is live at labotopia.id and has been in production since 2025. Since launch the platform has served more than 300 registered members and completed more than 270 characterization orders, covering over 900 samples. I kept building on it after launch, through a redesigned home page, brands and categories, and tax invoices at checkout, until October 2026.

(04)

Screens

A characterization service, with prices by customer type.
Lab equipment/material on a phone.
Each order shows where it is in its lifecycle.
Checkout with an optional tax invoice.
The back office for characterization orders.

Next project02 / 06

International Symposium on Functional Materials Conference Website

Got something in mind?

Let's build the next one.