Setelah proyek pengelola inventaris, kita lanjut dengan proyek yang lebih personal: pengelola keuangan pribadi. Aplikasi ini mencatat pemasukan dan pengeluaran harian, mengelompokkan transaksi berdasarkan kategori, menghitung saldo, dan menghasilkan laporan per kategori serta laporan bulanan. Aplikasi ini mengajarkan pola berbeda dari proyek sebelumnya: setiap transaksi punya “tanda” (income/expense) yang memengaruhi perhitungan saldo.

Program yang akan kita bangun bekerja dalam satu sesi terminal:

  • menerima pemasukan (gaji, bonus, hadiah) dan pengeluaran (makan, transport, hiburan),
  • menyimpan tiap transaksi dengan id, jumlah, deskripsi, kategori, dan tanggal,
  • memfilter transaksi berdasarkan kategori atau rentang tanggal,
  • menghitung total pemasukan, total pengeluaran, dan saldo bersih,
  • menghasilkan laporan per kategori dengan subtotal pemasukan dan pengeluaran,
  • menghasilkan laporan bulanan yang mengelompokkan transaksi per bulan,
  • menyimpan dan memuat data ke file CSV finance.csv,
  • menjalankan rangkaian unit test yang membuktikan setiap fitur bekerja.

Tampilan Program Akhir #

Sesi pemakaian berikut menunjukkan hasil akhir proyek:

text
Memuat 3 transaksi dari finance.csv

=== Pengelola Keuangan Pribadi ===
1. Catat pemasukan
2. Catat pengeluaran
3. Daftar semua transaksi
4. Ringkasan keuangan
5. Filter per kategori
6. Filter per tanggal
7. Laporan per kategori
8. Laporan bulanan
9. Hapus transaksi
S. Simpan ke file
L. Muat dari file
Q. Keluar
Pilihan: 4

=== Ringkasan Keuangan ===
Total pemasukan : Rp  5000000.00
Total pengeluaran: Rp    80000.00
Saldo bersih    : Rp  4920000.00

Pilihan: 7

=== Laporan per Kategori ===
Kategori              Pemasukan  Pengeluaran        Saldo   #Txn
Gaji                5000000.00        0.00   5000000.00       1
Makan                     0.00    30000.00     -30000.00       1
Transport                 0.00    50000.00     -50000.00       1
TOTAL               5000000.00    80000.00   4920000.00

Pilihan: 8

=== Laporan Bulanan ===
Bulan    Pemasukan  Pengeluaran        Saldo
2024-03  5000000.00    80000.00  4920000.00

Tampilan ini memperlihatkan struktur domain: tiap transaksi punya tanda arah (in/out), kategori yang dianalisis, dan tanggal yang dipakai untuk grouping bulanan.

Tujuan Pembelajaran #

Setelah menyelesaikan proyek ini, kita mampu:

  • merancang model data dengan enum untuk tipe transaksi (income/expense),
  • menerapkan perhitungan signed value (income positif, expense negatif) untuk akumulasi saldo,
  • menulis validator tanggal YYYY-MM-DD tanpa library eksternal,
  • mengelompokkan transaksi berdasarkan kategori atau bulan,
  • memformat output tabel yang rapi dengan printf width specifier,
  • menguji modul finansial dengan test case yang mencakup filter dan summary.

Prasyarat #

Proyek ini tidak memperkenalkan materi baru. Bab yang sering dirujuk:

  • enum dan bitmask — TxnType sebagai enum dengan nilai +1/-1 agar signed value mudah dihitung,
  • string dan string.h — strncmp untuk grouping bulanan dari field date[YYYY-MM-DD],
  • file I/O — modul storage CSV dengan sscanf format 6 field,
  • array dan pointer — filter transaksi ke buffer output dengan batas kapasitas,
  • fungsi dan modularitas — lima modul: transaction, finance, storage, report, main.

Tahapan Pengerjaan #

