feat(gl): add immutable postgres ledger storage

This commit is contained in:
2026-08-14 19:00:45 +03:30
parent b1e0b01598
commit 0b3eaa0c3d
14 changed files with 1139 additions and 1 deletions
+83
View File
@@ -0,0 +1,83 @@
// Package postgres provides GL's PostgreSQL adapters.
package postgres
import (
"context"
"fmt"
"net/url"
"strconv"
"gl/infrastructure/config"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type Row interface {
Scan(dest ...any) error
}
type Tx interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
QueryRow(context.Context, string, ...any) Row
Commit(context.Context) error
Rollback(context.Context) error
}
type Database interface {
Begin(context.Context) (Tx, error)
QueryRow(context.Context, string, ...any) Row
Ping(context.Context) error
Close()
}
type Pool struct {
pool *pgxpool.Pool
}
func Open(ctx context.Context, cfg config.DatabaseConfig) (*Pool, error) {
connectionURL := &url.URL{
Scheme: "postgres",
User: url.UserPassword(cfg.User, cfg.Password),
Host: cfg.Host + ":" + strconv.Itoa(cfg.Port),
Path: cfg.Name,
}
query := connectionURL.Query()
query.Set("sslmode", cfg.SSLMode)
connectionURL.RawQuery = query.Encode()
pool, err := pgxpool.New(ctx, connectionURL.String())
if err != nil {
return nil, fmt.Errorf("create postgres pool: %w", err)
}
return &Pool{pool: pool}, nil
}
func (p *Pool) Begin(ctx context.Context) (Tx, error) {
tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, err
}
return txAdapter{Tx: tx}, nil
}
func (p *Pool) Ping(ctx context.Context) error {
return p.pool.Ping(ctx)
}
func (p *Pool) QueryRow(ctx context.Context, sql string, args ...any) Row {
return p.pool.QueryRow(ctx, sql, args...)
}
func (p *Pool) Close() {
p.pool.Close()
}
type txAdapter struct {
pgx.Tx
}
func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row {
return t.Tx.QueryRow(ctx, sql, args...)
}
@@ -0,0 +1,179 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"gl/domain/ledger"
"github.com/jackc/pgx/v5"
)
var (
ErrIdempotencyConflict = errors.New("idempotency key belongs to a different payload")
ErrIncompleteJournal = errors.New("idempotency key belongs to an unsealed journal")
)
type AppendResult struct {
JournalID string
AlreadyExists bool
}
type JournalRepository struct {
database Database
}
func NewJournalRepository(database Database) *JournalRepository {
return &JournalRepository{database: database}
}
func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal) (result AppendResult, err error) {
if err := journal.Validate(); err != nil {
return AppendResult{}, fmt.Errorf("validate journal: %w", err)
}
metadataValues := journal.Metadata
if metadataValues == nil {
metadataValues = map[string]string{}
}
metadata, err := json.Marshal(metadataValues)
if err != nil {
return AppendResult{}, fmt.Errorf("encode metadata: %w", err)
}
tx, err := r.database.Begin(ctx)
if err != nil {
return AppendResult{}, fmt.Errorf("begin journal append: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback(ctx)
}
}()
var insertedID string
err = tx.QueryRow(ctx, insertJournalSQL,
journal.ID,
journal.SourceService,
journal.IdempotencyKey,
journal.SourceTransactionID,
journal.TrackingCode,
journal.EffectKind,
journal.EventVersion,
nullIfEmpty(journal.ReversalOfJournalID),
journal.OccurredAt,
journal.CorrelationID,
journal.ActorID,
journal.Blockchain.Network,
journal.Blockchain.TransactionHash,
journal.Blockchain.LedgerSequence,
metadata,
journal.PayloadHash,
).Scan(&insertedID)
if errors.Is(err, pgx.ErrNoRows) {
_ = tx.Rollback(ctx)
return r.resolveDuplicate(ctx, journal)
}
if err != nil {
return AppendResult{}, fmt.Errorf("insert journal: %w", err)
}
for _, entry := range journal.Entries {
var accountID int64
err = tx.QueryRow(ctx, insertAccountSQL,
entry.Account.Class,
entry.Account.OwnerType,
entry.Account.OwnerID,
entry.Account.AssetID,
).Scan(&accountID)
if errors.Is(err, pgx.ErrNoRows) {
err = tx.QueryRow(ctx, selectAccountSQL,
entry.Account.Class,
entry.Account.OwnerType,
entry.Account.OwnerID,
entry.Account.AssetID,
).Scan(&accountID)
}
if err != nil {
return AppendResult{}, fmt.Errorf("resolve entry %d account: %w", entry.LineNumber, err)
}
if _, err = tx.Exec(ctx, insertEntrySQL,
journal.ID,
entry.LineNumber,
accountID,
entry.Account.AssetID,
entry.Amount.String(),
entry.Description,
); err != nil {
return AppendResult{}, fmt.Errorf("insert entry %d: %w", entry.LineNumber, err)
}
}
if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil {
return AppendResult{}, fmt.Errorf("seal journal: %w", err)
}
if err = tx.Commit(ctx); err != nil {
return AppendResult{}, fmt.Errorf("commit journal: %w", err)
}
return AppendResult{JournalID: insertedID}, nil
}
func (r *JournalRepository) resolveDuplicate(ctx context.Context, journal ledger.Journal) (AppendResult, error) {
var (
journalID string
payloadHash string
sealed bool
)
err := r.database.QueryRow(ctx, selectIdempotencySQL, journal.IdempotencyKey).Scan(&journalID, &payloadHash, &sealed)
if err != nil {
return AppendResult{}, fmt.Errorf("read idempotent journal: %w", err)
}
if payloadHash != journal.PayloadHash {
return AppendResult{}, ErrIdempotencyConflict
}
if !sealed {
return AppendResult{}, ErrIncompleteJournal
}
return AppendResult{JournalID: journalID, AlreadyExists: true}, nil
}
func nullIfEmpty(value string) any {
if value == "" {
return nil
}
return value
}
const insertJournalSQL = `
INSERT INTO journals (
id, source_service, idempotency_key, source_transaction_id, tracking_code,
effect_kind, event_version, reversal_of_journal_id, occurred_at,
correlation_id, actor_id, blockchain_network, blockchain_transaction_hash,
blockchain_ledger_sequence, metadata, payload_hash
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id`
const insertAccountSQL = `
INSERT INTO ledger_accounts (class, owner_type, owner_id, asset_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (class, owner_type, owner_id, asset_id) DO NOTHING
RETURNING id`
const selectAccountSQL = `
SELECT id FROM ledger_accounts
WHERE class = $1 AND owner_type = $2 AND owner_id = $3 AND asset_id = $4`
const insertEntrySQL = `
INSERT INTO journal_entries (
journal_id, line_number, account_id, asset_id, amount, description
) VALUES ($1, $2, $3, $4, $5, $6)`
const selectIdempotencySQL = `
SELECT id, payload_hash, sealed_at IS NOT NULL
FROM journals
WHERE idempotency_key = $1`
@@ -0,0 +1,213 @@
package postgres
import (
"context"
"errors"
"strings"
"testing"
"time"
"gl/domain/ledger"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type fakeRow struct {
values []any
err error
}
func (r fakeRow) Scan(dest ...any) error {
if r.err != nil {
return r.err
}
if len(dest) != len(r.values) {
return errors.New("unexpected scan width")
}
for index, value := range r.values {
switch target := dest[index].(type) {
case *string:
*target = value.(string)
case *int64:
*target = value.(int64)
case *bool:
*target = value.(bool)
default:
return errors.New("unsupported scan target")
}
}
return nil
}
type fakeTx struct {
rows []Row
execCount int
execErrAt int
committed bool
rolledBack bool
}
func (t *fakeTx) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
t.execCount++
if t.execCount == t.execErrAt {
return pgconn.CommandTag{}, errors.New("exec failed")
}
return pgconn.NewCommandTag("INSERT 0 1"), nil
}
func (t *fakeTx) QueryRow(context.Context, string, ...any) Row {
row := t.rows[0]
t.rows = t.rows[1:]
return row
}
func (t *fakeTx) Commit(context.Context) error {
t.committed = true
return nil
}
func (t *fakeTx) Rollback(context.Context) error {
t.rolledBack = true
return nil
}
type fakeDatabase struct {
tx *fakeTx
directRow Row
beginCalled bool
}
func (d *fakeDatabase) Begin(context.Context) (Tx, error) {
d.beginCalled = true
return d.tx, nil
}
func (d *fakeDatabase) QueryRow(context.Context, string, ...any) Row { return d.directRow }
func (d *fakeDatabase) Ping(context.Context) error { return nil }
func (d *fakeDatabase) Close() {}
func TestJournalRepositoryAppendCommitsJournalEntriesAndSeal(t *testing.T) {
journal := repositoryJournal(t)
tx := &fakeTx{rows: []Row{
fakeRow{values: []any{journal.ID}},
fakeRow{values: []any{int64(10)}},
fakeRow{values: []any{int64(20)}},
}}
database := &fakeDatabase{tx: tx}
result, err := NewJournalRepository(database).Append(context.Background(), journal)
if err != nil {
t.Fatal(err)
}
if result.JournalID != journal.ID || result.AlreadyExists {
t.Fatalf("unexpected result: %+v", result)
}
if !tx.committed || tx.rolledBack {
t.Fatalf("unexpected transaction state: %+v", tx)
}
if tx.execCount != 3 {
t.Fatalf("expected two entry inserts and one seal, got %d execs", tx.execCount)
}
}
func TestJournalRepositoryReturnsExistingIdenticalJournal(t *testing.T) {
journal := repositoryJournal(t)
tx := &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}}
database := &fakeDatabase{
tx: tx,
directRow: fakeRow{values: []any{journal.ID, journal.PayloadHash, true}},
}
result, err := NewJournalRepository(database).Append(context.Background(), journal)
if err != nil {
t.Fatal(err)
}
if !result.AlreadyExists || result.JournalID != journal.ID {
t.Fatalf("unexpected result: %+v", result)
}
if !tx.rolledBack || tx.committed {
t.Fatalf("duplicate transaction was not rolled back: %+v", tx)
}
}
func TestJournalRepositoryRejectsConflictingIdempotencyPayload(t *testing.T) {
journal := repositoryJournal(t)
database := &fakeDatabase{
tx: &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}},
directRow: fakeRow{values: []any{journal.ID, strings.Repeat("b", 64), true}},
}
_, err := NewJournalRepository(database).Append(context.Background(), journal)
if !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("expected idempotency conflict, got %v", err)
}
}
func TestJournalRepositoryRollsBackEntryFailure(t *testing.T) {
journal := repositoryJournal(t)
tx := &fakeTx{
rows: []Row{
fakeRow{values: []any{journal.ID}},
fakeRow{values: []any{int64(10)}},
},
execErrAt: 1,
}
database := &fakeDatabase{tx: tx}
if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil {
t.Fatal("expected entry insert error")
}
if !tx.rolledBack || tx.committed {
t.Fatalf("failed append was not rolled back: %+v", tx)
}
}
func TestJournalRepositoryValidatesBeforeOpeningTransaction(t *testing.T) {
journal := repositoryJournal(t)
journal.Entries[1].Amount, _ = ledger.ParseAmount("9")
database := &fakeDatabase{tx: &fakeTx{}}
if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil {
t.Fatal("expected validation error")
}
if database.beginCalled {
t.Fatal("invalid journal opened a database transaction")
}
}
func repositoryJournal(t *testing.T) ledger.Journal {
t.Helper()
debit, err := ledger.ParseAmount("-10.25")
if err != nil {
t.Fatal(err)
}
return ledger.Journal{
ID: "11111111-1111-4111-8111-111111111111",
SourceService: "wallet",
IdempotencyKey: "wallet:1:internal-transfer:v1",
SourceTransactionID: "1",
TrackingCode: "track-1",
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: time.Unix(1, 0).UTC(),
PayloadHash: strings.Repeat("a", 64),
Metadata: map[string]string{"origin": "test"},
Entries: []ledger.Entry{
{
LineNumber: 1,
Account: ledger.AccountReference{
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5,
},
Amount: debit,
},
{
LineNumber: 2,
Account: ledger.AccountReference{
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5,
},
Amount: debit.Negate(),
},
},
}
}
+75
View File
@@ -0,0 +1,75 @@
package postgres
import (
"context"
"embed"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
)
//go:embed migrations/*.up.sql
var migrationFiles embed.FS
const migrationLockID int64 = 674301
func Migrate(ctx context.Context, database Database) (err error) {
tx, err := database.Begin(ctx)
if err != nil {
return fmt.Errorf("begin migrations: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback(ctx)
}
}()
if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLockID); err != nil {
return fmt.Errorf("lock migrations: %w", err)
}
if _, err = tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS ledger_schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
)`); err != nil {
return fmt.Errorf("create migration ledger: %w", err)
}
files, err := fs.Glob(migrationFiles, "migrations/*.up.sql")
if err != nil {
return fmt.Errorf("list migrations: %w", err)
}
sort.Strings(files)
for _, name := range files {
versionText := strings.SplitN(strings.TrimPrefix(name, "migrations/"), "_", 2)[0]
version, parseErr := strconv.ParseInt(versionText, 10, 64)
if parseErr != nil {
return fmt.Errorf("parse migration %s: %w", name, parseErr)
}
var applied bool
if err = tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM ledger_schema_migrations WHERE version = $1)", version).Scan(&applied); err != nil {
return fmt.Errorf("check migration %s: %w", name, err)
}
if applied {
continue
}
contents, readErr := migrationFiles.ReadFile(name)
if readErr != nil {
return fmt.Errorf("read migration %s: %w", name, readErr)
}
if _, err = tx.Exec(ctx, string(contents)); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err = tx.Exec(ctx, "INSERT INTO ledger_schema_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", version); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
}
if err = tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migrations: %w", err)
}
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package postgres
import (
"strings"
"testing"
)
func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) {
contents, err := migrationFiles.ReadFile("migrations/000001_init.up.sql")
if err != nil {
t.Fatal(err)
}
schema := string(contents)
for _, required := range []string{
"amount numeric(38, 18)",
"idempotency_key text NOT NULL UNIQUE",
"guard_journal_seal",
"journal is not balanced per asset",
"cannot append to a sealed journal",
"reject_ledger_mutation",
} {
if !strings.Contains(schema, required) {
t.Fatalf("migration is missing %q", required)
}
}
}
@@ -0,0 +1,14 @@
DROP TRIGGER IF EXISTS transaction_events_reject_update_or_delete ON transaction_events;
DROP TRIGGER IF EXISTS accounts_reject_update_or_delete ON ledger_accounts;
DROP TRIGGER IF EXISTS entries_reject_update_or_delete ON journal_entries;
DROP TRIGGER IF EXISTS entries_guard_insert ON journal_entries;
DROP TRIGGER IF EXISTS journals_reject_delete ON journals;
DROP TRIGGER IF EXISTS journals_guard_update ON journals;
DROP FUNCTION IF EXISTS guard_entry_insert();
DROP FUNCTION IF EXISTS guard_journal_seal();
DROP FUNCTION IF EXISTS reject_ledger_mutation();
DROP TABLE IF EXISTS transaction_events;
DROP TABLE IF EXISTS journal_entries;
DROP TABLE IF EXISTS journals;
DROP TABLE IF EXISTS ledger_accounts;
DROP TABLE IF EXISTS ledger_schema_migrations;
@@ -0,0 +1,172 @@
CREATE TABLE IF NOT EXISTS ledger_schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE ledger_accounts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
class text NOT NULL CHECK (class IN (
'USER_AVAILABLE',
'USER_FROZEN',
'EXTERNAL_BLOCKCHAIN',
'TREASURY',
'MARKET_CLEARING',
'IPG_CLEARING',
'COMMISSION_REVENUE'
)),
owner_type text NOT NULL DEFAULT '',
owner_id text NOT NULL DEFAULT '',
asset_id bigint NOT NULL CHECK (asset_id > 0),
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
UNIQUE (class, owner_type, owner_id, asset_id),
UNIQUE (id, asset_id),
CHECK (
class NOT IN ('USER_AVAILABLE', 'USER_FROZEN')
OR (owner_type <> '' AND owner_id <> '')
)
);
CREATE TABLE journals (
id uuid PRIMARY KEY,
source_service text NOT NULL CHECK (source_service <> ''),
idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''),
source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''),
tracking_code text NOT NULL DEFAULT '',
effect_kind text NOT NULL CHECK (effect_kind <> ''),
event_version integer NOT NULL CHECK (event_version > 0),
reversal_of_journal_id uuid REFERENCES journals (id),
occurred_at timestamptz NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
sealed_at timestamptz,
correlation_id text NOT NULL DEFAULT '',
actor_id text NOT NULL DEFAULT '',
blockchain_network text NOT NULL DEFAULT '',
blockchain_transaction_hash text NOT NULL DEFAULT '',
blockchain_ledger_sequence text NOT NULL DEFAULT '',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'),
CHECK (reversal_of_journal_id IS NULL OR reversal_of_journal_id <> id)
);
CREATE INDEX journals_source_transaction_idx
ON journals (source_service, source_transaction_id);
CREATE INDEX journals_recorded_at_idx ON journals (recorded_at, id);
CREATE TABLE journal_entries (
journal_id uuid NOT NULL REFERENCES journals (id),
line_number integer NOT NULL CHECK (line_number > 0),
account_id bigint NOT NULL,
asset_id bigint NOT NULL CHECK (asset_id > 0),
amount numeric(38, 18) NOT NULL CHECK (amount <> 0),
description text NOT NULL DEFAULT '',
PRIMARY KEY (journal_id, line_number),
FOREIGN KEY (account_id, asset_id) REFERENCES ledger_accounts (id, asset_id)
);
CREATE INDEX journal_entries_account_idx
ON journal_entries (account_id, journal_id, line_number);
CREATE TABLE transaction_events (
id uuid PRIMARY KEY,
source_service text NOT NULL CHECK (source_service <> ''),
idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''),
source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''),
tracking_code text NOT NULL DEFAULT '',
event_version integer NOT NULL CHECK (event_version > 0),
state text NOT NULL CHECK (state IN (
'CREATED',
'PENDING_TRANSACTION',
'PENDING_ADMIN',
'SUCCESSFUL',
'FAILED',
'SUSPENDED'
)),
error_code text NOT NULL DEFAULT '',
error_message text NOT NULL DEFAULT '',
occurred_at timestamptz NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
correlation_id text NOT NULL DEFAULT '',
actor_id text NOT NULL DEFAULT '',
blockchain_network text NOT NULL DEFAULT '',
blockchain_transaction_hash text NOT NULL DEFAULT '',
blockchain_ledger_sequence text NOT NULL DEFAULT '',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$')
);
CREATE INDEX transaction_events_source_idx
ON transaction_events (source_service, source_transaction_id, event_version);
CREATE FUNCTION reject_ledger_mutation() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION '% is append-only', TG_TABLE_NAME
USING ERRCODE = '55000';
END;
$$;
CREATE FUNCTION guard_journal_seal() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF OLD.sealed_at IS NOT NULL
OR NEW.sealed_at IS NULL
OR (to_jsonb(NEW) - 'sealed_at') IS DISTINCT FROM (to_jsonb(OLD) - 'sealed_at') THEN
RAISE EXCEPTION 'journals are immutable except for initial sealing'
USING ERRCODE = '55000';
END IF;
IF (SELECT count(*) FROM journal_entries WHERE journal_id = NEW.id) < 2 THEN
RAISE EXCEPTION 'journal requires at least two entries'
USING ERRCODE = '23514';
END IF;
IF EXISTS (
SELECT asset_id
FROM journal_entries
WHERE journal_id = NEW.id
GROUP BY asset_id
HAVING sum(amount) <> 0
) THEN
RAISE EXCEPTION 'journal is not balanced per asset'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION guard_entry_insert() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF (SELECT sealed_at IS NOT NULL FROM journals WHERE id = NEW.journal_id) THEN
RAISE EXCEPTION 'cannot append to a sealed journal'
USING ERRCODE = '55000';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER journals_guard_update
BEFORE UPDATE ON journals
FOR EACH ROW EXECUTE FUNCTION guard_journal_seal();
CREATE TRIGGER journals_reject_delete
BEFORE DELETE ON journals
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER entries_guard_insert
BEFORE INSERT ON journal_entries
FOR EACH ROW EXECUTE FUNCTION guard_entry_insert();
CREATE TRIGGER entries_reject_update_or_delete
BEFORE UPDATE OR DELETE ON journal_entries
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER accounts_reject_update_or_delete
BEFORE UPDATE OR DELETE ON ledger_accounts
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER transaction_events_reject_update_or_delete
BEFORE UPDATE OR DELETE ON transaction_events
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();