Enam bagian sebelumnya membuktikan perilaku aplikasi lewat walkthrough: kita menjalankan kode, membaca output, dan memastikan angkanya masuk akal. Cara itu jujur, tetapi tidak skala — tidak ada yang mau mengulang enam walkthrough setiap kali satu baris kode berubah, dan mata manusia cepat lelah sehingga regresi bisa lolos diam-diam. Pada aplikasi keuangan, taruhannya juga berbeda dari aplikasi biasa: tombol yang bergeser satu piksel tidak diperhatikan siapa pun, tetapi saldo yang meleset satu sen adalah bug, titik — karena Bagian 1 sampai 6 berulang kali menjanjikan angka yang eksak sampai sen terakhir. Bagian penutup ini menyerahkan pembuktian kepada PHPUnit: sembilan test menjalankan invarian paling mahal (saldo integer sen, aturan jenis-vs-kategori, budget dari transaksi nyata, idempotensi recurring dan impor) terhadap database :memory: yang segar untuk setiap test, dan seluruh suite selesai dalam belasan milidetik.

Setelah kebenaran kode terjaga, kita jaga kebenaran lingkungan tempat kode berjalan. Proyek dikemas menjadi image Docker berbasis php:8.4-cli-alpine yang membaca lokasi database dari environment variable DB_PATH, memigrasi schema saat container pertama kali menyala, lalu menutup prosesnya dengan smoke command yang melaporkan kesiapan beserta jumlah transaksi di dalamnya. Bagian ini sekaligus menutup tujuh bagian proyek dengan rekap singkat.

Invarian yang Wajib Dijaga Aplikasi Keuangan #

Test yang baik tidak menguji “apakah kode berjalan”, melainkan invarian: pernyataan tentang keadaan yang harus tetap benar bagaimanapun kode berubah. Aplikasi keuangan punya tiga keluarga invarian yang paling mahal ketika dilanggar: aritmetika uang, aturan domain, dan idempotensi. Ketiganya sudah kita tanam sejak Bagian 1 — sekarang kita pasang alarm di atasnya.

Kelompok pertama, aritmetika. Uang disimpan sebagai integer sen sejak Bagian 1, dan alasan paling nyatanya bisa didemonstrasikan dalam tujuh baris:

php
<?php
$rupiah = 0.1 + 0.2;  // Rp0,10 + Rp0,20 sebagai float
var_dump($rupiah);
var_dump($rupiah === 0.3);

$sen = 10 + 20;       // 10 sen + 20 sen sebagai integer
var_dump($sen === 30);

Output yang Dihasilkan:

text
float(0.30000000000000004)
bool(false)
bool(true)

Binary floating point tidak dapat merepresentasikan 0,3 secara eksak, sehingga perbandingan === menghasilkan false. Aplikasi keuangan menjumlahkan ribuan transaksi dan membandingkan saldo dengan batas budget setiap hari — galat sepersekian rupiah menumpuk menjadi selisih yang nyata. Integer sen tidak punya masalah itu, dan tugas suite test kita adalah memastikan tidak ada jalur kode yang diam-diam mengembalikan kebiasaan float. Kelompok kedua adalah aturan domain: transaksi pengeluaran tidak boleh memakai kategori pemasukan, budget hanya untuk kategori pengeluaran, dan masukan yang janggal ditolak sebelum menyentuh database. Kelompok ketiga adalah idempotensi: proses yang sah bila dijalankan sekali harus tetap sah bila dijalankan dua kali — gaji bulanan yang dobel karena cron berjalan ulang adalah bencana klasik.

Tabel berikut memetakan invarian proyek ini ke test yang menjaganya:

InvarianBila dilanggarTest penjaga
Saldo dihitung dari integer sen, eksakPembulatan float menggerus saldo tanpa jejaktestCatatMenyimpanTransaksiDanMenaikkanSaldo
Jenis transaksi searah dengan kategoriPengeluaran tercatat di kategori Gaji, laporan bohongtestCatatMenolakJenisBerbedaDariKategori
Masukan janggal ditolak sebelum INSERTBaris sampah masuk ke tabel transaksitestCatatMenolakInputTidakValid
Pembatalan bersih dan tidak bisa diulangSaldo bocor, transaksi hantutestDibatalkanMenghapusBarisDanMengembalikanData
Budget dihitung dari transaksi nyataSisa budget memakai angka basitestBudgetSisaDihitungDariTransaksiNyata
Budget hanya untuk kategori pengeluaranBatas dikenakan pada pemasukantestBudgetMenolakKategoriPemasukan
generateFor() idempoten per bulanCron ulang menduplikasi gaji dan cicilantestGenerateForIdempotenUntukBulanYangSama
Hari target melebihi akhir bulan digeserTransaksi rutin tanggal 31 hilang di FebruaritestGenerateForMenggeserHariKeAkhirBulan
Impor round-trip tidak menggandakan dataPemulihan cadangan menduplikasi seluruh bulantestImporCsvIdempotenSetelahPemulihan

Sebelum masuk ke PHPUnit, mari kita bongkar apa yang sebenarnya diminta sebuah test: satu pernyataan boolean tentang keadaan yang dijanjikan, plus laporan ketika pernyataan tersebut gagal. Skrip berikut membangun database :memory: dari schema.sql, mencatat satu transaksi lewat TransactionService, lalu memeriksa empat janji — simpan sebagai uji-manual.php di folder bagian ini dan jalankan:

php
<?php
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/AccountRepository.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/CategoryRepository.php';
require __DIR__ . '/../bagian-3-pencatatan-transaksi/src/TransactionRepository.php';
require __DIR__ . '/../bagian-3-pencatatan-transaksi/src/TransactionService.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-domain-dan-database/schema.sql'));

$akun = new AccountRepository($pdo);
$service = new TransactionService(
    $akun,
    new CategoryRepository($pdo),
    new TransactionRepository($pdo),
);

$service->catat(
    2,
    3,
    'pemasukan',
    5000000,
    'Bunga tabungan Februari',
    new DateTimeImmutable('2026-02-15 09:00:00', new DateTimeZone('Asia/Jakarta')),
    new DateTimeImmutable('2026-02-15 09:05:00', new DateTimeZone('Asia/Jakarta')),
);

$lulus = 0;
$gagal = 0;

$periksa = function (string $janji, bool $terpenuhi) use (&$lulus, &$gagal): void {
    if ($terpenuhi) {
        $lulus++;
        echo "ok    $janji\n";
    } else {
        $gagal++;
        echo "GAGAL $janji\n";
    }
};

$periksa(
    'saldo BCA kini tepat 2100735567 sen',
    $akun->saldo(2) === 2100735567
);
$periksa(
    'baris ke-10 menyimpan jumlah 5000000 sen',
    (int) (new TransactionRepository($pdo))->cariId(10)['jumlah'] === 5000000
);

$ditolak = false;
try {
    $service->catat(
        1,
        2,
        'pengeluaran',
        100000,
        'Pengeluaran memakai kategori Gaji',
        new DateTimeImmutable('2026-01-20 08:00:00', new DateTimeZone('Asia/Jakarta')),
        new DateTimeImmutable('2026-01-20 08:05:00', new DateTimeZone('Asia/Jakarta')),
    );
} catch (InvalidArgumentException) {
    $ditolak = true;
}

