dimesum

หน้าแรก / บล็อก / เงิน

เงิน

บั๊กเรื่องเงินหกจุดในการแยกบิลหลายสกุลเงิน

· อ่าน 6 นาที ·

การตรวจสอบเมื่อ 2026-08-21 พบข้อบกพร่องเรื่องสกุลเงินหกจุดที่ชุดทดสอบผ่านมาตลอด โดยต้นเหตุคือประโยคหนึ่งในเอกสารที่เลิกเป็นจริงไปอย่างเงียบ ๆ

ประโยคเก่าหนึ่งประโยคในเอกสารทำให้เกิดบั๊กเรื่องเงินหกจุด การตรวจสอบระบบหลายสกุลเงินของ Dimesum เมื่อ 2026-08-21 พบมันในโค้ดที่ชุดทดสอบของเราผ่านมาตลอด รวมถึงหน้าเคลมที่แสดงหนี้ ¥6,000 ออกมาเป็น -₹60.00 ประโยคนั้นคือ "groups are INR-pinned" ซึ่งเป็นจริงจนถึง W7 กลายเป็นเท็จตั้งแต่วินาทีที่มันปล่อยออกไป และยังคงค้างอยู่ใน FOLLOWUPS.md อีกหนึ่งสัปดาห์ต่อมา

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

รายการ follow-up ที่กลายเป็นจริงแล้วนั้นแย่กว่ารายการที่ยังค้างอยู่

Dimesum พักงานที่เลื่อนออกไปไว้ใน FOLLOWUPS.md หนึ่งแถวต่อหนึ่งการตัดสินใจ พร้อมเหตุผลที่พักมันไว้ รายการ A17 บอกว่ากลุ่มถูกตรึงเป็น INR ว่าการเดินทางไปต่างประเทศไม่สามารถบันทึกในสกุลเงินที่มันเกิดขึ้นได้ และว่าการแก้ไขรอการตัดสินใจของผู้ก่อตั้ง แต่ W7 ก็ปล่อยหลายสกุลเงินออกไปอยู่ดี ค่าใช้จ่ายอาจอยู่ในสกุลเงินใดก็ได้ และยอดคงเหลือถูกผูกด้วย (group_id, member_id, currency) โดย migration ledger/00005 ไม่มีใครขีดฆ่าแถวนั้น

การตรวจสอบครั้งถัดมาอ่าน A17 สรุปว่าส่วนนี้ยังไม่ได้สร้าง และไม่เคยเปิดโค้ดของการชำระ การเคลม หรือหน้าแลนดิงเลย เส้นทางเรื่องเงินที่มองไม่เห็นสกุลเงินสามจุดยังคงถูกปล่อยออกไปเพราะประโยคเดียว ไม่มีปัญหายากอะไรมาขวางเลย docstring ของ app/amount.py ก็มีคำอ้างเดียวกัน ผู้อ่านจึงถูกบอกถึงสองครั้ง

กฎที่เรานำมาใช้

ขีดฆ่ารายการ follow-up ในคอมมิตเดียวกับที่ปิดมัน แถวที่อธิบายโค้ดซึ่งเลิกทำงานแบบนั้นแล้วคือป้ายชี้ทางให้หันหนีจากไฟล์ที่คุณต้องอ่าน ซึ่งแพงกว่าการไม่พูดอะไรเลย

เลข 100 ที่ฮาร์ดโค้ดตัวเดียวกันโผล่มาในสามไฟล์ที่ต่างกัน

เงินในที่นี้คือหน่วยย่อยแบบ int64 บวกกับรหัส ISO 4217 และไม่มี float มาแตะต้อง จำนวนเต็มแม่นยำโดยธรรมชาติ ดังนั้นเลขคณิตที่ยังเสี่ยงเหลืออยู่จึงมีเพียงการแปลงระหว่างหน่วยหลักและหน่วยย่อย หน่วยย่อยไม่ได้เป็นหนึ่งในร้อยเสมอไป JPY ไม่มีเลย KWD มีทศนิยมสามตำแหน่ง และทุกครั้งที่ฮาร์ดโค้ดเลข 100 คือการเดิมพันว่าผู้ใช้อยู่แต่ในประเทศ

