Pencatatan transaksi adalah jantung aplikasi keuangan: satu-satunya tempat data baru benar-benar lahir. Bagian 1 sengaja meninggalkan satu celah — schema bisa memastikan kolom jenis berisi 'pemasukan' atau 'pengeluaran', tetapi tidak bisa memastikan jenis itu cocok dengan kategorinya; transaksi pemasukan pada kategori Makanan akan lolos mulus ke database. Pada bagian ini kita menutup celah itu dari sisi kode dengan dua class baru: TransactionRepository yang menguasai query transaksi, dan TransactionService yang menegakkan aturan domain sebelum satu baris pun ditulis. Dua kebutuhan lain menyusul di belakangnya: menampilkan transaksi per periode dan membatalkan pencatatan yang salah.

Repository dan Service, Pembagian Tugasnya #

Mengapa dua class untuk satu tabel? Karena tugasnya memang berbeda jenis. Repository menjawab “bagaimana data disimpan dan diambil” — murni SQL tanpa pendapat tentang aturan bisnis. Service menjawab “bolehkah data ini dicatat” — memeriksa bahwa akun ada, kategori ada, arah transaksi searah dengan kategorinya, dan jumlahnya positif. Pemisahan ini sejalan dengan prinsip Single Responsibility yang kita bahas pada chapter Single Responsibility : saat Bagian 7 menguji aturan pencatatan, test cukup membentuk service dengan database :memory: tanpa menyentuh HTTP maupun antarmuka CLI.

Satu keputusan desain lagi: TransactionRepository sengaja tidak menyediakan method perbarui(). Catatan keuangan yang diedit diam-diam merusak kepercayaan pada laporan — salah input yang benar adalah membatalkan lalu mencatat ulang, bukan menimpa. Method hapus() tetap ada sebagai kemampuan tingkat rendah yang hanya dipanggil lewat alur pembatalan.

Class TransactionRepository #

Berikut isi lengkap src/TransactionRepository.php:

php
<?php

declare(strict_types=1);

/**
 * Lapisan akses data untuk tabel transactions.
 *
 * Repository ini sengaja TIDAK menyediakan method perbarui():
 * catatan keuangan sebaiknya tidak diedit diam-diam. Salah input
 * berarti membatalkan transaksi lalu mencatat ulang yang benar,
 * alur yang disediakan TransactionService::dibatalkan().
 */
