General Ledger ERP: Merancang Inti Double-Entry

Foto oleh Keith Williamson on flickr
Double-entry bookkeeping mencatat setiap transaksi sebagai minimal dua baris line, satu debit dan satu kredit, yang jumlahnya harus nol. General ledger ERP membutuhkan ini karena hanya cara ini yang dapat menjamin secara matematis bahwa inti akuntansi tetap konsisten secara internal, tidak peduli berapa banyak modul yang menulis ke dalamnya, mulai dari sales hingga payroll dan inventory.
Check constraint biasa tidak bisa melihat baris lain, sehingga tidak dapat memastikan sendiri bahwa seluruh line pada satu journal entry berjumlah nol. Pendekatan yang andal adalah constraint trigger yang dideklarasikan deferrable dan initially deferred, sehingga aplikasi dapat memasukkan header beserta seluruh baris line-nya dalam satu transaksi dan pemeriksaan saldo hanya dijalankan sekali, tepat sebelum commit.
Lima jenis akun, yaitu aset, liabilitas, ekuitas, pendapatan, dan beban, menentukan apakah debit menambah atau mengurangi saldo suatu akun, karena aset dan beban bertambah dengan debit sementara liabilitas, ekuitas, dan pendapatan bertambah dengan kredit. Tanpa klasifikasi tersebut pada chart of accounts, laporan seperti balance sheet atau income statement tidak dapat dihasilkan dengan benar dari baris journal mentah.
Tidak, entry pada periode yang sudah tertutup sebaiknya diperlakukan sebagai imutabel, bukan diedit atau dihapus langsung. Koreksi seharusnya berupa entry pembalik baru yang bertanggal di periode yang masih terbuka, sehingga jejak audit tetap lengkap dan setiap trial balance yang pernah dilaporkan tetap dapat direproduksi persis seperti saat pertama diajukan.
Trial balance yang benar selalu berjumlah nol di seluruh entry yang sudah diposting dalam satu periode, karena setiap entry yang melewati jalur insert normal sudah diseimbangkan satu per satu oleh constraint trigger. Hasil yang tidak nol hampir selalu berarti ada sesuatu yang melewati trigger itu, biasanya berupa bulk import data atau perbaikan manual, dan sebaiknya diselidiki sebagai insiden integritas data, bukan dianggap sekadar selisih pembulatan.

