# Changelog

Format setiap entri:
- **Tanggal / Fase**
- **Selesai dibangun**: ringkasan
- **Keputusan desain**: penyimpangan dari spesifikasi README beserta alasan (jika ada)
- **Tertunda**: hal yang belum selesai terkait modul ini

---

## Fase 0 — Setup Project

- **Selesai dibangun**: Project Laravel 10 (GTGStore) diinisialisasi, Tailwind CSS v3 terpasang, `.env.example` dilengkapi variabel yang dibutuhkan (DB, Pakasir, admin login path, dsb), database lokal MariaDB di WSL berhasil migrasi dasar, struktur `docs/` dibuat.
- **Keputusan desain**:
  - Laravel 10 dipilih (bukan versi terbaru) karena versi PHP shared hosting produksi belum dikonfirmasi. Perlu ditinjau ulang begitu versi PHP hosting dipastikan.
  - `composer create-project` memerlukan `composer config --global policy.advisories.block false` karena composer versi baru memblokir seluruh rilis Laravel 10.x akibat advisory lama yang sudah dianggap resolved di rilis final. Audit keamanan sesungguhnya (`composer audit`) tetap wajib dijalankan manual sebelum deploy production.
  - Tailwind CSS dipin ke v3 (`tailwindcss@^3`) secara eksplisit, karena npm default menginstal v4 yang menghapus config berbasis `tailwind.config.js`.
- **Tertunda**: Konfirmasi versi PHP shared hosting ke provider (blocking untuk keputusan final upgrade versi Laravel jika hosting mendukung PHP 8.2+).

## Catatan Pra-Fase 4 — Riset Pakasir Webhook

- **Keputusan desain (penyimpangan dari README bagian 8.2)**: README mengasumsikan webhook
  Pakasir punya signature untuk divalidasi. Dokumentasi resmi Pakasir mengonfirmasi TIDAK
  ada signature pada webhook body. Sebagai gantinya, verifikasi dilakukan lewat callback ke
  Transaction Detail API setelah webhook diterima. Detail lengkap di `docs/webhook-handling.md`.
  Ini akan mempengaruhi struktur tabel `payment_events` di Fase 1 (kolom `signature_valid`
  diganti/ditambah `verified_via_callback`).

## Fase 1 — Skema Database dan Model Inti

- **Selesai dibangun**: Migrasi untuk seluruh tabel inti (`categories` tambahan, `admins`,
  `products`, `product_stock`, `orders`, `order_stock_link`, `payment_events`, `vouchers`,
  `activity_logs`), Eloquent model dengan cast sesuai kebutuhan (encrypted untuk
  `product_stock.payload`, array/json untuk kolom schema), factory dan seeder untuk data
  uji lokal, serta test migrasi dasar (create + rollback + remigrate tanpa error).

- **Keputusan desain**:
  - Tabel `categories` ditambahkan (tidak ada di daftar 8 tabel README bagian 4.2) karena
    `products.category_id` butuh referensi valid. Struktur minimal: id, name, slug, is_active.
  - Kolom `orders.voucher_id` dan `orders.discount_amount` (nullable/default 0) ditambahkan
    di Fase 1 untuk menghindari migrasi ulang saat modul voucher (Important priority)
    dikerjakan nanti. Belum ada logika penerapan voucher aktif di fase ini.
  - `payment_events.signature_valid` (asumsi awal README bagian 8.2) diganti jadi
    `verified_via_callback`, karena riset ke dokumentasi resmi Pakasir mengonfirmasi tidak
    ada signature webhook — verifikasi dilakukan lewat callback ke Transaction Detail API.
    Detail di `docs/webhook-handling.md`.
  - Guard `admin` ditambahkan di `config/auth.php` terpisah dari guard `users` bawaan
    Laravel, disiapkan sejak awal untuk single point of authorization (bagian 8.4), belum
    diaktifkan penuh sampai Fase 7.

