# LabaBersih — Gap Closure Plan (v1 → v2 Cutover)

> Dokumen ini adalah execution plan untuk menutup SEMUA gap antara v1 dan v2.
> Dibuat: 2 April 2026 | Verified langsung dari source code.
> Prinsip: Part by part. Setiap part: migrate → schema → context → test → UI → deploy.
> JANGAN loncat part. JANGAN gabung part.

---

## Status Verified (2 April 2026)

### Yang SUDAH ADA di v2 (jangan bikin ulang):
- ship_order atomic 5 step (status + FEFO consume + jurnal + HPP + audit) ✅
- FEFO/FIFO lot consumption (`consume_lots`) ✅
- Period closing (3 jurnal penutup + sequential + reopen) ✅
- Per-fee reconciliation + suspense account (61-999) ✅
- Void journal entry ✅
- Order delete + reverse stok + void jurnal ✅
- XLSX parsers (Shopee, TikTok, Mengantar) + settlement parser ✅
- Laporan Keuangan (Laba Rugi, Neraca, Trial Balance, Tutup Buku) ✅
- Buku Besar (general ledger + running saldo) ✅
- Settings (Profil, Organisasi, Entitas, Anggota) ✅
- Pajak (PPh Final + PKP threshold) ✅
- Transfer stok antar gudang (`transfer_stock/5`) ✅
- RTS Management (detect, hold, retry, resolve_saved/returned) ✅
- Claims backend (accept_claim, reject_claim, list_claims) ✅
- Daily Sales Journal batch (Oban worker) ✅
- Bundle products + unit conversions ✅
- Stock reservation (reserve, release, available) ✅
- Advisory lock ID generation ✅
- 36 migrations, 27 test files ✅

---

## Gap Summary (10 Parts, ~16 session)

| Part | Scope | Effort | Dependency |
|---|---|---|---|
| **A** | Pelanggan (Customer Master) | 2 session | Independent |
| **B** | Gudang Operations (Opname, Mutasi, Monitoring, Pemusnahan) | 3 session | Independent |
| **C** | Return Enhancement (Import XLSX, Klaim UI, Inbound UI) | 2 session | Independent |
| **D** | Accounting Enhancement (Input Biaya, Utang/AP) | 1 session | Independent |
| **E** | Auth Flow (Lupa Password, Invite Email, OAuth Refresh) | 1 session | Independent |
| **F** | Staging Pipeline UI (Sync Preview, Resi Import) | 2 session | Independent |
| **G** | Integrasi Platform (Settings UI, API Sync, Webhook) | 3 session | F selesai dulu |
| **H** | Infrastructure (Sentry, Email, File Storage) | 1 session | Independent |
| **I** | Marketing (Dashboard, Ads Input) | 1 session | G selesai dulu |
| **J** | Data Migration (v1 → v2 Cutover) | 1 session | Semua selesai |

### Execution Order

```
Parallel batch 1: A + B + C + D + E + H (independent, 6 parts)
Parallel batch 2: F (staging pipeline)
Sequential: G (integrasi, butuh F)
Sequential: I (marketing, butuh G)
Final: J (data migration, butuh semua)
```

---

## PART A: Pelanggan (Customer Master)

### Kenapa PENTING (bukan tier 3)

1. **CRM Foundation** — tanpa customer master, gak bisa bikin:
   - Repeat customer analytics (siapa yang beli berkali-kali?)
   - Audience overlap (customer Shopee yang juga beli di TikTok)
   - Customer lifetime value (CLV)
   - RTS rate per customer (blacklist yang sering RTS)
   - Segment: agen vs retail vs reseller
2. **v1 Migration** — v1 punya `customers` table. Kalau v2 gak punya, data hilang saat cutover.
3. **Future features** — loyalty, targeted campaign, customer notes, alamat favorit.
4. **Cost of retrofit** — bikin sekarang = 2 session. Retrofit setelah 100k orders = migrasi data + rewrite queries.

### Prinsip

- Customer = master data, dedup by phone number (primary identifier marketplace Indonesia)
- Order tetap simpan `customer_name` + `customer_phone` (denormalized untuk performa)
- Customer table = aggregator + enrichment (tipe, notes, alamat, stats)
- Auto-create customer saat order masuk (kalau phone baru)
- Merge duplicates by phone number

### Step A1: Migration

```elixir
# priv/repo/migrations/XXXXX_create_customers.exs

create table(:customers, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :org_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false
  add :name, :string, null: false
  add :phone, :string                          # primary identifier, bisa null (marketplace mask)
  add :email, :string
  add :customer_type, :string, default: "retail"  # retail / agen / reseller / dropshipper
  add :platform_ids, :map, default: %{}         # %{"tiktok" => "buyer_123", "shopee" => "buyer_456"}
  add :default_address, :text
  add :notes, :text                             # internal notes (e.g. "sering RTS", "agen Surabaya")
  add :tags, {:array, :string}, default: []     # flexible tagging: ["vip", "cod_risky", "agen_jatim"]
  add :is_active, :boolean, default: true
  add :first_order_at, :utc_datetime            # computed, kapan pertama kali order
  add :last_order_at, :utc_datetime             # computed, kapan terakhir order
  add :total_orders, :integer, default: 0       # denormalized counter
  add :total_spent, :decimal, default: 0        # denormalized sum total_harga
  add :total_rts, :integer, default: 0          # denormalized RTS count
  timestamps(type: :utc_datetime)
end

create index(:customers, [:org_id])
create index(:customers, [:org_id, :phone])
create unique_index(:customers, [:org_id, :phone], where: "phone IS NOT NULL", name: :customers_org_phone_unique)
create index(:customers, [:org_id, :customer_type])
create index(:customers, [:org_id, :tags], using: :gin)

# FK di orders
alter table(:orders) do
  add :customer_id, references(:customers, type: :binary_id, on_delete: :nilify_all)
end
create index(:orders, [:customer_id])
```

