จัดการ "เงิน" ใน PHP ให้ถูกต้อง: Money Value Object vs brick/money (สำหรับ Laravel/CI4)

โดย CyberMAN

เคยเจอบั๊กแบบนี้ไหมครับ... ลูกค้าโทรมาบอกว่าโดนเก็บเงินเกิน 1 สตางค์ แล้วทีมบัญชีก็ส่งมาถามว่า "ทำไมยอดมันไม่ตรง?" พอไปดูโค้ดก็เจอว่าเราเก็บราคาเป็น float หรือ decimal แล้วบวกลบกันไปมาจนค่ามันคลาดเคลื่อน นี่ไม่ใช่บั๊กเล็ก ๆ เลยครับ เพราะมันเกี่ยวกับ "เงิน" ของลูกค้าโดยตรง

ปัญหานี้เกิดจากแนวคิดพื้นฐานที่หลายคนมองข้าม คือ "เงิน" ไม่ใช่แค่ตัวเลข มันคือจำนวนเงินที่ผูกอยู่กับสกุลเงิน และมีกฎเกณฑ์ทางธุรกิจที่ตัวเลขล้วน ๆ ไม่สามารถบอกเราได้ เช่น เราไม่สามารถเอา 10 USD ไปบวกกับ 10 EUR ได้ตรง ๆ และเมื่อต้องหารเงินแบบไม่ลงตัว ใครจะเป็นคนรับเศษสตางค์ที่เหลือ?

ในบทความนี้ เราจะมาดูกันว่าทำไมการเก็บเงินด้วยตัวเลขธรรมดาถึงอันตราย แล้วจะแก้ปัญหาด้วยการสร้าง Value Object ของตัวเอง หรือจะใช้ไลบรารีสำเร็จรูปอย่าง brick/money ดีกว่ากัน รวมถึงแนวทางการออกแบบให้ทั้งสองแบบสามารถสลับใช้งานกันได้ในอนาคต เหมาะสำหรับคนที่กำลังพัฒนาเว็บแอปด้วย CodeIgniter 4 หรือ Laravel ครับ

🔍 ปัญหาที่ซ่อนอยู่ 3 อย่างของการเก็บเงินแบบ "ตัวเลขดิบ"

ก่อนจะไปถึงทางแก้ เรามาดูกันก่อนว่าทำไมการเก็บราคาเป็น float หรือแม้แต่ int/decimal แบบไม่มีการจัดการที่ดี ถึงนำไปสู่ปัญหาในระยะยาว

1. ปัญหา Floating Point (จุดทศนิยมไม่เที่ยง)

คอมพิวเตอร์เก็บเลขทศนิยมในรูปแบบ binary fraction ซึ่งตัวเลขอย่าง 0.10 ไม่สามารถแทนค่าได้แม่นยำ 100% ในระบบไบนารี เมื่อเราบวกราคาสินค้าหลายพันรายการเข้าด้วยกัน ความผิดพลาดเล็ก ๆ นี้จะสะสมจนกลายเป็นยอดเงินที่ผิดจริง ๆ

2. ปัญหาสกุลเงินที่หายไป

ตัวเลขเปล่า ๆ อย่าง 10 ไม่สามารถบอกได้ว่าหมายถึง 10 ดอลลาร์ หรือ 10 เยน ถ้าระบบของเรามีหลายสกุลเงิน แล้วดันเผลอเอายอด USD ไปบวกกับ JPY ตรง ๆ โดยไม่มีการตรวจสอบ ผลลัพธ์ที่ได้ก็จะเป็นตัวเลขที่ "รันได้" แต่ไม่มีความหมายทางธุรกิจเลย

3. ปัญหากฎการปัดเศษ (Rounding)

ลองสมมติว่าต้องหารส่วนลด 10.00 บาท ออกเป็น 3 รายการเท่า ๆ กัน จะได้ 3.333... บาทต่อรายการ คำถามคือใครจะรับเศษสตางค์ที่เหลือไป? ถ้าเราปล่อยให้แต่ละจุดในโค้ดปัดเศษกันเอง โดยไม่มีกฎกลางที่ชัดเจน ผลรวมที่ได้อาจไม่เท่ากับยอดตั้งต้น และนี่คือต้นเหตุของรายงานบัญชีที่ไม่ลงตัว

ปัญหาสาเหตุทางแก้
Floating point ไม่เที่ยงเก็บเงินเป็น float/decimal โดยตรงเก็บเป็นจำนวนเต็มในหน่วยย่อยที่สุด (สตางค์/เซนต์)
สกุลเงินหายไปใช้ตัวเลขเปล่า ๆ แทนเงินผูกสกุลเงินไว้กับจำนวนเงินเสมอ
ปัดเศษไม่สม่ำเสมอปัดเศษกระจายอยู่ทั่วโค้ดกำหนดกฎปัดเศษเป็น method กลางในโดเมน

🛠️ แนวทางที่ 1: สร้าง Money Value Object เอง

