Langganan sekarang, dan tonton semuanya cuma Rp 185.000.
Kamis, 26 February 2026

Implementasi Brick Money di Laravel: Studi Kasus Aplikasi Akuntansi

Panduan lengkap menggunakan library `brick/money` untuk kalkulasi keuangan presisi tinggi di Laravel, berdasarkan implementasi nyata di aplikasi akuntansi multi-currency.

Panduan lengkap menggunakan library brick/money untuk kalkulasi keuangan presisi tinggi di Laravel, berdasarkan implementasi nyata di aplikasi akuntansi multi-currency.

Mengapa Bukan Float?

Float di PHP (dan hampir semua bahasa) memiliki masalah presisi yang terkenal:

// PHP floating point surprise
echo 0.1 + 0.2; // 0.30000000000000004

// Dalam konteks uang: Rp 100.000 x 10.5% pajak
$harga = 100000;
$pajak = $harga * 10.5 / 100;
// Bisa menghasilkan: 10500.000000000002

Di aplikasi akuntansi, selisih 1 rupiah pun bisa menyebabkan jurnal tidak balance. brick/money menyelesaikan ini dengan menggunakan arbitrary-precision arithmetic yang menjamin hasil kalkulasi selalu tepat.


Instalasi dan Setup

composer require brick/money

Tidak perlu konfigurasi tambahan — library ini standalone dan tidak perlu service provider.


Konsep Dasar

brick/money memiliki tiga konsep utama:

Money — Nilai Uang yang Sudah Dibulatkan

use Brick\Money\Money;

$harga = Money::of(150000, 'IDR');        // Rp 150.000
$diskon = Money::of(25000, 'IDR');         // Rp 25.000
$total = $harga->minus($diskon);           // Rp 125.000

Money selalu memiliki jumlah desimal yang pasti sesuai mata uangnya (IDR = 0 desimal, USD = 2 desimal).

RationalMoney — Presisi Tak Terbatas untuk Kalkulasi Bertingkat

use Brick\Money\Money;
use Brick\Math\RoundingMode;
use Brick\Money\Context\DefaultContext;

// Mulai dengan rational untuk menghindari rounding error di tengah
$subtotal = Money::of(333333, 'IDR')->toRational();
$pajak = $subtotal->multipliedBy('11')->dividedBy('100');

// Baru bulatkan di akhir
$pajakFinal = $pajak->to(new DefaultContext(), RoundingMode::HALF_UP);

RationalMoney menyimpan angka sebagai pecahan (numerator/denominator), sehingga tidak ada presisi yang hilang sampai Anda memutuskan untuk membulatkan.

Context — Aturan Pembulatan per Mata Uang

use Brick\Money\Context\CustomContext;

// IDR: 0 desimal
$contextIDR = new CustomContext(0);
$money = Money::of(1500.7, 'IDR', $contextIDR); // Error! Harus dibulatkan dulu

// USD: 2 desimal (default ISO)
$usd = Money::of(12.50, 'USD'); // OK, 2 desimal adalah default USD

Pola 1: Kalkulasi Sederhana

Use case: Menghitung saldo penjualan setelah pembayaran dan retur.

// app/Services/OverReceiptService.php

use Brick\Money\Money;

public function calculateBalance(Sale $sale): Money
{
    $sale_amount    = Money::of($sale->amount, 'IDR');
    $payment_amount = Money::of($sale->payments->sum('amount'), 'IDR');
    $return_amount  = Money::of($sale->returns->sum('amount'), 'IDR');

    return $sale_amount
        ->minus($payment_amount)
        ->minus($return_amount);
}

public function hasOverpayment(Money $balance): bool
{
    return $balance->isNegative();
}

public function getOverReceiptAmount(Money $balance): int
{
    return $balance->abs()->getAmount()->toInt();
}

Mengapa ini penting?

Tanpa brick/money, kode ini mungkin ditulis:

// JANGAN seperti ini
$balance = $sale->amount - $payments - $returns;

Terlihat sederhana, tapi jika amount dan payments adalah float, hasil pengurangan bisa mengandung error presisi yang membuat $balance == 0 gagal meskipun seharusnya nol.

Dengan brick/money, Anda bisa yakin:

$balance->isZero();     // Tepat
$balance->isNegative(); // Tepat
$balance->isPositive(); // Tepat

Pola 2: Multi-Step Calculation dengan RationalMoney

