# Dokumentasi API NekBengkel

Base URL:

```text
https://domain-anda.com/api
```

Header umum:

```http
Accept: application/json
Content-Type: application/json
```

Endpoint terautentikasi:

```http
Authorization: Bearer {token}
```

## Format Response

Sukses:

```json
{
  "success": true,
  "message": "Pesan sukses.",
  "data": {}
}
```

Validasi gagal (`422`):

```json
{
  "message": "The given data was invalid.",
  "errors": {
    "field": ["Pesan validasi."]
  }
}
```

## Lifecycle Pendaftaran

```text
Pilih paket
  -> Register user + bengkel
  -> paket_langganan: pending
  -> invoice: pending
  -> Buat pembayaran Pakasir
  -> Pakasir callback / cek status
  -> Server verifikasi langsung ke Pakasir
  -> invoice: dibayar
  -> paket_langganan: aktif
  -> users.is_aktif: Y
  -> bengkel.is_aktif: Y
  -> Email pembayaran berhasil
  -> Login diizinkan
```

Langganan yang kedaluwarsa **tetap boleh login**. Transaksi (sync/finalize) ditolak sampai paket diperpanjang atau di-upgrade. Kuota transaksi habis juga menolak posting, bukan login.

Paket gratis diaktifkan otomatis tanpa Pakasir.

## 1. Daftar Paket

Dipakai saat pendaftaran **dan** upgrade/perpanjang setelah login.

```http
GET /paket
```

Response `200`:

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "nama": "Basic",
      "keterangan": "Paket bengkel",
      "harga": 100000,
      "max_transaksi": 100
    }
  ]
}
```

## 1b. Daftar Satuan

Master satuan produk bersifat global (bukan per bengkel).

```http
GET /satuan
```

Response `200`:

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "kode": "PCS",
      "nama": "Pcs"
    }
  ]
}
```

Satuan aktif juga ikut di `GET /sync/pull` pada key `satuan`. Push satuan dari Flutter ditolak.

## 1c. Kebijakan Privasi

```http
GET /kebijakan-privasi
```

Response `200`:

```json
{
  "success": true,
  "data": "<p>Isi kebijakan privasi</p>"
}
```

## 1d. Kontak Kami

```http
GET /kontak
```

Response `200`:

```json
{
  "success": true,
  "data": {
    "nama": "NekBengkel",
    "short_name": "NekBengkel",
    "deskripsi": "Aplikasi Manajemen Bengkel Modern",
    "email": "support@nekbengkel.com",
    "phone": "081234567890",
    "website": "https://nekbengkel.com",
    "developer": "NekBengkel Team",
    "logo": null
  }
}
```

## 2. Pendaftaran

```http
POST /register
```

Request:

```json
{
  "name": "Budi Santoso",
  "email": "budi@example.com",
  "phone": "081234567890",
  "address": "Jl. Pemilik No. 1",
  "username": "budibengkel",
  "password": "Password123!",
  "password_confirmation": "Password123!",
  "nama_bengkel": "Bengkel Maju Motor",
  "phone_bengkel": "081298765432",
  "email_bengkel": "majumotor@example.com",
  "alamat_bengkel": "Jl. Bengkel No. 10",
  "paket_id": 1
}
```

Response paket berbayar `201`:

```json
{
  "success": true,
  "message": "Pendaftaran berhasil. Selesaikan pembayaran agar akun dapat digunakan.",
  "data": {
    "invoice": {
      "nomor": "INV-27072026-001",
      "jumlah": 100000,
      "status": "pending",
      "jatuh_tempo": "2026-07-28",
      "dibayar_at": null
    },
    "requires_payment": true
  }
}
```

Catatan:

- User dan bengkel dibuat nonaktif sampai pembayaran terverifikasi.
- Username, email, dan phone user harus unik.
- Email pendaftaran dikirim setelah transaction DB berhasil.
- Password tidak dikirim melalui email.

## 3. Buat Pembayaran Pakasir

```http
POST /invoice/{nomor_invoice}/payment
```

Request:

```json
{
  "method": "qris"
}
```

Metode default:

```text
qris
bni_va
bri_va
atm_bersama_va
```

Daftar dapat diubah lewat `PAKASIR_METHODS`.

Response `200` meneruskan object `payment` resmi Pakasir:

```json
{
  "success": true,
  "message": "Pembayaran berhasil dibuat.",
  "data": {
    "payment_method": "qris",
    "payment_number": "...",
    "expired_at": "..."
  }
}
```

Flutter harus merender field sesuai response aktual Pakasir. Jangan mengasumsikan semua metode memiliki `payment_number`; QRIS dapat memiliki QR/payment URL.

## 4. Cek Status Invoice

```http
GET /invoice/{nomor_invoice}/status
```

Server akan meminta status terbaru langsung ke Pakasir. Jika lunas, aktivasi akun dilakukan secara idempotent.

Response `200`:

