diff --git a/docs/integration-weight-based-products.md b/docs/integration-weight-based-products.md
new file mode 100644
index 0000000..61b5b16
--- /dev/null
+++ b/docs/integration-weight-based-products.md
@@ -0,0 +1,269 @@
+# Integrasi Produk Timbangan — POS Mobile & Backoffice
+
+**Migrasi:** `000089` · **Base URL:** `/api/v1` · **Kompatibilitas:** mundur penuh
+
+Panduan untuk menjual produk per timbangan (ikan, daging, buah) dari sisi klien.
+Alasan di balik setiap keputusan desain ada di [`rfc-weight-based-products.md`](./rfc-weight-based-products.md).
+
+---
+
+## 1. Konsep inti
+
+**Satu penimbangan = satu baris order.**
+
+Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Itu **dua
+baris terpisah**, karena keduanya dua ikan berbeda yang ditimbang sendiri-sendiri.
+
+| Bukan begini | Melainkan begini |
+|---|---|
+| `quantity: 9.8` | `quantity: 1, weight: 4.2`
`quantity: 1, weight: 5.6` |
+| Dua ikan hilang jejaknya, dan `quantity` bertipe integer sehingga pecahan ditolak | Tiap penimbangan berdiri sendiri, bisa di-void atau dibayar terpisah |
+
+Empat aturan yang berlaku di seluruh dokumen ini:
+
+1. `quantity` untuk produk timbangan **selalu 1**. Backend memaksanya, dan database
+ menolak nilai lain lewat constraint `chk_order_items_weight_single_line`.
+2. `weight` menyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram —
+ apa pun yang dipilih saat setup).
+3. Harga baris dihitung `weight × unit_price`, bukan `quantity × unit_price`.
+ `unit_price` tetap berarti harga per satu satuan (per ons).
+4. **Jangan pernah menggabungkan dua baris** produk timbangan menjadi satu, meski
+ produknya sama.
+
+Untuk produk biasa tidak ada yang berubah: `weight` tidak dikirim, `quantity` tetap
+cacah seperti sekarang.
+
+---
+
+## 2. Backoffice — setup produk
+
+### 2.1 Pastikan satuannya ada
+
+Satuan disimpan per organisasi. Buat sekali, pakai ulang untuk semua produk timbangan.
+
+`POST /api/v1/units`
+
+```json
+{
+ "name": "Ons",
+ "abbreviation": "ons",
+ "is_active": true
+}
+```
+
+`abbreviation` yang dipakai POS untuk mencetak `4,2 ons` di struk — isi dengan bentuk
+pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/v1/units`.
+
+### 2.2 Buat produk sebagai produk timbangan
+
+`POST /api/v1/products`
+
+```json
+{
+ "category_id": "…",
+ "name": "Ikan Tude",
+ "price": 4500,
+ "cost": 3000,
+ "sell_by": "weight",
+ "unit_id": ""
+}
+```
+
+| Field | Tipe | Keterangan |
+|---|---|---|
+| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain ditolak validator. |
+| `unit_id` | UUID | Opsional di backend, tapi **wajib secara praktik** untuk `sell_by: "weight"` — lihat §8. |
+| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. |
+
+Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan
+ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`,
+`/products/:id`).
+
+### 2.3 Catatan UI
+
+- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit`
+ ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per
+ cacah — tapi akan membingungkan pengguna yang melihat riwayatnya.
+- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form.
+- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*.
+
+---
+
+## 3. POS Mobile — transaksi
+
+### 3.1 Bentuk input mengikuti `sell_by`
+
+| `sell_by` | Input di POS | Yang dikirim |
+|---|---|---|
+| `"unit"` | Stepper − / + seperti sekarang | `quantity: n`, tanpa `weight` |
+| `"weight"` | Papan angka desimal, satuan dari `unit` produk | `quantity: 1` + `weight: 4.2` |
+
+### 3.2 Mengirim order
+
+`POST /api/v1/orders`
+
+```json
+{
+ "outlet_id": "…",
+ "user_id": "…",
+ "order_type": "dine_in",
+ "order_items": [
+ { "product_id": "", "quantity": 1, "weight": 4.2 },
+ { "product_id": "", "quantity": 1, "weight": 5.6 },
+ { "product_id": "", "quantity": 2 }
+ ]
+}
+```
+
+Bentuk yang sama berlaku untuk `POST /api/v1/orders/:id/add-items` dan untuk pemesanan
+mandiri `POST /api/v1/self-order/orders`.
+
+**Presisi.** Berat dibulatkan backend ke 3 desimal. Kirim `4.2` atau `4.237`; angka di
+bawah `0.001` membulat ke nol dan ditolak.
+
+**Harga.** Field `unit_price` pada request **diabaikan** — harga selalu diambil backend
+dari master produk. Jangan mengirim harga hasil hitungan sendiri.
+
+---
+
+## 4. Menampilkan baris
+
+Setiap `order_items[]` di response membawa empat field tambahan:
+
+```json
+{
+ "product_name": "Ikan Tude",
+ "quantity": 1,
+ "weight": 4.2,
+ "unit_id": "…",
+ "unit_name": "Ons",
+ "unit_abbreviation": "ons",
+ "unit_price": 4500,
+ "total_price": 18900
+}
+```
+
+Semuanya `null` atau absen untuk produk biasa, jadi cabangkan tampilan pada `weight`:
+
+| Kondisi | Tampilkan |
+|---|---|
+| `weight == null` | `2 × Rp 25.000` |
+| `weight != null` | `4,2 ons × Rp 4.500` |
+
+**Jangan menampilkan `quantity` untuk baris berbobot** — nilainya selalu 1 dan akan
+terbaca seperti "satu ons". Gunakan `weight` dengan `unit_abbreviation`, dan pakai koma
+desimal sesuai format Indonesia.
+
+---
+
+## 5. Void, refund, split bill
+
+Baris berbobot bersifat **utuh**: dibatalkan seluruhnya atau tidak sama sekali. Karena
+`quantity`-nya 1, semua endpoint cukup dikirimi `1`, dan backend menghitung nilai
+rupiahnya dari `weight`.
+
+| Aksi | Endpoint | Field untuk baris berbobot |
+|---|---|---|
+| Void per item | `POST /orders/void` | `items[].quantity: 1` |
+| Refund per item | `POST /orders/:id/refund` | `order_items[].refund_quantity: 1` (atau kosongkan) |
+| Split bill per item | `POST /orders/split-bill` | `items[].quantity: 1` = bayar baris itu penuh |
+
+```json
+{
+ "order_id": "…",
+ "reason": "Salah timbang",
+ "type": "ITEM",
+ "items": [
+ { "order_item_id": "", "quantity": 1 }
+ ]
+}
+```
+
+Untuk split bill, baris berbobot hanya bisa berstatus belum dibayar atau lunas — tidak
+ada nilai di antaranya. Sembunyikan stepper jumlah pada baris berbobot, ganti dengan
+tombol pilih baris.
+
+**Batasan yang disengaja.** Mengembalikan *sebagian berat* — 1 ons dari baris 4,2 ons —
+tidak didukung. Koreksi salah timbang ditangani dengan void baris itu lalu input ulang,
+sehingga jejak auditnya tetap jujur.
+
+---
+
+## 6. Referensi error
+
+Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `900`:
+
+```json
+{
+ "success": false,
+ "data": null,
+ "errors": [
+ { "code": "900", "entity": "ORDER",
+ "cause": "product Ikan Tude is sold by weight and requires a weight" }
+ ]
+}
+```
+
+| Pesan (`cause`) | Penyebab | Perbaikan di klien |
+|---|---|---|
+| `… is sold by weight and requires a weight` | Produk `sell_by: "weight"` dikirim tanpa `weight` | Wajibkan input timbangan sebelum item masuk keranjang |
+| `… is not sold by weight and must not carry a weight` | `weight` dikirim untuk produk satuan | Kirim `weight` hanya bila `sell_by == "weight"` |
+| `weight for … must be greater than 0` | Berat nol, negatif, atau membulat ke nol | Validasi minimal `0.001` di keypad |
+| `quantity for … must be at least 1` | Produk satuan dengan `quantity` ≤ 0 | Perilaku lama, tidak berubah |
+
+Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir.
+
+---
+
+## 7. Kompatibilitas mundur
+
+- Semua produk lama otomatis `sell_by: "unit"`. Perilakunya identik dengan sebelumnya.
+- `weight` opsional di request. Klien yang tidak mengenalnya tetap berfungsi penuh untuk
+ produk satuan.
+- Field baru di response semuanya `omitempty` — tidak muncul untuk baris biasa, jadi
+ parser lama tidak terganggu.
+- `quantity` tetap **integer** di seluruh API. Tidak ada field yang berubah tipe.
+
+Yang tidak berfungsi di klien lama hanyalah menjual produk timbangan itu sendiri —
+permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam.
+
+---
+
+## 8. Batasan yang diketahui
+
+> **Perlu ditangani di frontend.** Backend **belum** memaksa `unit_id` terisi saat
+> `sell_by: "weight"`. Produk timbangan tanpa satuan akan tersimpan, tapi baris ordernya
+> keluar dengan `unit_abbreviation: null` — struk tidak bisa mencetak "ons". Sampai
+> validasi itu ditambahkan di backend, **Backoffice wajib mewajibkannya di form**.
+
+- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`,
+ bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu
+ diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat).
+- **Stok belum otomatis berkurang** saat penjualan — untuk produk timbangan maupun
+ produk biasa. Pengurangan stok belum tersambung di backend, jadi jangan menampilkan
+ sisa stok yang mengandaikan itu berjalan.
+
+---
+
+## 9. Checklist per tim
+
+**Backoffice Website**
+
+- [ ] CRUD satuan tersedia di menu master data
+- [ ] Form produk punya pilihan cara jual: Satuan / Timbangan
+- [ ] Pemilih satuan menjadi wajib saat Timbangan dipilih
+- [ ] Label harga ikut satuan — "Harga per ons"
+- [ ] `sell_by` dikunci untuk produk yang sudah bertransaksi
+- [ ] Daftar produk menandai mana yang dijual per timbangan
+- [ ] Laporan harian menampilkan kolom Berat
+
+**POS Mobile**
+
+- [ ] Menu membaca `sell_by` tiap produk
+- [ ] Papan angka desimal untuk produk timbangan
+- [ ] Kirim `quantity: 1` + `weight`
+- [ ] Dua penimbangan menjadi dua baris, tidak digabung
+- [ ] Keranjang & struk menampilkan `4,2 ons × Rp 4.500`
+- [ ] Void & refund baris berbobot bersifat utuh
+- [ ] Split bill: pilih baris, bukan stepper jumlah
+- [ ] Pesan error validasi ditampilkan ke kasir