- **Isu teknis yang ditemukan dan diperbaiki selama development**:
  - Urutan file migrasi awal tidak mengikuti urutan dependency foreign key (`orders` butuh
    `vouchers` lebih dulu, `order_stock_link` dan `product_stock` butuh `orders` lebih dulu).
    Timestamp file migrasi harus urut manual sesuai rantai dependency:
    categories -> admins -> products -> vouchers -> orders -> product_stock ->
    order_stock_link -> payment_events -> activity_logs.
  - Model awalnya ditulis dengan syntax `protected function casts(): array` yang merupakan
    fitur Laravel 11, tidak dikenali di Laravel 10 (versi yang dipakai project ini karena
    versi PHP hosting belum dikonfirmasi -- lihat catatan Fase 0). Semua model diperbaiki
    memakai property `protected $casts = [...]` sesuai konvensi Laravel 10.
  - Model `ProductStock` butuh `protected $table = 'product_stock';` eksplisit karena
    konvensi pluralisasi Eloquent menebak `product_stocks` (dengan s), tidak cocok dengan
    nama tabel di skema README.
  - MariaDB lokal WSL sempat gagal autentikasi user (`gtgstore`) karena user sudah lebih
    dulu ada dengan kredensial berbeda. Solusi: `DROP USER` lalu `CREATE USER` ulang dengan
    syntax standar MariaDB (bukan syntax `IDENTIFIED WITH ... BY` milik MySQL).
  - Database testing terpisah (`gtgstore_test`) dikonfigurasi di `phpunit.xml` supaya
    `php artisan test` tidak menghapus data development di database `gtgstore`.

- **Tertunda**: Konfirmasi versi PHP shared hosting (carry-over dari Fase 0). Belum ada
  logika bisnis apa pun di fase ini -- state machine order dan alokasi stok belum
  diimplementasikan, itu bagian Fase 2.

## Fase 2 — Service Layer Inti (Order State Machine dan Alokasi Stok)

- **Selesai dibangun**:
  - Tabel `order_status_history` (tambahan di luar 8 tabel README bagian 4.2) untuk
    mencatat linimasa lengkap tiap transisi status order beserta alasan dan admin yang
    memicu (jika manual).
  - `OrderStatusService` sebagai satu-satunya titik mutasi status order, dengan peta
    transisi valid sesuai state machine README bagian 5, membungkus update status +
    insert history dalam satu transaction.
  - `InvalidOrderTransitionException` untuk transisi yang tidak diizinkan.
  - `StockAllocationService` dengan `SELECT ... FOR UPDATE` di dalam transaction untuk
    reserve stok, method `markDelivered()` dan `release()` untuk melengkapi siklus
    alokasi (README bagian 7).
  - `OutOfStockException` untuk kondisi stok habis saat alokasi.
  - Test unit lengkap: seluruh transisi status valid dan tidak valid (data provider),
    serta test race condition yang membuktikan row locking benar-benar memblokir dua
    koneksi berbeda yang berebut unit stok terakhir secara bersamaan (bukan cuma
    happy path).

- **Keputusan desain**:
  - Linimasa status dipilih sebagai tabel terpisah (`order_status_history`), bukan
    kolom timestamp per status di tabel `orders`, untuk mendukung histori penuh
    berikut alasan tiap transisi -- relevan untuk audit dispute (bagian 10 README).

