# HollaPay — AI Agent / Vibe Coder Documentation

> Dokumen ini adalah source of truth untuk agent coding yang mengubah HollaPay.
> Ikuti aturan di sini sebelum menambah route, UI, API, atau integrasi.

## 1. Produk

HollaPay adalah payment platform berbasis QRIS untuk bisnis Indonesia.

Public URLs:

- `/` — marketing one-pager
- `/docs` — dokumentasi integrasi publik (tata letak sama dengan `/dashboard/docs`)
- `/status` — status layanan publik
- `/documentation.md` — acuan lengkap untuk agent coding (berkas yang sama ada di repo, disalin ke `public/`)
- `/contact` — kontak onboarding
- `/login` — autentikasi dashboard
- `/pay/{project}/{amount}?order_id=...` — halaman pembayaran customer
- `/dashboard` — dashboard merchant/operator, wajib login
- `/dashboard/simulasi` — simulasi pembayaran project mode sandbox (lunaskan invoice + kirim webhook)

Brand publik: **HollaPay**.

Jangan menampilkan nama provider, portal, sistem polling internal, alamat LAN, secret, token,
atau detail operasional internal pada halaman publik.

## 2. Stack

- Next.js App Router
- TypeScript
- React
- Prisma
- PostgreSQL
- CSS custom properties; tanpa framework CSS
- Vitest
- Font Awesome Free untuk ikon dashboard
- `qrcode` untuk render QRIS PNG/data URL lokal

Source utama:

```text
src/app/                 route pages
src/components/          shared React components
src/lib/                 domain logic, DB helpers, i18n, auth
src/worker/               background workers
prisma/schema.prisma      database schema
src/app/globals.css       design tokens + component CSS
```

## 3. Routing

Dashboard berada di `/dashboard/*` dan dilindungi middleware.

Jangan mengembalikan dashboard ke `/`. Root `/` adalah marketing page publik.

**Setiap tautan, `redirect()`, dan `revalidatePath()` di dalam dashboard wajib memakai prefiks
`/dashboard`.** Tautan lama seperti `/withdrawals`, `/transactions`, `/kyc`, atau `/projects` akan
mendarat di route publik (atau 404) dan membuat operator kehilangan konteks — bug nyata yang pernah
terjadi pada pesan "KYC disetujui. Lihat halaman Penarikan". Yang tetap absolut tanpa prefiks hanya
route publik (`/`, `/login`, `/register`, `/docs`, `/status`, `/contact`, `/pay/*`) dan API
(`/api/*`). Setelah menyentuh halaman dashboard, periksa dengan:

```bash
grep -rn 'href="/\(withdrawals\|transactions\|kyc\|projects\|users\|issues\|webhooks\|settings\)' src/app/dashboard
```

Public route yang tidak boleh meminta session:

```text
/
/login
/register
/pay/*
/api/*
/docs
/status
/contact
```

Jika menambah halaman publik baru, tambahkan path ke pengecualian middleware dengan alasan jelas.
Semua path dashboard tetap wajib melewati session guard.

## 4. Pembayaran

Flow customer:

1. Merchant membuat invoice.
2. HollaPay membuat nominal total.
3. Customer membuka payment URL.
4. QRIS dirender sebagai PNG/data URL lokal.
5. Customer membayar nominal tepat.
6. Status dicek berkala di halaman pembayaran.
7. Merchant menerima notifikasi/webhook setelah pembayaran terkonfirmasi.

Status transaksi:

```text
PENDING    menunggu pembayaran
PAID       pembayaran terkonfirmasi
EXPIRED    invoice kedaluwarsa
CANCELLED  invoice dibatalkan
```

Jangan menampilkan raw QRIS payload pada halaman customer. Payload hanya boleh muncul pada area
operator yang memang membutuhkannya.

### Kode unik adalah pendapatan platform — merchant tidak boleh melihatnya

Kode unik (`Transaction.uniqueCode`, 100–299) ikut ke dalam `totalPayment` yang discan pelanggan,
dan seluruh nilainya masuk ke akun `PLATFORM_REVENUE` di jurnal. Merchant hanya menerima
`amount` (dikurangi `fee` bila `feeBearer=MERCHANT`), jadi:

- **Merchant/user tidak boleh melihat** baris "Kode unik", payload QRIS mentah, nominal yang benar-
  benar discan (`totalPayment`), maupun angka pendapatan platform.
- **Total yang ditampilkan ke merchant harus sudah dikurangi kode unik.** Gunakan helper di
  `src/lib/format.ts`: `merchantTotal(trx)` untuk satu transaksi, `merchantPendingSum(sum)` dan
  `merchantPaidSum(sum)` untuk hasil agregat. Query agregatnya wajib ikut menjumlahkan
  `uniqueCode`, kalau tidak pengurangannya menjadi nol.
- **Admin tetap melihat angka bruto** (`totalPayment`, kode unik, jurnal, `PLATFORM_REVENUE`),
  karena dialah yang memutuskan uang keluar. Tulis perannya secara eksplisit di JSX:
  `admin ? trx.totalPayment : merchantTotal(trx)`.
- **Kartu saldo di Ikhtisar dan halaman proyek diberi label "Saldo"** (bukan "Hak merchant").
  Sub-kartunya juga wajib versi merchant: "uang masuk" dihitung dari `merchantPaidSum(...)`,
  BUKAN dari total bruto. Kesalahan nyata yang pernah terjadi: kartu menampilkan
  "uang masuk Rp 10.162" untuk tagihan Rp 10.000 — tiga digit kode unik ikut terlihat merchant.
- **Halaman publik dan teks UI tidak menyebut "kode unik".** Itu mekanisme pencocokan internal;
  merchant cukup tahu nominalnya dicocokkan otomatis. Jangan mengembalikannya ke copy publik
  (`home.ts`) maupun ke label UI.
- **Jangan menampilkan angka akun `PLATFORM_REVENUE` di halaman merchant.** Akun itu berisi biaya
  layanan DITAMBAH kode unik, jadi angkanya bukan "biaya" — ia pendapatan platform yang sebagian
  besar adalah kode unik. Kesalahan nyata: kartu "Sudah ditransfer" di halaman Penarikan
  menampilkan "biaya Rp 162", dan 162 itu persis kode unik sebuah transaksi. Kartu itu sekarang
  cukup menyebut jumlah penarikannya. Yang boleh tampil hanya `Withdrawal.fee` (biaya transfer
  BI Fast, Rp2.500) pada baris penarikan, karena itu biaya yang benar-benar dibebankan ke user.
- **Halaman bayar pelanggan** tidak boleh membedah asal-usul tiga digit itu: tampilkan
  `Tagihan`, `Biaya layanan` (bila ada), lalu `Total` — tanpa baris "Kode unik".
- Nominal di dalam QR tetap memuat kode unik dan harus dibayar tepat; yang disembunyikan adalah
  tampilannya, bukan mekanismenya. Jangan pernah mengubah `calcTotal`, `ledgerEntries`, atau
  pencocokan nominal untuk "merapikan" tampilan.

Regresi ini dijaga oleh `scripts/verify-kode-unik-tersembunyi.ts` dan `scripts/verify-rapi-uang.ts`.

### Jangan menaruh paragraf penjelasan/helper di aplikasi live

Ini aplikasi produksi, bukan dokumen onboarding. Teks yang menjelaskan "cara kerja" internal di
dalam halaman dashboard dihapus, bukan diperhalus:

- kartu "Yang diuji di sini" beserta paragraf panjangnya di `/dashboard/simulasi`;
- `description` pada modal simulasi yang mengulang cara pelunasan bekerja;
- paragraf "Integrasi" di halaman proyek yang menjelaskan bentuk jawaban API;
- sub-label teknis seperti "dari jurnal pembayaran" di halaman Penarikan;
- sebutan nama produk pihak lain (mis. "bentuk API sama dengan Pakasir") di UI dashboard.

Yang tetap boleh: label kolom, satuan, dan pesan galat yang benar-benar dibutuhkan operator.
Jangan mengganti paragraf panjang dengan paragraf lebih pendek — hapus saja.

### Satu angka, satu tempat

Halaman Pengaturan TIDAK mengulang hal yang sudah punya halamannya sendiri. Card "Aturan uang"
(biaya transfer, minimum penarikan) dan card "Sistem" (jumlah data, ukuran database, detak worker)
sudah dihapus dari `/dashboard/settings`:

- aturan uang tampil di halaman **Penarikan**, tempat uang itu benar-benar ditarik;
- keadaan sistem tampil di halaman **Status teknis**, tempat angka itu diperbarui.

Yang tersisa di Pengaturan hanya dua hal: kata sandi akun sendiri, dan QRIS merchant (admin).
Jangan menambahkan card ringkasan kembali ke sana: dua halaman yang menampilkan angka yang sama
cepat atau lambat akan saling bertentangan, dan yang salah justru yang sedang dibuka operator.

Saat menghapus sebuah card, sapu juga kode matinya: query yang hanya dipakai card itu (mis.
`prisma.workerHeartbeat.findMany`, `pg_database_size`, dan hitungan `count`), scope yang tidak lagi
terpakai, import yang jadi nganggur, dan kunci i18n-nya. Query yang tertinggal tetap dijalankan
setiap halaman dibuka — memanggil database tanpa alasan. Periksa dengan `npm run audit:i18n`
(kunci tak terpakai dilaporkan) dan `npx tsc --noEmit` (import/scope nganggur ketahuan).

### Simulasi pembayaran hanya untuk project sandbox

`/dashboard/simulasi` melunaskan invoice **project mode sandbox** tanpa uang nyata, lalu webhook
tetap terkirim seperti pembayaran sungguhan. Aturan yang mengikat:

- **Hanya `Project.sandbox = true`.** Aksi server menolak project produksi (`simulasiBukanSandbox`),
  bukan sekadar menyembunyikan tombolnya — kalau tidak, invoice produksi bisa ditandai lunas tanpa
  uang masuk.
- **Project tetap harus `approval = APPROVED` dan `status = ACTIVE`**, sama seperti jalur bayar
  sungguhan, supaya yang diuji memang kondisi yang berlaku.
- **Pelunasan memakai jalur yang sama**: `simulatePayment()` → `settleLocked()`. Jangan membuat
  jalur pelunasan kedua hanya untuk simulasi; jurnal, status, bentuk payload, dan header
  `x-gateway-signature` harus identik dengan pembayaran nyata.
- **URL webhook uji tidak boleh menulis ulang `Project.webhookUrl`.** URL itu diteruskan sebagai
  `webhookUrlOverride` dan hanya tersimpan pada baris `WebhookDelivery` milik simulasi tersebut.
  URL-nya wajib lolos `validasiWebhookSimpan()` (https, tanpa kredensial di URL, bukan host internal).
- **Uang simulasi TIDAK PERNAH MASUK SALDO.** `merchantBalance()`, `accountBalances()`,
  `balancesByUser()`, `heldBalance()`, dan `heldBreakdown()` semuanya mengecualikan baris jurnal
  yang transaksinya `sandbox = true`. Jadi saldo hak merchant tidak pernah memuat uang uji, dan
  halaman Penarikan tidak punya sebab "ditahan karena sandbox" — tidak ada yang perlu ditahan
  karena uangnya memang tidak pernah masuk. Jangan menambahkan kembali sebab itu.
- **Mode dibekukan per transaksi.** `Transaction.sandbox` diisi saat invoice DIBUAT
  (`createTransaction`) dan tidak pernah diubah lagi. Semua tampilan (daftar transaksi, webhook log,
  ikhtisar, halaman simulasi) membaca kolom ini, BUKAN `Project.sandbox`. Alasannya: kalau membaca
  `Project.sandbox`, mengubah mode proyek akan melabeli ulang transaksi lama — transaksi uji bisa
  tampil sebagai transaksi produksi, dan uang simulasi bisa ikut terhitung sebagai saldo nyata
  (celah cetak uang). Jangan "menyederhanakan" ini kembali ke `project.sandbox`.
- **Proyek sandbox → live menghapus transaksi ujinya.** `updateProject` dan `toggleSandboxAction`
  memanggil `purgeSandboxTransactions(projectId)` saat `Project.sandbox` berubah dari true ke false:
  transaksi `sandbox = true`, jurnalnya, dan antrean webhook-nya dihapus. Peristiwa pembayaran mentah
  hanya dilepas tautannya (tidak dihapus) supaya jejak uang masuk tetap ada. Ini yang membuat
  "transaksi sandbox hilang" saat proyek dijadikan live.