### Step A2: Schema

**File:** `lib/lababersih/customers/customer.ex`

```elixir
defmodule Lababersih.Customers.Customer do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  @customer_types ~w(retail agen reseller dropshipper)

  schema "customers" do
    field :name, :string
    field :phone, :string
    field :email, :string
    field :customer_type, :string, default: "retail"
    field :platform_ids, :map, default: %{}
    field :default_address, :string
    field :notes, :string
    field :tags, {:array, :string}, default: []
    field :is_active, :boolean, default: true
    field :first_order_at, :utc_datetime
    field :last_order_at, :utc_datetime
    field :total_orders, :integer, default: 0
    field :total_spent, :decimal, default: 0
    field :total_rts, :integer, default: 0

    belongs_to :organization, Lababersih.Accounts.Organization, foreign_key: :org_id

    timestamps(type: :utc_datetime)
  end

  def changeset(customer, attrs) do
    customer
    |> cast(attrs, [:name, :phone, :email, :customer_type, :platform_ids,
                    :default_address, :notes, :tags, :is_active, :org_id,
                    :first_order_at, :last_order_at, :total_orders, :total_spent, :total_rts])
    |> validate_required([:name, :org_id])
    |> validate_inclusion(:customer_type, @customer_types)
    |> unique_constraint([:org_id, :phone], name: :customers_org_phone_unique)
  end
end
```

**Update:** `lib/lababersih/orders/order.ex` — tambah `belongs_to :customer`

### Step A3: Context

**File:** `lib/lababersih/customers/customers.ex`

```elixir
defmodule Lababersih.Customers do
  @moduledoc "Customer master data — CRM foundation"

  # CRUD
  create_customer(attrs)
  get_customer!(id)
  get_customer_by_phone(org_id, phone)
  update_customer(customer, attrs)
  list_customers(org_id, opts \\ [])
    # opts: search (name/phone), customer_type, tags, is_active, page/per_page

  # Auto-link from order
  find_or_create_from_order(org_id, %{customer_name, customer_phone, platform})
    # 1. Cari by phone (kalau ada)
    # 2. Kalau gak ketemu → create customer baru
    # 3. Update denormalized counters (total_orders, last_order_at, total_spent)
    # 4. Return customer_id

  # Bulk backfill (migrasi existing orders ke customer records)
  backfill_customers(org_id)
    # Query semua orders, group by customer_phone
    # Per group: find_or_create customer, link semua orders

  # Analytics
  customer_summary(org_id)
    # Return: %{total, active_30d, new_30d, repeat_rate, avg_order_value}

  top_customers(org_id, opts \\ [])
    # opts: by (:total_spent | :total_orders | :total_rts), limit
    # Return: [%{customer, rank}]

  audience_overlap(org_id)
    # Customers yang beli di > 1 platform
    # Return: %{multi_platform_count, overlap_details: [%{customer, platforms: ["tiktok", "shopee"]}]}

  rts_risk_customers(org_id)
    # Customers dengan RTS rate > threshold
    # Return: [%{customer, rts_rate, total_rts}]

  update_customer_stats(customer_id)
    # Recalculate: total_orders, total_spent, total_rts, first/last_order_at
    # Call setelah order create/delete/rts
end
```

### Step A4: Hook ke Order Flow

**Update `Orders.create_order/1`:**
```elixir
# Setelah create order:
# 1. Customers.find_or_create_from_order(org_id, order)
# 2. Update order.customer_id = customer.id
```

**Update `Orders.ship_order/2`:** No change (stok + jurnal tetap)

**Update `Returns.process_rts/1`:**
```elixir
# Setelah create return:
# 1. Customers.update_customer_stats(order.customer_id)
# → total_rts += 1
```

### Step A5: Test

```
- [ ] Create customer manual
- [ ] find_or_create_from_order — new phone → create
- [ ] find_or_create_from_order — existing phone → link, update stats
- [ ] find_or_create_from_order — null phone → create without phone (no unique constraint)
- [ ] backfill_customers — 100 orders → grouped customers created
- [ ] audience_overlap — customer di 2 platform → detected
- [ ] rts_risk_customers — customer dengan > 10% RTS → flagged
- [ ] customer_summary — counts correct
- [ ] list_customers — search, filter by type, pagination
```

### Step A6: Seed

```elixir
# Update seeds.exs:
# - Create 5 sample customers (retail, agen, reseller)
# - Link to existing orders
```

