# WBMS Flutter App — API Documentation

API reference for the **Water Billing Management System (WBMS)** Flutter mobile app
that consumes the Laravel backend at `billing.happyimart.com`.

> Scope: **company level only**. The SaaS layer and the `super_admin` role are
> intentionally out of scope for the mobile app — the app never manages tenants,
> billing plans, or other companies. It operates inside a single company (tenant).

- **Base URL (prod)**: `https://billing.happyimart.com/api/v1`
- **Content-Type**: `application/json` (send `Accept: application/json` on every request)
- **API version**: v1

> **Status: all endpoints below are implemented and verified live (2026-07-05).**
> The API mirrors the web app's functionality and business rules exactly —
> same reading→approval→billing workflow, same tier pricing, same payment
> rules (partial payments, change computation, balance refresh).

---

## 1. Company scoping (no tenant slug needed)

Every request is scoped to one company, derived from the **authenticated
user**. The app never sends a tenant slug or `api_key` — the legacy
`X-Tenant` + `api_key` scheme is dead. The company (with branding) comes back
in the login response as `user.company`.

---

## 2. Authentication

Auth runs on Laravel Sanctum (`App\Http\Controllers\Api\AuthController`).
Role gating uses the `api.role` middleware (`App\Http\Middleware\EnsureApiRole`).

### 2.1 Login

```
POST /api/v1/auth/login
```

**Request**
```json
{
  "email": "reader@barangay-ws.gov.ph",
  "password": "secret",
  "device_name": "Pixel 8 - Field App"
}
```

**Success (200)** — `user.company` carries the **full branding payload** (see §4.1)
so the app can theme itself (logo, name, currency symbol) immediately:
```json
{
  "token": "12|9aXq...tokenstring",
  "user": {
    "id": 4,
    "name": "LGU Panganiban",
    "email": "admin@example.com",
    "role": "company_admin",
    "company_id": 3,
    "company": {
      "id": 3,
      "name": "Panganiban Water System",
      "slug": "panganiban-water-system-XXXX",
      "logo_url": "https://billing.happyimart.com/storage/company-logos/….jpg",
      "currency_code": "PHP",
      "currency_symbol": "₱",
      "billing": { "billing_due_days": 28, "disconnection_by_due_date": true, "…": "…" },
      "settings": { "app_name": "…", "timezone": "…", "show_qr_on_receipt": true, "…": "…" }
    }
  }
}
```

`device_name` is **required** — one active token per device; logging in again
with the same `device_name` revokes the previous token for that device.

**Errors**
| Status | Meaning |
|--------|---------|
| 401 | Wrong email/password |
| 403 | `super_admin` (not a mobile-app user) or company disabled |
| 422 | Validation failed (missing email/password/device_name) |

### 2.2 Authenticated requests

```
Authorization: Bearer 12|9aXq...tokenstring
Accept: application/json
```

### 2.3 Current user & logout

```
GET  /api/v1/auth/me        → { "user": { …same shape as login… } }
POST /api/v1/auth/logout    → revokes the current token (204)
```

On `401 Unauthorized`, clear the stored token and route back to login.

---

## 3. Roles & endpoint matrix

Client-side gating: read `user.role` and show/hide screens. **Server-side
gating is authoritative** — disallowed roles get
`403 { "message": "This action is unauthorized." }`.

| Endpoint | company_admin | cashier | meter_reader | installer | staff | customer |
|----------|:---:|:---:|:---:|:---:|:---:|:---:|
| `GET /company` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `GET /dashboard/summary` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `GET /clients`, `/clients/search`, `/clients/{id}`, `/clients/{id}/meter-info` | ✅ | ✅ | ✅¹ | – | – | – |
| `GET /meter-readings`, `/meter-readings/{id}` | ✅ | ✅ | ✅ | – | – | – |
| `GET /meter-reading(s)/history` | ✅ | ✅ | ✅ | – | – | own² |
| `POST /meter-readings` (submit) | ✅ | – | ✅ | – | – | – |
| `POST /meter-reading/scan` (vision draft) | ✅ | – | ✅ | – | – | – |
| `POST /meter-reading(s)/{id}/approve` / `reject` | ✅ | – | – | – | – | – |
| `GET /billings`, `/billings/{id}` | ✅ | ✅ | – | – | – | own² |
| `GET/POST /payments`, `GET /payments/search-clients`, `GET /payments/{id}` | ✅ | ✅ | – | – | – | – |
| `GET /work-orders`, `/work-orders/{id}`, `POST /work-orders/{id}/status` | ✅ | – | – | ✅ | – | – |
| `POST /work-orders` (create) | ✅ | – | – | – | – | – |

