Mencatat transaksi sudah berjalan dengan aturan penuh, tetapi dua pekerjaan keuangan masih dikerjakan manual. Pertama, kendali belanja: kita perlu tahu sebelum bulan berakhir bahwa pengeluaran Makanan sudah menembus batas. Kedua, pekerjaan rutin: gaji, cicilan, dan langganan jatuh di tanggal yang sama setiap bulan, dan melupakan satu saja membuat laporan bohong. Pada bagian ini kita menambahkan tabel budgets dan recurring_transactions lewat migrasi, lalu membangun BudgetService untuk membandingkan batas dengan realita serta RecurringTransactionService untuk membubuhkan transaksi rutin secara idempoten — dijalankan sekali atau sepuluh kali, hasilnya sama.

Migrasi Tahap Kedua #

Proyek yang tumbuh tidak menulis ulang schema dari nol; ia bermigrasi. Berkas migrasi-budget-recurring.sql menambahkan dua tabel baru dan memperluas tabel transactions:

sql
CREATE TABLE IF NOT EXISTS budgets (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    category_id INTEGER NOT NULL REFERENCES categories(id),
    bulan       TEXT    NOT NULL,
    batas       INTEGER NOT NULL CHECK (batas > 0),
    UNIQUE (category_id, bulan)
);

CREATE TABLE IF NOT EXISTS recurring_transactions (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    account_id  INTEGER NOT NULL REFERENCES accounts(id),
    category_id INTEGER NOT NULL REFERENCES categories(id),
    jenis       TEXT    NOT NULL CHECK (jenis IN ('pemasukan', 'pengeluaran')),
    jumlah      INTEGER NOT NULL CHECK (jumlah > 0),
    keterangan  TEXT    NOT NULL,
    hari_bulan  INTEGER NOT NULL CHECK (hari_bulan BETWEEN 1 AND 31),
    aktif       INTEGER NOT NULL DEFAULT 1 CHECK (aktif IN (0, 1))
);

ALTER TABLE transactions ADD COLUMN template_id INTEGER REFERENCES recurring_transactions(id);
ALTER TABLE transactions ADD COLUMN bulan TEXT;

CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_template_bulan
    ON transactions(template_id, bulan);

Lima keputusan pada migrasi tersebut perlu dibedah:

  1. budgets memakai UNIQUE (category_id, bulan): satu kategori hanya boleh punya satu budget per bulan — pasangan ini juga menjadi kunci pembaruan lewat UPSERT nanti.
  2. recurring_transactions adalah cetakan, bukan transaksi: kolomnya menggambarkan transaksi yang akan dibuat (akun, kategori, jenis, jumlah, keterangan) ditambah hari_bulan sebagai tanggal target dan aktif sebagai tombol hidup-mati.
  3. ALTER TABLE ... ADD COLUMN menambah dua kolom pada transactions tanpa memindahkan data yang ada: template_id mencatat cetakan asal sebuah transaksi, bulan menyimpan bulan target pembuatannya (format YYYY-MM). Nilai keduanya NULL untuk transaksi manual dari Bagian 3.
  4. CREATE UNIQUE INDEX ... (template_id, bulan) adalah jantung idempotensi: satu cetakan hanya boleh menghasilkan satu transaksi per bulan. INSERT kedua dengan pasangan yang sama ditolak database.
  5. NULL dianggap berbeda satu sama lain oleh index UNIQUE SQLite, sehingga ribuan transaksi manual dengan template_id = NULL tidak pernah saling bertabrakan — index hanya menahan pasangan cetakan + bulan.

Berbeda dengan schema.sql, migrasi ini tidak bisa dijalankan berulang: SQLite tidak menyediakan IF NOT EXISTS untuk ADD COLUMN, dan menjalankannya dua kali memicu galat duplicate column name. Karena itu pemuat migrasi di PHP memeriksa keberadaan kolom lebih dulu lewat pragma_table_info() yang bisa dipakai sebagai fungsi tabel:

php
<?php
function kolomSudahAda(PDO $pdo, string $tabel, string $kolom): bool
{
    $stmt = $pdo->prepare('SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?');
    $stmt->execute([$tabel, $kolom]);
    return (int) $stmt->fetchColumn() > 0;
}

$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'));

if (kolomSudahAda($pdo, 'transactions', 'template_id')) {
    echo "Kolom template_id sudah ada — migrasi dilewati\n";
} else {
    $pdo->exec(file_get_contents(__DIR__ . '/migrasi-budget-recurring.sql'));
    echo "Kolom template_id belum ada — migrasi dijalankan\n";
}

if (kolomSudahAda($pdo, 'transactions', 'template_id')) {
    echo "Kolom template_id sudah ada — migrasi dilewati\n";
} else {
    $pdo->exec(file_get_contents(__DIR__ . '/migrasi-budget-recurring.sql'));
    echo "Kolom template_id belum ada — migrasi dijalankan\n";
}

foreach ($pdo->query(
    "SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_transactions%' ORDER BY name"
) as $index) {
    echo "  index: {$index['name']}\n";
}

Output yang Dihasilkan:

text
Kolom template_id belum ada — migrasi dijalankan
Kolom template_id sudah ada — migrasi dilewati
  index: idx_transactions_account_id
  index: idx_transactions_category_id
  index: idx_transactions_template_bulan
  index: idx_transactions_terjadi_pada

Panggilan kedua melompati migrasi karena pemeriksaan kolom menemukan template_id sudah terpasang — pola sederhana yang membuat pemuat schema aman dipanggil setiap kali aplikasi boot. Daftar index di akhir memperlihatkan idx_transactions_template_bulan berdiri di samping tiga index dari Bagian 1.