### Step A7: UI — Halaman Pelanggan

**Route:** `/app/pelanggan`
**File:** `lib/lababersih_web/live/app/pelanggan_live.ex`

**Layout:**
```
Page Header: "Pelanggan" + [+ Tambah Pelanggan] primary
Stat Cards: Total | Aktif (30 hari) | Baru (30 hari) | Repeat Rate
Tab Filter: Semua | Retail | Agen | Reseller | Dropshipper
Filter: Search (nama/phone) + Tags
Table (7 kolom):
  | Nama | Phone | Tipe (badge) | Orders | Total Belanja | RTS | Last Order |
Pagination
```

### Step A8: UI — Detail Pelanggan

**Route:** `/app/pelanggan/:id`
**File:** `lib/lababersih_web/live/app/pelanggan_detail_live.ex`

**Layout:**
```
Header: ← + Nama + Tipe badge + [Edit] [Hapus]
Info Grid (2 col):
  | Nama, Phone, Email    | Tipe, Tags, Notes |
  | Platform IDs          | Default Address    |
Stats Cards: Total Orders | Total Belanja | RTS Count | RTS Rate
Order History (table — last 20 orders)
```

### Step A9: UI — Form Pelanggan

**Route:** `/app/pelanggan/new`, `/app/pelanggan/:id/edit`
**File:** `lib/lababersih_web/live/app/pelanggan_form_live.ex`

**Fields:** Nama*, Phone, Email, Tipe (dropdown), Tags (multi-input), Notes, Alamat

### Deliverable Part A
- [ ] Migration + schema
- [ ] Context: CRUD + find_or_create + analytics
- [ ] Hook ke order flow (auto-create + auto-link)
- [ ] backfill function (existing orders → customers)
- [ ] Tests (10+)
- [ ] Seed
- [ ] UI: list + detail + form (3 pages)
- [ ] Sidebar nav link
- [ ] Compile + test + deploy

---

## PART B: Gudang Operations

### Kenapa PENTING
- Stok opname = WAJIB untuk audit fisik (Nelly tiap bulan)
- Mutasi stok = history lengkap per produk (debugging stok gak cocok)
- Monitoring stok = real-time level (alert stok rendah)
- Pemusnahan = BAP resmi (produk expired/rusak, ada jurnal)

### Step B1: Migration — Stok Opname + Pemusnahan

```elixir
# priv/repo/migrations/XXXXX_create_warehouse_operations.exs

# Stok Opname
create table(:opname_sessions, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :org_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false
  add :warehouse_id, references(:warehouses, type: :binary_id, on_delete: :restrict), null: false
  add :name, :string, null: false               # "Opname Maret 2026"
  add :status, :string, default: "draft"         # draft / in_progress / completed / cancelled
  add :opname_date, :date, null: false
  add :started_by, references(:users, type: :binary_id)
  add :completed_by, references(:users, type: :binary_id)
  add :completed_at, :utc_datetime
  add :notes, :text
  add :total_selisih_qty, :integer, default: 0   # sum of all |system - actual|
  add :total_selisih_value, :decimal, default: 0 # sum of adjustment value
  timestamps(type: :utc_datetime)
end
create index(:opname_sessions, [:org_id])
create index(:opname_sessions, [:org_id, :warehouse_id])

create table(:opname_items, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :session_id, references(:opname_sessions, type: :binary_id, on_delete: :delete_all), null: false
  add :product_id, references(:products, type: :binary_id, on_delete: :restrict), null: false
  add :system_qty, :integer, null: false         # stok di system saat opname dimulai
  add :actual_qty, :integer                      # stok fisik (user input)
  add :selisih, :integer                         # actual - system (+ = lebih, - = kurang)
  add :unit_cost, :decimal                       # HPP per unit (untuk jurnal adjustment)
  add :notes, :string                            # catatan per item
  timestamps(type: :utc_datetime)
end
create index(:opname_items, [:session_id])
create unique_index(:opname_items, [:session_id, :product_id])

# Pemusnahan (BAP)
create table(:destruction_documents, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :org_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false
  add :warehouse_id, references(:warehouses, type: :binary_id, on_delete: :restrict), null: false
  add :document_number, :string, null: false     # "BAP-260401-001"
  add :status, :string, default: "draft"         # draft / approved / completed / cancelled
  add :reason, :string, null: false              # expired / rusak / recall / lainnya
  add :destruction_date, :date, null: false
  add :requested_by, references(:users, type: :binary_id)
  add :approved_by, references(:users, type: :binary_id)
  add :approved_at, :utc_datetime
  add :total_value, :decimal, default: 0
  add :notes, :text
  add :evidence, :text                           # foto/dokumen URL (future)
  timestamps(type: :utc_datetime)
end
create index(:destruction_documents, [:org_id])

create table(:destruction_items, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :document_id, references(:destruction_documents, type: :binary_id, on_delete: :delete_all), null: false
  add :product_id, references(:products, type: :binary_id, on_delete: :restrict), null: false
  add :quantity, :integer, null: false
  add :unit_cost, :decimal                       # HPP per unit
  add :lot_id, :binary_id                        # optional: specific lot to destroy
  timestamps(type: :utc_datetime)
end
create index(:destruction_items, [:document_id])
```

