Skema dari Bagian 1 sudah berdiri dengan aturannya yang terbukti; sekarang giliran kode PHP yang bekerja di atasnya. Dua entitas pertama yang butuh kelolaan penuh adalah akun dan kategori: membuat, mencari, mengubah nama, dan menonaktifkan. Ada pula satu kebutuhan khas keuangan yang tidak bisa disimpan sebagai kolom — saldo akun — yang harus dihitung ulang dari tabel transaksi setiap saat ditanya. Pada bagian ini kita membangun dua repository di atas PDO, plus helper formatRupiah() yang menerjemahkan sen menjadi teks rupiah yang enak dibaca.
Saldo Bukan Kolom, Melainkan Hasil Hitung #
Cara paling menggoda untuk menyimpan saldo adalah menambah kolom saldo pada tabel accounts lalu memperbaruinya setiap kali transaksi masuk. Pendekatan itu rapuh: dua transaksi yang ditulis bersamaan dapat saling menimpa pembaruan saldo, satu bug lupa menambah saldo membuat seluruh angka berikutnya bohong, dan tidak ada cara memverifikasi kolom itu terhadap riwayat transaksi. Pola derived data memilih sebaliknya — saldo adalah fungsi dari transaksi, cukup dihitung dengan SUM setiap kali dibutuhkan:
SELECT COALESCE(SUM(
CASE WHEN jenis = 'pemasukan' THEN jumlah ELSE -jumlah END
), 0)
FROM transactions
WHERE account_id = ?;Ekspresi CASE WHEN mengubah tanda sesuai arah transaksi: pemasukan dijumlahkan positif, pengeluaran dikurangkan. COALESCE(..., 0) menjaga akun yang belum punya transaksi tetap menghasilkan 0, bukan NULL. Dengan SQLite pada data pribadi, query ini cukup cepat berkat index account_id — dan yang terpenting, saldo tidak pernah bisa berbeda dari riwayatnya karena memang berasal dari sana.
Helper formatRupiah #
Bagian 1 menutup dengan transaksi yang tampil sebagai 2550000 sen. Teks rupiah berformat Indonesia — Rp25.500,00 — kita produksi lewat satu helper murni tanpa database, sehingga mudah diuji sendiri. Berikut isi lengkap berkas src/uang.php:
<?php
declare(strict_types=1);
/**
* Helper format mata uang untuk seluruh proyek keuangan.
*
* Uang disimpan sebagai INTEGER sen; helper ini menerjemahkan sen
* menjadi teks berformat Indonesia, misalnya 1234567 -> "Rp12.345,67".
* Dibuat sebagai function biasa agar mudah diuji secara terpisah.
*/
/**
* Format sen menjadi teks rupiah dengan gaya Indonesia.
*
* Pemisah ribuan memakai titik dan pecahan sen memakai koma,
* misalnya 2500000 -> "Rp25.000,00". Nilai negatif diawali tanda
* minus di depan "Rp", misalnya -150500 -> "-Rp1.505,00".
*/
function formatRupiah(int $sen): string
{
$negatif = $sen < 0;
$mutlak = abs($sen);
$rupiah = intdiv($mutlak, 100);
$sisa = $mutlak % 100;
$hasil = 'Rp' . number_format($rupiah, 0, ',', '.')
. ',' . str_pad((string) $sisa, 2, '0', STR_PAD_LEFT);
return $negatif ? '-' . $hasil : $hasil;
}Empat keputusan kecil pada fungsi tersebut layak dibedah:
intdiv($mutlak, 100)dan$mutlak % 100: keduanya bekerja pada integer sehingga pecahan sen tidak pernah melewati floating point.intdiv()membulatkan ke bawah, dan karena kita memakaiabs()lebih dulu, hasilnya konsisten untuk nilai negatif.number_format($rupiah, 0, ',', '.'): pemisah ribuan titik khas Indonesia; argumen desimal koma tidak terpakai karena bagiannya kita tulis sendiri.str_pad(..., 2, '0', STR_PAD_LEFT): sisa5sen harus tampil05, bukan5, agar lebar pecahan selalu dua digit.- Tanda minus di depan
Rp: konvensi-Rp1.505,00dipilih agar tanda langsung terlihat sebelum satuan. Kita uji sekarang:
<?php
require __DIR__ . '/src/uang.php';
$contoh = [2550000, 1234567, 350000, 850000000, 0, -150500];
foreach ($contoh as $sen) {
echo str_pad((string) $sen, 11, ' ', STR_PAD_LEFT), " sen => ", formatRupiah($sen), "\n";
}Output yang Dihasilkan:
2550000 sen => Rp25.500,00
1234567 sen => Rp12.345,67
350000 sen => Rp3.500,00
850000000 sen => Rp8.500.000,00
0 sen => Rp0,00
-150500 sen => -Rp1.505,00Nilai 1234567 — bunga tabungan dari data awal Bagian 1 — tampil utuh sebagai Rp12.345,67 sampai dua digit sen. Nilai negatif muncul saat sisa budget minus di Bagian 4 nanti. Karena masukan dan keluaran sama-sama ditentukan penuh, fungsi ini bisa diuji tanpa database — bekal yang dipakai lagi oleh Bagian 7.
Class AccountRepository #
Seluruh operasi akun kita pusatkan pada satu class berformat sama seperti pola pada chapter Repository Pattern
. Berikut isi lengkap src/AccountRepository.php:
<?php
declare(strict_types=1);
/**
* Lapisan akses data untuk tabel accounts.
*
* Seluruh query SQL yang menyentuh tabel accounts dipusatkan di
* class ini. Koneksi PDO disuntikkan lewat constructor, sehingga
* pemanggil-lah yang menentukan database yang dipakai: berkas di
* disk atau database sementara `sqlite::memory:`.
*/
final class AccountRepository
{
/** Daftar jenis akun yang diakui schema (tabel accounts). */
public const JENIS_AKUN = ['kas', 'bank', 'ewallet'];
public function __construct(private PDO $pdo)
{
// Pemeriksaan foreign key SQLite nonaktif secara default dan
// berlaku per koneksi; constructor adalah titik yang pasti
// dilewati setiap kali repository dibuat.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Simpan akun baru, kembalikan id baris yang baru dibuat.
*
* Nilai $jenis divalidasi di PHP agar gagal lebih awal dengan
* pesan yang jelas; CHECK constraint di database tetap menjadi
* baris pertahanan terakhir.
*/
public function simpan(string $nama, string $jenis): int
{
if (!in_array($jenis, self::JENIS_AKUN, true)) {
throw new InvalidArgumentException(
"Jenis akun tidak valid: '$jenis' (pilihan: " . implode(', ', self::JENIS_AKUN) . ')'
);
}
$stmt = $this->pdo->prepare('INSERT INTO accounts (nama, jenis) VALUES (?, ?)');
$stmt->execute([$nama, $jenis]);
return (int) $this->pdo->lastInsertId();
}
/**
* Ambil satu akun berdasarkan id; null bila tidak ditemukan.
*
* @return array<string, mixed>|null
*/
public function cariId(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT id, nama, jenis FROM accounts WHERE id = ?');
$stmt->execute([$id]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
/**
* Ambil seluruh akun, urut berdasarkan nama.
*
* @return list<array<string, mixed>>
*/
public function semua(): array
{
return $this->pdo
->query('SELECT id, nama, jenis FROM accounts ORDER BY nama')
->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Daftar seluruh akun beserta saldonya dalam sen.
*
* Saldo TIDAK disimpan sebagai kolom, melainkan dihitung ulang
* dari tabel transactions: pemasukan menambah, pengeluaran
* mengurangi. Akun tanpa transaksi memiliki saldo 0 lewat
* LEFT JOIN + COALESCE.
*
* @return list<array<string, mixed>> setiap baris memuat id, nama, jenis, saldo
*/
public function semuaDenganSaldo(): array
{
$sql = "SELECT a.id, a.nama, a.jenis,
COALESCE(SUM(
CASE WHEN t.jenis = 'pemasukan' THEN t.jumlah ELSE -t.jumlah END
), 0) AS saldo
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.id
GROUP BY a.id
ORDER BY a.nama";
return $this->pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Hitung saldo satu akun dalam sen, dihitung dari transaksinya.
*/
public function saldo(int $id): int
{
$sql = "SELECT COALESCE(SUM(
CASE WHEN jenis = 'pemasukan' THEN jumlah ELSE -jumlah END
), 0)
FROM transactions
WHERE account_id = ?";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$id]);
return (int) $stmt->fetchColumn();
}
/**
* Perbarui nama dan jenis satu akun.
* Mengembalikan false bila id tidak ditemukan.
*/
public function perbarui(int $id, string $nama, string $jenis): bool
{
if (!in_array($jenis, self::JENIS_AKUN, true)) {
throw new InvalidArgumentException(
"Jenis akun tidak valid: '$jenis' (pilihan: " . implode(', ', self::JENIS_AKUN) . ')'
);
}
$stmt = $this->pdo->prepare('UPDATE accounts SET nama = ?, jenis = ? WHERE id = ?');
$stmt->execute([$nama, $jenis, $id]);
return $stmt->rowCount() > 0;
}
/**
* Hapus satu akun yang belum memiliki transaksi.
*
* Mengembalikan false bila id tidak ditemukan. Akun yang sudah
* memiliki transaksi TIDAK dapat dihapus: foreign key dari
* transactions menolaknya, menjaga riwayat agar tetap utuh.
*/
public function hapus(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM accounts WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}Enam hal pada class tersebut perlu dibedah:
__construct(private PDO $pdo)memakai constructor property promotion dari PHP 8.0: koneksi masuk sebagai dependency dan hidup sepanjang umur objek. Constructor juga menyalakanPRAGMA foreign_keys = ON— mengikuti catatan Bagian 1 bahwa pragma berlaku per koneksi, dan constructor adalah titik yang pasti dilewati.- Konstanta
JENIS_AKUNmenjadi satu sumber kebenaran daftar jenis di sisi PHP. Validasiin_array(..., true)memakai perbandingan ketat agar'0'tidak pernah lolos sebagai0. simpan()danperbarui()melemparInvalidArgumentExceptionuntuk jenis yang tidak dikenal — pesan yang jauh lebih ramah daripadaCHECK constraint faileddari database. Database tetap menolak nilai busuk yang lolos dari jalur lain.cariId()menerjemahkanfalsedarifetch()menjadinullagar kontrak?arraybersih; pemanggil cukup memeriksa=== null.saldo()adalah wujud query derived data di atas;(int)di depanfetchColumn()karena PDO mengembalikan string.semuaDenganSaldo()menggabungkanLEFT JOINdan agregasiSUM(CASE ...)sekaligus: satu query menghasilkan daftar akun lengkap dengan saldonya, tanpa query per akun di dalam loop.LEFT JOINmenjaga akun tanpa transaksi tetap muncul dengan saldo0.
Class CategoryRepository #
Saudara dari AccountRepository mengelola kategori. CRUD-nya identik sehingga kita fokus pada bagian yang membedakan — byJenis(). Berikut isi lengkap src/CategoryRepository.php:
<?php
declare(strict_types=1);
/**
* Lapisan akses data untuk tabel categories.
*
* Kategori mengelompokkan transaksi menjadi dua arah: 'pemasukan'
* dan 'pengeluaran'. Arah inilah yang nanti divalidasi oleh
* TransactionService pada Bagian 3 — transaksi pemasukan hanya
* boleh memakai kategori pemasukan, dan sebaliknya.
*/
final class CategoryRepository
{
/** Dua arah arus uang yang diakui schema (tabel categories). */
public const JENIS_KATEGORI = ['pemasukan', 'pengeluaran'];
public function __construct(private PDO $pdo)
{
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Simpan kategori baru, kembalikan id baris yang baru dibuat.
*/
public function simpan(string $nama, string $jenis): int
{
if (!in_array($jenis, self::JENIS_KATEGORI, true)) {
throw new InvalidArgumentException(
"Jenis kategori tidak valid: '$jenis' (pilihan: " . implode(', ', self::JENIS_KATEGORI) . ')'
);
}
$stmt = $this->pdo->prepare('INSERT INTO categories (nama, jenis) VALUES (?, ?)');
$stmt->execute([$nama, $jenis]);
return (int) $this->pdo->lastInsertId();
}
/**
* Ambil satu kategori berdasarkan id; null bila tidak ditemukan.
*
* @return array<string, mixed>|null
*/
public function cariId(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT id, nama, jenis FROM categories WHERE id = ?');
$stmt->execute([$id]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
/**
* Ambil seluruh kategori, urut berdasarkan nama.
*
* @return list<array<string, mixed>>
*/
public function semua(): array
{
return $this->pdo
->query('SELECT id, nama, jenis FROM categories ORDER BY nama')
->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Ambil kategori yang arahnya sesuai $jenis, urut berdasarkan nama.
*
* Dipakai saat pengguna mencatat transaksi: daftar kategori yang
* tampil hanya yang searah dengan transaksi yang sedang dicatat.
*
* @param string $jenis 'pemasukan' atau 'pengeluaran'
* @return list<array<string, mixed>>
*/
public function byJenis(string $jenis): array
{
if (!in_array($jenis, self::JENIS_KATEGORI, true)) {
throw new InvalidArgumentException(
"Jenis kategori tidak valid: '$jenis' (pilihan: " . implode(', ', self::JENIS_KATEGORI) . ')'
);
}
$stmt = $this->pdo->prepare(
'SELECT id, nama, jenis FROM categories WHERE jenis = ? ORDER BY nama'
);
$stmt->execute([$jenis]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Perbarui nama dan jenis satu kategori.
* Mengembalikan false bila id tidak ditemukan.
*/
public function perbarui(int $id, string $nama, string $jenis): bool
{
if (!in_array($jenis, self::JENIS_KATEGORI, true)) {
throw new InvalidArgumentException(
"Jenis kategori tidak valid: '$jenis' (pilihan: " . implode(', ', self::JENIS_KATEGORI) . ')'
);
}
$stmt = $this->pdo->prepare('UPDATE categories SET nama = ?, jenis = ? WHERE id = ?');
$stmt->execute([$nama, $jenis, $id]);
return $stmt->rowCount() > 0;
}
/**
* Hapus satu kategori yang belum dipakai transaksi.
*
* Mengembalikan false bila id tidak ditemukan. Kategori yang
* sudah dipakai transaksi tidak dapat dihapus karena foreign
* key dari transactions menolaknya.
*/
public function hapus(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM categories WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}Method byJenis($jenis) menjawab kebutuhan nyata antarmuka pencatatan: ketika pengguna memilih transaksi pengeluaran, hanya kategori pengeluaran yang boleh tampil di daftar pilihan. Filter WHERE jenis = ? memakai parameter terikat sesuai materi Prepared Statements
, dan validasi $jenis tetap di PHP dengan pesan yang menyebut pilihan yang sah.
Walkthrough Akun dan Saldo Terhitung #
Seluruh contoh pada bagian ini memakai pola pembuka yang sama: buka koneksi :memory:, nyalakan error mode exception, aktifkan pragma foreign key, lalu pasang schema dari Bagian 1. Alur pertama kita menambah akun ewallet baru, membuktikan id yang tidak dikenal menghasilkan null, lalu mencetak saldo tiap akun:
<?php
require __DIR__ . '/src/AccountRepository.php';
require __DIR__ . '/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'));
$repo = new AccountRepository($pdo);
$idBaru = $repo->simpan('ShopeePay', 'ewallet');
echo "Akun baru tersimpan dengan id $idBaru\n";
$akun = $repo->cariId($idBaru);
echo "Nama : {$akun['nama']}\n";
echo "Jenis: {$akun['jenis']}\n";
var_dump($repo->cariId(999));
echo "\nSaldo dihitung ulang dari transaksi:\n";
echo " Dompet Kas : ", formatRupiah($repo->saldo(1)), "\n";
echo " Rekening BCA : ", formatRupiah($repo->saldo(2)), "\n";
echo " GoPay : ", formatRupiah($repo->saldo(3)), "\n";
echo " ShopeePay : ", formatRupiah($repo->saldo($idBaru)), "\n";
echo "\nDaftar akun beserta saldo:\n";
printf("%-14s %-9s %15s\n", 'Akun', 'Jenis', 'Saldo');
foreach ($repo->semuaDenganSaldo() as $baris) {
printf("%-14s %-9s %15s\n", $baris['nama'], $baris['jenis'], formatRupiah((int) $baris['saldo']));
}Output yang Dihasilkan:
Akun baru tersimpan dengan id 4
Nama : ShopeePay
Jenis: ewallet
NULL
Saldo dihitung ulang dari transaksi:
Dompet Kas : Rp1.849.500,00
Rekening BCA : Rp20.957.355,67
GoPay : Rp146.500,00
ShopeePay : Rp0,00
Daftar akun beserta saldo:
Akun Jenis Saldo
Dompet Kas kas Rp1.849.500,00
GoPay ewallet Rp146.500,00
Rekening BCA bank Rp20.957.355,67
ShopeePay ewallet Rp0,00Tiga hal terbukti pada output tersebut. Akun baru mendapat id 4 karena tiga data awal memakai id 1 sampai 3 dan AUTOINCREMENT melanjutkan urutan. Saldo Rekening BCA mencantumkan pecahan sen Rp20.957.355,67 — bunga tabungan Rp12.345,67 dari Bagian 1 terbawa utuh tanpa pembulatan, bukti jalur integer bekerja dari database sampai tampilan. Terakhir, ShopeePay tampil dengan saldo Rp0,00 — hasil LEFT JOIN + COALESCE pada akun yang belum bertransaksi, bukan baris yang hilang.
Walkthrough Kategori dan Perlindungan Riwayat #
Alur kedua menyusuri operasi kategori: daftar per jenis, simpan, perbarui nama, hapus yang belum dipakai, sampai dua penolakan yang justru menunjukkan sistem sehat:
<?php
require __DIR__ . '/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'));
$repo = new CategoryRepository($pdo);
echo "Kategori pengeluaran (byJenis):\n";
foreach ($repo->byJenis('pengeluaran') as $kategori) {
echo " #{$kategori['id']} {$kategori['nama']}\n";
}
$idBaru = $repo->simpan('Pendidikan', 'pengeluaran');
echo "\nsimpan('Pendidikan', 'pengeluaran') => id $idBaru\n";
echo $repo->perbarui($idBaru, 'Pendidikan & Buku', 'pengeluaran')
? "perbarui($idBaru) berhasil\n" : "perbarui($idBaru): id tidak ditemukan\n";
$kategori = $repo->cariId($idBaru);
echo "Nama kini: {$kategori['nama']}\n";
echo $repo->hapus($idBaru) ? "hapus($idBaru) berhasil — belum dipakai transaksi\n"
: "hapus($idBaru): id tidak ditemukan\n";
echo $repo->hapus(999) ? "terhapus\n" : "hapus(999): id tidak ditemukan\n";
echo "\nHapus kategori yang sudah dipakai transaksi:\n";
try {
$repo->hapus(4);
echo "Kategori #4 terhapus.\n";
} catch (PDOException $e) {
echo "Ditolak database [{$e->getCode()}]\n";
echo " {$e->getMessage()}\n";
}
echo "\nJenis di luar daftar:\n";
try {
$repo->simpan('Investasi', 'aset');
echo "Tersimpan.\n";
} catch (InvalidArgumentException $e) {
echo "Ditolak PHP: {$e->getMessage()}\n";
}Output yang Dihasilkan:
Kategori pengeluaran (byJenis):
#7 Belanja
#8 Cicilan
#6 Hiburan
#4 Makanan
#5 Transportasi
simpan('Pendidikan', 'pengeluaran') => id 9
perbarui(9) berhasil
Nama kini: Pendidikan & Buku
hapus(9) berhasil — belum dipakai transaksi
hapus(999): id tidak ditemukan
Hapus kategori yang sudah dipakai transaksi:
Ditolak database [23000]
SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed
Jenis di luar daftar:
Ditolak PHP: Jenis kategori tidak valid: 'aset' (pilihan: pemasukan, pengeluaran)Penolakan pertama datang dari database: kategori Makanan (#4) sudah dipakai transaksi #5, sehingga DELETE digagalkan foreign key dengan SQLSTATE 23000. Penolakan kedua datang dari PHP sebelum query dijalankan: jenis 'aset' tidak ada dalam JENIS_KATEGORI. Keduanya bekerja di lapisan berbeda dengan tujuan sama — menjaga data tetap konsisten — dan pemanggil tinggal memilih cara menangkapnya: try/catch untuk exception PHP, atau pemeriksaan balik false untuk id yang tidak ada.
Cara Menjalankan Contoh #
Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1 serta tiga berkas bundle 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
└── uji-akun.phpSimpan salah satu walkthrough di atas sebagai uji-akun.php, lalu jalankan dari dalam folder bagian-2-akun-dan-kategori:
php uji-akun.phpPath __DIR__ . '/../bagian-1-domain-dan-database/schema.sql' menunjuk satu tingkat ke atas lalu masuk folder bagian saudara, sehingga struktur folder ini harus dipertahankan. Seluruh contoh memakai :memory: — tidak ada berkas database yang tersisa setelah eksekusi, dan setiap eksekusi selalu mulai dari data awal yang sama.
Apa yang Berubah #
Dibandingkan Bagian 1 yang hanya memiliki schema, proyek kini memiliki lapisan akses data yang hidup:
- Berkas
src/AccountRepository.phpbaru dengan tujuh operasi:simpan(),cariId(),semua(),semuaDenganSaldo(),saldo(),perbarui(), danhapus()— seluruhnya berjalan di atas prepared statements. - Berkas
src/CategoryRepository.phpbaru dengan operasi yang sejajar ditambahbyJenis()untuk memfilter kategori searah transaksi. - Berkas
src/uang.phpbaru berisiformatRupiah()yang mengubah sen menjadi teksRp12.345,67tanpa pernah menyentuh floating point. - Saldo resmi menjadi data turunan: tidak ada kolom yang bisa basi, hanya
SUM(CASE ...)yang selalu segar terhadap riwayat transaksi.
Ringkasan #
Dua repository kini menjadikan tabel accounts dan categories sebagai satu-satunya urusan masing-masing class: PDO disuntikkan dari luar, nilai pengguna berjalan lewat parameter binding, saldo dihitung ulang dari transaksi lewat agregasi SUM(CASE ...), dan formatRupiah() menjaga tampilan tetap manusiawi tanpa kehilangan presisi sen. Namun aplikasi keuangan hidup dari transaksinya — dan pencatatan transaksi butuh aturan yang melampaui kemampuan repository: arah transaksi harus cocok dengan kategorinya. Bagian 3 membangun lapisan itulah, TransactionService, lengkap dengan validasi domain dan query periode.
Referensi #
- PDO Prepared Statements — php.net
- PDOStatement::fetchColumn — php.net
- intdiv — php.net
- PHP: The Right Way
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/AccountRepository.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Lapisan akses data untuk tabel accounts.
*
* Seluruh query SQL yang menyentuh tabel accounts dipusatkan di
* class ini. Koneksi PDO disuntikkan lewat constructor, sehingga
* pemanggil-lah yang menentukan database yang dipakai: berkas di
* disk atau database sementara `sqlite::memory:`.
*/
final class AccountRepository
{
/** Daftar jenis akun yang diakui schema (tabel accounts). */
public const JENIS_AKUN = ['kas', 'bank', 'ewallet'];
public function __construct(private PDO $pdo)
{
// Pemeriksaan foreign key SQLite nonaktif secara default dan
// berlaku per koneksi; constructor adalah titik yang pasti
// dilewati setiap kali repository dibuat.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Simpan akun baru, kembalikan id baris yang baru dibuat.
*
* Nilai $jenis divalidasi di PHP agar gagal lebih awal dengan
* pesan yang jelas; CHECK constraint di database tetap menjadi
* baris pertahanan terakhir.
*/
public function simpan(string $nama, string $jenis): int
{
if (!in_array($jenis, self::JENIS_AKUN, true)) {
throw new InvalidArgumentException(
"Jenis akun tidak valid: '$jenis' (pilihan: " . implode(', ', self::JENIS_AKUN) . ')'
);
}
$stmt = $this->pdo->prepare('INSERT INTO accounts (nama, jenis) VALUES (?, ?)');
$stmt->execute([$nama, $jenis]);
return (int) $this->pdo->lastInsertId();
}
/**
* Ambil satu akun berdasarkan id; null bila tidak ditemukan.
*
* @return array<string, mixed>|null
*/
public function cariId(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT id, nama, jenis FROM accounts WHERE id = ?');
$stmt->execute([$id]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
/**
* Ambil seluruh akun, urut berdasarkan nama.
*
* @return list<array<string, mixed>>
*/
public function semua(): array
{
return $this->pdo
->query('SELECT id, nama, jenis FROM accounts ORDER BY nama')
->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Daftar seluruh akun beserta saldonya dalam sen.
*
* Saldo TIDAK disimpan sebagai kolom, melainkan dihitung ulang
* dari tabel transactions: pemasukan menambah, pengeluaran
* mengurangi. Akun tanpa transaksi memiliki saldo 0 lewat
* LEFT JOIN + COALESCE.
*
* @return list<array<string, mixed>> setiap baris memuat id, nama, jenis, saldo
*/
public function semuaDenganSaldo(): array
{
$sql = "SELECT a.id, a.nama, a.jenis,
COALESCE(SUM(
CASE WHEN t.jenis = 'pemasukan' THEN t.jumlah ELSE -t.jumlah END
), 0) AS saldo
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.id
GROUP BY a.id
ORDER BY a.nama";
return $this->pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Hitung saldo satu akun dalam sen, dihitung dari transaksinya.
*/
public function saldo(int $id): int
{
$sql = "SELECT COALESCE(SUM(
CASE WHEN jenis = 'pemasukan' THEN jumlah ELSE -jumlah END
), 0)
FROM transactions
WHERE account_id = ?";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$id]);
return (int) $stmt->fetchColumn();
}
/**
* Perbarui nama dan jenis satu akun.
* Mengembalikan false bila id tidak ditemukan.
*/
public function perbarui(int $id, string $nama, string $jenis): bool
{
if (!in_array($jenis, self::JENIS_AKUN, true)) {
throw new InvalidArgumentException(
"Jenis akun tidak valid: '$jenis' (pilihan: " . implode(', ', self::JENIS_AKUN) . ')'
);
}
$stmt = $this->pdo->prepare('UPDATE accounts SET nama = ?, jenis = ? WHERE id = ?');
$stmt->execute([$nama, $jenis, $id]);
return $stmt->rowCount() > 0;
}
/**
* Hapus satu akun yang belum memiliki transaksi.
*
* Mengembalikan false bila id tidak ditemukan. Akun yang sudah
* memiliki transaksi TIDAK dapat dihapus: foreign key dari
* transactions menolaknya, menjaga riwayat agar tetap utuh.
*/
public function hapus(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM accounts WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}
src/CategoryRepository.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Lapisan akses data untuk tabel categories.
*
* Kategori mengelompokkan transaksi menjadi dua arah: 'pemasukan'
* dan 'pengeluaran'. Arah inilah yang nanti divalidasi oleh
* TransactionService pada Bagian 3 — transaksi pemasukan hanya
* boleh memakai kategori pemasukan, dan sebaliknya.
*/
final class CategoryRepository
{
/** Dua arah arus uang yang diakui schema (tabel categories). */
public const JENIS_KATEGORI = ['pemasukan', 'pengeluaran'];
public function __construct(private PDO $pdo)
{
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Simpan kategori baru, kembalikan id baris yang baru dibuat.
*/
public function simpan(string $nama, string $jenis): int
{
if (!in_array($jenis, self::JENIS_KATEGORI, true)) {
throw new InvalidArgumentException(
"Jenis kategori tidak valid: '$jenis' (pilihan: " . implode(', ', self::JENIS_KATEGORI) . ')'
);
}
$stmt = $this->pdo->prepare('INSERT INTO categories (nama, jenis) VALUES (?, ?)');
$stmt->execute([$nama, $jenis]);
return (int) $this->pdo->lastInsertId();
}
/**
* Ambil satu kategori berdasarkan id; null bila tidak ditemukan.
*
* @return array<string, mixed>|null
*/
public function cariId(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT id, nama, jenis FROM categories WHERE id = ?');
$stmt->execute([$id]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
/**
* Ambil seluruh kategori, urut berdasarkan nama.
*
* @return list<array<string, mixed>>
*/
public function semua(): array
{
return $this->pdo
->query('SELECT id, nama, jenis FROM categories ORDER BY nama')
->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Ambil kategori yang arahnya sesuai $jenis, urut berdasarkan nama.
*
* Dipakai saat pengguna mencatat transaksi: daftar kategori yang
* tampil hanya yang searah dengan transaksi yang sedang dicatat.
*
* @param string $jenis 'pemasukan' atau 'pengeluaran'
* @return list<array<string, mixed>>
*/
public function byJenis(string $jenis): array
{
if (!in_array($jenis, self::JENIS_KATEGORI, true)) {
throw new InvalidArgumentException(
"Jenis kategori tidak valid: '$jenis' (pilihan: " . implode(', ', self::JENIS_KATEGORI) . ')'
);
}
$stmt = $this->pdo->prepare(
'SELECT id, nama, jenis FROM categories WHERE jenis = ? ORDER BY nama'
);
$stmt->execute([$jenis]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Perbarui nama dan jenis satu kategori.
* Mengembalikan false bila id tidak ditemukan.
*/
public function perbarui(int $id, string $nama, string $jenis): bool
{
if (!in_array($jenis, self::JENIS_KATEGORI, true)) {
throw new InvalidArgumentException(
"Jenis kategori tidak valid: '$jenis' (pilihan: " . implode(', ', self::JENIS_KATEGORI) . ')'
);
}
$stmt = $this->pdo->prepare('UPDATE categories SET nama = ?, jenis = ? WHERE id = ?');
$stmt->execute([$nama, $jenis, $id]);
return $stmt->rowCount() > 0;
}
/**
* Hapus satu kategori yang belum dipakai transaksi.
*
* Mengembalikan false bila id tidak ditemukan. Kategori yang
* sudah dipakai transaksi tidak dapat dihapus karena foreign
* key dari transactions menolaknya.
*/
public function hapus(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM categories WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}
src/uang.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Helper format mata uang untuk seluruh proyek keuangan.
*
* Uang disimpan sebagai INTEGER sen; helper ini menerjemahkan sen
* menjadi teks berformat Indonesia, misalnya 1234567 -> "Rp12.345,67".
* Dibuat sebagai function biasa agar mudah diuji secara terpisah.
*/
/**
* Format sen menjadi teks rupiah dengan gaya Indonesia.
*
* Pemisah ribuan memakai titik dan pecahan sen memakai koma,
* misalnya 2500000 -> "Rp25.000,00". Nilai negatif diawali tanda
* minus di depan "Rp", misalnya -150500 -> "-Rp1.505,00".
*/
function formatRupiah(int $sen): string
{
$negatif = $sen < 0;
$mutlak = abs($sen);
$rupiah = intdiv($mutlak, 100);
$sisa = $mutlak % 100;
$hasil = 'Rp' . number_format($rupiah, 0, ',', '.')
. ',' . str_pad((string) $sisa, 2, '0', STR_PAD_LEFT);
return $negatif ? '-' . $hasil : $hasil;
}