Foto oleh Keith Williamson on flickr
Setiap modul ERP pada akhirnya berujung ke tempat yang sama: general ledger. Sales order, invoice pembelian, proses payroll, dan pergerakan inventory semuanya bermuara menjadi baris data di satu inti akuntansi yang sama, dan jika inti itu salah, seluruh laporan turunannya juga ikut salah. Dashboard, pelaporan pajak, dan update untuk investor semuanya hanyalah tampilan berbeda dari journal yang sama, sehingga skema di baliknya tidak boleh dianggap sebagai urusan sampingan yang ditambahkan setelah sisa ERP selesai dibangun.
Tulisan ini membahas cara merancang inti tersebut dari nol di PostgreSQL: chart of accounts, aturan double-entry yang menjaga setiap transaksi tetap seimbang, periode fiskal beserta penutupan periode, dan query trial balance yang membuktikan seluruh sistem benar-benar berjumlah nol. Tujuannya sepanjang tulisan ini adalah mendorong sebanyak mungkin kebenaran logika turun ke level database itu sendiri, lewat constraint dan trigger, agar bug di kode aplikasi salah satu modul tidak bisa diam-diam merusak ledger bersama yang menjadi tumpuan modul lainnya.
Akuntansi double-entry berpijak pada gagasan sederhana, yang pertama kali dirumuskan berabad-abad lalu dan masih menjadi tulang punggung setiap sistem akuntansi modern: setiap transaksi menyentuh minimal dua akun, satu didebit dan satu dikredit, dan kedua sisi itu selalu berjumlah nol. Sebelum menulis logika transaksi apa pun, sebuah ERP membutuhkan chart of accounts yang mengklasifikasikan setiap akun ke dalam salah satu dari lima jenis, karena klasifikasi itu menentukan apakah debit menambah atau mengurangi saldo akun tersebut. Jika klasifikasi ini salah, setiap laporan keuangan yang dihasilkan dari ledger, baik balance sheet, income statement, maupun cash flow, akan diam-diam menggambarkan bisnis secara keliru meskipun setiap entry secara individu tetap seimbang.
| Jenis akun | Saldo normal | Bertambah oleh |
|---|---|---|
| Aset | Debit | Debit |
| Liabilitas | Kredit | Kredit |
| Ekuitas | Kredit | Kredit |
| Pendapatan | Kredit | Kredit |
| Beban | Debit | Debit |
Chart of accounts adalah tabel hierarkis: akun header mengelompokkan akun yang bisa diposting untuk keperluan pelaporan, sehingga skemanya membutuhkan kolom parent yang merujuk ke dirinya sendiri serta sebuah flag yang menandai apakah suatu akun benar-benar boleh menerima posting. Periode fiskal mendapat tabel tersendiri agar ledger dapat melacak bulan mana yang masih terbuka untuk posting normal, mana yang sudah ditutup untuk entri biasa namun masih terbuka untuk penyesuaian, dan mana yang sudah terkunci penuh setelah audit. Menyimpan mata uang pada akun itu sendiri, bukan mengasumsikan satu mata uang tunggal untuk seluruh perusahaan, juga penting sejak awal: ERP multi entitas pada akhirnya akan membutuhkan akun dalam lebih dari satu mata uang, dan menambahkannya belakangan berarti harus menyentuh ulang setiap query posting yang sudah terlanjur ditulis terhadap tabel tersebut.
-- chart_of_accounts: hierarchical, one row per GL account
CREATE TABLE chart_of_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE, -- e.g. '1100', '2100', '4000'
name TEXT NOT NULL,
account_type TEXT NOT NULL, -- ASSET|LIABILITY|EQUITY|REVENUE|EXPENSE
parent_id UUID REFERENCES chart_of_accounts(id),
is_postable BOOLEAN NOT NULL DEFAULT true, -- false for header/summary accounts
currency CHAR(3) NOT NULL DEFAULT 'IDR',
active BOOLEAN NOT NULL DEFAULT true,
CHECK (account_type IN ('ASSET','LIABILITY','EQUITY','REVENUE','EXPENSE'))
);
-- fiscal_periods: one row per open/closed accounting period
CREATE TABLE fiscal_periods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
period_code TEXT NOT NULL UNIQUE, -- e.g. '2026-03'
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'OPEN', -- OPEN|CLOSED|LOCKED
CHECK (status IN ('OPEN','CLOSED','LOCKED'))
);Berikan setiap akun kode numerik yang stabil, bukan hanya nama, dan urutkan berdasarkan kode di mana pun. Mengubah nama akun tidak boleh pernah merusak laporan, dan tim finance sudah terbiasa membaca rentang akun berdasarkan angka jauh sebelum mereka membaca label di sampingnya.
Journal entry adalah unit atomik dari sistem akuntansi: satu baris header ditambah dua atau lebih baris line, di mana setiap line hanya boleh berupa debit atau kredit, tidak boleh keduanya sekaligus, dan jumlah seluruh baris debit pada satu entry harus sama dengan jumlah seluruh baris kredit. Memodelkan ini dengan benar di skema berarti menuangkan tiga aturan langsung sebagai constraint database, bukan mempercayakan sepenuhnya pada kode aplikasi untuk menjaganya benar di setiap jalur kode. Modul apa pun yang memposting ke ledger, entah layanan invoicing, job payroll, atau layar penyesuaian manual, pada akhirnya melewati tabel journal_entries dan journal_lines yang persis sama, dan itulah yang membuat ledger menjadi satu sumber kebenaran, bukan lima implementasi berbeda dari ide yang sama.
-- journal_entries: one row per transaction header
CREATE TABLE journal_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entry_number TEXT NOT NULL UNIQUE,
period_id UUID NOT NULL REFERENCES fiscal_periods(id),
entry_date DATE NOT NULL,
memo TEXT,
posted BOOLEAN NOT NULL DEFAULT false,
posted_at TIMESTAMPTZ,
created_by UUID NOT NULL REFERENCES employees(id)
);
-- journal_lines: two or more rows per entry, debit XOR credit
CREATE TABLE journal_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entry_id UUID NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES chart_of_accounts(id),
debit NUMERIC(18,2) NOT NULL DEFAULT 0,
credit NUMERIC(18,2) NOT NULL DEFAULT 0,
line_memo TEXT,
CHECK (debit >= 0 AND credit >= 0),
CHECK ((debit > 0 AND credit = 0) OR (debit = 0 AND credit > 0))
);
-- Trigger: reject a posted entry whose lines do not sum to zero
CREATE OR REPLACE FUNCTION assert_entry_balanced() RETURNS trigger AS $$
DECLARE
imbalance NUMERIC(18,2);
BEGIN
SELECT COALESCE(SUM(debit) - SUM(credit), 0) INTO imbalance
FROM journal_lines WHERE entry_id = NEW.id;
IF NEW.posted AND imbalance <> 0 THEN
RAISE EXCEPTION 'journal entry % is not balanced (diff=%)', NEW.id, imbalance;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER trg_entry_balanced
AFTER INSERT OR UPDATE ON journal_entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION assert_entry_balanced();Check constraint biasa tidak dapat melihat baris lain, sehingga tidak mampu memverifikasi bahwa baris-baris sebuah entry berjumlah nol dengan sendirinya. PostgreSQL mengevaluasi check constraint per baris, secara langsung, tanpa cara untuk menunda pemeriksaannya melewati satu statement. Validasi keseimbangan lintas baris line milik satu entry harus ditempatkan pada constraint trigger, dan trigger itu sebaiknya bersifat deferrable agar sebuah insert multi statement dapat selesai dulu sebelum pemeriksaan saldo dijalankan.
Karena PostgreSQL tidak dapat menunda check constraint melewati satu baris, cara standar untuk menegakkan invarian lintas baris seperti entry yang seimbang adalah constraint trigger yang dideklarasikan DEFERRABLE INITIALLY DEFERRED. Ini memungkinkan aplikasi memasukkan satu header beserta seluruh baris line-nya dalam satu transaksi, lalu database baru memverifikasi jumlahnya sekali saja, tepat sebelum commit, bukan gagal di tengah proses insert. Membuat trigger ini hanya berjalan saat posting, bukan setiap kali sebuah line dimasukkan, juga menjaga draft tetap bisa dipakai: entry yang belum diposting boleh berada dalam kondisi setengah jadi, diedit, bahkan dihapus sepenuhnya, tanpa pernah memicu pemeriksaan saldo yang seharusnya hanya berlaku untuk entry yang akan menjadi permanen.
Trial balance adalah pemeriksaan klasik dari setiap sistem akuntansi: daftar setiap akun beserta total debit dan total kreditnya untuk satu periode, dan jika setiap entry yang menyentuh periode itu benar-benar seimbang, total besar seluruh debit di semua akun harus sama dengan total besar seluruh kredit. Menjalankan ini sebagai satu query agregat tunggal atas entry yang sudah diposting dalam satu periode sekaligus menjadi artefak pelaporan yang diharapkan akuntan dan pemeriksaan integritas langsung terhadap ledger itu sendiri. Menyaring query dengan e.posted membuat draft yang belum selesai tidak ikut masuk ke laporan sama sekali, dan ini penting karena entry berstatus draft, menurut definisinya, memang boleh sementara tidak seimbang selama masih diedit oleh seseorang.
-- Trial balance: every account's debit/credit total for a period
SELECT
a.code,
a.name,
SUM(l.debit) AS total_debit,
SUM(l.credit) AS total_credit,
SUM(l.debit) - SUM(l.credit) AS net_balance
FROM chart_of_accounts a
JOIN journal_lines l ON l.account_id = a.id
JOIN journal_entries e ON e.id = l.entry_id AND e.posted
WHERE e.period_id = $1
GROUP BY a.code, a.name
ORDER BY a.code;
-- Sanity check: grand total of a correct trial balance is always zero
SELECT SUM(total_debit) - SUM(total_credit) AS should_be_zero
FROM (
SELECT SUM(l.debit) AS total_debit, SUM(l.credit) AS total_credit
FROM journal_lines l
JOIN journal_entries e ON e.id = l.entry_id AND e.posted
WHERE e.period_id = $1
) sub;Jika query trial balance itu pernah mengembalikan nilai selain nol, berarti ada sesuatu yang melewati constraint trigger, kemungkinan besar berupa bulk import atau perbaikan data manual yang melewati jalur insert normal. Perlakukan trial balance yang tidak nol sebagai insiden produksi, bukan sekadar keanehan pembulatan.
Setelah periode fiskal ditutup, entry yang sudah diposting di dalamnya seharusnya menjadi praktis imutabel. Daripada mengizinkan update atau delete pada baris periode yang tertutup, ledger ERP sebaiknya mewajibkan koreksi berupa entry pembalik baru yang bertanggal di periode yang masih terbuka, sehingga jejak audit tetap lengkap dan append only, dan setiap trial balance historis tetap dapat direproduksi persis seperti saat pertama dilaporkan. Di sinilah desain ini benar-benar terbayar di luar sekadar kebenaran pembukuan: auditor, otoritas pajak, dan engineer di masa depan yang sedang menelusuri selisih data semuanya mengandalkan kemampuan untuk memutar ulang persis apa yang diposting dan kapan, tanpa perlu bertanya-tanya apakah ada baris yang diam-diam diubah belakangan.