Proyek dikerjakan dalam tujuh bagian berurutan:

  1. Bagian 1: Model Transaksi — definisikan struct Transaction dengan TxnType enum, validasi tanggal.
  2. Bagian 2: Pencatatan Pemasukan/Pengeluaran — bangun Finance dengan add/delete dan tracking next_id.
  3. Bagian 3: Kategori dan Filter — tambahkan filter by category (case-insensitive) dan by date range.
  4. Bagian 4: Ringkasan Keuangan — hitung total income, total expense, dan balance bersih.
  5. Bagian 5: Penyimpanan Data — simpan dan muat transaksi ke CSV dengan format 6 field.
  6. Bagian 6: Laporan — laporan per kategori dengan subtotal, dan laporan bulanan dengan grouping.
  7. Bagian 7: Testing dan Build — rangkaian unit test dan integrasi make test.

Mulailah dari Bagian 1 dan ikuti urutannya. Setiap bagian dibangun di atas bagian sebelumnya. Berkas sumber tiap bagian tersedia di bagian Berkas Sumber di bawah halaman masing-masing.

Berkas Sumber

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

finance.c

Unduh berkas
c
/* finance.c - Implementasi operasi Finance */
#include "finance.h"

#include <stdio.h>
#include <string.h>
#include <ctype.h>

void finance_init(Finance *f) {
    if (f == NULL) {
        return;
    }
    f->count = 0;
    f->next_id = 1;
}

/* Helper: case-insensitive string equality. */
static int eq_ci(const char *a, const char *b) {
    if (a == NULL || b == NULL) {
        return 0;
    }
    while (*a != '\0' && *b != '\0') {
        if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) {
            return 0;
        }
        a++;
        b++;
    }
    return *a == '\0' && *b == '\0';
}

int finance_add(Finance *f, TxnType type, double amount,
                const char *description, const char *category,
                const char *date) {
    if (f == NULL || description == NULL || category == NULL || date == NULL) {
        return -1;
    }
    if (f->count >= FINANCE_CAPACITY) {
        return -1;
    }
    Transaction *slot = &f->items[f->count];
    int id = f->next_id;
    if (transaction_create(slot, id, type, amount, description,
                           category, date) != 0) {
        return -1;
    }
    f->next_id++;
    f->count++;
    return id;
}

int finance_delete(Finance *f, int id) {
    if (f == NULL) {
        return -1;
    }
    for (int i = 0; i < f->count; i++) {
        if (f->items[i].id == id) {
            memmove(&f->items[i], &f->items[i + 1],
                    (size_t)(f->count - i - 1) * sizeof(Transaction));
            f->count--;
            return 0;
        }
    }
    return -1;
}

int finance_filter_by_category(const Finance *f, const char *category,
                                Transaction *out, int out_cap) {
    if (f == NULL || out == NULL || out_cap <= 0) {
        return 0;
    }
    int found = 0;
    for (int i = 0; i < f->count && found < out_cap; i++) {
        if (eq_ci(f->items[i].category, category)) {
            out[found++] = f->items[i];
        }
    }
    return found;
}

int finance_filter_by_date(const Finance *f, const char *from,
                           const char *to, Transaction *out, int out_cap) {
    if (f == NULL || out == NULL || out_cap <= 0) {
        return 0;
    }
    int found = 0;
    for (int i = 0; i < f->count && found < out_cap; i++) {
        const char *d = f->items[i].date;
        if (from != NULL && strcmp(d, from) < 0) {
            continue;
        }
        if (to != NULL && strcmp(d, to) > 0) {
            continue;
        }
        out[found++] = f->items[i];
    }
    return found;
}

void finance_summary(const Finance *f, double *total_income,
                     double *total_expense, double *balance) {
    double inc = 0.0;
    double exp = 0.0;
    if (f != NULL) {
        for (int i = 0; i < f->count; i++) {
            if (f->items[i].type == TXN_INCOME) {
                inc += f->items[i].amount;
            } else {
                exp += f->items[i].amount;
            }
        }
    }
    if (total_income != NULL) {
        *total_income = inc;
    }
    if (total_expense != NULL) {
        *total_expense = exp;
    }
    if (balance != NULL) {
        *balance = inc - exp;
    }
}

void finance_print_all(const Finance *f) {
    if (f == NULL) {
        return;
    }
    printf("Total transaksi: %d/%d\n", f->count, FINANCE_CAPACITY);
    for (int i = 0; i < f->count; i++) {
        transaction_print(&f->items[i]);
    }
}