final class TransactionRepository
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Simpan transaksi baru, kembalikan id baris yang baru dibuat.
     *
     * Seluruh nilai uang dalam SEN. Kedua waktu diterima sebagai
     * string format 'Y-m-d H:i:s' — lapisan service-lah yang
     * memformat DateTimeImmutable menjadi string ini.
     */
    public function simpan(
        int $accountId,
        int $categoryId,
        string $jenis,
        int $jumlahSen,
        string $keterangan,
        string $terjadiPada,
        string $dibuatPada,
    ): int {
        $stmt = $this->pdo->prepare(
            'INSERT INTO transactions
                 (account_id, category_id, jenis, jumlah, keterangan, terjadi_pada, dibuat_pada)
             VALUES (?, ?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([$accountId, $categoryId, $jenis, $jumlahSen, $keterangan, $terjadiPada, $dibuatPada]);

        return (int) $this->pdo->lastInsertId();
    }

    /**
     * Ambil satu transaksi berdasarkan id; null bila tidak ditemukan.
     *
     * @return array<string, mixed>|null
     */
    public function cariId(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, account_id, category_id, jenis, jumlah, keterangan, terjadi_pada, dibuat_pada
               FROM transactions
              WHERE id = ?'
        );
        $stmt->execute([$id]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Hapus satu transaksi. Mengembalikan false bila id tidak ditemukan.
     */
    public function hapus(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM transactions WHERE id = ?');
        $stmt->execute([$id]);

        return $stmt->rowCount() > 0;
    }

    /**
     * Ambil transaksi yang terjadi antara dua batas waktu (inklusif).
     *
     * Karena terjadi_pada bertipe TEXT format 'Y-m-d H:i:s', rentang
     * dibandingkan secara leksikografis — urutan ISO inilah yang
     * membuat operator BETWEEN menghasilkan urutan waktu yang benar.
     *
     * @param string $dari    batas awal 'Y-m-d H:i:s' (inklusif)
     * @param string $sampai  batas akhir 'Y-m-d H:i:s' (inklusif)
     * @return list<array<string, mixed>> lengkap dengan nama akun dan kategori
     */
    public function findByPeriod(string $dari, string $sampai): array
    {
        $sql = 'SELECT t.id, t.jenis, t.jumlah, t.keterangan, t.terjadi_pada,
                       a.nama AS akun, c.nama AS kategori
                  FROM transactions t
                  JOIN accounts a ON a.id = t.account_id
                  JOIN categories c ON c.id = t.category_id
                 WHERE t.terjadi_pada BETWEEN ? AND ?
                 ORDER BY t.terjadi_pada, t.id';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$dari, $sampai]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Total nilai transaksi per kategori pada satu rentang waktu.
     *
     * @param string $dari    batas awal 'Y-m-d H:i:s' (inklusif)
     * @param string $sampai  batas akhir 'Y-m-d H:i:s' (inklusif)
     * @param string $jenis   'pemasukan' atau 'pengeluaran'
     * @return list<array<string, mixed>> setiap baris memuat id, nama,
     *         jumlah_transaksi, dan total (sen)
     */
    public function sumByCategory(string $dari, string $sampai, string $jenis): array
    {
        if (!in_array($jenis, ['pemasukan', 'pengeluaran'], true)) {
            throw new InvalidArgumentException("Jenis transaksi tidak valid: '$jenis'");
        }

        $sql = 'SELECT c.id, c.nama, COUNT(*) AS jumlah_transaksi, SUM(t.jumlah) AS total
                  FROM transactions t
                  JOIN categories c ON c.id = t.category_id
                 WHERE t.jenis = ? AND t.terjadi_pada BETWEEN ? AND ?
                 GROUP BY c.id
                 ORDER BY total DESC, c.nama';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$jenis, $dari, $sampai]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

Tiga hal pada class tersebut perlu dibedah:

  1. findByPeriod($dari, $sampai) memakai BETWEEN yang bersifat inklusif di kedua ujung. Karena terjadi_pada berformat ISO Y-m-d H:i:s, perbandingan string sekaligus perbandingan waktu — transaksi terakhir 31 Januari pukul 23:59:59 masuk rentang Januari, transaksi 1 Februari pukul 00:00:00 tidak. Urutan ORDER BY t.terjadi_pada, t.id menjaga baris dengan waktu sama tetap stabil.
  2. sumByCategory() menggabungkan JOIN, GROUP BY, dan agregasi COUNT(*)/SUM() — bahan mentah rekap bulanan. ORDER BY total DESC, c.nama membuat kategori boros selalu tampil paling atas, dengan urutan alfabetis untuk yang seri.
  3. Kolom waktu sebagai string: repository menerima 'Y-m-d H:i:s' mentah. Pemformatan dari DateTimeImmutable adalah urusan service, sehingga repository tidak punya pendapat tentang zona waktu.

Class TransactionService #

Service ini menyusun tiga repository dari Bagian 2 dan 3, lalu menegakkan enam aturan domain. Berikut isi lengkap src/TransactionService.php:

php
<?php

declare(strict_types=1);

/**
 * Lapisan aturan bisnis untuk pencatatan transaksi.
 *
 * Service ini berdiri di atas dua repository dari Bagian 2 dan
 * TransactionRepository. Tugasnya bukan menjalankan SQL, melainkan
 * memastikan setiap transaksi yang masuk memenuhi aturan domain:
 * akun dan kategori harus ada, arah transaksi harus cocok dengan
 * arah kategori, dan jumlah harus positif.
 *
 * Kedua parameter waktu disuntikkan sebagai DateTimeImmutable dari
 * luar ("frozen clock") sehingga hasil catatan dapat ditentukan
 * penuh oleh pemanggil dan test — service tidak pernah membaca
 * jam sistem sendiri.
 */
final class TransactionService
{
    public function __construct(
        private AccountRepository $accounts,
        private CategoryRepository $categories,
        private TransactionRepository $transactions,
    ) {
    }

    /**
     * Catat satu transaksi setelah lolos validasi, kembalikan id baris.
     *
     * Aturan yang ditegakkan:
     * 1. $jenis harus 'pemasukan' atau 'pengeluaran'.
     * 2. $jumlahSen harus lebih besar dari nol.
     * 3. $keterangan tidak boleh kosong.
     * 4. Akun dengan $accountId harus sudah terdaftar.
     * 5. Kategori dengan $categoryId harus sudah terdaftar.
     * 6. Arah transaksi ($jenis) harus sama dengan arah kategori.
     *
     * Pelanggaran aturan dilempar sebagai InvalidArgumentException;
     * PDOException hanya muncul bila ada balapan dengan perubahan
     * data lain di luar kontrol pemanggil.
     *
     * @param int              $accountId   id akun sumber/tujuan uang
     * @param int              $categoryId  id kategori pengelompokan
     * @param string           $jenis       'pemasukan' atau 'pengeluaran'
     * @param int              $jumlahSen   nilai transaksi dalam sen
     * @param string           $keterangan  deskripsi yang dibaca manusia
     * @param DateTimeImmutable $terjadiPada kapan peristiwa keuangan terjadi
     * @param DateTimeImmutable $dicatatPada kapan baris ini ditulis ke database
     */
    public function catat(
        int $accountId,
        int $categoryId,
        string $jenis,
        int $jumlahSen,
        string $keterangan,
        DateTimeImmutable $terjadiPada,
        DateTimeImmutable $dicatatPada,
    ): int {
        if (!in_array($jenis, ['pemasukan', 'pengeluaran'], true)) {
            throw new InvalidArgumentException("Jenis transaksi tidak valid: '$jenis'");
        }

        if ($jumlahSen <= 0) {
            throw new InvalidArgumentException("Jumlah harus lebih besar dari nol sen, diterima: $jumlahSen");
        }

        if (trim($keterangan) === '') {
            throw new InvalidArgumentException('Keterangan transaksi tidak boleh kosong');
        }

        $akun = $this->accounts->cariId($accountId);
        if ($akun === null) {
            throw new InvalidArgumentException("Akun #$accountId tidak ditemukan");
        }

        $kategori = $this->categories->cariId($categoryId);
        if ($kategori === null) {
            throw new InvalidArgumentException("Kategori #$categoryId tidak ditemukan");
        }

        if ($kategori['jenis'] !== $jenis) {
            throw new InvalidArgumentException(
                "Kategori '{$kategori['nama']}' bertipe {$kategori['jenis']}, "
                . "tidak bisa dipakai untuk transaksi $jenis"
            );
        }

        return $this->transactions->simpan(
            $accountId,
            $categoryId,
            $jenis,
            $jumlahSen,
            trim($keterangan),
            $terjadiPada->format('Y-m-d H:i:s'),
            $dicatatPada->format('Y-m-d H:i:s'),
        );
    }

    /**
     * Batalkan satu transaksi dan kembalikan datanya.
     *
     * Pembatalan menghapus baris transaksi. Ini penyederhanaan yang
     * sadar: aplikasi produksi biasanya memakai soft delete (kolom
     * dibatalkan_pada) agar jejak audit tetap dapat ditelusuri.
     * Data transaksi yang dihapus dikembalikan ke pemanggil supaya
     * konfirmasi "transaksi X dibatalkan" bisa ditampilkan.
     *
     * @return array<string, mixed> data transaksi yang baru saja dibatalkan
     * @throws InvalidArgumentException bila transaksi tidak ditemukan
     */
    public function dibatalkan(int $id): array
    {
        $transaksi = $this->transactions->cariId($id);

        if ($transaksi === null) {
            throw new InvalidArgumentException("Transaksi #$id tidak ditemukan");
        }

        $this->transactions->hapus($id);

        return $transaksi;
    }
}

Ada empat keputusan penting pada class tersebut:

  1. Constructor menerima tiga repository, bukan membuatnya sendiri. Pemanggil menyusun dependensi — pola yang sudah kita kenal sebagai dependency injection — sehingga test dapat menyuntikkan implementasi apa pun yang memenuhi kontrak constructor.
  2. Dua parameter DateTimeImmutable: $terjadiPada (waktu bisnis) dan $dicatatPada (waktu sistem). Service tidak pernah memanggil new DateTimeImmutable() tanpa argumen — jam dibekukan oleh pemanggil, persis seperti data awal Bagian 1. Konsekuensinya, output setiap contoh di halaman ini dapat direproduksi setiap saat, dan Bagian 7 dapat menguji alur “catat di masa lalu” tanpa menunggu tanggalnya tiba.
  3. Validasi berurutan dari yang termurah: cek jenis dan jumlah tanpa query, baru keberadaan akun dan kategori yang butuh round-trip ke database, terakhir kecocokan arah. Pelanggaran pertama langsung melempar InvalidArgumentException dengan pesan yang menyebut data mana yang salah.
  4. dibatalkan() mengembalikan data yang dihapus, bukan sekadar bool, agar pemanggil bisa menampilkan konfirmasi bermakna. cariId() dilakukan lebih dulu sehingga transaksi yang tidak ada ditolak sebagai exception, bukan diam-duci berhasil menghapus apa pun.

Walkthrough Mencatat Transaksi #

Alur pertama mencatat dua transaksi nyata di atas data awal Januari: satu pengeluaran makan malam dari dompet kas, satu pemasukan bonus proyek ke rekening. Saldo sebelum dan sesudah memperlihatkan efeknya:

php
<?php
require __DIR__ . '/src/TransactionRepository.php';
require __DIR__ . '/src/TransactionService.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/AccountRepository.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/CategoryRepository.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/uang.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),
);

echo "Saldo kas sebelum  : ", formatRupiah($akun->saldo(1)), "\n";

$idMakan = $service->catat(
    1,
    4,
    'pengeluaran',
    7500000,
    'Makan malam di resto Padang',
    new DateTimeImmutable('2026-01-20 19:30:00', new DateTimeZone('Asia/Jakarta')),
    new DateTimeImmutable('2026-01-20 19:35:00', new DateTimeZone('Asia/Jakarta')),
);
echo "Pengeluaran tercatat dengan id $idMakan\n";

$idBonus = $service->catat(
    2,
    2,
    'pemasukan',
    125000000,
    'Bonus proyek freelance',
    new DateTimeImmutable('2026-01-21 10:00:00', new DateTimeZone('Asia/Jakarta')),
    new DateTimeImmutable('2026-01-21 10:02:00', new DateTimeZone('Asia/Jakarta')),
);
echo "Pemasukan tercatat dengan id $idBonus\n";

echo "Saldo kas sesudah  : ", formatRupiah($akun->saldo(1)), "\n";
echo "Saldo bank sesudah : ", formatRupiah($akun->saldo(2)), "\n";

Output yang Dihasilkan:

text
Saldo kas sebelum  : Rp1.849.500,00
Pengeluaran tercatat dengan id 10
Pemasukan tercatat dengan id 11
Saldo kas sesudah  : Rp1.774.500,00
Saldo bank sesudah : Rp22.207.355,67

Transaksi baru mendapat id 10 dan 11 karena sembilan data awal memakai id 1 sampai 9. Saldo dompet kas turun dari Rp1.849.500,00 menjadi Rp1.774.500,00 (berkurang tepat Rp75.000,00), dan rekening BCA naik Rp1.250.000,00 — aritmetika integer menjaga kedua angka konsisten dengan riwayat. Perhatikan juga zona waktu Asia/Jakarta yang eksplisit pada kedua jam beku; format('Y-m-d H:i:s') menyimpan waktu dinding lokal, cukup untuk aplikasi pribadi satu zona waktu.

Validasi Menolak Data yang Tidak Konsisten #

Alur kedua membuktikan enam aturan catat() benar-benar menahan data busuk. Lima upaya pencatatan sengaja salah, masing-masing melanggar aturan yang berbeda:

php
<?php
require __DIR__ . '/src/TransactionRepository.php';
require __DIR__ . '/src/TransactionService.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/AccountRepository.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/CategoryRepository.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'));

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

$waktu = new DateTimeImmutable('2026-01-22 08:00:00', new DateTimeZone('Asia/Jakarta'));
$coba = [
    'akun tidak ada' => [99, 4, 'pengeluaran', 500000, 'Uji'],
    'kategori tidak ada' => [1, 99, 'pengeluaran', 500000, 'Uji'],
    'jenis tidak cocok' => [1, 4, 'pemasukan', 500000, 'Uji'],
    'jumlah nol' => [1, 4, 'pengeluaran', 0, 'Uji'],
    'keterangan kosong' => [1, 4, 'pengeluaran', 500000, '  '],
];

foreach ($coba as $label => $arg) {
    try {
        $service->catat($arg[0], $arg[1], $arg[2], $arg[3], $arg[4], $waktu, $waktu);
        echo "$label: tersimpan (tidak seharusnya)\n";
    } catch (InvalidArgumentException $e) {
        echo "$label => ditolak\n  {$e->getMessage()}\n";
    }
}

$jumlah = $pdo->query('SELECT COUNT(*) FROM transactions')->fetchColumn();
echo "Jumlah transaksi tetap: $jumlah\n";

Output yang Dihasilkan:

text
akun tidak ada => ditolak
  Akun #99 tidak ditemukan
kategori tidak ada => ditolak
  Kategori #99 tidak ditemukan
jenis tidak cocok => ditolak
  Kategori 'Makanan' bertipe pengeluaran, tidak bisa dipakai untuk transaksi pemasukan
jumlah nol => ditolak
  Jumlah harus lebih besar dari nol sen, diterima: 0
keterangan kosong => ditolak
  Keterangan transaksi tidak boleh kosong
Jumlah transaksi tetap: 9

Kasus ketiga adalah bintangnya: transaksi pemasukan dengan kategori Makanan — kombinasi yang lolos dari CHECK constraint Bagian 1 — kini ditolak service dengan pesan yang menjelaskan alasannya. Kelima penolakan terjadi sebelum INSERT dijalankan, terbukti dari jumlah transaksi yang tetap sembilan. Dua lapis pertahanan kini berdiri berdampingan: service untuk pesan yang ramah, database untuk data yang licin.

Menampilkan Periode dan Rekap Kategori #

Alur ketiga memakai query periode. Kita tambahkan dua transaksi kecil di pertengahan Januari, lalu tampilkan seluruh transaksi bulan itu dan rekap pengeluaran per kategori:

php
<?php
require __DIR__ . '/src/TransactionRepository.php';
require __DIR__ . '/src/TransactionService.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/AccountRepository.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/CategoryRepository.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/uang.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'));

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

