E-commerce · 2026
Suannn
ร้านผลไม้จากสวนที่สร้างตามมาตรฐาน production ครบทั้งระบบ ทั้งหน้าร้านของลูกค้า ระบบหลังบ้านของพนักงาน และ API ที่อยู่เบื้องหลัง พร้อมการชำระเงินจริง สต็อกแบบ FIFO ใบเสร็จ และความปลอดภัยของบัญชีพนักงาน
โจทย์
ผมทำ e-commerce ให้หลายบริษัทมาแล้ว จนกลายเป็นงานที่ถนัดที่สุด ถึงจุดหนึ่งก็อยากขายของเองบ้าง คือผลไม้ไทยสดและผลิตภัณฑ์แปรรูป ส่งตรงจากสวนที่ปลูก
ตอนนี้ยังไม่มีเวลาเปิดธุรกิจจริง แต่ก็ไม่อยากให้ร้านเป็นแค่ mockup ที่ดูเหมือนเสร็จแล้ว เลยสร้าง Suannn ให้พร้อมเหมือนจะเปิดร้านพรุ่งนี้ มีครบทุกส่วนที่ร้านจริงต้องใช้ ตั้งแต่หน้าที่ลูกค้าเห็น ไปจนถึงหน้าจอของคนแพ็กของ
ระหว่างที่ยังไม่เปิดที่ ecommerce.naay.cc โปรเจกต์นี้คือ showcase ทุกอย่างในหน้านี้ใช้งานได้จริง ภาพหน้าจอทั้งหมดถ่ายจากแอปที่รันอยู่ ไม่ใช่จากไฟล์ดีไซน์
สิ่งที่ทำ
Suannn คือแอปสามตัวใน Bun workspace เดียวกัน ใช้ design system ชุดเดียวกัน และใช้ type ของ API ชุดเดียวกัน
- commit
- 265
- module ใน API
- 22
- ตารางในฐานข้อมูล
- 43
- test อัตโนมัติ
- 1,164
หน้าร้านและหลังบ้านเป็น React 19 single-page app แยกกัน build ด้วย Vite ทั้งสองแอปได้ type มาจาก API โดยตรง API export นิยามของ route ออกมา แล้วทั้งสองแอปเรียกผ่าน Eden Treaty ถ้า response ของ API เปลี่ยนรูปร่าง ฝั่ง frontend จะ compile ไม่ผ่านทันที ไม่มีใครต้องเขียน type ซ้ำอีกชุด
หน้าร้าน
เลือกดูสินค้า

สินค้าแบ่งเป็นสองหมวด คือของสดและของแปรรูป ลูกค้าค้นหา กรองตามหมวด และเรียงตามสินค้าใหม่หรือราคาได้ รายการโหลดแบบ cursor pagination

หน้าสินค้าแสดงแกลเลอรีรูป ตัวเลือกสินค้า (แต่ละตัวเลือกมี SKU ขนาดบรรจุ และราคาของตัวเอง) และบอกว่าตอนนี้ซื้อได้หรือไม่ ข้อสุดท้ายนี้มาจากสต็อกจริง API เช็กว่าในคลังยังมีของที่ขายได้และยังไม่ถูกจองหรือเปล่า ลิงก์สินค้าใช้ slug ของสินค้า และถ้าพนักงานเปลี่ยน slug ลิงก์เก่าก็ยัง redirect มาที่สินค้าเดิมได้
สวนที่อยู่เบื้องหลัง

Suannn ตั้งใจให้ลูกค้ารู้ว่าอาหารมาจากไหน สวนจึงเป็นส่วนหนึ่งของ catalog สินค้าหนึ่งชิ้นผูกกับได้หลายสวน แต่ละสวนมีหน้าโปรไฟล์ที่มีภาพปก ภาพเจ้าของ เรื่องราว และสินค้าที่สวนนั้นส่งให้ หน้าแรกและหน้าสินค้าก็ลิงก์ไปหาสวนเหล่านี้


ตะกร้าและการชำระเงิน

