# Spareparts API Reference

For the Angular frontend. Base URL in development: `http://localhost:8000/api/` (the `reports` endpoints live one level further under `/api/reports/`, everything else directly under `/api/`).

## Contents

- [Conventions](#conventions)
- [Authentication](#authentication)
- [Users](#users) — manager only
- [Categories](#categories)
- [Spare Parts](#spare-parts)
- [Stock Movements](#stock-movements) — manager only
- [Purchases](#purchases) — manager only
- [Sales](#sales)
- [Expenses](#expenses) — manager only
- [Reports](#reports)
- [Roles & Permissions Summary](#roles--permissions-summary)
- [Angular Integration Notes](#angular-integration-notes)

---

## Conventions

**Auth header** — every endpoint except `POST /api/auth/login/` and `POST /api/auth/refresh/` requires:

```
Authorization: Bearer <access_token>
```

**Content type** — `application/json` for all request bodies except the invoice PDF response.

**Pagination** — list endpoints (anything returned by a `GET` on a collection route, e.g. `/api/sales/`) are wrapped in DRF's standard page format:

```json
{
  "count": 42,
  "next": "http://localhost:8000/api/sales/?page=2",
  "previous": null,
  "results": [ /* ... */ ]
}
```

Page size is 20. Use `?page=2` to paginate.

**Money fields** — all `DecimalField`s (prices, amounts, totals) are serialized as **JSON strings**, e.g. `"22000.00"`, not numbers. Parse them with `parseFloat`/`Number()` on the frontend, or use a decimal library if you need exact arithmetic.

**Error format** — validation errors return HTTP 400 with a field-keyed object:

```json
{ "quantity": ["Ensure this value is greater than or equal to 1."] }
```

or, for non-field errors (business-rule failures like insufficient stock):

```json
{ "detail": "Insufficient stock for Honda Brake Pad - HND-BP-001: requested 5, available 3." }
```

Auth failures are `401` (missing/expired/invalid token) or `403` (valid token, wrong role). A `ProtectedError` from trying to delete a record still referenced elsewhere (e.g. a `Category` with spare parts in it) is normalized to `400` with `{"detail": "Cannot delete: this record is referenced by other records."}` instead of a raw 500.

**No hard deletes on `SparePart`, `User`, `Expense`** — these viewsets don't accept `DELETE` (405 Method Not Allowed). Deactivate instead: `PATCH` with `{"is_active": false}`. `Sale` and `Purchase` don't accept `PUT`/`PATCH`/`DELETE` at all — they're immutable financial records, create-only.

---

## Authentication

### `POST /api/auth/login/`

No auth required.

**Request**
```json
{ "username": "john", "password": "secret123" }
```

**Response `200`**
```json
{
  "access": "eyJhbGciOi...",
  "refresh": "eyJhbGciOi...",
  "user": {
    "id": 3,
    "username": "john",
    "email": "",
    "full_name": "John Seller",
    "first_name": "John",
    "last_name": "Seller",
    "role": "SELLER",
    "is_active": true
  }
}
```

`401` on bad credentials.

### `POST /api/auth/refresh/`

No auth required (uses the refresh token itself).

**Request**
```json
{ "refresh": "eyJhbGciOi..." }
```

**Response `200`**
```json
{ "access": "eyJhbGciOi...", "refresh": "eyJhbGciOi..." }
```

Note: `ROTATE_REFRESH_TOKENS` is on, so a **new** refresh token comes back each time — store it, replacing the old one. The old refresh token is blacklisted after rotation.

- Access token lifetime: **60 minutes**
- Refresh token lifetime: **7 days**

### `POST /api/auth/logout/`

Requires auth. Blacklists a refresh token so it can no longer be used.

**Request**
```json
{ "refresh": "eyJhbGciOi..." }
```

**Response**: `200` empty body.

### `GET /api/auth/profile/`

Requires auth. Returns the current user (same shape as `login`'s `user` field).

### `POST /api/auth/change-password/`

Requires auth.

**Request**
```json
{ "old_password": "secret123", "new_password": "newSecret456!" }
```

**Response**: `204` empty body. `400` if `old_password` is wrong or `new_password` fails Django's validators (too short, too common, all-numeric, too similar to username).

---

## Users

Manager only (`403` for sellers on every action, including read).

| Method | Path | Notes |
|---|---|---|
| GET | `/api/users/` | paginated list |
| POST | `/api/users/` | create |
| GET | `/api/users/{id}/` | retrieve |
| PUT / PATCH | `/api/users/{id}/` | update |
| DELETE | `/api/users/{id}/` | **405** — use `PATCH {"is_active": false}` instead |

**Read shape** (`UserSerializer`):
```json
{
  "id": 3,
  "username": "john",
  "email": "john@example.com",
  "full_name": "John Seller",
  "first_name": "John",
  "last_name": "Seller",
  "role": "SELLER",
  "is_active": true
}
```

**Write body** (`POST`/`PUT`/`PATCH` — `UserWriteSerializer`):
```json
{
  "username": "john",
  "email": "john@example.com",
  "first_name": "John",
  "last_name": "Seller",
  "role": "SELLER",
  "is_active": true,
  "password": "secret123"
}
```
`role` is one of `"SELLER"` | `"MANAGER"`. `password` is write-only and validated by Django's password validators; omit it on `PATCH` to leave the password unchanged.

---

## Categories

Read: any authenticated user. Write (`POST`/`PUT`/`PATCH`/`DELETE`): manager only.

| Method | Path |
|---|---|
| GET | `/api/categories/` |
| POST | `/api/categories/` |
| GET | `/api/categories/{id}/` |
| PUT / PATCH | `/api/categories/{id}/` |
| DELETE | `/api/categories/{id}/` — `400` if any spare part still references it |

**Shape**:
```json
{ "id": 2, "name": "Brake System", "description": "Brake pads, discs, calipers" }
```

---

## Spare Parts

Read: any authenticated user. Write: manager only. `DELETE` is **405** — retire a part with `PATCH {"is_active": false}` instead (it may already be referenced by past purchases/sales, which is exactly why hard delete isn't allowed).

| Method | Path | Notes |
|---|---|---|
| GET | `/api/spare-parts/` | paginated list |
| POST | `/api/spare-parts/` | manager only |
| GET | `/api/spare-parts/{id}/` | |
| PUT / PATCH | `/api/spare-parts/{id}/` | manager only |
| GET | `/api/spare-parts/low-stock/` | parts where `quantity <= minimum_stock` |

**Shape**:
```json
{
  "id": 10,
  "name": "Honda Brake Pad",
  "part_number": "HND-BP-001",
  "category": 2,
  "category_name": "Brake System",
  "description": "",
  "buying_price": "15000.00",
  "selling_price": "22000.00",
  "quantity": 28,
  "minimum_stock": 5,
  "is_active": true,
  "is_low_stock": false,
  "created_at": "2026-08-18T06:59:21.110401Z",
  "updated_at": "2026-08-18T06:59:21.110407Z"
}
```

**Create/update body** — same fields except `quantity` is **read-only** (rejected silently if sent; it can only change via a purchase, sale, or stock movement):
```json
{
  "name": "Honda Brake Pad",
  "part_number": "HND-BP-001",
  "category": 2,
  "description": "",
  "buying_price": 15000,
  "selling_price": 22000,
  "minimum_stock": 5,
  "is_active": true
}
```

`low-stock` returns the same paginated shape as the list endpoint, filtered.

---

## Stock Movements

Manager only, for both read and write. This is the audit trail — every purchase and sale automatically logs its own movement here; this endpoint additionally lets a manager log manual corrections.

| Method | Path | Notes |
|---|---|---|
| GET | `/api/stock-movements/` | paginated, newest first |
| GET | `/api/stock-movements/?spare_part={id}` | history for one part |
| POST | `/api/stock-movements/` | manual correction only — see below |

**Read shape**:
```json
{
  "id": 3,
  "spare_part": 2,
  "spare_part_name": "Chain Sprocket",
  "movement_type": "DAMAGE",
  "quantity": -1,
  "reference": "dropped on floor",
  "created_by": 4,
  "created_by_name": "Test Manager",
  "created_at": "2026-08-18T07:20:45.017917Z"
}
```

`movement_type` is one of `"PURCHASE"`, `"SALE"`, `"DAMAGE"`, `"ADJUSTMENT"`, `"RETURN"`. `PURCHASE`/`SALE` rows are created automatically (`reference` is the purchase's `reference_number` or sale's `invoice_number`) — **you cannot create those two types via this endpoint**, only `DAMAGE`, `ADJUSTMENT`, `RETURN`.

**Create body** (manual correction):
```json
{
  "spare_part": 2,
  "movement_type": "DAMAGE",
  "quantity": -1,
  "reference": "dropped on floor"
}
```
`quantity` is **signed**: negative decreases stock (damage, loss), positive increases it (return, positive adjustment). It can't be `0`, and a movement that would push stock below zero is rejected with `400 {"detail": "Cannot apply -999: Chain Sprocket - CHN-SP-001 only has 7 in stock."}`. `reference` is optional free text.

---

## Purchases

Manager only. Immutable once created — no update or delete endpoints exist (only `GET`/`POST`).

| Method | Path |
|---|---|
| GET | `/api/purchases/` |
| POST | `/api/purchases/` |
| GET | `/api/purchases/{id}/` |

**Create body**:
```json
{
  "supplier_name": "Mlimani Motor Parts",
  "purchased_at": "2026-08-17T10:00:00Z",
  "items": [
    { "spare_part": 4, "quantity": 20, "unit_cost": 15000 },
    { "spare_part": 12, "quantity": 10, "unit_cost": 35000 }
  ]
}
```
`reference_number` (e.g. `"PUR-00001"`) is generated server-side — don't send it. On success, each item's `spare_part.quantity` is atomically incremented and a `PURCHASE` stock movement is logged per line.

**Response `201`** (`PurchaseSerializer`):
```json
{
  "id": 1,
  "reference_number": "PUR-00001",
  "supplier_name": "Mlimani Motor Parts",
  "purchased_at": "2026-08-17T10:00:00Z",
  "created_by": 2,
  "created_by_name": "Test Manager",
  "created_at": "2026-08-18T06:59:21.160061Z",
  "items": [
    {
      "id": 1,
      "spare_part": 4,
      "spare_part_name": "Honda Brake Pad",
      "quantity": 20,
      "unit_cost": "15000.00",
      "total_cost": "300000.00"
    }
  ],
  "total_cost": 450000.0
}
```

---

## Sales

Any authenticated user can create/list/retrieve (sellers make sales too). Immutable — no update or delete.

| Method | Path | Notes |
|---|---|---|
| GET | `/api/sales/` | paginated |
| POST | `/api/sales/` | create a sale |
| GET | `/api/sales/{id}/` | |
| GET | `/api/sales/{id}/invoice/` | returns a PDF, not JSON — see below |

**Create body** — send only `spare_part` + `quantity` per line. **Do not send prices**; the backend always reads the current `buying_price`/`selling_price` off the `SparePart` record, so any `unit_price`/`unit_cost` sent by the client is ignored:
```json
{
  "customer_name": "Joseph",
  "customer_phone": "0712345678",
  "payment_method": "CASH",
  "items": [
    { "spare_part": 4, "quantity": 2 },
    { "spare_part": 12, "quantity": 1 }
  ]
}
```
`payment_method` is one of `"CASH"`, `"MOBILE"`, `"BANK"`, `"CREDIT"`.

**Response `201`** (`SaleSerializer`):
```json
{
  "id": 1,
  "invoice_number": "INV-2026-000001",
  "customer_name": "Joseph",
  "customer_phone": "0712345678",
  "payment_method": "CASH",
  "sold_by": 2,
  "sold_by_name": "Test Manager",
  "sold_at": "2026-08-18T06:59:33.816079Z",
  "created_at": "2026-08-18T06:59:33.816084Z",
  "items": [
    {
      "id": 1,
      "spare_part": 4,
      "spare_part_name": "Honda Brake Pad",
      "quantity": 2,
      "unit_cost": "15000.00",
      "unit_price": "22000.00",
      "subtotal": "44000.00"
    }
  ],
  "total_amount": 44000.0
}
```

**Error `400`** if any line exceeds available stock (nothing is created or decremented — the whole request is rolled back):
```json
{ "detail": "Insufficient stock for Honda Brake Pad - HND-BP-001: requested 1000, available 27." }
```

`invoice_number` is generated server-side (yearly-reset, `INV-<year>-<6 digits>`) — don't send it.

### `GET /api/sales/{id}/invoice/`

Returns `Content-Type: application/pdf` (not JSON) — a printable receipt for the sale. In Angular, request it with `responseType: 'blob'`:

```typescript
this.http.get(`/api/sales/${id}/invoice/`, { responseType: 'blob' })
  .subscribe(blob => {
    const url = window.URL.createObjectURL(blob);
    window.open(url); // or trigger a download
  });
```

---

## Expenses

Manager only. No delete (`405` — correct a mistake with an update instead of deleting the record).

| Method | Path |
|---|---|
| GET | `/api/expenses/` |
| POST | `/api/expenses/` |
| GET | `/api/expenses/{id}/` |
| PUT / PATCH | `/api/expenses/{id}/` |

**Shape**:
```json
{
  "id": 1,
  "expense_type": "TRANSPORT",
  "description": "Fuel",
  "amount": "5000.00",
  "expense_date": "2026-08-18",
  "created_by": 2,
  "created_by_name": "Test Manager",
  "created_at": "2026-08-18T06:59:48.135895Z"
}
```
`expense_type` is one of `"RENT"`, `"TRANSPORT"`, `"SALARY"`, `"UTILITIES"`, `"DAMAGE"`, `"OTHER"`. `created_by` is set automatically from the logged-in user — don't send it on create.

---

## Reports

All under `/api/reports/`. **Manager only**, except `my-dashboard`, which any authenticated user can call (scoped to their own sales).

Date-range endpoints (`sales`, `profit`, `top-selling`) accept **either**:
- `?period=today|yesterday|this_week|this_month|this_year`, or
- `?from=YYYY-MM-DD&to=YYYY-MM-DD`

If neither is given, they default to today. Sending an unrecognized `period` or a malformed date returns `400`.

### `GET /api/reports/dashboard/`

Manager-only overview.
```json
{
  "today": { "sales_amount": 1450000.0, "profit": 375000.0, "transactions": 18 },
  "inventory": {
    "total_products": 175,
    "total_units": 3480,
    "low_stock": 8,
    "out_of_stock": 3,
    "stock_value": 48500000.0
  },
  "month": { "sales": 27500000.0, "gross_profit": 8250000.0, "expenses": 1650000.0, "net_profit": 6600000.0 }
}
```

### `GET /api/reports/my-dashboard/`

Seller-facing (any authenticated user, scoped to `request.user`). Deliberately excludes cost/profit — only revenue.
```json
{
  "today": { "sales_amount": 780000.0, "transactions": 12 },
  "recent_sales": [
    { "id": 91, "invoice_number": "INV-2026-000091", "total_amount": 85000.0, "sold_at": "2026-08-18T09:12:00Z" }
  ],
  "low_stock_count": 8
}
```
`recent_sales` is capped at 5, newest first, scoped to sales made by the calling user.

### `GET /api/reports/profit/?from=2026-08-01&to=2026-08-31`

```json
{
  "period": { "from": "2026-08-01", "to": "2026-08-31" },
  "total_sales": 8250000.0,
  "cost_of_goods_sold": 5650000.0,
  "gross_profit": 2600000.0,
  "expenses": 600000.0,
  "net_profit": 2000000.0,
  "number_of_sales": 185
}
```
`total_sales = Σ(quantity × unit_price)`, `cost_of_goods_sold = Σ(quantity × unit_cost)` — both computed from the historical `unit_cost`/`unit_price` frozen on each `SaleItem` at sale time, so this is accurate even after current prices change. `expenses` sums `Expense.amount` in the same date range (filtered on `expense_date`).

### `GET /api/reports/sales/?period=this_week`

Daily breakdown:
```json
{
  "period": { "from": "2026-08-17", "to": "2026-08-18" },
  "daily": [
    { "date": "2026-08-17", "total_sales": 320000.0, "items_sold": 14 },
    { "date": "2026-08-18", "total_sales": 96000.0, "items_sold": 4 }
  ]
}
```
`items_sold` is total line-item quantity sold that day (not transaction count).

### `GET /api/reports/inventory/`

Current snapshot, no date range:
```json
{
  "total_parts": 175,
  "total_quantity": 3480,
  "stock_value": 48500000.0,
  "low_stock_count": 8,
  "low_stock": [
    { "id": 10, "name": "Honda Brake Pad", "part_number": "HND-BP-001", "quantity": 3, "minimum_stock": 5 }
  ],
  "out_of_stock_count": 3
}
```
`stock_value = Σ(quantity × buying_price)` across all parts (active and inactive).

### `GET /api/reports/top-selling/?period=this_month`

```json
{
  "period": { "from": "2026-08-01", "to": "2026-08-18" },
  "top_selling": [
    { "spare_part": 4, "name": "Honda Brake Pad", "part_number": "HND-BP-001", "quantity_sold": 62, "revenue": 1364000.0 }
  ]
}
```
Top 10, ordered by `quantity_sold` descending.

---

## Roles & Permissions Summary

| Resource | Seller | Manager |
|---|---|---|
| Auth (login/refresh/logout/profile/change-password) | ✓ | ✓ |
| Users | ✗ (403) | ✓ full CRUD except delete |
| Categories — read | ✓ | ✓ |
| Categories — write/delete | ✗ | ✓ |
| Spare Parts — read | ✓ | ✓ |
| Spare Parts — write | ✗ | ✓ (no delete — use `is_active`) |
| Stock Movements | ✗ (403) | ✓ read + manual corrections |
| Purchases | ✗ (403) | ✓ create + read |
| Sales — create/list/retrieve | ✓ | ✓ |
| Sales — invoice PDF | ✓ | ✓ |
| Expenses | ✗ (403) | ✓ (no delete) |
| `reports/dashboard`, `sales`, `profit`, `inventory`, `top-selling` | ✗ (403) | ✓ |
| `reports/my-dashboard` | ✓ (own data only) | ✓ (own data only) |

---

## Angular Integration Notes

**Auth interceptor** — attach the access token to every outgoing request except `login`/`refresh`, and on a `401`, try `refresh` once before giving up:

```typescript
// auth.interceptor.ts (functional interceptor, Angular 15+)
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const token = auth.accessToken;

  const authedReq = token && !req.url.includes('/auth/login') && !req.url.includes('/auth/refresh')
    ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
    : req;

  return next(authedReq).pipe(
    catchError((err: HttpErrorResponse) => {
      if (err.status === 401) {
        return auth.refreshToken().pipe(
          switchMap(() => next(req.clone({ setHeaders: { Authorization: `Bearer ${auth.accessToken}` } }))),
          catchError(() => { auth.logout(); return throwError(() => err); }),
        );
      }
      return throwError(() => err);
    }),
  );
};
```

**Money fields**: bind DecimalField strings through a pipe (`{{ part.selling_price | number:'1.2-2' }}`) rather than treating them as numbers directly — Angular's `number` pipe coerces strings fine.

**Role gating in the UI**: `user.role === 'MANAGER'` from the `login`/`profile` response is the only signal you need — the backend enforces the same rule server-side on every endpoint, so UI-side hiding is a convenience, not the security boundary.

**Invoice PDF**: must be fetched with `responseType: 'blob'` (see the Sales section above) — a normal JSON request will fail to parse it.