$jam = new DateTimeImmutable('2026-01-15 08:00:00', new DateTimeZone('Asia/Jakarta'));
$service->catat(1, 4, 'pengeluaran', 2200000, 'Kopi dan camilan kantor', new DateTimeImmutable('2026-01-08 09:10:00', new DateTimeZone('Asia/Jakarta')), $jam);
$service->catat(3, 7, 'pengeluaran', 5000000, 'Pulsa internet bulanan', new DateTimeImmutable('2026-01-12 20:00:00', new DateTimeZone('Asia/Jakarta')), $jam);

echo "== Transaksi Januari 2026 (findByPeriod) ==\n";
printf("%-3s %-19s %-13s %-15s %16s %s\n", 'ID', 'Terjadi', 'Akun', 'Kategori', 'Jumlah', 'Keterangan');
foreach ($repo->findByPeriod('2026-01-01 00:00:00', '2026-01-31 23:59:59') as $t) {
    printf(
        "%-3d %-19s %-13s %-15s %16s %s\n",
        $t['id'],
        $t['terjadi_pada'],
        $t['akun'],
        $t['kategori'],
        ($t['jenis'] === 'pemasukan' ? '+' : '-') . formatRupiah((int) $t['jumlah']),
        $t['keterangan']
    );
}

echo "\n== Rekap pengeluaran per kategori (sumByCategory) ==\n";
printf("%-15s %9s %15s\n", 'Kategori', 'Transaksi', 'Total');
foreach ($repo->sumByCategory('2026-01-01 00:00:00', '2026-01-31 23:59:59', 'pengeluaran') as $baris) {
    printf("%-15s %9d %15s\n", $baris['nama'], $baris['jumlah_transaksi'], formatRupiah((int) $baris['total']));
}