### Step B2: Schema — OpnameSession, OpnameItem, DestructionDocument, DestructionItem

4 schema files baru di `lib/lababersih/inventory/`

### Step B3: Context — Stok Opname

```elixir
# Di Inventory context atau sub-module StockOpname

create_opname_session(org_id, warehouse_id, attrs)
  # 1. Create session (draft)
  # 2. Auto-populate items: semua produk di warehouse, system_qty = current stok
  # Return: {:ok, session_with_items}

update_opname_item(item_id, %{actual_qty: N})
  # 1. Set actual_qty
  # 2. Calculate selisih = actual - system
  # Return: {:ok, item}

complete_opname(session_id, completed_by_user_id)
  # Repo.transaction:
  # 1. Validate: semua items sudah diisi actual_qty
  # 2. Per item dengan selisih != 0:
  #    a. selisih > 0 (lebih): create_inbound(adjustment)
  #    b. selisih < 0 (kurang): create_outbound(adjustment) — atau stock_transaction type=adjustment
  #    c. Jurnal adjustment: Dr. Beban Selisih Stok / Cr. Persediaan (kalau kurang)
  #       atau Dr. Persediaan / Cr. Pendapatan Lain-lain (kalau lebih)
  # 3. Update session status → completed, set totals
  # Return: {:ok, session}

list_opname_sessions(org_id, opts)
get_opname_session!(id)
cancel_opname(session_id)
```

### Step B4: Context — Pemusnahan

```elixir
create_destruction_document(org_id, attrs, items)
  # Create document (draft) + items

approve_destruction(document_id, approved_by_user_id)
  # Update status → approved

complete_destruction(document_id)
  # Repo.transaction:
  # 1. Per item: outbound stok + consume lot (kalau lot_id specified)
  # 2. Jurnal: Dr. Beban Kerugian Barang (64-700) / Cr. Persediaan (13-100)
  # 3. Update status → completed, set total_value

list_destruction_documents(org_id, opts)
get_destruction_document!(id)
```

### Step B5: Test

```
- [ ] Create opname session → items auto-populated with current stok
- [ ] Complete opname — selisih kurang → stok turun + jurnal
- [ ] Complete opname — selisih lebih → stok naik + jurnal
- [ ] Complete opname — semua cocok → gak ada adjustment
- [ ] Complete opname — item belum diisi → reject
- [ ] Create destruction → approve → complete → stok turun + jurnal
- [ ] Complete destruction tanpa approve → reject
```

### Step B6: UI — 5 Halaman

**1. Stok Opname List** (`/app/gudang/opname`)
```
Header: "Stok Opname" + [+ Mulai Opname] primary
Stat Cards: Draft | In Progress | Completed | Total Selisih (Rp)
Table: Nama | Gudang | Tanggal | Status | Selisih Qty | Selisih Rp | Actions
```

**2. Stok Opname Detail** (`/app/gudang/opname/:id`)
```
Header: "Opname Maret 2026" + status badge + [Selesai] primary
Progress: "45/67 item sudah dihitung"
Table (editable):
  | SKU | Produk | Stok Sistem | Stok Fisik (INPUT) | Selisih | Nilai |
Footer: Total Selisih summary
```

**3. Pemusnahan List** (`/app/gudang/pemusnahan`)
```
Header: "Pemusnahan (BAP)" + [+ Buat BAP] primary
Table: No. Dokumen | Gudang | Tanggal | Alasan | Status | Nilai | Actions
```

**4. Pemusnahan Detail** (`/app/gudang/pemusnahan/:id`)
```
Header: BAP number + status + [Approve] + [Selesai]
Items table + approval info + notes
```

**5. Mutasi Stok** (`/app/gudang/mutasi`)
```
Header: "Mutasi Stok"
Filter: Produk (dropdown) + Gudang + Tanggal
Table: Tanggal | Tipe (inbound/outbound/adjustment) | Referensi | Qty | Saldo
  — Data dari stock_transactions (SUDAH ADA, tinggal UI)
```

**6. Monitoring Stok** (`/app/gudang/monitoring`)
```
Header: "Monitoring Stok"
Filter: Gudang + Kategori
Stat Cards: Total SKU | Stok Rendah (< 10) | Habis (0) | Total Nilai
Table: SKU | Produk | Stok | Reserved | Available | HPP | Nilai | Status (badge)
  — Status: Aman (hijau) / Rendah (kuning) / Habis (merah)
```

### Deliverable Part B
- [ ] Migration (2 tables baru: opname + pemusnahan)
- [ ] Schema (4 files)
- [ ] Context: opname CRUD + complete + adjustment jurnal
- [ ] Context: pemusnahan CRUD + approve + complete + jurnal
- [ ] Tests (7+)
- [ ] UI: 6 halaman (opname list/detail, pemusnahan list/detail, mutasi, monitoring)
- [ ] Sidebar nav: sub-menu Gudang (Daftar, Opname, Pemusnahan, Mutasi, Monitoring)
- [ ] Compile + test + deploy

---

## PART C: Return Enhancement

