← All projects

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.

TypeScriptBunElysiaReactPostgreSQLStripeCloudflare R2Docker

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.

Suannn storefront home page with the hero, product grid, and promotion bar
Checking the live site…Open in a new tab ↗

What I built

Suannn is three apps in one Bun workspace, sharing one design system and one typed API contract.

StorefrontWhat customers use: browse fresh and processed products, read about the farms, keep a cart, check out with a card or PromptPay through Stripe or with cash on delivery, and manage their account, addresses, orders, and receipts.
AdminWhat staff use: a sales dashboard, orders from payment to delivery, products and image galleries, lot-based stock, farm profiles, a shared media library, staff and roles, shop settings, and an audit log.
APIOne Elysia API on Bun behind both apps: PostgreSQL through Drizzle, Better Auth for customers and staff, Stripe, Resend email, PDF receipts, and Cloudflare R2 for images.
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

Fresh fruit category page with filters and product cards

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.

Product page for Nam Dok Mai mango with gallery, variant picker, and add-to-cart

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

Mae Rim demo orchard profile page

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.

Product page section showing the farms that grow it

Home page section introducing the partner farms

Cart and checkout

Side cart open over a product page

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 page with contact, billing address, payment method, and order summary

Checkout follows the WooCommerce pattern most Thai shoppers know:

  1. 01Contactan email for the order confirmation
  2. 02Billing addresspicked from saved addresses or typed in, with a separate shipping address if needed
  3. 03Paymentcard or PromptPay through Stripe, or cash on delivery
  4. 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.

Stripe Checkout in sandbox mode showing the order lines in Thai baht

Order confirmation page after Stripe confirmed the payment

Customer accounts

Customer account order history

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.

Customer order detail with payment status, shipment, and receipt download

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 storefront on a phone: home, product, and checkout

The back office

Dashboard

Admin dashboard with sales, orders, average order value, and orders to ship

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

Admin order list with status and payment columns

Admin order detail with items, addresses, payment, shipment, and history

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

Admin product list

Admin product edit page with gallery, variants, and farm links

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.

Admin media library grid

Images live in one shared media library instead of URL fields:

  1. 01UploadJPEG, PNG, WebP, or AVIF from the admin
  2. 02Deduplicateidentical bytes (SHA-256) return the existing image
  3. 03Optimizesharp re-encodes to WebP: display ≤2000 px at q80, thumbnail ≤480 px at q75, metadata stripped
  4. 04Storewritten once to Cloudflare R2 with a one-year immutable cache header
  5. 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

Admin inventory overview

Stock for one variant split into lots with expiry dates

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.

Stock movement history

Farms, customers, staff, and settings

Admin farm list

Admin customer detail with orders and addresses

Staff members and invitations

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.

Ownereverything, including other owners and shop-level settings
Admineverything except owner management
Catalog managerproducts, images, farms, and stock
Fulfillmentstock, packing, shipping, and collecting cash on delivery
Supportorders, cancellations, guest links, and customers

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.

Commerce settings with shipping fee and checkout switch

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

Audit log of staff and security actions

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.

  1. 01Holdstock is reserved when the Stripe session starts
  2. 02Expirethe session expires 34 minutes after it's first created
  3. 03Confirmonly a verified webhook marks the order paid
  4. 04Recoverif the server never saw the session ID, it repeats the original request to recover it, for up to 23 hours
  5. 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

Fails closed in productionThe API won't start without an exact list of allowed origins and a trusted proxy header. Auth URLs must be HTTPS, and cookies are always Secure.
Exact-origin writesEvery browser write must come from the storefront's or admin's exact origin and use JSON (image uploads use multipart form data). Credentialed CORS is limited to those two origins.
Rate limits in the databaseSign-up, sign-in, email-change, and write requests are rate-limited with counters stored in PostgreSQL, so limits hold across restarts and instances.
Allowlisted auth routesOnly a pinned list of Better Auth routes is exposed. Its raw admin and MFA-enrollment endpoints stay blocked.

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

  1. 01Qualityevery pull request and every push to main runs lint, type checks, all test suites, and production builds
  2. 02ReleaseGitHub Actions builds the api, migrate, and web images and pushes them to GHCR
  3. 03DeployDokploy pulls the images and runs the stack, with Traefik handling TLS
  4. 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

Payments are a state machineMost of the Stripe work wasn't calling Stripe. It was deciding what the shop believes when a redirect, a webhook, and a timeout disagree — and making the answer "hold the stock and ask a human" instead of guessing.
Put the rule where it can't be skippedCash on delivery for verified customers only, refunds only after cancellation, no publishing without alt text: each rule lives in the API or the database, not only in a button that a page hides.
Audit your own workWriting coverage reports that matched every API route to the screen that calls it found real gaps — missing UI for some routes, and pages still reading sample data — before a customer would have.

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.