Output yang Dihasilkan:

text
== Transaksi Januari 2026 (findByPeriod) ==
ID  Terjadi             Akun          Kategori                  Jumlah Keterangan
1   2026-01-01 08:00:00 Dompet Kas    Saldo Awal       +Rp2.000.000,00 Saldo awal dompet kas
2   2026-01-01 08:00:00 Rekening BCA  Saldo Awal      +Rp12.500.000,00 Saldo awal rekening BCA
3   2026-01-01 08:00:00 GoPay         Saldo Awal         +Rp150.000,00 Saldo awal GoPay
4   2026-01-02 09:00:00 Rekening BCA  Gaji             +Rp8.500.000,00 Gaji bulan Januari 2026
5   2026-01-03 12:30:00 Dompet Kas    Makanan             -Rp25.500,00 Makan siang di warung Bu Siti
6   2026-01-05 07:45:00 GoPay         Transportasi         -Rp3.500,00 Naik TransJakarta
7   2026-01-07 06:00:00 Rekening BCA  Hiburan             -Rp54.990,00 Langganan streaming bulanan
10  2026-01-08 09:10:00 Dompet Kas    Makanan             -Rp22.000,00 Kopi dan camilan kantor
8   2026-01-10 08:20:00 Dompet Kas    Belanja            -Rp125.000,00 Belanja mingguan di pasar
11  2026-01-12 20:00:00 GoPay         Belanja             -Rp50.000,00 Pulsa internet bulanan
9   2026-01-31 10:00:00 Rekening BCA  Bunga Tabungan      +Rp12.345,67 Bunga tabungan Desember