> ¹ Meter readers see their route only: clients assigned to them
> (`clients.assigned_reader_id`) **plus all unassigned clients**. Companies
> that never assign readers keep everyone-sees-everything behaviour.
>
> ² Customers are scoped in the controller to the client account linked via
> `users.client_id`; a customer with no linked account gets a 403 with a
> human-readable message.

---

## 4. Endpoints

All paths relative to `/api/v1`, all require `Authorization: Bearer`.

### 4.1 Company profile & branding

```
GET /company
```
```json
{
  "company": {
    "id": 3,
    "name": "Panganiban Water System",
    "slug": "panganiban-water-system-XXXX",
    "email": "…", "phone": null, "address": null,
    "logo_url": "https://billing.happyimart.com/storage/company-logos/….jpg",
    "currency_code": "PHP",
    "currency_symbol": "₱",
    "billing": {
      "billing_due_days": 28,
      "disconnection_by_due_date": true,
      "disconnection_days": 30,
      "disconnection_days_after_due": 15,
      "late_payment_fee_enabled": true,
      "late_payment_fee_rate": "10.00",
      "late_payment_fee_type": "percentage",
      "late_payment_fee_frequency": "monthly",
      "reconnection_fee_enabled": true,
      "reconnection_fee_amount": "200.00"
    },
    "settings": {
      "app_name": "…",
      "timezone": "Asia/Manila",
      "show_logo_on_pdf": true,
      "show_logo_on_thermal": false,
      "show_logo_on_public": true,
      "show_qr_on_receipt": true,
      "enable_block_lot_search": false,
      "reading_footer_text": "This is a reading acknowledgement, not an official bill.",
      "billing_footer_text": "Pay at the office or via GCash 0917-000-0000.",
      "disconnection_footer_text": "If payment has already been made, please disregard this notice."
    }
  }
}
```

**The app must follow this payload for branding**: show `logo_url` in the app
bar / drawer / receipts (respect the `show_logo_on_*` flags), use
`currency_symbol` for all money, use `name`/`app_name` in headers, and honor
`enable_block_lot_search` when building the client search UI. `logo_url` is
`null` when the company has no logo — fall back to the app icon.

**Custom document footer text** (`reading_footer_text`, `billing_footer_text`,
`disconnection_footer_text`): company-configured text the app must print at the
bottom of the matching printed document (reading receipt, bill, disconnection
notice), **after the document content and just before the standard footer**.
Each field is `null` or empty when the company has not set it — print nothing
in that case. Preserve line breaks (`\n`) and treat the value as plain text,
up to 1000 characters. See API_CUSTOM_FOOTER_TEXT.md for full details.

### 4.2 Dashboard (home screen)

```
GET /dashboard/summary
```
Role-aware counters; also embeds the full `company` payload from §4.1.

```json
{
  "role": "company_admin",
  "company": { "…": "§4.1 payload" },
  "summary": {
    "total_clients": 120, "today_collections": 540.00, "today_readings": 12,
    "total_billings": 300, "total_revenue": 45000.00, "total_collected": 39000.00,
    "total_due": 6000.00, "paid_billings": 250, "unpaid_billings": 50,
    "overdue_billings": 8, "disconnection_eligible": 5,
    "disconnected_clients": 2, "staff_count": 6, "pending_readings": 3
  }
}
```
- `company_admin`: all fields above.
- `cashier`: totals + `pending_billings`, `overdue_billings`, `disconnected_clients`.
- `meter_reader`: `total_clients`, `pending_readings`, `approved_readings`, `readings_today`.

### 4.3 Clients

```
GET /clients?search=&status=&page=1
```
```json
{
  "data": [
    {
      "id": 230, "account_number": "3-127600", "name": "Adolfo Villarey",
      "meter_number": null, "status": "active", "is_disconnected": false,
      "address": "Santa Maria (Pob.), PANGABINAN (PAYO)",
      "outstanding_balance": 0
    }
  ],
  "meta": { "current_page": 1, "last_page": 12, "per_page": 15, "total": 230 }
}
```

```
GET /clients/search?q=juan&block=&lot=
```
Autocomplete (min 2 chars) on name / account / meter number. `block`/`lot`
are honored only when `settings.enable_block_lot_search` is true. Returns up
to 10 (25 with block/lot) light rows.

Both list endpoints (and `/clients/{id}` + `/clients/{id}/meter-info`) apply
the meter-reader route scope: a reader only sees clients assigned to them via
`clients.assigned_reader_id`, plus every unassigned client (see §3 note ¹).

```
GET /clients/{id}
```
Full detail: contact info, `full_address`, coordinates, `tier_group`,
`last_reading`, and `unpaid_billings` (each with `balance` and `is_overdue`).