ไฟล์ app/amount.py ของ Python sidecar เป็นจุดแรกที่พบ ถูกดึงออกก่อนการตรวจสอบในฐานะกับระเบิดมากกว่าจะเป็นข้อบกพร่องที่ทำงานอยู่ มันคูณตัวเลขใด ๆ ที่อ่านออกมาจากประโยคด้วย 100 ใบเสร็จ ¥1,200 จึงกลายเป็น 120,000 หน่วยย่อย ซึ่งอ่านได้ว่า ¥120,000 บนสกุลเงินที่ไม่มีหน่วยย่อย ค่าคงที่นี้พองบิลของใครบางคนขึ้นร้อยเท่า ตอนนี้ฟังก์ชันจึงไปค้นค่า exponent ของสกุลเงินในคำขอแทน

ใบเสร็จ 1,200 เยนที่ถูกแปลงด้วยตัวคูณฮาร์ดโค้ด 100 และด้วยตาราง exponent ของ ISO 4217 ¥1,200 currency: JPY ก่อน minor = major x 100 ค่าคงที่ในโมดูล 120000 หน่วยย่อย อ่านได้เป็น ¥120,000 หลัง minor = major x 10^exp JPY exponent = 0 1200 หน่วยย่อย อ่านได้เป็น ¥1,200
กับดัก exponent ในภาพเดียว ตัวคูณค่าคงที่ 100 ถูกต้องสำหรับ INR แต่ผิดไปร้อยเท่าสำหรับ JPY ซึ่งไม่มีหน่วยย่อย และค่าคงที่เดียวกันคลาดไปสิบเท่าสำหรับ KWD ซึ่งมีทศนิยมสามตำแหน่ง

การตรวจสอบพบค่าคงที่เดียวกันบนหน้าที่ผู้ใช้ตัดสินใจว่าจะยอมรับยอดคงเหลือหรือไม่ formatMinor ที่อยู่เบื้องหลังหน้าเคลม /m/{token} หารด้วย 100 แล้วเอาสัญลักษณ์รูปีมาแปะไว้ข้างหน้า หนี้ ¥6,000 จึงพิมพ์ออกมาเป็น -₹60.00 สัญลักษณ์ผิดและเป็นแค่หนึ่งในร้อยของจำนวนเงิน พี่น้อง JSON ของมันคือ /v1/claims ก็พังไปพร้อมกัน มันยุบแถวต่อสกุลเงินของสมาชิกให้เหลือแถวเดียวและเก็บสกุลเงินที่แมปเขียนทับไว้เป็นตัวสุดท้าย

ไฟล์ที่สามคือ internal/ingestion/store.go ซึ่ง W8 พบขณะขยายการตรวจจับรายการซ้ำ ไม่ใช่จากการตรวจสอบ ค่าความคลาดเคลื่อน "บวกลบหนึ่งรูปี" ของมันคือค่าคงที่ 100 หนึ่งรูปีนับเป็น paise เป็นแถบบวกลบ ¥100 บนสกุลเงินที่ JPY ไม่มีหน่วยย่อยเลย ตอนนี้ทั้งสามไฟล์อ่านค่า exponent แล้ว

การเปรียบเทียบจำนวนเต็มเปล่า ๆ ทำให้เยนดูเหมือนรูปี

ชั้นตรวจจับรายการซ้ำของ Dimesum ตรวจรายการที่จับได้ใหม่เทียบกับค่าใช้จ่ายล่าสุด เพื่อไม่ให้ใบเสร็จที่นำเข้ามาคิดเงินซ้ำกับรายการที่มีคนพิมพ์เองด้วยมือ คิวรีที่อยู่ข้างแถบความคลาดเคลื่อนนั้นไม่มีตัวกรองสกุลเงินเลย ¥1,000 จึงตรงกับ ₹1,000 และการนำเข้าเสนอที่จะแทนที่ค่าใช้จ่ายที่มันไม่มีอะไรเกี่ยวข้องด้วย กลุ่มเป็นหลายสกุลเงินมาตั้งแต่ ledger/00005 เคสนี้จึงเข้าถึงได้จริง ไม่ใช่แค่ในทางทฤษฎี

