minibank

MiniBank API

CI

Demo online: minibank-465d7.containers.snapdeploy.app · Halaman proyek: minibank.kadaferhidup.my.id · API docs: Swagger UI

Server demo memakai paket gratis yang tidur setelah 15 menit tanpa pengunjung. Kunjungan pertama butuh sekitar satu menit untuk membangunkannya.

Backend core banking sederhana: nasabah bisa daftar, buka rekening, cek nama penerima, transfer pakai PIN, dan melihat mutasi. Teller bisa setor dan tarik tunai, membekukan rekening, melihat jejak audit, dan menjalankan rekonsiliasi buku besar.

Fokus proyek ini bukan jumlah fitur, tapi kebenaran data uang: saldo tidak boleh minus, uang tidak boleh hilang atau tercipta, dan permintaan yang terkirim dua kali tidak boleh memotong saldo dua kali, termasuk saat ada banyak permintaan di detik yang sama.

Ada juga tampilan web internet banking (di demo online, atau http://localhost:8080/ kalau dijalankan sendiri), ditulis tanpa framework (HTML, CSS, JavaScript biasa) dan disajikan langsung oleh Spring Boot.

Beranda MiniBank

Stack: Java 21 · Spring Boot 3.5 · Spring Security (JWT) · Spring Data JPA · PostgreSQL 16 · Flyway · springdoc OpenAPI · JUnit 5 · Testcontainers · Docker · GitHub Actions


Keputusan desain

Masalah di sistem bank Solusinya di MiniBank
Uang “hilang” kalau proses gagal di tengah Debit, kredit, jurnal, dan audit ditulis dalam satu transaksi database. Gagal satu langkah, semua dibatalkan.
Saldo tidak bisa ditelusuri asalnya Pembukuan double-entry: tiap transaksi punya satu baris DEBIT dan satu baris KREDIT di ledger_entries dengan nominal sama. Saldo rekening hanya cache dan bisa dihitung ulang dari buku besar.
Dua transfer bersamaan membuat saldo minus Rekening dikunci dengan SELECT ... FOR UPDATE sebelum dicek dan diubah. Database juga punya CHECK (balance >= 0) sebagai pengaman terakhir.
Deadlock saat A transfer ke B bersamaan B ke A Rekening selalu dikunci dalam urutan id yang sama, jadi tidak ada siklus saling tunggu.
Tombol “Kirim” kepencet dua kali, atau jaringan putus lalu aplikasi mengulang Header Idempotency-Key wajib. Kunci diklaim lewat unique constraint di dalam transaksi yang sama; permintaan kembar mendapat hasil transaksi pertama (200, Idempotent-Replayed: true) tanpa memotong saldo lagi. Kunci yang sama dengan isi berbeda ditolak.
Saldo dibaca sebelum dikunci (lost update) Nomor rekening diterjemahkan ke id lewat query skalar, entitas baru dimuat setelah baris dikunci, jadi saldo yang dihitung selalu yang terbaru.
PIN ditebak berkali-kali Salah 3 kali → PIN terkunci 15 menit. Hitungan salah disimpan di transaksi terpisah (REQUIRES_NEW) supaya tetap tercatat walau transfernya ditolak. PIN lemah seperti 123456 ditolak saat daftar.
Brute force login, dan menebak email terdaftar Login gagal dibatasi per email, dan pesan error serta waktu respons dibuat sama untuk email terdaftar maupun tidak (dicek dengan hash BCrypt pembanding).
Melihat atau memakai rekening orang lain Rekening milik orang lain dijawab 404, sama dengan rekening yang tidak ada, supaya nomor rekening tidak bisa ditebak lewat API.
Salah ketik nomor rekening tujuan Nomor rekening 10 digit memakai digit pemeriksa Luhn, jadi salah ketik satu angka langsung tertolak. Ada juga endpoint cek nama penerima yang menyamarkan nama (Ah*** Um******).
Bukti kalau datanya benar Endpoint rekonsiliasi memeriksa total debit = total kredit, tiap transaksi seimbang, dan saldo tiap rekening sama dengan hasil hitung ulang buku besar.
Siapa melakukan apa Tabel audit_logs mencatat register, login (berhasil/gagal), PIN salah/terkunci, transfer, setor, tarik, bekukan, dan aktifkan rekening beserta IP.

Tampilan web

Satu halaman di src/main/resources/static/ yang memanggil API yang sama dengan Swagger. Isinya:

Token disimpan di sessionStorage, jadi hilang saat tab ditutup. Respons 401 langsung mengeluarkan pengguna. Tampilan menyesuaikan layar HP (menu pindah ke bawah) dan menghormati pengaturan kurangi animasi.

Alur transfer sampai struk

Arsitektur

flowchart LR
    App[Aplikasi mobile / web] -->|HTTPS + JWT| API
    subgraph API[MiniBank API - Spring Boot]
        C[Controller] --> TS[TransferService]
        TS --> PIN[PinService<br/>REQUIRES_NEW]
        TS --> IDEM[IdempotencyService]
        IDEM --> MM[MoneyMovementService<br/>satu transaksi DB]
        MM --> LOCK[Kunci rekening<br/>FOR UPDATE, urut id]
        MM --> LEDGER[LedgerService<br/>jurnal double-entry]
        MM --> AUDIT[AuditService]
    end
    API --> DB[(PostgreSQL)]

Alur satu transfer:

  1. Verifikasi PIN (transaksi DB sendiri, supaya hitungan PIN salah tetap tersimpan).
  2. Kalau Idempotency-Key sudah pernah dipakai, kembalikan hasil lama.
  3. Mulai transaksi DB → klaim kunci idempotensi → kunci kedua rekening → cek kepemilikan, status, limit per transaksi, limit harian, dan saldo → tulis transaksi + 2 baris buku besar → ubah saldo → catat audit → commit.

Model data

erDiagram
    users ||--o{ accounts : memiliki
    accounts ||--o{ ledger_entries : "dicatat di"
    transactions ||--|{ ledger_entries : "terdiri dari"
    users ||--o{ idempotency_keys : mengirim
    idempotency_keys }o--|| transactions : "menghasilkan"
    users {
        uuid id PK
        varchar email UK
        varchar password_hash
        varchar pin_hash
        int pin_failed_attempts
        timestamptz pin_locked_until
        varchar role
    }
    accounts {
        uuid id PK
        varchar account_number UK
        varchar type "SAVINGS / INTERNAL"
        varchar status "ACTIVE / FROZEN / CLOSED"
        numeric balance "CHECK >= 0 untuk nasabah"
    }
    transactions {
        uuid id PK
        varchar reference UK
        varchar type "DEPOSIT / WITHDRAWAL / TRANSFER"
        numeric amount
    }
    ledger_entries {
        bigint id PK
        varchar direction "DEBIT / CREDIT"
        numeric amount
        numeric balance_after
    }

Rekening INTERNAL (9000000001) adalah kas bank: lawan pembukuan untuk setor dan tarik tunai. Saldo kas boleh minus karena mewakili uang tunai yang masuk ke laci teller.

Endpoint

Dokumentasi interaktif tersedia di /swagger-ui.html setelah aplikasi berjalan.

Method Path Akses Keterangan
POST /api/auth/register publik Daftar, langsung dibukakan rekening tabungan
POST /api/auth/login publik Mendapat access token JWT (30 menit)
GET /api/me nasabah Profil
GET /api/accounts nasabah Daftar rekening saya
POST /api/accounts nasabah Buka rekening tambahan (maks. 3)
GET /api/accounts/{nomor} pemilik Detail dan saldo
GET /api/accounts/{nomor}/inquiry nasabah Cek nama pemilik rekening tujuan
GET /api/accounts/{nomor}/statement?from=&to=&page=&size= pemilik Mutasi (maks. 90 hari)
POST /api/transfers nasabah Transfer, wajib Idempotency-Key dan PIN
GET /api/transactions/{referensi} pihak terkait Detail transaksi
POST /api/admin/deposits admin Setor tunai, wajib Idempotency-Key
POST /api/admin/withdrawals admin Tarik tunai, wajib Idempotency-Key
GET /api/admin/accounts/{nomor} admin Detail rekening + data pemilik
POST /api/admin/accounts/{nomor}/freeze admin Bekukan rekening
POST /api/admin/accounts/{nomor}/unfreeze admin Aktifkan kembali
GET /api/admin/audit-logs admin Jejak audit
GET /api/admin/ledger/verify admin Rekonsiliasi buku besar

Aturan bawaan

Bisa diubah di application.yml bagian minibank.

Aturan Nilai
Minimal transfer Rp10.000
Maksimal per transaksi Rp50.000.000
Limit transfer harian per rekening Rp100.000.000 (hari dihitung zona Asia/Jakarta)
PIN salah sebelum terkunci 3 kali, terkunci 15 menit
Login gagal sebelum ditahan 5 kali dalam 15 menit

Format error

Semua error memakai RFC 7807 (application/problem+json) dengan field code yang stabil, supaya aplikasi bisa menampilkan pesan yang tepat:

{
  "type": "https://minibank.dev/errors/insufficient-balance",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "Saldo tidak cukup",
  "code": "INSUFFICIENT_BALANCE"
}

Kode yang dipakai antara lain VALIDATION_FAILED, IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_KEY_REUSED, INVALID_CREDENTIALS, TOO_MANY_ATTEMPTS, INVALID_PIN, PIN_LOCKED, WEAK_PIN, INSUFFICIENT_BALANCE, LIMIT_EXCEEDED, DAILY_LIMIT_EXCEEDED, ACCOUNT_NOT_ACTIVE, DESTINATION_INVALID, ACCOUNT_NOT_FOUND. Daftar lengkapnya di ErrorCode.java.

Menjalankan

Dengan Docker (paling mudah)

docker compose up --build

Tampilan web di http://localhost:8080/, Swagger di http://localhost:8080/swagger-ui.html. Akun teller untuk lokal: teller@minibank.local / Teller12345 (diatur di docker-compose.yml).

Tanpa Docker

Butuh Java 21 dan PostgreSQL yang punya database minibank.

export JWT_SECRET="isi-dengan-rahasia-acak-minimal-32-karakter"
export ADMIN_EMAIL="teller@minibank.local"
export ADMIN_PASSWORD="Teller12345"
mvn spring-boot:run
Environment variable Default Keterangan
DB_URL jdbc:postgresql://localhost:5432/minibank  
DB_USER / DB_PASSWORD minibank / minibank  
JWT_SECRET wajib Minimal 32 karakter. Aplikasi menolak jalan kalau kosong.
JWT_TTL 30m Masa berlaku token
ADMIN_EMAIL / ADMIN_PASSWORD kosong Kalau diisi, akun teller dibuat saat start

Online: Supabase + SnapDeploy

Database memakai PostgreSQL di Supabase, aplikasinya di-deploy dari Dockerfile ke SnapDeploy. Server SnapDeploy memakai CPU ARM, jadi image dasar di Dockerfile dipilih yang tersedia untuk x86 dan ARM (CI mengecek ini).

  1. Di Supabase, buat project (region Singapore). Dari tombol Connect, ambil koneksi Session pooler (port 5432, jalan di IPv4).
  2. Di SnapDeploy, pilih Deploy from GitHub, repo ini, branch main, ukuran Small (gratis). Saat diminta database, pilih external / hosted PostgreSQL. SnapDeploy membaca Dockerfile sendiri dan mengisi PORT otomatis.
  3. Isi environment variable:
Variable Isi
DB_URL jdbc:postgresql://<host-pooler>:5432/postgres?sslmode=require
DB_USER postgres.<project-ref>
DB_PASSWORD password database Supabase
DB_POOL_SIZE 5
JWT_SECRET string acak minimal 32 karakter
ADMIN_EMAIL / ADMIN_PASSWORD akun teller produksi, jangan pakai password contoh. Akun ini dibuat sekali saat aplikasi pertama kali nyala; mengganti variabelnya kemudian tidak mengubah password yang sudah ada

Kalau Flyway menolak jalan dengan pesan Found non-empty schema “public” but no schema history table, berarti schema public di Supabase sudah berisi objek bawaan (misalnya fungsi rls_auto_enable). Pastikan tidak ada tabel bernama sama dengan tabel MiniBank, lalu tambahkan:

Variable Isi
SPRING_FLYWAY_BASELINE_ON_MIGRATE true
SPRING_FLYWAY_BASELINE_VERSION 0 (harus 0, supaya migrasi V1 tetap dijalankan)

Flyway membuat tabel saat aplikasi pertama kali jalan. Migrasi V2 menyalakan RLS di semua tabel MiniBank dan mencabut hak role anon/authenticated, jadi data tidak bisa dibaca lewat Data API Supabase walaupun anon key bocor. Aplikasi konek sebagai pemilik tabel, jadi tidak terpengaruh.

Paket gratis SnapDeploy tidur setelah 15 menit tanpa pengunjung, butuh sekitar satu menit untuk bangun lagi, dan dibatasi 100 jam jalan per bulan. Setiap push ke main memicu deploy ulang otomatis.

SnapDeploy menolak Dockerfile yang berisi pola /dev/tcp (mirip reverse shell), jadi health check di image memakai curl. CI ikut menolak pola itu.

Coba alurnya dengan curl

# 1. Daftar dua nasabah
curl -s localhost:8080/api/auth/register -H 'Content-Type: application/json' -d '{
  "fullName":"Ahmad Umraithi","email":"ahmad@contoh.id","phone":"081234567890",
  "password":"Rahasia123","pin":"246810"}'
curl -s localhost:8080/api/auth/register -H 'Content-Type: application/json' -d '{
  "fullName":"Budi Santoso","email":"budi@contoh.id","phone":"081298765432",
  "password":"Rahasia123","pin":"135792"}'
# catat accountNumber masing-masing, misalnya AHMAD=10xxxxxxxx dan BUDI=10yyyyyyyy

# 2. Teller setor tunai ke rekening Ahmad
TELLER=$(curl -s localhost:8080/api/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"teller@minibank.local","password":"Teller12345"}' | jq -r .accessToken)
curl -s localhost:8080/api/admin/deposits -H "Authorization: Bearer $TELLER" \
  -H 'Content-Type: application/json' -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"accountNumber\":\"$AHMAD\",\"amount\":500000}"

# 3. Ahmad cek nama penerima lalu transfer
TOKEN=$(curl -s localhost:8080/api/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"ahmad@contoh.id","password":"Rahasia123"}' | jq -r .accessToken)
curl -s localhost:8080/api/accounts/$BUDI/inquiry -H "Authorization: Bearer $TOKEN"
KEY=$(uuidgen)
curl -si localhost:8080/api/transfers -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -H "Idempotency-Key: $KEY" \
  -d "{\"fromAccount\":\"$AHMAD\",\"toAccount\":\"$BUDI\",\"amount\":125000,\"description\":\"Bayar kos\",\"pin\":\"246810\"}"

# 4. Kirim ulang dengan kunci yang sama: hasilnya sama, saldo tidak terpotong dua kali
curl -si localhost:8080/api/transfers -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -H "Idempotency-Key: $KEY" \
  -d "{\"fromAccount\":\"$AHMAD\",\"toAccount\":\"$BUDI\",\"amount\":125000,\"description\":\"Bayar kos\",\"pin\":\"246810\"}"

# 5. Mutasi dan rekonsiliasi
curl -s localhost:8080/api/accounts/$AHMAD/statement -H "Authorization: Bearer $TOKEN"
curl -s localhost:8080/api/admin/ledger/verify -H "Authorization: Bearer $TELLER"

Testing

mvn verify   # butuh Docker untuk Testcontainers

Test integrasi menjalankan aplikasi penuh dengan PostgreSQL asli di Docker, bukan database tiruan. Skenario yang diuji antara lain:

Unit test menguji generator nomor rekening (digit Luhn mendeteksi salah ketik), pembulatan nominal, dan penyamaran nama. Semua test jalan otomatis di GitHub Actions setiap push.

Struktur proyek

src/main/java/io/github/kadaferhidup/minibank
├── account/    rekening, nomor rekening Luhn, mutasi, cek nama penerima
├── admin/      endpoint teller: setor, tarik, bekukan, audit, rekonsiliasi
├── audit/      jejak audit
├── auth/       register, login JWT, PIN, pembatas login gagal
├── common/     format error RFC 7807, utilitas uang dan penyamaran
├── config/     security, properti aturan bank, OpenAPI, akun admin awal
├── ledger/     transaksi, buku besar double-entry, rekonsiliasi
├── transfer/   transfer, idempotency key, inti perpindahan uang
└── user/       entitas pengguna
src/main/resources/db/migration   skema database (Flyway)

Batasan dan langkah berikutnya

Proyek ini dibuat untuk belajar dan portofolio, bukan untuk dipakai menyimpan uang sungguhan. Yang akan ditambahkan kalau dikembangkan ke tahap produksi:


Dibuat oleh Ahmad Umraithi · Teknik Informatika, Universitas Malikussaleh