main.c

Unduh berkas
c
/* main.c - Program utama Pengelola Keuangan Pribadi */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>

#include "finance.h"
#include "storage.h"
#include "report.h"

#define DB_PATH "finance.csv"

static int read_line(char *buf, size_t cap) {
    if (fgets(buf, (int)cap, stdin) == NULL) {
        return -1;
    }
    size_t len = strlen(buf);
    while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) {
        buf[--len] = '\0';
    }
    return 0;
}

static void print_menu(void) {
    puts("");
    puts("=== Pengelola Keuangan Pribadi ===");
    puts("1. Catat pemasukan");
    puts("2. Catat pengeluaran");
    puts("3. Daftar semua transaksi");
    puts("4. Ringkasan keuangan");
    puts("5. Filter per kategori");
    puts("6. Filter per tanggal");
    puts("7. Laporan per kategori");
    puts("8. Laporan bulanan");
    puts("9. Hapus transaksi");
    puts("S. Simpan ke file");
    puts("L. Muat dari file");
    puts("Q. Keluar");
    printf("Pilihan: ");
}

static int ask_txn_type(TxnType wanted) {
    char buf[256];
    double amount;
    printf("Jumlah (Rp): ");
    if (read_line(buf, sizeof(buf)) != 0) {
        return -1;
    }
    amount = strtod(buf, NULL);
    if (amount <= 0) {
        printf("Jumlah harus positif.\n");
        return -1;
    }
    char desc[TXN_DESC_MAX];
    printf("Deskripsi: ");
    if (read_line(desc, sizeof(desc)) != 0) {
        return -1;
    }
    char cat[TXN_CATEGORY_MAX];
    printf("Kategori: ");
    if (read_line(cat, sizeof(cat)) != 0) {
        return -1;
    }
    char date[16];
    printf("Tanggal (YYYY-MM-DD, kosongkan = hari ini): ");
    if (read_line(date, sizeof(date)) != 0) {
        return -1;
    }
    if (date[0] == '\0') {
        strcpy(date, "2024-01-01");
    }
    int id = finance_add(NULL, wanted, 0, NULL, NULL, NULL);
    (void)id;
    return 0;
}

static void do_add(Finance *f, TxnType type) {
    char buf[256];
    printf("Jumlah (Rp): ");
    if (read_line(buf, sizeof(buf)) != 0) {
        return;
    }
    double amount = strtod(buf, NULL);
    if (amount <= 0) {
        printf("Jumlah harus positif.\n");
        return;
    }
    char desc[TXN_DESC_MAX];
    printf("Deskripsi: ");
    if (read_line(desc, sizeof(desc)) != 0) {
        return;
    }
    char cat[TXN_CATEGORY_MAX];
    printf("Kategori: ");
    if (read_line(cat, sizeof(cat)) != 0) {
        return;
    }
    char date[16];
    printf("Tanggal (YYYY-MM-DD): ");
    if (read_line(date, sizeof(date)) != 0) {
        return;
    }
    int id = finance_add(f, type, amount, desc, cat, date);
    if (id < 0) {
        printf("Gagal menambah transaksi. Periksa kembali input.\n");
        return;
    }
    printf("Transaksi dicatat dengan id=%d\n", id);
}

static void do_summary(const Finance *f) {
    double inc, exp, bal;
    finance_summary(f, &inc, &exp, &bal);
    printf("\n=== Ringkasan Keuangan ===\n");
    printf("Total pemasukan : Rp%12.2f\n", inc);
    printf("Total pengeluaran: Rp%12.2f\n", exp);
    printf("Saldo bersih    : Rp%12.2f\n", bal);
}

static void do_filter_category(const Finance *f) {
    char cat[TXN_CATEGORY_MAX];
    printf("Kategori: ");
    if (read_line(cat, sizeof(cat)) != 0) {
        return;
    }
    Transaction out[64];
    int n = finance_filter_by_category(f, cat, out, 64);
    printf("Ditemukan %d transaksi:\n", n);
    for (int i = 0; i < n; i++) {
        transaction_print(&out[i]);
    }
}