== Rekap pengeluaran per kategori (sumByCategory) ==
Kategori        Transaksi           Total
Belanja                 2    Rp175.000,00
Hiburan                 1     Rp54.990,00
Makanan                 2     Rp47.500,00
Transportasi            1      Rp3.500,00

Perhatikan urutan baris pada tabel pertama: transaksi #10 (8 Januari) tampil sebelum #8 (10 Januari) karena pengurutan mengikuti terjadi_pada, bukan id — id hanya pengikat seri. Batas bawah 2026-01-01 00:00:00 dan batas atas 2026-01-31 23:59:59 merengkuh seluruh detik bulan itu; inilah pola rentang yang nanti dipakai ulang oleh BudgetService pada Bagian 4. Rekap kategori menempatkan Belanja di puncak dengan dua transaksi senilai total Rp175.000,00 — agregasi GROUP BY bekerja pada nilai sen lalu baru diformat saat ditampilkan.

Membatalkan Transaksi #

Alur terakhir menutup siklus hidup transaksi: salah mencatat pulsa Rp50.000,00 padahal nominalnya Rp15.000,00. Kita batalkan, catat ulang yang benar, dan buktikan pembatalan kedua untuk id yang sama ditolak:

php
<?php
require __DIR__ . '/src/TransactionRepository.php';
require __DIR__ . '/src/TransactionService.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/AccountRepository.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/CategoryRepository.php';
require __DIR__ . '/../bagian-2-akun-dan-kategori/src/uang.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),
);

