# Cetak Biru — Koperasi Desa/Kelurahan Merah Putih (Fase 1: Pondasi)

> **Status:** Draft untuk review
> **Tanggal:** 2026-05-27
> **Subdomain:** `kopdes.developerkdmp.my.id`
> **Lingkup dokumen ini:** Fase 1 (Pondasi / Core Platform). Modul lanjutan (POS, inventory, e-commerce, marketplace, tele-health, LMS, payroll, dst.) punya spec sendiri di fase berikutnya, menempel pada pondasi ini.

---

## 1. Prinsip Desain

1. **Uang tidak boleh salah.** Semua mutasi finansial lewat *double-entry general ledger* (GL). Saldo adalah turunan dari jurnal, bukan kolom yang diedit bebas.
2. **Idempotent secara default.** Setiap operasi yang memindahkan uang aman diulang (retry/koneksi putus/klik ganda) tanpa efek dobel.
3. **Single-tenant, portabel.** 1 desa = 1 instalasi. Migrasi = salin folder `kopdes/` + restore dump database. Tidak ada `tenant_id`.
4. **Tanpa dependensi eksternal di runtime.** Tidak ada Redis/Docker. Queue, lock, dan session semuanya di PostgreSQL + JWT. Cocok untuk shared cPanel.
5. **Aksesibilitas dulu.** WCAG 2.1 AA, target sentuh besar, kontras tinggi, pesan error informatif berbahasa Indonesia.
6. **Modular & terisolasi.** Tiap modul = folder mandiri (`controller → service → repository → domain`) di atas satu *shared kernel*. Modul bisa dipahami & diuji terpisah.

---

## 2. Tech Stack (Final)

| Layer | Pilihan | Alasan |
|---|---|---|
| Tenancy | Single-tenant (1 desa = 1 instalasi) | Isolasi data total, migrasi sederhana, paling aman untuk uang |
| Frontend | Next.js 14+ (App Router), TypeScript, Tailwind CSS, shadcn/ui (Radix), Lucide, Recharts | SSR untuk SEO halaman depan, komponen aksesibel, chart EIS |
| Backend | Node 24 + **Express + TypeScript**, Clean Architecture modular | Konsisten dengan ekosistem existing, hemat RAM, cukup untuk skala ini |
| ORM/DB | **PostgreSQL 13 + Prisma** | ACID, `SERIALIZABLE`, advisory lock, migration tooling |
| Queue | **pg-boss** (antrian di dalam PostgreSQL) | Tanpa Redis; job durable, retry otomatis, survive restart |
| Lock | `pg_advisory_xact_lock` + isolation `SERIALIZABLE`/`REPEATABLE READ` | Cegah race condition antar transaksi akun yang sama |
| Session | **JWT akses (15 mnt) di memory + refresh (7 hr) di HttpOnly cookie**, rotasi token | Stateless, aman XSS/CSRF, mudah untuk web + React Native |
| Deploy | PM2 + Apache reverse-proxy, bind `127.0.0.1`, tanpa Docker | Pola sama dengan 4 project existing |
| Upload | Folder `kopdes/` di luar docroot, dirujuk dari DB | Portabel untuk migrasi |

**Catatan skala:** 5.000–10.000 anggota/desa, beban puncak ratusan transaksi konkuren. PostgreSQL + Express menangani ini dengan mudah. Bottleneck nyata = desain skema & disiplin transaksi, bukan framework.

---

## 3. Diagram Arsitektur