- **Isu teknis yang ditemukan dan diperbaiki selama development**:
  - Test race condition awalnya salah pakai `DB::connection()->getPdo()` sebagai
    "koneksi kedua" -- itu tetap koneksi yang sama dengan Laravel, bukan simulasi
    proses independen. Diperbaiki dengan membuat `new PDO(...)` benar-benar
    terpisah dari config `database.connections.mysql`.
  - Test race condition tidak bisa memakai `RefreshDatabase` karena trait itu
    membungkus test dalam transaction yang di-rollback di akhir -- koneksi kedua
    tidak akan pernah melihat data yang di-commit oleh koneksi pertama. Diganti
    dengan truncate manual di `setUp`/`tearDown`, dengan `FOREIGN_KEY_CHECKS`
    dimatikan sementara karena ada saling referensi foreign key antar tabel terkait.
  - Query `SELECT ... FOR UPDATE` pada koneksi PDO kedua harus dijalankan lewat
    `->query()` dengan hasil di-fetch eksplisit, bukan `->exec()` (yang didesain
    untuk statement non-SELECT) -- kesalahan ini sempat menyebabkan error
    "unbuffered queries are active" pada PDO MySQL.
  - `innodb_lock_wait_timeout` di-set ke 1 detik khusus untuk sesi test, supaya
    test yang sengaja menabrak lock tidak menunggu lama/hang.

- **Tertunda**: Belum ada `FulfillmentService` yang menggabungkan
  `OrderStatusService` + `StockAllocationService` dalam satu alur (PAID -> FULFILLING
  -> DELIVERED/FULFILLMENT_FAILED dengan penyiapan payload delivery). Ini akan
  dikerjakan sebagai bagian dari Fase 4 (Integrasi Pembayaran), karena baru di situ
  fulfillment sinkron dipicu oleh webhook yang terverifikasi (README bagian 6).

## Fase 3 — Checkout dan Model Produk Dinamis
- CheckoutService + StoreCheckoutRequest (validasi dinamis), OrderPublicResource/ProductPublicResource (id internal tidak terekspos), test lengkap.

## Fase 4 — Integrasi Pembayaran (Pakasir Webhook)
- PakasirClient, FulfillmentService, PakasirWebhookController (idempotency + verifikasi via callback), test valid/invalid/duplikat.

## Fase 5 — Reconciliation Cron
- Command orders:reconcile, scheduled everyMinute + withoutOverlapping, docs/deployment.md, test lengkap.

## Fase 6 — Halaman Invoice Publik dan Frontend Customer
- Storefront (katalog + halaman produk dengan form checkout dinamis via fetch ke /api/checkout), halaman invoice publik menampilkan status order dan payload delivery setelah DELIVERED. Desain sengaja dibuat sederhana (bukan final "digital ticket stub" dari README bagian mockup, karena file mockup tidak tersedia) — struktur data/route sudah final, tinggal restyle visual kapan saja tanpa ubah logic.
- Migrasi tambahan: tabel sessions (SESSION_DRIVER=database).

## Fase 7 — Admin Panel
- Auth admin: guard terpisah, path login custom dari .env, lockout brute force (5x gagal/15 menit via tabel login_attempts), logging percobaan login.
- CRUD lengkap: Produk, Kategori, Stok (per produk), Order (search WhatsApp + linimasa status + manual deliver untuk FULFILLMENT_FAILED), Voucher, Activity Log (read-only).
- ActivityLogService: semua aksi admin yang ubah data tercatat before_state/after_state.
- Dashboard: alert order butuh tindakan (FULFILLMENT_FAILED) + stok kritis.
- Styling sederhana (Tailwind minimal), sesuai kesepakatan — bisa restyle nanti tanpa ubah logic.

## Fase 8 — Keamanan dan Hardening Akhir
- Checklist keamanan diverifikasi: CSRF aktif (default Laravel, tidak dinonaktifkan), rate limiting webhook (60/menit), encrypted cast aktif (ProductStock.payload, Admin.two_factor_secret), APP_KEY terisi.
- Tidak ada fitur file upload di project ini — checklist bagian 8.5/11 README tidak relevan.
- Backup database mengandalkan fitur bawaan cPanel (bukan custom), didokumentasikan di docs/deployment.md. Test restore lokal berhasil (dump -> drop -> restore -> migrate:status semua "Ran").
- Catatan wajib ditambahkan ke docs/deployment.md: APP_ENV=production dan APP_DEBUG=false di .env production sebelum go-live.

## Catatan Pending — Fitur Voucher di Checkout

