บั๊กเรื่องเงินหกจุดในการแยกบิลหลายสกุลเงิน
การตรวจสอบเมื่อ 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 ของสกุลเงินในคำขอแทน
การตรวจสอบพบค่าคงที่เดียวกันบนหน้าที่ผู้ใช้ตัดสินใจว่าจะยอมรับยอดคงเหลือหรือไม่ 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-plan ถูกปลดออก ตอนนี้ internal/platform/settle เป็นเอนจินเดียว มันแบ่งพาร์ทิชันสกุลเงินออกเป็นแบบที่แปลงได้และแปลงไม่ได้ แปลงยอดคงเหลือสุทธิเพียงครั้งเดียว และจัดเส้นทางแต่ละสกุลเงินที่แปลงไม่ได้ในหน่วยของมันเอง การชำระระบุสกุลเงินที่มันเคลียร์ และฟิลด์นั้นจำเป็นเมื่อกลุ่มถือมากกว่าหนึ่งสกุลเงิน
เพดานเท่ากับศูนย์ถูกอ่านว่าไม่มีเพดาน
guard กันจ่ายเกินปฏิเสธการจ่ายที่มากกว่าหนี้ที่มันชำระ guard นั้นอ่าน outstanding > 0 && amount > outstanding เพดานเท่ากับศูนย์จึงข้ามการเปรียบเทียบไปทั้งหมด คนที่บันทึกการจ่ายเทียบกับหนี้ที่ไม่มีอยู่จริงคือเคสเดียวที่กฎนี้มีไว้เพื่อจัดการ และมันคือเคสที่ผ่านฉลุยไปได้
การแก้คือชนิดข้อมูล ไม่ใช่เงื่อนไข ตอนนี้ OutstandingMinor เป็น *int64 ดังนั้น "ไม่มีใครคำนวณค่านี้" จึงเขียนแบบเดียวกับ "คำตอบคือศูนย์" ไม่ได้ nil ข้ามการตรวจและหมายถึงไม่ทราบค่าจริง ๆ ผู้เรียกใด ๆ ที่เข้าถึงบัญชีได้ส่งตัวเลขจริงเข้ามา
ทำไมชุดทดสอบสีเขียวถึงไม่พิสูจน์อะไรเลย
ทุก fixture ในเส้นทางเหล่านี้ใช้ INR การเปรียบเทียบที่มองไม่เห็นสกุลเงินย่อมมองไม่เห็นภายใต้การทดสอบสกุลเงินเดียว เพราะเมื่อมีสกุลเงินเดียวก็ไม่มีอะไรให้สับสน ชุดทดสอบไม่ได้อ่อนแอ แต่มันแคบ และการตรวจสอบที่จะช่วยขยายมันให้กว้างขึ้นก็ถูก A17 ส่งกลับไปเสียแล้ว
ตัวตรวจสอบบัญชีแบบ end-to-end มีความมองไม่เห็นเดียวกัน ซึ่งเป็นส่วนที่ควรจดจำไว้ ตัวตรวจสอบรวมยอดรายการต่อสมาชิกโดยไม่จัดกลุ่มตามสกุลเงิน มันจึงตีปลาหน้าไซบนกลุ่มสองสกุลเงินที่แข็งแรงดี และรวมได้เป็นศูนย์บนกลุ่มที่พังถึงสองครั้ง false negative คือทิศทางที่อันตราย ตัวตรวจสอบที่สร้างบนสมมติฐานเดียวกับโค้ดย่อมเห็นด้วยกับโค้ดเสมอ
| ข้อบกพร่อง | ที่ไหน | สิ่งที่กลุ่ม 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 ไว้อีกหนึ่งสัปดาห์หลังจากที่หลายสกุลเงินปล่อยไปแล้ว และการตรวจสอบครั้งถัดมาก็ข้ามเส้นทางเรื่องเงินไปสามจุดเพราะเชื่อคำนั้น
บทความยอดนิยม
- บัญชีเพิ่มอย่างเดียวที่ทำให้ยอดหารบิลแม่นยำอ่าน 6 นาที
- ทำไมการแก้ไขค่าใช้จ่ายร่วมต้องระบุการแบ่งใหม่อ่าน 5 นาที
- เคลียร์ค่าใช้จ่ายกลุ่มให้จบในไม่กี่การโอนอ่าน 6 นาที
- วิธีแบ่งบิลเมื่อมีจานที่ไม่ได้แชร์กันอ่าน 6 นาที
- คู่มือแบ่งค่าเช่ากับเพื่อนร่วมห้องอย่างเป็นธรรมอ่าน 6 นาที