- **Kolom `sandbox` pada formulir hanya disentuh kalau field-nya dikirim.** `updateProject` memakai
  `formData.has('sandbox')`; form pengaturan di halaman detail proyek tidak punya checkbox itu, jadi
  tanpa penjagaan ini setiap penyimpanan pengaturan akan mematikan mode sandbox tanpa disadari.
- **Klien tetap boleh memakai API** `POST /api/paymentsimulation` dengan `api_key` project sandbox.
  Halaman dashboard memakai aksi server supaya operator tidak perlu menempel API key di UI.

### Invoice sandbox memakai kunci pencocokan sendiri

Merchant boleh membuat invoice lewat API pada project sandbox (`/api/transactioncreate`) dan
membukanya di `/pay/{slug}/{amount}` — itu memang cara menguji alur end-to-end. Karena itu ada dua
pagar yang wajib dipertahankan:

1. **`merchantKey` invoice sandbox = `SANDBOX:{projectId}`**, bukan NMID merchant
   (`merchantKeySandbox()` di `src/lib/simulasi.ts`, dipakai `createTransaction`).
   Alasannya konkret: `ingestPayment` mencocokkan uang nyata lewat NMID. Kalau invoice sandbox
   memakai NMID yang sama, pelanggan yang men-scan QR uji dengan uang sungguhan akan **melunasi
   invoice uji** — invoice ditandai lunas, webhook `transaction.paid` terkirim, tetapi saldonya
   ditahan permanen karena project-nya sandbox. Uang nyata yang tidak bisa ditarik itu kerugian
   yang tidak perlu. Dengan kunci terpisah, pembayaran nyata tidak menemukan invoice sandbox dan
   tercatat sebagai pembayaran tanpa invoice — peringatan operator, bukan uang hilang.
2. **Halaman bayar menandai dirinya sandbox** (`.sandbox-banner` di `components/checkout.tsx`,
   teks `pay.sandboxJudul` / `pay.sandboxIsi`). QR uji tidak boleh tampil seperti tagihan biasa.

Jangan menyatukan kembali kunci itu "supaya konsisten", dan jangan menghapus spanduknya karena
dianggap mengganggu: keduanya yang mencegah uang nyata masuk ke invoice uji.

Regresi dijaga oleh `scripts/verify-simulasi.ts`.

Jangan mengubah nominal matching, kode unik, expiry, ledger, signature webhook, atau status
transaksi tanpa memeriksa `src/lib/core.ts`, `src/lib/qris.ts`, schema, test, dan integrasi merchant.

## 5. API contract

API adalah kontrak publik. Sebelum mengubah endpoint:

- pertahankan nama field yang sudah digunakan client;
- pertahankan status HTTP;
- pertahankan bentuk error yang sudah dikonsumsi client;
- validasi input di boundary;
- jangan mengembalikan secret, token, raw credential, atau stack trace;
- gunakan idempotency untuk operasi yang dapat membuat transaksi atau withdraw;
- verifikasi signature webhook sebelum memproses event;
- uji unauthorized, malformed payload, duplicate event, dan replay.

Dokumentasi teknis detail berada di dashboard docs, bukan di halaman marketing. Halaman `/docs`
memakai susunan dan gaya visual yang sama dengan `/dashboard/docs` (lihat bagian 13); perbedaannya
hanya data: versi publik tidak memuat slug project nyata, NMID, webhook URL, atau kredensial.

## 6. Auth dan keamanan

- Session cookie: httpOnly, secure saat HTTPS, sameSite=lax.
- Jangan mengetik password, card number, CVC, atau OTP lewat agent/browser tool.
- Jangan commit `.env`, secret, token, credential, backup credential, atau generated session.
- Jangan menonaktifkan middleware hanya untuk memudahkan test.
- Redirect URL harus divalidasi; hanya URL internal atau URL yang memang diizinkan.
- Webhook invalid harus ditolak.
- Public status endpoint hanya boleh membocorkan status yang memang publik.

## 7. UI/UX rules

Gunakan design system yang sudah ada di `src/app/globals.css`:

- satu accent color;
- status color hanya untuk ok/warn/danger;
- border tipis;
- hierarchy lewat spacing dan type scale;
- jangan menambah gradient/glass/heavy shadow tanpa alasan desain;
- mobile harus bekerja dari 360–390px tanpa horizontal overflow. Verifikasi dengan MENGUKUR,
  bukan melihat: `document.documentElement.scrollWidth` harus sama dengan lebar viewport di 360px;
