← โปรเจกต์ทั้งหมด

E-commerce · 2026

Suannn

ร้านผลไม้จากสวนที่สร้างตามมาตรฐาน production ครบทั้งระบบ ทั้งหน้าร้านของลูกค้า ระบบหลังบ้านของพนักงาน และ API ที่อยู่เบื้องหลัง พร้อมการชำระเงินจริง สต็อกแบบ FIFO ใบเสร็จ และความปลอดภัยของบัญชีพนักงาน

TypeScriptBunElysiaReactPostgreSQLStripeCloudflare R2Docker

โจทย์

ผมทำ e-commerce ให้หลายบริษัทมาแล้ว จนกลายเป็นงานที่ถนัดที่สุด ถึงจุดหนึ่งก็อยากขายของเองบ้าง คือผลไม้ไทยสดและผลิตภัณฑ์แปรรูป ส่งตรงจากสวนที่ปลูก

ตอนนี้ยังไม่มีเวลาเปิดธุรกิจจริง แต่ก็ไม่อยากให้ร้านเป็นแค่ mockup ที่ดูเหมือนเสร็จแล้ว เลยสร้าง Suannn ให้พร้อมเหมือนจะเปิดร้านพรุ่งนี้ มีครบทุกส่วนที่ร้านจริงต้องใช้ ตั้งแต่หน้าที่ลูกค้าเห็น ไปจนถึงหน้าจอของคนแพ็กของ

ระหว่างที่ยังไม่เปิดที่ ecommerce.naay.cc โปรเจกต์นี้คือ showcase ทุกอย่างในหน้านี้ใช้งานได้จริง ภาพหน้าจอทั้งหมดถ่ายจากแอปที่รันอยู่ ไม่ใช่จากไฟล์ดีไซน์

หน้าแรกของ Suannn มีภาพ hero รายการสินค้า และแถบโปรโมชัน
กำลังตรวจสอบเว็บไซต์จริง…เปิดในแท็บใหม่ ↗

สิ่งที่ทำ

Suannn คือแอปสามตัวใน Bun workspace เดียวกัน ใช้ design system ชุดเดียวกัน และใช้ type ของ API ชุดเดียวกัน

หน้าร้านส่วนที่ลูกค้าใช้ ดูสินค้าสดและแปรรูป อ่านเรื่องราวของสวน ใส่ตะกร้า จ่ายเงินด้วยบัตรหรือพร้อมเพย์ผ่าน Stripe หรือเก็บเงินปลายทาง และจัดการบัญชี ที่อยู่ คำสั่งซื้อ และใบเสร็จของตัวเอง
หลังบ้านส่วนที่พนักงานใช้ dashboard ยอดขาย คำสั่งซื้อตั้งแต่จ่ายเงินจนส่งถึงมือ สินค้าและแกลเลอรีรูป สต็อกแบบแยกล็อต ข้อมูลสวน คลังภาพกลาง พนักงานและสิทธิ์ ตั้งค่าร้าน และบันทึกกิจกรรม
APIElysia API ตัวเดียวบน Bun ที่อยู่หลังทั้งสองแอป ใช้ PostgreSQL ผ่าน Drizzle, Better Auth สำหรับลูกค้าและพนักงาน, Stripe, อีเมลผ่าน Resend, ใบเสร็จ PDF และ Cloudflare R2 สำหรับรูปภาพ
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 ที่คนไทยคุ้นเคย

  1. 01ข้อมูลติดต่ออีเมลสำหรับรับรายละเอียดคำสั่งซื้อ
  2. 02ที่อยู่ออกใบเสร็จเลือกจากที่อยู่ที่บันทึกไว้หรือกรอกใหม่ และแยกที่อยู่จัดส่งได้ถ้าต้องการ
  3. 03ชำระเงินบัตรหรือพร้อมเพย์ผ่าน Stripe หรือเก็บเงินปลายทาง
  4. 04ยืนยันAPI ตรวจราคา สต็อก และค่าส่งอีกรอบจาก quote ที่เซ็นไว้

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

หน้า Stripe Checkout ในโหมด sandbox แสดงรายการสินค้าเป็นเงินบาท

หน้ายืนยันคำสั่งซื้อหลัง Stripe ยืนยันการชำระเงินแล้ว

บัญชีลูกค้า

ประวัติคำสั่งซื้อในบัญชีลูกค้า

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

รายละเอียดคำสั่งซื้อของลูกค้า มีสถานะการชำระเงิน การจัดส่ง และปุ่มดาวน์โหลดใบเสร็จ

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

บนมือถือ

หน้าร้านบนมือถือ: หน้าแรก หน้าสินค้า และหน้าชำระเงิน

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

Dashboard