แนวคิดหลักของ Value Object คือการสร้างคลาสที่ immutable (เปลี่ยนค่าไม่ได้หลังสร้าง) ปลอดภัยเรื่องสกุลเงิน และเก็บจำนวนเงินเป็นจำนวนเต็มในหน่วยย่อยที่สุด เช่น เก็บ 199.99 บาท เป็น 19999 สตางค์

เริ่มจากกำหนดสกุลเงินเป็น enum เพื่อให้ PHP ช่วยตรวจสอบความถูกต้องให้เราตั้งแต่ตอน compile:

Currency.php
enum Currency: string
{
    case USD = 'USD';
    case EUR = 'EUR';
    case THB = 'THB';
    case JPY = 'JPY';

    public function minorUnitDecimals(): int
    {
        return match ($this) {
            Currency::JPY => 0,
            default => 2,
        };
    }
}

สังเกตว่า minorUnitDecimals() สำคัญมาก เพราะ JPY ไม่มีทศนิยม ในขณะที่ USD, EUR, THB มี 2 ตำแหน่ง ถ้าเราใช้สูตรปัดเศษเดียวกันกับทุกสกุลเงิน ผลลัพธ์ของเยนจะผิดทันที

ต่อมาคือคลาส Money ที่เป็น Value Object หลัก:

Money.php
final class Money
{
    private function __construct(
        private readonly int $amountInMinorUnits,
        private readonly Currency $currency,
    ) {}

    public static function fromMinorUnits(int $amount, Currency $currency): self
    {
        return new self($amount, $currency);
    }

    public function add(Money $other): self
    {
        $this->assertSameCurrency($other);

        return new self(
            $this->amountInMinorUnits + $other->amountInMinorUnits,
            $this->currency,
        );
    }

    private function assertSameCurrency(Money $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException(
                'Cannot operate on different currencies.'
            );
        }
    }

    public function getAmount(): int
    {
        return $this->amountInMinorUnits;
    }

    public function getCurrency(): Currency
    {
        return $this->currency;
    }
}

จุดสำคัญของโค้ดนี้คือ assertSameCurrency() ถ้ามีการเอา Money สองสกุลที่ไม่ตรงกันมาบวกกัน ระบบจะ throw exception ทันที แทนที่จะให้ผลลัพธ์เป็นตัวเลขผิด ๆ ที่กลายเป็นปัญหาในอีก 3 สัปดาห์ข้างหน้า

การจัดการเรื่องปัดเศษด้วย Allocation

ส่วนที่ยากที่สุดของการจัดการเงินไม่ใช่การบวกลบ แต่คือการ "หาร" ลองดูตัวอย่างการแบ่งส่วนลด 1000 สตางค์ออกเป็น 3 ส่วน:

Money.php — allocate()
final class Money
{
    // ... โค้ดด้านบน

    /**
     * แบ่งจำนวนเงินออกเป็น N ส่วน โดยผลรวมต้องเท่ากับ
     * จำนวนเงินตั้งต้นเสมอ เศษสตางค์ที่เหลือจะถูกมอบให้
     * กับส่วนแรก ๆ เพื่อไม่ให้มีเงินงอกหรือหายไป
     */
    public function allocate(int $parts): array
    {
        $base = intdiv($this->amountInMinorUnits, $parts);
        $remainder = $this->amountInMinorUnits % $parts;

        $result = [];
        for ($i = 0; $i < $parts; $i++) {
            $amount = $base + ($i < $remainder ? 1 : 0);
            $result[] = new self($amount, $this->currency);
        }

        return $result;
    }
}
💡 ตัวอย่าง: ถ้าเราแบ่ง 1000 สตางค์ออกเป็น 3 ส่วน จะได้ 334, 333, 333 ซึ่งรวมกันได้ 1000 พอดี ไม่มีเงินงอกขึ้นมาจากไหน และไม่มีสตางค์หายไปไหน กฎการปัดเศษนี้ถูกเขียนไว้เป็น method ที่อ่านง่าย ทดสอบได้ และเป็นเจ้าของโดยโดเมนของเราเอง

📦 แนวทางที่ 2: ใช้ brick/money ไลบรารีสำเร็จรูป

เมื่อระบบของเราซับซ้อนขึ้น เช่น ต้องรองรับหลายสกุลเงินจริงจัง ต้องแปลงสกุลเงินตามอัตราแลกเปลี่ยน หรือต้องใช้กฎการปัดเศษหลายรูปแบบ (round half up, round half even ฯลฯ) การเขียน Value Object เองอาจไม่คุ้มค่าอีกต่อไป ตรงนี้แหละที่ไลบรารี brick/money เข้ามาช่วยได้

ติดตั้งผ่าน Composer:

terminal
composer require brick/money

ตัวอย่างการใช้งานเบื้องต้น:

example.php
use Brick\Money\Money;
use Brick\Math\RoundingMode;

$price = Money::of('199.99', 'THB');
$discount = $price->multipliedBy('0.1', RoundingMode::HALF_UP);

$finalPrice = $price->minus($discount);

echo $finalPrice->getAmount(); // 179.99
echo $finalPrice->getCurrency()->getCurrencyCode(); // THB