### Kenapa PENTING
- Import Return XLSX = Nelly import RTS dari platform tiap hari
- Klaim Kurir UI = backend ada (accept/reject), tapi gak ada halaman
- RTS Inbound UI = session + sortir baik/rusak/hilang, tabel ada tapi gak ada halaman

### Step C1: Return XLSX Parser

**File:** `lib/lababersih/returns/return_xlsx_parser.ex`

```elixir
defmodule Lababersih.Returns.ReturnXlsxParser do
  # Parse return XLSX from platforms
  # Detect: TikTok returns, Shopee returns, Mengantar returns
  # Return: [%{nomor_pesanan, platform, reason, items: [...]}]

  def parse(file_path)
  def parse(file_path, platform)
end
```

**Update:** `lib/lababersih/returns/returns.ex`

```elixir
import_returns_from_xlsx(org_id, file_path, platform \\ nil)
  # 1. Parse file
  # 2. Per return: cek order exists, cek duplikat
  # 3. Create return_record + items
  # 4. Auto-link to order (by nomor_pesanan)
  # Return: {:ok, %{imported: N, skipped: N, errors: [...]}}
```

### Step C2: Klaim Kurir UI

**Route:** `/app/rts/klaim`
**File:** `lib/lababersih_web/live/app/rts_klaim_live.ex`

**Layout:**
```
Header: "Klaim Kurir" + [+ Buat Klaim] primary
Tab: Semua | Pending | Diterima | Ditolak
Table: Return | Order | Amount | Status | Actions (Terima/Tolak)
```

Backend sudah ada: `Returns.accept_claim/2`, `Returns.reject_claim/2`, `Returns.list_claims/2`

### Step C3: RTS Inbound Session UI

**Schema:** `rts_inbound_sessions` + `rts_inbound_returns` tables sudah ada (migration 20260330155121). Perlu Ecto schema modules.

**Files baru:**
- `lib/lababersih/returns/rts_inbound_session.ex` (schema)
- `lib/lababersih/returns/rts_inbound_return.ex` (schema)

**Route:** `/app/rts/inbound`
**File:** `lib/lababersih_web/live/app/rts_inbound_live.ex`

**Layout:**
```
Header: "Terima Paket RTS" + [+ Mulai Session] primary
Active Session:
  Scan/pilih return → pilih kondisi (Baik/Rusak/Hilang) → process
  Per return: calls complete_rts_inbound(return_id, condition, org_id)
Completed Sessions: history list
```

### Step C4: Import Return UI

**Route:** `/app/rts/import`
**File:** `lib/lababersih_web/live/app/rts_import_live.ex`

**Layout:**
```
Header: "Import Return"
Upload: file input + platform dropdown (optional, auto-detect)
Result: imported X, skipped X, errors X
```

### Step C5: Test

```
- [ ] Parse TikTok return XLSX → correct return data
- [ ] Parse Mengantar return XLSX → correct
- [ ] Import returns — dedup by nomor_pesanan
- [ ] Import returns — order gak ada → flagged
- [ ] RTS inbound session — scan → baik → stok kembali
- [ ] RTS inbound session — scan → rusak → jurnal kerugian
- [ ] RTS inbound session — scan → hilang → jurnal klaim
```

### Deliverable Part C
- [ ] Return XLSX parser (2-3 platform)
- [ ] Ecto schemas for inbound session/returns
- [ ] Context: import_returns_from_xlsx
- [ ] Tests (7+)
- [ ] UI: Klaim list, Inbound session, Import return (3 pages)
- [ ] Update sidebar: RTS sub-menu (Daftar, Terima Paket, Klaim, Import)
- [ ] Compile + test + deploy

---

## PART D: Accounting Enhancement

### Step D1: Input Biaya (Manual Expense Entry)

**Route:** `/app/jurnal/biaya`
**File:** `lib/lababersih_web/live/app/input_biaya_live.ex`

**Layout:**
```
Header: "Input Biaya" + [+ Catat Biaya] primary
Recent expenses table (filter by month)
Form (modal or page):
  Tanggal, Deskripsi, Jumlah (Rp)
  Akun Beban (dropdown — filter type=expense)
  Akun Sumber (dropdown — default Kas 11-100)
  Legal Entity (dropdown)
  → Submit: create_journal_entry with Dr. Beban / Cr. Kas
```

Backend: `Accounting.create_journal_entry/1` sudah ada. UI ini = shortcut form yang auto-build 2-line journal.

### Step D2: Utang / AP Tracking

**Route:** `/app/pembelian/utang`
**File:** `lib/lababersih_web/live/app/utang_live.ex`

**Backend baru di Purchasing:**
```elixir
list_outstanding_payables(org_id)
  # PO WHERE payment_method = "utang" AND paid_amount < total
  # Return: [%{po, supplier, total, paid, remaining, age_days}]

payable_summary(org_id)
  # %{total_utang, total_paid, outstanding, overdue_count}
```

**Layout:**
```
Header: "Utang Usaha" + [Bayar] primary (opens pay_po modal)
Stat Cards: Total Utang | Sudah Bayar | Outstanding | Overdue
Table: PO ID (link) | Supplier | Total | Dibayar | Sisa | Umur | Actions (Bayar)
  — Click Bayar → modal amount → call pay_po(po_id, amount)
```

