เคยเจอบั๊กแบบนี้ไหมครับ... ลูกค้าโทรมาบอกว่าโดนเก็บเงินเกิน 1 สตางค์ แล้วทีมบัญชีก็ส่งมาถามว่า "ทำไมยอดมันไม่ตรง?" พอไปดูโค้ดก็เจอว่าเราเก็บราคาเป็น float หรือ decimal แล้วบวกลบกันไปมาจนค่ามันคลาดเคลื่อน นี่ไม่ใช่บั๊กเล็ก ๆ เลยครับ เพราะมันเกี่ยวกับ "เงิน" ของลูกค้าโดยตรง
ปัญหานี้เกิดจากแนวคิดพื้นฐานที่หลายคนมองข้าม คือ "เงิน" ไม่ใช่แค่ตัวเลข มันคือจำนวนเงินที่ผูกอยู่กับสกุลเงิน และมีกฎเกณฑ์ทางธุรกิจที่ตัวเลขล้วน ๆ ไม่สามารถบอกเราได้ เช่น เราไม่สามารถเอา 10 USD ไปบวกกับ 10 EUR ได้ตรง ๆ และเมื่อต้องหารเงินแบบไม่ลงตัว ใครจะเป็นคนรับเศษสตางค์ที่เหลือ?
ในบทความนี้ เราจะมาดูกันว่าทำไมการเก็บเงินด้วยตัวเลขธรรมดาถึงอันตราย แล้วจะแก้ปัญหาด้วยการสร้าง Value Object ของตัวเอง หรือจะใช้ไลบรารีสำเร็จรูปอย่าง brick/money ดีกว่ากัน รวมถึงแนวทางการออกแบบให้ทั้งสองแบบสามารถสลับใช้งานกันได้ในอนาคต เหมาะสำหรับคนที่กำลังพัฒนาเว็บแอปด้วย CodeIgniter 4 หรือ Laravel ครับ
🔍 ปัญหาที่ซ่อนอยู่ 3 อย่างของการเก็บเงินแบบ "ตัวเลขดิบ"
ก่อนจะไปถึงทางแก้ เรามาดูกันก่อนว่าทำไมการเก็บราคาเป็น float หรือแม้แต่ int/decimal แบบไม่มีการจัดการที่ดี ถึงนำไปสู่ปัญหาในระยะยาว
1. ปัญหา Floating Point (จุดทศนิยมไม่เที่ยง)
คอมพิวเตอร์เก็บเลขทศนิยมในรูปแบบ binary fraction ซึ่งตัวเลขอย่าง 0.10 ไม่สามารถแทนค่าได้แม่นยำ 100% ในระบบไบนารี เมื่อเราบวกราคาสินค้าหลายพันรายการเข้าด้วยกัน ความผิดพลาดเล็ก ๆ นี้จะสะสมจนกลายเป็นยอดเงินที่ผิดจริง ๆ
2. ปัญหาสกุลเงินที่หายไป
ตัวเลขเปล่า ๆ อย่าง 10 ไม่สามารถบอกได้ว่าหมายถึง 10 ดอลลาร์ หรือ 10 เยน ถ้าระบบของเรามีหลายสกุลเงิน แล้วดันเผลอเอายอด USD ไปบวกกับ JPY ตรง ๆ โดยไม่มีการตรวจสอบ ผลลัพธ์ที่ได้ก็จะเป็นตัวเลขที่ "รันได้" แต่ไม่มีความหมายทางธุรกิจเลย
3. ปัญหากฎการปัดเศษ (Rounding)
ลองสมมติว่าต้องหารส่วนลด 10.00 บาท ออกเป็น 3 รายการเท่า ๆ กัน จะได้ 3.333... บาทต่อรายการ คำถามคือใครจะรับเศษสตางค์ที่เหลือไป? ถ้าเราปล่อยให้แต่ละจุดในโค้ดปัดเศษกันเอง โดยไม่มีกฎกลางที่ชัดเจน ผลรวมที่ได้อาจไม่เท่ากับยอดตั้งต้น และนี่คือต้นเหตุของรายงานบัญชีที่ไม่ลงตัว
🛠️ แนวทางที่ 1: สร้าง Money Value Object เอง
แนวคิดหลักของ Value Object คือการสร้างคลาสที่ immutable (เปลี่ยนค่าไม่ได้หลังสร้าง) ปลอดภัยเรื่องสกุลเงิน และเก็บจำนวนเงินเป็นจำนวนเต็มในหน่วยย่อยที่สุด เช่น เก็บ 199.99 บาท เป็น 19999 สตางค์
เริ่มจากกำหนดสกุลเงินเป็น enum เพื่อให้ PHP ช่วยตรวจสอบความถูกต้องให้เราตั้งแต่ตอน compile:
สังเกตว่า minorUnitDecimals() สำคัญมาก เพราะ JPY ไม่มีทศนิยม ในขณะที่ USD, EUR, THB มี 2 ตำแหน่ง ถ้าเราใช้สูตรปัดเศษเดียวกันกับทุกสกุลเงิน ผลลัพธ์ของเยนจะผิดทันที
ต่อมาคือคลาส Money ที่เป็น Value Object หลัก:
จุดสำคัญของโค้ดนี้คือ assertSameCurrency() ถ้ามีการเอา Money สองสกุลที่ไม่ตรงกันมาบวกกัน ระบบจะ throw exception ทันที แทนที่จะให้ผลลัพธ์เป็นตัวเลขผิด ๆ ที่กลายเป็นปัญหาในอีก 3 สัปดาห์ข้างหน้า
การจัดการเรื่องปัดเศษด้วย Allocation
ส่วนที่ยากที่สุดของการจัดการเงินไม่ใช่การบวกลบ แต่คือการ "หาร" ลองดูตัวอย่างการแบ่งส่วนลด 1000 สตางค์ออกเป็น 3 ส่วน:
📦 แนวทางที่ 2: ใช้ brick/money ไลบรารีสำเร็จรูป
เมื่อระบบของเราซับซ้อนขึ้น เช่น ต้องรองรับหลายสกุลเงินจริงจัง ต้องแปลงสกุลเงินตามอัตราแลกเปลี่ยน หรือต้องใช้กฎการปัดเศษหลายรูปแบบ (round half up, round half even ฯลฯ) การเขียน Value Object เองอาจไม่คุ้มค่าอีกต่อไป ตรงนี้แหละที่ไลบรารี brick/money เข้ามาช่วยได้
ติดตั้งผ่าน Composer:
ตัวอย่างการใช้งานเบื้องต้น:
brick/money จัดการเรื่อง currency, rounding mode, และการแบ่งส่วน (allocation) ให้ครบแล้ว ทำให้เราไม่ต้องเขียนโค้ดเหล่านี้ซ้ำเอง
🚪 ทางออกที่ยั่งยืน: ซ่อน brick/money ไว้หลัง Port ของโดเมนตัวเอง
คำถามคือ ถ้าใช้ brick/money โดยตรงทั่วทั้งโปรเจกต์ แล้ววันหนึ่งอยากเปลี่ยนไลบรารี หรืออยากเพิ่มกฎทางธุรกิจเฉพาะของบริษัทเราเองล่ะ? คำตอบคือการสร้าง Port หรือ interface ของโดเมนเราเองขึ้นมาครอบไว้อีกชั้น
จากนั้นสร้าง implementation ที่ใช้ brick/money อยู่ภายใน:
ในโค้ด Controller หรือ Service ของ Laravel/CodeIgniter 4 เราจะเรียกผ่าน MoneyServiceInterface เสมอ ไม่เรียก BrickMoney ตรง ๆ:
brick/money ไปใช้ไลบรารีอื่น หรือกลับมาเขียน Value Object เองทั้งหมด เราแค่เขียน implementation ใหม่ของ MoneyServiceInterface โดยไม่ต้องแก้โค้ดส่วน business logic เลยแม้แต่บรรทัดเดียว นี่คือหลักการเดียวกับ Hexagonal Architecture ที่ว่า "กฎการปัดเศษอยู่ในโดเมนของเรา ส่วนไลบรารีเป็นแค่ตัวที่สลับเปลี่ยนได้"📌 สรุป
การจัดการ "เงิน" ในแอปพลิเคชัน PHP ไม่ใช่เรื่องของการเลือก data type ให้ถูกต้องเพียงอย่างเดียว แต่เป็นเรื่องของการออกแบบให้กฎทางธุรกิจ เช่น การปัดเศษ การแบ่งส่วน และการตรวจสอบสกุลเงิน อยู่ในที่เดียวที่ชัดเจนและทดสอบได้ ไม่ว่าจะเลือกเขียน Value Object เอง หรือใช้ brick/money สิ่งสำคัญคือต้องห่อหุ้มมันไว้หลัง interface ของโดเมนตัวเอง เพื่อให้ระบบยืดหยุ่นและรองรับการเปลี่ยนแปลงในอนาคตได้โดยไม่ต้องรื้อทั้งโปรเจกต์
สำหรับโปรเจกต์เล็ก ๆ ที่มีสกุลเงินเดียวและกฎไม่ซับซ้อน การเขียน Value Object เองก็เพียงพอและช่วยให้โค้ดเบาขึ้น แต่ถ้าโปรเจกต์เริ่มโตขึ้นและต้องรองรับหลายสกุลเงินหรือกฎการปัดเศษที่ซับซ้อน brick/money พร้อม Port ของตัวเองคือคำตอบที่คุ้มค่ากว่าในระยะยาว
