E-commerce · 2026
Suannn
A farm-to-table fruit shop built to production standards, end to end — a customer storefront, a back office for staff, and the API behind both, with real payments, FIFO stock, receipts, and staff security.
The problem
I've built e-commerce for several companies, and over time it became the work I'm most comfortable with. Eventually I wanted to sell something myself: fresh Thai fruit and fruit products, sold directly from the farms that grow them.
I haven't had time to start the actual business yet. But I didn't want the shop to be a mockup that only looks finished. So I built Suannn the way I'd need it on launch day — every part a real shop needs, from the page a customer sees to the screens the people packing orders use.
Until it opens at ecommerce.naay.cc, it's a showcase. Everything on this page runs; the screenshots are from the app, not from a design file.
What I built
Suannn is three apps in one Bun workspace, sharing one design system and one typed API contract.
- commits
- 265
- API modules
- 22
- database tables
- 43
- automated tests
- 1,164
The storefront and admin are separate React 19 single-page apps built with Vite. They get their types from the API itself: the API exports its route definitions, and both apps call it through Eden Treaty. If an API response changes shape, the frontend stops compiling. Nobody keeps a second copy of the types.
The storefront
Browsing and products

The catalog has two categories, fresh and processed. Customers can search, filter by category, and sort by newest or by price. Lists load with cursor pagination.

A product page shows its image gallery, a variant picker (each variant has its own SKU, pack size, and price), and whether it can be bought right now. That last part comes from real stock: the API checks for sellable, unreserved units in the warehouse. Product links use the product's slug, and if staff change a slug, the old links still redirect to the product.
Farms behind the food

Suannn is about knowing where food comes from, so farms are part of the catalog. A product can be linked to several farms. Each farm has a profile page with a cover photo, a portrait, its story, and the products it supplies. The home page and product pages link to them.


Cart and checkout

The cart is stored on the server, not in the browser. Guests get a cart through an HttpOnly cookie. The cookie's token is 256 random bits, stored only as a hash, and expires 30 days after the last change. When a guest signs in, their cart merges into their account's cart once. A cart holds up to 50 lines and 99 units of each variant, and an item that becomes unavailable stays visible with a reason instead of disappearing.

Checkout follows the WooCommerce pattern most Thai shoppers know:
- 01Contactan email for the order confirmation
- 02Billing addresspicked from saved addresses or typed in, with a separate shipping address if needed
- 03Paymentcard or PromptPay through Stripe, or cash on delivery
- 04Confirmthe API re-checks prices, stock, and shipping from a signed quote
Before placing an order, the storefront asks the API for a signed quote. Placing the order sends that quote back, so the price a customer agreed to is the price the server checks. Cash on delivery is only for signed-in customers who have verified their email, and the API enforces that rule, not just the page. Guests pay with Stripe.


Customer accounts

Customers can sign up without verifying their email first, so nothing blocks a first purchase. The account area covers profile, password, sessions, order history, cancelling an order, and re-ordering. Customers can save up to 20 Thai addresses, with separate defaults for shipping and billing. Changing the account email needs the current password and an 8-digit code sent to the new address. The code expires after 10 minutes and stops working after 5 wrong tries.

Guests can follow their order too. The confirmation email links to the order with an access token placed after the # in the URL, so browsers never send it to a server or in a Referer header. The page moves the token into the tab's session storage and clears it from the address bar.
On a phone

The back office
Dashboard

The dashboard shows sales, order count, average order value, and how many orders are waiting to ship, for the last 7 days, 30 days, or 3 months, each compared with the period before. Cancelled and unpaid orders aren't counted as sales.
Orders


Each order moves through processing → packed → shipped → delivered. Shipping records the carrier (Thailand Post, Kerry, Flash, J&T, or other) and a tracking number. Staff can also:
- cancel an order before it ships, which returns its exact units to the lots they came from;
- record a cash-on-delivery payment, which must match the order total exactly;
- refund a Stripe order in full, but only after cancelling it, so a refund never happens by accident;
- re-send or revoke a guest's order link without ever seeing the token.
Every one of these actions needs an Idempotency-Key. If a response gets lost and staff press the button again, the same action isn't applied twice.
Products and the media library


Products go from draft to published to archived. Archiving keeps their IDs and reserves their SKUs, so old orders still point at the right thing. A product can't be published without at least one image that has alt text.

Images live in one shared media library instead of URL fields:
- 01UploadJPEG, PNG, WebP, or AVIF from the admin
- 02Deduplicateidentical bytes (SHA-256) return the existing image
- 03Optimizesharp re-encodes to WebP: display ≤2000 px at q80, thumbnail ≤480 px at q75, metadata stripped
- 04Storewritten once to Cloudflare R2 with a one-year immutable cache header
- 05Useproducts get an ordered gallery of up to 12 images; farms get a cover and a portrait
Image URLs are built when they're read, not stored, so moving to a new image domain is a config change. An image that's in use can't be deleted — the database enforces it, not just the UI. Alt text belongs to the image, so fixing it once fixes it everywhere it's used.
Stock