```json
{
  "success": true,
  "data": {
    "nomor": "INV-27072026-001",
    "jumlah": 100000,
    "status": "dibayar",
    "jatuh_tempo": "2026-07-28",
    "dibayar_at": "2026-07-27T10:00:00.000000Z"
  }
}
```

Flutter disarankan polling setiap 5-10 detik selama halaman pembayaran terbuka, bukan terus-menerus di background.

## 5. Callback Pakasir

```http
POST /payment/pakasir/callback
```

URL penuh yang didaftarkan di Pakasir:

```text
https://domain-anda.com/api/payment/pakasir/callback
```

Payload minimum:

```json
{
  "order_id": "INV-27072026-001"
}
```

Callback tidak langsung mempercayai status payload. Backend memanggil `transactiondetail` Pakasir menggunakan API key server, lalu baru mengaktifkan akun. Pemrosesan memakai DB lock + transaction sehingga callback berulang tidak menggandakan aktivasi/email.

## 6. Login

```http
POST /login
```

Request:

```json
{
  "username": "budibengkel",
  "password": "Password123!",
  "device_name": "Samsung A55",
  "fcm_token": "optional-fcm-token"
}
```

Response `200`:

```json
{
  "success": true,
  "message": "Login berhasil.",
  "data": {
      "token": "1|sanctum-token",
      "user": {
      "id": 10,
      "username": "budibengkel",
      "name": "Budi Santoso",
      "email": "budi@example.com",
      "phone": "081234567890",
      "avatar": "https://domain-anda.com/storage/img/blank.png",
      "bengkel": {
        "id": 1,
        "kode": "BKL-0001",
        "nama": "Bengkel Maju Motor",
        "phone": "081298765432",
        "email": "majumotor@example.com",
        "alamat": "Jl. Bengkel No. 10",
        "logo": "https://domain-anda.com/storage/img/blank.png",
        "is_aktif": "Y"
      },
      "roles": ["Bengkel"]
    }
  }
}
```

Login diizinkan selama user dan bengkel aktif (`is_aktif = Y`), termasuk jika langganan sudah berakhir. Detail kuota/transaksi diambil dari `POST /home` (`bisa_transaksi`, `langganan`, `pemakaian`).

Response akun belum membayar pendaftaran pertama `403`:

```json
{
  "success": false,
  "message": "Akun belum aktif. Selesaikan pembayaran paket terlebih dahulu."
}
```

Simpan token dan objek `user` di Flutter Secure Storage. Refresh profil, bengkel, paket, dan langganan memakai `POST /home`. Statistik operasional dihitung dari data lokal Flutter.

## 7. Home

Satu payload untuk model halaman home Flutter, termasuk profil user dan data bengkel. Tidak ada `GET /profil` atau `GET /bengkel`; refresh memakai `/home`. `fcm_token` opsional. Objek `invoice` hanya terisi jika ada tagihan `pending`.

```http
POST /home
Authorization: Bearer {token}
```

Request body (`fcm_token` opsional):

```json
{
  "fcm_token": "token-dari-firebase"
}
```

Response `200`:

```json
{
  "success": true,
  "message": "Data home berhasil diambil.",
  "data": {
    "user": {
      "id": 10,
      "username": "budibengkel",
      "name": "Budi Santoso",
      "email": "budi@example.com",
      "phone": "081234567890",
      "avatar": "https://domain-anda.com/storage/img/blank.png"
    },
    "bengkel": {
      "id": 1,
      "kode": "BKL-0001",
      "nama": "Bengkel Maju Motor",
      "phone": "081298765432",
      "email": "majumotor@example.com",
      "alamat": "Jl. Bengkel No. 10",
      "logo": "https://domain-anda.com/storage/img/blank.png",
      "is_aktif": "Y"
    },
    "paket": {
      "id": 2,
      "nama": "Basic",
      "harga": 100000,
      "max_transaksi": 100
    },
    "langganan": {
      "id": 1,
      "paket_id": 2,
      "tanggal_mulai": "2026-08-01",
      "tanggal_selesai": "2026-09-01",
      "status": "aktif",
      "harga": 100000,
      "max_transaksi": 100,
      "paket_nama": "Basic"
    },
    "pemakaian": {
      "periode_mulai": "2026-08-01",
      "periode_selesai": "2026-09-01",
      "jumlah_transaksi": 12,
      "max_transaksi": 100,
      "sisa_transaksi": 88
    },
    "invoice": {
      "nomor": "INV-19082026-001",
      "jumlah": 100000,
      "status": "pending",
      "tanggal": "2026-08-19",
      "jatuh_tempo": "2026-08-20",
      "dibayar_at": "",
      "metode_pembayaran": "qris",
      "catatan": "Upgrade/perpanjang paket",
      "paket_id": 2,
      "paket_nama": "Basic",
      "pdf_url": "https://domain-anda.com/api/invoice/INV-19082026-001/pdf"
    },
    "bisa_transaksi": true
  }
}
```