หน่วยย่อยเปล่า ๆ เทียบข้ามสกุลเงินไม่ได้ และค่าความคลาดเคลื่อนก็เช่นกัน ตอนนี้ projection กรองตามสกุลเงินและคำนวณแถบของมันจากหนึ่งหน่วยหลักของสกุลเงินที่กำลังเปรียบเทียบ

เอนจินสองตัวตอบว่า "ใครจ่ายให้ใคร" และตัวที่สองมองไม่เห็นสกุลเงิน

ข้อบกพร่องที่แพงที่สุดเป็นเชิงโครงสร้าง ไม่ใช่เชิงเลขคณิต เส้นทางเขียนของการชำระวางแผนผ่าน internal/platform/simplify ซึ่งเป็นเอนจิน min-cash-flow ตัวที่สอง โดย struct Balance ของมันไม่มีฟิลด์สกุลเงิน ผลรวมศูนย์ต่อสกุลเงินทำให้ผลรวมแบบแบนเป็นศูนย์ไปด้วย หนี้ ¥300,000 กับหนี้ ₹500 จึงหักล้างกันจนเหลือศูนย์ และไม่มี guard ใดปฏิเสธแผนนั้น จากนั้นแผนจึงจับคู่เจ้าหนี้เยนกับลูกหนี้รูปี

การเขียนทำให้แย่ลงไปอีก บริการประทับ Currency: g.DefaultCurrency ลงบนทุกการชำระ หนี้ ¥300,000 จึงอนุมัติรายการบัญชี INR และหนี้เยนก็ไม่สามารถเคลียร์ได้เลย

ยอดคงเหลือสี่รายการเดียวกันที่ถูกจัดเส้นทางโดยเอนจิน simplify ที่มองไม่เห็นสกุลเงิน และโดยเอนจิน settle ที่รับรู้สกุลเงิน ยอดคงเหลือ Asha +¥300,000 Bhavna -¥300,000 Chetan +₹500 Dev -₹500 simplify: Balance{MemberID, Minor} ไม่มีสกุลเงินบน struct ทั้งสี่จึงหักล้างแบบแบน 300000 + (-300000) + 500 + (-500) = 0 แผน: Bhavna จ่าย Chetan ลูกหนี้เยนถูกส่งไปหาเจ้าหนี้รูปี settle: Balance{MemberID, money.Amount} แบ่งพาร์ทิชันตามสกุลเงิน แล้วจัดเส้นทางภายในแต่ละสกุล JPY: Bhavna จ่าย Asha ¥300,000 INR: Dev จ่าย Chetan ₹500 การชำระระบุสกุลเงินที่มันเคลียร์ simplify ถูกลบ ไม่ได้ถูกแก้
ยอดคงเหลือสี่รายการ เอนจินสองตัว ผลรวมศูนย์ต่อสกุลเงินหมายถึงผลรวมแบบแบนเป็นศูนย์ เอนจินที่มองไม่เห็นสกุลเงินจึงเห็นว่ากลุ่มสมดุลดี และจัดเส้นทางการจ่ายอย่างมั่นใจระหว่างคนสองคนที่ไม่ได้ติดหนี้กันเลย

simplify ถูกลบและ /settle-plan ถูกปลดออก ตอนนี้ internal/platform/settle เป็นเอนจินเดียว มันแบ่งพาร์ทิชันสกุลเงินออกเป็นแบบที่แปลงได้และแปลงไม่ได้ แปลงยอดคงเหลือสุทธิเพียงครั้งเดียว และจัดเส้นทางแต่ละสกุลเงินที่แปลงไม่ได้ในหน่วยของมันเอง การชำระระบุสกุลเงินที่มันเคลียร์ และฟิลด์นั้นจำเป็นเมื่อกลุ่มถือมากกว่าหนึ่งสกุลเงิน

เพดานเท่ากับศูนย์ถูกอ่านว่าไม่มีเพดาน