Kolom `orders.voucher_id`/`discount_amount` dan CRUD voucher admin sudah ada,
TAPI belum terhubung ke alur checkout customer. Belum ada:
- Input kode voucher di form checkout (storefront/show.blade.php).
- Validasi voucher (aktif, belum kadaluarsa, usage_limit belum tercapai) di
  CheckoutService/StoreCheckoutRequest.
- Logic hitung diskon (percentage/fixed) untuk mengurangi price_at_order.
- Increment usage_count saat voucher berhasil dipakai.

Ditunda sesuai keputusan - dikerjakan nanti sebagai fase tambahan terpisah.

## Fase 9 — Perbaikan UX Admin & Fitur Tambahan (Stok, Order, Produk, Settings)

- **Selesai dibangun**:
  - Halaman index stok lintas produk (`admin.stock.all`, nav utama "Stok") supaya admin
    tidak perlu masuk ke tiap produk dulu untuk lihat ringkasan stok.
  - Batch tambah stok: form `admin.stock.create` sekarang punya 2 mode (tab) — satuan
    (seperti semula) dan batch (textarea 1 baris = 1 unit, atau upload file `.txt`),
    kolom dipisah koma sesuai urutan `delivery_schema`. Semua unit dalam satu batch
    dibuat dalam satu DB transaction.
  - Manual deliver order (`admin.orders.show`) diubah dari full-page form submit
    menjadi AJAX (fetch + JSON), plus polling ringan tiap 5 detik ke endpoint baru
    `admin.orders.status-fragment` supaya status ikut ter-update otomatis kalau berubah
    dari webhook/cron di sisi lain — konsisten dengan pola polling yang sudah dipakai
    di halaman invoice publik.
  - Tombol "Copy ID" (clipboard) untuk `public_token` di halaman invoice publik
    (customer) dan halaman detail order admin.
  - Upload foto produk: kolom `products.image_path` baru. Foto disimpan langsung ke
    `public/uploads/{folder-random}/{file-random}.ext` via `ProductImageUploader`,
    BUKAN lewat disk `storage/app/public` + symlink `storage:link` — beberapa shared
    hosting cPanel tidak mendukung symlink dengan baik. Foto tampil di katalog,
    halaman detail produk publik, dan daftar produk admin.
  - Halaman baru `admin.orders.needs-action`: daftar order `FULFILLMENT_FAILED` atau
    produk tipe `manual` yang sudah `PAID` tapi belum diselesaikan, supaya admin tidak
    perlu menyaring manual dari daftar order biasa. Kartu "Order butuh tindakan" di
    dashboard sekarang link ke halaman ini dan hitungannya disamakan logikanya.
  - Form checkout customer wajib isi email: kolom `orders.customer_email` baru
    (nullable di skema untuk kompatibilitas data lama, wajib divalidasi di
    `StoreCheckoutRequest`). Diteruskan lewat `CheckoutService::createOrder()`.
  - Halaman `admin.settings.edit`: admin bisa isi `pakasir_project_slug` dan
    `pakasir_api_key` tanpa perlu ubah `.env` di server langsung. Disimpan di tabel
    `settings` baru (kolom `value` di-cast `encrypted`). `PakasirClient` memakai nilai
    dari DB kalau ada isinya (override), fallback ke `.env`/`config/services.php` kalau
    kosong — supaya tidak breaking untuk yang belum sempat isi lewat admin.

- **Keputusan desain**:
  - Hanya `pakasir_project_slug` dan `pakasir_api_key` yang bisa diatur lewat admin;
    `PAKASIR_BASE_URL` dan config lain (fulfillment, reconciliation) tetap murni dari
    `.env`, karena bukan secret dan jarang berubah.
  - Foto produk disimpan di `public/uploads/...` (bukan `storage/app/public`) secara
    sadar demi kompatibilitas shared hosting yang belum tentu mendukung symlink;
    trade-off-nya file tidak otomatis ikut ke sistem backup Laravel default seperti
    `storage/`, tapi tetap ikut ter-backup lewat backup cPanel yang sudah mencakup
    seluruh `public_html` (lihat `docs/deployment.md`).
  - `customer_email` dibuat nullable di skema DB (bukan `NOT NULL`) supaya migrasi
    tidak gagal terhadap data order lama, validasi wajibnya murni di level request.