brick/money จัดการเรื่อง currency, rounding mode, และการแบ่งส่วน (allocation) ให้ครบแล้ว ทำให้เราไม่ต้องเขียนโค้ดเหล่านี้ซ้ำเอง

🚪 ทางออกที่ยั่งยืน: ซ่อน brick/money ไว้หลัง Port ของโดเมนตัวเอง

คำถามคือ ถ้าใช้ brick/money โดยตรงทั่วทั้งโปรเจกต์ แล้ววันหนึ่งอยากเปลี่ยนไลบรารี หรืออยากเพิ่มกฎทางธุรกิจเฉพาะของบริษัทเราเองล่ะ? คำตอบคือการสร้าง Port หรือ interface ของโดเมนเราเองขึ้นมาครอบไว้อีกชั้น

MoneyServiceInterface.php
interface MoneyServiceInterface
{
    public function create(string $amount, string $currency): MoneyValue;

    public function add(MoneyValue $a, MoneyValue $b): MoneyValue;

    public function allocate(MoneyValue $money, int $parts): array;
}

จากนั้นสร้าง implementation ที่ใช้ brick/money อยู่ภายใน:

BrickMoneyService.php
use Brick\Money\Money as BrickMoney;
use Brick\Math\RoundingMode;

class BrickMoneyService implements MoneyServiceInterface
{
    public function create(string $amount, string $currency): MoneyValue
    {
        $money = BrickMoney::of($amount, $currency);

        return new MoneyValue(
            $money->getMinorAmount()->toInt(),
            $currency
        );
    }

    public function add(MoneyValue $a, MoneyValue $b): MoneyValue
    {
        $brickA = BrickMoney::ofMinor($a->amount, $a->currency);
        $brickB = BrickMoney::ofMinor($b->amount, $b->currency);

        $result = $brickA->plus($brickB);

        return new MoneyValue(
            $result->getMinorAmount()->toInt(),
            $a->currency
        );
    }

    public function allocate(MoneyValue $money, int $parts): array
    {
        $brick = BrickMoney::ofMinor($money->amount, $money->currency);
        $ratios = array_fill(0, $parts, 1);

        $allocated = $brick->allocate(...$ratios);

        return array_map(
            fn ($m) => new MoneyValue($m->getMinorAmount()->toInt(), $money->currency),
            $allocated
        );
    }
}

ในโค้ด Controller หรือ Service ของ Laravel/CodeIgniter 4 เราจะเรียกผ่าน MoneyServiceInterface เสมอ ไม่เรียก BrickMoney ตรง ๆ:

OrderService.php
class OrderService
{
    public function __construct(
        private MoneyServiceInterface $moneyService,
    ) {}

    public function splitDiscount(string $amount, string $currency, int $items): array
    {
        $money = $this->moneyService->create($amount, $currency);

        return $this->moneyService->allocate($money, $items);
    }
}
💡 ข้อดีของแนวทางนี้: ถ้าวันหนึ่งทีมตัดสินใจเปลี่ยนจาก brick/money ไปใช้ไลบรารีอื่น หรือกลับมาเขียน Value Object เองทั้งหมด เราแค่เขียน implementation ใหม่ของ MoneyServiceInterface โดยไม่ต้องแก้โค้ดส่วน business logic เลยแม้แต่บรรทัดเดียว นี่คือหลักการเดียวกับ Hexagonal Architecture ที่ว่า "กฎการปัดเศษอยู่ในโดเมนของเรา ส่วนไลบรารีเป็นแค่ตัวที่สลับเปลี่ยนได้"

📌 สรุป

การจัดการ "เงิน" ในแอปพลิเคชัน PHP ไม่ใช่เรื่องของการเลือก data type ให้ถูกต้องเพียงอย่างเดียว แต่เป็นเรื่องของการออกแบบให้กฎทางธุรกิจ เช่น การปัดเศษ การแบ่งส่วน และการตรวจสอบสกุลเงิน อยู่ในที่เดียวที่ชัดเจนและทดสอบได้ ไม่ว่าจะเลือกเขียน Value Object เอง หรือใช้ brick/money สิ่งสำคัญคือต้องห่อหุ้มมันไว้หลัง interface ของโดเมนตัวเอง เพื่อให้ระบบยืดหยุ่นและรองรับการเปลี่ยนแปลงในอนาคตได้โดยไม่ต้องรื้อทั้งโปรเจกต์

สำหรับโปรเจกต์เล็ก ๆ ที่มีสกุลเงินเดียวและกฎไม่ซับซ้อน การเขียน Value Object เองก็เพียงพอและช่วยให้โค้ดเบาขึ้น แต่ถ้าโปรเจกต์เริ่มโตขึ้นและต้องรองรับหลายสกุลเงินหรือกฎการปัดเศษที่ซับซ้อน brick/money พร้อม Port ของตัวเองคือคำตอบที่คุ้มค่ากว่าในระยะยาว




PHP CI MANIA - PHP Code Generator 

โปรแกรมช่วยสร้างโค้ด "ลดเวลาการเขียนโปรแกรม"
ราคาสุดคุ้ม  
http://www.phpcodemania.com