static void do_filter_date(const Finance *f) {
    char from[16], to[16];
    printf("Dari (YYYY-MM-DD): ");
    if (read_line(from, sizeof(from)) != 0) {
        return;
    }
    printf("Sampai (YYYY-MM-DD): ");
    if (read_line(to, sizeof(to)) != 0) {
        return;
    }
    Transaction out[64];
    int n = finance_filter_by_date(f, from, to, out, 64);
    printf("Ditemukan %d transaksi:\n", n);
    for (int i = 0; i < n; i++) {
        transaction_print(&out[i]);
    }
}

static void do_report_category(const Finance *f) {
    CategoryReport reports[32];
    int n = report_by_category(f, reports, 32);
    report_print_category(reports, n);
}

static void do_delete(Finance *f) {
    char buf[16];
    printf("Masukkan id transaksi: ");
    if (read_line(buf, sizeof(buf)) != 0) {
        return;
    }
    int id = (int)strtol(buf, NULL, 10);
    if (finance_delete(f, id) != 0) {
        printf("Transaksi id %d tidak ditemukan.\n", id);
        return;
    }
    printf("Transaksi id %d dihapus.\n", id);
}

int main(void) {
    Finance f;
    finance_init(&f);
    if (storage_load(&f, DB_PATH) > 0) {
        printf("Memuat %d transaksi dari %s\n", f.count, DB_PATH);
    }

    char choice[8];
    int running = 1;
    while (running) {
        print_menu();
        if (read_line(choice, sizeof(choice)) != 0) {
            break;
        }
        char c = (char)tolower((unsigned char)choice[0]);
        switch (c) {
            case '1': do_add(&f, TXN_INCOME); break;
            case '2': do_add(&f, TXN_EXPENSE); break;
            case '3': finance_print_all(&f); break;
            case '4': do_summary(&f); break;
            case '5': do_filter_category(&f); break;
            case '6': do_filter_date(&f); break;
            case '7': do_report_category(&f); break;
            case '8': report_print_monthly(&f); break;
            case '9': do_delete(&f); break;
            case 's': {
                int n = storage_save(&f, DB_PATH);
                if (n >= 0) {
                    printf("Menyimpan %d transaksi ke %s\n", n, DB_PATH);
                } else {
                    printf("Gagal menyimpan ke %s\n", DB_PATH);
                }
                break;
            }
            case 'l': {
                int n = storage_load(&f, DB_PATH);
                if (n >= 0) {
                    printf("Memuat %d transaksi dari %s\n", n, DB_PATH);
                } else {
                    printf("Gagal memuat dari %s\n", DB_PATH);
                }
                break;
            }
            case 'q': running = 0; break;
            default:  printf("Pilihan tidak dikenal: %c\n", c); break;
        }
    }
    /* Hindari warning unused function. */
    (void)ask_txn_type;
    puts("Terima kasih telah menggunakan Pengelola Keuangan.");
    return 0;
}

report.c

Unduh berkas
c
/* report.c - Implementasi laporan keuangan */
#include "report.h"

#include <stdio.h>
#include <string.h>
#include <ctype.h>

static int eq_ci(const char *a, const char *b) {
    while (*a != '\0' && *b != '\0') {
        if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) {
            return 0;
        }
        a++;
        b++;
    }
    return *a == '\0' && *b == '\0';
}

static void cat_report_init(CategoryReport *r, const char *category) {
    memset(r, 0, sizeof(*r));
    strncpy(r->category, category, TXN_CATEGORY_MAX - 1);
    r->category[TXN_CATEGORY_MAX - 1] = '\0';
}

int report_by_category(const Finance *f, CategoryReport *out, int out_cap) {
    if (f == NULL || out == NULL || out_cap <= 0) {
        return 0;
    }
    int unique = 0;
    for (int i = 0; i < f->count; i++) {
        const Transaction *t = &f->items[i];
        /* Cari kategori yang sudah ada di `out`. */
        int found = -1;
        for (int k = 0; k < unique; k++) {
            if (eq_ci(out[k].category, t->category)) {
                found = k;
                break;
            }
        }
        if (found < 0) {
            if (unique >= out_cap) {
                continue;
            }
            cat_report_init(&out[unique], t->category);
            found = unique;
            unique++;
        }
        if (t->type == TXN_INCOME) {
            out[found].income += t->amount;
        } else {
            out[found].expense += t->amount;
        }
        out[found].balance += transaction_signed_value(t);
        out[found].count++;
    }
    return unique;
}