$periksa('pengeluaran dengan kategori pemasukan ditolak', $ditolak);
$periksa(
    'penolakan tidak meninggalkan baris (tetap 10 transaksi)',
    (int) $pdo->query('SELECT COUNT(*) FROM transactions')->fetchColumn() === 10
);

echo "\nlulus=$lulus gagal=$gagal\n";
exit($gagal === 0 ? 0 : 1);

Output yang Dihasilkan:

text
ok    saldo BCA kini tepat 2100735567 sen
ok    baris ke-10 menyimpan jumlah 5000000 sen
ok    pengeluaran dengan kategori pemasukan ditolak
ok    penolakan tidak meninggalkan baris (tetap 10 transaksi)

lulus=4 gagal=0

Empat janji, empat baris ok, exit code 0. Perhatikan dua keputusan pada skrip ini. Pertama, nilai yang diharapkan ditulis sebagai literal (2100735567), bukan dihitung ulang dengan rumus yang sama seperti kode produksi — oracle yang independen itulah yang membuat sebuah assertion bermakna; bila kode dan test sama-sama salah dengan cara yang sama, tidak ada yang akan tahu. Kedua, penolakan diuji dengan pola try/catch plus variabel bendera karena setelah catch kita masih ingin memeriksa keadaan database — jumlah baris tetap sepuluh (sembilan seed ditambah satu transaksi yang sah), tanpa sampah yang tertinggal dari percobaan yang ditolak. PHPUnit pada dasarnya adalah mesin untuk menjalankan pola ini ribuan kali dengan laporan yang jauh lebih kaya; dasar-dasarnya kita bahas di chapter Menulis PHPUnit Tests .

Bootstrap: Satu Titik Muat untuk Seluruh Proyek #

PHPUnit tidak tahu apa-apa tentang struktur kursus ini — class hidup di folder src/ milik enam bagian yang bersaudara di bawah aplikasi-keuangan-pribadi/, tanpa autoloader Composer. Opsi --bootstrap memberi kita satu titik muat: berkas berikut dijalankan sekali sebelum test pertama, memuat sembilan berkas class lewat path relatif, lalu mendefinisikan dua konstanta path menuju schema dan migrasi:

php
<?php

declare(strict_types=1);

/**
 * Bootstrap PHPUnit untuk suite aplikasi keuangan pribadi.
 *
 * Tugas berkas ini satu: memuat seluruh class proyek SEKALI sebelum
 * test pertama berjalan, lewat path relatif antar folder bagian.
 *
 * Pada proyek nyata, class hidup dalam satu folder src/ dengan
 * namespace dan autoloader Composer, sehingga berkas semacam ini
 * cukup memanggil require 'vendor/autoload.php'. Pada bundle kursus
 * ini, class justru tersebar di folder src/ milik masing-masing
 * bagian agar setiap halaman bagian mandiri - folder bagian
 * bersaudara di bawah aplikasi-keuangan-pribadi/, maka dari tests/
 * kita naik dua tingkat ('/../..') lalu masuk ke bagian tujuan.
 * require_once menjaga berkas yang sama tidak dimuat dua kali bila
 * suite diperluas dengan berkas test lain.
 *
 * Suite ini menguji TransactionService sebagai jantung alur, lalu
 * ditutup grup yang memakai fixture sama untuk BudgetService,
 * RecurringTransactionService, dan ImportExportService - itulah
 * sebabnya class Bagian 4-6 ikut dimuat di sini.
 */

require_once __DIR__ . '/../../bagian-2-akun-dan-kategori/src/uang.php';
require_once __DIR__ . '/../../bagian-2-akun-dan-kategori/src/AccountRepository.php';
require_once __DIR__ . '/../../bagian-2-akun-dan-kategori/src/CategoryRepository.php';
require_once __DIR__ . '/../../bagian-3-pencatatan-transaksi/src/TransactionRepository.php';
require_once __DIR__ . '/../../bagian-3-pencatatan-transaksi/src/TransactionService.php';
require_once __DIR__ . '/../../bagian-4-budget-dan-recurring-transactions/src/BudgetService.php';
require_once __DIR__ . '/../../bagian-4-budget-dan-recurring-transactions/src/RecurringTransactionService.php';
require_once __DIR__ . '/../../bagian-5-laporan-dan-visualisasi/src/ReportService.php';
require_once __DIR__ . '/../../bagian-6-import-export/src/ImportExportService.php';

/**
 * Path absolut ke schema.sql Bagian 1 dan migrasi Bagian 4 - satu-
 * satunya sumber struktur database. setUp() tiap test membaca dan
 * mengeksekusi keduanya berurutan pada koneksi :memory: yang baru,
 * sehingga fixture selalu identik (3 akun, 8 kategori, 9 transaksi).
 */
const SCHEMA_KEUANGAN = __DIR__ . '/../../bagian-1-domain-dan-database/schema.sql';
const MIGRASI_KEUANGAN = __DIR__ . '/../../bagian-4-budget-dan-recurring-transactions/migrasi-budget-recurring.sql';

Dua hal layak dicermati. Pertama, arah path: dari tests/bootstrap.php, '../..' naik dua tingkat — keluar dari folder tests/ lalu keluar dari folder bagian ini — dan mendarat di root proyek aplikasi-keuangan-pribadi/, sehingga /bagian-2-akun-dan-kategori/src/uang.php mengarah ke folder bagian yang bersaudara. require_once menjaga berkas yang sama tidak dimuat dua kali bila suite diperluas dengan berkas test lain, dan pola ini persis yang pada proyek nyata digantikan satu baris require 'vendor/autoload.php' — topik chapter Composer dan Dependency Management dan autoloading PSR-4. Kedua, konstanta SCHEMA_KEUANGAN dan MIGRASI_KEUANGAN menunjuk langsung ke schema.sql Bagian 1 dan migrasi-budget-recurring.sql Bagian 4 — satu sumber kebenaran untuk struktur database, bukan salinan schema di dalam test. Bila schema berubah, test otomatis mengikuti; penyimpangan antara schema produksi dan schema test adalah kelas bug klasik yang dengan desain ini tidak punya tempat bersembunyi.

Sembilan Test untuk Invarian Paling Mahal #

Berikut isi lengkap tests/TransactionServiceTest.php — sembilan test yang membungkus janji terpenting seluruh proyek:

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

/**
 * Suite untuk invarian inti aplikasi keuangan pribadi: TransactionService
 * sebagai gerbang tunggal pencatatan, budget yang menghitung sisa dari
 * transaksi nyata, idempotensi RecurringTransactionService, dan ditutup
 * round-trip ImportExportService dari Bagian 6 - semuanya berbagi fixture
 * seed yang sama dari schema.sql Bagian 1.
 *
 * Class dimuat oleh tests/bootstrap.php, bukan oleh berkas ini;
 * jalankan dari folder bagian ini dengan:
 *   php phpunit.phar tests/TransactionServiceTest.php --bootstrap tests/bootstrap.php
 */
final class TransactionServiceTest extends TestCase
{
    private PDO $pdo;

    private TransactionService $service;

    private BudgetService $budget;

    private RecurringTransactionService $recurring;