### Step D3: Test

```
- [ ] Input biaya → journal created (Dr. Beban / Cr. Kas)
- [ ] list_outstanding_payables → only unpaid POs
- [ ] pay_po partial → remaining updated
- [ ] payable_summary → correct totals
```

### Deliverable Part D
- [ ] Context: list_outstanding_payables, payable_summary
- [ ] Tests (4+)
- [ ] UI: Input Biaya page, Utang page (2 pages)
- [ ] Sidebar nav: Akuntansi → Input Biaya; Pembelian → Utang
- [ ] Compile + test + deploy

---

## PART E: Auth Flow

### Step E1: Lupa Password

**Backend:**

```elixir
# Di Accounts context:
request_password_reset(email)
  # 1. Find user by email
  # 2. Generate token (random 32 bytes, base64)
  # 3. Store in password_reset_tokens table (user_id, token_hash, expires_at = +1 hour)
  # 4. Send email via Swoosh (link ke /reset-password?token=xxx)

reset_password(token, new_password)
  # 1. Lookup token (hashed)
  # 2. Validate not expired
  # 3. Update user.hashed_password
  # 4. Delete token
```

**Migration:**
```elixir
create table(:password_reset_tokens, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
  add :token_hash, :string, null: false
  add :expires_at, :utc_datetime, null: false
  timestamps(type: :utc_datetime)
end
create index(:password_reset_tokens, [:token_hash])
```

**UI:**
- `/lupa-password` — form email → request_password_reset
- `/reset-password` — form new password → reset_password

### Step E2: Invite Email (Swoosh → Resend)

**Backend sudah ada:** `Accounts.invite_member/3` — tapi cuma create org_member. Belum kirim email.

**Update:**
```elixir
# Di invite_member, setelah create member:
# Kirim email via Swoosh:
#   Subject: "Anda diundang ke {org.name} di LabaBersih"
#   Body: link ke /login (kalau sudah registered) atau /register (kalau belum)
```

**Config Swoosh → Resend adapter:**
```elixir
# config/prod.exs
config :lababersih, Lababersih.Mailer,
  adapter: Swoosh.Adapters.Resend,
  api_key: System.get_env("RESEND_API_KEY")
```

### Step E3: Test

```
- [ ] request_password_reset → token created
- [ ] reset_password valid token → password changed
- [ ] reset_password expired token → error
- [ ] invite_member → email sent (mock in test)
```

### Deliverable Part E
- [ ] Migration (password_reset_tokens)
- [ ] Context: request_password_reset, reset_password
- [ ] Swoosh email templates (reset, invite)
- [ ] Resend adapter config
- [ ] Tests (4+)
- [ ] UI: Lupa Password + Reset Password (2 pages)
- [ ] Compile + test + deploy

---

## PART F: Staging Pipeline UI

### Kenapa PENTING
- `staging_orders` table sudah ada, `integration_configs` sudah ada
- Sync dari API → staging → approve → production = flow yang sudah di-design
- Tanpa UI, staging gak bisa dipakai

### Step F1: Staging List Page

**Route:** `/app/staging`
**File:** `lib/lababersih_web/live/app/staging_live.ex`

**Backend baru di Integrations:**
```elixir
list_staging_orders(org_id, opts)
  # opts: status, platform, search, page/per_page

approve_staging_orders(org_id, staging_ids)
  # Per staging: create production order via Orders.create_order
  # Update staging.status = "approved", link synced_order_id

reject_staging_orders(org_id, staging_ids)
  # Update staging.status = "rejected"

staging_summary(org_id)
  # %{pending, approved, rejected, total}
```

**Layout:**
```
Header: "Staging Orders" + [Approve Semua] primary + [Reject] danger
Stat Cards: Menunggu | Disetujui | Ditolak | Total
Tab: Semua | Menunggu | Disetujui | Ditolak
Filter: Platform + Search
Table: Platform Order ID | Platform | Customer | Total | Status | Source | Actions
  — Checkbox bulk select
  — Actions: Approve / Reject per row
```

### Step F2: Resi Import

**Route:** `/app/pesanan/resi-import`
**File:** `lib/lababersih_web/live/app/resi_import_live.ex`

**Backend:**
```elixir
# Di Orders context:
import_resi_from_xlsx(org_id, file_path)
  # Parse XLSX: kolom nomor_pesanan + no_resi
  # Per row: find order by nomor_pesanan → update no_resi
  # Return: {:ok, %{updated: N, not_found: N}}
```

**Layout:**
```
Header: "Import Resi"
Upload: file input (.xlsx)
Result: updated X, not found X
```

### Step F3: Test

```
- [ ] approve_staging_orders → production orders created
- [ ] reject_staging_orders → status updated
- [ ] approve duplicate → skip
- [ ] import_resi → no_resi updated on matching orders
- [ ] import_resi — order not found → counted in not_found
```

### Deliverable Part F
- [ ] Context: staging CRUD + approve + reject + summary
- [ ] Context: import_resi_from_xlsx
- [ ] Tests (5+)
- [ ] UI: Staging list + Resi import (2 pages)
- [ ] Compile + test + deploy

---

## PART G: Integrasi Platform