void report_print_category(const CategoryReport *r, int count) {
    if (r == NULL || count <= 0) {
        printf("Tidak ada data untuk laporan.\n");
        return;
    }
    printf("\n=== Laporan per Kategori ===\n");
    printf("%-20s %12s %12s %12s  %6s\n",
           "Kategori", "Pemasukan", "Pengeluaran", "Saldo", "#Txn");
    double total_inc = 0, total_exp = 0;
    for (int i = 0; i < count; i++) {
        printf("%-20s %12.2f %12.2f %12.2f  %6d\n",
               r[i].category, r[i].income, r[i].expense,
               r[i].balance, r[i].count);
        total_inc += r[i].income;
        total_exp += r[i].expense;
    }
    printf("%-20s %12.2f %12.2f %12.2f\n",
           "TOTAL", total_inc, total_exp, total_inc - total_exp);
}

void report_print_monthly(const Finance *f) {
    if (f == NULL || f->count == 0) {
        printf("Tidak ada data untuk laporan bulanan.\n");
        return;
    }
    printf("\n=== Laporan Bulanan ===\n");
    printf("%-8s %12s %12s %12s\n",
           "Bulan", "Pemasukan", "Pengeluaran", "Saldo");

    char current[8] = "";
    double m_inc = 0, m_exp = 0;
    for (int i = 0; i < f->count; i++) {
        const Transaction *t = &f->items[i];
        char month[8];
        /* YYYY-MM diambil dari 7 karakter pertama date. */
        snprintf(month, sizeof(month), "%.7s", t->date);
        if (strcmp(month, current) != 0) {
            if (current[0] != '\0') {
                printf("%-8s %12.2f %12.2f %12.2f\n",
                       current, m_inc, m_exp, m_inc - m_exp);
            }
            strcpy(current, month);
            m_inc = 0;
            m_exp = 0;
        }
        if (t->type == TXN_INCOME) {
            m_inc += t->amount;
        } else {
            m_exp += t->amount;
        }
    }
    if (current[0] != '\0') {
        printf("%-8s %12.2f %12.2f %12.2f\n",
               current, m_inc, m_exp, m_inc - m_exp);
    }
}

storage.c

Unduh berkas
c
/* storage.c - Implementasi persistensi CSV untuk Finance */
#include "storage.h"

#include <stdio.h>
#include <string.h>

static void strip_newline(char *s) {
    size_t len = strlen(s);
    while (len > 0 && (s[len - 1] == '\n' || s[len - 1] == '\r')) {
        s[--len] = '\0';
    }
}

/* Parse satu baris CSV dengan field yang mungkin mengandung koma
 * jika dibungkus tanda kutip. Implementasi sederhana: kita asumsikan
 * description dan category tidak mengandung koma. */
static int parse_line(const char *line, int *id, int *type,
                      double *amount, char *desc, size_t desc_cap,
                      char *cat, size_t cat_cap, char *date, size_t date_cap) {
    char tmp_desc[TXN_DESC_MAX];
    char tmp_cat[TXN_CATEGORY_MAX];
    char tmp_date[16];
    int consumed = 0;
    int items = sscanf(line, "%d,%d,%lf,%63[^,],%31[^,],%10s%n",
                       id, type, amount, tmp_desc, tmp_cat, tmp_date,
                       &consumed);
    if (items != 6) {
        return 0;
    }
    char trailing = line[consumed];
    if (trailing != '\0' && trailing != '\n' && trailing != '\r') {
        return 0;
    }
    if (desc_cap < sizeof(tmp_desc) || cat_cap < sizeof(tmp_cat) ||
        date_cap < sizeof(tmp_date)) {
        return 0;
    }
    strcpy(desc, tmp_desc);
    strcpy(cat, tmp_cat);
    strcpy(date, tmp_date);
    return 1;
}