Dashboard หลังบ้าน แสดงยอดขาย จำนวนคำสั่งซื้อ ยอดเฉลี่ยต่อคำสั่งซื้อ และคำสั่งซื้อที่รอจัดส่ง

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

คำสั่งซื้อ

รายการคำสั่งซื้อในหลังบ้าน มีคอลัมน์สถานะและการชำระเงิน

รายละเอียดคำสั่งซื้อในหลังบ้าน มีรายการสินค้า ที่อยู่ การชำระเงิน การจัดส่ง และประวัติ

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

  • ยกเลิกคำสั่งซื้อก่อนจัดส่งได้ และของจะคืนกลับเข้าล็อตเดิมที่ถูกหยิบไป
  • บันทึกการรับเงินปลายทางได้ โดยยอดต้องตรงกับยอดคำสั่งซื้อพอดี
  • คืนเงินคำสั่งซื้อที่จ่ายผ่าน Stripe ได้เต็มจำนวน แต่ต้องยกเลิกคำสั่งซื้อก่อน การคืนเงินจึงไม่มีทางเกิดขึ้นโดยไม่ตั้งใจ
  • ส่งลิงก์คำสั่งซื้อให้ลูกค้าที่ไม่ได้ login ใหม่ หรือเพิกถอนลิงก์เดิมได้ โดยพนักงานไม่เคยเห็น token เลย

ทุกคำสั่งเหล่านี้ต้องมี Idempotency-Key ถ้า response หายระหว่างทางแล้วพนักงานกดซ้ำ คำสั่งเดิมจะไม่ถูกทำซ้ำสองรอบ

สินค้าและคลังภาพ

รายการสินค้าในหลังบ้าน

หน้าแก้ไขสินค้า มีแกลเลอรี ตัวเลือกสินค้า และการผูกกับสวน

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

หน้าคลังภาพในหลังบ้าน

รูปทั้งหมดอยู่ในคลังภาพกลางที่เดียว แทนการแปะ URL

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

URL ของรูปสร้างตอนอ่าน ไม่ได้เก็บไว้ในฐานข้อมูล ถ้าย้ายโดเมนรูปก็แค่แก้ config รูปที่ถูกใช้อยู่ลบไม่ได้ ฐานข้อมูลเป็นตัวบังคับ ไม่ใช่แค่ UI และ alt text ผูกกับตัวรูป แก้ครั้งเดียวก็เปลี่ยนทุกที่ที่ใช้รูปนั้น

สต็อก

ภาพรวมสต็อกในหลังบ้าน

สต็อกของตัวเลือกสินค้าหนึ่งตัว แยกเป็นล็อตพร้อมวันหมดอายุ

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

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

ประวัติการเคลื่อนไหวของสต็อก

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

รายการสวนในหลังบ้าน

รายละเอียดลูกค้าในหลังบ้าน มีคำสั่งซื้อและที่อยู่

รายชื่อพนักงานและคำเชิญ

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

Ownerทำได้ทุกอย่าง รวมถึงจัดการ owner คนอื่นและการตั้งค่าระดับร้าน
Adminทำได้ทุกอย่าง ยกเว้นการจัดการ owner
Catalog managerสินค้า รูปภาพ สวน และสต็อก
Fulfillmentสต็อก การแพ็ก การจัดส่ง และการรับเงินปลายทาง
Supportคำสั่งซื้อ การยกเลิก ลิงก์ของลูกค้าที่ไม่ได้ login และข้อมูลลูกค้า

แต่ละ role คือชุดสิทธิ์ที่กำหนดตายตัว (เช่น order:refund หรือ inventory:adjust) และ API ตรวจสิทธิ์ทุก request หลังบ้านซ่อนสิ่งที่ role นั้นทำไม่ได้ แต่ตัวที่บังคับจริงคือ API

ตั้งค่าการขาย มีค่าจัดส่งและสวิตช์เปิดปิด checkout

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 กลับมาที่ร้านไม่นับ

  1. 01ถือสต็อกจองของไว้ตอนเริ่ม Stripe session
  2. 02หมดเวลาsession หมดอายุ 34 นาทีหลังสร้างครั้งแรก
  3. 03ยืนยันมีแค่ webhook ที่ตรวจลายเซ็นแล้วเท่านั้นที่ทำให้คำสั่งซื้อเป็นจ่ายแล้ว
  4. 04กู้คืนถ้า server ไม่เคยได้ session ID ก็ส่ง request เดิมซ้ำเพื่อกู้คืน ได้นานสุด 23 ชั่วโมง
  5. 05ส่งต่อให้คนดูอะไรที่ไม่ชัดเจนจะเข้า manual review และยังถือสต็อกไว้