```
                          kopdes.developerkdmp.my.id  (HTTPS / Let's Encrypt)
                                          │
                     ┌────────────────────┴─────────────────────┐
                     │        APACHE  (reverse proxy + SSL)       │
                     │  /                → Next.js   127.0.0.1:3010│
                     │  /api/*           → Express   127.0.0.1:4010│
                     │  /uploads/*       → (X-Accel / alias kopdes)│
                     │  /<custom-admin>  → Next.js   (area admin)  │  ← URL admin dpt diubah dari setting
                     └────────────────────┬─────────────────────┘
                                          │ 127.0.0.1
        ┌─────────────────────────────────┼──────────────────────────────────┐
        ▼                                 ▼                                    ▼
┌──────────────────┐          ┌───────────────────────────┐        ┌────────────────────┐
│ FRONTEND (Web)   │          │      BACKEND (Express)      │        │ WORKER (pg-boss)   │
│ Next.js 14       │  fetch   │ ┌───────────────────────┐  │        │ kopdes-worker      │
│ • Portal Anggota │◄────────►│ │   SHARED KERNEL        │  │        │ • posting jurnal   │
│ • Admin EIS      │  cookie  │ │ auth · rbac ·          │  │        │   berat / batch    │
│ • Halaman Depan  │ HttpOnly │ │ idempotency ·          │  │        │ • generate laporan │
│   (CMS publik)   │          │ │ ledger · http(RFC7807) │  │        │ • hitung SHU       │
└──────────────────┘          │ │ audit · validation     │  │        │ • job backup       │
        ▲                     │ └───────────────────────┘  │        └─────────┬──────────┘
        │ REST + JWT          │   Modules (Fase 1):         │                  │
        │                     │   pengurus · anggota ·      │  enqueue         │
┌──────────────────┐         │   simpanan · pinjaman ·     │  job             ▼
│ MOBILE           │  REST   │   keuangan(GL) · cms ·      │        ┌────────────────────┐
│ React Native     │────────►│   backup · settings         │◄──────►│   PostgreSQL 13    │
│ (Android / iOS)  │  JWT    │                             │  SQL   │ • tabel domain     │
└──────────────────┘         └──────────────┬──────────────┘        │ • GL double-entry  │
                                            │                        │ • pgboss.* (queue) │
                                            ▼                        │ • idempotency_keys │
                                  ┌────────────────────┐             └────────────────────┘
                                  │  Folder kopdes/     │  ← foto artikel, form surat,
                                  │  (data upload,      │     dokumen anggota. Di-backup
                                  │   portabel migrasi) │     bareng dump DB.
                                  └────────────────────┘
```

**Alur transaksi finansial (ringkas):**
```
Client ──(POST /api/savings/deposit, header Idempotency-Key)──► Express
   1. Cek idempotency_keys → kalau sudah ada, balikkan respons tersimpan (STOP, no double)
   2. BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE
   3. pg_advisory_xact_lock(account_id)        ← kunci akun ini
   4. Insert journal_entry + journal_lines (debit = kredit, balance)
   5. Insert transaction (status=POSTED, idempotency_key)
   6. Update accounts.balance (turunan)
   7. Simpan respons ke idempotency_keys
   8. COMMIT  (kalau gagal → ROLLBACK, semua batal utuh)
```

---

## 4. Model Deployment (cPanel + PM2 + Apache)

```
kopdes.developerkdmp.my.id/         ← docroot Apache (project root + git)
├── .htaccess                       reverse proxy → Node
├── ecosystem.config.cjs            PM2: kopdes-backend, kopdes-frontend, kopdes-worker
├── backend/                        Express + TS  (port 4010)
├── frontend/                       Next.js 14    (port 3010)
├── worker/                         entry pg-boss (atau di-spawn dari backend)
├── installer/                      wizard instalasi/migrasi (lihat §12)
└── docs/

/home/developerkdmpmy/kopdes/       ← DI LUAR docroot, folder data upload portabel
├── uploads/  artikel/  surat/  anggota/  produk/
├── backups/  (dump .sql + arsip uploads)
└── config/   (.env terenkripsi / kredensial instance)
```

**Port (lanjutan dari CLAUDE.md):** frontend `3010`, backend `4010`, worker tanpa port (background).

`.htaccess` (inti):
```apache
RewriteEngine On
# API → Express
RewriteRule ^api/(.*)$ http://127.0.0.1:4010/api/$1 [P,L]
# sisanya → Next.js
RewriteCond %{REQUEST_URI} !^/api/
RewriteRule ^(.*)$ http://127.0.0.1:3010/$1 [P,L]
```

`ecosystem.config.cjs` (inti):
```js
module.exports = {
  apps: [
    { name: 'kopdes-backend',  cwd: './backend',  script: 'dist/main.js', env: { PORT: 4010, HOST: '127.0.0.1' } },
    { name: 'kopdes-frontend', cwd: './frontend', script: 'node_modules/next/dist/bin/next', args: 'start -p 3010 -H 127.0.0.1' },
    { name: 'kopdes-worker',   cwd: './backend',  script: 'dist/worker.js' },
  ],
};
```

---

## 5. RBAC — Role & Hak Akses Dinamis

Pengguna dipisah berdasarkan fungsi; admin bisa **membuat user, membuat/ubah/hapus jabatan, dan membatasi hak akses**.

- **Permission** = string granular `modul:aksi` (mis. `simpanan:create`, `pinjaman:approve`, `laporan:read`, `cms:publish`, `user:manage`).
- **Role** = kumpulan permission, **bisa ditambah/edit/hapus** dari admin (mis. "Ketua", "Bendahara", "Kasir Gerai", "Teller"). Selaras dengan "modul pengurus: jabatan bisa ditambah/hapus/edit".
- **User** punya 1+ role. Resolusi izin = union permission dari semua role.
- **Custom admin URL** disimpan di `settings` (`admin_path_slug`); middleware Next.js & Express memetakan slug itu ke area admin. Default acak saat instalasi demi keamanan (anti-bruteforce path `/admin`).
- Enforcement dua lapis: middleware Express (`requirePermission('pinjaman:approve')`) + guard UI di Next.js (sembunyikan menu yang tak diizinkan, tapi server tetap otoritas final).