$jam = new DateTimeImmutable('2026-01-15 08:00:00', new DateTimeZone('Asia/Jakarta'));
$idSalah = $service->catat(
    3, 7, 'pengeluaran', 5000000, 'Pulsa — salah nominal',
    new DateTimeImmutable('2026-01-12 20:00:00', new DateTimeZone('Asia/Jakarta')), $jam
);

echo "Saldo GoPay sebelum pembatalan: ", formatRupiah($akun->saldo(3)), "\n";

$dibatalkan = $service->dibatalkan($idSalah);
echo "Transaksi #{$dibatalkan['id']} dibatalkan ({$dibatalkan['keterangan']})\n";

echo "Saldo GoPay sesudah pembatalan: ", formatRupiah($akun->saldo(3)), "\n";

$service->catat(
    3, 7, 'pengeluaran', 1500000, 'Pulsa — nominal yang benar',
    new DateTimeImmutable('2026-01-12 20:00:00', new DateTimeZone('Asia/Jakarta')), $jam
);
echo "Dicatat ulang dengan nominal benar\n";
echo "Saldo GoPay akhir: ", formatRupiah($akun->saldo(3)), "\n";

echo "\nMembatalkan transaksi yang sudah tidak ada:\n";
try {
    $service->dibatalkan($idSalah);
    echo "Terbatalkan (tidak seharusnya)\n";
} catch (InvalidArgumentException $e) {
    echo "Ditolak: {$e->getMessage()}\n";
}

Output yang Dihasilkan:

text
Saldo GoPay sebelum pembatalan: Rp96.500,00
Transaksi #10 dibatalkan (Pulsa — salah nominal)
Saldo GoPay sesudah pembatalan: Rp146.500,00
Dicatat ulang dengan nominal benar
Saldo GoPay akhir: Rp131.500,00

Membatalkan transaksi yang sudah tidak ada:
Ditolak: Transaksi #10 tidak ditemukan

Saldo GoPay kembali tepat ke angka semula Rp146.500,00 setelah pembatalan — saldo memang selalu dihitung ulang dari transaksi yang tersisa, jadi tidak ada kolom yang perlu dikoreksi. Pencatatan ulang dengan nominal benar menurunkannya ke Rp131.500,00. Upaya pembatalan kedua untuk id yang sama ditolak dengan jelas.

Cara Menjalankan Contoh #

Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1, dua berkas dari Bagian 2, dan dua berkas baru di folder src/. Susun foldernya sebagai berikut:

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
    └── uji-transaksi.php

Simpan salah satu walkthrough di atas sebagai uji-transaksi.php, lalu jalankan dari dalam folder bagian-3-pencatatan-transaksi:

bash
php uji-transaksi.php

Snippet memuat class dengan require dan path __DIR__ — '/src/TransactionService.php' untuk berkas lokal, '/../bagian-2-akun-dan-kategori/src/AccountRepository.php' untuk berkas bagian saudara — sehingga struktur folder di atas harus dipertahankan. Seperti sebelumnya, seluruh contoh memakai :memory: dan tidak meninggalkan berkas database.

Apa yang Berubah #

Dibandingkan Bagian 2 yang mengelola akun dan kategori, proyek kini dapat mencatat peristiwa keuangan dengan aturan penuh:

  • Berkas src/TransactionRepository.php baru dengan simpan(), cariId(), hapus(), findByPeriod(), dan sumByCategory() — tanpa perbarui() by design.
  • Berkas src/TransactionService.php baru yang menyusun tiga repository dan menegakkan enam aturan domain pada catat(), plus dibatalkan() yang mengembalikan data transaksi yang dihapus.
  • Celah schema dari Bagian 1 resmi tertutup: kombinasi jenis transaksi dan jenis kategori yang bertentangan kini ditolak sebelum menyentuh database.
  • Kedua waktu transaksi kini mengalir dari DateTimeImmutable yang dibekukan pemanggil, membuat seluruh alur — termasuk pencatatan tanggal lampau — sepenuhnya deterministik.

Ringkasan #