Field yang tidak ada datanya tetap dikirim dengan key yang sama; string kosong dan angka `0` (bukan `null`). `bisa_transaksi` hanya `true` jika langganan **masih berlaku** dan sisa kuota > 0. Kuota sisa periode lama tidak bisa dipakai setelah `tanggal_selesai`. Setelah perpanjang/upgrade, kuota diganti kuota paket baru (tidak ditambah sisa lama).

Contoh invoice tanpa tagihan:

```json
"invoice": {
  "nomor": "",
  "jumlah": 0,
  "status": "",
  "tanggal": "",
  "jatuh_tempo": "",
  "dibayar_at": "",
  "metode_pembayaran": "",
  "catatan": "",
  "paket_id": 0,
  "paket_nama": "",
  "pdf_url": ""
}
```

## 8. Logout

```http
POST /logout
Authorization: Bearer {token}
```

Hanya token perangkat aktif yang dihapus.

FCM token dikirim opsional di `POST /login` atau `POST /home` (`fcm_token`). Tidak ada endpoint terpisah.

## 8c. Profil User

Tidak ada `GET /profil`. Data user diambil dari `POST /home` (`data.user`). Endpoint di bawah hanya untuk mengubah data. Semua membutuhkan `Authorization: Bearer {token}`. Password minimal 8 karakter.

```http
POST /profil
POST /profil/password
POST /profil/avatar
```

Response `POST /profil`:

```json
{
  "success": true,
      "message": "Profil berhasil diperbarui.",
  "data": {
    "id": 10,
    "username": "budibengkel",
    "name": "Budi Santoso",
    "email": "budi@example.com",
    "phone": "081234567890",
    "avatar": "https://domain-anda.com/storage/img/blank.png"
  }
}
```

`POST /profil` body:

```json
{
  "name": "Budi Santoso",
  "email": "budi@example.com",
  "phone": "081234567890"
}
```

`POST /profil/password`:

```json
{
  "password": "PasswordBaru123!",
  "password_confirmation": "PasswordBaru123!"
}
```

`POST /profil/avatar` mengirim `image` berupa string base64 (data URI atau raw). Response `data` adalah objek profil user (termasuk URL avatar baru).

## 8d. Bengkel

Tidak ada `GET /bengkel`. Data usaha diambil dari `POST /home` (`data.bengkel`). `POST /bengkel` untuk update termasuk logo.

```http
POST /bengkel
Authorization: Bearer {token}
```

Response `POST`:

```json
{
  "success": true,
  "message": "Data bengkel berhasil diperbarui.",
  "data": {
    "id": 1,
    "kode": "BKL-0001",
    "nama": "Bengkel Maju Motor",
    "phone": "081298765432",
    "email": "majumotor@example.com",
    "alamat": "Jl. Bengkel No. 10",
    "logo": "https://domain-anda.com/storage/img/blank.png",
    "is_aktif": "Y"
  }
}
```

`POST /bengkel` body JSON:

```json
{
  "nama": "Bengkel Maju Motor",
  "phone": "081298765432",
  "email": "majumotor@example.com",
  "alamat": "Jl. Bengkel No. 10",
  "logo": "data:image/png;base64,..."
}
```

`logo` opsional: string base64 atau file multipart (`image`, max 2 MB, jpg/png/webp). `kode` dan `is_aktif` tidak dapat diubah dari mobile. Logo kosong memakai `storage/img/blank.png`.

Endpoint ini tetap bisa dipanggil saat langganan kedaluwarsa.

## 8e. Invoice Bengkel

Daftar dan detail tagihan milik bengkel login. Pembayaran tetap memakai endpoint publik `POST /invoice/{nomor}/payment` dan `GET /invoice/{nomor}/status`. PDF invoice bisa dibuka di WebView Flutter tanpa token.

```http
GET /invoice
GET /invoice/{nomor}
Authorization: Bearer {token}
```

```http
GET /invoice/{nomor}/pdf
```

Response PDF `200`: `Content-Type: application/pdf` (inline). Flutter membuka `pdf_url` di WebView / PDF viewer. Endpoint ini publik berdasarkan nomor invoice (sama seperti cek status), termasuk sebelum login saat halaman pembayaran.

Response `GET /invoice`:

```json
{
  "success": true,
  "message": "Daftar invoice berhasil diambil.",
  "data": [
    {
      "nomor": "INV-19082026-001",
      "jumlah": 100000,
      "status": "pending",
      "tanggal": "2026-08-19",
      "jatuh_tempo": "2026-08-20",
      "dibayar_at": "",
      "metode_pembayaran": "",
      "catatan": "Upgrade/perpanjang paket",
      "paket_id": 2,
      "paket_nama": "Basic",
      "pdf_url": "https://domain-anda.com/api/invoice/INV-19082026-001/pdf"
    }
  ]
}
```

Jika belum ada invoice, `data` adalah array kosong `[]`.

## 8f. Langganan & Upgrade Paket

```http
GET /langganan
POST /langganan
Authorization: Bearer {token}
```

`GET /langganan` response:

```json
{
  "success": true,
  "message": "Data langganan berhasil diambil.",
  "data": {
    "aktif": {
      "id": 1,
      "paket_id": 2,
      "tanggal_mulai": "2026-08-01",
      "tanggal_selesai": "2026-09-01",
      "status": "aktif",
      "harga": 100000,
      "max_transaksi": 100,
      "paket_nama": "Basic"
    },
    "pemakaian": {
      "periode_mulai": "2026-08-01",
      "periode_selesai": "2026-09-01",
      "jumlah_transaksi": 12,
      "max_transaksi": 100,
      "sisa_transaksi": 88
    },
    "riwayat": []
  }
}
```

Jika langganan sudah berakhir, `aktif` dan `pemakaian` tetap dikirim dengan nilai kosong/`0`.

`POST /langganan` membuat tagihan baru untuk perpanjang atau upgrade. Pilih paket dari `GET /paket`.

```json
{
  "paket_id": 2
}
```

Response `201` (tagihan baru) atau `200` (masih ada tagihan pending):

```json
{
  "success": true,
  "message": "Tagihan paket berhasil dibuat. Selesaikan pembayaran untuk mengaktifkan.",
  "data": {
    "langganan": {
      "id": 2,
      "paket_id": 2,
      "tanggal_mulai": "2026-08-19",
      "tanggal_selesai": "2026-09-19",
      "status": "pending",
      "harga": 100000,
      "max_transaksi": 100,
      "paket_nama": "Basic"
    },
    "invoice": {
      "nomor": "INV-19082026-002",
      "jumlah": 100000,
      "status": "pending",
      "tanggal": "2026-08-19",
      "jatuh_tempo": "2026-08-20",
      "dibayar_at": "",
      "metode_pembayaran": "",
      "catatan": "Upgrade/perpanjang paket",
      "paket_id": 2,
      "paket_nama": "Basic",
      "pdf_url": "https://domain-anda.com/api/invoice/INV-19082026-002/pdf"
    },
    "requires_payment": true
  }
}
```

Alur Flutter:

```text
GET /paket
  -> POST /langganan { paket_id }
  -> bila requires_payment: POST /invoice/{nomor}/payment
  -> polling GET /invoice/{nomor}/status
  -> setelah dibayar, langganan lama expired, yang baru aktif
```

Satu bengkel hanya boleh punya satu invoice `pending`. Memilih paket lain saat masih pending akan memperbarui tagihan yang sama. Paket harga `0` langsung aktif tanpa Pakasir. Setelah bayar, periode baru dimulai dari tanggal pembayaran; kuota lama tidak dibawa, pemakaian mulai dari 0.

## 8g. Lookup Pelanggan & Kendaraan

Pelanggan dan kendaraan adalah **master bersama** antar bengkel. Nomor HP yang sama (digit saja) = pelanggan yang sama. Nomor polisi yang sama (huruf/angka, tanpa spasi/strip) = kendaraan yang sama.

Jangan pull seluruh master dunia ke setiap perangkat. Saat user mengetik nopol atau HP, Flutter memanggil lookup online. Jika `id` bukan `0`, tampilkan dan simpan data server; jangan buat record baru.

```http
GET /lookup/kendaraan?nomor_polisi=B1234XYZ
Authorization: Bearer {token}
```

Response `200` (ditemukan):

```json
{
  "success": true,
  "message": "Data kendaraan ditemukan.",
  "data": {
    "kendaraan": {
      "id": 12,
      "uuid": "550e8400-e29b-41d4-a716-446655440000",
      "pelanggan_id": 5,
      "nomor_polisi": "B 1234 XYZ",
      "merk": "Honda",
      "model": "Vario",
      "tahun": 2022,
      "warna": "Hitam",
      "pelanggan": {
        "id": 5,
        "uuid": "660e8400-e29b-41d4-a716-446655440000",
        "kode": "PLG-001",
        "nama": "Budi",
        "phone": "081234567890",
        "email": "",
        "alamat": ""
      }
    }
  }
}
```

Jika belum ada, `kendaraan.id` = `0` dan `pelanggan` di dalamnya juga objek kosong (`id` = `0`).

```http
GET /lookup/pelanggan?phone=081234567890
Authorization: Bearer {token}
```

Response `200`:

```json
{
  "success": true,
  "message": "Data pelanggan ditemukan.",
  "data": {
    "pelanggan": {
      "id": 5,
      "uuid": "660e8400-e29b-41d4-a716-446655440000",
      "kode": "PLG-001",
      "nama": "Budi",
      "phone": "081234567890",
      "email": "",
      "alamat": ""
    },
    "kendaraan": [
      {
        "id": 12,
        "uuid": "550e8400-e29b-41d4-a716-446655440000",
        "pelanggan_id": 5,
        "nomor_polisi": "B 1234 XYZ",
        "merk": "Honda",
        "model": "Vario",
        "tahun": 2022,
        "warna": "Hitam"
      }
    ]
  }
}
```