- topbar adalah titik rawan overflow: blok metriknya (`.topbar-metrics`) disembunyikan di layar
  sempit, dan `.topbar-title` wajib `min-width: 0` + `text-overflow: ellipsis`;
- gunakan `data-label` jika membuat tabel responsive-card;
- tombol mutating action gunakan modal/pola yang sudah ada;
- jangan menampilkan helper paragraph yang mengulang validation rule;
- jangan membuat section kosong atau data demo terlihat seperti customer nyata;
- jangan memakai emoji sebagai logo brand; gunakan CSS/SVG yang stabil bila membuat brand mark.

Marketing one-pager harus tetap fokus:

```text
navbar
hero + CTA
proof/trust jika datanya nyata
benefits
how it works
products
CTA
footer
```

Jangan menaruh nama project demo di social proof. Jangan klaim angka, customer, SLA, compliance,
fee, atau fitur yang belum diverifikasi di source code/DB.

## 8. Internationalization

Semua UI text wajib masuk ke bundle i18n:

```text
src/lib/i18n/messages/*.ts
```

Setiap key harus tersedia di `id` dan `en`.

Jalankan:

```bash
npm run audit:i18n
```

Jangan menaruh istilah provider/internal pada copy publik. Jangan hardcode teks user-facing di JSX.

## 9. Dokumentasi untuk agent

Sebelum coding:

1. Baca `documentation.md`.
2. Baca route/page/component terkait.
3. Cari test yang sudah ada.
4. Cari penggunaan field/API sebelum mengubah schema/contract.
5. Pastikan route baru tidak bertabrakan dengan route group.
6. Tentukan apakah perubahan publik atau internal.

Setelah coding:

```bash
npx tsc --noEmit
npm run audit:i18n
npm run audit:css
npx vitest run
npm audit
```

Untuk deployment:

```bash
sh scripts/deploy-app.sh
```

Deploy belum dianggap selesai sampai:

- container healthy;
- `/api/health` HTTP 200;
- route yang diubah diuji dari domain publik;
- mobile tidak overflow;
- tidak ada secret ikut staged/committed;
- git commit/push tercatat.

## 10. Visual QA

Untuk perubahan UI, verifikasi minimal:

- desktop 1280px;
- mobile 390px;
- light theme;
- dark theme jika komponen dipakai dashboard;
- inspect computed layout, bukan hanya status HTTP;
- cek `scrollWidth > clientWidth` untuk horizontal overflow;
- cek link baru tidak 404.

Capture failed bukan bukti UI rusak. Jika screenshot tool gagal, gunakan DOM/computed-style check
atau fallback CDP yang tersedia.

## 11. Git dan scope

- Perubahan kecil, mudah direview.
- Jangan refactor file yang tidak terkait.
- Jangan menghapus fitur domain hanya karena UI tidak menampilkannya.
- Jangan mengubah data produksi untuk membuat demo terlihat bagus.
- Commit message harus menjelaskan perubahan user-facing atau contract-facing.
- Sebelum push:

```bash
git status --short
git diff --check
git diff --stat
git status --short | grep -E '\.env|credentials|tokens|backups/'
```

Expected result untuk pemeriksaan secret: tidak ada file rahasia.

## 12. Definition of done

Task selesai hanya jika:

- acceptance criteria terpenuhi;
- source typecheck bersih;
- test lulus;
- route live sudah diverifikasi;
- UI responsive diverifikasi bila relevan;
- tidak ada internal plumbing/secret bocor;
- dokumentasi diupdate bila contract atau route berubah;
- untuk perubahan UI dashboard: jalankan `scripts/verify-kode-unik-tersembunyi.ts` dan pastikan
  tautan internal masih berprefiks `/dashboard`;
- commit dan deploy status dilaporkan dengan singkat.


## 13. Dokumentasi integrasi (`/docs` dan `/dashboard/docs`)

Ada dua halaman dokumentasi dan keduanya wajib memakai **susunan serta gaya visual yang sama**;
yang berbeda hanya datanya, bukan tata letaknya. `/docs` adalah versi publik, `/dashboard/docs`
adalah versi setelah login.