---

## 6. Integritas Keuangan (Detail)

### 6.1 Double-Entry General Ledger
- **Chart of Accounts (CoA)** standar koperasi: Aset (Kas, Bank, Piutang Pinjaman), Kewajiban (Simpanan Pokok/Wajib/Sukarela anggota), Ekuitas (Dana Cadangan, SHU ditahan), Pendapatan (Bunga/Jasa Pinjaman), Beban.
- Setiap transaksi → 1 `journal_entry` dengan ≥2 `journal_line` yang **wajib balance** (`SUM(debit) = SUM(credit)`), divalidasi di level service **dan** constraint DB.
- Saldo akun (`accounts.balance`) = kolom turunan yang diperbarui di dalam transaksi yang sama; sumber kebenaran tetap jurnal (bisa direkonsiliasi ulang dari `journal_line`).

Contoh: **Setor Simpanan Sukarela Rp100.000**
```
Dr  Kas                         100.000   (aset naik)
  Cr  Simpanan Sukarela Anggota   100.000 (kewajiban koperasi ke anggota naik)
```

### 6.2 Idempotency
- Klien wajib kirim header `Idempotency-Key` (UUID) pada semua endpoint mutasi uang.
- Tabel `idempotency_keys` menyimpan `(key, endpoint, request_hash, response_body, status_code)`.
- Key sama + request sama → balikkan respons tersimpan. Key sama + request beda → `409 Conflict`.

### 6.3 Anti Race-Condition
- `prisma.$transaction(..., { isolationLevel: 'Serializable' })`.
- `pg_advisory_xact_lock(hashtext(account_id))` di awal transaksi → serialisasi mutasi per akun, mencegah lost update saat setor & tarik bersamaan.
- Retry otomatis (max 3×) untuk error serialization (`40001`).

### 6.4 Queue (pg-boss)
- Operasi berat/non-interaktif (hitung SHU tahunan, generate laporan PDF, posting batch, backup terjadwal) dikirim ke pg-boss → diproses `kopdes-worker`.
- Job durable di PostgreSQL: aman walau server restart / koneksi putus. Untuk transaksi teller real-time tetap sinkron (anggota perlu respons langsung), tapi terlindungi idempotency + lock.

---

## 7. Skema Database (Prisma — Fase 1)

> File: `backend/prisma/schema.prisma`. Tipe uang pakai `Decimal(18,2)` (JANGAN float). Semua timestamp UTC.