Lookup membutuhkan login, **tidak** memakai middleware `bengkel.aktif` (langganan kedaluwarsa tetap bisa cek master). Tidak mengembalikan histori servis atau data bengkel lain.

Alur Flutter yang disarankan:

1. Online: ketik nopol → `GET /lookup/kendaraan`. Ketik HP → `GET /lookup/pelanggan`.
2. Jika ketemu, pakai `id` + `uuid` server di database lokal.
3. Jika tidak ketemu, buat lokal (UUID baru) lalu push.
4. Push urutan: `pelanggan` → `kendaraan` → `perbaikan`.
5. Response push bisa berisi `alias`; remap `uuid_lokal` ke `uuid`/`id` kanonik jika dua bengkel membuat record yang sama secara offline.

## Cron Perpanjang Langganan

Satu cron cukup. Perintah `langganan:perpanjang` sekaligus:

1. Menandai langganan `aktif` yang `tanggal_selesai` sudah lewat menjadi `expired` (sisa kuota lama tidak berlaku).
2. Pada tanggal `tanggal_selesai` (dan catch-up jika cron sempat terlewat), membuat `paket_langganan` + `invoice` periode berikutnya.
3. Paket **gratis** (`harga = 0`): invoice `0`, langsung aktif, kuota baru dari 0.
4. Paket **berbayar**: invoice pending. User login tetap bisa, tapi transaksi baru setelah bayar. Kuota baru mengganti kuota lama setelah lunas.

Jangan pasang 2 cron terpisah (expire vs generate). Idempotent: dilewati jika sudah ada invoice/langganan `pending` atau langganan aktif yang masih berlaku.

### Shared hosting (disarankan)

Panggil perintah langsung jam 02.00 WIB, **bukan** `schedule:run` setiap menit. Ganti path PHP dan folder project sesuai cPanel.

```cron
0 2 * * * /usr/bin/php /home/USERNAME/path-project/artisan langganan:perpanjang >> /home/USERNAME/path-project/storage/logs/cron-langganan.log 2>&1
```

Contoh cPanel Cron Jobs:

- Minute: `0`
- Hour: `2`
- Day/Month/Weekday: `*`
- Command: sama seperti di atas

Cek path PHP di cPanel (Select PHP Version / Terminal):

```bash
which php
php -v
```

Uji manual via SSH/Terminal:

```bash
cd /home/USERNAME/path-project
php artisan langganan:perpanjang
```

### Alternatif Laravel scheduler

Hanya jika hosting mengizinkan cron setiap menit:

```cron
* * * * * /usr/bin/php /home/USERNAME/path-project/artisan schedule:run >> /dev/null 2>&1
```

Jadwal di `routes/console.php` sudah `dailyAt('02:00')` timezone `Asia/Jakarta`. Di shared hosting, opsi langsung jam 2 lebih andal.

## Konfigurasi Pakasir

Tambahkan ke `.env` server:

```dotenv
PAKASIR_BASE_URL=https://app.pakasir.com/api
PAKASIR_PROJECT=nama-project-pakasir
PAKASIR_API_KEY=api-key-rahasia
PAKASIR_METHODS=qris,bni_va,bri_va,atm_bersama_va
```

Jangan menaruh API key di Flutter, source control, dokumentasi publik, atau response API.

## Konfigurasi Email

Development aman menggunakan:

```dotenv
MAIL_MAILER=log
```

Contoh SMTP Gmail untuk production:

```dotenv
MAIL_MAILER=smtp
MAIL_SCHEME=tls
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=alamat-email@gmail.com
MAIL_PASSWORD=google-app-password
MAIL_FROM_ADDRESS=alamat-email@gmail.com
MAIL_FROM_NAME="${APP_NAME}"
```

Gunakan Google App Password, bukan password akun Gmail utama. Setelah mengubah env jalankan:

```bash
php artisan optimize:clear
```

Email yang dikirim:

1. Pendaftaran berhasil: identitas bengkel, username, paket, invoice, instruksi pembayaran.
2. Pembayaran berhasil: invoice lunas dan akun aktif.

Password tidak pernah dikirim melalui email.

## HTTP Status

```text
200  Request berhasil
201  Pendaftaran berhasil dibuat
401  Token tidak ada/tidak valid
403  Akun belum aktif (belum bayar pendaftaran) atau transaksi ditolak karena langganan/kuota
404  Data tidak ditemukan
422  Validasi gagal/status tidak sesuai
429  Terlalu banyak request
502  Pakasir tidak dapat diproses/diverifikasi
```

## 9. Sinkronisasi Data (Offline/Online Sync)

Aplikasi mobile mendukung mode offline-first menggunakan mekanisme manual sync (Pull dan Push) berdasarkan `uuid` (sebagai primary key sinkronisasi) dan `updated_at`.

### A. Pull Data (Ambil Perubahan dari Server)

```http
GET /sync/pull?last_pulled_at=2026-07-27 10:00:00
Authorization: Bearer {token}
```
Parameter `last_pulled_at` (opsional) digunakan untuk hanya mengambil data yang berubah/terhapus sejak waktu tersebut. Jika kosong, akan mengambil data relevan milik bengkel.