- **Tertunda**: Belum ada uji otomatis (test) untuk fitur-fitur di fase ini (batch
  stok, upload foto, settings Pakasir, halaman needs-action) — perlu ditambahkan
  sebagai tindak lanjut.

## Fase 9.1 — Revisi UX Produk Manual & Perbaikan Bug

- **Selesai dibangun**:
  - Form tambah/edit produk: field "Field Pengiriman" otomatis disembunyikan (JS +
    validasi backend `required_unless:type,manual`) kalau tipe produk = Manual,
    supaya admin tidak bingung isi field yang tidak relevan. `delivery_schema`
    dipaksa jadi array kosong di server untuk tipe manual, terlepas dari input.
  - Halaman stok (index/create) dan daftar produk: tombol "Tambah Stok"/"+ Stok"
    disembunyikan untuk produk tipe manual, diganti pesan info bahwa produk manual
    diselesaikan langsung oleh admin lewat halaman order. Halaman ringkasan stok
    lintas produk (`admin.stock.all`) dan alert stok kritis di dashboard juga
    mengecualikan produk tipe manual (sebelumnya selalu muncul sebagai "stok kritis"
    palsu karena stoknya memang selalu 0).
  - **Bug fix — invoice publik untuk order manual**: sebelumnya section "Detail
    Pengiriman" tetap tampil (kosong) untuk status `DELIVERED_MANUAL`, padahal order
    manual memang tidak punya `stockLinks`/payload (lihat `PakasirWebhookController`
    — order manual tidak lewat `StockAllocationService`). Sekarang `DELIVERED_MANUAL`
    punya pesan sendiri ("sudah diselesaikan admin, hubungi kami kalau belum terima").
  - **Bug fix — invoice publik butuh refresh manual**: polling status sebelumnya
    cuma jalan selagi status `PENDING_PAYMENT`. Begitu order tipe manual masuk
    `PAID` (menunggu admin), polling berhenti, jadi transisi `PAID` ->
    `DELIVERED_MANUAL` tidak ter-refresh otomatis. Polling sekarang tetap jalan
    selama status ada di `PENDING_PAYMENT`, `PAID`, `FULFILLING`, atau
    `FULFILLMENT_FAILED` (kondisi yang bisa berubah dari sisi lain tanpa aksi
    customer).
  - **Bug fix — error "Attempt to read property name on null"**: `Product` pakai
    `SoftDeletes`, jadi `$order->product` bisa `null` kalau produk order tersebut
    sudah dihapus. Semua tempat yang akses `$order->product->name` (list order,
    halaman butuh tindakan, detail order, invoice publik) diubah jadi null-safe
    (`$order->product?->name ?? '(produk dihapus)'`).

- **Tertunda**: `DashboardController::index()` untuk hitungan "butuh tindakan" dan
  `OrderController::needsAction()` masih pakai `whereHas('product', ...)` untuk
  filter order manual yang `PAID` — kalau produknya sendiri sudah soft-deleted,
  order itu tidak akan muncul di daftar butuh tindakan (edge case yang belum
  ditangani, kemungkinan kecil terjadi tapi perlu diperhatikan).

  > **Update Fase 10**: sudah diperbaiki, lihat di bawah.

## Fase 10 — Integrasi QRIS Penuh, Fee Payer, dan Perbaikan Bug