endpoint ของ webhook ตรวจ header Stripe-Signature กับ request body ดิบก่อนอ่านอะไรทั้งนั้น อีเมลยืนยันคำสั่งซื้อส่งผ่าน outbox คำสั่งซื้อ commit ก่อน แล้วค่อยส่งอีเมลทีหลังพร้อม retry อีเมลล่มจึงไม่มีทางทำให้คำสั่งซื้อถูกยกเลิก

ใบเสร็จ

ร้านวางแผนจดทะเบียน VAT ทุกคำสั่งซื้อที่จ่ายแล้วจึงได้ใบเสร็จรับเงิน/ใบกำกับภาษีอย่างย่อ ผลไม้สดได้รับยกเว้น VAT ตามมาตรา 81 แต่ของแปรรูปไม่ได้รับยกเว้น ใบเสร็จจึงแยกสองยอดนี้ออกจากกัน เลขที่ใบเสร็จเรียงต่อกันและไม่ใช้ซ้ำ ยกเลิกใบเสร็จแล้วเลขเดิมก็ยังอยู่ ไฟล์ PDF สร้างด้วย pdf-lib ส่งทางอีเมล และลูกค้า ลูกค้าที่ไม่ได้ login หรือพนักงานดาวน์โหลดได้

ค่าเริ่มต้นด้านความปลอดภัย

production ไม่ผ่านก็ไม่เปิดAPI จะไม่ start ถ้าไม่มีรายการ origin ที่อนุญาตแบบตรงตัวและ header ของ proxy ที่เชื่อถือได้ URL ของ auth ต้องเป็น HTTPS และ cookie เป็น Secure เสมอ
เขียนข้อมูลได้จาก origin ที่ตรงเท่านั้นการเขียนจาก browser ทุกครั้งต้องมาจาก origin ของหน้าร้านหรือหลังบ้านแบบตรงตัว และเป็น JSON (ยกเว้นการอัปโหลดรูปที่ใช้ multipart form data) และ CORS ที่ส่ง credential ได้จำกัดแค่สอง origin นี้
rate limit เก็บในฐานข้อมูลสมัครสมาชิก login เปลี่ยนอีเมล และ request ที่เขียนข้อมูลถูกจำกัดอัตราด้วยตัวนับใน PostgreSQL ขีดจำกัดจึงยังอยู่แม้ restart หรือมีหลาย instance
เปิดเฉพาะ route ที่อนุญาตBetter Auth เปิดได้แค่ route ที่ล็อกไว้ในรายการ ส่วน endpoint admin และการลงทะเบียน MFA แบบดิบยังถูกปิดอยู่

request body จำกัดที่ 11 MB บัญชีลูกค้าและพนักงานอยู่ในตาราง user เดียวกันแต่ใช้ route ของกันและกันไม่ได้ session ของพนักงานเปิดหน้าบัญชีลูกค้าไม่ได้ และ session ของลูกค้าก็เปิดหลังบ้านไม่ได้

การ deploy

  1. 01ตรวจคุณภาพทุก pull request และทุกครั้งที่ push เข้า main จะรัน lint, type check, test ทุกชุด และ production build
  2. 02ออก releaseGitHub Actions build image ของ api, migrate และ web แล้ว push ขึ้น GHCR
  3. 03deployDokploy ดึง image มารัน stack โดยมี Traefik จัดการ TLS
  4. 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 ฉบับ

สิ่งที่ได้เรียนรู้

การชำระเงินคือ state machineงาน Stripe ส่วนใหญ่ไม่ใช่การเรียก Stripe แต่คือการตัดสินใจว่าร้านควรเชื่ออะไรเมื่อ redirect, webhook และ timeout บอกไม่ตรงกัน และทำให้คำตอบคือ "ถือสต็อกไว้แล้วให้คนตัดสิน" แทนการเดา
วางกฎไว้ในที่ที่ข้ามไม่ได้เก็บเงินปลายทางเฉพาะลูกค้าที่ยืนยันอีเมล คืนเงินได้หลังยกเลิกเท่านั้น เผยแพร่สินค้าไม่ได้ถ้าไม่มี alt text ทุกกฎอยู่ใน API หรือฐานข้อมูล ไม่ได้อยู่แค่ในปุ่มที่หน้าเว็บซ่อน
ตรวจงานตัวเองการเขียนรายงานที่จับคู่ทุก route ของ API กับหน้าจอที่เรียกใช้ ทำให้เจอช่องโหว่จริง ทั้ง route ที่ยังไม่มี UI และหน้าที่ยังอ่านข้อมูลตัวอย่าง ก่อนที่ลูกค้าจะเจอเอง

รูปสินค้าและรูปสวนเป็นภาพประกอบ (สร้างด้วย AI หรือจาก Unsplash) และสวนเป็นโปรไฟล์ตัวอย่างที่ระบุไว้ชัดเจน จะเปลี่ยนเป็นสวนและรูปจริงเมื่อร้านเปิด