```prisma
generator client { provider = "prisma-client-js" }
datasource db { provider = "postgresql"; url = env("DATABASE_URL") }

// ─────────── AUTH & RBAC ───────────
enum UserStatus { ACTIVE SUSPENDED PENDING }

model User {
  id            String     @id @default(uuid()) @db.Uuid
  nama          String
  nomorTelepon  String     @unique @map("nomor_telepon")
  email         String?    @unique
  passwordHash  String     @map("password_hash")
  status        UserStatus @default(ACTIVE)
  member        Member?
  roles         UserRole[]
  refreshTokens RefreshToken[]
  createdAt     DateTime   @default(now()) @map("created_at")
  updatedAt     DateTime   @updatedAt @map("updated_at")
  @@map("users")
}

model Role {
  id          String           @id @default(uuid()) @db.Uuid
  nama        String           @unique           // "Ketua", "Bendahara", "Kasir"
  deskripsi   String?
  isSystem    Boolean          @default(false) @map("is_system") // role inti tak bisa dihapus
  users       UserRole[]
  permissions RolePermission[]
  @@map("roles")
}

model Permission {
  id    String           @id @default(uuid()) @db.Uuid
  kode  String           @unique   // "simpanan:create", "pinjaman:approve"
  modul String
  roles RolePermission[]
  @@map("permissions")
}

model UserRole {
  userId String @map("user_id") @db.Uuid
  roleId String @map("role_id") @db.Uuid
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  role   Role   @relation(fields: [roleId], references: [id], onDelete: Cascade)
  @@id([userId, roleId])
  @@map("user_roles")
}

model RolePermission {
  roleId       String     @map("role_id") @db.Uuid
  permissionId String     @map("permission_id") @db.Uuid
  role         Role       @relation(fields: [roleId], references: [id], onDelete: Cascade)
  permission   Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
  @@id([roleId, permissionId])
  @@map("role_permissions")
}

model RefreshToken {
  id         String   @id @default(uuid()) @db.Uuid
  userId     String   @map("user_id") @db.Uuid
  tokenHash  String   @unique @map("token_hash")
  expiresAt  DateTime @map("expires_at")
  revokedAt  DateTime? @map("revoked_at")
  user       User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@map("refresh_tokens")
}

// ─────────── KEANGGOTAAN ───────────
enum MemberStatus { ACTIVE INACTIVE RESIGNED }

model Member {
  id           String       @id @default(uuid()) @db.Uuid
  userId       String       @unique @map("user_id") @db.Uuid
  nomorAnggota String       @unique @map("nomor_anggota")
  nik          String?      @unique
  alamat       String?
  tanggalGabung DateTime    @default(now()) @map("tanggal_gabung")
  status       MemberStatus @default(ACTIVE)
  user         User         @relation(fields: [userId], references: [id])
  accounts     Account[]
  loans        Loan[]
  @@map("members")
}

// ─────────── GENERAL LEDGER (double-entry) ───────────
enum AccountKind { ASSET LIABILITY EQUITY REVENUE EXPENSE }

// Chart of Accounts — akun pembukuan koperasi
model LedgerAccount {
  id      String        @id @default(uuid()) @db.Uuid
  kode    String        @unique         // "1000" Kas, "2100" Simpanan Sukarela
  nama    String
  kind    AccountKind
  lines   JournalLine[]
  @@map("ledger_accounts")
}

enum SavingsType { POKOK WAJIB SUKARELA }
enum AccountType  { SAVINGS LOAN }

// Akun milik anggota (saldo simpanan / pinjaman) — turunan dari jurnal
model Account {
  id          String        @id @default(uuid()) @db.Uuid
  memberId    String        @map("member_id") @db.Uuid
  jenisAkun   AccountType   @map("jenis_akun")
  savingsType SavingsType?  @map("savings_type")
  balance     Decimal       @default(0) @db.Decimal(18, 2)
  member      Member        @relation(fields: [memberId], references: [id])
  transactions Transaction[]
  createdAt   DateTime      @default(now()) @map("created_at")
  @@index([memberId])
  @@map("accounts")
}

enum TxType   { DEPOSIT WITHDRAWAL LOAN_DISBURSE LOAN_PAYMENT FEE INTEREST }
enum TxStatus { PENDING POSTED FAILED REVERSED }

model Transaction {
  id             String       @id @default(uuid()) @db.Uuid
  accountId      String       @map("account_id") @db.Uuid
  jumlah         Decimal      @db.Decimal(18, 2)
  jenis          TxType
  status         TxStatus     @default(PENDING)
  idempotencyKey String       @map("idempotency_key")
  journalEntryId String?      @map("journal_entry_id") @db.Uuid
  keterangan     String?
  createdBy      String?      @map("created_by") @db.Uuid
  createdAt      DateTime     @default(now()) @map("created_at")
  account        Account      @relation(fields: [accountId], references: [id])
  journalEntry   JournalEntry? @relation(fields: [journalEntryId], references: [id])
  @@unique([idempotencyKey])
  @@index([accountId, createdAt])
  @@map("transactions")
}

model JournalEntry {
  id          String        @id @default(uuid()) @db.Uuid
  tanggal     DateTime      @default(now())
  keterangan  String
  lines       JournalLine[]
  transactions Transaction[]
  createdAt   DateTime      @default(now()) @map("created_at")
  @@map("journal_entries")
}

model JournalLine {
  id            String        @id @default(uuid()) @db.Uuid
  entryId       String        @map("entry_id") @db.Uuid
  ledgerAccountId String      @map("ledger_account_id") @db.Uuid
  debit         Decimal       @default(0) @db.Decimal(18, 2)
  credit        Decimal       @default(0) @db.Decimal(18, 2)
  entry         JournalEntry  @relation(fields: [entryId], references: [id], onDelete: Cascade)
  ledgerAccount LedgerAccount @relation(fields: [ledgerAccountId], references: [id])
  @@index([entryId])
  @@map("journal_lines")
}

// ─────────── IDEMPOTENCY ───────────
model IdempotencyKey {
  key         String   @id
  endpoint    String
  requestHash String   @map("request_hash")
  statusCode  Int      @map("status_code")
  responseBody Json    @map("response_body")
  createdAt   DateTime @default(now()) @map("created_at")
  @@map("idempotency_keys")
}

// ─────────── SIMPAN PINJAM ───────────
enum LoanStatus { DRAFT PENDING APPROVED ACTIVE PAID_OFF DEFAULTED REJECTED }

model Loan {
  id            String       @id @default(uuid()) @db.Uuid
  memberId      String       @map("member_id") @db.Uuid
  pokok         Decimal      @db.Decimal(18, 2)
  bungaPersen   Decimal      @map("bunga_persen") @db.Decimal(5, 2)  // bunga/bulan
  tenorBulan    Int          @map("tenor_bulan")
  penaltyPersen Decimal      @default(0) @map("penalty_persen") @db.Decimal(5, 2) // custom denda
  status        LoanStatus   @default(DRAFT)
  member        Member       @relation(fields: [memberId], references: [id])
  schedules     LoanSchedule[]
  createdAt     DateTime     @default(now()) @map("created_at")
  @@map("loans")
}

model LoanSchedule {
  id          String    @id @default(uuid()) @db.Uuid
  loanId      String    @map("loan_id") @db.Uuid
  angsuranKe  Int       @map("angsuran_ke")
  jatuhTempo  DateTime  @map("jatuh_tempo")
  pokok       Decimal   @db.Decimal(18, 2)
  bunga       Decimal   @db.Decimal(18, 2)
  dibayar     Decimal   @default(0) @db.Decimal(18, 2)
  lunasAt     DateTime? @map("lunas_at")
  loan        Loan      @relation(fields: [loanId], references: [id], onDelete: Cascade)
  @@index([loanId])
  @@map("loan_schedules")
}

// ─────────── CMS HALAMAN DEPAN ───────────
enum ContentStatus { DRAFT PUBLISHED ARCHIVED }

model Content {
  id        String        @id @default(uuid()) @db.Uuid
  tipe      String        // "artikel" | "promo" | "profil" | "transparansi"
  slug      String        @unique
  judul     String
  isi       String        @db.Text
  coverUrl  String?       @map("cover_url")   // path relatif di folder kopdes/
  status    ContentStatus @default(DRAFT)
  publishedAt DateTime?   @map("published_at")
  createdAt DateTime      @default(now()) @map("created_at")
  @@map("contents")
}

// ─────────── SETTINGS & AUDIT ───────────
model Setting {
  key   String @id    // "admin_path_slug", "nama_koperasi", "anggota_diskon_persen"
  value Json
  @@map("settings")
}

model AuditLog {
  id        String   @id @default(uuid()) @db.Uuid
  userId    String?  @map("user_id") @db.Uuid
  aksi      String   // "LOGIN", "DEPOSIT", "LOAN_APPROVE"
  entitas   String?
  entitasId String?  @map("entitas_id")
  detail    Json?
  ip        String?
  createdAt DateTime @default(now()) @map("created_at")
  @@index([userId, createdAt])
  @@map("audit_logs")
}
```