int storage_save(const Finance *f, const char *path) {
    if (f == NULL || path == NULL) {
        return -1;
    }
    FILE *fp = fopen(path, "w");
    if (fp == NULL) {
        return -1;
    }
    fprintf(fp, "# id,type,amount,description,category,date\n");
    int written = 0;
    for (int i = 0; i < f->count; i++) {
        const Transaction *t = &f->items[i];
        int n = fprintf(fp, "%d,%d,%.2f,%s,%s,%s\n",
                        t->id, t->type, t->amount,
                        t->description, t->category, t->date);
        if (n < 0) {
            fclose(fp);
            return -1;
        }
        written++;
    }
    fclose(fp);
    return written;
}

int storage_load(Finance *f, const char *path) {
    if (f == NULL || path == NULL) {
        return -1;
    }
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        return -1;
    }
    finance_init(f);
    char line[256];
    int read = 0;
    int max_id_seen = 0;
    while (fgets(line, sizeof(line), fp) != NULL) {
        if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') {
            continue;
        }
        strip_newline(line);
        int id, type;
        double amount;
        char desc[TXN_DESC_MAX];
        char cat[TXN_CATEGORY_MAX];
        char date[16];
        if (!parse_line(line, &id, &type, &amount, desc, sizeof(desc),
                       cat, sizeof(cat), date, sizeof(date))) {
            continue;
        }
        if (f->count >= FINANCE_CAPACITY) {
            break;
        }
        if (type != TXN_INCOME && type != TXN_EXPENSE) {
            continue;
        }
        Transaction *slot = &f->items[f->count];
        if (transaction_create(slot, id, (TxnType)type, amount,
                               desc, cat, date) != 0) {
            continue;
        }
        if (id > max_id_seen) {
            max_id_seen = id;
        }
        f->count++;
        read++;
    }
    f->next_id = max_id_seen + 1;
    fclose(fp);
    return read;
}

tests.c

Unduh berkas
c
/* tests.c - Unit test untuk modul keuangan */
#include <stdio.h>
#include <string.h>

#include "finance.h"
#include "storage.h"
#include "report.h"

static int tests_run = 0;
static int tests_pass = 0;

#define ASSERT(cond, msg) \
    do { \
        tests_run++; \
        if (cond) { \
            tests_pass++; \
        } else { \
            printf("FAIL: %s (line %d)\n", msg, __LINE__); \
        } \
    } while (0)

static void test_transaction_create(void) {
    Transaction t;
    int rc = transaction_create(&t, 1, TXN_INCOME, 1000000.0,
                                "Gaji bulanan", "Gaji", "2024-03-01");
    ASSERT(rc == 0, "transaction_create valid harus sukses");
    ASSERT(t.id == 1, "id harus 1");
    ASSERT(t.type == TXN_INCOME, "type harus INCOME");
    ASSERT(transaction_signed_value(&t) == 1000000.0, "signed = +1M");

    Transaction t2;
    rc = transaction_create(&t2, 2, TXN_EXPENSE, 25000.0,
                            "Makan siang", "Makan", "2024-03-02");
    ASSERT(rc == 0, "transaction_create expense sukses");
    ASSERT(transaction_signed_value(&t2) == -25000.0, "signed = -25K");
}

static void test_transaction_invalid(void) {
    Transaction t;
    ASSERT(transaction_create(&t, 0, TXN_INCOME, 100, "X", "C", "2024-01-01") != 0,
           "id=0 ditolak");
    ASSERT(transaction_create(&t, 1, TXN_INCOME, -1, "X", "C", "2024-01-01") != 0,
           "amount negatif ditolak");
    ASSERT(transaction_create(&t, 1, TXN_INCOME, 100, "X", "C", "2024/01/01") != 0,
           "format tanggal salah ditolak");
    ASSERT(transaction_create(&t, 1, TXN_INCOME, 100, "X", "C", "2024-13-01") != 0,
           "bulan 13 ditolak");
    ASSERT(transaction_create(&t, 1, TXN_INCOME, 100, "X", "C", "2024-01-32") != 0,
           "hari 32 ditolak");
}

