# SAIMPEX Desktop — Frontend Offline Sync Guide

**Audience:** Vue 3 + Electron desktop app team  
**Base URL:** `{API_HOST}/api/desktop/`  
**Auth:** `Authorization: Bearer {vendor_accessToken}` (except `POST login`)  
**Backend doc (full roadmap):** `saimpex-desktop-app/docs/OFFLINE_SYNC_BACKEND.md`

This document lists **everything the backend supports today** for offline sync and what the frontend must implement.

---

## 1. Summary — backend capabilities

| Module | Idempotent writes (`client_id`) | Delta cache (`updated_since`) |
|--------|--------------------------------|-------------------------------|
| **Sync** | — | — (ping/status only) |
| **POS** | `createSale`, `holdSale`, `openShift`, `closeShift`, `createCashTransaction`, `updateCashTransaction`, `updateStock` | `fetchProducts` |
| **CRM** | `createCustomer`, `updateCustomer` | `customersList`, `getCustomers` |
| **ERP** | `createPurchaseOrder` (draft), `createJournalEntry` (draft), `addSupplier` | `getSuppliers`, `getProducts` |
| **HRM** | `markAttendance`, `requestLeave`, `requestExpense` | — |
| **Settings** | — | `getSettings` (delta wraps `settings`) |

**Still online-only (no backend offline support yet):** `applyCoupon`, `postJournalEntry`, payroll, app orders, most DELETE endpoints, `addProduct` / `updateProduct` (multipart), `updateSettings` (multipart), approve/reject leave/expense, `updateSupplier`, shift/employee CRUD beyond attendance.

---

## 2. Standard response envelope

```json
{
  "status": "true",
  "data": {},
  "message": "Success or localized object"
}
```

- Business payload: `response.data.data`
- Idempotent success: HTTP **200** or **201**, always check `data.idempotent_replay` (boolean)

---

## 3. Sync metadata (all offline **writes**)

| Field | Type | Required on replay | Notes |
|-------|------|-------------------|--------|
| `client_id` | UUID v4 | **Yes** | One per queued action; never change on retry |
| `client_created_at` | ISO 8601 | **Yes** | When the user performed the action locally |
| `device_id` | string, max 64 | Recommended | Stable per install; use `sync/registerDevice` |

**Optional mapping fields** (resolve offline-created server IDs):

| Field | Used on | Maps to |
|-------|---------|---------|
| `held_transaction_client_id` | `pos/createSale` | `held_transaction_id` |
| `open_shift_client_id` | `pos/closeShift` | `shift_id` |

Online requests may omit all sync fields.

---

## 4. Sync APIs

### 4.1 `GET sync/ping`

Heartbeat — API reachable.

**Response:**

```json
{
  "status": "true",
  "data": { "server_time": "2026-05-28T10:15:00.000000Z" },
  "message": "OK"
}
```

### 4.2 `GET sync/status`

Optional `?device_id=desktop-abc123`

**Response:**

```json
{
  "status": "true",
  "data": {
    "server_time": "...",
    "device": { "device_id": "...", "last_seen_at": "...", "registered_at": "..." },
    "master_data_cursors": {}
  }
}
```

### 4.3 `POST sync/registerDevice`

Call after login.

```json
{
  "device_id": "desktop-uuid",
  "device_name": "Counter PC 1",
  "app_version": "1.0.0",
  "platform": "darwin"
}
```

---

## 5. Delta cache pattern (`updated_since`)

Any **CACHE** endpoint accepts optional query `updated_since` (ISO datetime).

**When omitted:** full response (backward compatible; extra fields may be empty/null).

**When set:** response includes:

| Field | Action |
|-------|--------|
| `*_items` / `products` / `customers` / etc. | Upsert in local cache |
| `deleted_ids` | Remove those IDs from cache |
| `sync_cursor` | Store; use as next `updated_since` |

**Example — `GET pos/fetchProducts?updated_since=2026-05-28T09:00:00Z`**