guard กันจ่ายเกินปฏิเสธการจ่ายที่มากกว่าหนี้ที่มันชำระ guard นั้นอ่าน outstanding > 0 && amount > outstanding เพดานเท่ากับศูนย์จึงข้ามการเปรียบเทียบไปทั้งหมด คนที่บันทึกการจ่ายเทียบกับหนี้ที่ไม่มีอยู่จริงคือเคสเดียวที่กฎนี้มีไว้เพื่อจัดการ และมันคือเคสที่ผ่านฉลุยไปได้

การแก้คือชนิดข้อมูล ไม่ใช่เงื่อนไข ตอนนี้ OutstandingMinor เป็น *int64 ดังนั้น "ไม่มีใครคำนวณค่านี้" จึงเขียนแบบเดียวกับ "คำตอบคือศูนย์" ไม่ได้ nil ข้ามการตรวจและหมายถึงไม่ทราบค่าจริง ๆ ผู้เรียกใด ๆ ที่เข้าถึงบัญชีได้ส่งตัวเลขจริงเข้ามา

ทำไมชุดทดสอบสีเขียวถึงไม่พิสูจน์อะไรเลย

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

ตัวตรวจสอบบัญชีแบบ end-to-end มีความมองไม่เห็นเดียวกัน ซึ่งเป็นส่วนที่ควรจดจำไว้ ตัวตรวจสอบรวมยอดรายการต่อสมาชิกโดยไม่จัดกลุ่มตามสกุลเงิน มันจึงตีปลาหน้าไซบนกลุ่มสองสกุลเงินที่แข็งแรงดี และรวมได้เป็นศูนย์บนกลุ่มที่พังถึงสองครั้ง false negative คือทิศทางที่อันตราย ตัวตรวจสอบที่สร้างบนสมมติฐานเดียวกับโค้ดย่อมเห็นด้วยกับโค้ดเสมอ

ทุกข้อบกพร่องที่การตรวจสอบหลายสกุลเงินเมื่อ 2026-08-21 พบ สิ่งที่กลุ่มเยนได้รับ และสิ่งที่ปล่อยออกไป
ข้อบกพร่องที่ไหนสิ่งที่กลุ่ม JPY ได้รับการแก้
struct Balance ที่ไม่มีสกุลเงินอยู่บนนั้นplatform/simplifyเจ้าหนี้เยนถูกจับคู่กับลูกหนี้รูปีลบ simplify ให้ settle แบ่งพาร์ทิชันตามสกุลเงิน
ค่าเริ่มต้นของกลุ่มถูกประทับลงบนทุกการชำระบริการชำระเงินหนี้ ¥300,000 อนุมัติรายการบัญชี INRการชำระระบุสกุลเงินที่มันเคลียร์
outstanding > 0 ใน guard กันจ่ายเกินบริการชำระเงินเพดานเท่ากับศูนย์กลายเป็นไม่มีเพดานเลย*int64: nil หมายถึงไม่ทราบ ศูนย์หมายถึงศูนย์
หารด้วย 100 พร้อมสัญลักษณ์รูปีgateway formatMinorหนี้ ¥6,000 พิมพ์ออกมาเป็น -₹60.00สัญลักษณ์และทศนิยมมาจากสกุลเงิน
ยอดคงเหลือต่อสกุลเงินถูกยุบเหลือแถวเดียว/v1/claimsสกุลเงินที่แมปเขียนทับไว้เป็นตัวสุดท้ายหนึ่งรายการต่อหนึ่งสกุลเงิน ตรงกับ GET /balances
รายการถูกรวมต่อสมาชิก สกุลเงินถูกทิ้งตัวตรวจสอบบัญชีแบบ end-to-endกลุ่มที่พังสองชั้นถูกรายงานว่าสมดุลจัดกลุ่มตามสมาชิกและสกุลเงิน

Grep โค้ดเรื่องเงินของคุณหาเลข 100 ที่เขียนตรง ๆ

