# EzFin REST API

**Version:** 1.1 · **Last verified:** 2026-10-03
**Base URL:** `https://ezfin.app/api`
**Authentication:** Laravel Sanctum bearer tokens
**Format:** JSON (`Accept: application/json`)

Every endpoint in this document is covered by the automated suite in `tests/Feature/Api` (50 tests, 669 assertions), which exercises the request and response shapes described here.

---

## Table of Contents

1. [Conventions](#conventions)
2. [Authentication](#authentication)
3. [User Profile](#user-profile)
4. [Accounts](#accounts)
5. [Categories](#categories)
6. [Transactions](#transactions)
7. [Recurring Transactions](#recurring-transactions)
8. [Budgets](#budgets)
9. [Financial Goals](#financial-goals)
10. [Customers & Suppliers](#customers--suppliers)
11. [Accounts Receivable & Payable](#accounts-receivable--payable)
12. [Payments](#payments)
13. [Reports](#reports)
14. [Settings](#settings)
15. [Dashboard](#dashboard)
16. [Notifications](#notifications)
17. [Mobile](#mobile)
18. [Admin API](#admin-api)
19. [Endpoint Index](#endpoint-index)
20. [Quick Start](#quick-start)

---

## Conventions

### Headers

| Header | Value | When |
|--------|-------|------|
| `Accept` | `application/json` | Always. Without it, errors may come back as HTML redirects instead of JSON. |
| `Content-Type` | `application/json` | Requests with a JSON body |
| `Content-Type` | `multipart/form-data` | File uploads (transaction attachment, avatar) |
| `Authorization` | `Bearer {token}` | Every endpoint except `/test`, `/register` and `/login` |

### Identifiers

All IDs are **UUID strings** (for example `"01a1029e-d31c-700e-bab1-474f5458ea23"`), never integers.

### Data types

- **Money.** Amounts stored in the database (`amount`, `initial_balance`, `target_amount`, `paid_amount`, …) are returned as **decimal strings** such as `"1200.00"`. Values computed by the API (`current_balance`, `spent`, `progress`, report totals, …) are JSON numbers. Parse both as decimals.
- **Dates.** Send dates as `YYYY-MM-DD` and times as `HH:mm`. Date fields come back as ISO 8601 timestamps (`"2026-03-06T00:00:00.000000Z"`). Report labels and `upcoming` dates are plain `YYYY-MM-DD` strings.
- **Booleans.** Query-string flags accept `1`/`0` and `true`/`false`.

### Success responses

```json
{
  "success": true,
  "data": { },
  "message": "Optional human-readable message"
}
```

`201 Created` is returned when a resource is created; everything else that succeeds returns `200 OK`.

### Pagination

There are two pagination shapes.

**Laravel paginator.** Used by `/transactions`, `/accounts-receivable`, `/accounts-payable`, `/payments` and `/reports/saved`:

```json
{
  "success": true,
  "data": {
    "current_page": 1,
    "data": [ ],
    "first_page_url": "https://ezfin.app/api/transactions?page=1",
    "from": 1,
    "last_page": 4,
    "last_page_url": "https://ezfin.app/api/transactions?page=4",
    "links": [ ],
    "next_page_url": "https://ezfin.app/api/transactions?page=2",
    "path": "https://ezfin.app/api/transactions",
    "per_page": 25,
    "prev_page_url": null,
    "to": 25,
    "total": 92
  }
}
```

**Compact.** Used by `/notifications` and the admin lists:

```json
{
  "success": true,
  "data": {
    "items_key": [ ],
    "pagination": { "current_page": 1, "per_page": 15, "total": 25, "last_page": 2 }
  }
}
```

Use `?page=N` to move between pages. `per_page` is clamped to the range 1–100.

### Errors

| Status | Meaning | Body |
|--------|---------|------|
| `401` | Missing, invalid or revoked token | `{"message": "Unauthenticated."}` |
| `403` | Resource belongs to someone else | `{"success": false, "message": "Unauthorized"}` |
| `403` | Not an admin (on `/admin/*`) | `{"message": "Unauthorized action."}` |
| `403` | Account suspended | `{"success": false, "message": "Your account has been suspended."}` |
| `404` | Unknown ID or route | `{"message": "No query results for model [App\\Models\\Account] {id}"}` |
| `400` | Request understood but not allowed (business rule) | `{"success": false, "message": "..."}` |
| `422` | Validation failed | see below |
| `500` | Unexpected server error | `{"message": "Server Error"}` |

Validation errors come in one of two shapes, depending on the endpoint:

```json
{ "success": false, "errors": { "email": ["The email field must be a valid email address."] } }
```

```json
{ "message": "The provided credentials are incorrect.", "errors": { "email": ["The provided credentials are incorrect."] } }
```

The second shape is used by `/login`, `/mobile/sync` and every `/admin/*` endpoint. Both shapes always include an `errors` object keyed by field name.

### Supported values

- **Languages:** `en`, `pt-br`, `es`
- **Currencies:** `USD`, `EUR`, `BRL`, `GBP`, `CAD`, `AUD`, `JPY`, `CHF`, `CNY`, `MXN`
- **Themes:** `light`, `dark`, `system`

### Rate limiting

No request rate limit is currently enforced. If you receive `429 Too Many Requests`, back off and retry.

### Health check

```
GET /api/test
```

No authentication. Returns `{"message": "API is working", "timestamp": "..."}`.

---

## Authentication

### Register

```
POST /api/register
```

| Field | Type | Required | Rules |
|-------|------|----------|-------|
| `name` | string | yes | max 255 |
| `email` | string | yes | valid e-mail, unique |
| `password` | string | yes | min 8 |
| `password_confirmation` | string | yes | must match `password` |
| `language` | string | no | `en` (default), `pt-br`, `es` |
| `currency` | string | no | a supported currency, default `USD` (stored as `preferred_currency`) |
| `theme` | string | no | `light` (default), `dark`, `system` |

Registering through the API behaves like signing up on the website: the account gets the default categories and a welcome e-mail.

**201**

```json
{
  "success": true,
  "data": {
    "user": {
      "id": "01a1029e-d2c7-711e-8442-841f018a1a39",
      "name": "John Doe",
      "email": "john@example.com",
      "language": "en",
      "preferred_currency": "USD",
      "created_at": "2026-10-03T16:35:25.000000Z",
      "updated_at": "2026-10-03T16:35:25.000000Z"
    },
    "access_token": "01a1029e-d2df-710e-90cd-d0d9e261c9a9|t1Gnr0LjNcUfLnA9AfOfxJWmX2L0NFGxph09YHRa72d80122",
    "token_type": "Bearer"
  },
  "message": "User registered successfully"
}
```

### Login

```
POST /api/login
```

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `email` | string | yes | |
| `password` | string | yes | |
| `device_name` | string | no | Stored as the token name (defaults to the User-Agent) |
| `revoke_existing` | boolean | no | Revoke every other token of this user first |

**200.** Same `data` shape as Register (`user`, `access_token`, `token_type`), with the message `"Logged in successfully"`.

**422.** Wrong e-mail or password:

```json
{ "message": "The provided credentials are incorrect.", "errors": { "email": ["The provided credentials are incorrect."] } }
```

**403.** The account was suspended by an administrator.

### Current user

```
GET /api/user
GET /api/profile        (alias)
```

Returns the user together with their `setting` record.

```json
{
  "success": true,
  "data": {
    "id": "01a1029e-d2a7-72e2-8251-84cfc64c6df1",
    "name": "Jane Doe",
    "email": "jane@example.com",
    "phone": null,
    "language": "en",
    "preferred_currency": "USD",
    "is_admin": false,
    "avatar": null,
    "suspended_at": null,
    "setting": {
      "language": "en",
      "currency": "USD",
      "date_format": "Y-m-d",
      "time_format": "H:i",
      "theme": "light",
      "notifications_enabled": true,
      "notification_preferences": null,
      "device_token": null,
      "device_type": null
    }
  }
}
```

### Logout

```
POST /api/logout
```

Revokes the token used for the request. **200** `{"success": true, "message": "Logged out successfully"}`

### Refresh token

```
POST /api/refresh
```

Revokes the current token and issues a new one with the same name.

```json
{ "success": true, "data": { "access_token": "…|…", "token_type": "Bearer" }, "message": "Token refreshed successfully" }
```

### List tokens

```
GET /api/tokens
```

```json
{
  "success": true,
  "data": [
    {
      "id": "01a1029e-d30f-7259-999f-36fc83cce91b",
      "name": "iPhone 15",
      "abilities": ["*"],
      "last_used_at": null,
      "expires_at": null,
      "created_at": "2026-10-03T16:35:25.000000Z"
    }
  ]
}
```

### Create token

```
POST /api/tokens
```

| Field | Type | Required |
|-------|------|----------|
| `name` | string | yes, max 255 |

**201**

```json
{ "success": true, "data": { "token": "01a1029e-…|SbWn0qeh…", "token_id": "01a1029e-d30f-7259-999f-36fc83cce91b" }, "message": "Token created successfully" }
```

The plain-text token is shown only once.

### Delete token

```
DELETE /api/tokens/{token_id}
```

**200** `{"success": true, "message": "Token deleted successfully"}`. Returns **404** if the token does not exist or belongs to another user.

---

## User Profile

### Update profile

```
PUT /api/profile
```

Send only the fields you want to change.

| Field | Type | Rules |
|-------|------|-------|
| `name` | string | max 255 |
| `email` | string | valid e-mail, unique |
| `language` | string | `en`, `pt-br`, `es` |
| `preferred_currency` | string | supported currency |
| `phone` | string\|null | max 20 |
| `settings` | object | optional, see below |
| `settings.theme` | string | `light`, `dark`, `system` |
| `settings.date_format` | string | `Y-m-d`, `d/m/Y`, `m/d/Y`, `d-m-Y`, `m-d-Y` |
| `settings.time_format` | string | `H:i`, `h:i A` |
| `settings.notifications_enabled` | boolean | |
| `settings.notification_preferences` | object\|null | |

Unknown keys inside `settings` are ignored. **200** returns the user with `setting`, and the message `"Profile updated successfully"`.

### Change password

```
POST /api/profile/password
```

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `current_password` | string | yes | |
| `password` | string | yes | min 8, must be confirmed |
| `password_confirmation` | string | yes | |
| `revoke_other_sessions` | boolean | no | Revoke every token except the current one |

**200** `{"success": true, "message": "Password updated successfully"}`. A wrong `current_password` returns **422** with `errors.current_password`.

### Upload avatar

```
POST /api/profile/avatar
Content-Type: multipart/form-data
```

| Field | Type | Rules |
|-------|------|-------|
| `avatar` | file | `jpeg`, `png`, `jpg` or `gif`, max 2 MB |

```json
{ "success": true, "data": { "avatar_url": "https://ezfin.app/storage/avatars/abc123.jpg" }, "message": "Avatar uploaded successfully" }
```

Uploading a new avatar replaces and deletes the previous file.

### Delete account

```
POST /api/profile/delete-account
```

| Field | Type | Rules |
|-------|------|-------|
| `password` | string | the current password |
| `confirmation` | string | must be exactly `DELETE` |

**200** `{"success": true, "message": "Account deleted successfully"}`. This permanently removes the user, every record they own, and all of their tokens. A wrong password returns **422**.

---

## Accounts

Accounts can be **owned** by the user or **shared** with them by another user. A shared account carries a role: `viewer` (read-only) or `editor` (can edit the account and add or edit its transactions). Only the owner can delete an account.

### Account object

| Field | Type | Notes |
|-------|------|-------|
| `id` | uuid | |
| `name` | string | |
| `type` | string | `checking`, `savings`, `cash`, `credit_card`, `investment`, `other` |
| `initial_balance` | decimal | |
| `currency` | string | 3 letters |
| `icon` / `color` | string\|null | Material icon name / hex colour |
| `is_active` | boolean | |
| `include_in_totals` | boolean | Counted in `/accounts/balances` and the dashboard total |
| `closing_day` / `due_day` | int\|null | Credit cards only (1–31) |
| `current_balance` | number | `initial_balance + income − expense`. Transfers are not counted. |
| `user_role` | string | `owner`, or the role on a shared account |
| `is_owner` / `is_shared` | boolean | |
| `owner_name` / `owner_email` | string | Shared accounts only |

### List accounts

```
GET /api/accounts
```

| Query | Type | Description |
|-------|------|-------------|
| `active` | `true` | Only active accounts |
| `include_balance` | boolean | Add `current_balance` to every account (default `false`) |
| `include_shared` | boolean | Include accounts shared with the user (default `true`) |

```json
{
  "success": true,
  "data": {
    "accounts": [
      {
        "id": "01a1029e-d31c-700e-bab1-474f5458ea23",
        "name": "My Checking",
        "type": "checking",
        "initial_balance": "1000.00",
        "currency": "USD",
        "icon": "account_balance",
        "color": "#4CAF50",
        "is_active": true,
        "include_in_totals": true,
        "closing_day": null,
        "due_day": null,
        "user_role": "owner",
        "is_owner": true,
        "is_shared": false,
        "current_balance": 5950
      }
    ],
    "owned_count": 2,
    "shared_count": 0,
    "total_count": 2
  }
}
```

### Create account

```
POST /api/accounts
```

| Field | Type | Required | Rules |
|-------|------|----------|-------|
| `name` | string | yes | max 255 |
| `type` | string | yes | see the account object |
| `initial_balance` | number | yes | |
| `currency` | string | yes | exactly 3 characters |
| `icon` | string | no | max 50 |
| `color` | string | no | max 50 |
| `is_active` | boolean | no | default `true` |
| `include_in_totals` | boolean | no | default `true` |
| `closing_day` | int | no | 1–31, kept only when `type` is `credit_card` |
| `due_day` | int | no | 1–31, kept only when `type` is `credit_card` |

**201** returns the account, with the message `"Account created successfully"`.

### Get account

```
GET /api/accounts/{id}
```

| Query | Type | Description |
|-------|------|-------------|
| `include_members` | boolean | Add `owner`, `member_count` and `members[]` (`id`, `name`, `email`, `role`, `joined_at`) |

Always includes `current_balance`, `user_role` and `is_owner`. Owners and shared users can read the account; anyone else gets **403**.

### Update account

```
PUT /api/accounts/{id}
```

Accepts the same fields as create, all optional. Changing `type` away from `credit_card` clears `closing_day` and `due_day`. Requires the owner or an editor of the account.

### Delete account

```
DELETE /api/accounts/{id}
```

Owner only. Returns **422** `"Cannot delete account that has transactions"` if the account still has transactions.

### Balances

```
GET /api/accounts/balances
```

Totals across the user's own **active** accounts that have `include_in_totals = true`. Shared accounts are not included.

```json
{
  "success": true,
  "data": {
    "by_type": { "checking": { "USD": 5950 }, "savings": { "USD": 500 } },
    "by_currency": { "USD": 6450 }
  }
}
```

---

## Categories

### List categories

```
GET /api/categories
```

| Query | Description |
|-------|-------------|
| `type` | `income` or `expense` |

```json
{
  "success": true,
  "data": [
    { "id": "01a1029e-d322-736a-952e-9fd296e05e6d", "name": "Food", "type": "expense", "icon": "restaurant", "color": "#FF6384", "is_default": false }
  ]
}
```

### Create category

```
POST /api/categories
```

| Field | Type | Required | Rules |
|-------|------|----------|-------|
| `name` | string | yes | max 255 |
| `type` | string | yes | `income` or `expense` |
| `icon` | string | no | max 50 |
| `color` | string | no | max 50 |

**201** returns the category, with the message `"Category created successfully"`.

### Get / update / delete

```
GET    /api/categories/{id}
PUT    /api/categories/{id}     (name, type, icon, color — all optional)
DELETE /api/categories/{id}
```

Only the owner can read or change a category (**403** otherwise). Default categories (`is_default: true`) cannot be edited or deleted, and categories used by transactions cannot be deleted. Both cases return **422**.

---

## Transactions

### Transaction object

| Field | Type | Notes |
|-------|------|-------|
| `id` | uuid | |
| `account_id` / `category_id` | uuid | `category_id` may be null |
| `type` | string | `income`, `expense`, `transfer` |
| `amount` | decimal | always positive; `type` gives the direction |
| `date` | date | |
| `time` | datetime\|null | the time of day is the meaningful part |
| `description` | string\|null | |
| `attachment` | string\|null | path on the public disk, served at `https://ezfin.app/storage/{attachment}` |
| `transfer_transaction_id` | uuid\|null | the other leg of a transfer |
| `is_recurring` | boolean | created by a recurring transaction |
| `account` / `category` | object | embedded in list, show, create and update responses |

### List transactions

```
GET /api/transactions
```

Returns transactions from every account the user can access, including shared accounts.

| Query | Type | Description |
|-------|------|-------------|
| `account_id` | uuid | |
| `category_id` | uuid | |
| `type` | string | `income`, `expense`, `transfer` |
| `start_date` / `end_date` | date | Inclusive range on `date` |
| `min_amount` / `max_amount` | number | |
| `search` | string | Substring match on `description` |
| `order_by` | string | `date` (default), `amount`, `description`, `created_at` |
| `order_dir` | string | `desc` (default) or `asc` |
| `per_page` | int | default 25, max 100 |
| `page` | int | |

Returns a [Laravel paginator](#pagination) of transactions, each with `account` and `category` embedded.

### Create transaction

```
POST /api/transactions
```

| Field | Type | Required | Rules |
|-------|------|----------|-------|
| `account_id` | uuid | yes | an account the user owns or can edit |
| `type` | string | yes | `income`, `expense`, `transfer` |
| `amount` | number | yes | min 0.01 |
| `date` | date | yes | |
| `time` | string | no | `HH:mm` |
| `category_id` | uuid | no | must be one of the user's own categories |
| `description` | string | no | max 255 |
| `attachment` | file | no | max 10 MB (send as `multipart/form-data`) |
| `transfer_account_id` | uuid | if `type=transfer` | an account the user can edit, different from `account_id` |

**201** returns the transaction with `account` and `category`.

**Transfers.** Creating a transfer creates two linked `transfer` transactions: `"Transfer to {destination}"` on the source account and `"Transfer from {source}"` on the destination. Updating one leg keeps the amount, date and time of the other in sync, and deleting either leg deletes both.

**Errors.** **403** if the account, the category or the transfer destination is not one the user can use. **422** for validation errors.

### Get transaction

```
GET /api/transactions/{id}
```

Includes `account`, `category` and `transfer_transaction.account`.

### Update transaction

```
PUT /api/transactions/{id}
```

Accepts `account_id`, `category_id`, `type`, `amount`, `date`, `time`, `description` and `attachment`, all optional. To upload a new attachment, send `POST /api/transactions/{id}` as `multipart/form-data` with `_method=PUT`, because PHP does not parse multipart bodies on real `PUT` requests.

### Delete transaction

```
DELETE /api/transactions/{id}
```

Deletes the attachment and, for transfers, the counterpart transaction as well.

### Statistics

```
GET /api/transactions/statistics
```

| Query | Required | Values |
|-------|----------|--------|
| `start_date` | yes | date |
| `end_date` | yes | date ≥ `start_date` |
| `group_by` | yes | `day`, `week`, `month`, `year`, `category` |
| `type` | no | `income`, `expense`, `both` (default) |

Transfers are excluded. Grouping by time returns chart-ready datasets (label formats: `2026-03-06`, `2026-W10`, `2026-03`, `2026`):

```json
{
  "success": true,
  "data": {
    "labels": ["2026-01", "2026-02"],
    "datasets": [
      { "label": "Income", "data": [5000, 0], "backgroundColor": "#4CAF50" },
      { "label": "Expense", "data": [300, 200], "backgroundColor": "#F44336" }
    ]
  }
}
```

`group_by=category` returns:

```json
{ "success": true, "data": { "income": [{ "label": "Salary", "value": "5000.00" }], "expense": [{ "label": "Food", "value": "500.00" }] } }
```

---

## Recurring Transactions

### List

```
GET /api/recurring-transactions
```

| Query | Description |
|-------|-------------|
| `account_id`, `category_id` | Filters |
| `type` | `income` or `expense` |
| `active` | `true`: only items with no `end_date`, or with an `end_date` today or later |
| `order_by` / `order_dir` | Results are sorted by `next_date` (`asc` by default; `order_dir=desc` reverses) |

Each item has `next_date` (the next occurrence, or `null`) and embeds `account` and `category`.

### Create

```
POST /api/recurring-transactions
```

| Field | Type | Required | Rules |
|-------|------|----------|-------|
| `name` | string | no | max 255. Defaults to `description`, then to `"Recurring Transaction"` |
| `account_id` | uuid | yes | one of the user's own accounts |
| `category_id` | uuid | no | one of the user's own categories |
| `type` | string | yes | `income` or `expense` |
| `amount` | number | yes | min 0.01 |
| `description` | string | no | max 255 |
| `frequency` | string | yes | `daily`, `weekly`, `bi_weekly`, `monthly`, `quarterly`, `semi_annually`, `annually` |
| `day_of_month` | int | for monthly, quarterly, semi_annually and annually | 1–31 (clamped to the length of each month) |
| `day_of_week` | int | for weekly and bi_weekly | 0 (Sunday) – 6 (Saturday) |
| `start_date` | date | yes | |
| `end_date` | date | no | ≥ `start_date` |
| `auto_generate` | boolean | no | default `true`. The nightly job creates the transactions automatically |

**201**

```json
{
  "success": true,
  "data": {
    "id": "01a1029e-d362-73d4-9588-0cf24830e970",
    "name": "Monthly Rent",
    "type": "expense",
    "amount": "1200.00",
    "frequency": "monthly",
    "day_of_month": 5,
    "day_of_week": null,
    "start_date": "2026-01-01T00:00:00.000000Z",
    "end_date": null,
    "auto_generate": true,
    "next_date": "2026-10-05T00:00:00.000000Z",
    "account": { },
    "category": { }
  },
  "message": "Recurring transaction created successfully"
}
```

### Get / update / delete

```
GET    /api/recurring-transactions/{id}
PUT    /api/recurring-transactions/{id}
DELETE /api/recurring-transactions/{id}
```

Update accepts the same fields as create, all optional.

### Generate a transaction now

```
POST /api/recurring-transactions/{id}/generate
```

| Field | Type | Description |
|-------|------|-------------|
| `date` | date | Optional. Defaults to the next occurrence |

Creates a real transaction (`is_recurring: true`) and records it as `last_generated`.

```json
{ "success": true, "data": { "transaction": { }, "recurring_transaction": { "next_date": "2026-11-05T00:00:00.000000Z" } }, "message": "Transaction generated successfully" }
```

**400** `"No valid occurrence date found"` once the series has ended.

### Upcoming occurrences

```
GET /api/recurring-transactions/upcoming?days=30
```

`days` ranges from 1 to 365 (default 30). Returns every occurrence within that window, sorted by date:

```json
{
  "success": true,
  "data": [
    {
      "id": "01a1029e-d362-73d4-9588-0cf24830e970",
      "recurring_transaction_id": "01a1029e-d362-73d4-9588-0cf24830e970",
      "name": "Monthly Rent",
      "type": "expense",
      "amount": "1200.00",
      "description": null,
      "date": "2026-10-05",
      "auto_generate": true,
      "account": { },
      "category": { }
    }
  ]
}
```

---

## Budgets

### List

```
GET /api/budgets
```

| Query | Description |
|-------|-------------|
| `active` | `true`: `start_date` ≤ today and (`end_date` empty or ≥ today) |
| `category_id` | Filter |
| `period` | `daily`, `weekly`, `monthly`, `quarterly`, `yearly`, `annually`, `custom` |

Every budget includes:

- `spent`: the expenses in the **current** period (this day, week, month, quarter or year; for `custom`, the budget's own date range). Only the budget's category counts, or all expenses if it has no category.
- `progress`: the percentage of `amount` spent, capped at 100.

```json
{
  "success": true,
  "data": [
    {
      "id": "01a1029e-d378-7300-9c5e-6609e15fb1a6",
      "name": "Food Budget",
      "amount": "500.00",
      "period": "monthly",
      "start_date": "2026-01-01T00:00:00.000000Z",
      "end_date": null,
      "spent": 50,
      "progress": 10,
      "category": { }
    }
  ]
}
```

### Create

```
POST /api/budgets
```

| Field | Type | Required | Rules |
|-------|------|----------|-------|
| `name` | string | yes | max 255 |
| `amount` | number | yes | min 0 |
| `period` | string | yes | see List |
| `start_date` | date | yes | |
| `end_date` | date | no | ≥ `start_date` |
| `category_id` | uuid | no | one of the user's own categories |

### Get / update / delete

```
GET    /api/budgets/{id}        (includes spent and progress)
PUT    /api/budgets/{id}
DELETE /api/budgets/{id}
```

### Progress of active budgets

```
GET /api/budgets/progress
```

Every currently active budget, with `spent` and `progress`.

---

## Financial Goals

Both prefixes are equivalent: `/api/goals` and `/api/financial-goals`.

### List

```
GET /api/goals
```

| Query | Description |
|-------|-------------|
| `is_completed` | `true` or `false` |
| `order_by` | `name`, `target_date`, `target_amount`, `current_amount`, `created_at` (default) |
| `order_dir` | `desc` (default) or `asc` |

Every goal includes `progress` (0–100). Goals with a `target_date` also include `days_remaining` (whole days; negative when past) and `is_overdue`.

```json
{
  "success": true,
  "data": [
    {
      "id": "01a1029e-d38a-704f-8341-5caf85b1749e",
      "name": "Emergency Fund",
      "target_amount": "10000.00",
      "current_amount": "6500.00",
      "target_date": "2027-10-03T00:00:00.000000Z",
      "is_completed": false,
      "progress": 65,
      "days_remaining": 364,
      "is_overdue": false
    }
  ]
}
```

### Create

```
POST /api/goals
```

| Field | Type | Required | Rules |
|-------|------|----------|-------|
| `name` | string | yes | max 255 |
| `target_amount` | number | yes | min 0.01 |
| `current_amount` | number | no | min 0, default 0 |
| `target_date` | date | no | today or later |
| `description` | string | no | |
| `icon` / `color` | string | no | max 50 |

A goal whose `current_amount` already reaches `target_amount` is marked `is_completed: true`.

### Get / update / delete

```
GET    /api/goals/{id}
PUT    /api/goals/{id}          (same fields as create, plus is_completed)
DELETE /api/goals/{id}
```

### Add funds

```
POST /api/goals/{id}/add-funds
```

| Field | Type | Rules |
|-------|------|-------|
| `amount` | number | min 0.01 |

Returns the goal with the updated `current_amount`, `progress` and `is_completed`, and the message `"Funds added to goal successfully"`.

---

## Customers & Suppliers

Customers (`/api/customers`) and suppliers (`/api/suppliers`) have identical fields.

| Field | Type | Rules |
|-------|------|-------|
| `name` | string | required on create, max 255 |
| `document` | string | CPF/CNPJ/tax ID, max 20 |
| `email` | string | valid e-mail |
| `phone` | string | max 20 |
| `address` | string | |
| `city` / `state` / `country` | string | max 100 |
| `postal_code` | string | max 20 |
| `notes` | string | |
| `is_active` | boolean | default `true` |

```
GET    /api/customers            ?active=true|false  ?search=  (name, e-mail or document)
POST   /api/customers
GET    /api/customers/{id}
PUT    /api/customers/{id}
DELETE /api/customers/{id}
```

The `/api/suppliers` routes are the same.

- **List.** Customers come with `total_receivable`, `overdue_receivable` and their `accounts_receivable`. Suppliers come with `total_payable`, `overdue_payable` and their `accounts_payable`.
- **Show.** Also embeds the payments of each receivable or payable.
- **Delete.** **422** while the customer still has unpaid receivables, or the supplier unpaid payables.

---

## Accounts Receivable & Payable

Receivables (`/api/accounts-receivable`, linked to a `customer_id`) and payables (`/api/accounts-payable`, linked to a `supplier_id`) work the same way.

### Object

| Field | Type | Notes |
|-------|------|-------|
| `customer_id` / `supplier_id` | uuid | |
| `invoice_number` | string\|null | |
| `description` | string | |
| `amount` | decimal | |
| `paid_amount` / `remaining_amount` | decimal | maintained by the API |
| `issue_date` / `due_date` | date | |
| `status` | string | `pending`, `partial`, `paid`, `overdue`, computed automatically |
| `notes` | string\|null | |

### Endpoints

```
GET    /api/accounts-receivable            paginated
POST   /api/accounts-receivable
GET    /api/accounts-receivable/summary
GET    /api/accounts-receivable/{id}       includes customer and payments (with their account)
PUT    /api/accounts-receivable/{id}
DELETE /api/accounts-receivable/{id}
POST   /api/accounts-receivable/{id}/payments
```

The `/api/accounts-payable` routes are the same, with `supplier_id`.

**List filters:** `status`, `customer_id` (or `supplier_id`), `start_date`/`end_date` (on `due_date`), `per_page` (default 25). Results are sorted by `due_date` ascending.

**Create / update fields**

| Field | Type | Required | Rules |
|-------|------|----------|-------|
| `customer_id` / `supplier_id` | uuid | yes | must belong to the user |
| `description` | string | yes | max 255 |
| `amount` | number | yes | min 0.01 |
| `issue_date` | date | yes | |
| `due_date` | date | yes | ≥ `issue_date` |
| `invoice_number` | string | no | max 100 |
| `notes` | string | no | |

`paid_amount`, `remaining_amount`, `status` and `user_id` cannot be set by the client. Changing `amount` recalculates `remaining_amount` and `status`. Deleting a receivable or payable that already has payments returns **422**.

**Summary**

```json
{
  "success": true,
  "data": {
    "total": 5000,
    "pending": 0,
    "overdue": 0,
    "partial": 3000,
    "paid": 2000,
    "count": 1,
    "pending_count": 0,
    "overdue_count": 0,
    "paid_count": 0
  }
}
```

In the summary, `total` is the sum of `amount`; `pending`, `overdue` and `partial` are sums of `remaining_amount`; `paid` is the sum of `paid_amount`.

### Register a payment

```
POST /api/accounts-receivable/{id}/payments
POST /api/accounts-payable/{id}/payments
```

| Field | Type | Required | Rules |
|-------|------|----------|-------|
| `amount` | number | yes | min 0.01, max = the current `remaining_amount` |
| `account_id` | uuid | yes | one of the user's **own** bank accounts |
| `description` | string | no | max 255, default `"Payment for {invoice_number or description}"` |
| `payment_date` | date | no | default today |

Every payment automatically creates a matching transaction on `account_id`: **income** for receivables, **expense** for payables. It also updates `paid_amount`, `remaining_amount` and `status`.

**201**

```json
{
  "success": true,
  "data": {
    "id": "01a1029e-d3a7-70a2-ae5f-7749effe04a6",
    "type": "receivable",
    "amount": "2000.00",
    "payment_date": "2026-10-03T00:00:00.000000Z",
    "description": "Payment for INV-001",
    "transaction_id": "01a1029e-d3a8-7177-b409-5401ad4ebdec",
    "account": { },
    "receivable": { "status": "partial", "paid_amount": "2000.00", "remaining_amount": "3000.00", "customer": { } }
  },
  "message": "Payment received successfully."
}
```

For payables, the response has `payable` (with `supplier`) instead of `receivable`, and the message is `"Payment made successfully."`.

---

## Payments

```
GET    /api/payments
GET    /api/payments/{id}
PUT    /api/payments/{id}
DELETE /api/payments/{id}
```

**List filters:** `type` (`receivable` or `payable`), `account_id`, `date_from`, `date_to`, `per_page` (default 25). Results are sorted by `payment_date` descending, and each payment embeds `account`, `receivable.customer` and `payable.supplier`.

**Show** also embeds the linked `transaction`.

**Update** accepts `amount` (max = `remaining_amount` + this payment's `amount`), `account_id` (one of the user's own accounts), `description` and `payment_date`. The linked transaction and the receivable or payable totals are updated to match.

**Delete** reverses the paid amount on the receivable or payable, recalculates its status, and deletes the linked transaction.

---

## Reports

All report endpoints take these common query parameters:

| Query | Values |
|-------|--------|
| `period` | `daily` (last 30 days, grouped by day), `weekly` (12 weeks), `monthly` (12 months, the default), `yearly` (5 years) |
| `start_date` / `end_date` | Override the range set by `period` |
| `accounts[]` | Restrict to some of the user's own account IDs, e.g. `?accounts[]=uuid1&accounts[]=uuid2` |

Reports read the user's own transactions. Transfers never count as income or expense.

### Income vs expense

```
GET /api/reports/income-expense
```

Takes an extra `group_by` parameter (`day`, `week`, `month` or `year`) that overrides the grouping chosen by `period`.

```json
{
  "success": true,
  "data": {
    "labels": ["2026-09", "2026-10"],
    "datasets": [
      { "label": "Income", "data": [5000, 5200], "backgroundColor": "#4CAF50" },
      { "label": "Expense", "data": [300, 350], "backgroundColor": "#F44336" }
    ],
    "summary": { "total_income": 10200, "total_expense": 650, "net_amount": 9550 },
    "period": { "start": "2025-10-03", "end": "2026-10-03", "group_by": "month" }
  }
}
```

### By category

```
GET /api/reports/category
```

Takes an extra `type` parameter: `expense` (default) or `income`.

```json
{
  "success": true,
  "data": {
    "labels": ["Food", "Transport"],
    "datasets": [{ "data": [650, 120], "backgroundColor": ["#FF6384", "#36A2EB"] }],
    "details": [
      { "category": "Food", "icon": "restaurant", "color": "#FF6384", "amount": 650, "count": 3, "percentage": 84.4 }
    ],
    "summary": { "total": 770, "transaction_count": 5, "category_count": 2 },
    "period": { "start": "2025-10-03", "end": "2026-10-03", "type": "expense" }
  }
}
```

### Cash flow

```
GET /api/reports/cash-flow
```

```json
{
  "success": true,
  "data": {
    "labels": ["Sep 2026", "Oct 2026"],
    "datasets": [
      { "label": "Balance", "data": [4700, 9550], "type": "line", "backgroundColor": "#2196F3", "borderColor": "#2196F3" },
      { "label": "Income", "data": [5000, 5200], "backgroundColor": "#4CAF50" },
      { "label": "Expense", "data": [300, 350], "backgroundColor": "#F44336" }
    ],
    "summary": { "initial_balance": 0, "final_balance": 9550, "total_income": 10200, "total_expense": 650, "net_change": 9550 },
    "period": { "start": "2026-09-01", "end": "2026-10-03" }
  }
}
```

`initial_balance` is the net of income and expense before `start_date`. Data points are grouped by the granularity of `period`.

### Net worth

```
GET /api/reports/net-worth
```

Takes `period`, `start_date` and `end_date`. Account balances follow the same rule as `current_balance` (initial balance + income − expense, as of each date).

- **Assets:** `checking`, `savings`, `investment` and `cash` accounts.
- **Liabilities:** `credit_card` accounts, counted by the absolute value of their balance.

```json
{
  "success": true,
  "data": {
    "labels": ["Aug 2026", "Sep 2026", "Oct 2026"],
    "datasets": [
      { "label": "Assets", "data": [1000, 5700, 10700] },
      { "label": "Liabilities", "data": [0, 0, 150] },
      { "label": "Net Worth", "data": [1000, 5700, 10550], "type": "line" }
    ],
    "summary": { "current_assets": 10700, "current_liabilities": 150, "current_net_worth": 10550, "change": 9550, "change_percentage": 955 },
    "accounts": [
      { "id": "…", "name": "Checking", "type": "checking", "balance": 10700, "is_asset": true },
      { "id": "…", "name": "Card", "type": "credit_card", "balance": -150, "is_asset": false }
    ],
    "period": { "start": "2026-08-03", "end": "2026-10-03" }
  }
}
```

### Saved reports

```
POST   /api/reports/save
GET    /api/reports/saved           paginated, 10 per page
GET    /api/reports/saved/{id}      runs the report with its saved parameters
DELETE /api/reports/saved/{id}
```

**Save body**

| Field | Type | Required | Rules |
|-------|------|----------|-------|
| `name` | string | yes | max 255 |
| `type` | string | yes | `income_expense`, `category`, `cash_flow`, `net_worth` |
| `parameters` | object | yes | the query parameters of that report, e.g. `{"period": "monthly"}`; may be empty |

**201**

```json
{ "success": true, "data": { "id": "…", "name": "Monthly overview", "type": "income_expense", "parameters": { "period": "monthly" } }, "message": "Report saved successfully" }
```

`GET /reports/saved/{id}` returns exactly the same response as calling that report endpoint with the saved parameters. Reports saved from the website appear here too.

---

## Settings

### Get settings

```
GET /api/settings
```

Creates default settings if the user has none yet.

```json
{
  "success": true,
  "data": {
    "language": "en",
    "currency": "USD",
    "date_format": "Y-m-d",
    "time_format": "H:i",
    "theme": "light",
    "notifications_enabled": true,
    "notification_preferences": null,
    "device_token": null,
    "device_type": null
  }
}
```

### Update settings

```
PUT /api/settings
```

| Field | Rules |
|-------|-------|
| `language` | `en`, `pt-br`, `es`. Also updates the user's language |
| `currency` | 3 characters |
| `date_format` | `Y-m-d`, `d/m/Y`, `m/d/Y`, `d-m-Y`, `m-d-Y` |
| `time_format` | `H:i`, `h:i A` |
| `theme` | `light`, `dark`, `system` |
| `notifications_enabled` | boolean |
| `notification_preferences` | a JSON-encoded **string**, e.g. `"{\"email\":false}"` |

### Update theme

```
PATCH /api/settings/theme
```

Body: `{"theme": "dark"}`. Returns `{"success": true, "data": {"theme": "dark"}, "message": "Theme updated successfully"}`.

---

## Dashboard

### Overview

```
GET /api/dashboard
```

```json
{
  "success": true,
  "data": {
    "summary": {
      "total_balance": 10550,
      "formatted_balance": "$10,550.00",
      "monthly_income": 5200,
      "formatted_income": "$5,200.00",
      "monthly_expense": 350,
      "formatted_expense": "$350.00",
      "net_flow": 4850,
      "formatted_net_flow": "$4,850.00",
      "income_change": 4,
      "expense_change": 16.67
    },
    "accounts": [
      { "id": "…", "name": "Checking", "type": "checking", "balance": 10700, "currency": "USD", "formatted_balance": "$10,700.00" }
    ],
    "budgets": [
      { "id": "…", "name": "Food", "amount": 1000, "category": "Food", "budget": "$1,000.00", "spent": 350, "remaining": 650, "percentage": 35 }
    ],
    "goals": [
      { "id": "…", "name": "Trip", "target_amount": "1000.00", "current_amount": "250.00", "progress": 25, "target_date": null }
    ],
    "recent_transactions": [
      { "id": "…", "type": "expense", "amount": "200.00", "formatted_amount": "$200.00", "description": "Groceries", "date": "2026-10-01", "category": "Food", "account": "Checking" }
    ]
  }
}
```

- `total_balance` is the sum of `current_balance` over the user's active, `include_in_totals` accounts. `accounts` lists every account the user owns.
- Monthly figures cover the current calendar month across all accessible accounts. `income_change` and `expense_change` are percentages against the previous month.
- `budgets` (up to 5) shows active budgets with this month's spending. `goals` (up to 3) shows incomplete goals.
- `recent_transactions` holds the last 10 transactions.
- The `formatted_*` fields always use a `$` symbol. Format the numeric fields yourself for other currencies.

### Widgets

```
GET /api/dashboard/widgets
```

```json
{
  "success": true,
  "data": {
    "counts": { "accounts": 2, "categories": 2, "transactions": 5, "budgets": 1, "goals": 1 },
    "category_spending": [{ "category": "Food", "amount": 350, "count": 2 }]
  }
}
```

`category_spending` holds the top 5 expense categories of the current month.

### Recent activity

```
GET /api/dashboard/recent-activity
```

The 15 most recent transactions, as `{type: "transaction", action, description, amount, date, data: {account, category}}`.

---

## Notifications

In-app notifications sent to the user.

### List

```
GET /api/notifications
```

| Query | Description |
|-------|-------------|
| `status` | `pending`, `sent`, `delivered`, `read`, `failed` |
| `channel` | `in_app`, `email`, `sms`, `whatsapp` |
| `unread_only` | boolean |
| `per_page` | default 15 |

Fetching the list marks the returned `in_app` notifications as `delivered`.

```json
{
  "success": true,
  "data": {
    "notifications": [
      {
        "id": "01a1029e-d3fc-73ca-972e-f6e260d15387",
        "title": "Budget Alert",
        "content": "Your Food budget is at 90%",
        "channel": "in_app",
        "status": "sent",
        "read_at": null,
        "sent_at": "2026-10-03T16:35:25.000000Z",
        "delivered_at": null,
        "created_at": "2026-10-03T16:35:25.000000Z",
        "is_read": false
      }
    ],
    "pagination": { "current_page": 1, "per_page": 15, "total": 1, "last_page": 1 },
    "summary": { "total": 1, "unread": 1, "in_app": 1, "email": 0 }
  }
}
```

### Actions

```
POST   /api/notifications/{id}/read     mark one as read
POST   /api/notifications/read-all      → {"message": "Marked 3 notifications as read"}
DELETE /api/notifications/{id}
```

A notification that belongs to another user returns **404**.

---

## Mobile

### Sync

```
GET /api/mobile/sync?last_sync=2026-03-06T15:00:00Z
```

Without `last_sync`, returns everything. With it, returns only records whose `updated_at` is later than `last_sync`. `last_sync` must be a valid date (**422** otherwise).

```json
{
  "success": true,
  "data": {
    "accounts": [ ],
    "shared_accounts": [ ],
    "categories": [ ],
    "transactions": [ ],
    "budgets": [ ],
    "goals": [ ],
    "sync_timestamp": "2026-10-03T16:35:25.000000Z"
  }
}
```

Each account embeds its `transactions`, and each shared account adds `user_role` and `is_shared`. Store `sync_timestamp` and send it as `last_sync` next time.

Deleted records are **not** reported by an incremental sync, so run a full sync (no `last_sync`) periodically to drop items removed elsewhere.

### Offline bundle

```
GET /api/mobile/offline-data
```

Returns the essentials for offline use: `user` (`id`, `name`, `email`, `preferred_currency`, `language`), `accounts`, `shared_accounts`, `categories`, the 100 most recent transactions in `recent_transactions`, `currencies` (a map of code to symbol, e.g. `"BRL": "R$"`) and `downloaded_at`.

### Register device token

```
POST /api/mobile/device-token
```

| Field | Rules |
|-------|-------|
| `device_token` | string, max 255 (FCM/APNs token) |
| `device_type` | `ios`, `android`, `web` |

```json
{ "success": true, "message": "Device token updated successfully.", "data": { "device_token": "fcm_token_here", "device_type": "android" } }
```

---

## Admin API

Every endpoint is under `/api/admin` and requires a token belonging to a user with `is_admin = true`. Anyone else receives **403** `{"message": "Unauthorized action."}`. Validation errors here use the `{message, errors}` shape.

Analytics endpoints accept `period`: `7days`, `30days` (default), `90days` or `1year`.

### Dashboard

```
GET /api/admin/dashboard
GET /api/admin/dashboard/stats?period=30days
GET /api/admin/dashboard/charts
```

- `dashboard` returns `metrics.users` (`total`, `active` = users with transactions in the last 30 days, `new_today`, `new_this_week`, `new_this_month`, `growth_rate`), `metrics.transactions` (`total`, `today`, `this_month`, `total_revenue`, `total_expenses`, `net_flow`), `metrics.whatsapp`, `metrics.subscriptions` (`active`, `revenue`) and `recent_activity` (the latest 10 `users` and `transactions`).
- `stats` returns `user_growth[]` (`date`, `count`), `transaction_volume[]` (`date`, `count`, `income`, `expenses`), `category_usage[]` (top 10) and `period`.
- `charts` returns `monthly_revenue[]` (the last 12 months), `subscription_distribution[]` (`Free` first) and `transaction_types[]` for the current month.

### Users

```
GET    /api/admin/users
POST   /api/admin/users
GET    /api/admin/users/{id}
PUT    /api/admin/users/{id}
DELETE /api/admin/users/{id}
POST   /api/admin/users/{id}/toggle-admin
POST   /api/admin/users/{id}/suspend
POST   /api/admin/users/{id}/activate
```

**List filters:** `search` (name or e-mail), `is_admin`, `subscription_status` (`active` or `free`), `suspended` (boolean), `registered_after`, `registered_before`, `sort_by` (`name`, `email`, `created_at` (default), `updated_at`, `is_admin`, `language`, `preferred_currency`), `sort_direction`, `per_page` (default 15).

Each user in the list has `is_suspended`, `suspended_at`, `subscription` and `stats` (`accounts_count`, `transactions_count`, `total_balance`). The list comes back as `data.users[]` plus `data.pagination`.

**Show** returns `user`, `subscription`, `statistics` (`total_balance`, `monthly_income`, `monthly_expense`, `net_flow`, and the number of accounts, categories, transactions, budgets and goals), `accounts[]`, `recent_transactions[]` and `notification_logs[]`.

**Create** (`201`, returns `data.user`)

| Field | Required | Rules |
|-------|----------|-------|
| `name` | yes | max 255 |
| `email` | yes | unique |
| `password` | yes | min 8 |
| `phone` | no | max 20 |
| `language` | no | default `en` |
| `preferred_currency` | no | default `USD` |
| `is_admin` | no | boolean |

**Update** accepts the same fields, all optional.

**Rules and side effects**

- The last remaining admin cannot be demoted (`update`, `toggle-admin`) or deleted. These attempts return **400**.
- `suspend` sets `suspended_at` and revokes every token of that user. A suspended user cannot log in (**403**) and is refused on every endpoint.
- `activate` clears the suspension.
- Admins cannot suspend themselves.

```json
{ "success": true, "message": "User suspended", "data": { "user_id": "…", "is_suspended": true, "suspended_at": "2026-10-03T16:35:25.000000Z" } }
```

### Transactions

```
GET    /api/admin/transactions
GET    /api/admin/transactions/analytics?period=30days
GET    /api/admin/transactions/{id}
DELETE /api/admin/transactions/{id}
```

**List filters:** `search` (description, user name or user e-mail), `type`, `user_id`, `category_id`, `min_amount`, `max_amount`, `date_from`, `date_to`, `sort_by` (`date` (default), `amount`, `type`, `description`, `created_at`), `sort_direction`, `per_page`.

The list returns `transactions[]` (each with `user`, `account` and `category`), `pagination`, and a global `summary` (`total_income`, `total_expense`, `total_transactions`).

`analytics` returns `transaction_volume`, `top_categories`, `top_users`, `type_distribution`, `average_amounts`, `period` and `date_range`.

Deleting a transfer leg also deletes its counterpart.

### Categories

```
GET    /api/admin/categories
GET    /api/admin/categories/analytics?period=30days
GET    /api/admin/categories/{id}
POST   /api/admin/categories
PUT    /api/admin/categories/{id}
DELETE /api/admin/categories/{id}
POST   /api/admin/categories/bulk-actions
```

**List filters:** `search`, `user_id`, `type` (`income` or `expense`), `usage` (`used` or `unused`), `per_page`. Returns `categories[]` (each with `user` and `transactions_count`), `pagination`, and `summary` (`total_categories`, `income_categories`, `expense_categories`, `used_categories`, `unused_categories`).

**Show** returns `category`, `statistics` (`total_transactions`, `total_amount`, `income_amount`, `expense_amount`, `average_amount`, `unique_users`, `first_used`, `last_used`) and `recent_transactions[]`.

**Create** takes `name`, `type` (`income` or `expense`), `user_id` (the owner) and, optionally, `color` (max 7) and `icon` (max 50). **Update** takes the same fields, all optional.

**Delete** returns **400** if the category has transactions.

**Bulk actions**

| Field | Required | Rules |
|-------|----------|-------|
| `action` | yes | `delete` (refused with **400** if any category has transactions) or `merge` |
| `category_ids` | yes | array of category IDs |
| `target_category_id` | for `merge` | the category that receives the transactions. The source categories are deleted. |

### Notification templates

```
GET    /api/admin/notifications
GET    /api/admin/notifications/analytics?period=30days
GET    /api/admin/notifications/{id}
POST   /api/admin/notifications
PUT    /api/admin/notifications/{id}
DELETE /api/admin/notifications/{id}
POST   /api/admin/notifications/{id}/send
```

**List filters:** `search`, `type`, `is_active`, `per_page`. Returns `templates[]`, `pagination` and `summary`.

**Create / update body** (on update, every required field must be sent again)

| Field | Required | Rules |
|-------|----------|-------|
| `name` | yes | max 255. The `slug` is generated from it |
| `type` | yes | `email`, `sms`, `whatsapp`, `in_app` |
| `content` | yes | supports `{{user_name}}` |
| `target_audience` | yes | `all`, `admin`, `subscribed`, `new_users` |
| `subject` | no | max 255 |
| `trigger_event` | no | max 255 |
| `variables` | no | array |
| `is_active` | no | boolean, default `true` |
| `category` | no | max 100 |
| `settings` | no | object |

**Send**

| Field | Description |
|-------|-------------|
| `user_ids` | array of user IDs |
| `send_to_all` | boolean. Sends to every user |

Send creates one `pending` notification log per user, with the content rendered for that user, and returns `{"template_id": "…", "users_count": 4}`. For `in_app` templates, the user sees the notification in `GET /api/notifications`.

**Show** returns `template`, `statistics` (sent, delivered, read, failed, delivery rate) and `recent_logs[]`.

**Analytics** returns `notification_volume`, `delivery_rates` (per channel) and `top_templates`.

### WhatsApp connections

```
GET    /api/admin/whatsapp
POST   /api/admin/whatsapp
GET    /api/admin/whatsapp/{id}
PUT    /api/admin/whatsapp/{id}
DELETE /api/admin/whatsapp/{id}
GET    /api/admin/whatsapp/{id}/messages
POST   /api/admin/whatsapp/{id}/test
POST   /api/admin/whatsapp/messages/{message_id}/process
```

**Create** takes `name`, `phone_number` (max 20), `business_account_id`, `access_token`, `webhook_verify_token` and, optionally, `settings`. New connections start with `status: "inactive"`. **Update** requires the same fields plus `status` (`active`, `inactive` or `disconnected`). Secrets (`access_token`, `webhook_verify_token`) are never returned.

**List** filters by `search` (name or phone) and `status`. **Show** returns `connection`, `statistics` and the 15 most recent messages.

**Messages** filters by `status`, `direction` (`inbound` or `outbound`), `date_from`, `date_to`, `search` and `per_page`.

`test` marks the connection as `active` and stamps `last_connected_at`. `process` marks a `pending` message as `processed`, and returns **400** if the message is not pending.

---

## Endpoint Index

| Method | Path | Auth |
|--------|------|------|
| GET | `/test` | — |
| POST | `/register` | — |
| POST | `/login` | — |
| POST | `/logout` | user |
| GET | `/user` · `/profile` | user |
| POST | `/refresh` | user |
| GET · POST | `/tokens` | user |
| DELETE | `/tokens/{id}` | user |
| PUT | `/profile` | user |
| POST | `/profile/password` · `/profile/avatar` · `/profile/delete-account` | user |
| GET · POST | `/accounts` | user |
| GET | `/accounts/balances` | user |
| GET · PUT · DELETE | `/accounts/{id}` | user |
| GET · POST | `/categories` | user |
| GET · PUT · DELETE | `/categories/{id}` | user |
| GET · POST | `/transactions` | user |
| GET | `/transactions/statistics` | user |
| GET · PUT · DELETE | `/transactions/{id}` | user |
| GET · POST | `/recurring-transactions` | user |
| GET | `/recurring-transactions/upcoming` | user |
| GET · PUT · DELETE | `/recurring-transactions/{id}` | user |
| POST | `/recurring-transactions/{id}/generate` | user |
| GET · POST | `/budgets` | user |
| GET | `/budgets/progress` | user |
| GET · PUT · DELETE | `/budgets/{id}` | user |
| GET · POST | `/goals` · `/financial-goals` | user |
| GET · PUT · DELETE | `/goals/{id}` · `/financial-goals/{id}` | user |
| POST | `/goals/{id}/add-funds` · `/financial-goals/{id}/add-funds` | user |
| GET · POST | `/customers` · `/suppliers` | user |
| GET · PUT · DELETE | `/customers/{id}` · `/suppliers/{id}` | user |
| GET · POST | `/accounts-receivable` · `/accounts-payable` | user |
| GET | `/accounts-receivable/summary` · `/accounts-payable/summary` | user |
| GET · PUT · DELETE | `/accounts-receivable/{id}` · `/accounts-payable/{id}` | user |
| POST | `/accounts-receivable/{id}/payments` · `/accounts-payable/{id}/payments` | user |
| GET | `/payments` | user |
| GET · PUT · DELETE | `/payments/{id}` | user |
| GET | `/reports/income-expense` · `/reports/category` · `/reports/cash-flow` · `/reports/net-worth` | user |
| POST | `/reports/save` | user |
| GET | `/reports/saved` | user |
| GET · DELETE | `/reports/saved/{id}` | user |
| GET · PUT | `/settings` | user |
| PATCH | `/settings/theme` | user |
| GET | `/dashboard` · `/dashboard/widgets` · `/dashboard/recent-activity` | user |
| GET | `/notifications` | user |
| POST | `/notifications/{id}/read` · `/notifications/read-all` | user |
| DELETE | `/notifications/{id}` | user |
| GET | `/mobile/sync` · `/mobile/offline-data` | user |
| POST | `/mobile/device-token` | user |
| GET | `/admin/dashboard` · `/admin/dashboard/stats` · `/admin/dashboard/charts` | admin |
| GET · POST | `/admin/users` | admin |
| GET · PUT · DELETE | `/admin/users/{id}` | admin |
| POST | `/admin/users/{id}/toggle-admin` · `/suspend` · `/activate` | admin |
| GET | `/admin/transactions` · `/admin/transactions/analytics` | admin |
| GET · DELETE | `/admin/transactions/{id}` | admin |
| GET · POST | `/admin/categories` | admin |
| GET | `/admin/categories/analytics` | admin |
| GET · PUT · DELETE | `/admin/categories/{id}` | admin |
| POST | `/admin/categories/bulk-actions` | admin |
| GET · POST | `/admin/notifications` | admin |
| GET | `/admin/notifications/analytics` | admin |
| GET · PUT · DELETE | `/admin/notifications/{id}` | admin |
| POST | `/admin/notifications/{id}/send` | admin |
| GET · POST | `/admin/whatsapp` | admin |
| GET · PUT · DELETE | `/admin/whatsapp/{id}` | admin |
| GET | `/admin/whatsapp/{id}/messages` | admin |
| POST | `/admin/whatsapp/{id}/test` | admin |
| POST | `/admin/whatsapp/messages/{id}/process` | admin |

`PUT` endpoints also accept `PATCH`, except `PUT /profile` and `PUT /settings`.

---

## Quick Start

```bash
# 1. Log in and keep the token
TOKEN=$(curl -s -X POST https://ezfin.app/api/login \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -d '{"email":"john@example.com","password":"secret123","device_name":"My App"}' \
  | jq -r '.data.access_token')

# 2. Find an account
ACCOUNT=$(curl -s https://ezfin.app/api/accounts \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
  | jq -r '.data.accounts[0].id')

# 3. Record an expense
curl -X POST https://ezfin.app/api/transactions \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" -H "Content-Type: application/json" \
  -d "{\"account_id\":\"$ACCOUNT\",\"type\":\"expense\",\"amount\":42.50,\"date\":\"2026-03-06\",\"description\":\"Groceries\"}"

# 4. Dashboard
curl https://ezfin.app/api/dashboard -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"

# 5. Incremental sync for a mobile app
curl "https://ezfin.app/api/mobile/sync?last_sync=2026-03-06T15:00:00Z" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
```

JavaScript:

```js
const api = async (path, { method = 'GET', body, token } = {}) => {
  const res = await fetch(`https://ezfin.app/api${path}`, {
    method,
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` }),
    },
    body: body && JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw Object.assign(new Error(json.message ?? 'Request failed'), { status: res.status, errors: json.errors });
  return json.data ?? json;
};

const { access_token } = await api('/login', { method: 'POST', body: { email, password, device_name: 'Web' } });
const dashboard = await api('/dashboard', { token: access_token });
```