ตะกร้าเก็บไว้บน server ไม่ได้เก็บใน browser ลูกค้าที่ยังไม่ login ได้ตะกร้าผ่าน cookie แบบ HttpOnly token ใน cookie สุ่มขนาด 256 bit เก็บในฐานข้อมูลเป็นค่า hash เท่านั้น และหมดอายุเมื่อไม่มีการแก้ไขครบ 30 วัน พอ login ตะกร้าเดิมจะรวมเข้ากับตะกร้าของบัญชีหนึ่งครั้ง ตะกร้าหนึ่งใบมีได้ 50 รายการ รายการละไม่เกิน 99 ชิ้น และถ้าสินค้าไหนซื้อไม่ได้แล้ว ก็ยังแสดงอยู่พร้อมบอกเหตุผล ไม่หายไปเฉย ๆ

ขั้นตอน checkout ทำตามแบบ WooCommerce ที่คนไทยคุ้นเคย
- 01ข้อมูลติดต่ออีเมลสำหรับรับรายละเอียดคำสั่งซื้อ
- 02ที่อยู่ออกใบเสร็จเลือกจากที่อยู่ที่บันทึกไว้หรือกรอกใหม่ และแยกที่อยู่จัดส่งได้ถ้าต้องการ
- 03ชำระเงินบัตรหรือพร้อมเพย์ผ่าน Stripe หรือเก็บเงินปลายทาง
- 04ยืนยันAPI ตรวจราคา สต็อก และค่าส่งอีกรอบจาก quote ที่เซ็นไว้
ก่อนสั่งซื้อ หน้าร้านขอ quote ที่เซ็นแล้วจาก API ตอนกดสั่งซื้อก็ส่ง quote นั้นกลับไป ราคาที่ลูกค้าตกลงจึงเป็นราคาเดียวกับที่ server ตรวจ การเก็บเงินปลายทางใช้ได้เฉพาะลูกค้าที่ login และยืนยันอีเมลแล้ว และกฎนี้บังคับที่ API ไม่ใช่แค่ซ่อนตัวเลือกในหน้าเว็บ ส่วนลูกค้าที่ไม่ได้ login จ่ายผ่าน Stripe


บัญชีลูกค้า

ลูกค้าสมัครสมาชิกได้โดยไม่ต้องยืนยันอีเมลก่อน การซื้อครั้งแรกจึงไม่มีอะไรมาขวาง ในบัญชีมีโปรไฟล์ รหัสผ่าน session ประวัติคำสั่งซื้อ การยกเลิกคำสั่งซื้อ และการสั่งซ้ำ ลูกค้าบันทึกที่อยู่ไทยได้ถึง 20 ที่ และตั้งค่าเริ่มต้นสำหรับจัดส่งกับออกใบเสร็จแยกกันได้ การเปลี่ยนอีเมลต้องใส่รหัสผ่านปัจจุบันและรหัส 8 หลักที่ส่งไปยังอีเมลใหม่ รหัสหมดอายุใน 10 นาที และใช้ไม่ได้อีกหลังกรอกผิด 5 ครั้ง

ลูกค้าที่ไม่ได้ login ก็ติดตามคำสั่งซื้อได้ อีเมลยืนยันมีลิงก์ไปหน้าคำสั่งซื้อพร้อม access token ที่อยู่หลังเครื่องหมาย # ใน URL browser จึงไม่ส่ง token นี้ไปให้ server หรือใส่ใน header Referer เลย หน้าเว็บจะย้าย token ไปเก็บใน session storage ของแท็บนั้น แล้วลบออกจากแถบที่อยู่
บนมือถือ

ระบบหลังบ้าน
Dashboard

Dashboard แสดงยอดขาย จำนวนคำสั่งซื้อ ยอดเฉลี่ยต่อคำสั่งซื้อ และจำนวนคำสั่งซื้อที่รอจัดส่ง เลือกดูย้อนหลัง 7 วัน 30 วัน หรือ 3 เดือนได้ และเทียบกับช่วงก่อนหน้าทุกตัวเลข คำสั่งซื้อที่ยกเลิกหรือยังไม่ชำระไม่นับเป็นยอดขาย
คำสั่งซื้อ