    protected function setUp(): void
    {
        // Database :memory: baru untuk setiap test: terisolasi, cepat,
        // dan schema.sql + migrasi selalu menghasilkan fixture identik
        // (3 akun, 8 kategori, 9 transaksi Januari 2026).
        $this->pdo = new PDO('sqlite::memory:');
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->pdo->exec('PRAGMA foreign_keys = ON');
        $this->pdo->exec((string) file_get_contents(SCHEMA_KEUANGAN));
        $this->pdo->exec((string) file_get_contents(MIGRASI_KEUANGAN));

        $this->service = new TransactionService(
            new AccountRepository($this->pdo),
            new CategoryRepository($this->pdo),
            new TransactionRepository($this->pdo),
        );
        $this->budget = new BudgetService($this->pdo);
        $this->recurring = new RecurringTransactionService($this->pdo);
    }

    public function testCatatMenyimpanTransaksiDanMenaikkanSaldo(): void
    {
        $jam = new DateTimeImmutable('2026-02-15 09:05:00', new DateTimeZone('Asia/Jakarta'));

        $id = $this->service->catat(
            2,
            3,
            'pemasukan',
            5000000,
            'Bunga tabungan Februari',
            new DateTimeImmutable('2026-02-15 09:00:00', new DateTimeZone('Asia/Jakarta')),
            $jam
        );

        // Seed berisi 9 transaksi; baris baru adalah yang ke-10.
        $this->assertSame(10, $id);

        $baris = (new TransactionRepository($this->pdo))->cariId($id);
        $this->assertSame(2, (int) $baris['account_id']);
        $this->assertSame(3, (int) $baris['category_id']);
        $this->assertSame('pemasukan', $baris['jenis']);
        $this->assertSame(5000000, (int) $baris['jumlah']);
        $this->assertSame('Bunga tabungan Februari', $baris['keterangan']);
        $this->assertSame('2026-02-15 09:00:00', $baris['terjadi_pada']);
        $this->assertSame('2026-02-15 09:05:00', $baris['dibuat_pada']);

        // Saldo BCA seed Rp20.957.355,67 + Rp50.000,00 baru.
        $akun = new AccountRepository($this->pdo);
        $this->assertSame(2095735567 + 5000000, $akun->saldo(2));
        $this->assertSame(10, $this->hitungTransaksi());
    }

    public function testCatatMenolakJenisBerbedaDariKategori(): void
    {
        try {
            $this->service->catat(
                1,
                2,
                'pengeluaran',
                100000,
                'Pengeluaran dengan kategori Gaji',
                new DateTimeImmutable('2026-01-20 08:00:00', new DateTimeZone('Asia/Jakarta')),
                new DateTimeImmutable('2026-01-20 08:05:00', new DateTimeZone('Asia/Jakarta'))
            );
            $this->fail('catat() seharusnya menolak jenis yang berbeda dari arah kategori.');
        } catch (InvalidArgumentException $e) {
            $this->assertSame(
                "Kategori 'Gaji' bertipe pemasukan, tidak bisa dipakai untuk transaksi pengeluaran",
                $e->getMessage()
            );
        }

        // Penolakan terjadi sebelum INSERT: jumlah baris tetap 9.
        $this->assertSame(9, $this->hitungTransaksi());
    }

    public function testCatatMenolakInputTidakValid(): void
    {
        $jam = new DateTimeImmutable('2026-01-20 08:05:00', new DateTimeZone('Asia/Jakarta'));
        $terjadi = new DateTimeImmutable('2026-01-20 08:00:00', new DateTimeZone('Asia/Jakarta'));

        $kasus = [
            ['jenis asing', fn () => $this->service->catat(1, 4, 'transfer', 100, 'Jenis asing', $terjadi, $jam)],
            ['jumlah nol', fn () => $this->service->catat(1, 4, 'pengeluaran', 0, 'Nol', $terjadi, $jam)],
            ['keterangan kosong', fn () => $this->service->catat(1, 4, 'pengeluaran', 100, '   ', $terjadi, $jam)],
            ['akun tidak ada', fn () => $this->service->catat(99, 4, 'pengeluaran', 100, 'Akun hantu', $terjadi, $jam)],
            ['kategori tidak ada', fn () => $this->service->catat(1, 99, 'pengeluaran', 100, 'Kategori hantu', $terjadi, $jam)],
        ];

        foreach ($kasus as [$label, $coba]) {
            try {
                $coba();
                $this->fail("catat() seharusnya menolak kasus: $label.");
            } catch (InvalidArgumentException) {
                // Ditolak seperti diharapkan - pesan spesifiknya sudah
                // dijelaskan pada Bagian 3; di sini cukup jenisnya.
            }
        }

        // Lima penolakan tidak meninggalkan satu baris pun.
        $this->assertSame(9, $this->hitungTransaksi());
    }

    public function testDibatalkanMenghapusBarisDanMengembalikanData(): void
    {
        $akun = new AccountRepository($this->pdo);
        $sebelum = $akun->saldo(3);

        $id = $this->service->catat(
            3,
            7,
            'pengeluaran',
            1500000,
            'Pulsa — nominal salah',
            new DateTimeImmutable('2026-01-12 20:00:00', new DateTimeZone('Asia/Jakarta')),
            new DateTimeImmutable('2026-01-12 20:05:00', new DateTimeZone('Asia/Jakarta'))
        );
        $this->assertSame($sebelum - 1500000, $akun->saldo(3));

        $dibatalkan = $this->service->dibatalkan($id);
        $this->assertSame('Pulsa — nominal salah', $dibatalkan['keterangan']);
        $this->assertSame($sebelum, $akun->saldo(3));
        $this->assertSame(9, $this->hitungTransaksi());

        // Pembatalan kedua untuk id yang sama ditolak.
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage("Transaksi #$id tidak ditemukan");
        $this->service->dibatalkan($id);
    }

    public function testBudgetSisaDihitungDariTransaksiNyata(): void
    {
        $this->budget->setBudget(4, '2026-01', 50000000);

        $awal = $this->budget->ringkas(4, '2026-01');
        $this->assertSame(50000000, $awal['batas']);
        $this->assertSame(2550000, $awal['terpakai']); // warung Bu Siti dari seed
        $this->assertSame(47450000, $awal['sisa']);
        $this->assertFalse($awal['lewat']);
        $this->assertSame(47450000, $this->budget->sisa(4, '2026-01'));

        $this->service->catat(
            1,
            4,
            'pengeluaran',
            48000000,
            'Makan di restoran hotel',
            new DateTimeImmutable('2026-01-25 20:00:00', new DateTimeZone('Asia/Jakarta')),
            new DateTimeImmutable('2026-01-25 20:05:00', new DateTimeZone('Asia/Jakarta'))
        );

        $akhir = $this->budget->ringkas(4, '2026-01');
        $this->assertSame(50550000, $akhir['terpakai']);
        $this->assertSame(-550000, $akhir['sisa']);
        $this->assertTrue($akhir['lewat']);
    }

    public function testBudgetMenolakKategoriPemasukan(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage("Budget hanya untuk kategori pengeluaran; kategori 'Gaji' bertipe pemasukan");

        $this->budget->setBudget(2, '2026-01', 1000000);
    }