**Constraint kritis (SQL tambahan via migration — tak bisa diekspresikan murni di Prisma):**
```sql
-- journal_line tidak boleh debit & credit dua-duanya terisi / dua-duanya nol
ALTER TABLE journal_lines ADD CONSTRAINT chk_debit_xor_credit
  CHECK ((debit = 0) <> (credit = 0));

-- saldo & angka uang tak boleh negatif kecuali akun pinjaman
ALTER TABLE journal_lines ADD CONSTRAINT chk_nonneg CHECK (debit >= 0 AND credit >= 0);

-- index pencarian transaksi per anggota (ledger)
CREATE INDEX idx_tx_account_created ON transactions(account_id, created_at DESC);
```
> Validasi "total debit = total credit per entry" ditegakkan di service (lihat §9) dan diverifikasi ulang oleh job rekonsiliasi harian di worker.

---

## 8. Spesifikasi API

- **REST**, prefix `/api`. Versioning `/api/v1`.
- **Response sukses** seragam: `{ "data": ..., "meta": {...} }`.
- **Error** mengikuti **RFC 7807 Problem Details**:
  ```json
  { "type": "https://kopdes/errors/insufficient-funds",
    "title": "Saldo tidak cukup", "status": 422,
    "detail": "Saldo simpanan Rp50.000 < penarikan Rp100.000",
    "instance": "/api/v1/savings/withdraw" }
  ```
- **HTTP status semantik:** `200/201` sukses, `400` validasi, `401` auth, `403` izin, `404` not found, `409` idempotency conflict, `422` aturan bisnis (saldo kurang), `429` rate limit, `500` internal.
- **Header transaksi:** `Idempotency-Key: <uuid>` wajib pada `POST` finansial.
- Contoh endpoint Fase 1: `POST /api/v1/auth/login`, `POST /api/v1/savings/deposit`, `POST /api/v1/savings/withdraw`, `POST /api/v1/loans`, `POST /api/v1/loans/:id/pay`, `GET /api/v1/members/me/dashboard`, `GET /api/v1/admin/eis/summary`, CRUD `/api/v1/cms/contents`.

---

## 9. Output #3 — Service "Setor Simpanan" (TypeScript, aman)

> `backend/src/modules/simpanan/simpanan.service.ts` — transaction `SERIALIZABLE` + advisory lock + idempotency + double-entry. Ini *reference implementation* untuk SEMUA endpoint finansial.