```
GET /clients/{id}/meter-info
```
Pre-reading check for the reading form:
```json
{
  "client_id": 223, "name": "Reggie Castro 2", "account_number": "3-23005755",
  "meter_number": "12345", "meter_number_required": false,
  "meter_start_reading": "265.00", "has_pending_reading": false,
  "last_reading_date": "2026-07-02T16:00:00.000000Z", "last_reading_value": "270.00"
}
```

### 4.4 Meter readings — the web workflow, exactly

A submitted reading is a **draft pending approval**. Billing is generated
**on approval** (company_admin), not on submit — same as the web app.

```
GET /meter-readings?status=&search=&client_id=&page=
```
Paginated list, newest first; rejected readings hidden unless
`status=rejected` is requested. Statuses: `draft` → `approved` / `rejected`.

```
POST /meter-readings
```
**Request** (`meter_number` required only for a client's first reading;
`reading_date` defaults to now):
```json
{
  "client_id": 223,
  "reading_value": 272,
  "meter_number": null,
  "reading_date": "2026-07-05T08:15:00Z",
  "notes": "Normal reading"
}
```
**Success (201)**
```json
{
  "message": "Meter reading recorded successfully. Pending approval.",
  "reading": {
    "id": 25, "client_id": 223, "reading_value": "272.00",
    "previous_reading": "270.00", "units_consumed": "2.00",
    "reading_date": "2026-07-05T23:28:40.000000Z",
    "recorded_by": "LGU Panganiban", "status": "draft",
    "client": { "id": 223, "name": "Reggie Castro 2", "account_number": "3-23005755", "meter_number": "12345" },
    "notes": "Normal reading", "billing_id": null
  }
}
```
**409** if the client already has a pending (draft) reading.
**422** if the first reading is missing a meter number, the meter number is
already assigned to another client, or the `reading_value` is **below the
account's last approved reading** (or its `meter_start_reading` when none has
been approved). Equal to the previous reading is allowed (zero consumption).
The 422 body carries `message` plus `errors.reading_value`.

```
GET /meter-readings/{id}
```
Single reading with computed `previous_reading` / `units_consumed` for drafts.

```
POST /meter-readings/{id}/approve      (company_admin)
```
Approves the draft and **generates the billing** using the client's tier
group and the company's due-date / disconnection settings:
```json
{
  "message": "Meter reading approved and billing generated successfully.",
  "reading": { "…": "status: approved, billing_id: 21" },
  "billing": {
    "id": 21, "billing_number": "BIL-3-000017", "units_consumed": "2.00",
    "subtotal": "80.00", "total_amount_due": "80.00",
    "billing_date": "2026-07-05T23:28:56.000000Z",
    "due_date": "2026-07-28T15:59:59.000000Z",
    "disconnection_date": "2026-08-12T15:59:59.000000Z",
    "status": "sent"
  }
}
```
**409** if the reading is not a draft anymore.

```
POST /meter-readings/{id}/reject       (company_admin)
```
Body: `{ "reason": "Misread meter" }` (optional). **409** if not a draft.

```
GET /meter-readings/history?client_id=223       (or ?account_number=3-23005755)
```
Last 12 readings for the client, most recent first.

```
POST /meter-reading/scan
```
Vision-assisted reading (photo → suggested value). Returns a draft to
confirm; it does **not** submit. See [FLUTTER_METER_SCAN.md](FLUTTER_METER_SCAN.md).

### 4.5 Billings *(company_admin, cashier)*

```
GET /billings?status=&search=&client_id=&account_number=&page=
```
`status=unpaid` is a shortcut for everything not paid/cancelled. Rows include
the client summary, readings, amounts, `balance`, `is_overdue`.

```
GET /billings/{id}
```
Full detail incl. `subtotal`, `tax`, `other_charges`, `penalties`, effective
`disconnection_date`, real `payments`, and `system_charges` (late fees /
notices — charges, not money received).

Billing statuses: `draft`, `sent`, `partially_paid`, `overdue`, `paid`, `cancelled`.

### 4.6 Payments (cashier POS) *(company_admin, cashier)*

```
GET /payments/search-clients?q=reg
```
POS search: clients matching name/account/meter **with their unpaid billings**
so the cashier picks which billing to pay:
```json
{
  "results": [
    {
      "id": 223, "name": "Reggie Castro 2", "account_number": "3-23005755",
      "meter_number": "12345", "total_balance": 80.00,
      "billings": [
        { "id": 21, "billing_number": "BIL-3-000017", "billing_period": "Jul 2026",
          "due_date": "2026-07-28T15:59:59.000000Z", "total_amount_due": 80.00,
          "amount_paid": 0, "balance": 80.00, "penalties": 0,
          "status": "sent", "is_overdue": false }
      ]
    }
  ]
}
```