    public function testGenerateForIdempotenUntukBulanYangSama(): void
    {
        $this->recurring->define(2, 2, 850000000, 'Gaji bulanan', 2);
        $this->recurring->define(2, 6, 5499000, 'Langganan streaming', 7);
        $this->recurring->define(2, 8, 75000000, 'Cicilan kartu kredit', 28);

        $jam = new DateTimeImmutable('2026-02-01 08:00:00', new DateTimeZone('Asia/Jakarta'));

        $this->assertSame(3, $this->recurring->generateFor('2026-02', $jam));
        $this->assertSame(12, $this->hitungTransaksi());

        // Eksekusi kedua untuk bulan yang sama: nol transaksi baru.
        $this->assertSame(0, $this->recurring->generateFor('2026-02', $jam));
        $this->assertSame(12, $this->hitungTransaksi());

        // Baris hasil cetakan membawa jejak template_id dan bulan.
        $stmt = $this->pdo->prepare('SELECT template_id, bulan FROM transactions WHERE id = ?');
        $stmt->execute([10]);
        $jejak = $stmt->fetch(PDO::FETCH_ASSOC);
        $this->assertSame(1, (int) $jejak['template_id']);
        $this->assertSame('2026-02', $jejak['bulan']);
    }

    public function testGenerateForMenggeserHariKeAkhirBulan(): void
    {
        $this->recurring->define(2, 8, 75000000, 'Cicilan kartu kredit', 31);

        $jam = new DateTimeImmutable('2026-02-01 08:00:00', new DateTimeZone('Asia/Jakarta'));
        $this->assertSame(1, $this->recurring->generateFor('2026-02', $jam));

        // Februari 2026 punya 28 hari: hari 31 bergeser ke 28.
        $stmt = $this->pdo->prepare('SELECT terjadi_pada FROM transactions WHERE id = ?');
        $stmt->execute([10]);
        $this->assertSame('2026-02-28 08:00:00', $stmt->fetchColumn());
    }

    public function testImporCsvIdempotenSetelahPemulihan(): void
    {
        $io = new ImportExportService($this->pdo, new TransactionRepository($this->pdo));
        $categories = new CategoryRepository($this->pdo);
        $laporan = new ReportService($this->pdo);

        $resolver = function (string $nama, string $jenis) use ($categories): ?int {
            foreach ($categories->byJenis($jenis) as $k) {
                if ($k['nama'] === $nama) {
                    return (int) $k['id'];
                }
            }

            return null;
        };

        $csv = $io->eksporCsv('2026-01-01 00:00:00', '2026-01-31 23:59:59');
        $this->assertSame(9, substr_count($csv, "\n") - 1);
        $saldoSebelum = $laporan->ringkasanBulanan('2026-01')['total_saldo_akhir'];

        $this->pdo->exec('DELETE FROM transactions');
        $this->assertSame(0, $this->hitungTransaksi());

        $jam = new DateTimeImmutable('2026-02-01 07:00:00', new DateTimeZone('Asia/Jakarta'));
        $pemulihan = $io->imporCsv($csv, $resolver, $jam);
        $this->assertSame(9, $pemulihan['imported']);
        $this->assertSame(0, $pemulihan['skipped']);
        $this->assertSame(0, $pemulihan['rejected']);
        $this->assertSame(9, $this->hitungTransaksi());
        $this->assertSame($saldoSebelum, $laporan->ringkasanBulanan('2026-01')['total_saldo_akhir']);

        $ulang = $io->imporCsv($csv, $resolver, $jam);
        $this->assertSame(0, $ulang['imported']);
        $this->assertSame(9, $ulang['skipped']);
        $this->assertSame(9, $this->hitungTransaksi());
    }

    private function hitungTransaksi(): int
    {
        return (int) $this->pdo->query('SELECT COUNT(*) FROM transactions')->fetchColumn();
    }
}

Fixture yang Selalu Identik di setUp #

Setiap test membangun koneksi sqlite::memory: yang baru di setUp(), menjalankan schema.sql lalu migrasi Bagian 4 di atasnya, dan membentuk tiga service dari koneksi tersebut. Konsekuensinya: fixture selalu identik (3 akun, 8 kategori, 9 transaksi Januari 2026), test tidak pernah berbagi keadaan, urutan eksekusi bebas, dan kegagalan satu test tidak menular ke tetangganya. Pola “database segar per test” untuk SQLite in-memory ini dibahas lebih mendalam di chapter Database Testing . Seluruh waktu dikirim sebagai DateTimeImmutable yang dibekukan — service kita memang tidak pernah membaca jam sistem, sehingga test pun menentukan penuh apa yang tersimpan di kolom terjadi_pada dan dibuat_pada.

Mencatat: Aritmetika Sen yang Bisa Diaudit #

testCatatMenyimpanTransaksiDanMenaikkanSaldo mencatat bunga tabungan Rp50.000,00 ke Rekening BCA, lalu memeriksa delapan field baris hasil satu per satu — id dari nilai kembalian catat(), tujuh kolom lainnya dari baris yang dibaca ulang lewat cariId(). Pemeriksaan saldonya adalah inti bagian ini: assertSame(2095735567 + 5000000, $akun->saldo(2)). Angka harapan dihitung dalam integer sen murni — tidak ada pembagian, tidak ada float, tidak ada tempat bagi pembulatan untuk bersembunyi. assertSame sekaligus menuntut tipe sama, sehingga bila suatu saat saldo() berubah mengembalikan string atau float, test langsung merah meskipun nilainya “kebetulan” sama. Inilah bentuk nyata dari janji “eksak sampai sen terakhir”.

Menolak yang Salah Arah #

Tiga test berikutnya menjaga gerbang catat(). testCatatMenolakJenisBerbedaDariKategori mencoba mencatat pengeluaran dengan kategori Gaji dan menuntut pesan penolakan yang persis — pesan error adalah antarmuka pengguna juga, dan perubahan yang tak disengaja padanya adalah regresi. Pola try/catch plus $this->fail() dipakai karena setelah catch kita masih memeriksa bahwa jumlah baris tetap sembilan: penolakan harus terjadi sebelum INSERT, bukan sesudahnya. testCatatMenolakInputTidakValid merangkum lima kasus janggal dalam tabel closure — jenis asing, jumlah nol, keterangan spasi, akun hantu, kategori hantu — lalu menuntut kelima-limanya ditolak tanpa meninggalkan satu baris pun. testDibatalkanMenghapusBarisDanMengembalikanData memastikan pembatalan mengembalikan saldo ke semula, menyerahkan data transaksi ke pemanggil, dan menolak pembatalan kedua untuk id yang sama.

Budget dari Transaksi Nyata #

testBudgetSisaDihitungDariTransaksiNyata menetapkan budget Makanan Januari sebesar 50.000.000 sen — Rp500.000,00 dalam format helper kita. Terpakai awal 2.550.000 sen datang sendiri dari seed (makan siang di warung Bu Siti), sehingga sisa 47.500.000 sen. Setelah satu transaksi restoran senilai 48.000.000 sen dicatat lewat catat(), ringkas() melaporkan terpakai 50.550.000 sen, sisa minus 550.000 sen, dan lewat menjadi true. Poin strukturalnya: BudgetService tidak menyimpan penghitung terpakai — angka itu selalu di-SUM ulang dari tabel transactions, sehingga transaksi baru yang dicatat lewat jalur mana pun langsung tercermin. Test terakhir pada kelompok ini memastikan setBudget() menolak kategori pemasukan seperti Gaji dengan pesan yang menyebut alasannya.

Idempotensi: Recurring dan Round-Trip Impor #

