Aplikasi yang menyimpan data hanya untuk dirinya sendiri adalah istana tertutup: begitu pengguna ingin memindahkan catatannya ke spreadsheet, mengarsipkan cadangan, atau berpindah aplikasi, temboknya jadi penjara. Bagian ini membangun pintu dua arah pada proyek keuangan kita. eksporCsv() menerjemahkan tabel transactions menjadi teks CSV yang ditulis ke stream memori php://temp — tidak ada berkas sementara yang tertinggal di disk — dan eksporJson() menyajikan format yang mudah dikonsumsi program lain. Arah sebaliknya lebih menuntut: imporCsv() harus memvalidasi setiap baris, menolak yang janggal dengan alasan yang bisa dibaca manusia, melewati yang sudah ada, dan melaporkan ketiganya dalam hitungan yang bisa diverifikasi. Seluruh alur diuji dengan round-trip penuh: ekspor, hapus data, impor, lalu buktikan saldo kembali ke angka yang sama persis.
CSV, Format Pertukaran yang Paling Sederhana #
CSV hidup lebih lama dari hampir semua teknologi dalam kursus ini karena aturannya sederhana: satu baris adalah satu rekaman, field dipisah koma, dan field yang memuat koma atau tanda kutip dibungkus tanda kutip ganda dengan tanda kutip di dalamnya ditulis ganda — sabun, sampo menjadi "sabun, sampo" dan "promo" menjadi """promo""". Aturan pembungkusan ini dibakukan dalam RFC 4180, dan PHP menyediakan pembaca-penulisnya sejak lama: fputcsv() untuk menulis satu baris dari array, str_getcsv() untuk membaca satu baris menjadi array.
Ada satu perubahan PHP 8.4 yang wajib Anda ketahui sebelum menulis satu baris kode CSV pun:
Satu kebiasaan fputcsv() yang akan Anda lihat pada output: field yang memuat spasi ikut dibungkus tanda kutip — "Dompet Kas" berkutip sementara Gaji telanjang. Pembungkusan berlebih seperti ini tidak melanggar apa pun; pembaca CSV yang benar mengabaikan kutip yang tidak diperlukan, dan round-trip kita membuktikannya.
Class ImportExportService #
Service ini berdiri di atas TransactionRepository untuk membaca dan menyisipkan, plus PDO untuk dua pencarian kecil. Berikut isi lengkap src/ImportExportService.php:
<?php
declare(strict_types=1);
/**
* Jembatan data masuk-keluar untuk Aplikasi Keuangan Pribadi.
*
* Ekspor menerjemahkan tabel transactions menjadi CSV (ditulis ke
* stream memori php://temp lewat fputcsv) dan JSON; impor membaca
* CSV kembali dengan validasi per baris dan laporan terperinci.
*
* Aturan penting impor:
* - Satu baris CSV = satu transaksi; field ber-kutip boleh memuat
* koma, tetapi field yang memuat baris baru tidak didukung.
* - Kolom id dari berkas TIDAK dipakai sebagai kunci; baris dianggap
* duplikat bila isinya identik dengan transaksi yang sudah ada
* (akun, kategori, jenis, jumlah, keterangan, terjadi_pada).
* - Baris yang gagal validasi ditolak satu per satu; baris lain
* tetap diproses. Seluruh keputusan dilaporkan lewat array hasil.
*/
final class ImportExportService
{
/** Baris pertama berkas CSV wajib persis seperti ini. */
public const HEADER_CSV = ['id', 'terjadi_pada', 'akun', 'kategori', 'jenis', 'jumlah', 'keterangan'];
public function __construct(
private PDO $pdo,
private TransactionRepository $transactions,
) {
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Ekspor transaksi satu rentang waktu menjadi teks CSV.
*
* Baris ditulis ke stream memori php://temp — tidak ada berkas
* sementara di disk yang lupa dihapus. Parameter $escape dikirim
* eksplisit string kosong: sejak PHP 8.4 memanggil fputcsv tanpa
* parameter tersebut memicu Deprecated karena nilai defaultnya
* akan berubah; string kosong berarti mekanisme escape warisan
* dimatikan sepenuhnya.
*
* @param string $dari batas awal 'Y-m-d H:i:s' (inklusif)
* @param string $sampai batas akhir 'Y-m-d H:i:s' (inklusif)
*/
public function eksporCsv(string $dari, string $sampai): string
{
$baris = $this->transactions->findByPeriod($dari, $sampai);
$keluaran = fopen('php://temp', 'w+');
if ($keluaran === false) {
throw new RuntimeException('Gagal membuka stream php://temp');
}
try {
fputcsv($keluaran, self::HEADER_CSV, ',', '"', '');
foreach ($baris as $t) {
fputcsv(
$keluaran,
[$t['id'], $t['terjadi_pada'], $t['akun'], $t['kategori'],
$t['jenis'], $t['jumlah'], $t['keterangan']],
',', '"', ''
);
}
rewind($keluaran);
return (string) stream_get_contents($keluaran);
} finally {
fclose($keluaran);
}
}
/**
* Ekspor transaksi satu rentang waktu menjadi JSON cantik.
*
* Nilai jumlah dibiarkan integer sen agar konsumen JSON tidak
* kehilangan presisi; JSON_UNESCAPED_UNICODE menjaga keterangan
* berbahasa Indonesia tetap terbaca manusia.
*
* @param string $dari batas awal 'Y-m-d H:i:s' (inklusif)
* @param string $sampai batas akhir 'Y-m-d H:i:s' (inklusif)
*/
public function eksporJson(string $dari, string $sampai): string
{
$baris = $this->transactions->findByPeriod($dari, $sampai);
$data = [
'dari' => $dari,
'sampai' => $sampai,
'jumlah' => count($baris),
'transaksi' => array_map(
static fn (array $t): array => [
'id' => (int) $t['id'],
'terjadi_pada' => $t['terjadi_pada'],
'akun' => $t['akun'],
'kategori' => $t['kategori'],
'jenis' => $t['jenis'],
'jumlah' => (int) $t['jumlah'],
'keterangan' => $t['keterangan'],
],
$baris
),
];
return (string) json_encode(
$data,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
}
/**
* Impor teks CSV, kembalikan laporan per hitungan dan alasan.
*
* Setiap baris data divalidasi berurutan; yang lolos disisipkan
* sebagai transaksi manual, yang identik dengan transaksi
* tersedia dilewati (skipped), dan yang melanggar aturan
* ditolak (rejected) disertai nomor baris dan alasannya.
* Kategori diterjemahkan lewat $kategoriResolver — callable
* yang menerima (nama kategori, jenis transaksi) dan
* mengembalikan id kategori, atau null bila tidak ada yang cocok.
* $dicatatPada adalah jam pencatatan yang dibekukan dan menjadi
* nilai kolom dibuat_pada seluruh baris yang diimpor.
*
* @param callable(string $nama, string $jenis): ?int $kategoriResolver
* @param DateTimeImmutable $dicatatPada jam pencatatan beku
* @return array{imported: int, skipped: int, rejected: int, pesan: list<string>}
*/
public function imporCsv(string $csv, callable $kategoriResolver, DateTimeImmutable $dicatatPada): array
{
$barisCsv = preg_split('/\r\n|\r|\n/', trim($csv));
if ($barisCsv === false || $barisCsv === ['']) {
throw new InvalidArgumentException('Berkas CSV kosong');
}
$header = str_getcsv((string) $barisCsv[0], ',', '"', '');
if ($header !== self::HEADER_CSV) {
throw new InvalidArgumentException(
'Header CSV tidak dikenali: [' . implode(', ', $header)
. '] (harus [' . implode(', ', self::HEADER_CSV) . '])'
);
}
$laporan = ['imported' => 0, 'skipped' => 0, 'rejected' => 0, 'pesan' => []];
$cariAkun = $this->pdo->prepare('SELECT id FROM accounts WHERE nama = ?');
$cariDuplikat = $this->pdo->prepare(
'SELECT id FROM transactions
WHERE account_id = ? AND category_id = ? AND jenis = ?
AND jumlah = ? AND keterangan = ? AND terjadi_pada = ?'
);
$dibuatPada = $dicatatPada->format('Y-m-d H:i:s');
foreach (array_slice($barisCsv, 1) as $indeks => $isiBaris) {
$nomor = $indeks + 2; // baris 1 adalah header
if (trim($isiBaris) === '') {
continue; // baris kosong diabaikan, bukan galat
}
$tolak = static function (string $alasan) use (&$laporan, $nomor): void {
$laporan['rejected']++;
$laporan['pesan'][] = "baris $nomor ditolak: $alasan";
};
$kolom = str_getcsv($isiBaris, ',', '"', '');
if (count($kolom) !== 7) {
$tolak('jumlah kolom ' . count($kolom) . ', harus 7 — koma di dalam teks wajib dikutip');
continue;
}
[$idLuar, $terjadiPada, $namaAkun, $namaKategori, $jenis, $jumlah, $keterangan] = $kolom;
$keterangan = trim($keterangan);
if (!ctype_digit((string) $idLuar)) {
$tolak("kolom id bukan integer: '$idLuar'");
continue;
}
if (!in_array($jenis, ['pemasukan', 'pengeluaran'], true)) {
$tolak("jenis tidak dikenal: '$jenis'");
continue;
}
if (!ctype_digit($jumlah) || (int) $jumlah <= 0) {
$tolak("jumlah harus integer sen lebih besar dari nol, diterima: '$jumlah'");
continue;
}
$waktu = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $terjadiPada);
if ($waktu === false || $waktu->format('Y-m-d H:i:s') !== $terjadiPada) {
$tolak("format terjadi_pada tidak valid: '$terjadiPada'");
continue;
}
if ($keterangan === '') {
$tolak('keterangan tidak boleh kosong');
continue;
}
$cariAkun->execute([$namaAkun]);
$idAkun = $cariAkun->fetchColumn();
if ($idAkun === false) {
$tolak("akun tidak dikenal: '$namaAkun'");
continue;
}
$idKategori = $kategoriResolver($namaKategori, $jenis);
if ($idKategori === null) {
$tolak("kategori '$namaKategori' tidak ditemukan untuk transaksi $jenis");
continue;
}
$cariDuplikat->execute([$idAkun, $idKategori, $jenis, (int) $jumlah, $keterangan, $terjadiPada]);
$idDuplikat = $cariDuplikat->fetchColumn();
if ($idDuplikat !== false) {
$laporan['skipped']++;
$laporan['pesan'][] = "baris $nomor dilewati: identik dengan transaksi #$idDuplikat yang sudah ada";
continue;
}
$this->transactions->simpan(
(int) $idAkun,
(int) $idKategori,
$jenis,
(int) $jumlah,
$keterangan,
$terjadiPada,
$dibuatPada
);
$laporan['imported']++;
}
return $laporan;
}
}Tujuh hal pada class tersebut perlu dibedah:
eksporCsv()menulis kephp://temp, bukan berkas. Stream ini hidup di memori (tumpah ke berkas sementara hanya bila datanya membesar) dan lenyap saat ditutup —rewind()memutar kepala-baca ke awal,stream_get_contents()menelan seluruh isinya menjadi satu string, dan blokfinallymenutup stream apa pun yang terjadi. Tidak adaunlink()yang bisa lupa dipanggil.- Baris data diekspor apa adanya dari
findByPeriod(). Urutannya mengikutiterjadi_pada, id— perhatikan pada walkthrough bahwa transaksi #10 (20 Januari) tampil sebelum #9 (31 Januari); cadangan yang diurut menurut waktu kejadian lebih mudah dibaca manusia daripada urutan id. imporCsv()menomori baris seperti manusia menghitungnya. Baris pertama berkas adalah header, sehingga baris data pertama adalah baris ke-2 —$indeks + 2. Semua pesan penolakan menyebut nomor ini, jadi pengguna bisa membuka berkasnya dan melompat langsung ke baris yang bermasalah.- Validasi berjalan dari yang termurah ke yang termahal. Pemeriksaan format murni (jumlah kolom, id, jenis, jumlah, tanggal, keterangan) menolak baris rusak sebelum satu pun query database dijalankan; baru setelah itu akun dicari lewat prepared statement, kategori diterjemahkan lewat resolver, dan duplikat diperiksa paling akhir.
- Tanggal divalidasi lewat round-trip.
DateTimeImmutable::createFromFormat('Y-m-d H:i:s', ...)menerima2026-02-30tanpa protes — ia menggulungnya menjadi 2 Maret. Triknya adalah memformat kembali hasilnya dan membandingkan dengan masukan:2026-03-02 08:00:00 !== 2026-02-30 08:00:00berarti tanggal asli mustahil, dan baris ditolak. - Kategori diterjemahkan lewat callable yang disuntikkan. Service impor tidak tahu kategori disimpan di tabel
categories— ia hanya tahu kontrak resolver: terima(nama, jenis), kembalikan id ataunull. Walkthrough memberi closure yang membungkusCategoryRepository; aplikasi lain bisa memberi resolver yang membaca berkas konfigurasi. Injeksi kebijakan seperti ini membuat service bisa diuji tanpa menyentuh database kategori sama sekali. - Jam pencatatan diimpor dalam keadaan beku.
$dicatatPadamenjadidibuat_padaseluruh baris yang lolos — satu nilai untuk satu eksekusi impor, ditentukan pemanggil, tidak pernah dibaca dari jam sistem. Determinisme Bagian 3 dan 4 berlanjut ke sini.
Walkthrough Ekspor ke CSV dan JSON #
Alur pertama mengekspor seluruh Januari. Satu transaksi tambahan sengaja memuat koma dan tanda kutip di keterangannya agar aturan pembungkusan CSV terlihat:
<?php
require __DIR__ . '/src/ImportExportService.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'));
$service = new TransactionService(
new AccountRepository($pdo),
new CategoryRepository($pdo),
new TransactionRepository($pdo),
);
$io = new ImportExportService($pdo, new TransactionRepository($pdo));
$service->catat(
1, 7, 'pengeluaran', 5000000, 'Belanja: sabun, sampo, "promo"',
new DateTimeImmutable('2026-01-20 16:00:00', new DateTimeZone('Asia/Jakarta')),
new DateTimeImmutable('2026-01-20 16:05:00', new DateTimeZone('Asia/Jakarta')),
);
$csv = $io->eksporCsv('2026-01-01 00:00:00', '2026-01-31 23:59:59');
echo "== CSV Januari ==\n";
echo $csv;
echo 'Jumlah baris data: ', substr_count($csv, "\n") - 1, "\n";
echo "\n== JSON jendela 3-7 Januari ==\n";
echo $io->eksporJson('2026-01-03 00:00:00', '2026-01-07 23:59:59'), "\n";Output yang Dihasilkan:
== CSV Januari ==
id,terjadi_pada,akun,kategori,jenis,jumlah,keterangan
1,"2026-01-01 08:00:00","Dompet Kas","Saldo Awal",pemasukan,200000000,"Saldo awal dompet kas"
2,"2026-01-01 08:00:00","Rekening BCA","Saldo Awal",pemasukan,1250000000,"Saldo awal rekening BCA"
3,"2026-01-01 08:00:00",GoPay,"Saldo Awal",pemasukan,15000000,"Saldo awal GoPay"
4,"2026-01-02 09:00:00","Rekening BCA",Gaji,pemasukan,850000000,"Gaji bulan Januari 2026"
5,"2026-01-03 12:30:00","Dompet Kas",Makanan,pengeluaran,2550000,"Makan siang di warung Bu Siti"
6,"2026-01-05 07:45:00",GoPay,Transportasi,pengeluaran,350000,"Naik TransJakarta"
7,"2026-01-07 06:00:00","Rekening BCA",Hiburan,pengeluaran,5499000,"Langganan streaming bulanan"
8,"2026-01-10 08:20:00","Dompet Kas",Belanja,pengeluaran,12500000,"Belanja mingguan di pasar"
10,"2026-01-20 16:00:00","Dompet Kas",Belanja,pengeluaran,5000000,"Belanja: sabun, sampo, ""promo"""
9,"2026-01-31 10:00:00","Rekening BCA","Bunga Tabungan",pemasukan,1234567,"Bunga tabungan Desember"
Jumlah baris data: 10
== JSON jendela 3-7 Januari ==
{
"dari": "2026-01-03 00:00:00",
"sampai": "2026-01-07 23:59:59",
"jumlah": 3,
"transaksi": [
{
"id": 5,
"terjadi_pada": "2026-01-03 12:30:00",
"akun": "Dompet Kas",
"kategori": "Makanan",
"jenis": "pengeluaran",
"jumlah": 2550000,
"keterangan": "Makan siang di warung Bu Siti"
},
{
"id": 6,
"terjadi_pada": "2026-01-05 07:45:00",
"akun": "GoPay",
"kategori": "Transportasi",
"jenis": "pengeluaran",
"jumlah": 350000,
"keterangan": "Naik TransJakarta"
},
{
"id": 7,
"terjadi_pada": "2026-01-07 06:00:00",
"akun": "Rekening BCA",
"kategori": "Hiburan",
"jenis": "pengeluaran",
"jumlah": 5499000,
"keterangan": "Langganan streaming bulanan"
}
]
}Perhatikan baris keterangan "Belanja: sabun, sampo, ""promo""" — koma di dalam teks membuat field wajib dibungkus, dan tanda kutip aslinya ditulis ganda; str_getcsv() akan mengurai keduanya menjadi teks asli saat diimpor kembali. JSON memilih jalur lain: struktur eksplisit dengan jumlah tetap berupa integer sen — angka JSON tidak kehilangan presisi, dan JSON_UNESCAPED_UNICODE menjaga keterangan berbahasa Indonesia tetap terbaca alih-alih menjadi deretan \uXXXX.
Walkthrough Pemulihan: Round-Trip Penuh #
Alur kedua adalah ujian sesungguhnya bagi sepasang method ini: ambil cadangan, hapus seluruh data (mensimulasikan bencana), pulihkan dari cadangan, lalu pulihkan sekali lagi untuk membuktikan idempotensi. Verifikasi akhir memakai ReportService Bagian 5 — laporan yang membaca saldo total harus kembali ke angka yang sama:
<?php
require __DIR__ . '/src/ImportExportService.php';
require __DIR__ . '/../bagian-5-laporan-dan-visualisasi/src/ReportService.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'));
$service = new TransactionService(
new AccountRepository($pdo),
new CategoryRepository($pdo),
new TransactionRepository($pdo),
);
$io = new ImportExportService($pdo, new TransactionRepository($pdo));
$laporan = new ReportService($pdo);
$categories = new CategoryRepository($pdo);
$service->catat(
1, 7, 'pengeluaran', 5000000, 'Belanja: sabun, sampo, "promo"',
new DateTimeImmutable('2026-01-20 16:00:00', new DateTimeZone('Asia/Jakarta')),
new DateTimeImmutable('2026-01-20 16:05:00', new DateTimeZone('Asia/Jakarta')),
);
$resolver = function (string $nama, string $jenis) use ($categories): ?int {
foreach ($categories->byJenis($jenis) as $k) {
if ($k['nama'] === $nama) {
return (int) $k['id'];
}
}
return null;
};
$csv = $io->eksporCsv('2026-01-01 00:00:00', '2026-01-31 23:59:59');
$saldo = fn (): string => formatRupiah($laporan->ringkasanBulanan('2026-01')['total_saldo_akhir']);
$hitung = fn (): int => (int) $pdo->query('SELECT COUNT(*) FROM transactions')->fetchColumn();
echo 'Cadangan diambil: ', substr_count($csv, "\n") - 1, " baris data\n";
echo 'Sebelum bencana : ', $saldo(), ' (', $hitung(), " transaksi)\n";
$pdo->exec('DELETE FROM transactions');
echo 'Setelah bencana : ', $saldo(), ' (', $hitung(), " transaksi)\n";
$jamImpor = new DateTimeImmutable('2026-02-01 07:00:00', new DateTimeZone('Asia/Jakarta'));
$r1 = $io->imporCsv($csv, $resolver, $jamImpor);
echo "\nPemulihan pertama: imported={$r1['imported']} skipped={$r1['skipped']} rejected={$r1['rejected']}\n";
echo 'Setelah pulih : ', $saldo(), ' (', $hitung(), " transaksi)\n";
$r2 = $io->imporCsv($csv, $resolver, $jamImpor);
echo "\nPemulihan kedua : imported={$r2['imported']} skipped={$r2['skipped']} rejected={$r2['rejected']}\n";
echo "Tiga pesan pertama laporan kedua:\n";
foreach (array_slice($r2['pesan'], 0, 3) as $pesan) {
echo " - $pesan\n";
}Output yang Dihasilkan:
Cadangan diambil: 10 baris data
Sebelum bencana : Rp22.903.355,67 (10 transaksi)
Setelah bencana : Rp0,00 (0 transaksi)
Pemulihan pertama: imported=10 skipped=0 rejected=0
Setelah pulih : Rp22.903.355,67 (10 transaksi)
Pemulihan kedua : imported=0 skipped=10 rejected=0
Tiga pesan pertama laporan kedua:
- baris 2 dilewati: identik dengan transaksi #11 yang sudah ada
- baris 3 dilewati: identik dengan transaksi #12 yang sudah ada
- baris 4 dilewati: identik dengan transaksi #13 yang sudah adaBaca baris-baris kuncinya: cadangan berisi 10 baris data; saldo total sebelum bencana Rp22.903.355,67; setelah DELETE nol; pemulihan pertama mengimpor 10 baris dan saldo total kembali persis ke Rp22.903.355,67 — bukan mirip, persis, karena seluruh perhitungan berjalan di atas integer sen. Pemulihan kedua mengimpor nol dan melewati 10: setiap baris dikenali sebagai identik dengan transaksi hasil pemulihan pertama, lengkap dengan id transaksi yang ditabraknya. Inilah idempotensi level eksekusi penuh — cadangan yang sama boleh diimpor berkali-kali tanpa menggandakan data.
Walkthrough Validasi Baris dan Laporan Impor #
Alur ketiga memberi makan imporCsv() dengan berkas yang sengaja berpenyakit: satu baris sehat, satu baris berisi teks sama yang lupa dikutip, lalu tujuh variasi kesalahan, dan ditutup baris yang menduplikasi baris sehat. Heredoc <<<'CSV' menjaga teks apa adanya tanpa interpolasi:
<?php
require __DIR__ . '/src/ImportExportService.php';
require __DIR__ . '/../bagian-3-pencatatan-transaksi/src/TransactionRepository.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'));
$io = new ImportExportService($pdo, new TransactionRepository($pdo));
$categories = new CategoryRepository($pdo);
$resolver = function (string $nama, string $jenis) use ($categories): ?int {
foreach ($categories->byJenis($jenis) as $k) {
if ($k['nama'] === $nama) {
return (int) $k['id'];
}
}
return null;
};
$csvBerpenyakit = <<<'CSV'
id,terjadi_pada,akun,kategori,jenis,jumlah,keterangan
101,2026-02-02 12:30:00,Dompet Kas,Makanan,pengeluaran,2550000,"Makan siang di warung, porsi besar"
102,2026-02-03 08:00:00,Dompet Kas,Makanan,pengeluaran,2550000,Makan siang di warung, porsi besar
103,2026-02-04 09:00:00,Rekening BCA,Gaji,pengeluaran,5000000,Gaji freelance
104,2026-02-05 10:00:00,Dompet Kas,Makanan,pengeluaran,Rp25.500,Jajan sore
105,2026-02-06 11:00:00,Dompet Kas,Makanan,pengeluaran,0,Coba nol
106,2026-02-30 08:00:00,Dompet Kas,Makanan,pengeluaran,1500000,Tanggal mustahil
107,2026-02-07 08:00:00,Kantor Pos,Makanan,pengeluaran,1500000,Akun tidak ada
108,2026-02-08 08:00:00,Dompet Kas,Pupuk,pengeluaran,1500000,Kategori asing
109,2026-02-09 08:00:00,Dompet Kas,Makanan,pengeluaran,1500000,
110,2026-02-02 12:30:00,Dompet Kas,Makanan,pengeluaran,2550000,"Makan siang di warung, porsi besar"
CSV;
$jamImpor = new DateTimeImmutable('2026-02-10 09:00:00', new DateTimeZone('Asia/Jakarta'));
$r = $io->imporCsv($csvBerpenyakit, $resolver, $jamImpor);
echo "imported={$r['imported']} skipped={$r['skipped']} rejected={$r['rejected']}\n";
foreach ($r['pesan'] as $pesan) {
echo " $pesan\n";
}
echo 'Transaksi kini: ', $pdo->query('SELECT COUNT(*) FROM transactions')->fetchColumn(), "\n";Output yang Dihasilkan:
imported=1 skipped=1 rejected=8
baris 3 ditolak: jumlah kolom 8, harus 7 — koma di dalam teks wajib dikutip
baris 4 ditolak: kategori 'Gaji' tidak ditemukan untuk transaksi pengeluaran
baris 5 ditolak: jumlah harus integer sen lebih besar dari nol, diterima: 'Rp25.500'
baris 6 ditolak: jumlah harus integer sen lebih besar dari nol, diterima: '0'
baris 7 ditolak: format terjadi_pada tidak valid: '2026-02-30 08:00:00'
baris 8 ditolak: akun tidak dikenal: 'Kantor Pos'
baris 9 ditolak: kategori 'Pupuk' tidak ditemukan untuk transaksi pengeluaran
baris 10 ditolak: keterangan tidak boleh kosong
baris 11 dilewati: identik dengan transaksi #10 yang sudah ada
Transaksi kini: 10Pasangan baris 2 dan 3 adalah pelajaran CSV dalam satu contoh: keduanya memuat keterangan yang sama, tetapi baris 2 mengutip komanya sehingga terbaca 7 kolom dan lolos, sedangkan baris 3 telanjang sehingga koma membelah field dan terbaca 8 kolom — ditolak. Baris 4 menunjukkan resolver bekerja: kategori Gaji memang ada, tetapi bertipe pemasukan, sehingga resolver mengembalikan null untuk transaksi pengeluaran. Baris 5 menolak Rp25.500 karena jumlah wajib integer sen — pemisah ribuan adalah urusan tampilan, bukan penyimpanan. Baris 7 adalah tanggal mustahil yang tertangkap oleh trik round-trip. Baris 11 menduplikasi baris 2 dan dilewati dengan menyebut id transaksi hasil impor baris 2. Total: 1 diimpor, 1 dilewati, 8 ditolak, dan setiap keputusan punya alamat — nomor baris — yang bisa diperiksa pengguna.
Cara Menjalankan Contoh #
Contoh pada bagian ini membutuhkan berkas Bagian 1-3 untuk ekspor dan impor, plus ReportService Bagian 5 untuk verifikasi saldo pada walkthrough pemulihan. 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-5-laporan-dan-visualisasi/
│ └── src/
│ └── ReportService.php
└── bagian-6-import-export/
├── src/
│ └── ImportExportService.php
└── uji-io.phpSimpan salah satu walkthrough di atas sebagai uji-io.php, lalu jalankan dari dalam folder bagian-6-import-export:
php uji-io.phpImpor tidak memuat migrasi Bagian 4: kolom template_id dan bulan boleh tetap NULL karena seluruh baris hasil impor memang transaksi manual. Seperti bagian-bagian sebelumnya, seluruh contoh memakai :memory: dan tidak meninggalkan berkas.
Apa yang Berubah #
Proyek kini punya pintu keluar-masuk data:
- Berkas
src/ImportExportService.phpbaru denganeksporCsv()(fputcsv kephp://temp+rewind()+stream_get_contents()+finally fclose()),eksporJson()(pretty-print, integer sen, unicode utuh), danimporCsv()(validasi berjenjang per baris, resolver kategori ter-suntik, deteksi duplikat berbasis isi, laporanimported/skipped/rejectedplus pesan beralamat). - Konstanta
HEADER_CSVmenuntut baris pertama persis — berkas dengan struktur asing ditolak cepat dengan pesan yang menunjukkan header yang ditemukan. - Parameter
$escapeeksplisit''pada seluruh pemanggilanfputcsv()/str_getcsv()mengikuti aturan baru PHP 8.4, terhindar dariDeprecatedyang akan berubah perilaku di PHP 9. - Round-trip terverifikasi penuh: ekspor 10 baris, hapus data, impor 10 baris, saldo total kembali identik
Rp22.903.355,67; impor ulang kedua melewati seluruh 10 baris tanpa duplikasi.
Ringkasan #
Import/export menuntaskan mobilitas data: CSV untuk manusia dan spreadsheet, JSON untuk program, validasi per baris dengan alasan beralamat untuk kejelasan, dan idempotensi agar operasi yang diulang tidak pernah merusak. Pola yang dipakai di sini — stream memori, callable yang disuntikkan, jam beku, integer sen ujung ke ujung — adalah pola yang sudah kita kumpulkan satu per satu sejak Bagian 1. Tinggal satu pertanyaan yang belum dijawab proyek ini: bagaimana memastikan seluruh perilaku yang kita klaim ini tetap benar setiap kali kode berubah? Bagian 7 menjawabnya dengan PHPUnit dan pengemasan Docker.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/ImportExportService.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Jembatan data masuk-keluar untuk Aplikasi Keuangan Pribadi.
*
* Ekspor menerjemahkan tabel transactions menjadi CSV (ditulis ke
* stream memori php://temp lewat fputcsv) dan JSON; impor membaca
* CSV kembali dengan validasi per baris dan laporan terperinci.
*
* Aturan penting impor:
* - Satu baris CSV = satu transaksi; field ber-kutip boleh memuat
* koma, tetapi field yang memuat baris baru tidak didukung.
* - Kolom id dari berkas TIDAK dipakai sebagai kunci; baris dianggap
* duplikat bila isinya identik dengan transaksi yang sudah ada
* (akun, kategori, jenis, jumlah, keterangan, terjadi_pada).
* - Baris yang gagal validasi ditolak satu per satu; baris lain
* tetap diproses. Seluruh keputusan dilaporkan lewat array hasil.
*/
final class ImportExportService
{
/** Baris pertama berkas CSV wajib persis seperti ini. */
public const HEADER_CSV = ['id', 'terjadi_pada', 'akun', 'kategori', 'jenis', 'jumlah', 'keterangan'];
public function __construct(
private PDO $pdo,
private TransactionRepository $transactions,
) {
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Ekspor transaksi satu rentang waktu menjadi teks CSV.
*
* Baris ditulis ke stream memori php://temp — tidak ada berkas
* sementara di disk yang lupa dihapus. Parameter $escape dikirim
* eksplisit string kosong: sejak PHP 8.4 memanggil fputcsv tanpa
* parameter tersebut memicu Deprecated karena nilai defaultnya
* akan berubah; string kosong berarti mekanisme escape warisan
* dimatikan sepenuhnya.
*
* @param string $dari batas awal 'Y-m-d H:i:s' (inklusif)
* @param string $sampai batas akhir 'Y-m-d H:i:s' (inklusif)
*/
public function eksporCsv(string $dari, string $sampai): string
{
$baris = $this->transactions->findByPeriod($dari, $sampai);
$keluaran = fopen('php://temp', 'w+');
if ($keluaran === false) {
throw new RuntimeException('Gagal membuka stream php://temp');
}
try {
fputcsv($keluaran, self::HEADER_CSV, ',', '"', '');
foreach ($baris as $t) {
fputcsv(
$keluaran,
[$t['id'], $t['terjadi_pada'], $t['akun'], $t['kategori'],
$t['jenis'], $t['jumlah'], $t['keterangan']],
',', '"', ''
);
}
rewind($keluaran);
return (string) stream_get_contents($keluaran);
} finally {
fclose($keluaran);
}
}
/**
* Ekspor transaksi satu rentang waktu menjadi JSON cantik.
*
* Nilai jumlah dibiarkan integer sen agar konsumen JSON tidak
* kehilangan presisi; JSON_UNESCAPED_UNICODE menjaga keterangan
* berbahasa Indonesia tetap terbaca manusia.
*
* @param string $dari batas awal 'Y-m-d H:i:s' (inklusif)
* @param string $sampai batas akhir 'Y-m-d H:i:s' (inklusif)
*/
public function eksporJson(string $dari, string $sampai): string
{
$baris = $this->transactions->findByPeriod($dari, $sampai);
$data = [
'dari' => $dari,
'sampai' => $sampai,
'jumlah' => count($baris),
'transaksi' => array_map(
static fn (array $t): array => [
'id' => (int) $t['id'],
'terjadi_pada' => $t['terjadi_pada'],
'akun' => $t['akun'],
'kategori' => $t['kategori'],
'jenis' => $t['jenis'],
'jumlah' => (int) $t['jumlah'],
'keterangan' => $t['keterangan'],
],
$baris
),
];
return (string) json_encode(
$data,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
}
/**
* Impor teks CSV, kembalikan laporan per hitungan dan alasan.
*
* Setiap baris data divalidasi berurutan; yang lolos disisipkan
* sebagai transaksi manual, yang identik dengan transaksi
* tersedia dilewati (skipped), dan yang melanggar aturan
* ditolak (rejected) disertai nomor baris dan alasannya.
* Kategori diterjemahkan lewat $kategoriResolver — callable
* yang menerima (nama kategori, jenis transaksi) dan
* mengembalikan id kategori, atau null bila tidak ada yang cocok.
* $dicatatPada adalah jam pencatatan yang dibekukan dan menjadi
* nilai kolom dibuat_pada seluruh baris yang diimpor.
*
* @param callable(string $nama, string $jenis): ?int $kategoriResolver
* @param DateTimeImmutable $dicatatPada jam pencatatan beku
* @return array{imported: int, skipped: int, rejected: int, pesan: list<string>}
*/
public function imporCsv(string $csv, callable $kategoriResolver, DateTimeImmutable $dicatatPada): array
{
$barisCsv = preg_split('/\r\n|\r|\n/', trim($csv));
if ($barisCsv === false || $barisCsv === ['']) {
throw new InvalidArgumentException('Berkas CSV kosong');
}
$header = str_getcsv((string) $barisCsv[0], ',', '"', '');
if ($header !== self::HEADER_CSV) {
throw new InvalidArgumentException(
'Header CSV tidak dikenali: [' . implode(', ', $header)
. '] (harus [' . implode(', ', self::HEADER_CSV) . '])'
);
}
$laporan = ['imported' => 0, 'skipped' => 0, 'rejected' => 0, 'pesan' => []];
$cariAkun = $this->pdo->prepare('SELECT id FROM accounts WHERE nama = ?');
$cariDuplikat = $this->pdo->prepare(
'SELECT id FROM transactions
WHERE account_id = ? AND category_id = ? AND jenis = ?
AND jumlah = ? AND keterangan = ? AND terjadi_pada = ?'
);
$dibuatPada = $dicatatPada->format('Y-m-d H:i:s');
foreach (array_slice($barisCsv, 1) as $indeks => $isiBaris) {
$nomor = $indeks + 2; // baris 1 adalah header
if (trim($isiBaris) === '') {
continue; // baris kosong diabaikan, bukan galat
}
$tolak = static function (string $alasan) use (&$laporan, $nomor): void {
$laporan['rejected']++;
$laporan['pesan'][] = "baris $nomor ditolak: $alasan";
};
$kolom = str_getcsv($isiBaris, ',', '"', '');
if (count($kolom) !== 7) {
$tolak('jumlah kolom ' . count($kolom) . ', harus 7 — koma di dalam teks wajib dikutip');
continue;
}
[$idLuar, $terjadiPada, $namaAkun, $namaKategori, $jenis, $jumlah, $keterangan] = $kolom;
$keterangan = trim($keterangan);
if (!ctype_digit((string) $idLuar)) {
$tolak("kolom id bukan integer: '$idLuar'");
continue;
}
if (!in_array($jenis, ['pemasukan', 'pengeluaran'], true)) {
$tolak("jenis tidak dikenal: '$jenis'");
continue;
}
if (!ctype_digit($jumlah) || (int) $jumlah <= 0) {
$tolak("jumlah harus integer sen lebih besar dari nol, diterima: '$jumlah'");
continue;
}
$waktu = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $terjadiPada);
if ($waktu === false || $waktu->format('Y-m-d H:i:s') !== $terjadiPada) {
$tolak("format terjadi_pada tidak valid: '$terjadiPada'");
continue;
}
if ($keterangan === '') {
$tolak('keterangan tidak boleh kosong');
continue;
}
$cariAkun->execute([$namaAkun]);
$idAkun = $cariAkun->fetchColumn();
if ($idAkun === false) {
$tolak("akun tidak dikenal: '$namaAkun'");
continue;
}
$idKategori = $kategoriResolver($namaKategori, $jenis);
if ($idKategori === null) {
$tolak("kategori '$namaKategori' tidak ditemukan untuk transaksi $jenis");
continue;
}
$cariDuplikat->execute([$idAkun, $idKategori, $jenis, (int) $jumlah, $keterangan, $terjadiPada]);
$idDuplikat = $cariDuplikat->fetchColumn();
if ($idDuplikat !== false) {
$laporan['skipped']++;
$laporan['pesan'][] = "baris $nomor dilewati: identik dengan transaksi #$idDuplikat yang sudah ada";
continue;
}
$this->transactions->simpan(
(int) $idAkun,
(int) $idKategori,
$jenis,
(int) $jumlah,
$keterangan,
$terjadiPada,
$dibuatPada
);
$laporan['imported']++;
}
return $laporan;
}
}