static void test_finance_add_summary(void) {
    Finance f;
    finance_init(&f);
    int id1 = finance_add(&f, TXN_INCOME, 5000000.0, "Gaji", "Gaji", "2024-03-01");
    int id2 = finance_add(&f, TXN_EXPENSE, 50000.0, "Bensin", "Transport", "2024-03-02");
    int id3 = finance_add(&f, TXN_EXPENSE, 30000.0, "Makan", "Makan", "2024-03-02");
    ASSERT(id1 == 1 && id2 == 2 && id3 == 3, "id berurutan");
    ASSERT(f.count == 3, "count harus 3");

    double inc, exp, bal;
    finance_summary(&f, &inc, &exp, &bal);
    ASSERT(inc == 5000000.0, "total income 5M");
    ASSERT(exp == 80000.0, "total expense 80K");
    ASSERT(bal == 4920000.0, "balance 4.92M");
}

static void test_finance_filter_category(void) {
    Finance f;
    finance_init(&f);
    finance_add(&f, TXN_EXPENSE, 10000.0, "Kopi", "Makan", "2024-03-01");
    finance_add(&f, TXN_EXPENSE, 20000.0, "Bakso", "Makan", "2024-03-01");
    finance_add(&f, TXN_EXPENSE, 50000.0, "Bensin", "Transport", "2024-03-02");

    Transaction out[16];
    int n = finance_filter_by_category(&f, "makan", out, 16);
    ASSERT(n == 2, "filter 'makan' case-insensitive = 2");

    n = finance_filter_by_category(&f, "TidakAda", out, 16);
    ASSERT(n == 0, "kategori tidak ada = 0");
}

static void test_finance_filter_date(void) {
    Finance f;
    finance_init(&f);
    finance_add(&f, TXN_EXPENSE, 10000.0, "A", "X", "2024-03-01");
    finance_add(&f, TXN_EXPENSE, 20000.0, "B", "X", "2024-03-02");
    finance_add(&f, TXN_EXPENSE, 30000.0, "C", "X", "2024-03-05");
    finance_add(&f, TXN_EXPENSE, 40000.0, "D", "X", "2024-03-10");

    Transaction out[16];
    int n = finance_filter_by_date(&f, "2024-03-02", "2024-03-05", out, 16);
    ASSERT(n == 2, "rentang tanggal 02-05 = 2 transaksi");

    n = finance_filter_by_date(&f, NULL, "2024-03-02", out, 16);
    ASSERT(n == 2, "hanya sampai 02 = 2");

    n = finance_filter_by_date(&f, "2024-03-06", NULL, out, 16);
    ASSERT(n == 1, "hanya dari 06 = 1");
}

static void test_storage_roundtrip(void) {
    Finance f;
    finance_init(&f);
    finance_add(&f, TXN_INCOME, 1000000.0, "Gaji", "Gaji", "2024-03-01");
    finance_add(&f, TXN_EXPENSE, 50000.0, "Bensin", "Transport", "2024-03-02");

    const char *path = "/tmp/test_finance.csv";
    int written = storage_save(&f, path);
    ASSERT(written == 2, "harus menulis 2 baris");

    Finance loaded;
    int read = storage_load(&loaded, path);
    ASSERT(read == 2, "harus membaca 2 baris");
    ASSERT(loaded.count == 2, "count=2 setelah load");
    ASSERT(loaded.next_id == 3, "next_id=3 setelah load");
    ASSERT(strcmp(loaded.items[0].description, "Gaji") == 0,
           "deskripsi[0] = Gaji");
}