### Perbedaan data (bukan tata letak)

| | `/docs` | `/dashboard/docs` |
|---|---|---|
| Akses | publik, tanpa sesi | wajib sesi dashboard |
| Slug project | placeholder `<slug>` | slug project nyata |
| API key | placeholder `<API_KEY>` | tetap placeholder |
| NMID / QRIS aktif | tidak ditampilkan | ditampilkan dari `MerchantConfig` |
| Webhook URL terpasang | tidak ditampilkan | daftar `slug → webhookUrl` |
| Perkakas akun (`scripts/create-user.ts`) | tidak ditampilkan | ditampilkan |
| Tautan `documentation.md` | ditampilkan | ditampilkan |

Jangan pernah menaikkan slug project nyata, NMID, webhook URL, atau kredensial ke `/docs`.

**`documentation.md` bisa dikonsumsi publik.** Berkasnya disalin ke `public/documentation.md`
sehingga bisa dibuka di `/documentation.md` tanpa login, dan ditautkan dari `/docs` (di bawah
pengantar) serta `/dashboard/docs` (di kepala halaman). Karena itu isinya wajib tetap bersih dari
nama portal/host partner, NMID, alamat LAN, dan kredensial. Setelah mengubah `documentation.md`,
salin ulang ke `public/`:

```bash
cp documentation.md public/documentation.md
```

### Tata letak wajib (sama di kedua halaman)

1. `public-docs` / `.content` sebagai kolom tunggal ber-`gap: 22px`;
2. satu `.card` per bagian — `.card-head` berisi `<h2>` bernomor, lalu `.card-body`;
3. nomor bagian berurutan `1…12` ditambah kartu **Endpoint ringkas** di akhir;
4. setiap endpoint memakai pola tetap: **badge metode + path**, lalu `Permintaan`, `Jawaban`, dan
   `Ketentuan` (sebagai `dl.kv`, `ol.steps`, atau `ul.list` — bukan paragraf);
5. blok contoh memakai `.code-block` (mono, `white-space: pre`), bukan blok kutipan berwarna;
6. judul halaman memakai `.page-head` + `.page-sub`, diikuti satu `.public-lead` sebagai pengantar;
7. tombol aksi di akhir halaman memakai `.public-actions`.

### Isi kedua halaman

Bagian 1–12 (urut sama di kedua halaman):

```text
1.  Alur akun                    7.  Membatalkan transaksi
2.  Penarikan saldo              8.  Sandbox dan simulasi
3.  Ikhtisar integrasi           9.  Webhook
4.  URL halaman pembayaran       10. Laporan masalah
5.  Membuat transaksi            11. Multi-user dan kepemilikan
6.  Detail transaksi             12. Ketentuan umum
```

Isinya harus selalu konsisten dengan endpoint yang benar-benar tersedia di source code, bukan
contoh API provider lain.

### Ketika salah satu halaman diubah

Menambah, menghapus, atau mengubah satu bagian berarti menyentuh **kedua** halaman: bagian 1–12 di
`/docs` dan `/dashboard/docs` harus tetap sinkron. Setelah mengubahnya jalankan:

```bash
npx tsc --noEmit
npm run audit:css
npm run audit:i18n
```

lalu verifikasi dari HTML yang sudah dirender (bukan hanya status HTTP):

- hitung `<section class="card">` dan bandingkan daftar `<h2>`-nya dengan daftar bagian di atas;
- pastikan tidak ada bagian yang kehilangan isi (kartu berjudul tanpa `<dt>`/`<dd>`/`<li>` di dalamnya);
- pastikan `/docs` tidak memuat slug project nyata, NMID, webhook URL, atau kredensial.

### Isi rinci tiap bagian

Bagian berikut merinci isi yang wajib ada pada kedua halaman. Di `/docs`, nilai yang bersifat
internal diganti placeholder.

#### Akun, KYC, dan project

1. User mendaftar/masuk melalui `/login`; akun baru berperan `USER`.
2. User mengirim KYC dari dashboard. Dokumen KYC dapat dibaca admin melalui
   `/api/kyc-document/<userId>` setelah session admin diverifikasi.
3. User membuat project. Project baru menunggu persetujuan admin; request API sebelum disetujui
   ditolak dengan `403 project_pending_approval`.