Use case: Menghitung total Purchase Order dengan item, pajak, diskon persentase, biaya pengiriman, dan biaya tambahan.

Ini adalah pola paling canggih di project ini — semua kalkulasi dilakukan dalam domain RationalMoney, dan pembulatan hanya terjadi satu kali di akhir.

// app/Support/PurchaseOrderCalculator.php

use Brick\Math\RoundingMode;
use Brick\Money\Context\DefaultContext;
use Brick\Money\Money;

public function calculate(array $items, array $options): array
{
    // 1. Inisialisasi akumulator dalam RationalMoney
    $subtotalRational      = Money::of('0', 'IDR')->toRational();
    $totalTaxRational      = Money::of('0', 'IDR')->toRational();
    $totalDiscountRational = Money::of('0', 'IDR')->toRational();
    $totalFeesRational     = Money::of('0', 'IDR')->toRational();

    // 2. Akumulasi per-item TANPA pembulatan
    foreach ($items as $item) {
        $amount = $item['quantity'] * $item['unit_price'];
        $tax    = $amount * ($item['tax_rate'] / 100);

        $subtotalRational = $subtotalRational
            ->plus(Money::of($amount, 'IDR')->toRational());
        $totalTaxRational = $totalTaxRational
            ->plus(Money::of($tax, 'IDR')->toRational());
    }

    // 3. Bulatkan subtotal untuk basis kalkulasi persentase
    $subtotal = $subtotalRational
        ->to(new DefaultContext(), RoundingMode::HALF_UP);

    // 4. Hitung diskon persentase di RationalMoney
    foreach ($discounts as $discount) {
        if ($discount['type'] === 'percentage') {
            $discountRational = $subtotal->toRational()
                ->multipliedBy($discount['value'])
                ->dividedBy('100');
        } else {
            $discountRational = Money::of($discount['value'], 'IDR')
                ->toRational();
        }
        $totalDiscountRational = $totalDiscountRational
            ->plus($discountRational);
    }

    // 5. Grand total — semua operasi di RationalMoney
    $shippingCost = Money::of($options['shipping_cost'] ?? 0, 'IDR');

    $grandTotalRational = $subtotalRational
        ->plus($totalTaxRational)
        ->minus($totalDiscountRational)
        ->plus($shippingCost->toRational())
        ->plus($totalFeesRational);

    // 6. Pembulatan HANYA di akhir
    $grandTotal = $grandTotalRational
        ->to(new DefaultContext(), RoundingMode::HALF_UP);
    $totalTax = $totalTaxRational
        ->to(new DefaultContext(), RoundingMode::HALF_UP);

    return [
        'grand_total' => $grandTotal->getAmount()->__toString(),
        'total_tax'   => $totalTax->getAmount()->__toString(),
    ];
}

Mengapa RationalMoney, bukan Money biasa?

Bayangkan 100 item masing-masing Rp 33.333 dengan pajak 11%:

  • Money: Setiap pajak per-item dibulatkan → 33333 * 11% = 3666.63 → 3667 → total 100 item = 366.700
  • RationalMoney: Pajak per-item tetap pecahan → total = 366.663 → dibulatkan sekali = 366.663 → 366.663

Selisih kecil per-item, tapi terakumulasi di ratusan item.


Pola 3: Multi-Currency Support

Use case: Konversi Purchase Order dari mata uang supplier (USD/EUR) ke IDR.

// Currency model menyediakan factory method
// app/Models/Currency.php

use Brick\Money\Context\CustomContext;
use Brick\Money\Money;

public function toMoney(float|int|null $amount): Money
{
    $rounded_amount = round($amount ?? 0, $this->decimal_places);
    $context = new CustomContext($this->decimal_places);

    try {
        return Money::of($rounded_amount, $this->code, $context);
    } catch (\Brick\Money\Exception\UnknownCurrencyException $e) {
        // Untuk kode mata uang kustom yang tidak ada di ISO
        return Money::of($rounded_amount, 'XXX', $context);
    }
}

Penggunaan di controller untuk konversi dokumen:

// app/Http/Controllers/PurchaseOrderController.php

use Brick\Math\RoundingMode;
use Brick\Money\Money;