คำสั่งซื้อแต่ละรายการเดินตามขั้นกำลังเตรียม → แพ็กแล้ว → จัดส่งแล้ว → ส่งถึงแล้ว ตอนจัดส่งจะบันทึกขนส่ง (ไปรษณีย์ไทย, Kerry, Flash, J&T หรืออื่น ๆ) และเลขพัสดุ นอกจากนี้พนักงานยัง
- ยกเลิกคำสั่งซื้อก่อนจัดส่งได้ และของจะคืนกลับเข้าล็อตเดิมที่ถูกหยิบไป
- บันทึกการรับเงินปลายทางได้ โดยยอดต้องตรงกับยอดคำสั่งซื้อพอดี
- คืนเงินคำสั่งซื้อที่จ่ายผ่าน Stripe ได้เต็มจำนวน แต่ต้องยกเลิกคำสั่งซื้อก่อน การคืนเงินจึงไม่มีทางเกิดขึ้นโดยไม่ตั้งใจ
- ส่งลิงก์คำสั่งซื้อให้ลูกค้าที่ไม่ได้ login ใหม่ หรือเพิกถอนลิงก์เดิมได้ โดยพนักงานไม่เคยเห็น token เลย
ทุกคำสั่งเหล่านี้ต้องมี Idempotency-Key ถ้า response หายระหว่างทางแล้วพนักงานกดซ้ำ คำสั่งเดิมจะไม่ถูกทำซ้ำสองรอบ
สินค้าและคลังภาพ


สินค้าเปลี่ยนสถานะจากฉบับร่าง เป็นเผยแพร่ แล้วเก็บเข้าคลัง การเก็บเข้าคลังยังคง ID ไว้และกัน SKU นั้นไม่ให้ใช้ซ้ำ คำสั่งซื้อเก่าจึงยังชี้ไปที่สินค้าที่ถูกต้อง สินค้าจะเผยแพร่ไม่ได้ถ้ายังไม่มีรูปที่ใส่ alt text อย่างน้อยหนึ่งรูป

รูปทั้งหมดอยู่ในคลังภาพกลางที่เดียว แทนการแปะ URL
- 01อัปโหลดJPEG, PNG, WebP หรือ AVIF จากหลังบ้าน
- 02กันรูปซ้ำถ้าไฟล์เหมือนกันทุก byte (SHA-256) จะได้รูปเดิมกลับมา
- 03ปรับขนาดsharp แปลงเป็น WebP ขนาดแสดงผลไม่เกิน 2000 px ที่ q80 และ thumbnail ไม่เกิน 480 px ที่ q75 พร้อมลบ metadata
- 04จัดเก็บเขียนลง Cloudflare R2 ครั้งเดียว พร้อม cache header แบบ immutable หนึ่งปี
- 05นำไปใช้สินค้ามีแกลเลอรีเรียงลำดับได้ถึง 12 รูป ส่วนสวนมีภาพปกและภาพเจ้าของ
URL ของรูปสร้างตอนอ่าน ไม่ได้เก็บไว้ในฐานข้อมูล ถ้าย้ายโดเมนรูปก็แค่แก้ config รูปที่ถูกใช้อยู่ลบไม่ได้ ฐานข้อมูลเป็นตัวบังคับ ไม่ใช่แค่ UI และ alt text ผูกกับตัวรูป แก้ครั้งเดียวก็เปลี่ยนทุกที่ที่ใช้รูปนั้น
สต็อก


ผลไม้เสียได้ สต็อกจึงนับเป็นล็อต ทุกล็อตต้องมีวันหมดอายุ และล็อตจะขายไม่ได้ตั้งแต่ต้นวันนั้นตามเวลากรุงเทพฯ ตัวเลือกสินค้ากำหนดได้ด้วยว่าต้องเหลืออายุอีกกี่วันถึงจะขาย คำสั่งซื้อหยิบของจากล็อตที่เก่าที่สุดที่ยังผ่านเงื่อนไข (FIFO)
พนักงานรับล็อตใหม่ กักล็อตไว้ตรวจและปล่อยกลับ ตัดของเสีย และปรับยอดหลังนับของจริงได้ การจองสต็อกถือของไว้ 15 นาที ทุกการเปลี่ยนแปลงถูกบันทึกเป็นรายการเคลื่อนไหวที่แก้ไขไม่ได้ ประวัติจึงรวมแล้วตรงเสมอ

สวน ลูกค้า พนักงาน และการตั้งค่า



