นากการ์ตูนสีมิ้นต์ชั่งเหรียญบนตาชั่งทองเหลืองที่แม่นยำ ข้างม้วนกระดาษใบเสร็จ เครื่องคิดเลข และลิ้นชักที่มีเหรียญเรียงเป็นระเบียบ

Java คิด VAT เป็นบาทและสตางค์ให้ตรงทุกใบเสร็จ: BigDecimal, setScale และ RoundingMode

น้องกายEngineering

ลูกค้าโทรมาถามว่า “ทำไมใบเสร็จบอก VAT 2.10 บาท แต่ในระบบบัญชีขึ้น 2.11 บาท” เศษหนึ่งสตางค์แบบนี้ดูเล็ก แต่ถ้าเกิดกับทุกบิลทั้งเดือน ฝ่ายบัญชีต้องนั่งไล่หาที่มาเป็นวันครับ

ต้นเหตุมักมี 3 อย่าง: ใช้ double กับเงิน, สร้าง BigDecimal ผิดวิธี, และไม่ได้ตกลงกันว่าจะปัดเศษตรงไหน วันนี้ผมจะไล่ทีละเรื่องด้วย BigDecimal

ตัวอย่างในบทความใช้อัตรา VAT 7% เพื่อสาธิตการคำนวณเท่านั้น ในระบบจริงควรเก็บอัตราไว้ใน config และตรวจกับประกาศของกรมสรรพากร อย่าเขียนตายตัวในโค้ด

WHY: ทำไม double ใช้กับเงินไม่ได้

  • double เก็บเลขเป็นฐานสอง ทศนิยมอย่าง 0.1 เก็บแบบตรงเป๊ะไม่ได้
  • ตอนผมรัน 0.1 + 0.2 ใน Java ได้ 0.30000000000000004
  • บวกลบไปหลายรอบ เศษเล็กๆ สะสมจนเห็นที่หลักสตางค์

HOW ขั้นที่ 1: สร้าง BigDecimal ให้ถูก

เอกสาร BigDecimal เตือนตรงๆ ว่า constructor new BigDecimal(double) ให้ผล “คาดเดาได้ยาก” เพราะมันเก็บค่า double ตามจริงทุกหลัก

  • new BigDecimal(0.1) ได้ 0.1000000000000000055511151231257827021181583404541015625
  • new BigDecimal("0.1") ได้ 0.1 ตรงตามที่พิมพ์
  • BigDecimal.valueOf(0.1) ก็ได้ 0.1 เพราะแปลงผ่านข้อความของ double

กฎง่ายๆ: รับราคาเป็นข้อความ แล้วสร้างด้วย new BigDecimal("199.00")

HOW ขั้นที่ 2: ปัดเศษด้วย setScale

price.multiply(rate) ได้ผลเต็มทุกหลัก เช่น 199.00 × 0.07 = 13.9300 ต้องปัดให้เหลือ 2 ตำแหน่งด้วย setScale(2, RoundingMode.X)

RoundingMode ที่ใช้บ่อยกับเงิน:

  • HALF_UP ปัดไปหาค่าที่ใกล้ที่สุด ถ้าอยู่กึ่งกลางพอดีให้ปัดขึ้น เช่น 2.345 ได้ 2.35 แบบที่เรียนกันในโรงเรียน
  • HALF_EVEN ถ้าอยู่กึ่งกลางพอดี ให้ปัดไปหาเลขคู่ เช่น 2.345 ได้ 2.34 บางคนเรียก “banker’s rounding” ช่วยลดการปัดขึ้นสะสมเมื่อรวมเลขจำนวนมาก

ไม่มีแบบไหนถูกเสมอ สิ่งที่สำคัญคือ ทั้งระบบต้องใช้แบบเดียวกัน และตรงกับที่ฝ่ายบัญชีกำหนด

HOW ขั้นที่ 3: ปัดต่อบรรทัด หรือปัดที่ยอดรวม

นี่คือที่มาของเศษหนึ่งสตางค์ในเรื่องข้างบน สินค้าราคา 10.05 บาท 3 ชิ้น:

  • ปัด VAT ทีละบรรทัด: 10.05 × 0.07 = 0.7035 ปัดเป็น 0.70 รวม 3 บรรทัด = 2.10
  • ปัดที่ยอดรวม: 30.15 × 0.07 = 2.1105 ปัดเป็น 2.11

ทั้งสองแบบ “ถูก” ทางคณิตศาสตร์ แต่ถ้าใบเสร็จใช้แบบหนึ่ง ระบบบัญชีใช้อีกแบบ ยอดจะไม่ตรงกัน ให้ตกลงกฎกับฝ่ายบัญชีแล้วเขียนเทสต์ล็อกไว้

กับดักที่เจอบ่อย

  • divide โดยไม่บอกจำนวนตำแหน่ง ถ้าผลหารไม่จบ เช่น 10 ÷ 3 จะโยน ArithmeticException ต้องใช้ divide(x, 2, RoundingMode.HALF_UP)
  • equals ดูทั้งค่าและจำนวนตำแหน่ง (scale) 2.50 กับ 2.5 จึง equals เป็น false ถ้าจะเทียบค่า ใช้ compareTo(...) == 0
  • เพราะเหตุนี้ อย่าใช้ BigDecimal ที่ scale ปนกันเป็น key ของ HashMap หรือใส่ HashSet โดยไม่ปรับ scale ให้เท่ากันก่อน

โค้ดพร้อม assertions

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;