ค้นหาค่าคงที่นั้น และทุกการเปรียบเทียบที่วางจำนวนเงินสองก้อนไว้ข้างกันโดยไม่มีสกุลเงินอยู่ข้าง ๆ จากนั้นแก้สิ่งที่ถูกกว่า สิ่งที่ป้องกันหกจุดถัดไป ขีดฆ่ารายการ follow-up ในคอมมิตเดียวกับที่ปิดมัน และลบเอนจินที่ซ้ำกันทิ้งแทนที่จะซ่อมมัน คำตอบสองคำตอบว่า "ใครจ่ายให้ใคร" คือสาเหตุที่ทำให้คำตอบหนึ่งยังผิดอยู่

คำถามที่พบบ่อย

multi-currency expense splitting คืออะไร?

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

ทำไมการฮาร์ดโค้ดเลข 100 ในโค้ดเรื่องเงินถึงอันตราย?

การฮาร์ดโค้ดเลข 100 เป็นการเหมาว่าทุกสกุลเงินมีทศนิยมสองตำแหน่ง ซึ่งหลายสกุลไม่ได้เป็นแบบนั้น JPY ไม่มีหน่วยย่อยเลย การคูณด้วย 100 จึงทำให้ใบเสร็จ ¥1,200 กลายเป็น 120,000 หน่วยย่อย เป็นการพองตัวขึ้นร้อยเท่า ไม่ใช่แค่ความคลาดเคลื่อนจากการปัดเศษ ส่วน KWD มีทศนิยมสามตำแหน่ง ค่าคงที่เดิมจึงคลาดไปสิบเท่า ให้อ่านค่า exponent จาก ISO 4217 แทน

จะป้องกันไม่ให้ settlement engine สองตัวให้คำตอบขัดกันได้อย่างไร?

ให้ลบหนึ่งในสองเอนจินทิ้งไป แทนที่จะพยายามปรับให้ตรงกัน เพราะคำตอบที่สองว่าใครต้องจ่ายให้ใครคือสาเหตุที่ทำให้คำตอบแรกยังผิดอยู่ Dimesum เคยรัน simplify และ settle ควบคู่กัน จนการตรวจสอบพบว่าตัวแรกไม่มีสกุลเงินบนชนิดข้อมูลของยอดคงเหลือ ซึ่งทำให้แผนจับคู่เจ้าหนี้เยนกับลูกหนี้รูปี จึงลบ simplify ออกและปลด /settle-plan แทนที่จะแก้ทีละจุด

ทำไมชุดทดสอบถึงผ่านทั้งที่มีบั๊กเรื่องเงินหกจุด?

ชุดทดสอบผ่านเพราะทุก fixture ในเส้นทางที่ได้รับผลกระทบใช้สกุลเงินเดียว และการเปรียบเทียบที่มองไม่เห็นสกุลเงินย่อมไม่มีทางล้มเหลวเมื่อมีสกุลเงินเดียว ตัวตรวจสอบบัญชีแบบ end-to-end ก็ตั้งอยู่บนสมมติฐานเดียวกัน มันรวมยอดรายการต่อสมาชิกโดยไม่จัดกลุ่มตามสกุลเงิน จึงรายงานว่ากลุ่มที่พังถึงสองชั้นนั้นสมดุลดี ตัวตรวจสอบที่สร้างบนสมมติฐานเดียวกับโค้ดย่อมเห็นด้วยกับโค้ดเสมอ

เมื่อฟีเจอร์ปล่อยแล้วควรทำอย่างไรกับรายการ follow-up?

ให้ขีดฆ่ารายการ follow-up ในคอมมิตเดียวกับที่ปิดงานที่มันอธิบายไว้ รายการ follow-up ที่กลายเป็นจริงไปเงียบ ๆ นั้นแย่กว่ารายการที่ยังค้างอยู่ เพราะมันชี้นำผู้อ่านในภายหลังให้หันหนีจากโค้ดที่มันเคยอธิบายไว้ Dimesum ทิ้งรายการที่บอกว่ากลุ่มถูกตรึงเป็น INR ไว้อีกหนึ่งสัปดาห์หลังจากที่หลายสกุลเงินปล่อยไปแล้ว และการตรวจสอบครั้งถัดมาก็ข้ามเส้นทางเรื่องเงินไปสามจุดเพราะเชื่อคำนั้น