```json
{
  "status": "true",
  "data": {
    "products": [{ "id": 42, "stock": 15, "updated_at": "..." }],
    "deleted_ids": [101],
    "sync_cursor": "2026-05-28T10:00:00.000000Z"
  }
}
```

---

## 6. POS module

### 6.1 Writes (idempotent)

#### `POST pos/createSale` *(already integrated)*

**Sync fields:** `client_id`, `client_created_at`, `device_id`  
**Mapping:** `held_transaction_client_id` → server hold id  

**Offline stock:** Server allows **negative** stock when `client_id` is sent.  
**Online:** Rejects insufficient stock.

**Success:**

```json
{
  "data": {
    "order": { "id": 9001, "order_code": "ORD-POS-000042" },
    "idempotent_replay": false
  }
}
```

Replay: `idempotent_replay: true`, same `order`, HTTP 200.

---

#### `POST pos/holdSale`

Same cart body as sale (no payment / common_discount). **Add sync fields on replay.**

**Success:**

```json
{
  "data": {
    "held_transaction": { "id": 12, "cart_data": {}, "total_amount": 100 },
    "idempotent_replay": false
  }
}
```

Store `held_transaction.id` in local id-map keyed by `client_id` for later `createSale` (`held_transaction_client_id`).

---

#### `POST pos/openShift`

```json
{
  "cashier_id": 5,
  "opening_cash": 500,
  "notes": "Morning",
  "client_id": "uuid",
  "client_created_at": "...",
  "device_id": "desktop-..."
}
```

**Success:** shift object at root of `data` + `idempotent_replay`. Save `data.id` mapped to `client_id`.

---

#### `POST pos/closeShift`

```json
{
  "shift_id": 12,
  "open_shift_client_id": "uuid-of-open-shift",
  "closing_cash": 1200,
  "variance_reason": null,
  "client_id": "uuid",
  "client_created_at": "...",
  "device_id": "..."
}
```

Use **`open_shift_client_id`** when shift was opened offline (instead of `shift_id` until synced).

---

#### `POST pos/createCashTransaction`

```json
{
  "amount": 50,
  "type": 1,
  "reason": "Petty cash",
  "category": "misc",
  "notes": null,
  "date": "2026-05-28",
  "client_id": "uuid",
  "client_created_at": "...",
  "device_id": "..."
}
```

`type`: `1` = in, `2` = out.

---

#### `POST pos/updateCashTransaction`

Requires server `id` (cash transaction id). Include sync fields on replay.

---

#### `POST pos/updateStock`

```json
{
  "product_id": 42,
  "stock_type": 1,
  "quantity": 10,
  "notes": "Stock in",
  "client_id": "uuid",
  "client_created_at": "...",
  "device_id": "..."
}
```

`stock_type`: `1` = in, `2` = out.  
**Offline replay (`client_id`):** stock out may go **negative** (same rule as sales).

---

### 6.2 Reads (cache)

| Method | Endpoint | Delta |
|--------|----------|-------|
| GET | `pos/fetchProducts` | `updated_since` |

---

### 6.3 Online-only POS

`applyCoupon`, `deleteHeldSale`, `resumeHeldSale`, app order actions, `addProduct`, `updateProduct` (multipart).

---

## 7. CRM module

### 7.1 Writes

#### `POST crm/createCustomer`

Standard customer body + sync fields.

**Success:** customer fields at root of `data` (plus `idempotent_replay`). Save `data.id` as server customer id.

---

#### `POST crm/updateCustomer`

```json
{
  "customer_id": 10,
  "name": "...",
  "email": "...",
  "client_id": "uuid",
  "client_created_at": "...",
  "device_id": "..."
}
```

Idempotency is per **update operation** (`client_id`), not per customer.

---

### 7.2 Reads

| Method | Endpoint | Delta |
|--------|----------|-------|
| GET | `crm/customersList` | `updated_since` (filters `users.updated_at`) |
| GET | `getCustomers` | `updated_since` (short dropdown list) |

**`customersList` delta response:**

```json
{
  "data": {
    "customers": [{ "id": 1, "name": "...", "updated_at": "..." }],
    "pagination": { "current_page": 1, "per_page": 20, "total": 1, "last_page": 1 },
    "deleted_ids": [],
    "sync_cursor": "..."
  }
}
```