**Pelanggan & kendaraan** tidak di-pull seluruh dunia. Yang ikut pull hanya:

- record yang `bengkel_id`-nya bengkel login (pembuat pertama), atau
- record yang pernah dipakai di `perbaikan` bengkel login (termasuk kendaraan pelanggan yang sama).

Master baru dari bengkel lain diambil lewat `GET /lookup/*` saat user mengetik nopol/HP, lalu ikut pull berikutnya setelah pernah tersimpan di transaksi bengkel ini.

Response `200`:
```json
{
  "success": true,
  "data": {
    "pelanggan": [
      {
        "id": 1,
        "uuid": "550e8400-e29b-41d4-a716-446655440000",
        "nama": "Pelanggan A",
        "updated_at": "2026-07-27T10:15:00.000000Z",
        "deleted_at": null
      }
    ],
    "perbaikan": [
      {
        "uuid": "...",
        "nomor": "PRB-001",
        "status": "selesai",
        "deleted_at": "2026-07-27T10:20:00.000000Z"
      }
    ]
  },
  "timestamp": "2026-07-27 10:30:00"
}
```

Key `satuan` berisi master global. `produk_log` hanya muncul di pull (ledger server).

*Catatan: Item dengan `deleted_at` tidak null artinya data tersebut sudah dihapus di server dan harus dihapus (atau ditandai terhapus) di database lokal Flutter.*

### B. Push Data (Kirim Perubahan Offline ke Server)

```http
POST /sync/push
Authorization: Bearer {token}
```

Request:
```json
{
  "changes": {
    "pelanggan": [
      {
        "uuid": "990e8400-e29b-41d4-a716-446655449999",
        "kode": "PLG-002",
        "nama": "Pelanggan Offline",
        "phone": "08111"
      }
    ],
    "perbaikan": [
      {
        "uuid": "880e8400-e29b-41d4-a716-446655448888",
        "nomor": "PRB-002",
        "status": "draft"
      }
    ]
  }
}
```

Response `200`:
```json
{
  "success": true,
  "message": "Sync push berhasil diproses.",
  "finalisasi": [
    {
      "table": "perbaikan",
      "uuid": "880e8400-e29b-41d4-a716-446655448888",
      "aksi": "finalize",
      "success": true
    }
  ],
  "alias": [
    {
      "table": "kendaraan",
      "uuid_lokal": "990e8400-e29b-41d4-a716-446655449999",
      "uuid": "550e8400-e29b-41d4-a716-446655440000",
      "id": 12
    }
  ],
  "timestamp": "2026-07-27 10:35:00"
}
```

Server menggunakan metode _Upsert_ berdasarkan `uuid` yang dikirim Flutter. Jika record dengan `uuid` tersebut sudah ada di server, server akan meng-update datanya (atau melakukan _soft delete_ jika field `deleted_at` dikirim). Jika belum ada, server akan membuat record baru dengan UUID yang sama. Seluruh proses _push_ dibungkus dalam Database Transaction (jika 1 gagal, seluruhnya dibatalkan).

**Pelanggan & kendaraan bersama:** dicari dulu by `uuid` global, lalu by `phone_norm` / `nomor_polisi_norm`. Jika UUID lokal berbeda dengan record kanonik, UUID server tidak diganti; Flutter wajib remap lewat `alias`. `bengkel_id` hanya diisi saat create (pembuat pertama) dan tidak ditimpa saat bengkel lain meng-update.

**Ditolak di push:** `produk_log` (hanya pull), `posted_at`, `produk.stok` (kecuali stok awal produk baru, yang dicatat lewat `TransaksiService`), serta kas kategori `perbaikan`/`pembelian` (dibuat server saat finalisasi). Kas operasional/koreksi/lainnya tetap boleh di-push.

**Validasi foreign key:** `pelanggan_id`, `kendaraan_id`, dan `satuan_id` valid secara global. `produk_id`, `service_id`, dan FK transaksi lain harus milik bengkel login. Jika tidak valid, seluruh push dibatalkan.

**Finalisasi otomatis:** Saat status `perbaikan` atau `pembelian` menjadi `selesai` atau `batal` melalui sync — termasuk record baru yang langsung selesai — server memanggil `TransaksiService`. Hasil finalisasi dikembalikan di field `finalisasi`. Endpoint sync/transaksi menolak akun yang belum aktif atau langganan yang kedaluwarsa (`403`). Login dan menu profil/bengkel/invoice/langganan tetap diizinkan.

## 10. Finalisasi & Pembatalan Transaksi

Endpoint langsung untuk finalisasi/pembatalan transaksi (alternatif selain via sync). Semua endpoint membutuhkan autentikasi Sanctum dan hanya bisa mengakses transaksi milik bengkel user login.

### A. Finalisasi Perbaikan

```http
POST /perbaikan/{uuid}/finalize
Authorization: Bearer {token}
```