TransactionService menjadikan pencatatan transaksi sebagai alur bervalidasi: repository menjalankan SQL, service memeriksa enam aturan domain, jam beku menjaga determinisme, dan query periode BETWEEN pada kolom ISO menjadi pintu masuk seluruh laporan. Pembatalan menghapus baris dengan kesadaran penuh akan trade-off auditnya. Namun mencatat hanyalah setengah dari hidup keuangan — setengah lainnya adalah kendali: berapa boleh dihabiskan, dan transaksi rutin yang tidak boleh lupa dicatat. Bagian 4 melengkapi keduanya dengan budget bulanan per kategori dan cetakan transaksi berulang yang idempoten.

Referensi #

Berkas Sumber

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

src/TransactionRepository.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Lapisan akses data untuk tabel transactions.
 *
 * Repository ini sengaja TIDAK menyediakan method perbarui():
 * catatan keuangan sebaiknya tidak diedit diam-diam. Salah input
 * berarti membatalkan transaksi lalu mencatat ulang yang benar,
 * alur yang disediakan TransactionService::dibatalkan().
 */
final class TransactionRepository
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Simpan transaksi baru, kembalikan id baris yang baru dibuat.
     *
     * Seluruh nilai uang dalam SEN. Kedua waktu diterima sebagai
     * string format 'Y-m-d H:i:s' — lapisan service-lah yang
     * memformat DateTimeImmutable menjadi string ini.
     */
    public function simpan(
        int $accountId,
        int $categoryId,
        string $jenis,
        int $jumlahSen,
        string $keterangan,
        string $terjadiPada,
        string $dibuatPada,
    ): int {
        $stmt = $this->pdo->prepare(
            'INSERT INTO transactions
                 (account_id, category_id, jenis, jumlah, keterangan, terjadi_pada, dibuat_pada)
             VALUES (?, ?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([$accountId, $categoryId, $jenis, $jumlahSen, $keterangan, $terjadiPada, $dibuatPada]);

        return (int) $this->pdo->lastInsertId();
    }

    /**
     * Ambil satu transaksi berdasarkan id; null bila tidak ditemukan.
     *
     * @return array<string, mixed>|null
     */
    public function cariId(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, account_id, category_id, jenis, jumlah, keterangan, terjadi_pada, dibuat_pada
               FROM transactions
              WHERE id = ?'
        );
        $stmt->execute([$id]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Hapus satu transaksi. Mengembalikan false bila id tidak ditemukan.
     */
    public function hapus(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM transactions WHERE id = ?');
        $stmt->execute([$id]);

        return $stmt->rowCount() > 0;
    }

    /**
     * Ambil transaksi yang terjadi antara dua batas waktu (inklusif).
     *
     * Karena terjadi_pada bertipe TEXT format 'Y-m-d H:i:s', rentang
     * dibandingkan secara leksikografis — urutan ISO inilah yang
     * membuat operator BETWEEN menghasilkan urutan waktu yang benar.
     *
     * @param string $dari    batas awal 'Y-m-d H:i:s' (inklusif)
     * @param string $sampai  batas akhir 'Y-m-d H:i:s' (inklusif)
     * @return list<array<string, mixed>> lengkap dengan nama akun dan kategori
     */
    public function findByPeriod(string $dari, string $sampai): array
    {
        $sql = 'SELECT t.id, t.jenis, t.jumlah, t.keterangan, t.terjadi_pada,
                       a.nama AS akun, c.nama AS kategori
                  FROM transactions t
                  JOIN accounts a ON a.id = t.account_id
                  JOIN categories c ON c.id = t.category_id
                 WHERE t.terjadi_pada BETWEEN ? AND ?
                 ORDER BY t.terjadi_pada, t.id';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$dari, $sampai]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Total nilai transaksi per kategori pada satu rentang waktu.
     *
     * @param string $dari    batas awal 'Y-m-d H:i:s' (inklusif)
     * @param string $sampai  batas akhir 'Y-m-d H:i:s' (inklusif)
     * @param string $jenis   'pemasukan' atau 'pengeluaran'
     * @return list<array<string, mixed>> setiap baris memuat id, nama,
     *         jumlah_transaksi, dan total (sen)
     */
    public function sumByCategory(string $dari, string $sampai, string $jenis): array
    {
        if (!in_array($jenis, ['pemasukan', 'pengeluaran'], true)) {
            throw new InvalidArgumentException("Jenis transaksi tidak valid: '$jenis'");
        }

        $sql = 'SELECT c.id, c.nama, COUNT(*) AS jumlah_transaksi, SUM(t.jumlah) AS total
                  FROM transactions t
                  JOIN categories c ON c.id = t.category_id
                 WHERE t.jenis = ? AND t.terjadi_pada BETWEEN ? AND ?
                 GROUP BY c.id
                 ORDER BY total DESC, c.nama';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$jenis, $dari, $sampai]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

src/TransactionService.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Lapisan aturan bisnis untuk pencatatan transaksi.
 *
 * Service ini berdiri di atas dua repository dari Bagian 2 dan
 * TransactionRepository. Tugasnya bukan menjalankan SQL, melainkan
 * memastikan setiap transaksi yang masuk memenuhi aturan domain:
 * akun dan kategori harus ada, arah transaksi harus cocok dengan
 * arah kategori, dan jumlah harus positif.
 *
 * Kedua parameter waktu disuntikkan sebagai DateTimeImmutable dari
 * luar ("frozen clock") sehingga hasil catatan dapat ditentukan
 * penuh oleh pemanggil dan test — service tidak pernah membaca
 * jam sistem sendiri.
 */
final class TransactionService
{
    public function __construct(
        private AccountRepository $accounts,
        private CategoryRepository $categories,
        private TransactionRepository $transactions,
    ) {
    }

    /**
     * Catat satu transaksi setelah lolos validasi, kembalikan id baris.
     *
     * Aturan yang ditegakkan:
     * 1. $jenis harus 'pemasukan' atau 'pengeluaran'.
     * 2. $jumlahSen harus lebih besar dari nol.
     * 3. $keterangan tidak boleh kosong.
     * 4. Akun dengan $accountId harus sudah terdaftar.
     * 5. Kategori dengan $categoryId harus sudah terdaftar.
     * 6. Arah transaksi ($jenis) harus sama dengan arah kategori.
     *
     * Pelanggaran aturan dilempar sebagai InvalidArgumentException;
     * PDOException hanya muncul bila ada balapan dengan perubahan
     * data lain di luar kontrol pemanggil.
     *
     * @param int              $accountId   id akun sumber/tujuan uang
     * @param int              $categoryId  id kategori pengelompokan
     * @param string           $jenis       'pemasukan' atau 'pengeluaran'
     * @param int              $jumlahSen   nilai transaksi dalam sen
     * @param string           $keterangan  deskripsi yang dibaca manusia
     * @param DateTimeImmutable $terjadiPada kapan peristiwa keuangan terjadi
     * @param DateTimeImmutable $dicatatPada kapan baris ini ditulis ke database
     */
    public function catat(
        int $accountId,
        int $categoryId,
        string $jenis,
        int $jumlahSen,
        string $keterangan,
        DateTimeImmutable $terjadiPada,
        DateTimeImmutable $dicatatPada,
    ): int {
        if (!in_array($jenis, ['pemasukan', 'pengeluaran'], true)) {
            throw new InvalidArgumentException("Jenis transaksi tidak valid: '$jenis'");
        }

        if ($jumlahSen <= 0) {
            throw new InvalidArgumentException("Jumlah harus lebih besar dari nol sen, diterima: $jumlahSen");
        }

        if (trim($keterangan) === '') {
            throw new InvalidArgumentException('Keterangan transaksi tidak boleh kosong');
        }

        $akun = $this->accounts->cariId($accountId);
        if ($akun === null) {
            throw new InvalidArgumentException("Akun #$accountId tidak ditemukan");
        }

        $kategori = $this->categories->cariId($categoryId);
        if ($kategori === null) {
            throw new InvalidArgumentException("Kategori #$categoryId tidak ditemukan");
        }

        if ($kategori['jenis'] !== $jenis) {
            throw new InvalidArgumentException(
                "Kategori '{$kategori['nama']}' bertipe {$kategori['jenis']}, "
                . "tidak bisa dipakai untuk transaksi $jenis"
            );
        }

        return $this->transactions->simpan(
            $accountId,
            $categoryId,
            $jenis,
            $jumlahSen,
            trim($keterangan),
            $terjadiPada->format('Y-m-d H:i:s'),
            $dicatatPada->format('Y-m-d H:i:s'),
        );
    }

    /**
     * Batalkan satu transaksi dan kembalikan datanya.
     *
     * Pembatalan menghapus baris transaksi. Ini penyederhanaan yang
     * sadar: aplikasi produksi biasanya memakai soft delete (kolom
     * dibatalkan_pada) agar jejak audit tetap dapat ditelusuri.
     * Data transaksi yang dihapus dikembalikan ke pemanggil supaya
     * konfirmasi "transaksi X dibatalkan" bisa ditampilkan.
     *
     * @return array<string, mixed> data transaksi yang baru saja dibatalkan
     * @throws InvalidArgumentException bila transaksi tidak ditemukan
     */
    public function dibatalkan(int $id): array
    {
        $transaksi = $this->transactions->cariId($id);

        if ($transaksi === null) {
            throw new InvalidArgumentException("Transaksi #$id tidak ditemukan");
        }

        $this->transactions->hapus($id);

        return $transaksi;
    }
}