4. Setelah project aktif, gunakan `slug`, API key project, webhook URL, dan webhook secret sesuai
   scope project. Jangan pernah menaruh API key atau webhook secret di halaman publik.

#### URL pembayaran

```text
/pay/{slug}/{amount}?order_id={order_id}
```

Parameter:

- `slug`: slug project aktif;
- `amount`: nominal dasar invoice;
- `order_id`: ID order unik merchant;
- `redirect`: URL internal/yang diizinkan setelah pembayaran;
- `qris_only=1`: memaksa tampilan QRIS.
- Invoice yang sudah kedaluwarsa/lunas/dibatalkan **tidak** menampilkan QR lagi; buat transaksi baru dengan `order_id` baru.

#### Endpoint pembayaran

```text
POST /api/transactioncreate/{method}
GET  /api/transactiondetail?project=…&amount=…&order_id=…&api_key=…
POST /api/transactioncancel
POST /api/paymentsimulation
GET  /api/v1/transactions/{order_id}
GET  /api/v1/qr/{order_id}
```

`paymentsimulation` hanya untuk project sandbox. Endpoint mutating wajib divalidasi, scoped ke
project, dan idempotent. Status transaksi: `pending`, `completed`, `expired`, `canceled`.

Permintaan ulang dengan `order_id` yang sama mengembalikan invoice yang sama — kecuali invoice itu sudah `expired`/`canceled`, maka invoice **baru** dibuat (kode unik dan masa berlaku baru). `GET /api/v1/qr/{order_id}` menjawab `409 invoice_not_payable` untuk invoice yang tidak bisa dibayar.

#### Webhook

Webhook dikirim sebagai `POST` setelah transaksi terkonfirmasi. Client wajib memverifikasi:

```text
x-gateway-signature
x-gateway-project
x-gateway-delivery
```

Signature dihitung memakai rumus yang ditentukan oleh project; jangan memproses payload sebelum
signature valid. Simpan `x-gateway-delivery` untuk deduplikasi. Pengiriman gagal dapat diperiksa
dan dikirim ulang dari dashboard → Webhook.

#### Penarikan

Merchant mengajukan penarikan dari dashboard setelah KYC disetujui, rekening bank tersimpan, dan
saldo mencukupi. Admin dapat menyetujui atau menolak. Penarikan otomatis mengikuti status KYC,
rekening, saldo, dan konfigurasi gateway; jangan mengubah ledger manual.

Saldo yang tampil TIDAK PERNAH memuat uang dari transaksi sandbox — uang uji dikeluarkan dari seluruh
perhitungan saldo, bukan sekadar ditahan. Yang masih ditahan hanya: project yang belum disetujui
admin, dan uang yang belum lewat masa tahan H+2.

#### Status layanan

- `/status`: status publik layanan (tiga baris, tanpa detail internal);
- `/api/health`: health check teknis;
- `/dashboard/status`: ringkasan kesehatan per kelompok layanan untuk operator yang sudah login.

**`/dashboard/status` sengaja ringkas dan tidak membeberkan plumbing internal.** Yang ditampilkan
hanya tiga kelompok (Pembayaran QRIS, Aplikasi & API, Notifikasi pembayaran) beserta keadaannya.
Yang TIDAK boleh ditampilkan: nama host/portal partner, alamat target monitor, jenis
pemeriksaan (`http`/`db`/`freshness`/`queue`/`heartbeat`), latensi, galat mentah, dan status code.
Halaman ini bisa dibuka setiap operator yang sudah masuk — bukan hanya admin — jadi jangan
mengembalikan detail teknis ke sini. Kamusnya (`status.layanan_*`) dibangun sebagai literal, bukan
template literal, supaya `audit:i18n` tetap melihatnya.

Daftar layanannya memakai kelas `.svc-row` (punya `padding` sendiri), BUKAN `.public-status-row`:
baris di dalam `.card-body.tight` (padding 0) akan menempel dan meluber keluar tepi kartu.
`.svc-info` wajib `min-width: 0` supaya nama layanan membungkus, bukan mendorong kolom status
keluar layar.

Jangan menyalin kredensial, token sesi, webhook secret, atau data project produksi ke issue,
commit, dokumentasi publik, atau contoh curl.
