
Java คิด VAT เป็นบาทและสตางค์ให้ตรงทุกใบเสร็จ: BigDecimal, setScale และ RoundingMode
ลูกค้าโทรมาถามว่า “ทำไมใบเสร็จบอก 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.1000000000000000055511151231257827021181583404541015625new 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 จากการ “ลบ” ไม่ได้คูณใหม่ ทำแบบนี้ยอดสองส่วนรวมกลับได้เท่าราคาเดิมทุกครั้ง ไม่มีเศษสตางค์หาย
ใช้ตรงไหนในงานจริง
| งาน | แนวทาง |
|---|---|
| รับราคาจากฟอร์ม/API | new 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