Fruit spoils, so stock is tracked in lots. Every lot has an expiry date, and a lot stops being sellable at the start of that day, Bangkok time. A variant can also require extra days of shelf life left before it's sold. Orders take units from the oldest lot that still qualifies (FIFO).
Staff can receive new lots, quarantine and release them, write off spoiled units, and correct counts after a physical check. Reservations hold stock for 15 minutes. Every change is written as a movement that can never be edited, so the history always adds up.

Farms, customers, staff, and settings



Nobody can sign up as staff. An owner or admin sends an invitation, and by default the new staff member must set up an authenticator app (TOTP) and save backup codes before they can open any admin page. Owners can turn this requirement off in Feature controls; it starts on. Staff sessions end after 30 minutes idle or 8 hours total. The shop always keeps at least one owner: the last one can't be demoted or suspended.
Each role is a fixed set of permissions (for example order:refund or inventory:adjust), and the API checks the permission on every request. The admin hides what a role can't do, but the API is what enforces it.

Checkout starts switched off. Staff set a shipping fee first, then turn checkout on as a separate step. The shop never quietly assumes free shipping.
Audit log

Security and business actions — staff invitations, role and MFA changes, settings, product changes, stock commands, order actions — are written to an append-only audit log. Each entry is written in the same database transaction as the action itself, so the log can't miss one. Entries keep IDs and reason codes, not secrets or full addresses.
Technical decisions
One typed contract
The API is a chain of Elysia modules. Each feature is split into a controller, a service, and a repository (plus a policy file where it has business rules), and app.ts exports the combined App type. Both frontends use that type through Eden Treaty. The API also publishes an OpenAPI spec and interactive docs at /api/v1/docs. Errors always come back as { code, message }, never a stack trace.
Orders that can't oversell
A cash-on-delivery order is placed in a single PostgreSQL transaction: check the quote, create the order, reserve units from the oldest lots, and confirm them. The order records exactly which lots its units came from, so cancelling puts them back in the same places.
Stripe orders are harder, because the customer leaves the site to pay. Stock is held while they're on Stripe's page, and the order is only marked paid when a signed Stripe webhook says so. The redirect back to the shop doesn't count.
- 01Holdstock is reserved when the Stripe session starts
- 02Expirethe session expires 34 minutes after it's first created
- 03Confirmonly a verified webhook marks the order paid
- 04Recoverif the server never saw the session ID, it repeats the original request to recover it, for up to 23 hours
- 05Escalateanything ambiguous goes to manual review and keeps the stock held
The webhook endpoint checks the Stripe-Signature header against the raw request body before reading anything. Order confirmation emails go through an outbox: the order commits first, and email is sent afterwards with retries, so an email outage can never cancel an order.
Receipts
The shop is planned as VAT-registered, so every paid order gets an abbreviated tax invoice (ใบเสร็จรับเงิน/ใบกำกับภาษีอย่างย่อ). Fresh fruit is VAT-exempt under Section 81 while processed products aren't, so the receipt shows the two amounts separately. Receipt numbers are sequential and never reused. Cancelling a receipt keeps its number. The PDF is generated with pdf-lib, emailed, and can be downloaded by the customer, the guest, or staff.
Security defaults
Secure.Request bodies are capped at 11 MB. Customer and staff accounts share one user table but can't use each other's routes: a staff session can't open the customer account pages, and a customer session can't open the admin.
Deployment
- 01Qualityevery pull request and every push to
mainruns lint, type checks, all test suites, and production builds - 02ReleaseGitHub Actions builds the api, migrate, and web images and pushes them to GHCR
- 03DeployDokploy pulls the images and runs the stack, with Traefik handling TLS
- 04Migratea migrate job runs once per deploy before the new API starts
The storefront, admin, and API run on separate subdomains of one domain, which keeps session cookies same-site. The API has /health and /ready endpoints. /ready only returns 200 when the database is reachable and the schema is current. The whole stack also runs locally with one docker compose up.
cp docker.env.example .env
docker compose up --build
docker compose run --rm api bun src/cli/bootstrap-owner.ts owner@example.com
Outcome
Suannn does what a small shop needs on day one: a customer can find a product, learn where it comes from, pay, and track it, and staff can pack, ship, refund, restock, and see what happened.
- API unit tests
- 350
- API integration tests against PostgreSQL
- 337
- storefront tests
- 207
- admin tests
- 270
CI runs all four suites on every pull request and push to main, with the integration tests against a PostgreSQL 16 service. Integration tests refuse to run on any database whose name doesn't end in _test, so they can't wipe a real one. The last full run recorded in the repo (5 October 2026) passed 775 of 775 tests with 4,887 assertions. In my latest local run (19 October), the API unit and admin suites all passed and 206 of 207 storefront tests passed; the one failure is a checkout test for switching the billing address's phone number.
I built the project in a spec-first loop: each feature started as a written design, became an implementation plan, and ended with a verification report. The repo has 15 specs, 15 plans, and 13 audit and verification reports.
What I learned
The product and farm photos are illustrations (AI-generated or from Unsplash), and the farms are demo profiles marked as such. They'll be replaced with real farms and photos when the shop opens.