### Kenapa PENTING
- v1 punya 31 Edge Functions untuk TikTok/Mengantar/ScaleV/Facebook
- v2 punya `integration_configs` table + stub context
- Tanpa ini, gak bisa auto-sync orders/settlements

### Step G1: Platform Settings UI

**Route:** `/app/pengaturan/integrasi`
**File:** Update `settings_live.ex` — tambah tab "Integrasi"

**Layout per platform:**
```
TikTok Shop: OAuth Connect button → redirect → callback → save tokens
Mengantar: API Key input → save → test connection
ScaleV: Webhook Secret → save + display webhook URL
Facebook Ads: Token input → save
```

**Backend:** `integration_configs` table sudah ada. Config = JSON map per platform.

### Step G2: TikTok Shop OAuth

**Files baru:**
- `lib/lababersih/integrations/tiktok_shop/client.ex` — HTTP client + HMAC signing
- `lib/lababersih/integrations/tiktok_shop/tiktok_shop.ex` — public API
- `lib/lababersih_web/controllers/tiktok_callback_controller.ex` — OAuth callback

**Alur:**
```
1. User klik "Connect TikTok" → redirect ke TikTok OAuth URL
2. TikTok redirect ke /auth/tiktok/callback?code=xxx
3. Exchange code → access_token + refresh_token
4. Save ke integration_configs (config: %{access_token, refresh_token, expires_at, shops: [...]})
5. Redirect ke settings/integrasi
```

### Step G3: Order Sync Worker

**File:** `lib/lababersih/workers/sync_tiktok_orders.ex` (Oban worker)

```elixir
# Schedule: setiap jam (configurable)
# 1. Get valid access token (refresh kalau expired)
# 2. Fetch orders dari TikTok API (3 hari ke belakang)
# 3. Upsert ke staging_orders
# 4. Log sync result
```

### Step G4: Mengantar + ScaleV + Facebook

Pattern sama: client.ex + context.ex + worker.ex per platform.
Detail API reference di `rules/v1-integration-reference.md`.

### Step G5: Webhook Endpoint

**File:** `lib/lababersih_web/controllers/webhook_controller.ex`

```elixir
# POST /api/webhook/scalev — receive ScaleV orders
# POST /api/webhook/tiktok — receive TikTok notifications
# Verify HMAC signature → upsert staging_orders
```

### Deliverable Part G
- [ ] Settings Integrasi tab
- [ ] TikTok OAuth flow (redirect + callback + token storage)
- [ ] TikTok client (HMAC signing, token refresh)
- [ ] Mengantar client (API key auth)
- [ ] ScaleV webhook handler
- [ ] Oban workers: sync orders per platform
- [ ] Tests (mock HTTP)
- [ ] Compile + test + deploy

---

## PART H: Infrastructure

### Step H1: Error Tracking (Sentry)

```elixir
# mix.exs
{:sentry, "~> 10.0"}

# config/prod.exs
config :sentry,
  dsn: System.get_env("SENTRY_DSN"),
  environment_name: :prod,
  enable_source_code_context: true

# lib/lababersih_web/endpoint.ex
plug Sentry.PlugCapture
```

### Step H2: Email Sending (Resend)

```elixir
# mix.exs — Swoosh already included
# config/prod.exs
config :lababersih, Lababersih.Mailer,
  adapter: Swoosh.Adapters.Resend,
  api_key: System.get_env("RESEND_API_KEY")

# Verify DNS: SPF + DKIM + DMARC for lababersih.id
```

### Step H3: File Storage

```elixir
# Untuk upload XLSX, evidence foto pemusnahan, dll
# Opsi 1: Fly.io volume (simple, /data/uploads/)
# Opsi 2: S3-compatible (Tigris via Fly.io, atau Cloudflare R2)

# Phase 1: local volume (simple)
# Phase 2: S3 kalau butuh CDN
```

### Deliverable Part H
- [ ] Sentry dependency + config + test error
- [ ] Resend adapter config + DNS verify
- [ ] File upload directory / S3 config
- [ ] Fly.io env vars: SENTRY_DSN, RESEND_API_KEY
- [ ] Compile + deploy

---

## PART I: Marketing

### Kenapa delay
- Butuh integrasi platform (Part G) dulu — data ads dari TikTok/Facebook/Google
- Dashboard = aggregasi dari data yang di-sync

### Step I1: Ad Entries Table

```elixir
create table(:ad_entries, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :org_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false
  add :store_id, references(:stores, type: :binary_id)
  add :platform, :string, null: false        # tiktok_ads / facebook / google_ads
  add :campaign_id, :string
  add :campaign_name, :string
  add :ad_id, :string
  add :ad_name, :string
  add :date, :date, null: false
  add :spend, :decimal, default: 0
  add :impressions, :integer, default: 0
  add :clicks, :integer, default: 0
  add :conversions, :integer, default: 0
  add :revenue, :decimal, default: 0
  add :roas, :decimal                        # revenue / spend
  timestamps(type: :utc_datetime)
end
create index(:ad_entries, [:org_id, :date])
create unique_index(:ad_entries, [:org_id, :platform, :ad_id, :date])
```

### Step I2: Marketing Dashboard

**Route:** `/app/marketing`

