เมื่อสัปดาห์ก่อนผมเปิดดู Brevo (ระบบส่งเมลที่ Newton ใช้อยู่) แล้วเจอเรื่องที่ทำเอาอึ้งไปแป๊บ — มีลูกค้าคนนึงที่ผมรู้แน่ๆ ว่าจ่ายเงินไปแล้ว แต่ชื่อเขายังโผล่อยู่ในลิสต์ "Trial Reminder"
คือเขายังโดนส่งเมล "Trial เหลือ 2 วันสุดท้าย!" อยู่ ทั้งที่บัตรตัดผ่านมาตั้งสัปดาห์ที่แล้ว 555
มันไม่ใช่เรื่องใหญ่ในเชิงตัวเลข — ลูกค้าคนนั้นไม่ได้บ่นอะไร แต่ในแง่ความรู้สึก ผมรับไม่ได้ครับ ผมคิดเลยว่า "ถ้าผมเป็นเขา ผมต้องคิดว่า Newton ระบบมั่วแน่ๆ — จ่ายเงินไปแล้วยังโดน sales pitch อยู่"
เลยเปิดแชทคุยกับทิม (AI Agent ของผม)ว่า "ระบบของเรามีปัญหาแหละ มาดูกันว่าทำไมถึงเกิดเคสแบบนี้ได้"
Root cause: webhook หาย 1 ตัวเดียว = list mismatch ตลอดกาล
ทิมไล่ดู logic เก่าให้ผมเห็นภาพ — ของเดิมเป็น event-driven ครับ คือทุกครั้งที่มี event ใน Stripe ระบบจะยิง action ไปที่ Brevo ทันที
- ลูกค้าเริ่ม trial → call
brevo_add_trial_contact()→ ใส่เข้าลิสต์ Trial - ลูกค้าจ่ายเงินครั้งแรก → call
brevo_move_to_paid()→ ย้ายจาก Trial ไป Paid - ลูกค้ายกเลิก → call
brevo_move_to_newsletter()→ ย้ายไป Newsletter
ฟังดูเรียบร้อย แต่ปัญหาคือมันสมมติว่า "ทุก event ที่ Stripe ยิงมา ระบบจะรับครบเสมอ"
ในความจริง webhook มัน drop ได้ครับ — บางที server เรา restart พอดีตอน Stripe ยิงเข้ามา บางที network glitch บางที endpoint ตอบช้าเกิน 5 วินาที Stripe ก็ retry แต่ retry แล้ว fail ก็เลิก
พอมี 1 event ที่หาย เช่น invoice.paid ของลูกค้าคนนี้ — ระบบไม่รู้ว่าเขาจ่ายแล้ว เลยไม่ย้ายเขาออกจากลิสต์ Trial ผลคือเขาติดอยู่ในนั้นถาวร จนกว่าจะมีคนสังเกตเห็นแล้วลากเขาออกด้วยมือ
ทิมสรุปประโยคเดียว: "Event-driven design มันจำสิ่งที่เกิดขึ้น — ถ้าเกิดเหตุการณ์แล้วระบบไม่ได้รับ มันก็ไม่มีทางรู้ว่ามันเกิดขึ้น"
ทิมเสนอ rewrite เป็น state-driven แทน
แนวคิดนี้ทิมอธิบายให้ผมเข้าใจง่ายๆ ว่า — แทนที่จะถามว่า "ตะกี๊เกิดอะไรขึ้น?" ให้ถามว่า "ตอนนี้ลูกค้าควรอยู่ลิสต์ไหน ตามข้อมูลในฐานข้อมูล?"
กฎเดียว ที่ derive ได้จากตาราง subscriptions:
is_trial=0+ status = active หรือ cancelling → ลิสต์ PAIDis_trial=1+ status = trialing หรือ past_due → ลิสต์ TRIAL- นอกจากนั้นทั้งหมด (ไม่มี sub, ยกเลิกไปแล้ว, ทิ้ง trial) → ลิสต์ NEWSLETTER
แล้วทิมก็เขียนฟังก์ชั่นใหม่ตัวเดียวชื่อ brevo_sync_customer_list(customer_id) — รับ id ลูกค้าเข้ามา อ่าน DB ว่าเขาควรอยู่ลิสต์ไหน แล้ว call Brevo API ใส่เข้าลิสต์ที่ถูก + ลบออกจากอีก 2 ลิสต์ที่เหลือ
ของเก่าที่เคยมี เช่น brevo_add_trial_contact, brevo_move_to_paid, brevo_move_to_newsletter — ทิมไม่ลบทิ้งครับ แต่เปลี่ยนให้กลายเป็น "shim" บางๆ — ทุกตัวสุดท้าย call brevo_sync_customer_list หมด
ผลคือทุก code path ในระบบ ไม่ว่าจะเรียกฟังก์ชั่นชื่ออะไร สุดท้ายมันก็ derive จาก state จริงใน DB เสมอ ไม่มีทางหลุด
เพิ่ม cron self-heal ทุกคืน — webhook ดรอปยังไงก็จับได้
แต่ทิมยังไม่หยุดแค่นั้น — มันบอกว่า "ถ้าก่อนหน้านี้มีลูกค้าหลายคนติดผิดลิสต์มาเงียบๆ — code ใหม่จะแก้เคสใหม่ที่จะเข้ามา แต่จะ ไม่ ไปแก้เคสเก่าที่ลิสต์ผิดไปแล้ว เพราะไม่มีเหตุการณ์ใหม่มา trigger sync"
เลยเสนอเขียนสคริปต์ตัวสำรองชื่อ brevo_reconcile.py — ทำงานง่ายมาก:
- อ่านลูกค้าทุกคนจาก DB
- วน loop เรียก
brevo_sync_customer_listให้ทุกคน - ถ้ามี fail ส่ง Telegram บอกผม
แล้วใส่ cron วันละครั้ง ตี 4 (ฝั่ง TH) กับตี 4:23 (ฝั่ง EN) ขณะระบบเงียบ
ผลคือ — ถ้าวันไหน webhook ดรอปไม่ครบ ลูกค้าติดผิดลิสต์ — เช้าวันถัดไปไม่เกิน 5:00 น. cron จะมาเช็คให้ทุกคน sync state ใน DB กับ state ใน Brevo ตรงกัน 100%
ลูกค้าผิดลิสต์อย่างเก่งก็ไม่เกิน 24 ชั่วโมง — และส่วนใหญ่ก็คือไม่เกินไม่กี่นาทีอยู่แล้ว เพราะ webhook ส่วนใหญ่ก็มาตามปกติ
3 บั๊กเก่าหายไปด้วยใน rewrite เดียว
ที่ผมไม่คาดมาก่อนคือ พอเปลี่ยนเป็น state-driven แล้ว ทิมเล่าให้ฟังว่ามีบั๊กเก่าที่ผมยังไม่รู้ตัว 3 ตัวหายไปด้วย:
บั๊ก 1: ฟังก์ชั่นส่งเมล "trial abandoned" (คนเริ่มกรอกเมลแต่ไม่ได้ใส่บัตร) เคย add คนพวกนั้นเข้าลิสต์ Trial — ทั้งที่เขาไม่มี subscription จริงๆ ผลคือเขาโดนเมล "trial เหลือ 2 วัน" ทั้งที่ไม่เคย trial เลย ระบบใหม่เห็นว่า "ไม่มี subscription" ก็ route ไป Newsletter แทน ถูกต้องแล้ว
บั๊ก 2: ตอน delete_server เคยมี DELETE FROM customers ที่ fail เพราะ FOREIGN KEY constraint (ตาราง subscriptions ยังอ้างอยู่) → ทำให้ทั้งฟังก์ชั่นล้ม → brevo_move_to_newsletter ที่อยู่บรรทัดท้ายไม่ได้ทำงาน → ลูกค้ายกเลิกแล้วยังอยู่ในลิสต์ Paid ทิมเอาบรรทัด DELETE ออก เก็บ row ลูกค้าไว้ตลอด (เผื่อกลับมาใช้อีก) แล้ว reconcile ก็ตามเก็บกวาดให้
บั๊ก 3: เคสที่ผมเริ่มเรื่องด้วย — webhook invoice.paid หายเงียบๆ — เคสนี้แค่รอให้ reconcile รันคืนถัดไป ก็แก้ตัวเองโดยไม่ต้องไปแก้ code อะไรเลย (สำหรับเคสที่ webhook event ขาดไปจริงทั้ง endpoint — ผมเขียนไว้แยกในโพสต์ใหม่ ทิมเจอ root cause แล้วแก้เสร็จในชั่วโมงเดียวครับ)
บทเรียนที่ผมได้
ผมไม่ใช่ dev เก่า ผมเรียนเรื่องพวกนี้จากการคุยกับทิมไปทำงานไป จากเรื่องนี้ผมจดไว้ 2 ข้อ:
1. webhook คืออะไรที่ "ส่วนใหญ่จะมา" — ไม่ใช่ "มาเสมอ" — ใครออกแบบระบบที่พึ่ง webhook 100% ในที่ที่ต้อง strict เรื่อง state — เตรียมรับเคสประหลาดได้เลย
2. State-driven design + reconcile cron = ระบบ self-healing — แม้จะเขียน code ผิดในอนาคต หรือแก้ DB ตรงๆ ผ่าน SQL ระบบก็จะกลับมา consistent เองภายใน 24 ชม. ผมว่านี่คือ design pattern ที่ทุก SaaS เล็กๆ ควรใช้ เพราะเรามีคนน้อย ไม่มีเวลามานั่งไล่จับเคสประหลาดทุกวัน
เคสนี้คล้ายๆ กับเรื่อง grace period 24 ชม. ตอนบัตรลูกค้าเด้งที่ผมเขียนไว้ก่อนหน้านี้ — หลักการเดียวกัน คือ "อย่าไว้ใจ event เดี่ยวๆ ให้คิดเป็น state แล้วมี process ตรวจซ้ำ"
เรื่องนี้ทำคนเดียวไม่ได้แน่ๆ
ผมนั่งคิดดูว่าถ้าผมไม่มีทิม ผมจะแก้เรื่องนี้ยังไง
1. ต้องเปิด Brevo manually ลากลูกค้าออกจากลิสต์ผิดเอง → แก้แค่เคสเดียว แต่บั๊กยังอยู่
2. หรือไปจ้างคนเขียน sync script — รอ 1-2 อาทิตย์ จ่ายหลายพัน
3. หรือนั่งศึกษา design pattern เอง อ่าน docs Brevo API เอง เขียน Python เอง deploy เอง — เผลอๆ เป็นเดือน
แต่กับทิมก็คือ — บ่ายเดียว rewrite ระบบเก่า เขียน reconcile script เพิ่ม ตั้ง cron ทดสอบบนทั้ง TH+EN กด commit + push deploy ไป production ทั้ง 2 ฝั่ง ส่ง Telegram แจ้งผมเสร็จ — ผมแค่นั่งดู
ที่สำคัญที่สุดคือทิมรู้บริบทธุรกิจผมเป็นอย่างดี — ทิมรู้อยู่แล้วว่า Newton มี 2 ฝั่ง (TH+EN), รู้ว่า DB schema เป็นยังไง, รู้ว่า Brevo มีลิสต์ไหนบ้าง (TH = 5,7 / EN = 6,8 / shared = 3), รู้ว่า cron ตี 4 ฝั่งไหน — เพราะทิมอยู่บนเซิร์ฟเวอร์ของผมเองมาตั้งแต่ต้น มันก็ไม่ต้องอธิบายอะไรซ้ำ
คำถามที่พบบ่อย
event-driven กับ state-driven design ต่างกันยังไง?
event-driven ระบบตัดสินใจตาม "เหตุการณ์ที่เพิ่งเกิดขึ้น" เช่น ลูกค้าจ่ายเงิน → ย้ายลิสต์ ครับ ส่วน state-driven ตัดสินใจตาม "สถานะปัจจุบันใน database" เช่น อ่าน DB ว่าลูกค้าควรอยู่ลิสต์ไหนตอนนี้ → sync ให้ตรง ข้อดีคือถ้า event หายก็ไม่มีผล เพราะ reconcile cron มาเก็บกวาดให้ทุกคืนอยู่แล้ว
webhook drop คืออะไร และจัดการยังไง?
webhook drop คือเหตุการณ์ที่ Stripe ยิง event เข้ามาแต่ server เราไม่ได้รับ ไม่ว่าจะเพราะ server กำลัง restart, network glitch, หรือ endpoint ตอบช้าเกินครับ วิธีรับมือที่ดีคือไม่ไว้ใจ event เดี่ยวๆ แต่มี reconcile process ที่ตรวจสอบ state ทั้งหมดเป็นประจำ เพื่อ self-heal ความผิดพลาดที่เกิดขึ้น
reconcile cron script ทำงานยังไง?
มันทำงานง่ายมากครับ — อ่านลูกค้าทุกคนจาก DB, วน loop เรียก sync function ให้ทุกคน, ถ้า fail ส่ง notification บอกเรา แล้วตั้งให้รันทุกคืน ผลคือถ้าวันไหน webhook หายหรือมี state ผิดพลาด เช้าวันถัดไปก็จะ sync ตัวเองกลับมาถูกต้องโดยไม่ต้องมีใครมานั่งไล่แก้เอง
ลูกค้าที่จ่ายเงินแล้วยังได้รับเมล "trial หมดแล้ว" รู้สึกยังไง?
แม้ลูกค้าอาจไม่ได้บ่น แต่มันส่งสัญญาณผิดๆ ว่าระบบเราไม่รู้ว่าเขาจ่ายเงินไปแล้ว ทำให้รู้สึกว่าบริษัทไม่ได้ใส่ใจหรือระบบมั่วครับ ความรู้สึกแบบนี้สะสมเป็น distrust และเพิ่มโอกาส churn ในระยะยาวแม้ตัว product จะดี
อยากมี AI ที่แก้ระบบหลังบ้านธุรกิจให้ตัวเองแบบนี้ไหม?
ถ้าคุณเป็นเจ้าของธุรกิจออนไลน์ที่ใช้ Stripe + Brevo + อะไรก็ตาม — แล้วเคยเจอเคส webhook ดรอป, list mismatch, สถานะไม่ตรงกันระหว่างหลายระบบ — คุณจะรู้ว่ามันน่ารำคาญแค่ไหนที่ต้องมานั่งไล่จับเอง
ลอง Newton ดูได้ครับ เซิร์ฟเวอร์ส่วนตัว + AI Agent ที่ทำงานให้ 24 ชั่วโมง รู้บริบทธุรกิจคุณ เข้าถึง database ได้ ssh server ได้ deploy code เองได้ สร้างเครื่องมือเฉพาะธุรกิจคุณได้โดยไม่ต้องจ่าย SaaS รายเดือน พร้อมใช้ใน 10 นาที
เผื่อวันหนึ่งคุณก็เจอเคสทำนองว่า "ทำไมลูกค้าจ่ายเงินแล้วยังโดนเมล trial อยู่" 555 — จะได้มี AI ของคุณเองคอย rewrite ระบบให้ตอนตี 2 ครับ
— ปอนด์