testGenerateForIdempotenUntukBulanYangSama mendefinisikan tiga cetakan rutin (gaji, langganan streaming, cicilan), menjalankan generateFor('2026-02') dan mendapat 3 transaksi, lalu menjalankan yang sama untuk bulan yang sama dan mendapat 0 — jumlah baris tetap 12. Index UNIQUE (template_id, bulan) dari migrasi Bagian 4 adalah mesin penolaknya; test juga memeriksa bahwa baris hasil cetakan membawa jejak template_id dan bulan, bukan sekadar “kebetulan tiga baris baru”. testGenerateForMenggeserHariKeAkhirBulan menjaga kasus pinggir: cetakan hari 31 pada Februari 2026 bergeser ke tanggal 28 tanpa galat. Terakhir, testImporCsvIdempotenSetelahPemulihan memutar round-trip penuh dari Bagian 6: ekspor 9 baris Januari, DELETE FROM transactions, impor kembali (9 imported, 0 skipped, 0 rejected), saldo total Januari kembali ke angka yang sama persis, dan impor ulang yang sama melewati seluruh 9 baris sebagai duplikat.

Menjalankan PHPUnit #

Dari folder bagian-7-testing-dan-deployment, jalankan PHPUnit dengan berkas test dan bootstrap kita (sesuaikan path phpunit.phar dengan lokasi berkasnya di mesin Anda):

bash
php phpunit.phar tests/TransactionServiceTest.php --bootstrap tests/bootstrap.php

Hasil eksekusi nyata pada PHP 8.4.23 dengan PHPUnit 12.5.39:

text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

.........                                                           9 / 9 (100%)

Time: 00:00.016, Memory: 28.05 MB

OK (9 tests, 47 assertions)

Sembilan titik adalah sembilan test yang lulus, dan 47 assertions berarti 47 pemeriksaan individual semuanya benar — lebih dari lima kali lipat jumlah testnya, karena satu test memeriksa banyak sisi sekaligus. Baris Time dan Memory berbeda-beda antar mesin, tetapi baris OK beserta jumlahnya stabil. Tambahkan opsi --testdox untuk tampilan yang terbaca seperti daftar perilaku yang dijanjikan:

bash
php phpunit.phar tests/TransactionServiceTest.php --bootstrap tests/bootstrap.php --testdox
text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

.........                                                           9 / 9 (100%)

Time: 00:00.015, Memory: 28.05 MB

Transaction Service
 ✔ Catat menyimpan transaksi dan menaikkan saldo
 ✔ Catat menolak jenis berbeda dari kategori
 ✔ Catat menolak input tidak valid
 ✔ Dibatalkan menghapus baris dan mengembalikan data
 ✔ Budget sisa dihitung dari transaksi nyata
 ✔ Budget menolak kategori pemasukan
 ✔ Generate for idempoten untuk bulan yang sama
 ✔ Generate for menggeser hari ke akhir bulan
 ✔ Impor csv idempoten setelah pemulihan

OK (9 tests, 47 assertions)

Daftar bercentang itulah spesifikasi aplikasi keuangan kita dalam bentuk paling ringkas: saldo naik tepat, transaksi salah arah ditolak, budget dihitung jujur, gaji tidak pernah dobel, dan pemulihan cadangan tidak menggandakan data. Setiap kali kode berubah, satu perintah memastikan kesembilan janji itu masih berlaku — dan bila salah satu rusak, nama test yang gagal langsung menunjuk janji mana yang dilanggar.

Mengemas Aplikasi dengan Docker #

Test menjaga kebenaran kode; deployment menjaga kebenaran lingkungan tempat kode berjalan. “Berjalan di mesin saya” berhenti menjadi alasan begitu aplikasi dikirim sebagai image: versi PHP, ekstensi, struktur folder, dan schema database ikut terkemas. Dockerfile berikut merangkumnya — dibangun dari root proyek agar seluruh bagian berada dalam build context:

dockerfile
# Dockerfile — image aplikasi keuangan pribadi (Bagian 7).
#
# Build dari folder aplikasi-keuangan-pribadi/ (konteks build),
# bukan dari dalam folder bagian ini:
#   docker build -f bagian-7-testing-dan-deployment/Dockerfile -t keuangan:kursus .

FROM php:8.4-cli-alpine

# Konfigurasi lewat environment variable: satu-satunya hal yang boleh
# berbeda antar lingkungan tanpa membangun ulang image.
ENV DB_PATH=/data/keuangan.sqlite

WORKDIR /app

# Schema (Bagian 1), migrasi (Bagian 4), dan seluruh class + helper
# (Bagian 2-6) disalin merata ke /app: dua berkas SQL di root,
# sembilan berkas PHP di src/.
COPY bagian-1-domain-dan-database/schema.sql ./schema.sql
COPY bagian-4-budget-dan-recurring-transactions/migrasi-budget-recurring.sql ./migrasi.sql
COPY bagian-2-akun-dan-kategori/src/ ./src/
COPY bagian-3-pencatatan-transaksi/src/ ./src/
COPY bagian-4-budget-dan-recurring-transactions/src/ ./src/
COPY bagian-5-laporan-dan-visualisasi/src/ ./src/
COPY bagian-6-import-export/src/ ./src/

# Direktori database milik user non-root; volume yang di-mount ke
# /data akan menimpanya saat runtime.
RUN mkdir -p /data && chown www-data:www-data /data

# Proses berjalan sebagai user non-root: bila proses di dalam
# container berhasil dieksploitasi, penyerang tidak langsung
# mendapat akses root.
USER www-data

# Health check: buka database di DB_PATH dan pastikan tabel
# transactions terbaca. Hanya bermakna bila CMD diganti entry point
# yang berjalan lama (worker, scheduler) — pada container sekali-jalan
# ia tidak sempat dievaluasi, tetapi resepnya sudah siap.
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
    CMD php -r '$pdo = new PDO("sqlite:" . getenv("DB_PATH")); exit($pdo->query("SELECT COUNT(*) FROM transactions") !== false ? 0 : 1);'

# Image ini memaketkan class library, bukan aplikasi interaktif —
# proyek kursus tidak membangun CLI console. CMD menjalankan
# verifikasi siap: muat seluruh class, terapkan schema dan migrasi
# bila database masih kosong (seed schema kita TIDAK idempoten,
# sehingga guard keberadaan tabel wajib — berbeda dari proyek yang
# menaburkan seed lewat INSERT OR IGNORE), lalu laporkan kesiapan.
# Untuk produksi, ganti titik ini dengan entry point nyata: console
# command, worker antrean, atau web root di depan php:8.4-fpm-alpine.
CMD ["php", "-r", "foreach (glob('src/*.php') as $f) { require $f; } $db = getenv('DB_PATH'); $pdo = new PDO('sqlite:' . $db); $tabel = (int) $pdo->query(\"SELECT COUNT(*) FROM pragma_table_info('accounts')\")->fetchColumn(); if ($tabel === 0) { $pdo->exec((string) file_get_contents('schema.sql')); } $kolom = (int) $pdo->query(\"SELECT COUNT(*) FROM pragma_table_info('transactions') WHERE name = 'template_id'\")->fetchColumn(); if ($kolom === 0) { $pdo->exec((string) file_get_contents('migrasi.sql')); } echo 'keuangan siap: ' . $db . ' (' . $pdo->query('SELECT COUNT(*) FROM transactions')->fetchColumn() . ' transaksi)' . PHP_EOL;"]