// Konversi item PO ke invoice dengan kalkulasi presisi
$items = collect($validated['items'])->map(function ($item) use ($currency_code) {
    return [
        'unit_price'  => Money::of($item['unit_price'], $currency_code)
            ->getAmount()
            ->toFloat(),
        'total_price' => Money::of($item['unit_price'], $currency_code)
            ->multipliedBy($item['remaining_invoice_quantity'], RoundingMode::HALF_UP)
            ->getAmount()
            ->toFloat(),
    ];
});

Poin penting: RoundingMode::HALF_UP wajib dispecify saat multipliedBy() atau dividedBy() karena hasilnya bisa mengandung desimal yang lebih panjang dari context mata uang. Tanpa rounding mode, brick/money akan throw exception.


Pola 4: Konversi ke/dari Database

Menyimpan ke Database

// Dari Money object ke integer (untuk kolom bigInteger)
$debit = Money::of($sale->amount, 'IDR')
    ->getAmount()
    ->toInt();

// Dari Money object ke float (untuk kolom decimal)
$unit_price = Money::of($item['unit_price'], 'USD')
    ->getAmount()
    ->toFloat();

// Dari RationalMoney ke string (untuk presisi penuh)
$grand_total = $grandTotalRational
    ->to(new DefaultContext(), RoundingMode::HALF_UP)
    ->getAmount()
    ->__toString();

Membaca dari Database

// Dari kolom decimal — langsung pakai Money::of()
$amount = Money::of($model->amount, 'IDR');

// Dari kolom bigInteger (minor units) — pakai Money::ofMinor()
$amount = Money::ofMinor($model->amount_minor, 'IDR');

Jurnal Akuntansi

// app/Listeners/PostSaleJournalListener.php

// Debit piutang dagang
$ar_debit = Money::of($sale->amount, 'IDR')->getAmount()->toInt();

// Credit pendapatan per item
foreach ($sale->items as $item) {
    $revenue_credit = Money::of($item['total_price'], 'IDR')
        ->getAmount()
        ->toInt();
}

// Debit HPP dan credit persediaan
$hpp_debit = Money::of($item_cogs, 'IDR')->getAmount()->toInt();
$inventory_credit = Money::of($item_cogs, 'IDR')->getAmount()->toInt();

Dengan brick/money, jurnal yang diposting dijamin balance karena setiap konversi melalui presisi yang terkontrol.


Pola 5: Integrasi dengan Spatie Laravel Data (DTO)

DTO menerima nilai mentah (int/float), dan konversi ke Money terjadi di layer Service/Action:

// app/Data/TransferData.php
class TransferData extends Data
{
    public function __construct(
        public int $amount,  // Minor units (integer)
        // ...
    ) {}

    public static function rules(): array
    {
        return [
            'amount' => ['required', 'integer'],
        ];
    }
}

// Di Service layer
class TransferService
{
    public function execute(TransferData $data): void
    {
        $amount = Money::ofMinor($data->amount, 'IDR');

        // Kalkulasi dengan presisi penuh
        $fee = $amount->multipliedBy('0.01', RoundingMode::HALF_UP);
        $net = $amount->minus($fee);

        // Simpan
        Transfer::query()->create([
            'amount'     => $net->getMinorAmount()->toInt(),
            'fee_amount' => $fee->getMinorAmount()->toInt(),
        ]);
    }
}

Prinsip: DTO hanya transport data. Logika uang ada di Service/Action.


Anti-Pattern yang Harus Dihindari

1. Aritmatika PHP pada Nilai Uang

// SALAH — floating point error
$total = $harga * $quantity;
$pajak = $total * 11 / 100;
$grand = $total + $pajak - $diskon;

// BENAR — brick/money
$total = Money::of($harga, 'IDR')
    ->multipliedBy($quantity, RoundingMode::HALF_UP);
$pajak = $total->toRational()
    ->multipliedBy('11')->dividedBy('100')
    ->to(new DefaultContext(), RoundingMode::HALF_UP);
$grand = $total->plus($pajak)->minus(Money::of($diskon, 'IDR'));

2. Lupa RoundingMode

// SALAH — Exception jika hasil tidak bisa direpresentasikan
$result = $money->multipliedBy(1.5);

// BENAR — selalu specify RoundingMode
$result = $money->multipliedBy(1.5, RoundingMode::HALF_UP);

3. Mencampur Mata Uang

// SALAH — Exception: cannot add different currencies
$idr = Money::of(100000, 'IDR');
$usd = Money::of(10, 'USD');
$total = $idr->plus($usd); // MoneyMismatchException!