ไม่มีใครสมัครเป็นพนักงานเองได้ owner หรือ admin ต้องส่งคำเชิญ แล้วโดยค่าเริ่มต้น พนักงานใหม่ต้องตั้งค่าแอป authenticator (TOTP) และบันทึก backup code ก่อนจะเปิดหน้าไหนในหลังบ้านได้ owner ปิดข้อบังคับนี้ได้ใน Feature controls แต่ตั้งต้นเปิดไว้ session ของพนักงานหมดอายุเมื่อไม่ได้ใช้งาน 30 นาที หรือครบ 8 ชั่วโมง ร้านต้องมี owner อย่างน้อยหนึ่งคนเสมอ owner คนสุดท้ายจึงถูกลดสิทธิ์หรือระงับไม่ได้
แต่ละ role คือชุดสิทธิ์ที่กำหนดตายตัว (เช่น order:refund หรือ inventory:adjust) และ API ตรวจสิทธิ์ทุก request หลังบ้านซ่อนสิ่งที่ role นั้นทำไม่ได้ แต่ตัวที่บังคับจริงคือ API

checkout ปิดอยู่ตั้งแต่เริ่ม พนักงานต้องตั้งค่าจัดส่งก่อน แล้วค่อยเปิด checkout เป็นอีกขั้น ร้านไม่เคยแอบถือว่าส่งฟรี
บันทึกกิจกรรม