```typescript
import { Prisma, PrismaClient, TxType, TxStatus } from '@prisma/client';
import { createHash } from 'crypto';

const prisma = new PrismaClient();

export class BusinessError extends Error {
  constructor(public status: number, public type: string, message: string) {
    super(message);
  }
}

interface DepositInput {
  accountId: string;
  jumlah: Prisma.Decimal | number; // dipakai sebagai Decimal
  idempotencyKey: string;          // dari header Idempotency-Key
  createdBy: string;               // user id teller/anggota
  keterangan?: string;
}

const MAX_RETRY = 3;

export async function setorSimpanan(input: DepositInput) {
  const requestHash = createHash('sha256')
    .update(`${input.accountId}|${input.jumlah}`)
    .digest('hex');

  // 1) IDEMPOTENCY — kalau key sudah pernah diproses, balikkan respons lama
  const existing = await prisma.idempotencyKey.findUnique({ where: { key: input.idempotencyKey } });
  if (existing) {
    if (existing.requestHash !== requestHash) {
      throw new BusinessError(409, 'idempotency-conflict',
        'Idempotency-Key sudah dipakai untuk request yang berbeda.');
    }
    return existing.responseBody; // sukses sebelumnya → tidak diproses ulang (anti double)
  }

  const jumlah = new Prisma.Decimal(input.jumlah);
  if (jumlah.lte(0)) {
    throw new BusinessError(400, 'invalid-amount', 'Jumlah setoran harus lebih dari nol.');
  }

  // 2) TRANSACTION + retry pada serialization failure (40001)
  for (let attempt = 1; ; attempt++) {
    try {
      const result = await prisma.$transaction(async (tx) => {
        // 3) advisory lock per-akun → serialisasi mutasi akun yang sama
        await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${input.accountId}))`;

        const account = await tx.account.findUniqueOrThrow({ where: { id: input.accountId } });

        // 4) cari akun GL: Kas (debit) & Simpanan anggota (credit)
        const kas       = await tx.ledgerAccount.findFirstOrThrow({ where: { kode: '1000' } });
        const simpananGl = await tx.ledgerAccount.findFirstOrThrow({ where: { kode: '2100' } });

        // 5) JOURNAL ENTRY — wajib balance (debit == credit)
        const debit = jumlah, credit = jumlah;
        if (!debit.equals(credit)) {
          throw new BusinessError(500, 'unbalanced-journal', 'Jurnal tidak balance.');
        }
        const entry = await tx.journalEntry.create({
          data: {
            keterangan: input.keterangan ?? 'Setor simpanan',
            lines: {
              create: [
                { ledgerAccountId: kas.id,        debit,  credit: 0 },
                { ledgerAccountId: simpananGl.id, debit: 0, credit },
              ],
            },
          },
        });

        // 6) catat transaksi (POSTED) + simpan idempotency key
        const trx = await tx.transaction.create({
          data: {
            accountId: account.id, jumlah, jenis: TxType.DEPOSIT,
            status: TxStatus.POSTED, idempotencyKey: input.idempotencyKey,
            journalEntryId: entry.id, createdBy: input.createdBy,
            keterangan: input.keterangan,
          },
        });

        // 7) update saldo turunan (di dalam transaksi yang sama)
        const updated = await tx.account.update({
          where: { id: account.id },
          data: { balance: { increment: jumlah } },
        });

        const response = {
          transactionId: trx.id,
          accountId: updated.id,
          saldoBaru: updated.balance,
          status: trx.status,
        };

        // 8) persist idempotency → request berikutnya dgn key sama balikkan ini
        await tx.idempotencyKey.create({
          data: {
            key: input.idempotencyKey, endpoint: 'POST /api/v1/savings/deposit',
            requestHash, statusCode: 201, responseBody: response as object,
          },
        });

        return response;
      }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 10_000 });

      return result; // COMMIT sukses
    } catch (e: any) {
      // retry hanya untuk serialization_failure
      if (e?.code === 'P2034' /* prisma write conflict */ || e?.meta?.code === '40001') {
        if (attempt < MAX_RETRY) continue;
      }
      throw e; // ROLLBACK otomatis → tidak ada perubahan parsial
    }
  }
}
```

**Controller-nya** menarik `Idempotency-Key` dari header, memanggil service, dan memetakan `BusinessError` ke Problem Details (RFC 7807). Endpoint penarikan (`withdraw`) sama persis kecuali: cek `balance >= jumlah` (lempar `422 insufficient-funds`) dan jurnal terbalik (Dr Simpanan / Cr Kas).

---

## 10. Output #4 — Dashboard Portal Anggota (Next.js + Tailwind)

> `frontend/app/(member)/dashboard/page.tsx` — tampilan setara digital banking. Tema Crimson `#C8102E` / Navy `#1E293B` / Slate `#FAFAFA`. Target sentuh besar, kontras tinggi (WCAG AA).