```
POST /payments
```
**Request** — mobile vocabulary (preferred):
```json
{
  "billing_number": "BIL-3-000017",
  "amount": 100.00,
  "method": "cash",
  "reference": "OR-2026-0455",
  "received_by": "Cashier Name"
}
```
`method`: `cash | gcash | bank | check` (`bank` is stored as `bank_transfer`
and mapped back on output). `received_by` is the receipt display name; it is
**persisted** on the payment and returned on every later fetch — when omitted
it defaults to the recording user's name.

The legacy/web body is still accepted: `billing_id`, `payment_method`
(`cash | check | bank_transfer | online | gcash`), `payment_type`
(`billing | installation | reconnection | late_fee | deposit | other`),
`reference_number`, `sales_invoice_number`, `sales_invoice_date`, `notes`.

Partial payments are allowed. Cash tendered above the balance is applied only
up to the balance and the rest returned as `change`:

**Success (201)**
```json
{
  "message": "Payment recorded successfully.",
  "payment": { "id": 98, "amount": "80.00", "…": "…" },
  "change": 20,
  "billing": { "id": 21, "billing_number": "BIL-3-000017",
               "total_amount_due": "80.00", "amount_paid": "80.00",
               "balance": "0.00", "status": "paid" }
}
```
**409** if the billing is already fully paid.

```
GET /payments?search=&status=&payment_type=&page=
```
Paginated history (system-generated fee records excluded);
`meta.total_collected_today` gives the cashier's running total.

```
GET /payments/{id}
```

---

## 5. Conventions

- **Dates**: ISO 8601 UTC — `2026-07-05T23:28:40.000000Z`.
- **Money & readings**: strings with 2 decimals in most payloads (Laravel
  decimal casts) — parse with `double.parse(...)` in Dart.
- **Pagination**: `?page=N`; responses include a `meta` block.
- **Reading status**: `draft` → `approved` / `rejected`.

### Error envelope
```json
{ "message": "Human readable error", "errors": { "field": ["..."] } }
```
| Code | Use |
|------|-----|
| 200 / 201 | OK / created |
| 401 | Missing/expired token → force re-login |
| 403 | Role not permitted / no company on account |
| 404 | Resource not found (or belongs to another company) |
| 409 | Conflict (pending reading exists, already approved, already paid) |
| 422 | Validation error |
| 500 | Server error |

---

## 6. Flutter integration

### 6.1 Dio client with interceptors

```dart
class ApiClient {
  ApiClient(this._tokenStore, {required String baseUrl})
      : dio = Dio(BaseOptions(
          baseUrl: baseUrl, // https://billing.happyimart.com/api/v1
          connectTimeout: const Duration(seconds: 15),
          receiveTimeout: const Duration(seconds: 20),
          headers: {'Accept': 'application/json'},
        )) {
    dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) {
        final token = _tokenStore.token;
        if (token != null) options.headers['Authorization'] = 'Bearer $token';
        handler.next(options);
      },
      onError: (e, handler) {
        if (e.response?.statusCode == 401) _tokenStore.clear(); // → login screen
        handler.next(e);
      },
    ));
  }

  final Dio dio;
  final TokenStore _tokenStore;
}
```

### 6.2 Branding after login

```dart
final res = await dio.post('/auth/login', data: {...});
final company = CompanyBrand.fromJson(res.data['user']['company']);
// company.logoUrl  → CachedNetworkImage in the app bar / drawer header
// company.currencySymbol → money formatting everywhere
// company.settings.enableBlockLotSearch → toggle block/lot fields in search
await brandStore.save(company); // refresh from GET /company on app start
```

### 6.3 Reading flow (mirrors the web)

1. `GET /clients/search?q=…` → pick client
2. `GET /clients/{id}/meter-info` → block the form if `has_pending_reading`,
   require meter number if `meter_number_required`
3. (optional) `POST /meter-reading/scan` with a photo → prefill value
4. `POST /meter-readings` → draft created
5. Admin (in-app or web): `POST /meter-readings/{id}/approve` → billing returned

### 6.4 Offline-first for field readers

- Cache clients locally (Drift/Isar); queue readings with a `synced` flag.
- Background sync POSTs queued readings when online; a `409` means a pending
  reading already exists — surface it, don't retry blindly.

### 6.5 Token storage

`flutter_secure_storage` (Keychain/Keystore), never `SharedPreferences`.

---

## 7. Security notes

- HTTPS only; consider certificate pinning for production builds.
- Per-user revocable tokens; logout calls `/auth/logout`.
- The server is the source of truth for permissions; client-side role checks are UX only.

---

**Version**: 2.0 · **Last updated**: 2026-07-05 · Companion to
[FLUTTER_DEPLOYMENT.md](FLUTTER_DEPLOYMENT.md) and
[FABLE5_FLUTTER_APP_INSTRUCTION.md](FABLE5_FLUTTER_APP_INSTRUCTION.md)