การกระทำด้านความปลอดภัยและธุรกิจ ทั้งการเชิญพนักงาน การเปลี่ยน role และ MFA การตั้งค่า แก้สินค้า สั่งงานสต็อก และจัดการคำสั่งซื้อ ถูกเขียนลงบันทึกกิจกรรมที่เพิ่มได้อย่างเดียว แต่ละรายการเขียนใน database transaction เดียวกับการกระทำนั้น บันทึกจึงไม่มีทางตกหล่น และเก็บแค่ ID กับรหัสเหตุผล ไม่เก็บข้อมูลลับหรือที่อยู่เต็ม
เทคนิค
type ชุดเดียวทั้งระบบ
API คือ Elysia module หลายตัวต่อกัน แต่ละ feature แยกเป็น controller, service และ repository (และมีไฟล์ policy ใน feature ที่มีกฎทางธุรกิจ) แล้ว app.ts export type App ที่รวมทั้งหมดออกมา frontend ทั้งสองตัวใช้ type นี้ผ่าน Eden Treaty API ยังมี OpenAPI spec และหน้าเอกสารที่ลองเรียกได้ที่ /api/v1/docs และ error ตอบกลับเป็น { code, message } เสมอ ไม่มี stack trace หลุดออกไป
คำสั่งซื้อที่ขายเกินสต็อกไม่ได้
คำสั่งซื้อแบบเก็บเงินปลายทางสร้างใน PostgreSQL transaction เดียว ตรวจ quote สร้างคำสั่งซื้อ จองของจากล็อตที่เก่าที่สุด แล้วยืนยัน คำสั่งซื้อจดไว้ว่าของแต่ละชิ้นมาจากล็อตไหน ตอนยกเลิกจึงคืนกลับที่เดิมได้ถูกต้อง
คำสั่งซื้อผ่าน Stripe ยากกว่า เพราะลูกค้าออกจากเว็บไปจ่ายเงิน ระหว่างที่อยู่หน้า Stripe สต็อกจะถูกถือไว้ และคำสั่งซื้อจะถือว่าจ่ายแล้วก็ต่อเมื่อ webhook ที่ Stripe เซ็นมาแจ้งเท่านั้น การ redirect กลับมาที่ร้านไม่นับ
- 01ถือสต็อกจองของไว้ตอนเริ่ม Stripe session
- 02หมดเวลาsession หมดอายุ 34 นาทีหลังสร้างครั้งแรก
- 03ยืนยันมีแค่ webhook ที่ตรวจลายเซ็นแล้วเท่านั้นที่ทำให้คำสั่งซื้อเป็นจ่ายแล้ว
- 04กู้คืนถ้า server ไม่เคยได้ session ID ก็ส่ง request เดิมซ้ำเพื่อกู้คืน ได้นานสุด 23 ชั่วโมง
- 05ส่งต่อให้คนดูอะไรที่ไม่ชัดเจนจะเข้า manual review และยังถือสต็อกไว้
endpoint ของ webhook ตรวจ header Stripe-Signature กับ request body ดิบก่อนอ่านอะไรทั้งนั้น อีเมลยืนยันคำสั่งซื้อส่งผ่าน outbox คำสั่งซื้อ commit ก่อน แล้วค่อยส่งอีเมลทีหลังพร้อม retry อีเมลล่มจึงไม่มีทางทำให้คำสั่งซื้อถูกยกเลิก
ใบเสร็จ
ร้านวางแผนจดทะเบียน VAT ทุกคำสั่งซื้อที่จ่ายแล้วจึงได้ใบเสร็จรับเงิน/ใบกำกับภาษีอย่างย่อ ผลไม้สดได้รับยกเว้น VAT ตามมาตรา 81 แต่ของแปรรูปไม่ได้รับยกเว้น ใบเสร็จจึงแยกสองยอดนี้ออกจากกัน เลขที่ใบเสร็จเรียงต่อกันและไม่ใช้ซ้ำ ยกเลิกใบเสร็จแล้วเลขเดิมก็ยังอยู่ ไฟล์ PDF สร้างด้วย pdf-lib ส่งทางอีเมล และลูกค้า ลูกค้าที่ไม่ได้ login หรือพนักงานดาวน์โหลดได้
ค่าเริ่มต้นด้านความปลอดภัย
Secure เสมอrequest body จำกัดที่ 11 MB บัญชีลูกค้าและพนักงานอยู่ในตาราง user เดียวกันแต่ใช้ route ของกันและกันไม่ได้ session ของพนักงานเปิดหน้าบัญชีลูกค้าไม่ได้ และ session ของลูกค้าก็เปิดหลังบ้านไม่ได้
การ deploy
- 01ตรวจคุณภาพทุก pull request และทุกครั้งที่ push เข้า
mainจะรัน lint, type check, test ทุกชุด และ production build - 02ออก releaseGitHub Actions build image ของ api, migrate และ web แล้ว push ขึ้น GHCR
- 03deployDokploy ดึง image มารัน stack โดยมี Traefik จัดการ TLS
- 04migratejob migrate รันหนึ่งครั้งต่อการ deploy ก่อน API ตัวใหม่จะเริ่ม
หน้าร้าน หลังบ้าน และ API อยู่คนละ subdomain ของโดเมนเดียวกัน session cookie จึงยังเป็น same-site API มี endpoint /health และ /ready โดย /ready ตอบ 200 ก็ต่อเมื่อเชื่อมฐานข้อมูลได้และ schema เป็นเวอร์ชันล่าสุด ทั้ง stack ยังรันบนเครื่องได้ด้วย 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
ผลลัพธ์
Suannn ทำได้ครบทุกอย่างที่ร้านเล็ก ๆ ต้องใช้ในวันแรก ลูกค้าหาสินค้า รู้ว่ามาจากสวนไหน จ่ายเงิน และติดตามคำสั่งซื้อได้ ส่วนพนักงานแพ็ก ส่ง คืนเงิน เติมสต็อก และย้อนดูได้ว่าเกิดอะไรขึ้น
- API unit test
- 350
- API integration test กับ PostgreSQL
- 337
- test ของหน้าร้าน
- 207
- test ของหลังบ้าน
- 270
CI รัน test ทั้งสี่ชุดทุก pull request และทุกครั้งที่ push เข้า main โดย integration test รันกับ PostgreSQL 16 integration test จะไม่ยอมรันกับฐานข้อมูลที่ชื่อไม่ได้ลงท้ายด้วย _test จึงไม่มีทางล้างฐานข้อมูลจริงได้ การรันครบทุกชุดครั้งล่าสุดที่บันทึกไว้ใน repo (5 ตุลาคม 2026) ผ่าน 775 จาก 775 test รวม 4,887 assertion ส่วนการรันบนเครื่องครั้งล่าสุด (19 ตุลาคม) API unit test และ test ของหลังบ้านผ่านทั้งหมด และ test ของหน้าร้านผ่าน 206 จาก 207 ตัว ตัวที่ไม่ผ่านคือ test ของ checkout เรื่องการสลับเบอร์โทรเมื่อเปลี่ยนที่อยู่ออกใบเสร็จ
ผมทำโปรเจกต์นี้แบบเขียน spec ก่อน ทุก feature เริ่มจาก design ที่เขียนเป็นเอกสาร แล้วกลายเป็นแผนลงมือ และจบด้วยรายงานตรวจสอบ ใน repo มี spec 15 ฉบับ แผน 15 ฉบับ และรายงานตรวจสอบ 13 ฉบับ
สิ่งที่ได้เรียนรู้
รูปสินค้าและรูปสวนเป็นภาพประกอบ (สร้างด้วย AI หรือจาก Unsplash) และสวนเป็นโปรไฟล์ตัวอย่างที่ระบุไว้ชัดเจน จะเปลี่ยนเป็นสวนและรูปจริงเมื่อร้านเปิด
