Transaksi yang dicatat harus bertahan antar sesi. Pada bagian ini, kita bangun modul storage.c yang menyimpan dan memuat transaksi ke file CSV. Formatnya adalah perluasan dari proyek 7: setiap baris punya enam field (id, type, amount, description, category, date) yang dipisahkan koma.

Konsep #

Format CSV untuk transaksi:

csv
# id,type,amount,description,category,date
1,1,5000000.00,Gaji bulanan,Gaji,2024-03-01
2,-1,50000.00,Bensin,Transport,2024-03-02

Kolom type bernilai 1 (income) atau -1 (expense) sesuai nilai enum TxnType. Field amount selalu positif karena tanda sudah ditentukan oleh type. Field description dan category diasumsikan tidak mengandung koma.

Mekanisme kerja: storage_save iterasi transaksi dan tulis dengan fprintf. storage_load baca baris per baris dengan fgets, parse dengan sscanf format 6 field, lalu validasi dengan transaction_create. Bila validasi gagal, baris dilewati (skip tanpa crash). Setelah load, next_id di-set ke max_id_seen + 1 agar id baru tidak bertabrakan.

Implementasi #

Fungsi parse_line dengan 6 field:

c
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;
}

Fungsi storage_save:

c
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, (int)t->type, t->amount,
                        t->description, t->category, t->date);
        if (n < 0) {
            fclose(fp);
            return -1;
        }
        written++;
    }
    fclose(fp);
    return written;
}

Fungsi storage_load:

c
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;
}

Verifikasi roundtrip:

c
/* demo_storage_finance.c - Test save/load finance */
#include <stdio.h>
#include "finance.h"
#include "storage.h"

int main(void) {
    Finance f;
    finance_init(&f);
    finance_add(&f, TXN_INCOME, 5000000.0, "Gaji bulanan",
                "Gaji", "2024-03-01");
    finance_add(&f, TXN_EXPENSE, 50000.0, "Bensin",
                "Transport", "2024-03-02");
    finance_add(&f, TXN_EXPENSE, 30000.0, "Makan siang",
                "Makan", "2024-03-02");

    const char *path = "/tmp/finance_demo.csv";
    int n = storage_save(&f, path);
    printf("Menyimpan %d transaksi\n", n);

    /* Tampilkan isi file. */
    FILE *fp = fopen(path, "r");
    char buf[256];
    printf("--- Isi file ---\n");
    while (fgets(buf, sizeof(buf), fp) != NULL) {
        printf("%s", buf);
    }
    fclose(fp);

    /* Load ke Finance baru. */
    Finance loaded;
    int m = storage_load(&loaded, path);
    printf("\nMemuat %d transaksi kembali\n", m);
    printf("next_id setelah load: %d\n", loaded.next_id);
    finance_print_all(&loaded);
    return 0;
}

Output yang Dihasilkan:

text
Menyimpan 3 transaksi
--- Isi file ---
# id,type,amount,description,category,date
1,1,5000000.00,Gaji bulanan,Gaji,2024-03-01
2,-1,50000.00,Bensin,Transport,2024-03-02
3,-1,30000.00,Makan siang,Makan,2024-03-02

Memuat 3 transaksi kembali
next_id setelah load: 4
Total transaksi: 3/512
[0001] 2024-03-01 MASUK  Gaji  Rp  5000000.00  Gaji bulanan
[0002] 2024-03-02 KELUAR Transport  Rp   -50000.00  Bensin
[0003] 2024-03-02 KELUAR Makan     Rp   -30000.00  Makan siang

Mari kita telaah baris-baris kode di atas:

  1. Baris 9 (sscanf format string): "%d,%d,%lf,%63[^,],%31[^,],%10s%n" membaca 6 field berturut-turut. %63[^,] membaca string hingga 63 karakter atau koma berikutnya. %10s membaca tanggal dengan maksimal 10 karakter. %n menyimpan jumlah karakter yang sudah diproses untuk trailing check.
  2. Baris 13-15 (trailing check): Setelah parsing, karakter pada posisi consumed harus newline, NULL, atau CR. Bila ada karakter lain (mis. koma ekstra), baris dianggap rusak.
  3. Baris 17-21 (size guard): Pastikan buffer pemanggil cukup besar. Bila tidak, return 0 (parse gagal). Pattern defensif mencegah buffer overflow.
  4. Baris 47 (fprintf dengan (int)t->type): Cast enum ke int eksplisit. Standard C tidak menjamin ukuran enum, jadi cast ke int memastikan format specifier %d benar.
  5. Baris 75-77 (skip baris rusak): Bila parse_line gagal, continue ke iterasi berikutnya. Program tetap jalan meski ada barik rusak di file.
  6. Baris 84-86 (max_id_seen tracking): Lacak id terbesar yang dilihat, lalu set next_id = max + 1. Bila file berisi id 1,2,5,8 (ada gap), next_id = 9. Id baru tidak akan bertabrakan dengan id yang sudah ada.
  7. Baris 80-82 (skip type invalid): Bila type bukan 1 atau -1, lewati baris. Pola defensive mencegah enum invalid masuk ke struktur data.

Kode Lengkap #

Kode lengkap untuk storage.h dan storage.c ada di direktori proyek. Fungsi strip_newline dan parse_line dibuat static.

Pengujian #

Edge case yang harus dipastikan:

  • File dengan baris komentar # di tengah → dilewati.
  • File dengan baris kosong → dilewati.
  • File dengan baris rusak (kurang kolom) → dilewati, transaksi lain tetap dimuat.
  • File dengan id tidak berurutan (1, 5, 8) → next_id = 9.
  • File dengan type invalid (mis. 2) → baris tersebut dilewati.
  • File tidak ada → storage_load mengembalikan -1 (program tetap jalan dengan Finance kosong).
bash
gcc -std=c11 -Wall -Wextra -o /tmp/demo_storage_finance \
    demo_storage_finance.c storage.c finance.c transaction.c -lm
/tmp/demo_storage_finance