- **Selesai dibangun**:
  - **Bug fix — soft-deleted product di needs-action/dashboard**: `whereHas('product', ...)`
    di `OrderController::needsAction()` dan `DashboardController::index()` sekarang pakai
    `withTrashed()` di closure relasinya, supaya order manual `PAID` tetap terhitung
    walau produknya sudah dihapus.
  - **Toggle stok produk manual**: kolom `products.is_stock_available` (default true).
    Produk tipe manual tidak punya stok otomatis (`availableStockCount()` selalu 0 by
    design) sehingga sebelumnya selalu tampil "Stok habis" di storefront. Sekarang
    `Product::isOutOfStock()` membedakan logic manual (pakai toggle admin) vs tipe lain
    (pakai hitungan asli). Checkout juga diblok kalau produk manual sedang ditoggle habis.
  - **Integrasi QRIS penuh (baru, sebelumnya tidak ada sama sekali)**: `PakasirClient::createTransaction()`
    memanggil `POST /api/transactioncreate/qris`, QR string dirender langsung di halaman
    invoice publik (client-side, via qrcodejs) alih-alih redirect ke halaman pembayaran
    Pakasir. Transaksi dibuat lazy saat invoice pertama kali dibuka (`InvoiceController::show()`),
    bukan sinkron di alur checkout, supaya response `/api/checkout` tidak bergantung pada
    latensi Pakasir dan otomatis bisa di-retry cukup dengan refresh halaman invoice kalau
    gagal.
  - **Fee QRIS: buyer vs seller/admin**: kolom `products.fee_payer` (`buyer`/`seller`,
    dropdown di form produk), di-snapshot ke `orders.fee_payer` saat checkout. Kalkulasi
    fee mengikuti skema resmi Pakasir (0.7%+Rp310 untuk amount <=105rb, 1% untuk >105rb;
    lihat `PakasirFeeCalculator`). **Perubahan penting pada verifikasi**: `orders.qris_request_amount`
    (amount yang benar-benar dikirim ke Pakasir saat transaksi dibuat) sekarang jadi
    sumber kebenaran pencocokan amount webhook/transaction-detail API, MENGGANTIKAN
    `price_at_order` — karena kalau `fee_payer=seller`, amount yang dikirim ke Pakasir
    sengaja dihitung mundur (lebih kecil dari `price_at_order`) supaya total yang
    dibayar buyer tetap pas dengan harga produk. `PakasirWebhookController` dan
    `ReconcileOrders::reconcilePendingPayments()` diupdate untuk pakai `qris_request_amount`.
  - **Expiry invoice 30 menit**: default `INVOICE_EXPIRY_MINUTES` diubah dari 60 ke 30
    di `.env`/`.env.example`, sesuai keputusan bisnis terbaru. Mekanisme transisi
    otomatis ke status `EXPIRED` tetap lewat cron `orders:reconcile` yang sudah ada
    (state machine order tidak punya status `FAILED` terpisah, `EXPIRED` dipakai sebagai
    padanannya).
  - **Copy ID + Link invoice**: tombol "Copy ID" di invoice publik dan detail order admin
    diubah jadi "Copy ID + Link" — menyalin `Invoice {token} - {url invoice}` sekaligus,
    bukan cuma token, supaya bisa langsung ditempel ke chat WhatsApp tanpa customer/admin
    perlu cari linknya secara terpisah.
  - **Mode sandbox/dummy Pakasir diaktifkan** (`services.pakasir.sandbox_mode`, dari
    `PAKASIR_SANDBOX_MODE` yang sebelumnya ada di `.env.example` tapi belum pernah
    dipakai di kode manapun). Saat aktif, `PakasirClient` tidak memanggil API Pakasir
    sama sekali (baik create transaction maupun transaction detail) — dipakai supaya
    development bisa jalan tanpa akun Pakasir asli. Command baru
    `php artisan pakasir:simulate-payment {order_public_token}` (dev only, ditolak di
    environment production) mensimulasikan alur "webhook terverifikasi" secara langsung
    lewat `OrderStatusService`/`FulfillmentService` yang sama dengan alur produksi.