Class BudgetService #

Budget membandingkan dua angka: batas yang kita tetapkan dan total transaksi yang sudah terjadi. Berikut isi lengkap src/BudgetService.php:

php
<?php

declare(strict_types=1);

/**
 * Lapisan aturan bisnis untuk budget bulanan per kategori.
 *
 * Sebuah budget membatasi total pengeluaran satu kategori pada satu
 * bulan tertentu. Batas dan pengeluaran sama-sama dinyatakan dalam
 * sen sehingga selisihnya dihitung dengan aritmetika integer yang
 * eksak. Service ini memegang PDO secara langsung karena seluruh
 * kebutuhannya hanyalah dua tabel: budgets dan transactions.
 */
final class BudgetService
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Tetapkan (atau perbarui) budget satu kategori untuk satu bulan.
     *
     * Bila pasangan (kategori, bulan) sudah memiliki budget, batas
     * lama diganti dengan yang baru lewat klausa UPSERT
     * ON CONFLICT ... DO UPDATE — tidak ada baris kedua yang dibuat.
     *
     * @param int    $categoryId id kategori pengeluaran
     * @param string $bulan      bulan target format 'YYYY-MM', mis. '2026-01'
     * @param int    $batasSen   batas belanja dalam sen
     */
    public function setBudget(int $categoryId, string $bulan, int $batasSen): void
    {
        $this->pastikanBulanValid($bulan);

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

        $stmt = $this->pdo->prepare('SELECT nama, jenis FROM categories WHERE id = ?');
        $stmt->execute([$categoryId]);
        $kategori = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($kategori === false) {
            throw new InvalidArgumentException("Kategori #$categoryId tidak ditemukan");
        }

        if ($kategori['jenis'] !== 'pengeluaran') {
            throw new InvalidArgumentException(
                "Budget hanya untuk kategori pengeluaran; kategori '{$kategori['nama']}' bertipe {$kategori['jenis']}"
            );
        }

        $stmt = $this->pdo->prepare(
            'INSERT INTO budgets (category_id, bulan, batas)
             VALUES (?, ?, ?)
             ON CONFLICT(category_id, bulan) DO UPDATE SET batas = excluded.batas'
        );
        $stmt->execute([$categoryId, $bulan, $batasSen]);
    }

    /**
     * Sisa budget satu kategori pada satu bulan, dalam sen.
     *
     * Rumusnya: batas dikurangi total transaksi kategori tersebut
     * pada bulan itu. Hasil negatif berarti pengeluaran sudah
     * melebihi batas.
     */
    public function sisa(int $categoryId, string $bulan): int
    {
        return $this->ringkas($categoryId, $bulan)['sisa'];
    }

    /**
     * Ringkasan lengkap budget: batas, terpakai, sisa, dan status lewat.
     *
     * Karena kategori budget selalu bertipe pengeluaran (ditegakkan
     * setBudget), seluruh transaksi kategori tersebut pasti arah
     * keluar sehingga totalnya cukup di-SUM tanpa filter jenis.
     *
     * @return array{batas: int, terpakai: int, sisa: int, lewat: bool}
     */
    public function ringkas(int $categoryId, string $bulan): array
    {
        $this->pastikanBulanValid($bulan);

        $stmt = $this->pdo->prepare('SELECT batas FROM budgets WHERE category_id = ? AND bulan = ?');
        $stmt->execute([$categoryId, $bulan]);
        $batas = $stmt->fetchColumn();

        if ($batas === false) {
            throw new InvalidArgumentException(
                "Budget kategori #$categoryId untuk bulan $bulan belum ditetapkan — panggil setBudget() dahulu"
            );
        }

        [$dari, $sampai] = $this->rentangBulan($bulan);

        $stmt = $this->pdo->prepare(
            'SELECT COALESCE(SUM(jumlah), 0)
               FROM transactions
              WHERE category_id = ? AND terjadi_pada BETWEEN ? AND ?'
        );
        $stmt->execute([$categoryId, $dari, $sampai]);
        $terpakai = (int) $stmt->fetchColumn();

        return [
            'batas' => (int) $batas,
            'terpakai' => $terpakai,
            'sisa' => (int) $batas - $terpakai,
            'lewat' => $terpakai > (int) $batas,
        ];
    }

    /**
     * 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')];
    }
}

Empat hal pada class tersebut perlu dibedah:

  1. ON CONFLICT(category_id, bulan) DO UPDATE adalah UPSERT SQLite: INSERT yang menabrak UNIQUE berubah menjadi UPDATE pada baris yang sama. Menetapkan budget kedua kali untuk pasangan kategori + bulan yang sama memperbarui batas, bukan menduplikasi baris.
  2. rentangBulan() menerjemahkan '2026-02' menjadi ['2026-02-01 00:00:00', '2026-02-28 23:59:59'] — modify('last day of this month') menghitung tanggal terakhir termasuk tahun kabisat, sehingga Februari 2028 otomatis menjadi hari 29. Dengan begitu, perbandingan BETWEEN memakai rentang penuh tanpa hardcode 31.
  3. ringkas() mengembalikan empat nilai sekaligus: batas, terpakai, sisa, dan status lewat. Method sisa() hanyalah pintasan ke satu elemennya — pemanggil tampilan cukup satu query untuk semua angka.
  4. Validasi bulan dengan regex terjangkar ^\d{4}-(0[1-9]|1[0-2])$ menolak '2026-13', 'Januari', dan '2026-1' sebelum sempat menjadi query aneh. Karena kategori budget selalu pengeluaran, SUM(jumlah) tidak perlu memfilter jenis.

Walkthrough Budget: Batas, Terpakai, Sisa #

Alur pertama menetapkan budget Makanan Januari Rp500.000,00 di atas data awal, lalu membiarkan satu pengeluaran besar menembusnya:

php
<?php
require __DIR__ . '/src/BudgetService.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__ . '/migrasi-budget-recurring.sql'));

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

$budget->setBudget(4, '2026-01', 50000000);

$r = $budget->ringkas(4, '2026-01');
echo "Budget Makanan Januari 2026:\n";
echo "  batas   : ", formatRupiah($r['batas']), "\n";
echo "  terpakai: ", formatRupiah($r['terpakai']), "\n";
echo "  sisa    : ", formatRupiah($r['sisa']), "\n";
echo "  status  : ", $r['lewat'] ? 'LEWAT BUDGET' : 'masih dalam budget', "\n";

$service->catat(
    1, 4, 'pengeluaran', 48000000, 'Makan di restoran hotel',
    new DateTimeImmutable('2026-01-25 20:00:00', new DateTimeZone('Asia/Jakarta')),
    new DateTimeImmutable('2026-01-25 20:05:00', new DateTimeZone('Asia/Jakarta')),
);
echo "\nSetelah makan di restoran hotel:\n";
$r = $budget->ringkas(4, '2026-01');
echo "  terpakai: ", formatRupiah($r['terpakai']), "\n";
echo "  sisa    : ", formatRupiah($r['sisa']), "\n";
echo "  status  : ", $r['lewat'] ? 'LEWAT BUDGET' : 'masih dalam budget', "\n";

$budget->setBudget(4, '2026-01', 60000000);
echo "\nBatas dinaikkan menjadi ", formatRupiah(60000000), ":\n";
$r = $budget->ringkas(4, '2026-01');
echo "  sisa    : ", formatRupiah($r['sisa']), "\n";
echo "  status  : ", $r['lewat'] ? 'LEWAT BUDGET' : 'masih dalam budget', "\n";
echo "  baris budget: ", $pdo->query('SELECT COUNT(*) FROM budgets')->fetchColumn(), "\n";

$budget->setBudget(4, '2026-02', 50000000);
$r = $budget->ringkas(4, '2026-02');
echo "\nBudget Makanan Februari (belum ada transaksi):\n";
echo "  terpakai: ", formatRupiah($r['terpakai']), "\n";
echo "  sisa    : ", formatRupiah($r['sisa']), "\n";

echo "\nValidasi yang ditolak:\n";
foreach ([
    ['budget untuk kategori pemasukan', fn () => $budget->setBudget(2, '2026-01', 1000000)],
    ['bulan format salah', fn () => $budget->setBudget(4, 'Januari', 1000000)],
    ['sisa tanpa budget', fn () => $budget->sisa(6, '2026-01')],
] as [$label, $coba]) {
    try {
        $coba();
        echo "  $label: lolos (tidak seharusnya)\n";
    } catch (InvalidArgumentException $e) {
        echo "  $label => {$e->getMessage()}\n";
    }
}

Output yang Dihasilkan:

text
Budget Makanan Januari 2026:
  batas   : Rp500.000,00
  terpakai: Rp25.500,00
  sisa    : Rp474.500,00
  status  : masih dalam budget

Setelah makan di restoran hotel:
  terpakai: Rp505.500,00
  sisa    : -Rp5.500,00
  status  : LEWAT BUDGET

Batas dinaikkan menjadi Rp600.000,00:
  sisa    : Rp94.500,00
  status  : masih dalam budget
  baris budget: 1

Budget Makanan Februari (belum ada transaksi):
  terpakai: Rp0,00
  sisa    : Rp500.000,00

Validasi yang ditolak:
  budget untuk kategori pemasukan => Budget hanya untuk kategori pengeluaran; kategori 'Gaji' bertipe pemasukan
  bulan format salah => Format bulan tidak valid: 'Januari' (harus 'YYYY-MM', contoh '2026-01')
  sisa tanpa budget => Budget kategori #6 untuk bulan 2026-01 belum ditetapkan — panggil setBudget() dahulu

Empat babak pada output tersebut masing-masing mengajarkan satu hal. Awalnya budget Rp500.000,00 terpakai Rp25.500,00 dari transaksi warung Bagian 1, sisa Rp474.500,00. Satu makan di restoran hotel Rp480.000,00 membalik sisa menjadi -Rp5.500,00 dan status LEWAT BUDGET — angka minus diformat rapi oleh formatRupiah(). setBudget() kedua dengan batas Rp600.000,00 memperbarui baris yang sama, terbukti dari jumlah baris budget yang tetap satu. Terakhir, tiga validasi menolak kategori pemasukan, format bulan yang salah, dan permintaan sisa tanpa budget.

Class RecurringTransactionService #

Cetakan transaksi rutin punya satu tugas sulit: proses pembubuhan bisa saja terpanggil dua kali — jadwal cron yang jalan ulang, skrip yang dieksekusi ulang, atau pengguna yang mengklik dua kali. Desain idempoten membuat semua itu aman. Berikut isi lengkap src/RecurringTransactionService.php:

php
<?php

declare(strict_types=1);

/**
 * Lapisan aturan bisnis untuk transaksi rutin (recurring).
 *
 * Transaksi rutin seperti gaji bulanan, cicilan, dan langganan
 * streaming tidak seharusnya dicatat manual setiap bulan. Service
 * ini menyimpan "cetakan" transaksi (define) lalu membubuhkannya
 * ke satu bulan tertentu (generateFor). Pencantuman bersifat
 * idempoten: index UNIQUE (template_id, bulan) pada tabel
 * transactions menolak pembubuhan kedua untuk pasangan cetakan +
 * bulan yang sama, sehingga proses yang diulang tidak pernah
 * menduplikasi data.
 */