Memposting transaksi perbaikan: kurangi stok produk, buat produk_log, increment paket_pemakaian, catat kas masuk, set `posted_at`. Idempotent — jika sudah difinalisasi, tidak ada efek samping.

Response `200`:
```json
{
  "success": true,
  "message": "Perbaikan berhasil difinalisasi.",
  "data": {
    "id": 1,
    "uuid": "880e8400-e29b-41d4-a716-446655448888",
    "nomor": "PRB-002",
    "status": "selesai",
    "posted_at": "2026-07-27 10:40:00"
  }
}
```

Response `422` (stok tidak cukup / langganan tidak aktif / limit tercapai):
```json
{
  "success": false,
  "message": "Stok produk Oli Mesin tidak cukup (sisa 5, butuh 10)."
}
```

### B. Batalkan Perbaikan

```http
POST /perbaikan/{uuid}/cancel
Authorization: Bearer {token}
```

Membatalkan transaksi perbaikan: kembalikan stok produk (mutasi balik), buat produk_log tipe `retur_jual`, decrement paket_pemakaian, catat kas keluar (refund). Idempotent via pengecekan produk_log retur.

### C. Finalisasi Pembelian

```http
POST /pembelian/{uuid}/finalize
Authorization: Bearer {token}
```

Memposting transaksi pembelian: tambah stok produk, buat produk_log tipe `pembelian`, catat kas keluar, set `posted_at`. Idempotent.

### D. Batalkan Pembelian

```http
POST /pembelian/{uuid}/cancel
Authorization: Bearer {token}
```

Membatalkan transaksi pembelian: kurangi stok produk (mutasi balik), buat produk_log tipe `retur_beli`, catat kas masuk (refund). Idempotent.

## 11. Pelunasan Piutang & Hutang

Pelunasan sisa pembayaran transaksi yang sudah difinalisasi. Semua endpoint membutuhkan autentikasi Sanctum + bengkel aktif.

### A. Pelunasan Perbaikan (Piutang Pelanggan)

```http
POST /perbaikan/{uuid}/pelunasan
Authorization: Bearer {token}
Content-Type: application/json

{
  "jumlah": 50000
}
```

Mencatat pelunasan piutang perbaikan: catat kas masuk (kategori `perbaikan`), update `dibayar` dan `sisa`. Validasi: transaksi harus sudah difinalisasi (`posted_at`), status `selesai`, dan `sisa > 0`. `jumlah` tidak boleh melebihi `sisa`.

Response `200`:
```json
{
  "success": true,
  "message": "Pelunasan perbaikan PRB-002 berhasil dicatat.",
  "data": {
    "id": 1,
    "uuid": "880e8400-e29b-41d4-a716-446655448888",
    "nomor": "PRB-002",
    "status": "selesai",
    "dibayar": 150000,
    "sisa": 0,
    "posted_at": "2026-07-27 10:40:00"
  }
}
```

### B. Pelunasan Pembelian (Hutang Supplier)

```http
POST /pembelian/{uuid}/pelunasan
Authorization: Bearer {token}
Content-Type: application/json

{
  "jumlah": 100000
}
```

Mencatat pelunasan hutang supplier: catat kas keluar (kategori `pembelian`), update `dibayar` dan `sisa`. Validasi sama seperti pelunasan perbaikan.

## 12. Nota Perbaikan PDF

```http
GET /perbaikan/{uuid}/pdf
Authorization: Bearer {token}
```

Mengembalikan nota perbaikan format PDF (paper A5) berisi: header bengkel, info pelanggan & kendaraan, detail jasa + sparepart, total/dibayar/sisa/kembalian, dan catatan. Response: `application/pdf` (stream).

### Share Link Nota Publik

```http
GET /perbaikan/{uuid}/share-link?berlaku_hari=30
Authorization: Bearer {token}
```

Menghasilkan URL nota publik yang bisa dibagikan ke pelanggan (via WhatsApp,
SMS, dll). Token terenkripsi (AES-256-GCM via APP_KEY), default berlaku 30 hari
(maks 365). Perbaikan dengan status `draft`/`batal` tidak bisa dibagikan.

Response:

```json
{
  "success": true,
  "message": "Share link berhasil dibuat.",
  "data": {
    "uuid": "9c1e...",
    "nomor": "RPR-20260819-0001",
    "url": "https://domain.com/nota/eyJpdiI6...",
    "berlaku_hingga": "2026-09-18T17:19:30+07:00"
  }
}
```

Di halaman `/nota/{token}` pelanggan dapat:
- Melihat nota lengkap (rincian item, harga, subtotal, diskon, total)
- Melengkapi data pelanggan (nama, HP, email, alamat) & kendaraan (merk, model,
  tahun, warna) melalui form — data langsung tersimpan
  ke database bengkel

Halaman ini publik (tanpa login), dilindungi rate limit, dan `noindex`.

## 13. Laporan

Endpoint laporan POS. Semua endpoint membutuhkan autentikasi Sanctum + bengkel aktif. Parameter periode opsional: `dari` dan `sampai` (format `Y-m-d`, default: bulan berjalan).

