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
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:
findByPeriod($dari, $sampai)memakaiBETWEENyang bersifat inklusif di kedua ujung. Karenaterjadi_padaberformat ISOY-m-d H:i:s, perbandingan string sekaligus perbandingan waktu — transaksi terakhir 31 Januari pukul23:59:59masuk rentang Januari, transaksi 1 Februari pukul00:00:00tidak. UrutanORDER BY t.terjadi_pada, t.idmenjaga baris dengan waktu sama tetap stabil.sumByCategory()menggabungkanJOIN,GROUP BY, dan agregasiCOUNT(*)/SUM()— bahan mentah rekap bulanan.ORDER BY total DESC, c.namamembuat kategori boros selalu tampil paling atas, dengan urutan alfabetis untuk yang seri.- Kolom waktu sebagai string: repository menerima
'Y-m-d H:i:s'mentah. Pemformatan dariDateTimeImmutableadalah 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
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:
- 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.
- Dua parameter
DateTimeImmutable:$terjadiPada(waktu bisnis) dan$dicatatPada(waktu sistem). Service tidak pernah memanggilnew 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. - 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
InvalidArgumentExceptiondengan pesan yang menyebut data mana yang salah. dibatalkan()mengembalikan data yang dihapus, bukan sekadarbool, 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
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:
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,67Transaksi 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
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:
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: 9Kasus 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
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:
== 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,00Perhatikan 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
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:
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 ditemukanSaldo 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:
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.phpSimpan salah satu walkthrough di atas sebagai uji-transaksi.php, lalu jalankan dari dalam folder bagian-3-pencatatan-transaksi:
php uji-transaksi.phpSnippet 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.phpbaru dengansimpan(),cariId(),hapus(),findByPeriod(), dansumByCategory()— tanpaperbarui()by design. - Berkas
src/TransactionService.phpbaru yang menyusun tiga repository dan menegakkan enam aturan domain padacatat(), plusdibatalkan()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
DateTimeImmutableyang 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
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
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;
}
}