public class Main {
    static final BigDecimal VAT_RATE = new BigDecimal("0.07"); // ตัวอย่าง: ควรมาจาก config
    static final RoundingMode MODE = RoundingMode.HALF_UP;

    static BigDecimal baht(BigDecimal v) {
        return v.setScale(2, MODE);
    }

    static BigDecimal vatPerLine(List<BigDecimal> lines) {
        BigDecimal total = BigDecimal.ZERO;
        for (BigDecimal line : lines) total = total.add(baht(line.multiply(VAT_RATE)));
        return total;
    }

    static BigDecimal vatOnTotal(List<BigDecimal> lines) {
        BigDecimal sum = BigDecimal.ZERO;
        for (BigDecimal line : lines) sum = sum.add(line);
        return baht(sum.multiply(VAT_RATE));
    }

    /** ราคารวม VAT แล้ว แยกเป็น [ก่อน VAT, VAT] */
    static BigDecimal[] splitInclusive(BigDecimal gross) {
        BigDecimal net = gross.divide(BigDecimal.ONE.add(VAT_RATE), 2, MODE);
        return new BigDecimal[] {net, gross.subtract(net)};
    }

    public static void main(String[] args) {
        // double เพี้ยน
        assert 0.1 + 0.2 != 0.3;

        // สร้างจาก double ได้ค่าไม่ตรง สร้างจากข้อความได้ตรง
        assert !new BigDecimal(0.1).equals(new BigDecimal("0.1"));
        assert BigDecimal.valueOf(0.1).equals(new BigDecimal("0.1"));

        // คูณแล้วปัด
        BigDecimal price = new BigDecimal("199.00");
        assert price.multiply(VAT_RATE).toPlainString().equals("13.9300");
        assert baht(price.multiply(VAT_RATE)).equals(new BigDecimal("13.93"));

        // HALF_UP กับ HALF_EVEN ต่างกันตอนอยู่กึ่งกลางพอดี
        assert new BigDecimal("2.345").setScale(2, RoundingMode.HALF_UP).equals(new BigDecimal("2.35"));
        assert new BigDecimal("2.345").setScale(2, RoundingMode.HALF_EVEN).equals(new BigDecimal("2.34"));

        // ปัดต่อบรรทัด vs ปัดที่ยอดรวม
        List<BigDecimal> lines = List.of(new BigDecimal("10.05"), new BigDecimal("10.05"), new BigDecimal("10.05"));
        assert vatPerLine(lines).equals(new BigDecimal("2.10"));
        assert vatOnTotal(lines).equals(new BigDecimal("2.11"));

        // แยก VAT จากราคารวม
        BigDecimal[] a = splitInclusive(new BigDecimal("107.00"));
        assert a[0].equals(new BigDecimal("100.00")) && a[1].equals(new BigDecimal("7.00"));
        BigDecimal[] b = splitInclusive(new BigDecimal("1000.00"));
        assert b[0].equals(new BigDecimal("934.58")) && b[1].equals(new BigDecimal("65.42"));
        assert b[0].add(b[1]).equals(new BigDecimal("1000.00")); // รวมกลับได้ครบ

        // divide ที่ไม่จบต้องบอก scale
        boolean threw = false;
        try {
            new BigDecimal("10").divide(new BigDecimal("3"));
        } catch (ArithmeticException e) {
            threw = true;
        }
        assert threw;
        assert new BigDecimal("10").divide(new BigDecimal("3"), 2, MODE).equals(new BigDecimal("3.33"));

        // equals ดู scale ด้วย
        assert !new BigDecimal("2.50").equals(new BigDecimal("2.5"));
        assert new BigDecimal("2.50").compareTo(new BigDecimal("2.5")) == 0;

        System.out.println("ok");
    }
}

ตอนตรวจบทความ โค้ดนี้คอมไพล์ด้วย javac และรันด้วย java -ea (เปิด assertions) บน JDK 21 ของเครื่องนี้

สังเกตฟังก์ชัน splitInclusive: ผมคิดยอดก่อน VAT ด้วยการหารแล้วปัด จากนั้นได้ VAT จากการ “ลบ” ไม่ได้คูณใหม่ ทำแบบนี้ยอดสองส่วนรวมกลับได้เท่าราคาเดิมทุกครั้ง ไม่มีเศษสตางค์หาย

ใช้ตรงไหนในงานจริง

งานแนวทาง
รับราคาจากฟอร์ม/APInew BigDecimal(text) ไม่ผ่าน double
คำนวณ VAT/ส่วนลดคูณแบบเต็มหลัก แล้ว setScale(2, MODE) จุดเดียวตามกฎที่ตกลง
แยก VAT จากราคารวมหารหายอดก่อน VAT แล้วลบหา VAT
เทียบยอดเงินcompareTo ไม่ใช่ equals
เก็บลงฐานข้อมูลคอลัมน์ทศนิยมแบบตายตัว (DECIMAL/NUMERIC) หรือเก็บเป็นสตางค์จำนวนเต็ม

Recap

  • เลิกใช้ double กับเงิน
  • สร้าง BigDecimal จากข้อความ new BigDecimal("199.00")
  • เลือก RoundingMode แบบเดียวทั้งระบบ ตามที่ฝ่ายบัญชีกำหนด
  • ตกลงให้ชัดว่าปัด VAT ต่อบรรทัดหรือที่ยอดรวม แล้วเขียนเทสต์ล็อกไว้
  • divide ต้องบอก scale และ RoundingMode เสมอ
  • เทียบเงินด้วย compareTo และเก็บอัตรา VAT ไว้ใน config
  • รันโค้ดด้านบนด้วย java -ea เพื่อเปิด assertions