**Layout:**
```
Header: "Marketing" + Date Range picker
Stat Cards: Total Spend | Total Revenue | ROAS | CPA
Chart: Spend vs Revenue over time (per hari)
Per Platform breakdown table
Top Campaigns table
```

### Step I3: Input Iklan Manual

**Route:** `/app/marketing/input`

Untuk platform yang belum ada API sync — user input manual daily spend.

### Deliverable Part I
- [ ] Migration (ad_entries)
- [ ] Schema + context
- [ ] UI: Marketing dashboard + Input iklan (2 pages)
- [ ] Compile + test + deploy

---

## PART J: Data Migration (v1 → v2 Cutover)

### Prasyarat
Semua Part A-I selesai. v2 feature-complete.

### Step J1: Export v1 Data

```bash
# Dari Supabase:
pg_dump --data-only --no-owner -F c -f v1_dump.sql
```

### Step J2: Transform Script

**File:** `priv/repo/scripts/migrate_v1_data.exs`

```elixir
# 1. Import users (bcrypt compatible ✅)
# 2. Import organizations + org_members
# 3. Import legal_entities (v1 gak punya → create default)
# 4. Import accounts (COA) — map kode ke v2 structure
# 5. Import stores — auto-generate account FKs
# 6. Import products (stok = 0, recalculate from lots)
# 7. Import inventory_lots — map to v2 schema
# 8. Import suppliers + POs
# 9. Import orders + order_items + link customer_id
# 10. Import return_records + rts_claims
# 11. Import journal_entries + lines
# 12. Import reconciliations
# 13. Import customers (from v1 customers table + backfill from orders)
# 14. Verify: count rows, spot check, trial balance
```

### Step J3: Cutover Procedure

```
1. Announce downtime ke Nelly (Sabtu malam, 2-4 jam)
2. Freeze v1 (maintenance mode)
3. Final pg_dump
4. Run transform script
5. Verify data integrity
6. Switch DNS lababersih.id → Fly.io
7. Nelly test critical flows
8. OK → done. Gagal → rollback ke v1
```

### Step J4: Post-Migration

```
1. Monitor 1-2 minggu
2. Backfill customers dari existing orders
3. Cancel Supabase + Vercel
4. Delete v1 code
```

### Deliverable Part J
- [ ] Transform script (tested with v1 dump)
- [ ] Cutover runbook
- [ ] Rollback plan
- [ ] Post-migration checklist

---

## Timeline Summary

```
Session 1-2:   Part A (Pelanggan)
Session 3-5:   Part B (Gudang Operations)
Session 6-7:   Part C (Return Enhancement)
Session 8:     Part D (Accounting Enhancement)
Session 9:     Part E (Auth Flow)
Session 10:    Part H (Infrastructure)
Session 11-12: Part F (Staging Pipeline)
Session 13-15: Part G (Integrasi Platform)
Session 16:    Part I (Marketing)
Session 17:    Part J (Data Migration)
```

### Parallel Opportunities

```
Batch 1 (independent): A + B + D + E + H = 7 session parallel
Batch 2 (independent): C + F = 4 session parallel
Batch 3 (sequential): G → I = 4 session
Final: J = 1 session
```

**Best case (max parallel): ~12 session total**
**Realistic (1 executor): ~17 session sequential**

---

## Sidebar Navigation (Final Structure)

```
Dashboard
─────────────
PESANAN
  Pesanan
  Fulfillment
  Packing
─────────────
GUDANG
  Produk
  Gudang
  Stok Opname        ← NEW (Part B)
  Pemusnahan          ← NEW (Part B)
  Mutasi Stok         ← NEW (Part B)
  Monitoring          ← NEW (Part B)
─────────────
PEMBELIAN
  Purchase Order
  Utang Usaha         ← NEW (Part D)
─────────────
RETURN
  Daftar RTS
  Terima Paket        ← NEW (Part C)
  Klaim Kurir         ← NEW (Part C)
  Import Return       ← NEW (Part C)
─────────────
PELANGGAN             ← NEW (Part A)
  Daftar Pelanggan
─────────────
AKUNTANSI
  Rekonsiliasi
  Input Biaya         ← NEW (Part D)
  Jurnal
  Bagan Akun
  Buku Besar
  Laporan
  Pajak
─────────────
TOKO & CHANNEL
  Daftar Toko
  Mapping Ekspedisi
  SKU Importer
  Staging Orders      ← NEW (Part F)
─────────────
MARKETING             ← NEW (Part I)
  Dashboard
  Input Iklan
─────────────
Pengaturan
```

---

## Rules

1. **Per part: schema → context → test → seed → UI → deploy** — urutan WAJIB
2. **Customer auto-link di setiap order create** — jangan skip
3. **org_id di setiap query** — multi-tenant
4. **Repo.transaction untuk multi-step** — atomic
5. **Jurnal WAJIB balanced** — debit = credit
6. **Test setiap function** — gak ada skip
7. **Bahasa Indonesia** — semua label, title, placeholder, error message
8. **Design system** — refer `rules/design-system.md` untuk komponen
9. **Max 7 kolom tabel** — Miller's Law
10. **WIB timezone** — semua datetime +07:00