Baris demi baris: FROM php:8.4-cli-alpine memilih image PHP resmi yang kecil; ekstensi pdo_sqlite sudah terbundel di dalamnya sehingga tidak ada langkah docker-php-ext-install. ENV DB_PATH=/data/keuangan.sqlite menerapkan satu aturan penting dari chapter Environment Variables : satu-satunya hal yang boleh berbeda antar lingkungan adalah environment variable — image yang sama bisa dipakai development dan produksi hanya dengan mengganti nilai DB_PATH. Tujuh baris COPY meratakan schema, migrasi, dan sembilan berkas class dari enam bagian menjadi layout /app yang bersih, sesuai konvensi satu class per berkas yang dibahas di chapter Docker dan PHP . Direktori /data disiapkan milik user www-data dan USER www-data menjalankan proses sebagai non-root: bila proses di dalam container berhasil dieksploitasi, penyerang tidak langsung mendapat akses root.

HEALTHCHECK membuka database di DB_PATH dan memastikan tabel transactions terbaca — resep yang bermakna penuh bila CMD diganti entry point yang berjalan lama (worker, scheduler), karena orchestrator seperti Docker Compose atau Kubernetes memakainya untuk memutuskan kapan container perlu direstart. Bagian ini diilhami chapter Health Checks .

CMD adalah jantungnya: muat seluruh class, buka database di DB_PATH, lalu migrasi dengan dua gerbang. Gerbang pertama memeriksa pragma_table_info('accounts'): bila tabel belum ada, schema.sql dijalankan. Gerbang kedua memeriksa kolom template_id pada transactions: bila belum ada, migrasi Bagian 4 dijalankan. Baru setelah itu container mencetak keuangan siap: /data/keuangan.sqlite (9 transaksi). Dua gerbang itu bukan hiasan:

Perintah membangun dan menjalankannya:

bash
cd aplikasi-keuangan-pribadi
docker build -f bagian-7-testing-dan-deployment/Dockerfile -t keuangan:kursus .
docker run --rm keuangan:kursus

Untuk kebutuhan produksi, tiga penyesuaian lazim dilakukan. Pertama, database sebaiknya tidak dibuat di dalam layer container melainkan dipasang sebagai volume (docker run -v keuangan-data:/data ...) agar data bertahan lintas rilis image. Kedua, CMD smoke ini adalah alat verifikasi, bukan aplikasi — ganti dengan entry point nyata: console command interaktif, worker antrean, atau web root di depan php:8.4-fpm-alpine. Ketiga, ekspor cadangan CSV/JSON dari Bagian 6 tetap menjadi ritual sebelum setiap pergantian image: migrasi punya gerbang, tetapi cadangan adalah satu-satunya jalan mundur.

Checklist Deployment #

Sebelum aplikasi naik dari mesin pengembang ke lingkungan yang dijalankan orang lain, berikut daftar periksa yang kita pakai untuk proyek ini — dan yang layak Anda pakai untuk proyek berikutnya:

LangkahAlasan
Jalankan suite PHPUnit hingga OK sebelum buildRegresi tertangkap sebelum menyeberang lingkungan
Bangun image dari root proyek dengan -f ke Dockerfile bagian iniBuild context harus memuat tujuh bagian; COPY gagal cepat bila struktur tidak utuh
Simpan database di volume, bukan di dalam imageData pengguna tidak ikut terbuang setiap kali image diganti
Ekspor cadangan CSV/JSON sebelum migrasiMigrasi punya gerbang, tetapi cadangan adalah satu-satunya jalan mundur
Jalankan container sebagai user non-rootCelah eksploitasi tidak langsung menjadi akses root
Pasang HEALTHCHECK bila proses berjalan lamaOrchestrator tahu kapan container perlu direstart
Pin versi PHP dan pantau status dukungannyaUpgrade PHP jadi rencana terjadwal, bukan kejutan darurat
Ganti smoke CMD dengan entry point produksiImage kursus memverifikasi kesiapan; aplikasi nyata melayani pengguna

Rekap Tujuh Bagian Proyek #

Aplikasi keuangan pribadi genap tujuh bagian. Perjalanan panjangnya bisa dipetakan begini:

BagianFondasi yang diletakkan
1. Domain dan DatabaseUang sebagai integer sen, schema dengan CHECK constraint, seed waktu beku
2. Akun dan KategoriRepository PDO disuntikkan, saldo dihitung ulang dari transaksi, formatRupiah()
3. Pencatatan TransaksiTransactionService sebagai gerbang tunggal, validasi jenis-vs-kategori, jam beku
4. Budget dan RecurringUPSERT budget, migrasi ALTER TABLE, idempotensi lewat UNIQUE (template_id, bulan)
5. Laporan dan VisualisasiRingkasan bulanan, arus kas, persentase basis point dengan intdiv, grafik teks
6. Import/ExportCSV lewat php://temp, JSON, validasi impor per baris, round-trip terbukti
7. Testing dan DeploymentSembilan test 47 assertion, image Docker non-root dengan migrasi tergerbang saat start

Benang merahnya konsisten dari hari pertama: setiap keputusan yang diambil karena alasan domain (sen bukan rupiah, saldo dihitung ulang bukan disimpan, idempotensi disandarkan constraint database) berbuah menjadi test yang mudah ditulis dan deployment yang mudah dipercaya. Bila seluruh aturan hidup di satu gerbang service dengan waktu yang disuntikkan, mengujinya hanyalah soal memanggil gerbang itu dengan masukan beku lalu membandingkan hasil dengan literal. Anda tidak perlu berandai-andai tentang kode yang tidak bisa diuji, karena sejak Bagian 1 tidak ada yang ditulis dengan cara itu.

Cara Menjalankan Contoh #

Susunan folder yang dibutuhkan bagian ini — struktur antarbagian harus utuh karena test memuat class lintas bagian:

text
aplikasi-keuangan-pribadi/
├── bagian-1-domain-dan-database/
│   └── schema.sql
├── bagian-2-akun-dan-kategori/
│   └── src/
│       ├── AccountRepository.php
│       ├── CategoryRepository.php
│       └── uang.php
├── bagian-3-pencatatan-transaksi/
│   └── src/
│       ├── TransactionRepository.php
│       └── TransactionService.php
├── bagian-4-budget-dan-recurring-transactions/
│   ├── migrasi-budget-recurring.sql
│   └── src/
│       ├── BudgetService.php
│       └── RecurringTransactionService.php
├── bagian-5-laporan-dan-visualisasi/
│   └── src/
│       └── ReportService.php
├── bagian-6-import-export/
│   └── src/
│       └── ImportExportService.php
└── bagian-7-testing-dan-deployment/
    ├── Dockerfile
    └── tests/
        ├── TransactionServiceTest.php
        └── bootstrap.php

Test dijalankan dari folder bagian ketujuh; skrip uji-manual.php dari awal bagian ini disimpan di folder yang sama dan dijalankan dengan php uji-manual.php:

bash
cd bagian-7-testing-dan-deployment
php phpunit.phar tests/TransactionServiceTest.php --bootstrap tests/bootstrap.php
php uji-manual.php

Seluruh test memakai :memory: sehingga tidak meninggalkan berkas apa pun di disk; baris Time dan Memory pada laporan PHPUnit bervariasi antar mesin. Docker membangun dari root proyek karena build context harus memuat seluruh bagian, dan container menyimpan berkas database di /data sebagaimana dirancang.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

