การปัดสกุลเงินในแอปการเงิน: เก็บเงินอย่างปลอดภัย
การปัดสกุลเงินในแอปการเงินอาจทำให้เกิดความต่างหนึ่งเซนต์ เรียนรู้การเก็บเป็นหน่วยย่อยแบบจำนวนเต็ม กฎการปัดภาษี และการแสดงผลที่สอดคล้องทั้งเว็บและมือถือ

ทำไมจึงเกิดบั๊กต่างกันหนึ่งเซนต์\n\nบั๊กต่างกันหนึ่งเซนต์เป็นข้อผิดพลาดที่ผู้ใช้สังเกตเห็นทันที ยอดรวมในหน้ารายการสินค้าแสดง $19.99 แต่ที่หน้าชำระเงินกลายเป็น $20.00 คืนเงิน $14.38 กลายเป็น $14.37 ใบแจ้งหนี้มีบรรทัด "ภาษี: $1.45" แต่ยอดรวมดูเหมือนได้คำนวณภาษีไปคนละแบบ\n\nปัญหาเหล่านี้มักมาจากความแตกต่างเล็ก ๆ ของการปัดเศษที่สะสม เงินไม่ใช่แค่ "ตัวเลข" มันมีกรอบกติกา: จำนวนทศนิยมที่สกุลใช้, เวลาในการปัด, และว่าปัดต่อบรรทัดหรือปัดจากยอดรวมสุดท้าย ถ้าแอปคุณเลือกต่างกันที่จุดใดจุดหนึ่ง เซนต์เดียวก็อาจปรากฏหรือหายไปได้\n\nปัญหาเหล่านี้ยังกระจายตัวและเกิดขึ้นเป็นบางครั้ง ทำให้ยากต่อการดีบั๊ก อินพุตชุดเดียวกันอาจให้ผลต่างกันขึ้นกับการตั้งค่าภูมิภาคของอุปกรณ์ ลำดับการคำนวณ หรือการแปลงประเภทข้อมูล\n\nสาเหตุทั่วไปได้แก่ การคำนวณด้วย float และปัด "ตอนท้าย" (แต่คำว่า "ตอนท้าย" อาจไม่เหมือนกันในทุกที่), คำนวณภาษีต่อไอเท็มที่หน้าหนึ่งแต่คำนวณจากยอดรวมในอีกหน้าหนึ่ง, ผสมสกุลเงินหรืออัตราแลกเปลี่ยนแล้วปัดไม่สอดคล้องกัน, หรือฟอร์แมตเพื่อแสดงผลแล้วเผลอแปลงกลับเป็นตัวเลข\n\nผลกระทบร้ายแรงที่สุดคือจุดที่ความเชื่อใจเปราะบางและจำนวนเงินถูกตรวจสอบ เช่น ยอดชำระ, การคืนเงิน, ใบแจ้งหนี้, การสมัครสมาชิก, ทิป, การจ่ายเงิน และรายงานค่าใช้จ่าย ความต่างหนึ่งเซนต์อาจทำให้การชำระล้มเหลว เกิดปัญหาในการไกล่เกลี่ยบัญชี และตั๋วซัพพอร์ตที่บอกว่า "แอปของคุณขโมยเงินฉัน"\n\nเป้าหมายชัดเจน: อินพุตชุดเดียวกันควรให้ผลเป็นเซนต์เดียวกันในทุกที่ รายการเดียวกัน ภาษีเดียวกัน ส่วนลดและกฎการปัดเดียวกัน ไม่ว่าจอ ไหน อุปกรณ์ ภาษา หรือไฟล์ส่งออกใดๆ\n\nตัวอย่าง: ถ้าสินค้าสองชิ้นราคาชิ้นละ $9.99 มีภาษี 7.25% ให้ตัดสินใจว่าปัดภาษีต่อไอเท็มหรือปัดจากยอดรวม แล้วทำแบบเดียวกันใน backend, เว็บ UI และแอปมือถือ ความสอดคล้องจะป้องกันคำถามว่า "ทำไมที่นี่ต่างกัน?"\n\n## ทำไม float ถึงเสี่ยงสำหรับเงิน\n\nภาษาการเขียนโปรแกรมส่วนใหญ่เก็บค่า float และ double ในรูปแบบไบนารี ราคาทศนิยมจำนวนมากไม่สามารถแทนค่าได้อย่างแม่นยำในไบนารี ดังนั้นค่าที่คุณคิดว่าเก็บอาจจะสูงหรือต่ำกว่าจริงเล็กน้อย\n\nตัวอย่างคลาสสิกคือ 0.1 + 0.2 ซึ่งในหลายระบบกลายเป็น 0.30000000000000004 นั่นดูไม่เป็นไร แต่ตรรกะการเงินมักเป็นสายการคำนวณ: ราคาสินค้า ส่วนลด ภาษี ค่าธรรมเนียม แล้วค่อยปัดขั้นสุดท้าย ความคลาดเล็ก ๆ อาจพลิกการตัดสินใจปัดและสร้างความต่างหนึ่งเซนต์\n\nอาการที่คนมักพบเมื่อการปัดเงินผิดพลาด:\n\n- ค่าที่ดูเป็น 9.989999 หรือ 19.9000001 ปรากฏในล็อกหรือการตอบ API\n- ยอดรวมเบี่ยงหลังจากเพิ่มสินค้าหลายชิ้น แม้ว่าสินค้าแต่ละชิ้นจะดูถูกต้อง\n- ยอดคืนเงินไม่ตรงกับยอดชาร์จเดิมต่างกัน $0.01\n\nการฟอร์แมตกมักปิดบังปัญหา ถ้าคุณพิมพ์ 9.989999 ด้วยทศนิยมสองตำแหน่ง มันจะแสดงเป็น 9.99 ดังนั้นทุกอย่างดูถูกต้อง แต่บั๊กจะโผล่เมื่อนำค่าหลายตัวมาบวก เปรียบเทียบยอด หรือปัดหลังคิดภาษี นั่นเป็นเหตุผลที่ทีมบางทีมปล่อยฟีเจอร์แล้วค้นพบปัญหาในขั้นตอนไกล่เกลี่ยกับผู้ให้บริการชำระเงินหรือการส่งออกบัญชี\n\nกฎง่าย ๆ: อย่าเก็บหรือรวมเงินเป็นจำนวนทศนิยมแบบลอยตัว จงเก็บเป็นจำนวนเต็มของหน่วยย่อยของสกุลเงิน (เช่น เซนต์) หรือใช้ชนิดทศนิยมที่รับประกันการคำนวณทศนิยมอย่างแม่นยำ\n\nหากคุณสร้าง backend, เว็บ หรือแอปมือถือ ให้ใช้หลักการเดียวกันทั่ว: เก็บค่าให้แม่นยำ คำนวณด้วยค่าที่แม่นยำ แล้วค่อยฟอร์แมตเมื่อต้องแสดงผล\n\n## เลือกรูปแบบการเก็บเงินให้สอดคล้องกับสกุลจริง\n\nบั๊กเรื่องเงินส่วนใหญ่เริ่มก่อนการคำนวณเลย: โมเดลข้อมูลไม่ตรงกับวิธีที่สกุลเงินทำงาน จัดโมเดลให้ถูกตั้งแต่ต้นแล้วการปัดจะกลายเป็นเรื่องของกฎ ไม่ใช่การเดา\n\nค่าเริ่มต้นที่ปลอดภัยคือเก็บเงินเป็นจำนวนเต็มในหน่วยย่อยของสกุล เช่น สำหรับ USD คือเซนต์ สำหรับ EUR คือเซนต์ยูโร ฐานข้อมูลและโค้ดจะจัดการจำนวนเต็มที่แม่นยำ และคุณค่อย "เพิ่มจุดทศนิยม" เมื่อฟอร์แมตให้มนุษย์ดู\n\nไม่ใช่ทุกสกุลมีทศนิยม 2 ตำแหน่ง โมเดลต้องรับรู้สกุลเงิน JPY ไม่มีหน่วยย่อย (1 เยนเป็นหน่วยเล็กสุด) BHD ใช้สามตำแหน่ง (1 dinar = 1000 fils) หากคุณฝัง "สองตำแหน่งตายตัว" จะเกิดการคิดเงินเกินหรือน้อยโดยไม่รู้ตัว\n\nระเบียนเงินควรมีอย่างน้อย:\n\n- amount_minor (integer เช่น 1999 แทน $19.99)\n- currency_code (สตริง เช่น USD, EUR, JPY)\n- ตัวเลือก: minor_unit หรือ scale (0, 2, 3) ถ้าระบบของคุณไม่สามารถดึงข้อมูลนี้ได้เสมอ\n\nเก็บรหัสสกุลเงินกับทุกจำนวนเงิน แม้ในตารางเดียวกัน จะช่วยป้องกันข้อผิดพลาดเมื่อคุณเพิ่มการตั้งราคาหลายสกุล การคืนเงิน หรือรายงานภายหลัง\n\nนอกจากนี้ ให้ตัดสินใจว่าที่ไหนอนุญาตให้ปัดและที่ไหนห้ามปัด หนึ่งนโยบายที่ทนทานคือ: อย่าปัดภายในยอดรวมภายใน ระบบการจัดสรร ห้องสมุดบัญชี หรือการแปลงที่ยังไม่เสร็จ; ปัดเฉพาะจุดที่กำหนด (เช่น ขั้นตอนภาษี ส่วนลด หรือต่อบรรทัดใบแจ้งหนี้); และบันทึกโหมดการปัดที่ใช้ (half up, half even, round down) เพื่อให้ผลลัพธ์ทำซ้ำได้\n\n## ทีละขั้นตอน: นำ amount_minor เป็นรูปแบบหลักของเงิน\n\nถ้าต้องการความประหลาดใจน้อยลง ให้เลือกรูปทรงภายในหนึ่งแบบสำหรับเงินและอย่าทำลายมัน: เก็บจำนวนเป็นจำนวนเต็มในหน่วยย่อยของสกุล\n\nนั่นหมายความว่า $10.99 กลายเป็น 1099 พร้อมสกุล USD สำหรับสกุลที่ไม่มีหน่วยย่อย เช่น JPY, 1,500 เยนยังคงเป็น 1500\n\nเส้นทางการใช้งานที่เรียบง่ายและขยายได้:\n\n1. ฐานข้อมูล: เก็บ amount_minor เป็น integer 64 บิต พร้อมรหัสสกุลเงิน (เช่น USD, EUR, JPY) ตั้งชื่อลำคาญให้ชัดเจนเพื่อไม่ให้ใครเข้าใจผิดว่าเป็นทศนิยม\n2. สัญญา API: ส่งและรับ { amount_minor: 1099, currency: "USD" } หลีกเลี่ยงสตริงฟอร์แมตเช่น "$10.99" และหลีกเลี่ยง float ใน JSON\n3. อินพุต UI: ถือว่าผู้ใช้พิมพ์เป็นข้อความ ไม่ใช่ตัวเลข ทำการนอร์ม (ตัดช่องว่าง ยอมรับตัวคั่นทศนิยมหนึ่งแบบ) แล้วแปลงตามจำนวนทศย่อยของสกุล\n4. คณิตศาสตร์ทั้งหมดเป็นจำนวนเต็ม: ยอดรวม ส่วนลด ค่าธรรมเนียม และภาษี ให้ทำงานบนจำนวนเต็มเท่านั้น กำหนดกฎเช่น "คำนวณเปอร์เซ็นต์ส่วนลดแล้วปัดเป็นหน่วยย่อย" และใช้วิธีเดียวกันเสมอ\n5. ฟอร์แมตเฉพาะเมื่อจะแสดง: เมื่อต้องแสดงเงิน ให้แปลง amount_minor เป็นสตริงโดยใช้กฎท้องถิ่นและสกุลเงิน อย่าแปลงสตริงฟอร์แมตกลับมาใช้คำนวณ\n\nตัวอย่างการแปลงง่าย ๆ: สำหรับ USD ให้รับ "12.3" และถือว่าเป็น "12.30" ก่อนแปลงเป็น 1230 สำหรับ JPY ปฏิเสธจุดทศนิยมตั้งแต่ต้น\n\n## กฎการปัดสำหรับภาษี ส่วนลด และค่าธรรมเนียม\n\nข้อพิพาทหนึ่งเซนต์ส่วนใหญ่ไม่ใช่ความผิดพลาดทางคณิตศาสตร์ แต่เป็นความผิดพลาดด้านนโยบาย สองระบบอาจทั้งถูกต้องตามนิยาม แต่ยังคงขัดแย้งกันได้ถ้าปัดในจุดที่ต่างกัน\n\nเขียนนโยบายการปัดของคุณและใช้มันทุกที่: การคำนวณ ใบเสร็จ ส่งออก และการคืนเงิน ตัวเลือกทั่วไปได้แก่การปัดแบบ half-up (0.5 ขึ้น) และ half-even (0.5 ไปหาเลขคู่ใกล้สุด) บางค่าธรรมเนียมต้องปัดขึ้นเสมอ (ceiling) เพื่อไม่ให้เรียกเก็บน้อยเกินไป\n\nยอดรวมเปลี่ยนตามการตัดสินใจไม่กี่อย่าง: ปัดต่อบรรทัดหรือปัดจากยอดรวมบิล ผสมกฎหรือไม่ (เช่น ภาษีต่อบรรทัดแต่ค่าธรรมเนียมบนบิล) และราคารวมภาษีหรือไม่รวมภาษี (คุณคำนวณภาษีย้อนจากราคารวมหรือคำนวณจากราคาก่อนภาษี)\n\nส่วนลดเพิ่มความซับซ้อนอีกหนึ่งทาง กรณี "ลด 10%" ถ้าใช้ก่อนภาษีจะลดฐานคิดภาษี ในขณะที่ส่วนลดหลังภาษีลดจำนวนที่ลูกค้าจ่ายแต่ไม่ได้เปลี่ยนภาษีที่รายงาน ขึ้นอยู่กับเขตอำนาจและสัญญา\n\nตัวอย่างเล็ก ๆ แสดงว่าทำไมกฎเข้มงวดจึงสำคัญ สินค้าสองชิ้นราคา $9.99 ภาษี 7.5% ถ้าปัดภาษีต่อบรรทัด ภาษีแต่ละบรรทัดเป็น $0.75 (9.99 x 0.075 = 0.74925) ยอดภาษีรวมเป็น $1.50 ถ้าคิดภาษีจากยอดรวมบิล ภาษีก็ออก $1.50 ในกรณีนี้ แต่เปลี่ยนราคานิดหน่อยก็เห็นความต่างหนึ่งเซนต์ได้\n\nเขียนกฎเป็นภาษาง่าย ๆ เพื่อให้ฝ่ายซัพพอร์ตและการเงินอธิบายได้ จากนั้นใช้ helper เดียวกันสำหรับภาษี ค่าธรรมเนียม ส่วนลด และการคืนเงิน\n\n## การแปลงสกุลเงินโดยไม่ให้ยอดเบี่ยง\n\nคณิตศาสตร์หลายสกุลเงินเป็นจุดที่การเลือกการปัดเล็ก ๆ น้อย ๆ สามารถเปลี่ยนยอดรวมได้ช้า ๆ เป้าหมายชัดเจน: แปลงครั้งเดียว ปัดอย่างมีจุดมุ่งหมาย และเก็บหลักฐานต้นฉบับไว้\n\nเก็บอัตราแลกเปลี่ยนด้วยความแม่นยำที่ระบุชัด รูปแบบหนึ่งที่ใช้บ่อยคือจำนวนเต็มสเกล เช่น "rate_micro" ที่ 1.234567 ถูกเก็บเป็น 1234567 กับสเกล 1,000,000 อีกทางเลือกคือชนิดทศนิยมคงที่ แต่ยังต้องบันทึกสเกลในฟิลด์เพื่อป้องกันการเดา\n\nเลือกสกุลพื้นฐานสำหรับรายงานและบัญชี (มักเป็นสกุลของบริษัท) แปลงจำนวนเข้าพื้นฐานสำหรับบัญชีและการวิเคราะห์ แต่เก็บจำนวนและสกุลเดิมไว้ด้วย เพื่อที่คุณจะอธิบายทุกตัวเลขได้ในภายหลัง\n\nกฎที่ป้องกันการ drift:\n\n- แปลงในทิศทางเดียวสำหรับการบัญชี (ต่างประเทศเป็นพื้นฐาน) และหลีกเลี่ยงการแปลงกลับไปกลับมา\n- ตัดสินใจเวลาในการปัด: ปัดต่อบรรทัดเมื่อจำเป็นต้องแสดงบรรทัดหรือปัดตอนท้ายเมื่อแสดงเฉพาะยอดรวม\n- ใช้โหมดการปัดเดียวและบันทึกไว้\n- เก็บจำนวนเดิม สกุลเดิม และอัตราที่ใช้สำหรับธุรกรรม\n\nตัวอย่าง: ลูกค้าจ่าย 19.99 EUR ให้เก็บเป็น 1999 หน่วยย่อยพร้อม currency=EUR และเก็บอัตราแลกเปลี่ยนที่ใช้ในเช็คเอาต์ด้วย สมุดบัญชีเก็บจำนวนเงินแปลงเป็น USD (ปัดตามกฎ) แต่การคืนเงินใช้จำนวน EUR ต้นฉบับและสกุลเงิน ไม่ใช่การแปลงกลับจาก USD จุดนี้ป้องกันตั๋ว "ทำไมฉันได้คืน 19.98 EUR?"\n\n## การฟอร์แมตและการแสดงผลข้ามอุปกรณ์\n\nก้าวสุดท้ายคือหน้าจอ ค่าที่ถูกต้องในที่เก็บข้อมูลยังอาจดูผิดถ้าการฟอร์แมตเปลี่ยนระหว่างเว็บและมือถือ\n\nแต่ละภูมิภาคคาดหวังการเว้นวรรคและตำแหน่งสัญลักษณ์ต่างกัน ผู้ใช้ในสหรัฐอาจอ่าน $1,234.50 ขณะที่ในยุโรปหลายแห่งคาดหวัง 1.234,50 € (ค่าเดียวกันแต่ตัวคั่นและตำแหน่งสัญลักษณ์ต่างกัน) หากคุณฝังรูปแบบไว้ในโค้ดจะสับสนและเพิ่มงานซัพพอร์ต\n\nยึดกฎเดียวกันทั่ว: ฟอร์แมตที่ขอบระบบ อย่าฟอร์แมตในแกนกลาง ข้อมูลที่เป็น truth ควรเป็น (รหัสสกุลเงิน, จำนวนหน่วยย่อยเป็น integer) แปลงเป็นสตริงเมื่อจะแสดงเท่านั้น อย่าแปลงสตริงฟอร์แมตกลับมาใช้คำนวณ เพราะตรงนั้นแหละที่การปัด การตัด และตัวคั่นท้องถิ่นจะทำให้เกิดความประหลาดใจ\n\nสำหรับจำนวนลบเช่นคืนเงิน ให้เลือกสไตล์ที่สอดคล้องและใช้แบบเดียวกันทุกที่ บางระบบแสดง -$12.34 บางระบบแสดง ($12.34) ทั้งสองแบบใช้ได้ แต่การสลับกันระหว่างหน้าจอทำให้ดูเหมือนข้อผิดพลาด\n\nสัญญาข้ามอุปกรณ์ที่ใช้ง่าย:\n\n- เก็บสกุลเป็นรหัส ISO (เช่น USD, EUR) ไม่ใช่แค่สัญลักษณ์\n- ฟอร์แมตตาม locale ของอุปกรณ์เป็นค่าเริ่มต้น แต่ให้มีการตั้งค่าในแอปแก้ไขได้\n- แสดงรหัสสกุลในหน้าจอที่มีหลายสกุล (เช่น 12.34 USD)\n- แยกการจัดการอินพุตออกจากการฟอร์แมตการแสดงผล\n- ปัดครั้งเดียวตามกฎการเงินก่อนฟอร์แมต\n\nตัวอย่าง: ลูกค้าเห็นการคืนเงินเป็น 10,00 EUR ในมือถือ แล้วเปิดบนเดสก์ท็อปเห็น -€10 หากคุณแสดงรหัสสกุลด้วย (10,00 EUR) และรักษาสไตล์ลบให้คงที่ พวกเขาจะไม่สงสัยว่ามันเปลี่ยนไปหรือไม่\n\n## ตัวอย่าง: เช็คเอาต์ ภาษี และการคืนเงินโดยไม่มีความประหลาดใจ\n\nตะกร้าเรียบง่าย:\n\n- ไอเท็ม A: $4.99 (499 เซนต์)\n- ไอเท็ม B: $2.50 (250 เซนต์)\n- ไอเท็ม C: $1.20 (120 เซนต์)\n\nยอดรวมย่อย = 869 เซนต์ ($8.69). ใช้ส่วนลด 10% ก่อน: 869 x 10% = 86.9 เซนต์ ปัดเป็น 87 เซนต์ ยอดรวมหลังส่วนลด = 782 เซนต์ ($7.82). จากนั้นคำนวณภาษี 8.875%\n\nตรงนี้แหละที่กฎการปัดเปลี่ยนเพนนีสุดท้าย\n\nถ้าคำนวณภาษีจากยอดรวมบิล: 782 x 8.875% = 69.4025 เซนต์ ปัดเป็น 69 เซนต์\n\nถ้าคำนวณภาษีต่อบรรทัด (หลังส่วนลด) แล้วปัดแต่ละบรรทัด:\n\n- ไอเท็ม A: ภาษี $4.49 = 39.84875 เซนต์ ปัดเป็น 40\n- ไอเท็ม B: ภาษี $2.25 = 19.96875 เซนต์ ปัดเป็น 20\n- ไอเท็ม C: ภาษี $1.08 = 9.585 เซนต์ ปัดเป็น 10\n\nภาษีรวมต่อบรรทัด = 70 เซนต์ ตะกร้าเดียวกัน อัตราเดียวกัน แต่วิธีที่ถูกก็อาจต่างกัน 1 เซนต์\n\nเพิ่มค่าจัดส่งหลังภาษี สมมติ 399 เซนต์ ($3.99) ยอดรวมจะเป็น $12.50 (กรณีภาษีแบบ invoice-level) หรือ $12.51 (กรณีภาษีแบบ line-level) เลือกกฎหนึ่งข้อ บันทึกไว้ และรักษาให้คงที่\n\nตอนนี้คืนเงินเฉพาะไอเท็ม B คืนราคาแบบหลังส่วนลด (225 เซนต์) บวกภาษีที่เป็นของมัน ในกรณีภาษีแบบ line-level นั่นคือ 225 + 20 = 245 เซนต์ ($2.45) ยอดคงเหลือยังไกล่เกลี่ยได้อย่างแม่นยำ\n\nเพื่ออธิบายความต่างทีหลัง ให้บันทึกค่านี้สำหรับทุกการชาร์จและการคืนเงิน:\n\n- จำนวนสุทธิต่อบรรทัด (หน่วยย่อย), ภาษีต่อบรรทัด (หน่วยย่อย), และโหมดการปัด\n- ส่วนลดของบิล (หน่วยย่อย) และวิธีจัดสรร\n- อัตราภาษีและฐานที่ใช้ (หน่วยย่อย)\n- ค่าจัดส่ง/ค่าธรรมเนียม (หน่วยย่อย) และว่าจำเป็นต้องเสียภาษีหรือไม่\n- ยอดรวมสุดท้าย (หน่วยย่อย) และยอดคืน (หน่วยย่อย)\n\n## วิธีทดสอบการคำนวณเงิน\n\nบั๊กเรื่องเงินส่วนใหญ่ไม่ใช่ "บั๊กคณิตศาสตร์" แต่เป็นบั๊กการปัด ลำดับ และการฟอร์แมตที่เกิดขึ้นเฉพาะกับตะกร้าหรือวันที่บางแบบ การทดสอบที่ดีจะทำให้กรณีเหล่านี้เป็นเรื่องน่าเบื่อ\n\nเริ่มจากการทดสอบแบบ golden: อินพุตคงที่กับผลลัพธ์ที่คาดหวังเป็นหน่วยย่อย (เช่น เซนต์) เก็บการยืนยันอย่างเข้มงวด ตรวจสอบค่าด้วยจำนวนเต็ม ไม่ใช่สตริงฟอร์แมต ถ้ามีไอเท็ม 199 เซนต์และภาษี 15 เซนต์ เทสควรเช็คค่า integer เหล่านั้น\n\nชุด golden เล็ก ๆ ช่วยครอบคลุมมาก:\n\n- ไอเท็มเดียวกับภาษี แล้วส่วนลด แล้วค่าธรรมเนียม (เช็คการปัดแต่ละขั้นกลาง)\n- หลายไอเท็มที่ภาษีปัดต่อบรรทัดเทียบกับยอดรวมหรือไม่ (ยืนยันกฎที่เลือก)\n- การคืนเงินและการคืนบางส่วน (ยืนยันเครื่องหมายลบและทิศทางการปัด)\n- การแปลงรอบ (A เป็น B แล้วกลับเป็น A) โดยมีนโยบายที่กำหนดว่าปัดที่ไหน\n- ค่าชายแดน (ไอเท็ม 1 เซนต์, ปริมาณมาก, ยอดรวมใหญ่)\n\nแล้วเพิ่มการตรวจแบบ property-based หรือการสุ่มง่าย ๆ เพื่อจับความประหลาดใจ แทนที่จะคาดหวังตัวเลขเดียว ให้ยืนยันเงื่อนไขคงที่: ยอดรวมเท่ากับผลรวมของบรรทัดเสมอ ไม่มีหน่วยย่อยเป็นทศนิยม และ "ยอดรวม = ยอดย่อย + ภาษี + ค่าธรรมเนียม - ส่วนลด" ให้เป็นจริงเสมอ\n\nการทดสอบข้ามแพลตฟอร์มสำคัญเพราะผลลัพธ์อาจเบี่ยงระหว่าง backend และไคลเอนต์ ถ้าคุณมี Go backend กับ Vue เว็บ และ Kotlin/SwiftUI บนมือถือ ให้รันชุดเทสเดียวกันในแต่ละชั้นและเปรียบเทียบผลลัพธ์เป็น integer ไม่ใช่สตริง UI\n\nสุดท้าย ทดสอบกรณีตามเวลา เก็บอัตราภาษีที่ใช้บนใบแจ้งหนี้แล้วยืนยันว่าใบเก่าคำนวณซ้ำได้เหมือนเดิมแม้อัตราจะเปลี่ยน นี่คือที่มาของบั๊ก "มันเคยตรงกันแต่ตอนนี้ไม่ตรงแล้ว"\n\n## กับดักทั่วไปที่ควรหลีกเลี่ยง\n\nบั๊กหนึ่งเซนต์ส่วนใหญ่ไม่ใช่ความผิดพลาดทางคณิตศาสตร์ แต่เป็นความผิดพลาดด้านนโยบาย: โค้ดทำตามที่คุณสั่ง แต่ไม่ใช่สิ่งที่ฝ่ายการเงินคาดหวัง\n\nกับดักที่ควรระวัง:\n\n- ปัดเร็วเกินไป: ถ้าคุณปัดทุกบรรทัด แล้วปัดยอดย่อย แล้วปัดภาษี ยอดรวมอาจเบี่ยง เลือกกฎ (เช่น: ปัดภาษีต่อบรรทัดหรือปัดยอดรวม) และปัดเฉพาะจุดที่นโยบายอนุญาต\n- ผสมสกุลเงินในยอดเดียว: การรวม USD และ EUR ในฟิลด์ "ยอดรวม" ดูไม่มีพิษมีภัยจนกว่าจะต้องคืนเงิน รายงาน หรือไกล่เกลี่ย แยกจำนวนตามสกุลและแปลงตามอัตราที่ตกลงก่อนรวม\n- แยกวิเคราะห์อินพุตผู้ใช้ผิด: ผู้ใช้พิมพ์ "1,000.50", "1 000,50" หรือ "10.0" ถ้าพาร์เซอร์สมมติรูปแบบเดียว อาจเรียกเก็บผิดหรือทิ้งศูนย์ท้าย จงนอร์มอินพุต ยืนยัน และเก็บเป็นหน่วยย่อย\n- ใช้สตริงฟอร์แมตใน API หรือ DB: "$1,234.56" มีไว้แสดงเท่านั้น ถ้า API รับค่านี้ ระบบอื่นอาจพาร์สต่างกัน ส่ง integer (หน่วยย่อย) พร้อมรหัสสกุล และให้แต่ละไคลเอนต์ฟอร์แมตเอง\n- ไม่เวอร์ชันกฎภาษีหรือตารางอัตรา: อัตราภาษีเปลี่ยน การยกเว้นเปลี่ยน ถ้าคุณเขียนทับอัตราเดิม ใบเก่าจะไม่สามารถทำซ้ำได้ เก็บเวอร์ชันหรือวันที่มีผลพร้อมการคำนวณทุกครั้ง\n\nตรวจสอบความเป็นจริงอย่างรวดเร็ว: เช็คเอาต์ที่สร้างวันจันทร์ใช้ค่าอัตราของเดือนก่อน ผู้ใช้คืนเงินวันศุกร์หลังอัตราเปลี่ยน ถ้าคุณไม่เก็บเวอร์ชันกฎภาษีและนโยบายการปัดเดิม การคืนเงินจะไม่ตรงกับใบเสร็จเดิม\n\n## เช็คลิสต์ด่วนและขั้นตอนต่อไป\n\nถ้าต้องการความประหลาดใจน้อยลง ให้ปฏิบัติกับเงินเป็นระบบเล็ก ๆ ที่มีอินพุต กฎ และเอาต์พุตชัดเจน บั๊กหนึ่งเซนต์ส่วนใหญ่เกิดเพราะไม่มีใครเขียนลงว่าที่ไหนอนุญาตให้ปัด\n\nเช็คลิสต์ก่อนส่งของ:\n\n- เก็บจำนวนเป็นหน่วยย่อย (เช่น เซนต์) ทุกที่: ฐานข้อมูล ลอจิกธุรกิจ และ API\n- ทำคณิตศาสตร์ทั้งหมดเป็นจำนวนเต็ม และแปลงเป็นฟอร์แมตตอนจะแสดงเท่านั้น\n- เลือกจุดการปัดเดียวต่อการคำนวณ (ภาษี ส่วนลด ค่าธรรมเนียม FX) และบังคับใช้ที่จุดเดียว\n- ฟอร์แมตตามกฎสกุลเงินที่ถูกต้อง (ทศนิยม ตัวคั่น ค่าเชิงลบ) อย่างสอดคล้องทั้งเว็บและมือถือ\n- เพิ่มการทดสอบกรณีขอบ: 0.01, ทศนิยมซ้ำในการแปลง, การคืนเงิน, การจับยอดบางส่วน, และตะกร้าขนาดใหญ่\n\nเขียนนโยบายการปัดหนึ่งรายการต่อประเภทการคำนวณ เช่น: "ส่วนลดปัดต่อบรรทัดให้ใกล้เคียงเซนต์ที่สุด; ภาษีปัดที่ยอดรวมบิล; การคืนเงินทำตามเส้นทางการปัดเดิม" วางนโยบายเหล่านี้ใกล้โค้ดและในเอกสารทีมเพื่อไม่ให้เลือนหาย\n\nเพิ่มล็อกน้ำหนักเบาสำหรับทุกขั้นตอนการเงินที่สำคัญ จับอินพุต ชื่อกฎที่เลือก และเอาต์พุตเป็นหน่วยย่อย เมื่อผู้ใช้รายงาน "ชาร์จฉันเกินหนึ่งเซนต์" คุณต้องการบรรทัดเดียวที่อธิบายได้ว่าทำไม\n\nวางแผนการตรวจสอบเล็ก ๆ ก่อนเปลี่ยนลอจิกในโปรดักชัน คำนวณยอดซ้ำบนตัวอย่างคำสั่งซื้อประวัติ แล้วเปรียบเทียบผลเก่าและผลใหม่ นับจำนวนที่ไม่ตรงและตรวจสอบด้วยมือสักสองสามรายการเพื่อตรวจว่าตรงตามนโยบายใหม่หรือไม่\n\nถ้าต้องการสร้างฟลูว์แบบ end-to-end โดยไม่ต้องเขียนกฎซ้ำสามครั้ง AppMaster (appmaster.io) ถูกออกแบบมาสำหรับแอปครบวงจร คุณสามารถมอดูลจำนวนเป็น integer หน่วยย่อยใน PostgreSQL ผ่าน Data Designer, ใส่ขั้นตอนการปัดและภาษีใน Business Process เดียว แล้วนำตรรกะเดียวกันไปใช้ทั้งเว็บและมือถือ
คำถามที่พบบ่อย
ปัญหานี้มักเกิดเมื่อส่วนต่าง ๆ ของแอปปัดเศษในช่วงเวลาหรือวิธีที่ต่างกัน ถ้ารายการสินค้าแสดงผลปัดเศษคนละขั้นตอนกับหน้าชำระเงิน ตะกร้าเดียวกันอาจมีเซนต์ต่างกันได้จริงๆ
เพราะ float มักเก็บค่าทศนิยมในระบบไบนารี ซึ่งเลขทศนิยมทั่วไปบางค่าไม่สามารถแทนได้อย่างแม่นยำ ความคลาดเล็ก ๆ เหล่านี้จะสะสมและอาจทำให้การตัดสินใจปัดเศษเปลี่ยนไปและเกิดความต่างหนึ่งเซนต์
เก็บเงินเป็นจำนวนเต็มของหน่วยย่อยของสกุลเงิน เช่น เซนต์สำหรับ USD (1999 แทน $19.99) พร้อมรหัสสกุลเงิน ทำคำนวณด้วยจำนวนเต็ม และแปลงเป็นสตริงทศนิยมเมื่อจะแสดงเท่านั้น
การกำหนดทศนิยมไว้ตายตัวเป็นสองตำแหน่งจะผิดพลาดกับสกุลอย่าง JPY (0 ตำแหน่ง) หรือ BHD (3 ตำแหน่ง) จงเก็บรหัสสกุลเงินพร้อมจำนวนและใช้สเกลของสกุลนั้นเมื่อตีความอินพุตหรือฟอร์แมตเอาต์พุต
เลือกกฎชัดเจนแล้วใช้ให้ทั่ว เช่น ปัดภาษีต่อบรรทัดหรือปัดภาษีจากยอดรวมบิล จุดสำคัญคือความสอดคล้องใน backend เว็บ มือถือ เอกสารส่งออก และการคืนเงิน พร้อมใช้โหมดการปัดเดียวกันทุกครั้ง
ตัดสินใจก่อนว่าใช้อะไรเป็นลำดับ เช่น ส่วนลดก่อน (เพื่อลดฐานคิดภาษี) แล้วค่อยคิดภาษี หรือทำตามกฎหมายและนโยบายธุรกิจของคุณ แล้วใช้ลำดับเดียวกันในทุกหน้าจอและบริการ
แปลงครั้งเดียวโดยเก็บอัตราแลกเปลี่ยนที่มีความแม่นยำชัดเจน (เช่น เก็บเป็นจำนวนเต็มสเกล micro) ปัดตามขั้นตอนที่กำหนด และเก็บจำนวนและสกุลเดิมไว้เพื่อการคืนเงิน หลีกเลี่ยงการแปลงไปกลับเพราะการปัดซ้ำคือแหล่งเกิด drift
อย่าแปลงสตริงที่ฟอร์แมตแล้วกลับเป็นตัวเลข เพราะเครื่องหมายคั่นท้องถิ่นและการปัดอาจเปลี่ยนค่า ให้ส่ง (amount_minor, currency_code) เป็นข้อมูลโครงสร้างและฟอร์แมตที่ UI ปลายทางโดยใช้กฎท้องถิ่น
ใช้ชุดทดสอบ "golden" ที่อินพุตคงที่และคาดผลลัพธ์เป็นหน่วยย่อย (เช่น เซนต์) ตรวจสอบเป็นจำนวนเต็ม ไม่ใช่สตริงฟอร์แมต แล้วเพิ่มการตรวจเงื่อนไข เช่น ยอดรวมเท่ากับผลรวมของบรรทัดเสมอ ไม่มีหน่วยย่อยเป็นเศษ และการคืนเงินต้องทำตามเส้นทางการปัดเดิม
เก็บคณิตศาสตร์การเงินไว้ที่เดียวและนำกลับมาใช้ซ้ำทั้ง backend เว็บ และมือถือ เพื่อให้ข้อมูลเข้าเดียวกันให้ผลเป็นเซนต์เดียวกัน ใน AppMaster แนวปฏิบัติคือเก็บ amount_minor เป็น integer ใน PostgreSQL แล้วใส่ตรรกะการปัดและภาษีไว้ใน Business Process เดียวที่หลายๆ ส่วนใช้ร่วมกัน