```tsx
import { ArrowDownToLine, Receipt, Wallet, TrendingUp } from 'lucide-react';

async function getDashboard() {
  const res = await fetch(`${process.env.API_URL}/api/v1/members/me/dashboard`, {
    headers: { cookie: '' /* diteruskan dari server action */ }, cache: 'no-store',
  });
  return res.json();
}

const rupiah = (n: number) =>
  new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(n);

export default async function MemberDashboard() {
  const { data } = await getDashboard();

  return (
    <main className="min-h-screen bg-[#FAFAFA] px-4 py-6 sm:px-8">
      {/* Header */}
      <header className="mx-auto mb-8 flex max-w-5xl items-center justify-between">
        <div>
          <p className="text-sm text-slate-500">Selamat datang,</p>
          <h1 className="text-xl font-bold text-[#1E293B]">{data.nama}</h1>
        </div>
        <span className="rounded-full bg-[#C8102E]/10 px-3 py-1 text-sm font-semibold text-[#C8102E]">
          No. {data.nomorAnggota}
        </span>
      </header>

      {/* Kartu Saldo — gradient premium */}
      <section className="mx-auto grid max-w-5xl gap-4 sm:grid-cols-2">
        <div className="rounded-3xl bg-gradient-to-br from-[#C8102E] to-[#9B0B22] p-6 text-white shadow-lg">
          <div className="flex items-center gap-2 text-sm opacity-90">
            <Wallet className="h-5 w-5" aria-hidden /> Total Simpanan
          </div>
          <p className="mt-3 text-4xl font-bold tracking-tight">{rupiah(data.totalSimpanan)}</p>
          <p className="mt-1 text-sm opacity-80">Pokok + Wajib + Sukarela</p>
        </div>
        <div className="rounded-3xl bg-[#1E293B] p-6 text-white shadow-lg">
          <div className="flex items-center gap-2 text-sm opacity-90">
            <TrendingUp className="h-5 w-5" aria-hidden /> Sisa Pinjaman
          </div>
          <p className="mt-3 text-4xl font-bold tracking-tight">{rupiah(data.sisaPinjaman)}</p>
          <p className="mt-1 text-sm opacity-80">Jatuh tempo: {data.jatuhTempoBerikutnya ?? '—'}</p>
        </div>
      </section>

      {/* Tombol Cepat — target sentuh besar (min 56px), kontras tinggi */}
      <section className="mx-auto mt-6 grid max-w-5xl grid-cols-2 gap-4 sm:grid-cols-3">
        <QuickAction icon={<ArrowDownToLine />} label="Setor Simpanan" href="/setor" primary />
        <QuickAction icon={<Receipt />} label="Bayar Tagihan" href="/bayar" />
        <QuickAction icon={<TrendingUp />} label="Ajukan Pinjaman" href="/pinjaman" />
      </section>

      {/* Riwayat Transaksi (ledger) */}
      <section className="mx-auto mt-8 max-w-5xl rounded-2xl border border-slate-200 bg-white p-2 shadow-sm">
        <h2 className="px-4 py-3 text-lg font-semibold text-[#1E293B]">Riwayat Transaksi</h2>
        <table className="w-full text-left">
          <thead>
            <tr className="border-b border-slate-100 text-sm text-slate-500">
              <th className="px-4 py-2 font-medium">Tanggal</th>
              <th className="px-4 py-2 font-medium">Keterangan</th>
              <th className="px-4 py-2 text-right font-medium">Jumlah</th>
            </tr>
          </thead>
          <tbody>
            {data.riwayat.map((t: any) => (
              <tr key={t.id} className="border-b border-slate-50 text-sm">
                <td className="px-4 py-3 text-slate-500">{t.tanggal}</td>
                <td className="px-4 py-3 font-medium text-[#1E293B]">{t.keterangan}</td>
                <td className={`px-4 py-3 text-right font-semibold ${t.masuk ? 'text-emerald-600' : 'text-[#C8102E]'}`}>
                  {t.masuk ? '+' : '−'} {rupiah(t.jumlah)}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </section>
    </main>
  );
}

function QuickAction({ icon, label, href, primary }: {
  icon: React.ReactNode; label: string; href: string; primary?: boolean;
}) {
  return (
    <a href={href}
       className={`flex min-h-[56px] items-center justify-center gap-2 rounded-2xl px-4 py-4 text-base font-semibold
         shadow-sm transition focus:outline-none focus:ring-4 focus:ring-[#C8102E]/30
         ${primary ? 'bg-[#C8102E] text-white hover:bg-[#9B0B22]'
                   : 'bg-white text-[#1E293B] border border-slate-200 hover:bg-slate-50'}`}>
      <span className="h-6 w-6" aria-hidden>{icon}</span>
      {label}
    </a>
  );
}
```

**Dashboard Admin EIS** (terpisah) memakai Recharts: kartu *likuiditas*, *rasio NPL* (Non-Performing Loans), dan grafik *pertumbuhan anggota aktif* — disuplai `GET /api/v1/admin/eis/summary`.

---

## 11. Keamanan (OWASP Top 10)

| Risiko | Mitigasi |
|---|---|
| A01 Broken Access Control | RBAC dua lapis, server otoritas final, deny-by-default |
| A02 Cryptographic Failures | bcrypt/argon2 password, JWT signed, cookie `HttpOnly`+`Secure`+`SameSite=Lax` |
| A03 Injection | Prisma parameterized, validasi input (zod) di setiap endpoint |
| A04 Insecure Design | Double-entry, idempotency, audit log, isolation ketat |
| A05 Misconfiguration | `.env` di luar git, helmet headers, CORS allowlist |
| A07 Auth Failures | Rate limit login, refresh-token rotation + reuse detection, lockout |
| A08 Integrity Failures | Audit log immutable, constraint DB, rekonsiliasi GL |
| A09 Logging | Audit log + PM2 logs terpusat di `~/logs/` |
| A10 SSRF | Tidak ada fetch URL dari input user tanpa allowlist |

Plus: `predeploy-scan` (semgrep/gitleaks/trivy) wajib lulus sebelum deploy.

---

## 12. Backup / Restore / Migrasi (untuk orang awam)

Prinsip: **"unduh folder `kopdes/` + dump DB"** cukup untuk pindah server.

- **`installer/install.sh`** — wizard interaktif: tanya nama koperasi, buat DB (`developerkdmpmy_kopdes_pg` utama + `_shadow` untuk migrasi), generate `.env` (`DATABASE_URL` + `SHADOW_DATABASE_URL`), `prisma migrate deploy`, seed CoA + role inti + admin pertama, set `admin_path_slug` acak, `pm2 start`. Satu perintah.
- **`installer/backup.sh`** — `pg_dump` → `kopdes/backups/db-YYYYMMDD.sql.gz` + `tar` folder uploads. Bisa dijadwalkan via job pg-boss/cron.
- **`installer/restore.sh`** — pilih file backup → restore DB + extract uploads. Idempotent.
- **Menu di Admin Web** — tombol "Backup Sekarang", daftar backup, "Restore", "Unduh paket migrasi" (zip DB+uploads) — memanggil script di atas via job worker, progress ditampilkan.

---

## 13. Strategi Testing

- **Unit** — service finansial (deposit/withdraw/loan) termasuk uji: idempotency dobel, saldo kurang, jurnal balance.
- **Integration** — uji concurrency: 2 setor + 1 tarik paralel ke akun sama → saldo akhir benar (uji advisory lock + serializable).
- **Idempotency** — kirim request sama 5× → hanya 1 transaksi tercatat.
- **E2E** — alur login → setor → cek dashboard.

---

## 14. Roadmap Fase Berikut (ringkas)

| Fase | Modul | Bergantung pada |
|---|---|---|
| 1 | **Pondasi**: auth/RBAC, anggota, GL, simpanan, pinjaman, CMS, backup | — |
| 2 | Keuangan lanjutan: dana cadangan, SHU, laporan & grafik EIS lengkap | GL Fase 1 |
| 3 | POS + Inventory/Gudang + harga anggota vs non-anggota + sistem poin | GL, anggota |
| 4 | E-commerce + Marketplace + Distributor + Mitra strategis | POS, inventory |
| 5 | Karyawan + Payroll | GL |
| 6 | Tele-health + Klinik/Apotek + LMS | anggota, POS |

Tiap fase: spec → plan → implementasi sendiri, menempel ke pondasi Fase 1.

---

## 15. Keputusan yang Dikunci (2026-05-27)

1. **Bunga pinjaman = flat per bulan.** `LoanSchedule` digenerate dgn pokok rata + bunga flat; arsitektur tetap siap untuk anuitas bila nanti diubah (strategi perhitungan dipisah jadi fungsi tersendiri).
2. **Simpanan Pokok & Wajib = nominal global** disimpan di `settings` (`simpanan_pokok_nominal`, `simpanan_wajib_nominal`), dapat diubah dari Admin.
3. **Mobile = web dulu.** API dirancang mobile-ready (REST + JWT); app React Native (Android/iOS) menyusul setelah API stabil. Fase 1 TIDAK men-scaffold RN.
```