final class RecurringTransactionService
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Definisikan cetakan transaksi rutin, kembalikan id cetakan.
     *
     * Arah transaksi (jenis) tidak diterima dari pemanggil, melainkan
     * diambil dari kategori: cetakan untuk kategori Gaji otomatis
     * menjadi pemasukan, cetakan untuk Cicilan otomatis menjadi
     * pengeluaran. Dengan begitu cetakan tidak pernah bertentangan
     * dengan aturan pencatatan di TransactionService::catat().
     *
     * @param int    $accountId   akun tempat transaksi rutin dicatat
     * @param int    $categoryId  kategori pengelompokan
     * @param int    $jumlahSen   nilai transaksi rutin dalam sen
     * @param string $keterangan  deskripsi yang dibubuhkan setiap bulan
     * @param int    $hariBulan   tanggal target dalam sebulan (1-31)
     */
    public function define(
        int $accountId,
        int $categoryId,
        int $jumlahSen,
        string $keterangan,
        int $hariBulan,
    ): int {
        if ($jumlahSen <= 0) {
            throw new InvalidArgumentException("Jumlah harus lebih besar dari nol sen, diterima: $jumlahSen");
        }

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

        if ($hariBulan < 1 || $hariBulan > 31) {
            throw new InvalidArgumentException("Hari dalam bulan harus 1-31, diterima: $hariBulan");
        }

        $stmt = $this->pdo->prepare('SELECT id FROM accounts WHERE id = ?');
        $stmt->execute([$accountId]);

        if ($stmt->fetchColumn() === false) {
            throw new InvalidArgumentException("Akun #$accountId tidak ditemukan");
        }

        $stmt = $this->pdo->prepare('SELECT nama, jenis FROM categories WHERE id = ?');
        $stmt->execute([$categoryId]);
        $kategori = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($kategori === false) {
            throw new InvalidArgumentException("Kategori #$categoryId tidak ditemukan");
        }

        $stmt = $this->pdo->prepare(
            'INSERT INTO recurring_transactions
                 (account_id, category_id, jenis, jumlah, keterangan, hari_bulan)
             VALUES (?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $accountId,
            $categoryId,
            $kategori['jenis'],
            $jumlahSen,
            trim($keterangan),
            $hariBulan,
        ]);

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

    /**
     * Bubuhkan seluruh cetakan aktif ke satu bulan, kembalikan
     * jumlah transaksi yang benar-benar dibuat.
     *
     * $jamJalan adalah jam eksekusi yang dibekukan (frozen clock)
     * dan menjadi nilai kolom dibuat_pada; waktu kejadian setiap
     * transaksi memakai hari target cetakan pada jam 08:00:00.
     * Cetakan dengan hari target melebihi jumlah hari bulan
     * tersebut digeser ke hari terakhir — hari 31 pada Februari
     * menjadi 28 atau 29 sesuai tahunnya.
     *
     * Pemanggilan kedua untuk bulan yang sama mengembalikan 0:
     * setiap INSERT duplikat ditolak index UNIQUE (template_id,
     * bulan) lalu dilewati, bukan dianggap galat.
     *
     * @param string           $bulan    bulan target format 'YYYY-MM'
     * @param DateTimeImmutable $jamJalan jam pencatatan yang dibekukan
     */
    public function generateFor(string $bulan, DateTimeImmutable $jamJalan): int
    {
        $this->pastikanBulanValid($bulan);

        $jumlahHari = (int) (new DateTimeImmutable($bulan . '-01'))->format('t');
        $dibuatPada = $jamJalan->format('Y-m-d H:i:s');

        $templates = $this->pdo
            ->query('SELECT * FROM recurring_transactions WHERE aktif = 1 ORDER BY id')
            ->fetchAll(PDO::FETCH_ASSOC);

        $tercatat = 0;

        foreach ($templates as $template) {
            $hari = min((int) $template['hari_bulan'], $jumlahHari);
            $terjadiPada = sprintf('%s-%02d 08:00:00', $bulan, $hari);

            // Statement disiapkan di dalam loop: statement pdo_sqlite
            // yang gagal execute() tidak dapat dipakai ulang, dan pada
            // eksekusi berikutnya INSERT duplikat memang diharapkan
            // gagal lalu dilewati.
            $stmt = $this->pdo->prepare(
                'INSERT INTO transactions
                     (account_id, category_id, jenis, jumlah, keterangan,
                      terjadi_pada, dibuat_pada, template_id, bulan)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
            );

            try {
                $stmt->execute([
                    $template['account_id'],
                    $template['category_id'],
                    $template['jenis'],
                    $template['jumlah'],
                    $template['keterangan'],
                    $terjadiPada,
                    $dibuatPada,
                    $template['id'],
                    $bulan,
                ]);
                $tercatat++;
            } catch (PDOException $e) {
                $duplikat = $e->getCode() === '23000'
                    && str_contains($e->getMessage(), 'UNIQUE constraint failed');

                if (!$duplikat) {
                    throw $e;
                }

                // Pasangan (cetakan, bulan) ini sudah dibubuhkan — lewati.
            }
        }

        return $tercatat;
    }

    /**
     * Nonaktifkan satu cetakan agar tidak dibubuhkan lagi.
     *
     * Mengembalikan false bila id tidak ditemukan. Transaksi yang
     * sudah dibuat oleh cetakan ini tetap tersimpan; nonaktif hanya
     * menghentikan pembubuhan berikutnya.
     */
    public function nonaktifkan(int $id): bool
    {
        $stmt = $this->pdo->prepare(
            'UPDATE recurring_transactions SET aktif = 0 WHERE id = ? AND aktif = 1'
        );
        $stmt->execute([$id]);

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

    /**
     * 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')"
            );
        }
    }
}

Lima hal pada class tersebut perlu dibedah:

  1. define() tidak menerima parameter jenis: arah transaksi diambil dari kategori sehingga cetakan tidak pernah bertentangan dengan aturan catat() Bagian 3 sejak lahir. Validasi akun, kategori, jumlah, keterangan, dan hari berjalan seperti di service lain.
  2. Idempotensi ditegakkan database, bukan pengecekan awal. Alternatif yang terlihat lebih sopan — SELECT dulu untuk memeriksa apakah bulan ini sudah dibubuhkan — punya celah balapan: dua proses yang memeriksa bersamaan sama-sama melihat “belum ada” lalu sama-sama menyisipkan. Dengan mengandalkan index UNIQUE, INSERT kedua ditolak database apa pun kecepatannya, dan catch cukup membedakan penolakan duplikat dari galat lain lewat kode 23000 plus isi pesan.
  3. min($template['hari_bulan'], $jumlahHari) menggeser hari 31 ke hari terakhir bulan pendek; format('t') mengetahui Februari 2026 punya 28 hari dan 2028 punya 29.
  4. prepare() dipindahkan ke dalam loop dengan sengaja. Statement pdo_sqlite yang barusan gagal execute() menolak dipakai ulang (bad parameter or other API misuse), dan pada pemanggilan kedua generateFor() kegagalan INSERT duplikat memang terjadi tiap iterasi — menyiapkan statement segar per cetakan adalah satu-satunya pola yang benar di sini.
  5. Kolom bulan pada transaksi hasil cetakan memakai format YYYY-MM bulan target, sementara template_id mengikat ke baris cetakannya; keduanya bersama-sama adalah kunci yang diawasi index UNIQUE.

Walkthrough Recurring: Dibubuhkan Sekali Per Bulan #

Alur kedua mendefinisikan tiga cetakan — gaji tanggal 2, langganan streaming tanggal 7, dan cicilan kartu kredit tanggal 31 — lalu membubuhkannya ke Februari dua kali berturut-turut:

php
<?php
require __DIR__ . '/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__ . '/migrasi-budget-recurring.sql'));

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

$idGaji = $recurring->define(2, 2, 850000000, 'Gaji bulanan', 2);
$idStream = $recurring->define(2, 6, 5499000, 'Langganan streaming', 7);
$idCicilan = $recurring->define(2, 8, 75000000, 'Cicilan kartu kredit', 31);
echo "Cetakan #{$idGaji}: Gaji bulanan, hari 2\n";
echo "Cetakan #{$idStream}: Langganan streaming, hari 7\n";
echo "Cetakan #{$idCicilan}: Cicilan kartu kredit, hari 31 (Februari bergeser ke hari terakhir)\n";

$jamJalan = new DateTimeImmutable('2026-02-01 08:00:00', new DateTimeZone('Asia/Jakarta'));

echo "\n== Eksekusi pertama ==\n";
echo "Transaksi dibuat: ", $recurring->generateFor('2026-02', $jamJalan), "\n";

echo "\n== Eksekusi kedua untuk bulan yang sama ==\n";
echo "Transaksi dibuat: ", $recurring->generateFor('2026-02', $jamJalan), "\n";

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

echo "\nTransaksi manual di bulan yang sama tetap bisa dicatat:\n";
$idManual = $service->catat(
    1, 4, 'pengeluaran', 2550000, 'Makan siang di warung Bu Siti',
    new DateTimeImmutable('2026-02-05 12:15:00', new DateTimeZone('Asia/Jakarta')),
    new DateTimeImmutable('2026-02-05 12:20:00', new DateTimeZone('Asia/Jakarta')),
);
echo "Transaksi manual #$idManual tersimpan\n";

echo "\n== Maret: cetakan streaming dinonaktifkan ==\n";
echo $recurring->nonaktifkan($idStream) ? "Cetakan #$idStream dinonaktifkan\n" : "gagal\n";
$jamMaret = new DateTimeImmutable('2026-03-01 08:00:00', new DateTimeZone('Asia/Jakarta'));
echo "Transaksi Maret dibuat: ", $recurring->generateFor('2026-03', $jamMaret), "\n";
foreach ($repo->findByPeriod('2026-03-01 00:00:00', '2026-03-31 23:59:59') as $t) {
    echo "  {$t['terjadi_pada']} {$t['keterangan']} — ", formatRupiah((int) $t['jumlah']), "\n";
}

Output yang Dihasilkan:

text
Cetakan #1: Gaji bulanan, hari 2
Cetakan #2: Langganan streaming, hari 7
Cetakan #3: Cicilan kartu kredit, hari 31 (Februari bergeser ke hari terakhir)

== Eksekusi pertama ==
Transaksi dibuat: 3

== Eksekusi kedua untuk bulan yang sama ==
Transaksi dibuat: 0

== Transaksi Februari 2026 ==
ID  Terjadi             Kategori               Jumlah Keterangan
10  2026-02-02 08:00:00 Gaji          +Rp8.500.000,00 Gaji bulanan
11  2026-02-07 08:00:00 Hiburan          -Rp54.990,00 Langganan streaming
12  2026-02-28 08:00:00 Cicilan         -Rp750.000,00 Cicilan kartu kredit

Transaksi manual di bulan yang sama tetap bisa dicatat:
Transaksi manual #13 tersimpan

== Maret: cetakan streaming dinonaktifkan ==
Cetakan #2 dinonaktifkan
Transaksi Maret dibuat: 2
  2026-03-02 08:00:00 Gaji bulanan — Rp8.500.000,00
  2026-03-31 08:00:00 Cicilan kartu kredit — Rp750.000,00

Empat bukti penting ada pada output tersebut. Eksekusi pertama membuat tiga transaksi; eksekusi kedua untuk bulan yang sama membuat nol — idempotensi bekerja. Cicilan dengan hari target 31 jatuh di 2026-02-28 karena Februari 2026 hanya punya 28 hari. Transaksi manual catat() di bulan yang sama tetap lolos — template_id NULL tidak pernah bertabrakan dengan index UNIQUE. Terakhir, setelah cetakan streaming dinonaktifkan, Maret hanya menerima gaji dan cicilan.

Cara Menjalankan Contoh #

Contoh pada bagian ini membutuhkan seluruh berkas dari Bagian 1-3 plus tiga berkas baru. Susun foldernya sebagai berikut:

text
aplikasi-keuangan-pribadi/
├── bagian-1-domain-dan-database/
│   └── schema.sql
├── bagian-2-akun-dan-kategori/
│   └── src/
│       ├── AccountRepository.php
│       ├── CategoryRepository.php
│       └── uang.php
├── bagian-3-pencatatan-transaksi/
│   └── src/
│       ├── TransactionRepository.php
│       └── TransactionService.php
└── bagian-4-budget-dan-recurring-transactions/
    ├── migrasi-budget-recurring.sql
    ├── src/
    │   ├── BudgetService.php
    │   └── RecurringTransactionService.php
    └── uji-budget.php

Simpan salah satu walkthrough di atas sebagai uji-budget.php, lalu jalankan dari dalam folder bagian-4-budget-dan-recurring-transactions:

bash
php uji-budget.php

Urutan pemuatan penting: schema.sql Bagian 1 harus dieksekusi lebih dulu, baru migrasi-budget-recurring.sql, karena migrasi menambahkan kolom pada tabel transactions dan merujuk tabel categories. Seluruh contoh tetap memakai :memory: sehingga tidak ada berkas yang tersisa.

Apa yang Berubah #

Dibandingkan Bagian 3 yang fokus pada pencatatan, proyek kini memiliki kendali dan otomasi:

  • Berkas migrasi-budget-recurring.sql baru menambahkan tabel budgets dan recurring_transactions serta kolom template_id dan bulan pada transactions, dengan index UNIQUE (template_id, bulan) sebagai penjaga idempotensi.
  • Berkas src/BudgetService.php baru dengan setBudget() berbasis UPSERT, sisa(), dan ringkas() yang membandingkan batas dengan total transaksi satu bulan penuh.
  • Berkas src/RecurringTransactionService.php baru dengan define(), generateFor() yang idempoten, dan nonaktifkan().
  • Basis data lengkap kini dimuat lewat dua berkas berurutan — schema.sql lalu migrasi — pola yang dipakai terus oleh Bagian 5 sampai 7.

Ringkasan #

Budget mengubah pencatatan menjadi kendali: batas disimpan sekali per kategori per bulan, pengeluaran dihitung ulang dari transaksi lewat rentang BETWEEN yang dihitung dari 'YYYY-MM', dan status lewat batas tinggal membandingkan dua integer. Transaksi rutin menghapus pekerjaan manual: cetakan menyimpan seluruh bahan transaksi, jam beku menjaga determinisme, dan idempotensi ditegakkan index UNIQUE — bukan pengecekan yang bisa kalah balapan. Data yang tertata rapi ini adalah bahan mentah sempurna untuk pertanyaan berikutnya: bagaimana menyajikannya. Bagian 5 menyusun laporan bulanan dan visualisasi arus kas di atas fondasi ini.

Referensi #

Berkas Sumber

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

migrasi-budget-recurring.sql

Unduh berkas
sql
-- migrasi-budget-recurring.sql — migrasi tahap dua Aplikasi Keuangan Pribadi
-- Jalankan SETELAH schema.sql dari bagian-1-domain-dan-database.
--
-- Menambahkan dua tabel baru dan dua kolom pada tabel transactions:
--   budgets                 : batas belanja per kategori per bulan (sen)
--   recurring_transactions  : cetakan transaksi rutin (gaji, cicilan, langganan)
--   transactions.template_id: id cetakan asal (NULL untuk transaksi manual)
--   transactions.bulan      : bulan target pembuatan otomatis 'YYYY-MM'
--
-- Index UNIQUE pada (template_id, bulan) adalah kunci idempotensi:
-- satu cetakan hanya boleh menghasilkan SATU transaksi per bulan,
-- sehingga proses generate yang diulang tidak pernah menduplikasi
-- data. Baris transaksi manual memiliki template_id NULL, dan NULL
-- dianggap berbeda satu sama lain oleh SQLite sehingga transaksi
-- manual tidak pernah saling bertabrakan.
--
-- CATATAN: ALTER TABLE ADD COLUMN tidak mendukung IF NOT EXISTS di
-- SQLite, sehingga migrasi ini hanya boleh dijalankan sekali per
-- database. Kode PHP di Bagian 4 memeriksa keberadaan kolom lebih
-- dulu sebelum menjalankannya.

CREATE TABLE IF NOT EXISTS budgets (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    category_id INTEGER NOT NULL REFERENCES categories(id),
    bulan       TEXT    NOT NULL,
    batas       INTEGER NOT NULL CHECK (batas > 0),
    UNIQUE (category_id, bulan)
);

CREATE TABLE IF NOT EXISTS recurring_transactions (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    account_id  INTEGER NOT NULL REFERENCES accounts(id),
    category_id INTEGER NOT NULL REFERENCES categories(id),
    jenis       TEXT    NOT NULL CHECK (jenis IN ('pemasukan', 'pengeluaran')),
    jumlah      INTEGER NOT NULL CHECK (jumlah > 0),
    keterangan  TEXT    NOT NULL,
    hari_bulan  INTEGER NOT NULL CHECK (hari_bulan BETWEEN 1 AND 31),
    aktif       INTEGER NOT NULL DEFAULT 1 CHECK (aktif IN (0, 1))
);

ALTER TABLE transactions ADD COLUMN template_id INTEGER REFERENCES recurring_transactions(id);
ALTER TABLE transactions ADD COLUMN bulan TEXT;

CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_template_bulan
    ON transactions(template_id, bulan);

src/BudgetService.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Lapisan aturan bisnis untuk budget bulanan per kategori.
 *
 * Sebuah budget membatasi total pengeluaran satu kategori pada satu
 * bulan tertentu. Batas dan pengeluaran sama-sama dinyatakan dalam
 * sen sehingga selisihnya dihitung dengan aritmetika integer yang
 * eksak. Service ini memegang PDO secara langsung karena seluruh
 * kebutuhannya hanyalah dua tabel: budgets dan transactions.
 */
final class BudgetService
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Tetapkan (atau perbarui) budget satu kategori untuk satu bulan.
     *
     * Bila pasangan (kategori, bulan) sudah memiliki budget, batas
     * lama diganti dengan yang baru lewat klausa UPSERT
     * ON CONFLICT ... DO UPDATE — tidak ada baris kedua yang dibuat.
     *
     * @param int    $categoryId id kategori pengeluaran
     * @param string $bulan      bulan target format 'YYYY-MM', mis. '2026-01'
     * @param int    $batasSen   batas belanja dalam sen
     */
    public function setBudget(int $categoryId, string $bulan, int $batasSen): void
    {
        $this->pastikanBulanValid($bulan);

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

        $stmt = $this->pdo->prepare('SELECT nama, jenis FROM categories WHERE id = ?');
        $stmt->execute([$categoryId]);
        $kategori = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($kategori === false) {
            throw new InvalidArgumentException("Kategori #$categoryId tidak ditemukan");
        }

        if ($kategori['jenis'] !== 'pengeluaran') {
            throw new InvalidArgumentException(
                "Budget hanya untuk kategori pengeluaran; kategori '{$kategori['nama']}' bertipe {$kategori['jenis']}"
            );
        }

        $stmt = $this->pdo->prepare(
            'INSERT INTO budgets (category_id, bulan, batas)
             VALUES (?, ?, ?)
             ON CONFLICT(category_id, bulan) DO UPDATE SET batas = excluded.batas'
        );
        $stmt->execute([$categoryId, $bulan, $batasSen]);
    }

    /**
     * Sisa budget satu kategori pada satu bulan, dalam sen.
     *
     * Rumusnya: batas dikurangi total transaksi kategori tersebut
     * pada bulan itu. Hasil negatif berarti pengeluaran sudah
     * melebihi batas.
     */
    public function sisa(int $categoryId, string $bulan): int
    {
        return $this->ringkas($categoryId, $bulan)['sisa'];
    }

    /**
     * Ringkasan lengkap budget: batas, terpakai, sisa, dan status lewat.
     *
     * Karena kategori budget selalu bertipe pengeluaran (ditegakkan
     * setBudget), seluruh transaksi kategori tersebut pasti arah
     * keluar sehingga totalnya cukup di-SUM tanpa filter jenis.
     *
     * @return array{batas: int, terpakai: int, sisa: int, lewat: bool}
     */
    public function ringkas(int $categoryId, string $bulan): array
    {
        $this->pastikanBulanValid($bulan);

        $stmt = $this->pdo->prepare('SELECT batas FROM budgets WHERE category_id = ? AND bulan = ?');
        $stmt->execute([$categoryId, $bulan]);
        $batas = $stmt->fetchColumn();

        if ($batas === false) {
            throw new InvalidArgumentException(
                "Budget kategori #$categoryId untuk bulan $bulan belum ditetapkan — panggil setBudget() dahulu"
            );
        }

        [$dari, $sampai] = $this->rentangBulan($bulan);

        $stmt = $this->pdo->prepare(
            'SELECT COALESCE(SUM(jumlah), 0)
               FROM transactions
              WHERE category_id = ? AND terjadi_pada BETWEEN ? AND ?'
        );
        $stmt->execute([$categoryId, $dari, $sampai]);
        $terpakai = (int) $stmt->fetchColumn();

        return [
            'batas' => (int) $batas,
            'terpakai' => $terpakai,
            'sisa' => (int) $batas - $terpakai,
            'lewat' => $terpakai > (int) $batas,
        ];
    }

    /**
     * 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')];
    }
}

src/RecurringTransactionService.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Lapisan aturan bisnis untuk transaksi rutin (recurring).
 *
 * Transaksi rutin seperti gaji bulanan, cicilan, dan langganan
 * streaming tidak seharusnya dicatat manual setiap bulan. Service
 * ini menyimpan "cetakan" transaksi (define) lalu membubuhkannya
 * ke satu bulan tertentu (generateFor). Pencantuman bersifat
 * idempoten: index UNIQUE (template_id, bulan) pada tabel
 * transactions menolak pembubuhan kedua untuk pasangan cetakan +
 * bulan yang sama, sehingga proses yang diulang tidak pernah
 * menduplikasi data.
 */
final class RecurringTransactionService
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Definisikan cetakan transaksi rutin, kembalikan id cetakan.
     *
     * Arah transaksi (jenis) tidak diterima dari pemanggil, melainkan
     * diambil dari kategori: cetakan untuk kategori Gaji otomatis
     * menjadi pemasukan, cetakan untuk Cicilan otomatis menjadi
     * pengeluaran. Dengan begitu cetakan tidak pernah bertentangan
     * dengan aturan pencatatan di TransactionService::catat().
     *
     * @param int    $accountId   akun tempat transaksi rutin dicatat
     * @param int    $categoryId  kategori pengelompokan
     * @param int    $jumlahSen   nilai transaksi rutin dalam sen
     * @param string $keterangan  deskripsi yang dibubuhkan setiap bulan
     * @param int    $hariBulan   tanggal target dalam sebulan (1-31)
     */
    public function define(
        int $accountId,
        int $categoryId,
        int $jumlahSen,
        string $keterangan,
        int $hariBulan,
    ): int {
        if ($jumlahSen <= 0) {
            throw new InvalidArgumentException("Jumlah harus lebih besar dari nol sen, diterima: $jumlahSen");
        }

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

        if ($hariBulan < 1 || $hariBulan > 31) {
            throw new InvalidArgumentException("Hari dalam bulan harus 1-31, diterima: $hariBulan");
        }

        $stmt = $this->pdo->prepare('SELECT id FROM accounts WHERE id = ?');
        $stmt->execute([$accountId]);

        if ($stmt->fetchColumn() === false) {
            throw new InvalidArgumentException("Akun #$accountId tidak ditemukan");
        }

        $stmt = $this->pdo->prepare('SELECT nama, jenis FROM categories WHERE id = ?');
        $stmt->execute([$categoryId]);
        $kategori = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($kategori === false) {
            throw new InvalidArgumentException("Kategori #$categoryId tidak ditemukan");
        }

        $stmt = $this->pdo->prepare(
            'INSERT INTO recurring_transactions
                 (account_id, category_id, jenis, jumlah, keterangan, hari_bulan)
             VALUES (?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $accountId,
            $categoryId,
            $kategori['jenis'],
            $jumlahSen,
            trim($keterangan),
            $hariBulan,
        ]);

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

    /**
     * Bubuhkan seluruh cetakan aktif ke satu bulan, kembalikan
     * jumlah transaksi yang benar-benar dibuat.
     *
     * $jamJalan adalah jam eksekusi yang dibekukan (frozen clock)
     * dan menjadi nilai kolom dibuat_pada; waktu kejadian setiap
     * transaksi memakai hari target cetakan pada jam 08:00:00.
     * Cetakan dengan hari target melebihi jumlah hari bulan
     * tersebut digeser ke hari terakhir — hari 31 pada Februari
     * menjadi 28 atau 29 sesuai tahunnya.
     *
     * Pemanggilan kedua untuk bulan yang sama mengembalikan 0:
     * setiap INSERT duplikat ditolak index UNIQUE (template_id,
     * bulan) lalu dilewati, bukan dianggap galat.
     *
     * @param string           $bulan    bulan target format 'YYYY-MM'
     * @param DateTimeImmutable $jamJalan jam pencatatan yang dibekukan
     */
    public function generateFor(string $bulan, DateTimeImmutable $jamJalan): int
    {
        $this->pastikanBulanValid($bulan);

        $jumlahHari = (int) (new DateTimeImmutable($bulan . '-01'))->format('t');
        $dibuatPada = $jamJalan->format('Y-m-d H:i:s');

        $templates = $this->pdo
            ->query('SELECT * FROM recurring_transactions WHERE aktif = 1 ORDER BY id')
            ->fetchAll(PDO::FETCH_ASSOC);

        $tercatat = 0;

        foreach ($templates as $template) {
            $hari = min((int) $template['hari_bulan'], $jumlahHari);
            $terjadiPada = sprintf('%s-%02d 08:00:00', $bulan, $hari);

            // Statement disiapkan di dalam loop: statement pdo_sqlite
            // yang gagal execute() tidak dapat dipakai ulang, dan pada
            // eksekusi berikutnya INSERT duplikat memang diharapkan
            // gagal lalu dilewati.
            $stmt = $this->pdo->prepare(
                'INSERT INTO transactions
                     (account_id, category_id, jenis, jumlah, keterangan,
                      terjadi_pada, dibuat_pada, template_id, bulan)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
            );

            try {
                $stmt->execute([
                    $template['account_id'],
                    $template['category_id'],
                    $template['jenis'],
                    $template['jumlah'],
                    $template['keterangan'],
                    $terjadiPada,
                    $dibuatPada,
                    $template['id'],
                    $bulan,
                ]);
                $tercatat++;
            } catch (PDOException $e) {
                $duplikat = $e->getCode() === '23000'
                    && str_contains($e->getMessage(), 'UNIQUE constraint failed');

                if (!$duplikat) {
                    throw $e;
                }

                // Pasangan (cetakan, bulan) ini sudah dibubuhkan — lewati.
            }
        }

        return $tercatat;
    }

    /**
     * Nonaktifkan satu cetakan agar tidak dibubuhkan lagi.
     *
     * Mengembalikan false bila id tidak ditemukan. Transaksi yang
     * sudah dibuat oleh cetakan ini tetap tersimpan; nonaktif hanya
     * menghentikan pembubuhan berikutnya.
     */
    public function nonaktifkan(int $id): bool
    {
        $stmt = $this->pdo->prepare(
            'UPDATE recurring_transactions SET aktif = 0 WHERE id = ? AND aktif = 1'
        );
        $stmt->execute([$id]);

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

    /**
     * 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')"
            );
        }
    }
}