- **Keputusan desain**:
  - Transaksi QRIS dibuat lazy di `InvoiceController` (bukan di `CheckoutService` saat
    order dibuat), supaya kegagalan panggilan ke Pakasir tidak menggagalkan checkout dan
    otomatis retry-able tanpa endpoint tambahan.
  - `orders.qris_request_amount` dipisah dari `price_at_order` alih-alih menambah kolom
    "adjusted_price", supaya `price_at_order` tetap murni merepresentasikan harga produk
    (dipakai untuk laporan/tampilan), sementara `qris_request_amount` murni untuk
    kebutuhan teknis pencocokan pembayaran.
  - Fee dihitung dengan fixed-point iteration singkat (`PakasirFeeCalculator::reverseForSellerFee()`)
    untuk kasus `fee_payer=seller`, karena komponen flat Rp 310 di tier rendah membuat
    fee tidak bisa dibalik dengan rumus aljabar langsung.

- **Tertunda**: Belum ada endpoint/tombol untuk regenerate QRIS manual kalau transaksi
  pertama gagal dibuat di luar refresh halaman invoice biasa (retry saat ini murni pasif,
  terjadi tiap kali halaman invoice dibuka ulang). Belum ada test otomatis untuk
  `PakasirFeeCalculator`, alur lazy QRIS generation, dan command simulasi pembayaran.



## Catatan Pending — Hasil Review Cepat (Pra-Frontend)

- **Belum ada test otomatis** untuk fitur Fase 9-10: batch stock, upload foto,
  settings Pakasir, halaman needs-action, PakasirFeeCalculator, lazy QRIS
  generation. Perlu ditambahkan sebagai tindak lanjut.
- **PakasirFeeCalculator::reverseForSellerFee()** — iterasi fixed-point cuma 5x
  tanpa fallback/assert kalau tidak konvergen. Berpotensi meleset receh untuk
  amount di sekitar batas tier (Rp 105.000). Perlu dicek manual dengan angka
  nyata dan/atau tambah assertion/logging kalau setelah 5 iterasi masih ada
  diff != 0.
- Upload foto produk: belum perlu validasi MIME/ekstensi ketat untuk saat ini
  (admin cuma 1 user terpercaya, tidak ada form upload publik lain) — didokumentasikan
  sebagai keputusan sadar, ditunda sampai ada kebutuhan nyata (misal multi-admin).

## Fase 11 — Perbaikan Bug Batch, Terminologi Output, dan Live Stock

- **Bug fix — invoice tampak kedaluwarsa instan saat dibuat (mode sandbox)**:
  `PakasirClient::dummyCreateTransaction()` menyusun `qris_expired_at` dengan
  `now()->addMinutes(30)->toISOString()`. `toISOString()` mengonversi nilai ke
  UTC ('Z'), tapi kolom `qris_expired_at` di-cast `datetime` oleh Eloquent yang
  membaca/menulis nilai sebagai waktu NAIVE di `APP_TIMEZONE` (Asia/Jakarta),
  bukan UTC. Akibatnya jam yang tersimpan ke DB mundur 7 jam dari yang
  seharusnya, dan saat dibaca ulang oleh `hasUsableQris()`/countdown JS di
  invoice publik, waktu itu selalu tampak sudah lewat sesaat setelah dibuat —
  invoice tampak "kedaluwarsa" instan dan QR sandbox tidak pernah bisa
  ditampilkan/diuji. Diperbaiki dengan `toIso8601String()` (mempertahankan
  offset lokal +07:00), konsisten dengan pola yang sudah dipakai di
  `Order::expires_at` dan view invoice. Ini murni bug di jalur simulasi lokal
  (`PAKASIR_SANDBOX_MODE=true`) — bukan soal jam sistem WSL seperti dugaan
  awal, dan tidak mempengaruhi transaksi Pakasir asli (yang mengirim
  `expired_at` dengan offset eksplisit dari API mereka sendiri).