---

## 8. ERP module

### 8.1 Writes

#### `POST erp/createPurchaseOrder`

**Offline (`client_id` present):**

- PO created with **`status: 1` (Draft)**
- **No invoice / payment** rows created
- Response includes `is_draft: true`

**Online (no `client_id`):** unchanged (received flow, invoice/payment as today).

```json
{
  "supplier_id": 1,
  "order_date": "2026-05-28",
  "delivery_date": "2026-05-30",
  "products": [{ "product_id": 42, "quantity": 10, "unit_price": 5, "tax_rate": 0 }],
  "client_id": "uuid",
  "client_created_at": "...",
  "device_id": "..."
}
```

Save `data.id` / `po_number` after sync.

---

#### `POST erp/createJournalEntry`

**Offline:** `post_now` is **ignored** — always saved as **draft**.

```json
{
  "entry_date": "2026-05-28",
  "description": "Adjustment",
  "lines": [
    { "account_code": "1000", "debit": 100, "credit": 0 },
    { "account_code": "3000", "debit": 0, "credit": 100 }
  ],
  "post_now": false,
  "client_id": "uuid",
  "client_created_at": "...",
  "device_id": "..."
}
```

Response includes `is_draft: true` when draft. **Posting** stays online: `GET erp/postJournalEntry`.

---

#### `POST erp/addSupplier`

Body: `supplier_code`, `supplier_name`, `contact_person`, `phone`, … + sync fields.

---

### 8.2 Reads

| Method | Endpoint | Delta |
|--------|----------|-------|
| GET | `erp/getSuppliers` | `updated_since` → `data.suppliers`, `deleted_ids`, `sync_cursor` |
| GET | `erp/getProducts` | Same pattern as `fetchProducts` |

---

### 8.3 Online-only ERP

`updateSupplier`, `purchaseOrderRepayment`, `postJournalEntry`, ledger, trial balance, dashboards.

---

## 9. HRM module

### 9.1 Writes

#### `POST hrm/markAttendance`

Full attendance body + sync fields.

**Success:**

```json
{
  "data": {
    "attendance": { "id": 55, "employee_id": 3, "date": "2026-05-28", "status": 1 },
    "idempotent_replay": false
  }
}
```

---

#### `POST hrm/requestLeave`

```json
{
  "user_id": 3,
  "type": 1,
  "from_date": "2026-06-01",
  "to_date": "2026-06-03",
  "reason": "Family",
  "client_id": "uuid",
  "client_created_at": "...",
  "device_id": "..."
}
```

---

#### `POST hrm/requestExpense`

Same as today; **receipt file** must be queued locally and sent on sync (multipart). Include `client_id` as form field when replaying.

---

### 9.2 Online-only HRM

`approveLeave`, `rejectLeave`, `approveExpense`, `rejectExpense`, payroll generate/approve/pay, `deleteEmployee`, etc.

---

## 10. Settings

### `GET getSettings`

| Mode | Response shape |
|------|----------------|
| **Full** (no `updated_since`) | `data` = vendor object directly (**unchanged**) |
| **Delta** | `data.settings`, `data.deleted_ids`, `data.sync_cursor` |

Frontend: if using delta, read `response.data.data.settings`; else use `response.data.data` as today.

---

## 11. Central outbox — suggested queue shape

Extend POS queue to a **module-aware outbox** (localStorage now, SQLite later):

```javascript
{
  "client_id": "550e8400-e29b-41d4-a716-446655440000",
  "client_created_at": "2026-05-28T14:32:00.000Z",
  "device_id": "desktop-abc123",
  "entity_type": "purchase_order",
  "endpoint": "erp/createPurchaseOrder",
  "method": "POST",
  "payload": { /* API body without sync fields */ },
  "sync_status": "pending",
  "server_id": null,
  "sync_error": null,
  "idempotent_replay": null
}
```

### `entity_type` → endpoint map

