{"id":48446190,"url":"https://github.com/pacemakerx/ledger-core","last_synced_at":"2026-04-06T18:01:42.090Z","repository":{"id":346759856,"uuid":"1176707436","full_name":"PacemakerX/ledger-core","owner":"PacemakerX","description":"Production-grade double-entry accounting ledger system. Go, PostgreSQL, Docker. ","archived":false,"fork":false,"pushed_at":"2026-04-02T19:17:17.000Z","size":437,"stargazers_count":3,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-02T20:09:55.364Z","etag":null,"topics":["docker","docker-compose","double-entry-accounting","golang","golang-migrate","mvcc","productionready","uuidv7","zap-logger"],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/PacemakerX.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-09T09:44:22.000Z","updated_at":"2026-04-02T19:17:22.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/PacemakerX/ledger-core","commit_stats":null,"previous_names":["pacemakerx/ledger-core"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/PacemakerX/ledger-core","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/PacemakerX%2Fledger-core","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/PacemakerX%2Fledger-core/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/PacemakerX%2Fledger-core/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/PacemakerX%2Fledger-core/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/PacemakerX","download_url":"https://codeload.github.com/PacemakerX/ledger-core/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/PacemakerX%2Fledger-core/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31483380,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-06T17:22:55.647Z","status":"ssl_error","status_checked_at":"2026-04-06T17:22:54.741Z","response_time":112,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["docker","docker-compose","double-entry-accounting","golang","golang-migrate","mvcc","productionready","uuidv7","zap-logger"],"created_at":"2026-04-06T18:01:41.049Z","updated_at":"2026-04-06T18:01:42.081Z","avatar_url":"https://github.com/PacemakerX.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# ledger-core\n\nA production-grade double-entry accounting ledger built in Go and PostgreSQL.\nHandles concurrent transfers with full ACID guarantees, idempotency, partial refunds, and real-time observability.\n\n[![Go](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat\u0026logo=go)](https://golang.org/)\n[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-18-336791?style=flat\u0026logo=postgresql)](https://postgresql.org/)\n[![API Docs](https://img.shields.io/badge/Swagger-OpenAPI-85EA2D?style=flat\u0026logo=swagger)](https://swagger.io/)\n[![Metrics](https://img.shields.io/badge/Prometheus-Metrics-E6522C?style=flat\u0026logo=prometheus)](https://prometheus.io/)\n[![Dashboards](https://img.shields.io/badge/Grafana-Visualization-F46800?style=flat\u0026logo=grafana)](https://grafana.com/)\n[![Error Tracking](https://img.shields.io/badge/Sentry-Monitoring-362D59?style=flat\u0026logo=sentry)](https://sentry.io/)\n[![Load Testing](https://img.shields.io/badge/k6-Performance-7D64FF?style=flat\u0026logo=k6)](https://k6.io/)\n[![Logging](https://img.shields.io/badge/Zap-Structured%20Logging-black?style=flat)]()\n[![Architecture](https://img.shields.io/badge/Architecture-Hexagonal%20%2B%20ACID-lightgrey?style=flat)]()\n[![System](https://img.shields.io/badge/System-Double--Entry%20Ledger-blue?style=flat)]()\n[![Domain](https://img.shields.io/badge/Domain-FinTech-0A66C2?style=flat)]()\n[![Idempotency](https://img.shields.io/badge/Idempotency-Exactly--Once-important?style=flat)]()\n[![Concurrency](https://img.shields.io/badge/Concurrency-Row%20Level%20Locks-critical?style=flat)]()\n[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)\n\n## What is ledger-core?\n\nledger-core implements the same core accounting principles used by Razorpay, Stripe, and Zerodha — double-entry bookkeeping where every financial movement creates balanced journal entries that are mathematically verifiable.\n\nEvery rupee that moves through the system is tracked across four journal entries. The ledger is always consistent. Bugs in financial logic are caught before they commit.\n\n\n## Architecture\n\n```bash\nHTTP Request\n     ↓\nHandler         — parses JSON, maps errors to HTTP status codes\n     ↓\nService         — business logic, orchestrates repositories\n     ↓\nInterfaces      — ports (hexagonal architecture)\n     ↓\nPostgres        — adapters, all SQL lives here\n```\n\n**Hexagonal architecture** — the service layer has zero knowledge of PostgreSQL.\nRepositories can be swapped (MySQL, SQLite) without touching business logic.\n\n## Project Structure\n\n```bash\n.\n├── cmd\n│   └── server\n│       └── main.go\n├── config\n│   └── config.go\n├── docs\n│   ├── adr\n│   │   ├── 000-template.md\n│   │   ├── 001-why-go.md\n│   │   ├── 002-why-postgreSQL.md\n│   │   ├── 003-why-pgx.md\n│   │   ├── 004-why-chi.md\n│   │   ├── 005-why-zap.md\n│   │   ├── 006-why-uuid.md\n│   │   ├── 007-why-golang-migrate-over-gorm-automigrate.md\n│   │   ├── 008-why-double-entry-accounting.md\n│   │   └── 009-original-transaction-id.md\n│   ├── images\n│   │   ├── grafana.jpg\n│   │   └── load_test.png\n│   ├── docs.go\n│   ├── swagger.json\n│   └── swagger.yaml\n├── internal\n│   ├── db\n│   │   └── postgres.go\n│   ├── errors\n│   │   ├── errors.go\n│   │   └── response.go\n│   ├── handler\n│   │   ├── account.go\n│   │   ├── customer.go\n│   │   ├── health.go\n│   │   ├── refund.go\n│   │   ├── statement.go\n│   │   ├── transaction.go\n│   │   ├── transfer.go\n│   │   └── validate.go\n│   ├── metrics\n│   │   └── metrics.go\n│   ├── middleware\n│   │   ├── logger.go\n│   │   └── metrics_middleware.go\n│   ├── models\n│   │   ├── account.go\n│   │   ├── account_limit.go\n│   │   ├── account_type.go\n│   │   ├── audit_log.go\n│   │   ├── country.go\n│   │   ├── currency.go\n│   │   ├── customer.go\n│   │   ├── exchange_rate.go\n│   │   ├── idempotency_key.go\n│   │   ├── journal_entry.go\n│   │   └── transaction.go\n│   ├── repository\n│   │   ├── postgres\n│   │   │   ├── account_limit_repository.go\n│   │   │   ├── account_repository.go\n│   │   │   ├── account_type_repository.go\n│   │   │   ├── country_repository.go\n│   │   │   ├── currency_repository.go\n│   │   │   ├── customer_repository.go\n│   │   │   ├── idempotency_repository.go\n│   │   │   ├── journal_entry_repository.go\n│   │   │   ├── transaction_repository.go\n│   │   │   └── tx_manager.go\n│   │   └── interfaces.go\n│   └── service\n│       ├── account.go\n│       ├── customer.go\n│       ├── refund.go\n│       ├── statement.go\n│       ├── transaction.go\n│       └── transfer.go\n├── migrations\n│   ├── 000001_create_currencies.down.sql\n│   ├── 000001_create_currencies.up.sql\n│   ├── 000002_create_exchange_rates.down.sql\n│   ├── 000002_create_exchange_rates.up.sql\n│   ├── 000003_create_account_types.down.sql\n│   ├── 000003_create_account_types.up.sql\n│   ├── 000004_create_countries.down.sql\n│   ├── 000004_create_countries.up.sql\n│   ├── 000005_create_customers.down.sql\n│   ├── 000005_create_customers.up.sql\n│   ├── 000006_create_accounts.down.sql\n│   ├── 000006_create_accounts.up.sql\n│   ├── 000007_create_transactions.down.sql\n│   ├── 000007_create_transactions.up.sql\n│   ├── 000008_create_journal_entries.down.sql\n│   ├── 000008_create_journal_entries.up.sql\n│   ├── 000009_create_idempotency_keys.down.sql\n│   ├── 000009_create_idempotency_keys.up.sql\n│   ├── 000010_create_audit_logs.down.sql\n│   ├── 000010_create_audit_logs.up.sql\n│   ├── 000011_create_account_limits.down.sql\n│   ├── 000011_create_account_limits.up.sql\n│   ├── 000012_create_indexes.down.sql\n│   ├── 000012_create_indexes.up.sql\n│   ├── 000013_seed_platform_accounts.up.sql\n│   ├── 000014_fix_idempotency_keys.down.sql\n│   ├── 000014_fix_idempotency_keys.up.sql\n│   ├── 000015_add_account_ids_to_transactions.down.sql\n│   ├── 000015_add_account_ids_to_transactions.up.sql\n│   ├── 000016_add_amount_to_transactions.down.sql\n│   ├── 000016_add_amount_to_transactions.up.sql\n│   ├── 000017_add_original_transaction_id.down.sql\n│   └── 000017_add_original_transaction_id.up.sql\n├── scripts\n│   └── loadtest\n│       └── k6.js\n├── docker-compose.yml\n├── Dockerfile\n├── go.mod\n├── go.sum\n├── LICENSE\n├── Makefile\n├── prometheus.yml\n└── README.md\n\n20 directories, 101 files\n\n```\n\n## How a Transfer Works\n\nEvery transfer executes these steps atomically inside a single database transaction:\n\n1. Check idempotency key — return cached response if duplicate request\n2. Fetch sender account — verify it exists and is active\n3. Fetch receiver account — verify it exists and is active\n4. Verify KYC status for both customers\n5. Check sender account limits — DAILY, MONTHLY, YEARLY, TRANSACTION\n6. Verify sufficient balance (derived from journal entries via `SUM`, never stored as a column)\n7. `BEGIN` transaction\n8. `defer tx.Rollback` — automatic rollback on any failure\n9. Create idempotency key (`PENDING`) inside the transaction\n10. `SELECT FOR UPDATE` both accounts (lower UUID first — deadlock prevention)\n11. Create transaction record (`PENDING`)\n12. `CreateBatch` — 4 journal entries:\n\n| Account        | Entry  | Effect            |\n| -------------- | ------ | ----------------- |\n| Sender         | CREDIT | money leaving     |\n| Platform Float | DEBIT  | platform receives |\n| Platform Float | CREDIT | platform releases |\n| Receiver       | DEBIT  | money arriving    |\n\n13. Verify `SUM(debits) - SUM(credits) = 0` — mathematically enforced before every commit\n14. Update transaction → `COMPLETED`\n15. Update limit usage\n16. Set idempotency response → `COMPLETED`\n17. `COMMIT`\n\nIf any step fails, the entire transaction rolls back automatically including the idempotency key.\n\n\n## Key Design Decisions\n\n**Why `SELECT FOR UPDATE` with lock ordering?**\nConcurrent transfers between the same accounts cause lost updates without row-level locks.\nAlways locking the lower UUID first across all code paths prevents deadlocks.\n\n**Why derive balance from journal entries?**\nStoring balance as a column creates a TOCTOU race condition under concurrency.\nDeriving it from immutable journal entries means the ledger is always the source of truth.\n\n```sql\nSELECT COALESCE(\n    SUM(CASE WHEN entry_type = 'DEBIT' THEN amount ELSE -amount END), 0\n)\nFROM journal_entries WHERE account_id = $1\n```\n\n**Why idempotency keys inside the database transaction?**\nIf the transaction rolls back, the idempotency key rolls back with it.\nThis prevents a failed transfer from being treated as already-processed on retry.\n\n**Why UUIDv7 over v4?**\nUUIDv7 is time-ordered — sequential inserts cause less B-tree fragmentation in PostgreSQL indexes. Also enables cursor-based pagination without an extra timestamp column.\n\n**Why `BIGINT` for money?**\nFloating point arithmetic is non-deterministic. `BIGINT` in smallest currency unit (paise for INR, cents for USD) is exact.\n\n**Why append-only journal entries?**\nFinancial ledgers must be auditable. No `UPDATE` or `DELETE` ever touches `journal_entries`.\nCorrections are made via new entries (refunds, adjustments), never by editing history.\n\n**Why `original_transaction_id` on refunds?**\nPartial refunds require tracking cumulative refunded amount. Linking every refund transaction back to its original transfer via FK enables a single query to prevent over-refunding across multiple partial refund requests.\n\n\n\n## API Endpoints\n\nFull interactive documentation available at `http://localhost:8080/swagger/index.html`\n\n| Method  | Endpoint                            | Description                                   |\n| ------- | ----------------------------------- | --------------------------------------------- |\n| `POST`  | `/api/v1/customers`                 | Register a new customer                       |\n| `PATCH` | `/api/v1/customers/:id/kyc`         | Update KYC status                             |\n| `POST`  | `/api/v1/accounts`                  | Open account for verified customer            |\n| `POST`  | `/api/v1/transfers`                 | Transfer funds between accounts               |\n| `POST`  | `/api/v1/refunds`                   | Refund a completed transfer (partial or full) |\n| `GET`   | `/api/v1/accounts/:id/transactions` | Paginated transaction history                 |\n| `GET`   | `/api/v1/accounts/:id/statement`    | Download PDF account statement                |\n| `GET`   | `/health`                           | Service and database health check             |\n| `GET`   | `/metrics`                          | Prometheus metrics                            |\n| `GET`   | `/swagger/*`                        | Swagger UI                                    |\n\n### Transfer Request\n\n```json\n{\n  \"from_account_id\": \"uuid\",\n  \"to_account_id\": \"uuid\",\n  \"amount\": 100000,\n  \"currency\": \"INR\",\n  \"idempotency_key\": \"unique-key-per-request\"\n}\n```\n\n### Refund Request (partial supported)\n\n```json\n{\n  \"transaction_id\": \"uuid-of-original-transfer\",\n  \"amount\": 50000,\n  \"idempotency_key\": \"unique-key-per-request\"\n}\n```\n\n### Error Response Format\n\nAll errors return structured JSON with domain-specific error codes:\n\n```json\n{\n  \"code\": \"LEDGER_002_INSUFFICIENT_BALANCE\",\n  \"message\": \"account does not have sufficient balance\",\n  \"request_id\": \"abc-123\"\n}\n```\n\n| Code                              | Status | Meaning                 |\n| --------------------------------- | ------ | ----------------------- |\n| `LEDGER_001_NOT_FOUND`            | 404    | Resource not found      |\n| `LEDGER_002_INSUFFICIENT_BALANCE` | 422    | Not enough funds        |\n| `LEDGER_003_KYC_NOT_VERIFIED`     | 403    | Customer KYC incomplete |\n| `LEDGER_004_ACCOUNT_INACTIVE`     | 422    | Account is inactive     |\n| `LEDGER_005_DAILY_LIMIT_EXCEEDED` | 422    | Daily limit breached    |\n| `LEDGER_500_INTERNAL_ERROR`       | 500    | Internal server error   |\n\n\n## Performance\n\nLoad tested with k6 on a single development machine (Go app + PostgreSQL + Prometheus + Grafana running locally).\n\n![k6 Load Test](docs/images/load_test.png)\n\n| Scenario              | VUs | TPS | p50   | p95   | p99   | Errors |\n| --------------------- | --- | --- | ----- | ----- | ----- | ------ |\n| Baseline              | 1   | 95  | 10ms  | 12ms  | 14ms  | 0%     |\n| Realistic concurrency | 20  | 332 | 53ms  | 107ms | 147ms | 0%     |\n| Stress test           | 200 | 526 | 143ms | 459ms | 579ms | 0%     |\n\n**94,660 transfers completed under stress test with zero errors or data corruption.**\n\nLatency increases under high concurrency because `SELECT FOR UPDATE` serializes writes to the same account pair by design. This is correct behavior for a financial system — concurrent writes to the same account must be ordered. In production, load is distributed across millions of account pairs, eliminating this bottleneck.\n\n\n## Observability\n\n![Grafana Dashboard](docs/images/grafana.jpg)\n\n| Tool                | Purpose                                                                                   |\n| ------------------- | ----------------------------------------------------------------------------------------- |\n| **Prometheus**      | Scrapes `/metrics` every 15 seconds — request count, latency histograms                   |\n| **Grafana**         | 7-panel dashboard — request rate, p50/p95/p99 latency, transfer count, error rate         |\n| **Sentry**          | Error tracking — captures exceptions with full stack traces and request context           |\n| **Zap**             | Structured JSON request logging — method, path, status, latency, request ID on every line |\n| **Health endpoint** | `GET /health` returns db connectivity, uptime, version, environment                       |\n\n---\n\n## Database Schema\n\n17 migrations, all append-only:\n\n```\n001 currencies               — INR, USD, SGD (50 seeded)\n002 exchange_rates           — NUMERIC(20,8), daily rates\n003 account_types            — asset/liability/equity/revenue/expense\n004 countries                — iso_code, dial_code, FK to currencies\n005 customers                — UUID PK, KYC status, is_active\n006 accounts                 — UUIDv7, NO balance column\n007 transactions             — TRANSFER/REFUND/ADJUSTMENT, PENDING/COMPLETED/FAILED\n008 journal_entries          — immutable, append-only, BIGINT amounts\n009 idempotency_keys         — exactly-once semantics\n010 audit_logs               — compliance trail\n011 account_limits           — DAILY/MONTHLY/YEARLY/TRANSACTION limits per account\n012 indexes                  — composite indexes for query optimization\n013 seed_platform_accounts   — platform float/cash/revenue accounts + test data\n014 fix_idempotency_keys     — corrected column types (append-only migration pattern)\n015 add_account_ids          — from_account_id, to_account_id on transactions\n016 add_amount               — amount, currency_id on transactions\n017 original_transaction_id  — FK for partial refund tracking\n```\n\n---\n\n## Tech Stack\n\n| Layer            | Technology             |\n| ---------------- | ---------------------- |\n| Language         | Go                     |\n| Database         | PostgreSQL             |\n| Router           | Chi v5                 |\n| Connection Pool  | pgx/v5 + pgxpool       |\n| Migrations       | golang-migrate         |\n| Logging          | Zap (structured)       |\n| Metrics          | Prometheus             |\n| Dashboards       | Grafana                |\n| Error Tracking   | Sentry                 |\n| API Docs         | Swagger UI (swaggo)    |\n| Load Testing     | k6                     |\n| Containerization | Docker, Docker Compose |\n| Hot Reload       | Air                    |\n\n---\n\n## Getting Started\n\n### Prerequisites\n\n- Go 1.21+\n- Docker and Docker Compose\n- [golang-migrate CLI](https://github.com/golang-migrate/migrate)\n- [k6](https://k6.io/) (optional, for load testing)\n\n### Run Locally\n\n```bash\n# Clone the repo\ngit clone https://github.com/PacemakerX/ledger-core.git\ncd ledger-core\n\n# Copy environment variables\ncp .env.example .env\ncp .env.docker.example .env.docker\n\n# Start PostgreSQL\ndocker-compose up postgres -d\n\n# Run database migrations\nmake migrate-up\n\n# Start the server (with hot reload)\nair\n\n# Or without hot reload\ngo run cmd/server/main.go\n```\n\nServer runs at `http://localhost:8080`\n\n### Run Full Stack (with Prometheus + Grafana)\n\n```bash\ndocker-compose up -d\n```\n\n| Service    | URL                                      |\n| ---------- | ---------------------------------------- |\n| API        | http://localhost:8080                    |\n| Swagger UI | http://localhost:8080/swagger/index.html |\n| Prometheus | http://localhost:9090                    |\n| Grafana    | http://localhost:3000                    |\n\n### Run Load Tests\n\n```bash\nk6 run scripts/loadtest/k6.js\n```\n\n\n## Architecture Decision Records\n\n9 ADRs documented in `docs/adr/`:\n\n```\n001 — Why Go\n002 — Why PostgreSQL (MVCC, ACID, WAL, SELECT FOR UPDATE)\n003 — Why pgx over database/sql\n004 — Why Chi over Gin/Echo\n005 — Why Zap (structured logging, zero allocation)\n006 — Why UUIDv7 (time-ordered, less index fragmentation)\n007 — Why golang-migrate over GORM AutoMigrate\n008 — Why Double-Entry Accounting\n009 — Why original transaction ID on refunds\n```\n\n## License\n\nMIT — see [LICENSE](LICENSE) for details.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpacemakerx%2Fledger-core","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpacemakerx%2Fledger-core","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpacemakerx%2Fledger-core/lists"}