static void test_report_category(void) {
    Finance f;
    finance_init(&f);
    finance_add(&f, TXN_INCOME, 1000000.0, "Gaji", "Gaji", "2024-03-01");
    finance_add(&f, TXN_EXPENSE, 50000.0, "Bensin", "Transport", "2024-03-02");
    finance_add(&f, TXN_EXPENSE, 30000.0, "Makan", "Makan", "2024-03-02");
    finance_add(&f, TXN_EXPENSE, 20000.0, "Cafe", "Makan", "2024-03-03");

    CategoryReport r[16];
    int n = report_by_category(&f, r, 16);
    ASSERT(n == 3, "harus ada 3 kategori unik");

    int found_gaji = 0, found_makan = 0, found_transport = 0;
    for (int i = 0; i < n; i++) {
        if (strcmp(r[i].category, "Gaji") == 0) {
            found_gaji = 1;
            ASSERT(r[i].income == 1000000.0, "Gaji income 1M");
            ASSERT(r[i].count == 1, "Gaji count 1");
        }
        if (strcmp(r[i].category, "Makan") == 0) {
            found_makan = 1;
            ASSERT(r[i].expense == 50000.0, "Makan expense 50K");
            ASSERT(r[i].count == 2, "Makan count 2");
        }
        if (strcmp(r[i].category, "Transport") == 0) {
            found_transport = 1;
        }
    }
    ASSERT(found_gaji && found_makan && found_transport,
           "ketiga kategori harus ada");
}

int main(void) {
    test_transaction_create();
    test_transaction_invalid();
    test_finance_add_summary();
    test_finance_filter_category();
    test_finance_filter_date();
    test_storage_roundtrip();
    test_report_category();

    printf("\n=== Hasil Test ===\n");
    printf("Total : %d\n", tests_run);
    printf("Lulus : %d\n", tests_pass);
    printf("Gagal : %d\n", tests_run - tests_pass);
    return (tests_run == tests_pass) ? 0 : 1;
}

transaction.c

Unduh berkas
c
/* transaction.c - Implementasi model Transaction */
#include "transaction.h"

#include <stdio.h>
#include <string.h>
#include <math.h>
#include <ctype.h>

int transaction_date_is_valid(const char *date) {
    if (date == NULL) {
        return 0;
    }
    /* Format: YYYY-MM-DD = 10 karakter. */
    if (strlen(date) != 10) {
        return 0;
    }
    for (int i = 0; i < 10; i++) {
        char c = date[i];
        if (i == 4 || i == 7) {
            if (c != '-') {
                return 0;
            }
        } else {
            if (!isdigit((unsigned char)c)) {
                return 0;
            }
        }
    }
    /* Validasi rentang bulan dan hari secara kasar. */
    int y, m, d;
    if (sscanf(date, "%d-%d-%d", &y, &m, &d) != 3) {
        return 0;
    }
    if (y < 1900 || y > 2999) {
        return 0;
    }
    if (m < 1 || m > 12) {
        return 0;
    }
    if (d < 1 || d > 31) {
        return 0;
    }
    return 1;
}

int transaction_create(Transaction *t, int id, TxnType type,
                       double amount, const char *description,
                       const char *category, const char *date) {
    if (t == NULL || description == NULL || category == NULL || date == NULL) {
        return -1;
    }
    if (id <= 0) {
        return -2;
    }
    if (type != TXN_INCOME && type != TXN_EXPENSE) {
        return -3;
    }
    if (amount <= 0.0 || isnan(amount) || isinf(amount)) {
        return -4;
    }
    if (!transaction_date_is_valid(date)) {
        return -5;
    }

    t->id = id;
    t->type = type;
    t->amount = amount;
    strncpy(t->description, description, TXN_DESC_MAX - 1);
    t->description[TXN_DESC_MAX - 1] = '\0';
    strncpy(t->category, category, TXN_CATEGORY_MAX - 1);
    t->category[TXN_CATEGORY_MAX - 1] = '\0';
    strncpy(t->date, date, sizeof(t->date) - 1);
    t->date[sizeof(t->date) - 1] = '\0';
    return 0;
}

double transaction_signed_value(const Transaction *t) {
    if (t == NULL) {
        return 0.0;
    }
    return t->type * t->amount;
}

void transaction_print(const Transaction *t) {
    if (t == NULL) {
        printf("(null transaction)\n");
        return;
    }
    const char *sign = (t->type == TXN_INCOME) ? "+" : "-";
    const char *label = (t->type == TXN_INCOME) ? "MASUK " : "KELUAR";
    printf("[%04d] %s %s %s  Rp%12.2f  %s\n",
           t->id, t->date, label, t->category,
           t->type * t->amount, t->description);
    (void)sign;
}