| `entity_type` | Endpoint | Notes |
|---------------|----------|--------|
| `sale` | `pos/createSale` | |
| `held_sale` | `pos/holdSale` | |
| `shift_open` | `pos/openShift` | |
| `shift_close` | `pos/closeShift` | May need `open_shift_client_id` |
| `cash_transaction` | `pos/createCashTransaction` or `pos/updateCashTransaction` | |
| `stock_update` | `pos/updateStock` | |
| `customer` | `crm/createCustomer` | |
| `customer_update` | `crm/updateCustomer` | |
| `supplier` | `erp/addSupplier` | |
| `purchase_order` | `erp/createPurchaseOrder` | Draft on server |
| `journal_entry` | `erp/createJournalEntry` | Draft on server |
| `attendance` | `hrm/markAttendance` | |
| `leave` | `hrm/requestLeave` | |
| `expense` | `hrm/requestExpense` | Multipart |

### Recommended sync order (FIFO per type)

1. `shift_open`
2. `held_sale`, `customer`, `supplier`
3. `sale`, `cash_transaction`, `stock_update`, `attendance`, `leave`, `expense`
4. `shift_close`
5. `purchase_order`, `journal_entry`
6. `customer_update`
7. Pull deltas: `fetchProducts`, `customersList`, `getSuppliers`, `getProducts`, `getSettings`

After each successful batch, run delta pulls to refresh caches.

---

## 12. Id-map (localStorage)

Persist server IDs from sync responses:

```javascript
// saimpex_offline_id_map
{
  "550e8400-e29b-41d4-a716-446655440000": {
    "entity_type": "shift_open",
    "server_id": 12
  }
}
```

Use when building later payloads (`open_shift_client_id`, `held_transaction_client_id`, `customer_id` for offline-created customers, etc.).

---

## 13. Error handling

| HTTP | Queue action |
|------|----------------|
| 200 / 201 + `idempotent_replay: true` | `synced` (success) |
| 422 | `failed` — show message |
| 409 | `failed` — missing original / conflict |
| 401 | Pause sync; re-login |
| Network | Keep `pending`, retry |

---

## 14. Frontend files to add/update

| File | Action |
|------|--------|
| `src/utils/deviceId.js` | Stable `device_id` |
| `src/services/sync/outbox.js` | Module-aware queue CRUD |
| `src/services/sync/idMap.js` | `client_id` → `server_id` |
| `src/services/sync/connectivity.js` | `sync/ping` |
| `src/services/sync/pullCache.js` | Delta pulls for all CACHE endpoints |
| `src/services/sync/replay.js` | Merge payload + sync fields; route by `endpoint` |
| Login flow | `sync/registerDevice` |
| `Sales.vue` | Done for sales; wire hold + mappings |
| `Shifts.vue` | open/close offline queue |
| `Cash.vue` | cash tx offline queue |
| `Customers.vue` | create/update + list cache |
| `Purchases.vue` | create PO offline queue |
| `Accounting.vue` | journal draft queue |
| `Attendance.vue` | mark attendance queue |
| `Approvals.vue` | leave/expense queue |

---

## 15. Testing checklist

- [ ] Each write endpoint: first sync creates row; second sync same `client_id` → `idempotent_replay: true`
- [ ] Offline PO → `status` draft, `is_draft: true`, no duplicate on replay
- [ ] Offline journal → draft only; `post_now` ignored with `client_id`
- [ ] Close shift with `open_shift_client_id` after offline open
- [ ] Sale with `held_transaction_client_id` after offline hold
- [ ] Delta pulls merge `deleted_ids` and advance `sync_cursor`
- [ ] `getSettings` full load still works without `updated_since`
- [ ] 401 pauses global sync

---

## 16. Backend reference

| Item | Path |
|------|------|
| Trait | `app/Traits/HandlesOfflineSync.php` |
| Service | `app/Services/desktop/OfflineSyncService.php` |
| Cache helper | `app/Services/desktop/OfflineCacheHelper.php` |
| Routes | `routes/desktop.php` |

---

**Version:** 2.0 — May 2026  
**Scope:** POS (full write set) + CRM + ERP drafts + HRM requests + cache reads