tests/bootstrap.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Bootstrap PHPUnit untuk suite aplikasi keuangan pribadi.
 *
 * Tugas berkas ini satu: memuat seluruh class proyek SEKALI sebelum
 * test pertama berjalan, lewat path relatif antar folder bagian.
 *
 * Pada proyek nyata, class hidup dalam satu folder src/ dengan
 * namespace dan autoloader Composer, sehingga berkas semacam ini
 * cukup memanggil require 'vendor/autoload.php'. Pada bundle kursus
 * ini, class justru tersebar di folder src/ milik masing-masing
 * bagian agar setiap halaman bagian mandiri - folder bagian
 * bersaudara di bawah aplikasi-keuangan-pribadi/, maka dari tests/
 * kita naik dua tingkat ('/../..') lalu masuk ke bagian tujuan.
 * require_once menjaga berkas yang sama tidak dimuat dua kali bila
 * suite diperluas dengan berkas test lain.
 *
 * Suite ini menguji TransactionService sebagai jantung alur, lalu
 * ditutup grup yang memakai fixture sama untuk BudgetService,
 * RecurringTransactionService, dan ImportExportService - itulah
 * sebabnya class Bagian 4-6 ikut dimuat di sini.
 */

require_once __DIR__ . '/../../bagian-2-akun-dan-kategori/src/uang.php';
require_once __DIR__ . '/../../bagian-2-akun-dan-kategori/src/AccountRepository.php';
require_once __DIR__ . '/../../bagian-2-akun-dan-kategori/src/CategoryRepository.php';
require_once __DIR__ . '/../../bagian-3-pencatatan-transaksi/src/TransactionRepository.php';
require_once __DIR__ . '/../../bagian-3-pencatatan-transaksi/src/TransactionService.php';
require_once __DIR__ . '/../../bagian-4-budget-dan-recurring-transactions/src/BudgetService.php';
require_once __DIR__ . '/../../bagian-4-budget-dan-recurring-transactions/src/RecurringTransactionService.php';
require_once __DIR__ . '/../../bagian-5-laporan-dan-visualisasi/src/ReportService.php';
require_once __DIR__ . '/../../bagian-6-import-export/src/ImportExportService.php';

/**
 * Path absolut ke schema.sql Bagian 1 dan migrasi Bagian 4 - satu-
 * satunya sumber struktur database. setUp() tiap test membaca dan
 * mengeksekusi keduanya berurutan pada koneksi :memory: yang baru,
 * sehingga fixture selalu identik (3 akun, 8 kategori, 9 transaksi).
 */
const SCHEMA_KEUANGAN = __DIR__ . '/../../bagian-1-domain-dan-database/schema.sql';
const MIGRASI_KEUANGAN = __DIR__ . '/../../bagian-4-budget-dan-recurring-transactions/migrasi-budget-recurring.sql';

tests/TransactionServiceTest.php

Unduh berkas
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

/**
 * Suite untuk invarian inti aplikasi keuangan pribadi: TransactionService
 * sebagai gerbang tunggal pencatatan, budget yang menghitung sisa dari
 * transaksi nyata, idempotensi RecurringTransactionService, dan ditutup
 * round-trip ImportExportService dari Bagian 6 - semuanya berbagi fixture
 * seed yang sama dari schema.sql Bagian 1.
 *
 * Class dimuat oleh tests/bootstrap.php, bukan oleh berkas ini;
 * jalankan dari folder bagian ini dengan:
 *   php phpunit.phar tests/TransactionServiceTest.php --bootstrap tests/bootstrap.php
 */
final class TransactionServiceTest extends TestCase
{
    private PDO $pdo;

    private TransactionService $service;

    private BudgetService $budget;

    private RecurringTransactionService $recurring;

    protected function setUp(): void
    {
        // Database :memory: baru untuk setiap test: terisolasi, cepat,
        // dan schema.sql + migrasi selalu menghasilkan fixture identik
        // (3 akun, 8 kategori, 9 transaksi Januari 2026).
        $this->pdo = new PDO('sqlite::memory:');
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->pdo->exec('PRAGMA foreign_keys = ON');
        $this->pdo->exec((string) file_get_contents(SCHEMA_KEUANGAN));
        $this->pdo->exec((string) file_get_contents(MIGRASI_KEUANGAN));

        $this->service = new TransactionService(
            new AccountRepository($this->pdo),
            new CategoryRepository($this->pdo),
            new TransactionRepository($this->pdo),
        );
        $this->budget = new BudgetService($this->pdo);
        $this->recurring = new RecurringTransactionService($this->pdo);
    }

    public function testCatatMenyimpanTransaksiDanMenaikkanSaldo(): void
    {
        $jam = new DateTimeImmutable('2026-02-15 09:05:00', new DateTimeZone('Asia/Jakarta'));

        $id = $this->service->catat(
            2,
            3,
            'pemasukan',
            5000000,
            'Bunga tabungan Februari',
            new DateTimeImmutable('2026-02-15 09:00:00', new DateTimeZone('Asia/Jakarta')),
            $jam
        );

        // Seed berisi 9 transaksi; baris baru adalah yang ke-10.
        $this->assertSame(10, $id);

        $baris = (new TransactionRepository($this->pdo))->cariId($id);
        $this->assertSame(2, (int) $baris['account_id']);
        $this->assertSame(3, (int) $baris['category_id']);
        $this->assertSame('pemasukan', $baris['jenis']);
        $this->assertSame(5000000, (int) $baris['jumlah']);
        $this->assertSame('Bunga tabungan Februari', $baris['keterangan']);
        $this->assertSame('2026-02-15 09:00:00', $baris['terjadi_pada']);
        $this->assertSame('2026-02-15 09:05:00', $baris['dibuat_pada']);

        // Saldo BCA seed Rp20.957.355,67 + Rp50.000,00 baru.
        $akun = new AccountRepository($this->pdo);
        $this->assertSame(2095735567 + 5000000, $akun->saldo(2));
        $this->assertSame(10, $this->hitungTransaksi());
    }

    public function testCatatMenolakJenisBerbedaDariKategori(): void
    {
        try {
            $this->service->catat(
                1,
                2,
                'pengeluaran',
                100000,
                'Pengeluaran dengan kategori Gaji',
                new DateTimeImmutable('2026-01-20 08:00:00', new DateTimeZone('Asia/Jakarta')),
                new DateTimeImmutable('2026-01-20 08:05:00', new DateTimeZone('Asia/Jakarta'))
            );
            $this->fail('catat() seharusnya menolak jenis yang berbeda dari arah kategori.');
        } catch (InvalidArgumentException $e) {
            $this->assertSame(
                "Kategori 'Gaji' bertipe pemasukan, tidak bisa dipakai untuk transaksi pengeluaran",
                $e->getMessage()
            );
        }

        // Penolakan terjadi sebelum INSERT: jumlah baris tetap 9.
        $this->assertSame(9, $this->hitungTransaksi());
    }

