Empat bagian belakangan proyek ini sibuk menulis: akun, kategori, transaksi, budget, cetakan rutin. Kini meja kerja berbalik arah — laporan hanya membaca. Pertanyaan yang harus dijawab bukan lagi “bagaimana data masuk dengan aman”, melainkan “berapa saya habiskai bulan ini, di kategori apa, dan ke arah mana arus kas saya bergerak”. Pada bagian ini kita membangun ReportService di atas fondasi tersebut: ringkasan bulanan per akun lengkap dengan saldo awal dan saldo akhir, arus kas enam bulan, rincian pengeluaran per kategori dengan persentase, dan laba rugi sederhana — semuanya dihitung database lewat GROUP BY, digunakan apa adanya sebagai integer sen, lalu diterjemahkan menjadi grafik batang dan sparkline berbasis teks yang bisa dicetak di terminal mana pun.
Laporan adalah Query, Bukan Tabel Baru #
Kesalahan desain yang paling sering muncul saat aplikasi butuh laporan adalah menambahkan tabel rangkuman — rekap_bulanan yang diisi ulang setiap malam. Pendekatan itu menambah satu sumber kebenaran lagi yang harus dijaga sinkron. Proyek ini menolaknya: seluruh laporan dihitung langsung dari transactions pada saat diminta. Basis datanya sudah benar sejak Bagian 1 — uang disimpan sebagai integer sen, terjadi_pada berformat ISO Y-m-d H:i:s sehingga bisa dibandingkan secara leksikografis, dan index pada terjadi_pada, account_id, category_id sudah menunggu pola query laporan.
Satu alat SQL yang belum kita pakai sejauh ini: fungsi strftime() milik SQLite. Fungsi ini membaca teks berformat ISO-8601 lalu memformatnya ulang — strftime('%Y-%m', '2026-02-10 07:30:00') menghasilkan '2026-02'. Dengan begitu, mengelompokkan transaksi per bulan cukup satu query: GROUP BY strftime('%Y-%m', terjadi_pada). Karena seluruh data demo kita membekukan waktu, hasil agregasi ini pun sepenuhnya deterministik — laporan yang sama pada data yang sama selalu menghasilkan angka yang sama.
Class ReportService #
Service ini murni membaca: tidak satu pun method-nya menulis ke database. Berikut isi lengkap src/ReportService.php:
<?php
declare(strict_types=1);
/**
* Lapisan laporan untuk Aplikasi Keuangan Pribadi.
*
* Service ini murni membaca: seluruh method-nya menjalankan SELECT
* lalu menyusun hasilnya menjadi angka-angka yang siap dicetak.
* Uang tetap dibeoarkan sebagai INTEGER sen dan persentase dihitung
* dengan aritmetika integer (basis point) sehingga tidak ada float
* yang bisa membulatkan hasil secara diam-diam.
*
* Dua method di akhir menerjemahkan angka menjadi grafik berbasis
* teks: batang horizontal (str_repeat) dan sparkline (blok Unicode).
* Keduanya deterministik — panjang batang dan tinggi blok murni
* fungsi dari data, bukan dari jam atau acak.
*
* Catatan dependensi: method grafik memanggil formatRupiah() dari
* berkas uang.php Bagian 2, jadi pemanggil wajib memuat berkas itu
* lebih dulu (seluruh demo proyek memuatnya).
*/
final class ReportService
{
/** Karakter blok untuk sparkline, dari paling rendah ke paling tinggi. */
private const BLOK_SPARKLINE = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
public function __construct(private PDO $pdo)
{
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Ringkasan satu bulan: arus dan posisi tiap akun.
*
* Setiap akun mendapat lima angka: saldo awal (semua transaksi
* SEBELUM bulan itu), pemasukan dan pengeluaran DI DALAM bulan,
* lalu saldo akhir = saldo awal + pemasukan - pengeluaran. Akun
* tanpa transaksi tetap muncul dengan angka nol lewat LEFT JOIN
* + COALESCE. Kunci array hasil: total_pemasukan,
* total_pengeluaran, selisih, total_saldo_awal, total_saldo_akhir,
* dan akun (daftar baris per akun).
*
* @param string $bulan bulan target format 'YYYY-MM', mis. '2026-01'
* @return array<string, mixed>
*/
public function ringkasanBulanan(string $bulan): array
{
$this->pastikanBulanValid($bulan);
[$dari, $sampai] = $this->rentangBulan($bulan);
$sql = "SELECT a.id, a.nama, a.jenis,
COALESCE(SUM(
CASE WHEN t.terjadi_pada < :dari THEN
CASE WHEN t.jenis = 'pemasukan' THEN t.jumlah ELSE -t.jumlah END
ELSE 0 END
), 0) AS saldo_awal,
COALESCE(SUM(
CASE WHEN t.terjadi_pada BETWEEN :dari AND :sampai AND t.jenis = 'pemasukan'
THEN t.jumlah ELSE 0 END
), 0) AS pemasukan,
COALESCE(SUM(
CASE WHEN t.terjadi_pada BETWEEN :dari AND :sampai AND t.jenis = 'pengeluaran'
THEN t.jumlah ELSE 0 END
), 0) AS pengeluaran
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.id
GROUP BY a.id
ORDER BY a.nama";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['dari' => $dari, 'sampai' => $sampai]);
$akun = [];
$totalPemasukan = 0;
$totalPengeluaran = 0;
$totalSaldoAwal = 0;
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $baris) {
$pemasukan = (int) $baris['pemasukan'];
$pengeluaran = (int) $baris['pengeluaran'];
$saldoAwal = (int) $baris['saldo_awal'];
$akun[] = [
'id' => (int) $baris['id'],
'nama' => $baris['nama'],
'jenis' => $baris['jenis'],
'saldo_awal' => $saldoAwal,
'pemasukan' => $pemasukan,
'pengeluaran' => $pengeluaran,
'saldo_akhir' => $saldoAwal + $pemasukan - $pengeluaran,
];
$totalPemasukan += $pemasukan;
$totalPengeluaran += $pengeluaran;
$totalSaldoAwal += $saldoAwal;
}
return [
'bulan' => $bulan,
'akun' => $akun,
'total_saldo_awal' => $totalSaldoAwal,
'total_pemasukan' => $totalPemasukan,
'total_pengeluaran' => $totalPengeluaran,
'selisih' => $totalPemasukan - $totalPengeluaran,
'total_saldo_akhir' => $totalSaldoAwal + $totalPemasukan - $totalPengeluaran,
];
}
/**
* Arus kas beberapa bulan berakhir di $bulanAkhir, per bulan.
*
* Daftar bulan dibangun di PHP lewat DateTimeImmutable sehingga
* bulan tanpa transaksi tetap muncul dengan angka nol; hasil
* query GROUP BY hanya menimpa bulan yang memang berisi. Nilai
* net = pemasukan - pengeluaran, negatif berarti bulan defisit.
* Urutan hasil dari bulan tertua ke $bulanAkhir.
*
* @param string $bulanAkhir bulan terakhir jendela, format 'YYYY-MM'
* @param int $jumlahBulan panjang jendela, minimal 1
* @return list<array<string, int|string>> tiap baris: bulan, pemasukan, pengeluaran, net
*/
public function arusKas(string $bulanAkhir, int $jumlahBulan = 6): array
{
$this->pastikanBulanValid($bulanAkhir);
if ($jumlahBulan < 1) {
throw new InvalidArgumentException("Jumlah bulan harus minimal 1, diterima: $jumlahBulan");
}
$akhir = new DateTimeImmutable($bulanAkhir . '-01');
$bulanDaftar = [];
for ($i = $jumlahBulan - 1; $i >= 0; $i--) {
$bulanDaftar[] = $akhir->modify("-$i months")->format('Y-m');
}
$dari = $bulanDaftar[0] . '-01 00:00:00';
$sampai = $akhir->modify('last day of this month')->format('Y-m-d') . ' 23:59:59';
$sql = "SELECT strftime('%Y-%m', terjadi_pada) AS periode, jenis, SUM(jumlah) AS total
FROM transactions
WHERE terjadi_pada BETWEEN ? AND ?
GROUP BY strftime('%Y-%m', terjadi_pada), jenis
ORDER BY periode";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$dari, $sampai]);
$perBulan = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $baris) {
$perBulan[$baris['periode']][$baris['jenis']] = (int) $baris['total'];
}
$hasil = [];
foreach ($bulanDaftar as $bulan) {
$pemasukan = $perBulan[$bulan]['pemasukan'] ?? 0;
$pengeluaran = $perBulan[$bulan]['pengeluaran'] ?? 0;
$hasil[] = [
'bulan' => $bulan,
'pemasukan' => $pemasukan,
'pengeluaran' => $pengeluaran,
'net' => $pemasukan - $pengeluaran,
];
}
return $hasil;
}
/**
* Rincian pengeluaran per kategori pada satu bulan, terbesar dulu.
*
* Kolom persen menyimpan basis point: satu persen = 100 bp,
* jadi 5981 berarti 59,81%. Perhitungan memakai intdiv sehingga
* hasil selalu integer; jumlah persentase seluruh kategori bisa
* sedikit di bawah 100% karena pembulatan ke bawah — tampilkan
* dengan formatPersen().
*
* @param string $bulan bulan target format 'YYYY-MM'
* @return list<array<string, int|string>> tiap baris: id, nama, jumlah_transaksi, total, persen
*/
public function pengeluaranPerKategori(string $bulan): array
{
$this->pastikanBulanValid($bulan);
[$dari, $sampai] = $this->rentangBulan($bulan);
$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 = \'pengeluaran\' AND t.terjadi_pada BETWEEN ? AND ?
GROUP BY c.id
ORDER BY total DESC, c.nama';
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$dari, $sampai]);
$baris = $stmt->fetchAll(PDO::FETCH_ASSOC);
$totalKeseluruhan = 0;
foreach ($baris as $b) {
$totalKeseluruhan += (int) $b['total'];
}
$hasil = [];
foreach ($baris as $b) {
$hasil[] = [
'id' => (int) $b['id'],
'nama' => $b['nama'],
'jumlah_transaksi' => (int) $b['jumlah_transaksi'],
'total' => (int) $b['total'],
'persen' => intdiv((int) $b['total'] * 10000, $totalKeseluruhan),
];
}
return $hasil;
}
/**
* Laba rugi sederhana satu bulan: total pemasukan, total
* pengeluaran, selisihnya, dan margin.
*
* Margin = laba / pemasukan dalam basis point (integer). Bila
* bulan tanpa pemasukan, margin bernilai null — membagi dengan
* nol bukanlah jawaban.
*
* @param string $bulan bulan target format 'YYYY-MM'
* @return array{bulan: string, pemasukan: int, pengeluaran: int, laba: int, margin: ?int}
*/
public function labaRugi(string $bulan): array
{
$this->pastikanBulanValid($bulan);
[$dari, $sampai] = $this->rentangBulan($bulan);
$stmt = $this->pdo->prepare(
'SELECT jenis, SUM(jumlah) AS total
FROM transactions
WHERE terjadi_pada BETWEEN ? AND ?
GROUP BY jenis'
);
$stmt->execute([$dari, $sampai]);
$perJenis = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $baris) {
$perJenis[$baris['jenis']] = (int) $baris['total'];
}
$pemasukan = $perJenis['pemasukan'] ?? 0;
$pengeluaran = $perJenis['pengeluaran'] ?? 0;
$laba = $pemasukan - $pengeluaran;
return [
'bulan' => $bulan,
'pemasukan' => $pemasukan,
'pengeluaran' => $pengeluaran,
'laba' => $laba,
'margin' => $pemasukan > 0 ? intdiv($laba * 10000, $pemasukan) : null,
];
}
/**
* Grafik batang horizontal pengeluaran per kategori, sebagai teks.
*
* Panjang batang kategori terbesar selalu $lebarMaks karakter;
* kategori lain diskalakan proporsional dengan intdiv dan tidak
* pernah nol karakter selama nilainya positif. Butuh
* formatRupiah() dari uang.php Bagian 2.
*
* @param string $bulan bulan target format 'YYYY-MM'
* @param int $lebarMaks panjang batang terpanjang dalam karakter
*/
public function grafikPengeluaran(string $bulan, int $lebarMaks = 30): string
{
if ($lebarMaks < 1) {
throw new InvalidArgumentException("Lebar batang harus minimal 1, diterima: $lebarMaks");
}
$baris = $this->pengeluaranPerKategori($bulan);
if ($baris === []) {
return "(tidak ada pengeluaran pada $bulan)\n";
}
$terbesar = $baris[0]['total'];
$out = '';
foreach ($baris as $b) {
$panjang = max(1, intdiv($b['total'] * $lebarMaks, $terbesar));
$out .= sprintf(
"%-14s |%s %7s %s\n",
$b['nama'],
str_repeat('█', $panjang),
self::formatPersen($b['persen']),
formatRupiah($b['total'])
);
}
return $out;
}
/**
* Sparkline arus kas: satu karakter blok per bulan.
*
* Nilai net tiap bulan dinormalkan ke delapan tingkat blok
* Unicode. Bila seluruh bulan bernilai sama, semua bulan
* digambar '▄' di tengah — datar, bukan kosong. String keluaran
* panjangnya selalu $jumlahBulan karakter.
*
* @param string $bulanAkhir bulan terakhir jendela, format 'YYYY-MM'
* @param int $jumlahBulan panjang jendela, minimal 1
*/
public function sparklineArusKas(string $bulanAkhir, int $jumlahBulan = 6): string
{
$arus = $this->arusKas($bulanAkhir, $jumlahBulan);
$nilai = array_map(static fn (array $b): int => $b['net'], $arus);
$terkecil = min($nilai);
$terbesar = max($nilai);
$out = '';
foreach ($nilai as $net) {
if ($terbesar === $terkecil) {
$out .= '▄';
continue;
}
$out .= self::BLOK_SPARKLINE[intdiv(($net - $terkecil) * 7, $terbesar - $terkecil)];
}
return $out;
}
/**
* Format basis point sebagai persen gaya Indonesia, 5981 -> "59,81%".
*/
public static function formatPersen(int $persenBp): string
{
return sprintf('%d,%02d%%', intdiv($persenBp, 100), $persenBp % 100);
}
/**
* Tolak format bulan selain 'YYYY-MM'.
*/
private function pastikanBulanValid(string $bulan): void
{
if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $bulan)) {
throw new InvalidArgumentException(
"Format bulan tidak valid: '$bulan' (harus 'YYYY-MM', contoh '2026-01')"
);
}
}
/**
* Ubah 'YYYY-MM' menjadi pasangan batas waktu 'Y-m-d H:i:s'.
*
* @return array{0: string, 1: string} batas awal dan akhir bulan (inklusif)
*/
private function rentangBulan(string $bulan): array
{
$awal = new DateTimeImmutable($bulan . '-01');
$akhir = $awal->modify('last day of this month')->setTime(23, 59, 59);
return [$awal->format('Y-m-d H:i:s'), $akhir->format('Y-m-d H:i:s')];
}
}Enam hal pada class tersebut perlu dibedah:
ringkasanBulanan()menghitung tiga angka dalam satu query. Saldo awal dihitung dari seluruh transaksi sebelum bulan (t.terjadi_pada < :dari), sedangkan pemasukan dan pengeluaran memakaiBETWEEN :dari AND :sampai— batas atas wajib ada, kalau tidak, “bulan ini” akan menyedot seluruh masa depan. Saldo akhir tidak di-query, melainkan dijumlah di PHP:saldo_awal + pemasukan - pengeluaran, murni aritmetika integer.- Named parameter dipakai berulang dalam satu statement.
:darimuncul tiga kali dan:sampaidua kali; PDO mengizinkan hal ini dan cukup mengirim satu nilai per nama. Padanan positional?akan menuntut kita mengirim nilai yang sama berulang kali dengan urutan yang harus dihitung manual — query seperti ini lebih aman memakai nama. arusKas()membangun daftar bulan di PHP, bukan di SQL.GROUP BYhanya mengembalikan bulan yang berisi transaksi; bulan sepi tidak akan pernah muncul dari query. Karena itu daftar enam bulan dibangun lebih dulu lewatDateTimeImmutable::modify("-$i months"), diisi nol, lalu hasil query menimpa bulan yang memang berisi — baris?? 0menjaga bulan kosong tetap nol tanpa perluisset().- Persentase dihitung dalam basis point. Satu persen = 100 basis point, jadi 5981 bp = 59,81%.
intdiv($total * 10000, $totalKeseluruhan)tidak pernah menyentuh float; pembulatan selalu ke bawah dan konsisten. Konsekuensinya jujur ditunjukkan pada walkthrough: jumlah persentase seluruh kategori bisa sedikit di bawah 100%. grafikPengeluaran()menskalakan batang denganintdiv. Kategori terbesar selalu tepat$lebarMakskarakter (intdiv($terbesar * $lebarMaks, $terbesar)=$lebarMaks), kategori lain proporsional, danmax(1, ...)menjaga kategori terkecil tetap terlihat satu karakter.str_repeat()mengulang blok█sepanjang hasilnya — tidak ada jam, tidak ada acak, output sama di setiap eksekusi.sparklineArusKas()menormalkan nilai net ke delapan tinggi blok Unicode▁▂▃▄▅▆▇█. Rumusintdiv(($net - $terkecil) * 7, $terbesar - $terkecil)memetakan nilai terkecil ke indeks 0 dan terbesar ke indeks 7. Kasus seluruh bulan bernilai sama (termasuk semua nol) ditangani khusus dengan▄di tengah — hindari pembagian dengan nol.
Ada satu jebakan yang kami temukan sendiri saat menguji query arus kas, dan layak dibagikan karena mudah terulir:
Walkthrough Ringkasan Bulanan dan Laba Rugi #
Alur pertama membaca data awal Bagian 1 apa adanya — sembilan transaksi Januari, tanpa tambahan apa pun:
<?php
require __DIR__ . '/src/ReportService.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'));
$laporan = new ReportService($pdo);
$r = $laporan->ringkasanBulanan('2026-01');
echo "== Ringkasan bulanan {$r['bulan']} ==\n";
printf("%-13s %17s %17s %17s %17s\n", 'Akun', 'Saldo awal', 'Pemasukan', 'Pengeluaran', 'Saldo akhir');
foreach ($r['akun'] as $a) {
printf(
"%-13s %17s %17s %17s %17s\n",
$a['nama'],
formatRupiah($a['saldo_awal']),
formatRupiah($a['pemasukan']),
formatRupiah($a['pengeluaran']),
formatRupiah($a['saldo_akhir'])
);
}
printf(
"%-13s %17s %17s %17s %17s\n",
'TOTAL',
formatRupiah($r['total_saldo_awal']),
formatRupiah($r['total_pemasukan']),
formatRupiah($r['total_pengeluaran']),
formatRupiah($r['total_saldo_akhir'])
);
echo 'Selisih arus bulan ini: ', formatRupiah($r['selisih']), "\n";
$lr = $laporan->labaRugi('2026-01');
echo "\n== Laba rugi {$lr['bulan']} ==\n";
echo ' pemasukan : ', formatRupiah($lr['pemasukan']), "\n";
echo ' pengeluaran: ', formatRupiah($lr['pengeluaran']), "\n";
echo ' laba : ', formatRupiah($lr['laba']), "\n";
echo ' margin : ', ReportService::formatPersen($lr['margin']), "\n";Output yang Dihasilkan:
== Ringkasan bulanan 2026-01 ==
Akun Saldo awal Pemasukan Pengeluaran Saldo akhir
Dompet Kas Rp0,00 Rp2.000.000,00 Rp150.500,00 Rp1.849.500,00
GoPay Rp0,00 Rp150.000,00 Rp3.500,00 Rp146.500,00
Rekening BCA Rp0,00 Rp21.012.345,67 Rp54.990,00 Rp20.957.355,67
TOTAL Rp0,00 Rp23.162.345,67 Rp208.990,00 Rp22.953.355,67
Selisih arus bulan ini: Rp22.953.355,67
== Laba rugi 2026-01 ==
pemasukan : Rp23.162.345,67
pengeluaran: Rp208.990,00
laba : Rp22.953.355,67
margin : 99,09%Tiga akun tampil lengkap meskipun LEFT JOIN memungkinkan akun tanpa transaksi — di sini ketiganya memang bertransaksi. Kolom saldo awal Januari bernilai nol untuk semua akun karena data baru mulai mengalir tanggal 1 Januari; angka ini baru menarik pada walkthrough berikutnya ketika laporan Februari berdiri di atas saldo Januari. Rekening BCA menyerap hampir seluruh aktivitas: pemasukan Rp21.012.345,67 (saldo awal yang besar, gaji, dan bunga) dengan pengeluaran hanya Rp54.990,00. Laba rugi bulan ini positif Rp22.953.355,67 dengan margin 99,09% — angka margin yang absurd tinggi memang wajar untuk bulan pertama yang diisi saldo awal; justru itu alasan margin dihitung dari pemasukan, bukan dari saldo.
Walkthrough Persentase dan Grafik Batang #
Alur kedua membedah ke mana pengeluaran Januari pergi, lalu menggambarkannya:
<?php
require __DIR__ . '/src/ReportService.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'));
$laporan = new ReportService($pdo);
echo "== Pengeluaran per kategori 2026-01 ==\n";
$total = 0;
$jumlahPersen = 0;
foreach ($laporan->pengeluaranPerKategori('2026-01') as $b) {
printf(
"%-13s %2d trx %14s %8s\n",
$b['nama'],
$b['jumlah_transaksi'],
formatRupiah($b['total']),
ReportService::formatPersen($b['persen'])
);
$total += $b['total'];
$jumlahPersen += $b['persen'];
}
echo 'Total : ', formatRupiah($total), "\n";
echo 'Jumlah persen: ', ReportService::formatPersen($jumlahPersen), " (intdiv membulatkan ke bawah)\n";
echo "\n== Grafik batang pengeluaran (lebar 30) ==\n";
echo $laporan->grafikPengeluaran('2026-01');
echo "== Bulan tanpa pengeluaran ==\n";
echo $laporan->grafikPengeluaran('2025-12');Output yang Dihasilkan:
== Pengeluaran per kategori 2026-01 ==
Belanja 1 trx Rp125.000,00 59,81%
Hiburan 1 trx Rp54.990,00 26,31%
Makanan 1 trx Rp25.500,00 12,20%
Transportasi 1 trx Rp3.500,00 1,67%
Total : Rp208.990,00
Jumlah persen: 99,99% (intdiv membulatkan ke bawah)
== Grafik batang pengeluaran (lebar 30) ==
Belanja |██████████████████████████████ 59,81% Rp125.000,00
Hiburan |█████████████ 26,31% Rp54.990,00
Makanan |██████ 12,20% Rp25.500,00
Transportasi |█ 1,67% Rp3.500,00
== Bulan tanpa pengeluaran ==
(tidak ada pengeluaran pada 2025-12)Belanja memimpin dengan 59,81% dari total Rp208.990,00, diikuti Hiburan 26,31%, Makanan 12,20%, dan Transportasi 1,67%. Perhatikan baris terakhir: jumlah keempat persentase adalah 99,99%, bukan 100,00% — bukan bug, melainkan konsekuensi jujur intdiv yang membulatkan ke bawah per kategori. Kalau Anda butuh total persen yang selalu pas 100%, kategori terakhir bisa dihitung sebagai selisih (10000 - jumlah bp kategori lain), tetapi itu menempatkan seluruh galat pembulatan pada satu kategori; kami memilih jujur per baris. Bulan tanpa transaksi mengembalikan teks penjelas, bukan string kosong — laporan yang diam tanpa keterangan membuat pengguna bertanya-tanya apakah querynya gagal.
Walkthrough Arus Kas dan Sparkline #
Alur ketiga butuh data yang benar-benar mengalir lintas bulan, jadi walkthrough ini memakai cetakan rutin Bagian 4 untuk membubuhkan gaji, langganan streaming, dan cicilan ke Februari-Maret, menambah beberapa transaksi manual, lalu membaca jendela enam bulan yang berakhir Maret:
<?php
require __DIR__ . '/src/ReportService.php';
require __DIR__ . '/../bagian-4-budget-dan-recurring-transactions/src/RecurringTransactionService.php';
require __DIR__ . '/../bagian-3-pencatatan-transaksi/src/TransactionRepository.php';
require __DIR__ . '/../bagian-3-pencatatan-transaksi/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'));
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-4-budget-dan-recurring-transactions/migrasi-budget-recurring.sql'));
$laporan = new ReportService($pdo);
$recurring = new RecurringTransactionService($pdo);
$service = new TransactionService(
new AccountRepository($pdo),
new CategoryRepository($pdo),
new TransactionRepository($pdo),
);
$recurring->define(2, 2, 850000000, 'Gaji bulanan', 2);
$recurring->define(2, 6, 5499000, 'Langganan streaming', 7);
$recurring->define(2, 8, 75000000, 'Cicilan kartu kredit', 28);
$jamJalan = new DateTimeImmutable('2026-02-01 08:00:00', new DateTimeZone('Asia/Jakarta'));
echo 'Cetakan Februari dibubuhkan: ', $recurring->generateFor('2026-02', $jamJalan), " transaksi\n";
$jamJalan = new DateTimeImmutable('2026-03-01 08:00:00', new DateTimeZone('Asia/Jakarta'));
echo 'Cetakan Maret dibubuhkan : ', $recurring->generateFor('2026-03', $jamJalan), " transaksi\n";
$jamCatat = new DateTimeImmutable('2026-02-15 09:00:00', new DateTimeZone('Asia/Jakarta'));
$service->catat(1, 4, 'pengeluaran', 1500000, 'Sarapan bubur ayam', new DateTimeImmutable('2026-02-10 07:30:00', new DateTimeZone('Asia/Jakarta')), $jamCatat);
$service->catat(3, 5, 'pengeluaran', 850000, 'Naik MRT ke kantor', new DateTimeImmutable('2026-02-12 08:00:00', new DateTimeZone('Asia/Jakarta')), $jamCatat);
$service->catat(1, 7, 'pengeluaran', 9750000, 'Belanja bulanan: beras, minyak, gula', new DateTimeImmutable('2026-02-14 08:20:00', new DateTimeZone('Asia/Jakarta')), $jamCatat);
$service->catat(1, 4, 'pengeluaran', 6800000, 'Kopi kekinian bareng teman kantor', new DateTimeImmutable('2026-03-08 15:00:00', new DateTimeZone('Asia/Jakarta')), $jamCatat);
$service->catat(3, 5, 'pengeluaran', 1200000, 'Ojek online bandara', new DateTimeImmutable('2026-03-20 19:30:00', new DateTimeZone('Asia/Jakarta')), $jamCatat);
echo "\n== Arus kas 6 bulan berakhir 2026-03 ==\n";
printf("%-8s %17s %17s %17s\n", 'Bulan', 'Pemasukan', 'Pengeluaran', 'Net');
foreach ($laporan->arusKas('2026-03', 6) as $b) {
printf(
"%-8s %17s %17s %17s\n",
$b['bulan'],
formatRupiah($b['pemasukan']),
formatRupiah($b['pengeluaran']),
formatRupiah($b['net'])
);
}
echo 'Sparkline: ', $laporan->sparklineArusKas('2026-03', 6), "\n";
echo "\n== Grafik pengeluaran Februari ==\n";
echo $laporan->grafikPengeluaran('2026-02');
echo "== Ringkasan Februari: saldo awal kini terisi ==\n";
$r = $laporan->ringkasanBulanan('2026-02');
foreach ($r['akun'] as $a) {
printf(
"%-13s %17s %17s %17s %17s\n",
$a['nama'],
formatRupiah($a['saldo_awal']),
formatRupiah($a['pemasukan']),
formatRupiah($a['pengeluaran']),
formatRupiah($a['saldo_akhir'])
);
}Output yang Dihasilkan:
Cetakan Februari dibubuhkan: 3 transaksi
Cetakan Maret dibubuhkan : 3 transaksi
== Arus kas 6 bulan berakhir 2026-03 ==
Bulan Pemasukan Pengeluaran Net
2025-10 Rp0,00 Rp0,00 Rp0,00
2025-11 Rp0,00 Rp0,00 Rp0,00
2025-12 Rp0,00 Rp0,00 Rp0,00
2026-01 Rp23.162.345,67 Rp208.990,00 Rp22.953.355,67
2026-02 Rp8.500.000,00 Rp925.990,00 Rp7.574.010,00
2026-03 Rp8.500.000,00 Rp884.990,00 Rp7.615.010,00
Sparkline: ▁▁▁█▃▃
== Grafik pengeluaran Februari ==
Cicilan |██████████████████████████████ 80,99% Rp750.000,00
Belanja |███ 10,52% Rp97.500,00
Hiburan |██ 5,93% Rp54.990,00
Makanan |█ 1,61% Rp15.000,00
Transportasi |█ 0,91% Rp8.500,00
== Ringkasan Februari: saldo awal kini terisi ==
Dompet Kas Rp1.849.500,00 Rp0,00 Rp112.500,00 Rp1.737.000,00
GoPay Rp146.500,00 Rp0,00 Rp8.500,00 Rp138.000,00
Rekening BCA Rp20.957.355,67 Rp8.500.000,00 Rp804.990,00 Rp28.652.365,67Baca output tersebut dari bawah ke atas. Ringkasan Februari kini menunjukkan mengapa kolom saldo awal ada: Rekening BCA membuka bulan dengan Rp20.957.355,67 (hasil akhir Januari), menerima gaji Rp8.500.000,00, membayar streaming dan cicilan Rp804.990,00, lalu menutup bulan di Rp28.652.365,67. Tabel arus kas memperlihatkan tiga bulan kosong di awal jendela — tetap tampil dengan nol karena daftar bulan dibangun di PHP. Sparkline ▁▁▁█▃▃ meringkas keenam bulan itu dalam enam karakter: tiga bulan kosong di dasar, lonjakan besar Januari di puncak, lalu dua bulan stabil di sepertiga bawah. Terakhir, grafik Februari memindahkan puncak pengeluaran ke Cicilan 80,99% — komposisi yang sepenuhnya berbeda dari Januari, terlihat dalam sekejap tanpa membaca satu angka pun.
Cara Menjalankan Contoh #
Contoh pada bagian ini membutuhkan berkas Bagian 1-2 untuk dua walkthrough pertama, plus migrasi dan service Bagian 3-4 untuk walkthrough arus kas. 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
├── bagian-4-budget-dan-recurring-transactions/
│ ├── migrasi-budget-recurring.sql
│ └── src/
│ ├── BudgetService.php
│ └── RecurringTransactionService.php
└── bagian-5-laporan-dan-visualisasi/
├── src/
│ └── ReportService.php
└── uji-laporan.phpSimpan salah satu walkthrough di atas sebagai uji-laporan.php, lalu jalankan dari dalam folder bagian-5-laporan-dan-visualisasi:
php uji-laporan.phpMethod grafik memanggil formatRupiah() dari uang.php Bagian 2, jadi berkas itu wajib dimuat sebelum grafik dicetak — seluruh walkthrough di atas sudah melakukannya. Seperti sebelumnya, seluruh contoh memakai :memory: dan tidak meninggalkan berkas database.
Apa yang Berubah #
Dibandingkan Bagian 4 yang menegakkan aturan, proyek kini bisa menjawab pertanyaan:
- Berkas
src/ReportService.phpbaru denganringkasanBulanan()(saldo awal, arus, saldo akhir per akun dalam satu query),arusKas()(jendela N bulan dengan bulan kosong tetap tampil),pengeluaranPerKategori()(persen basis point),labaRugi()(margin integer,nullsaat tanpa pemasukan), plusgrafikPengeluaran()dansparklineArusKas()sebagai perender teks. - Helper statis
ReportService::formatPersen()menerjemahkan basis point ke teks59,81%— satu tempat, dipakai laporan maupun grafik. - Tidak ada satu pun method yang menulis: seluruh laporan adalah
SELECT, sehingga memanggilnya berulang kali tidak pernah mengubah data. - Jebakan alias
GROUP BYyang bertabrakan dengan kolombulandari migrasi Bagian 4 ditemukan saat pengujian dan ditutup dengan aliasperiodeplus pengelompokan lewat ekspresi penuh.
Ringkasan #
Laporan menutup setengah perjalanan terakhir proyek ini: data yang dicatat dengan aturan kini terbaca sebagai keputusan. Ringkasan bulanan menjawab “di mana uang saya berdiri”, arus kas menjawab “ke arah mana ia bergerak”, persentase menjawab “ke siapa ia pergi”, dan grafik teks membuat ketiganya terlihat tanpa angka dibaca satu per satu. Semua itu dicapai tanpa tabel rangkuman — cukup agregasi GROUP BY di atas integer sen dan sedikit aritmetika intdiv. Data yang mudah dibaca ini juga mudah dipindahkan: format CSV dan JSON adalah bahasa yang dimengerti spreadsheet dan aplikasi lain mana pun. Bagian 6 membangun jembatan itu — ekspor dan impor yang tervalidasi.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/ReportService.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Lapisan laporan untuk Aplikasi Keuangan Pribadi.
*
* Service ini murni membaca: seluruh method-nya menjalankan SELECT
* lalu menyusun hasilnya menjadi angka-angka yang siap dicetak.
* Uang tetap dibeoarkan sebagai INTEGER sen dan persentase dihitung
* dengan aritmetika integer (basis point) sehingga tidak ada float
* yang bisa membulatkan hasil secara diam-diam.
*
* Dua method di akhir menerjemahkan angka menjadi grafik berbasis
* teks: batang horizontal (str_repeat) dan sparkline (blok Unicode).
* Keduanya deterministik — panjang batang dan tinggi blok murni
* fungsi dari data, bukan dari jam atau acak.
*
* Catatan dependensi: method grafik memanggil formatRupiah() dari
* berkas uang.php Bagian 2, jadi pemanggil wajib memuat berkas itu
* lebih dulu (seluruh demo proyek memuatnya).
*/
final class ReportService
{
/** Karakter blok untuk sparkline, dari paling rendah ke paling tinggi. */
private const BLOK_SPARKLINE = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
public function __construct(private PDO $pdo)
{
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Ringkasan satu bulan: arus dan posisi tiap akun.
*
* Setiap akun mendapat lima angka: saldo awal (semua transaksi
* SEBELUM bulan itu), pemasukan dan pengeluaran DI DALAM bulan,
* lalu saldo akhir = saldo awal + pemasukan - pengeluaran. Akun
* tanpa transaksi tetap muncul dengan angka nol lewat LEFT JOIN
* + COALESCE. Kunci array hasil: total_pemasukan,
* total_pengeluaran, selisih, total_saldo_awal, total_saldo_akhir,
* dan akun (daftar baris per akun).
*
* @param string $bulan bulan target format 'YYYY-MM', mis. '2026-01'
* @return array<string, mixed>
*/
public function ringkasanBulanan(string $bulan): array
{
$this->pastikanBulanValid($bulan);
[$dari, $sampai] = $this->rentangBulan($bulan);
$sql = "SELECT a.id, a.nama, a.jenis,
COALESCE(SUM(
CASE WHEN t.terjadi_pada < :dari THEN
CASE WHEN t.jenis = 'pemasukan' THEN t.jumlah ELSE -t.jumlah END
ELSE 0 END
), 0) AS saldo_awal,
COALESCE(SUM(
CASE WHEN t.terjadi_pada BETWEEN :dari AND :sampai AND t.jenis = 'pemasukan'
THEN t.jumlah ELSE 0 END
), 0) AS pemasukan,
COALESCE(SUM(
CASE WHEN t.terjadi_pada BETWEEN :dari AND :sampai AND t.jenis = 'pengeluaran'
THEN t.jumlah ELSE 0 END
), 0) AS pengeluaran
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.id
GROUP BY a.id
ORDER BY a.nama";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['dari' => $dari, 'sampai' => $sampai]);
$akun = [];
$totalPemasukan = 0;
$totalPengeluaran = 0;
$totalSaldoAwal = 0;
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $baris) {
$pemasukan = (int) $baris['pemasukan'];
$pengeluaran = (int) $baris['pengeluaran'];
$saldoAwal = (int) $baris['saldo_awal'];
$akun[] = [
'id' => (int) $baris['id'],
'nama' => $baris['nama'],
'jenis' => $baris['jenis'],
'saldo_awal' => $saldoAwal,
'pemasukan' => $pemasukan,
'pengeluaran' => $pengeluaran,
'saldo_akhir' => $saldoAwal + $pemasukan - $pengeluaran,
];
$totalPemasukan += $pemasukan;
$totalPengeluaran += $pengeluaran;
$totalSaldoAwal += $saldoAwal;
}
return [
'bulan' => $bulan,
'akun' => $akun,
'total_saldo_awal' => $totalSaldoAwal,
'total_pemasukan' => $totalPemasukan,
'total_pengeluaran' => $totalPengeluaran,
'selisih' => $totalPemasukan - $totalPengeluaran,
'total_saldo_akhir' => $totalSaldoAwal + $totalPemasukan - $totalPengeluaran,
];
}
/**
* Arus kas beberapa bulan berakhir di $bulanAkhir, per bulan.
*
* Daftar bulan dibangun di PHP lewat DateTimeImmutable sehingga
* bulan tanpa transaksi tetap muncul dengan angka nol; hasil
* query GROUP BY hanya menimpa bulan yang memang berisi. Nilai
* net = pemasukan - pengeluaran, negatif berarti bulan defisit.
* Urutan hasil dari bulan tertua ke $bulanAkhir.
*
* @param string $bulanAkhir bulan terakhir jendela, format 'YYYY-MM'
* @param int $jumlahBulan panjang jendela, minimal 1
* @return list<array<string, int|string>> tiap baris: bulan, pemasukan, pengeluaran, net
*/
public function arusKas(string $bulanAkhir, int $jumlahBulan = 6): array
{
$this->pastikanBulanValid($bulanAkhir);
if ($jumlahBulan < 1) {
throw new InvalidArgumentException("Jumlah bulan harus minimal 1, diterima: $jumlahBulan");
}
$akhir = new DateTimeImmutable($bulanAkhir . '-01');
$bulanDaftar = [];
for ($i = $jumlahBulan - 1; $i >= 0; $i--) {
$bulanDaftar[] = $akhir->modify("-$i months")->format('Y-m');
}
$dari = $bulanDaftar[0] . '-01 00:00:00';
$sampai = $akhir->modify('last day of this month')->format('Y-m-d') . ' 23:59:59';
$sql = "SELECT strftime('%Y-%m', terjadi_pada) AS periode, jenis, SUM(jumlah) AS total
FROM transactions
WHERE terjadi_pada BETWEEN ? AND ?
GROUP BY strftime('%Y-%m', terjadi_pada), jenis
ORDER BY periode";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$dari, $sampai]);
$perBulan = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $baris) {
$perBulan[$baris['periode']][$baris['jenis']] = (int) $baris['total'];
}
$hasil = [];
foreach ($bulanDaftar as $bulan) {
$pemasukan = $perBulan[$bulan]['pemasukan'] ?? 0;
$pengeluaran = $perBulan[$bulan]['pengeluaran'] ?? 0;
$hasil[] = [
'bulan' => $bulan,
'pemasukan' => $pemasukan,
'pengeluaran' => $pengeluaran,
'net' => $pemasukan - $pengeluaran,
];
}
return $hasil;
}
/**
* Rincian pengeluaran per kategori pada satu bulan, terbesar dulu.
*
* Kolom persen menyimpan basis point: satu persen = 100 bp,
* jadi 5981 berarti 59,81%. Perhitungan memakai intdiv sehingga
* hasil selalu integer; jumlah persentase seluruh kategori bisa
* sedikit di bawah 100% karena pembulatan ke bawah — tampilkan
* dengan formatPersen().
*
* @param string $bulan bulan target format 'YYYY-MM'
* @return list<array<string, int|string>> tiap baris: id, nama, jumlah_transaksi, total, persen
*/
public function pengeluaranPerKategori(string $bulan): array
{
$this->pastikanBulanValid($bulan);
[$dari, $sampai] = $this->rentangBulan($bulan);
$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 = \'pengeluaran\' AND t.terjadi_pada BETWEEN ? AND ?
GROUP BY c.id
ORDER BY total DESC, c.nama';
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$dari, $sampai]);
$baris = $stmt->fetchAll(PDO::FETCH_ASSOC);
$totalKeseluruhan = 0;
foreach ($baris as $b) {
$totalKeseluruhan += (int) $b['total'];
}
$hasil = [];
foreach ($baris as $b) {
$hasil[] = [
'id' => (int) $b['id'],
'nama' => $b['nama'],
'jumlah_transaksi' => (int) $b['jumlah_transaksi'],
'total' => (int) $b['total'],
'persen' => intdiv((int) $b['total'] * 10000, $totalKeseluruhan),
];
}
return $hasil;
}
/**
* Laba rugi sederhana satu bulan: total pemasukan, total
* pengeluaran, selisihnya, dan margin.
*
* Margin = laba / pemasukan dalam basis point (integer). Bila
* bulan tanpa pemasukan, margin bernilai null — membagi dengan
* nol bukanlah jawaban.
*
* @param string $bulan bulan target format 'YYYY-MM'
* @return array{bulan: string, pemasukan: int, pengeluaran: int, laba: int, margin: ?int}
*/
public function labaRugi(string $bulan): array
{
$this->pastikanBulanValid($bulan);
[$dari, $sampai] = $this->rentangBulan($bulan);
$stmt = $this->pdo->prepare(
'SELECT jenis, SUM(jumlah) AS total
FROM transactions
WHERE terjadi_pada BETWEEN ? AND ?
GROUP BY jenis'
);
$stmt->execute([$dari, $sampai]);
$perJenis = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $baris) {
$perJenis[$baris['jenis']] = (int) $baris['total'];
}
$pemasukan = $perJenis['pemasukan'] ?? 0;
$pengeluaran = $perJenis['pengeluaran'] ?? 0;
$laba = $pemasukan - $pengeluaran;
return [
'bulan' => $bulan,
'pemasukan' => $pemasukan,
'pengeluaran' => $pengeluaran,
'laba' => $laba,
'margin' => $pemasukan > 0 ? intdiv($laba * 10000, $pemasukan) : null,
];
}
/**
* Grafik batang horizontal pengeluaran per kategori, sebagai teks.
*
* Panjang batang kategori terbesar selalu $lebarMaks karakter;
* kategori lain diskalakan proporsional dengan intdiv dan tidak
* pernah nol karakter selama nilainya positif. Butuh
* formatRupiah() dari uang.php Bagian 2.
*
* @param string $bulan bulan target format 'YYYY-MM'
* @param int $lebarMaks panjang batang terpanjang dalam karakter
*/
public function grafikPengeluaran(string $bulan, int $lebarMaks = 30): string
{
if ($lebarMaks < 1) {
throw new InvalidArgumentException("Lebar batang harus minimal 1, diterima: $lebarMaks");
}
$baris = $this->pengeluaranPerKategori($bulan);
if ($baris === []) {
return "(tidak ada pengeluaran pada $bulan)\n";
}
$terbesar = $baris[0]['total'];
$out = '';
foreach ($baris as $b) {
$panjang = max(1, intdiv($b['total'] * $lebarMaks, $terbesar));
$out .= sprintf(
"%-14s |%s %7s %s\n",
$b['nama'],
str_repeat('█', $panjang),
self::formatPersen($b['persen']),
formatRupiah($b['total'])
);
}
return $out;
}
/**
* Sparkline arus kas: satu karakter blok per bulan.
*
* Nilai net tiap bulan dinormalkan ke delapan tingkat blok
* Unicode. Bila seluruh bulan bernilai sama, semua bulan
* digambar '▄' di tengah — datar, bukan kosong. String keluaran
* panjangnya selalu $jumlahBulan karakter.
*
* @param string $bulanAkhir bulan terakhir jendela, format 'YYYY-MM'
* @param int $jumlahBulan panjang jendela, minimal 1
*/
public function sparklineArusKas(string $bulanAkhir, int $jumlahBulan = 6): string
{
$arus = $this->arusKas($bulanAkhir, $jumlahBulan);
$nilai = array_map(static fn (array $b): int => $b['net'], $arus);
$terkecil = min($nilai);
$terbesar = max($nilai);
$out = '';
foreach ($nilai as $net) {
if ($terbesar === $terkecil) {
$out .= '▄';
continue;
}
$out .= self::BLOK_SPARKLINE[intdiv(($net - $terkecil) * 7, $terbesar - $terkecil)];
}
return $out;
}
/**
* Format basis point sebagai persen gaya Indonesia, 5981 -> "59,81%".
*/
public static function formatPersen(int $persenBp): string
{
return sprintf('%d,%02d%%', intdiv($persenBp, 100), $persenBp % 100);
}
/**
* Tolak format bulan selain 'YYYY-MM'.
*/
private function pastikanBulanValid(string $bulan): void
{
if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $bulan)) {
throw new InvalidArgumentException(
"Format bulan tidak valid: '$bulan' (harus 'YYYY-MM', contoh '2026-01')"
);
}
}
/**
* Ubah 'YYYY-MM' menjadi pasangan batas waktu 'Y-m-d H:i:s'.
*
* @return array{0: string, 1: string} batas awal dan akhir bulan (inklusif)
*/
private function rentangBulan(string $bulan): array
{
$awal = new DateTimeImmutable($bulan . '-01');
$akhir = $awal->modify('last day of this month')->setTime(23, 59, 59);
return [$awal->format('Y-m-d H:i:s'), $akhir->format('Y-m-d H:i:s')];
}
}