### A. Ringkasan Laporan

```http
GET /laporan?dari=2026-07-01&sampai=2026-07-31
Authorization: Bearer {token}
```

Response `200`:
```json
{
  "success": true,
  "message": "Laporan periode 01-07-2026 s/d 31-07-2026",
  "data": {
    "ringkasan": {
      "periode": { "mulai": "2026-07-01", "selesai": "2026-07-31" },
      "perbaikan": { "jumlah": 12, "omzet": 2400000 },
      "pembelian": { "jumlah": 3, "total": 900000 },
      "laba_kotor": 1200000,
      "hpp_produk": 400000,
      "kas": { "masuk": 2500000, "keluar": 900000, "saldo": 1600000 },
      "piutang": { "jumlah": 2, "total": 300000 },
      "hutang": { "jumlah": 1, "total": 150000 }
    },
    "omzet_harian": [
      { "tanggal": "2026-07-01", "jumlah": 3, "omzet": 600000 }
    ],
    "produk_terlaris": [
      { "produk_id": 1, "nama": "Oli Mesin", "qty": 10, "omzet": 500000 }
    ],
    "piutang": [
      { "uuid": "...", "nomor": "PRB-002", "tanggal": "2026-07-20", "pelanggan": "Budi", "phone": "0812...", "total": 150000, "dibayar": 100000, "sisa": 50000 }
    ],
    "hutang": [
      { "uuid": "...", "nomor": "PBL-001", "tanggal": "2026-07-15", "supplier": "Toko Suku Cadang", "total": 300000, "dibayar": 150000, "sisa": 150000 }
    ]
  }
}
```

- **Laba kotor** = omzet perbaikan − HPP produk terjual (harga_beli × qty).
- **Piutang/hutang** dihitung semua waktu (belum lunas), bukan hanya periode.

### B. Export Laporan

```http
GET /laporan/export?format=pdf&dari=2026-07-01&sampai=2026-07-31
GET /laporan/export?format=xlsx&dari=2026-07-01&sampai=2026-07-31
Authorization: Bearer {token}
```

- `format=pdf` (default): PDF berisi ringkasan, omzet harian, produk terlaris, piutang, hutang.
- `format=xlsx`: Excel (PhpSpreadsheet) dengan sheet yang sama.

Response: file download (`application/pdf` atau `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`).

## 14. Manajemen User Bengkel (Multi-User)

Satu bengkel bisa punya banyak user. Pembeda hak akses memakai **role Spatie**:

| Role | Keterangan |
|------|------------|
| `Bengkel` | Pemilik bengkel (user pendaftar). Satu-satunya yang boleh mengelola user. |
| `Teknisi` | Teknisi bengkel |
| `Kasir` | Kasir bengkel |

Semua endpoint di bawah membutuhkan autentikasi Sanctum + bengkel aktif, dan **hanya role `Bengkel`** (di-guard di controller, response `403` jika bukan pemilik).

### A. Daftar User

```http
GET /user
Authorization: Bearer {token}
```

Response `200`:
```json
{
  "success": true,
  "message": "Daftar user bengkel",
  "data": [
    {
      "id": 1,
      "username": "bengkeljaya",
      "name": "Bengkel Jaya",
      "email": "jaya@mail.com",
      "phone": "0812...",
      "is_aktif": "Y",
      "roles": ["Bengkel"],
      "avatar": "https://...",
      "created_at": "2026-07-01T00:00:00.000000Z"
    }
  ]
}
```

### B. Tambah User

```http
POST /user
Authorization: Bearer {token}
Content-Type: application/json

{
  "username": "teknisi1",
  "name": "Andi Teknisi",
  "email": "andi@mail.com",
  "phone": "0813...",
  "password": "rahasia123",
  "role": "Teknisi"
}
```

`role` hanya boleh `Teknisi` atau `Kasir` (tidak bisa menambah pemilik). Response `201` berisi data user baru.

### C. Update User

```http
PUT /user/{id}
Authorization: Bearer {token}
Content-Type: application/json

{
  "name": "Andi Teknisi Senior",
  "password": "passwordbaru",
  "role": "Kasir",
  "is_aktif": "N"
}
```

Semua field opsional (`sometimes`). Role user pemilik (`Bengkel`) tidak bisa diubah. Saat `is_aktif=N`, semua token user tersebut dihapus (dipaksa logout).

### D. Nonaktifkan User

```http
DELETE /user/{id}
Authorization: Bearer {token}
```

User dinonaktifkan (`is_aktif=N`) dan tokennya dihapus. User pemilik (role `Bengkel`) tidak bisa dinonaktifkan (response `422`).

### Notifikasi Stok Minimum

Saat finalisasi perbaikan/pembelian, sistem otomatis mengirim notifikasi FCM ke semua user bengkel aktif yang punya `fcm_token` jika ada produk yang stoknya ≤ `stok_minimum`. Best-effort — kegagalan kirim tidak membatalkan transaksi.