    public function testCatatMenolakInputTidakValid(): void
    {
        $jam = new DateTimeImmutable('2026-01-20 08:05:00', new DateTimeZone('Asia/Jakarta'));
        $terjadi = new DateTimeImmutable('2026-01-20 08:00:00', new DateTimeZone('Asia/Jakarta'));

        $kasus = [
            ['jenis asing', fn () => $this->service->catat(1, 4, 'transfer', 100, 'Jenis asing', $terjadi, $jam)],
            ['jumlah nol', fn () => $this->service->catat(1, 4, 'pengeluaran', 0, 'Nol', $terjadi, $jam)],
            ['keterangan kosong', fn () => $this->service->catat(1, 4, 'pengeluaran', 100, '   ', $terjadi, $jam)],
            ['akun tidak ada', fn () => $this->service->catat(99, 4, 'pengeluaran', 100, 'Akun hantu', $terjadi, $jam)],
            ['kategori tidak ada', fn () => $this->service->catat(1, 99, 'pengeluaran', 100, 'Kategori hantu', $terjadi, $jam)],
        ];

        foreach ($kasus as [$label, $coba]) {
            try {
                $coba();
                $this->fail("catat() seharusnya menolak kasus: $label.");
            } catch (InvalidArgumentException) {
                // Ditolak seperti diharapkan - pesan spesifiknya sudah
                // dijelaskan pada Bagian 3; di sini cukup jenisnya.
            }
        }

        // Lima penolakan tidak meninggalkan satu baris pun.
        $this->assertSame(9, $this->hitungTransaksi());
    }

    public function testDibatalkanMenghapusBarisDanMengembalikanData(): void
    {
        $akun = new AccountRepository($this->pdo);
        $sebelum = $akun->saldo(3);

        $id = $this->service->catat(
            3,
            7,
            'pengeluaran',
            1500000,
            'Pulsa — nominal salah',
            new DateTimeImmutable('2026-01-12 20:00:00', new DateTimeZone('Asia/Jakarta')),
            new DateTimeImmutable('2026-01-12 20:05:00', new DateTimeZone('Asia/Jakarta'))
        );
        $this->assertSame($sebelum - 1500000, $akun->saldo(3));

        $dibatalkan = $this->service->dibatalkan($id);
        $this->assertSame('Pulsa — nominal salah', $dibatalkan['keterangan']);
        $this->assertSame($sebelum, $akun->saldo(3));
        $this->assertSame(9, $this->hitungTransaksi());

        // Pembatalan kedua untuk id yang sama ditolak.
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage("Transaksi #$id tidak ditemukan");
        $this->service->dibatalkan($id);
    }

    public function testBudgetSisaDihitungDariTransaksiNyata(): void
    {
        $this->budget->setBudget(4, '2026-01', 50000000);

        $awal = $this->budget->ringkas(4, '2026-01');
        $this->assertSame(50000000, $awal['batas']);
        $this->assertSame(2550000, $awal['terpakai']); // warung Bu Siti dari seed
        $this->assertSame(47450000, $awal['sisa']);
        $this->assertFalse($awal['lewat']);
        $this->assertSame(47450000, $this->budget->sisa(4, '2026-01'));

        $this->service->catat(
            1,
            4,
            'pengeluaran',
            48000000,
            'Makan di restoran hotel',
            new DateTimeImmutable('2026-01-25 20:00:00', new DateTimeZone('Asia/Jakarta')),
            new DateTimeImmutable('2026-01-25 20:05:00', new DateTimeZone('Asia/Jakarta'))
        );

        $akhir = $this->budget->ringkas(4, '2026-01');
        $this->assertSame(50550000, $akhir['terpakai']);
        $this->assertSame(-550000, $akhir['sisa']);
        $this->assertTrue($akhir['lewat']);
    }

    public function testBudgetMenolakKategoriPemasukan(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage("Budget hanya untuk kategori pengeluaran; kategori 'Gaji' bertipe pemasukan");

        $this->budget->setBudget(2, '2026-01', 1000000);
    }

    public function testGenerateForIdempotenUntukBulanYangSama(): void
    {
        $this->recurring->define(2, 2, 850000000, 'Gaji bulanan', 2);
        $this->recurring->define(2, 6, 5499000, 'Langganan streaming', 7);
        $this->recurring->define(2, 8, 75000000, 'Cicilan kartu kredit', 28);

        $jam = new DateTimeImmutable('2026-02-01 08:00:00', new DateTimeZone('Asia/Jakarta'));

        $this->assertSame(3, $this->recurring->generateFor('2026-02', $jam));
        $this->assertSame(12, $this->hitungTransaksi());

        // Eksekusi kedua untuk bulan yang sama: nol transaksi baru.
        $this->assertSame(0, $this->recurring->generateFor('2026-02', $jam));
        $this->assertSame(12, $this->hitungTransaksi());

        // Baris hasil cetakan membawa jejak template_id dan bulan.
        $stmt = $this->pdo->prepare('SELECT template_id, bulan FROM transactions WHERE id = ?');
        $stmt->execute([10]);
        $jejak = $stmt->fetch(PDO::FETCH_ASSOC);
        $this->assertSame(1, (int) $jejak['template_id']);
        $this->assertSame('2026-02', $jejak['bulan']);
    }

    public function testGenerateForMenggeserHariKeAkhirBulan(): void
    {
        $this->recurring->define(2, 8, 75000000, 'Cicilan kartu kredit', 31);

        $jam = new DateTimeImmutable('2026-02-01 08:00:00', new DateTimeZone('Asia/Jakarta'));
        $this->assertSame(1, $this->recurring->generateFor('2026-02', $jam));

        // Februari 2026 punya 28 hari: hari 31 bergeser ke 28.
        $stmt = $this->pdo->prepare('SELECT terjadi_pada FROM transactions WHERE id = ?');
        $stmt->execute([10]);
        $this->assertSame('2026-02-28 08:00:00', $stmt->fetchColumn());
    }

    public function testImporCsvIdempotenSetelahPemulihan(): void
    {
        $io = new ImportExportService($this->pdo, new TransactionRepository($this->pdo));
        $categories = new CategoryRepository($this->pdo);
        $laporan = new ReportService($this->pdo);

        $resolver = function (string $nama, string $jenis) use ($categories): ?int {
            foreach ($categories->byJenis($jenis) as $k) {
                if ($k['nama'] === $nama) {
                    return (int) $k['id'];
                }
            }

            return null;
        };

        $csv = $io->eksporCsv('2026-01-01 00:00:00', '2026-01-31 23:59:59');
        $this->assertSame(9, substr_count($csv, "\n") - 1);
        $saldoSebelum = $laporan->ringkasanBulanan('2026-01')['total_saldo_akhir'];

        $this->pdo->exec('DELETE FROM transactions');
        $this->assertSame(0, $this->hitungTransaksi());

        $jam = new DateTimeImmutable('2026-02-01 07:00:00', new DateTimeZone('Asia/Jakarta'));
        $pemulihan = $io->imporCsv($csv, $resolver, $jam);
        $this->assertSame(9, $pemulihan['imported']);
        $this->assertSame(0, $pemulihan['skipped']);
        $this->assertSame(0, $pemulihan['rejected']);
        $this->assertSame(9, $this->hitungTransaksi());
        $this->assertSame($saldoSebelum, $laporan->ringkasanBulanan('2026-01')['total_saldo_akhir']);

        $ulang = $io->imporCsv($csv, $resolver, $jam);
        $this->assertSame(0, $ulang['imported']);
        $this->assertSame(9, $ulang['skipped']);
        $this->assertSame(9, $this->hitungTransaksi());
    }

    private function hitungTransaksi(): int
    {
        return (int) $this->pdo->query('SELECT COUNT(*) FROM transactions')->fetchColumn();
    }
}