// BENAR — konversi dulu ke mata uang yang sama
$rate = $currency->getRateForDate();
$usdInIdr = $usd->multipliedBy($rate, RoundingMode::HALF_UP);
// Tapi ini tetap error karena currency code berbeda!
// Solusi: buat ulang sebagai IDR
$usdConverted = Money::of(
    $usd->getAmount()->toFloat() * $rate,
    'IDR'
);
$total = $idr->plus($usdConverted);

4. Menyimpan Float di Database untuk Uang

// KURANG IDEAL — float bisa kehilangan presisi
$table->float('amount');
$table->double('amount');

// LEBIH BAIK — decimal dengan presisi eksplisit
$table->decimal('amount', 20, 2);

// PALING BAIK — integer minor units
$table->bigInteger('amount'); // Simpan dalam satuan terkecil (sen/rupiah)

5. Membulatkan di Setiap Langkah

// SALAH — rounding error terakumulasi
foreach ($items as $item) {
    $tax = Money::of($item->price, 'IDR')
        ->multipliedBy('0.11', RoundingMode::HALF_UP); // Bulatkan per item
    $totalTax = $totalTax->plus($tax);
}

// BENAR — akumulasi di RationalMoney, bulatkan sekali
foreach ($items as $item) {
    $tax = Money::of($item->price, 'IDR')
        ->toRational()
        ->multipliedBy('11')
        ->dividedBy('100');
    $totalTaxRational = $totalTaxRational->plus($tax);
}
$totalTax = $totalTaxRational->to(new DefaultContext(), RoundingMode::HALF_UP);

Referensi File Project

File Peran
app/Models/Currency.php Model mata uang dengan toMoney(), formatAmount(), parseCurrency()
app/Support/PurchaseOrderCalculator.php Kalkulasi PO dengan RationalMoney (pola paling lengkap)
app/Services/OverReceiptService.php Kalkulasi saldo penjualan dengan Money
app/Listeners/PostSaleJournalListener.php Posting jurnal penjualan dengan konversi Money ke int
app/Listeners/UpdateExpenseJournalListener.php Posting jurnal biaya dengan kalkulasi pajak
app/Http/Controllers/PurchaseOrderController.php Konversi PO ke invoice (multi-currency)
app/Http/Controllers/PurchaseDeliveryController.php Konversi delivery ke invoice
app/Http/Controllers/PurchaseQuotationController.php Konversi quotation ke invoice
app/Utilities/Currency.php Helper functions: formatCurrency(), parseCurrency(), formatCurrencyAccounting()
resources/js/utilities/Currency.ts Frontend formatting: currency(), formatAmount(), parseCurrencyString()

Cheat Sheet

use Brick\Money\Money;
use Brick\Money\Context\DefaultContext;
use Brick\Money\Context\CustomContext;
use Brick\Math\RoundingMode;

// Buat Money
$m = Money::of(100000, 'IDR');
$m = Money::ofMinor(10000000, 'IDR');     // Dari minor units

// Aritmatika
$m->plus(Money::of(5000, 'IDR'));          // Tambah
$m->minus(Money::of(5000, 'IDR'));         // Kurang
$m->multipliedBy(2, RoundingMode::HALF_UP); // Kali
$m->dividedBy(3, RoundingMode::HALF_UP);   // Bagi

// RationalMoney untuk presisi
$r = $m->toRational();
$r->multipliedBy('11')->dividedBy('100');
$final = $r->to(new DefaultContext(), RoundingMode::HALF_UP);

// Konversi
$m->getAmount()->toInt();                   // Ke integer
$m->getAmount()->toFloat();                 // Ke float
$m->getMinorAmount()->toInt();              // Ke minor units (int)
$m->getAmount()->__toString();              // Ke string

// Perbandingan
$m->isZero();
$m->isPositive();
$m->isNegative();
$m->isEqualTo(Money::of(100000, 'IDR'));
$m->isGreaterThan(Money::of(50000, 'IDR'));
$m->isLessThan(Money::of(200000, 'IDR'));

// Custom decimal places
$context = new CustomContext(0);  // 0 desimal (IDR)
$m = Money::of(100000, 'IDR', $context);

Pelajari beragam topik penting

Kami menyediakan beragam topik penting seperti Laravel, React, Next.js, Tailwind CSS, dan banyak lagi yang dapat Anda pelajari untuk meningkatkan level keahlian Anda.

Mulai belajar