- **Bug fix — panah spinner number input di stepper jumlah batch**: input
  `type="number"` untuk jumlah batch di halaman produk sudah punya tombol
  +/- custom sendiri, tapi browser tetap menampilkan panah atas/bawah bawaan
  (putih, tidak mengikuti tema gelap). Ditambahkan class `.no-spinner` di
  `resources/css/app.css` (aturan `-webkit-appearance: none` +
  `-moz-appearance: textfield`) dan diterapkan ke input jumlah.
- **Batch pembelian sekarang berlaku untuk 2 tipe produk (account + redeem_code)**:
  sebelumnya `StoreCheckoutRequest` dan UI stepper jumlah di
  `storefront/show.blade.php` hanya mengizinkan `type === 'account'`. Backend
  fulfillment (`FulfillmentService`, `StockAllocationService`) sebenarnya
  sudah generik terhadap `order->quantity` sejak awal, jadi perluasan ini
  murni melonggarkan gerbang validasi/UI. Ditambahkan `Product::BATCHABLE_TYPES`
  (`['account', 'redeem_code']`) dan `Product::isBatchable()` sebagai satu
  sumber kebenaran, dipakai di request validation dan view, menggantikan
  pengecekan string `'account'` yang tersebar di beberapa tempat.
- **Terminologi output dibedakan per tipe produk**: ditambahkan
  `Product::unitLabel()`/`unitLabelForType()` ("Akun" untuk account, "Kode
  Redeem" untuk redeem_code, "Link" untuk link_invite, "Item" fallback).
  Dipakai untuk mengganti label "Akun" yang sebelumnya hardcoded di semua
  tempat (invoice publik, detail order admin) walaupun produknya redeem code
  atau link invite — termasuk pesan error validasi batch dan teks hasil
  "Salin Semua" di clipboard.
- **Tombol "Salin Semua" dibatasi untuk order batch sungguhan**: sebelumnya
  invoice publik menampilkan tombol "Salin Semua Akun" untuk SEMUA order yang
  punya `stockLinks` tidak kosong, termasuk order 1 unit (mis. link_invite
  yang memang selalu 1 unit per order) — membingungkan karena isinya sama
  saja dengan satu kartu yang sudah tampil. Sekarang tombol ini hanya muncul
  kalau `$order->isBatch()` (quantity > 1, hanya mungkin untuk tipe
  batchable). Halaman detail order admin tetap menampilkan semua unit secara
  penuh untuk keperluan investigasi (kebijakan berbeda dari invoice publik,
  tidak diubah), hanya label "Akun" diganti dinamis.
- **Live stock update di storefront**: endpoint publik baru
  `GET /api/products/{public_id}/stock-status` (`ProductStockStatusController`)
  mengembalikan `is_out_of_stock` dan `available_stock` (null untuk tipe
  non-batchable). Katalog (`storefront/index.blade.php`) polling tiap 8 detik
  untuk me-refresh badge "Tersedia"/"Stok Habis" tiap kartu produk. Halaman
  detail produk (`storefront/show.blade.php`) polling tiap 5 detik untuk
  badge stok, menonaktifkan tombol checkout kalau stok habis di tengah
  kunjungan, dan menyesuaikan batas atas stepper jumlah batch secara
  realtime. Pendekatan polling dipilih (bukan WebSocket/broadcasting) supaya
  konsisten dengan pola polling yang sudah dipakai di invoice publik dan
  detail order admin, dan karena tidak ada infrastruktur broadcasting
  (`BROADCAST_DRIVER=log`, tidak ada Pusher/Reverb) atau queue worker
  permanen (keterbatasan shared hosting cPanel, lihat `docs/deployment.md`).

- **Tertunda**: Belum ada test otomatis untuk perubahan di fase ini
  (`Product::isBatchable()`/`unitLabel()`, endpoint stock-status, perbaikan
  timezone `qris_expired_at`) — perlu ditambahkan sebagai tindak lanjut,
  konsisten dengan item tertunda dari fase-fase sebelumnya.
