# Developer Guide: apibot + cabinet

Technical reference for developers and AI agents. Describes architecture, APIs, data model, and integration flows.

---

## Repository layout

```
api-cabinet/
├── apibot/                 # Node.js Express API + admin UI
│   ├── server.js           # Main application
│   ├── database.sqlite     # SQLite (runtime, not in repo)
│   ├── public/index.html   # Admin SPA
│   ├── .env                # Secrets (API_KEY, ADMIN_*, Square, Telegram)
│   └── package.json
├── cabint/                 # PHP client cabinet
│   ├── cabinet.php         # HTML page
│   ├── api.php             # Server-side proxy to apibot
│   ├── config.php          # Cabinet configuration
│   └── assets/
│       ├── cabinet.js      # Frontend logic
│       └── cabinet.css
└── cron-bot/               # Scheduled Telegram reports + payment sync
    ├── notifier.js
    └── .env
```

---

## Architecture

```
[Browser: cabinet.php]
        │ POST JSON
        ▼
[cabint/api.php]  ──x-api-key──►  [apibot/server.js]  ──►  [SQLite]
        │                                    │
        │ Telegram (optional)                ├── Square API
        └────────────────────────────────────├── Telegram alerts
                                             └── bestcase.my (via cron-bot)
```

**Security principle:** `APIBOT_API_KEY` lives only in `cabint/config.php` and server env. The browser never sees it; all apibot calls go through `api.php`.

---

## apibot

### Stack

- Node.js, Express 5, SQLite3
- Auth: `x-api-key` header (timing-safe compare), rate limit 500/15min
- Admin UI: `/admin` — HTTP Basic Auth (`ADMIN_USER`, `ADMIN_PASS`)

### Required environment

| Variable | Description |
|----------|-------------|
| `API_KEY` | External API key (min 16 chars) |
| `ADMIN_USER`, `ADMIN_PASS` | Admin panel credentials |
| `PORT` | Default 3000 |
| `SQUARE_AMOUNT_MIN`, `SQUARE_AMOUNT_MAX` | Default 250–1000 |
| `BESTCASE_IDS` | Comma-separated IDs for sync (optional) |
| Telegram vars | For stock/checkout alerts (optional) |

### Database schema (key tables)

#### `events`

| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER PK | |
| user_id | TEXT | Leading zeros stripped on save |
| event_type | TEXT | `pay`, `call`, … |
| domain, date, time | TEXT | |
| payment_system | TEXT | System code |
| email | TEXT | |
| is_paid | BOOLEAN | 0/1 |
| payment_error | BOOLEAN | |
| payment_id, amount, payment_status, payment_method, card, company | TEXT | |
| payment_link_id | INTEGER | Link clicked on cabinet (for on_paid consume) |

#### `payment_timeline`

History of payment status changes per `event_id` (`kind`: `error` | `success`).

#### `payment_systems`

| Column | Notes |
|--------|-------|
| code | Unique slug, e.g. `square_synergy` |
| enabled | 0/1 |
| show_presets, show_amount, show_links | Display flags for cabinet |
| square_access_token, square_location_id, square_env | Per-system Square |

#### `payment_links`

| Column | Notes |
|--------|-------|
| system_id | FK → payment_systems |
| url | http/https, max 2048 |
| amount | Display/grouping (TEXT, e.g. `292.50`) |
| label | Admin label only |
| link_type | `unlimited` \| `single` |
| single_mode | `on_issue` \| `on_paid` (single only) |
| status | `active` \| `issued` (legacy; on_paid no longer sets issued) |
| issued_to, issued_at | Legacy / admin restore |

**Startup migration:** `single/on_paid` links with `status='issued'` are reset to `active`.

---

## Payment link lifecycle

### `unlimited`

- Listed in `issue-batch`, claim returns URL, **no DB change**.

### `single` + `on_issue`

- **claim** / **issue**: `DELETE WHERE id=? AND status='active'` (atomic).
- 409 if already deleted.

### `single` + `on_paid`

- **claim** / **issue**: URL returned, **status stays `active`** — visible to all users.
- **consumePaidLinks({ userId?, email? })** on mark-paid:
  1. Latest `pay` event (by `user_id`, `email`, or both) → `payment_link_id` → delete that link.
  2. Fallback: legacy `status='issued' AND issued_to=userId`.
  3. Fallback: match `payment_system` + `amount` on active links.

Cabinet writes `payment_link_id` via `/api/save` on Pay click (`notify_pay` → `save_event`).

---

## apibot API reference

All `/api/*` routes (except noted) require header:

```
x-api-key: <API_KEY>
```

Deprecated: `?key=` query param (logged as warning).

### Events

#### `POST /api/save`

Create event.

```json
{
  "user_id": "51699",
  "event_type": "pay",
  "domain": "example.com",
  "date": "2026-06-15",
  "time": "14:30",
  "payment_system": "square_synergy",
  "email": "a@b.c",
  "amount": "292.50",
  "payment_link_id": 42
}
```

Required: `user_id`, `date`, `time`.  
Response: `201 { success, id }`

#### `GET /api/data`

Paginated/filtered events. Query: `page`, `pageSize`, `startDate`, `endDate`, `startHour`, `endHour`, `domain`, `event_type`, `payment_system`, `user_id`, `email`.

#### `PUT /api/update/:id` / `DELETE /api/delete/:id`

Update/delete event (admin).

#### `POST /api/mark-paid`

Mark payment status for events. Requires **at least one** identifier: `user_id` or `email`.

```json
{ "user_id": "51699", "status": 1 }
```

```json
{ "email": "client@mail.com", "status": 1 }
```

```json
{ "user_id": "51699", "email": "client@mail.com", "status": 1 }
```

| Field | Required | Description |
|-------|----------|-------------|
| `user_id` | one of `user_id` / `email` | Leading zeros stripped |
| `email` | one of `user_id` / `email` | Exact match (trimmed) |
| `status` | no | `1` = paid (default), `0` = unpaid |

**Update scope:**

| Request | `UPDATE events WHERE …` |
|---------|-------------------------|
| only `user_id` | `user_id = ?` (all events of user) |
| only `email` | `email = ?` (all events with email) |
| both | `user_id = ? AND email = ?` |

On `status: 1`:

- Sets `is_paid = 1`, clears `payment_error`
- Appends timeline `success` to latest matching `pay` event
- Calls `consumePaidLinks({ userId, email })` to delete `on_paid` links

Response:

```json
{
  "success": true,
  "updated": 3,
  "status": 1,
  "user_id": "51699",
  "email": "client@mail.com"
}
```

Errors: `400` if neither `user_id` nor `email` provided.

#### `POST /api/payment-error`

Find latest `pay` by email; set error fields + timeline entry.

#### `GET /api/event/:id`

Event + timeline.

#### `POST /api/event/:id/reset-payment`

Reset payment fields on event.

#### `DELETE /api/timeline/:id`

Delete timeline row.

#### `GET /api/sync-external`

Query: `startDate`, `endDate`. Polls bestcase.my, mark-paid for paid leads.

#### `GET /api/external-stats-count`

External CT stats aggregation.

---

### Payment systems

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/payment-systems` | All systems |
| GET | `/api/payment-systems/enabled` | Enabled only (+ display flags) |
| POST | `/api/payment-systems` | Create |
| PUT | `/api/payment-systems/:id` | Update flags, Square creds |
| DELETE | `/api/payment-systems/:id` | Delete |

Enabled response item:

```json
{
  "code": "square_synergy",
  "name": "Square Synergy",
  "show_presets": 1,
  "show_amount": 1,
  "show_links": 1
}
```

---

### Payment links

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/payment-links` | List (?system_id, ?status) |
| POST | `/api/payment-links` | Create |
| PUT | `/api/payment-links/:id` | Update (incl. status restore) |
| DELETE | `/api/payment-links/:id` | Delete |
| POST | `/api/payment-links/issue` | Issue one link (FIFO) |
| POST | `/api/payment-links/issue-batch` | List links for display |
| POST | `/api/payment-links/claim` | User clicked specific link |

#### `POST /api/payment-links/issue-batch`

```json
{
  "system": "square_synergy",
  "user_id": "51699",
  "limit": 100,
  "link_type": "single"
}
```

- `limit`: 1–100 (default 5)
- `link_type`: optional `single` | `unlimited`
- Returns active links only; **does not mutate** rows
- Order: `link_type` (single first), `amount ASC`, `id ASC`

#### `POST /api/payment-links/claim`

```json
{ "link_id": 42, "user_id": "51699" }
```

Response: `{ success, url, link_id, link_type, single_mode, amount }`  
409 if link not `active` (deleted on_issue) or not found.

---

### Square dynamic checkout

#### `POST /api/square/checkout`

```json
{
  "system": "square_synergy",
  "amount": 350.00,
  "user_id": "51699",
  "note": "Optional label"
}
```

Creates Square Payment Link via `POST /v2/online-checkout/payment-links`.  
Amount validated against `SQUARE_AMOUNT_MIN/MAX`.

#### `GET /api/square/status`

Which systems have Square configured (no token leak).

---

## cabinet (cabint)

### Entry point

`cabinet.php` — renders page, injects `window.CABINET`:

```javascript
{ userId, email, name, phone }
```

URL params sanitized server-side.

### Proxy: `api.php`

Single POST endpoint; JSON body with `action` field.

| action | apibot calls | Purpose |
|--------|--------------|---------|
| `get_options` | GET enabled systems, POST issue-batch | Payment UI options |
| `claim` | POST payment-links/claim | Redirect URL on button click |
| `checkout` | POST square/checkout | Custom amount |
| `notify_call` | POST /api/save (event) + Telegram | Call button |
| `notify_pay` | POST /api/save + Telegram | Pay click tracking |

Auth to apibot: server-side curl with `x-api-key`.

### `get_options` logic

1. Load enabled systems (optional filter `PAYMENT_SYSTEM_CODE`).
2. First system with any available mode wins.
3. For `show_links`: fetch up to 100 singles → `pick_links_by_amount_tier()` → max `SINGLE_LINKS_COUNT` (3–5).
4. For `show_presets`: same for `unlimited`.
5. Tier grouping (`pick_links_by_amount_tier` in api.php):
   - Sort by numeric `amount`
   - One link per `floor(amount/100)*100`
   - Return `link_id`, `amount`, `label`, `tier`

Response shape:

```json
{
  "success": true,
  "system": "square_synergy",
  "amount_input": { "min": 250, "max": 1000 },
  "singles": [{ "link_id": 1, "amount": "292.50", "tier": 200 }],
  "unlimited": null
}
```

### Frontend: `cabinet.js`

- `loadOptions()` → `get_options` → render buttons
- Button label: exact `amount` as `$292.50` (`fmtLinkAmount`)
- Pay click: `notify_pay` (keepalive) + `claim` → redirect
- Amount input: `checkout` → redirect

### config.php constants

```php
APIBOT_BASE_URL, APIBOT_API_KEY, SUPPORT_PHONE,
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID,
PAYMENT_SYSTEM_CODE, APIBOT_TIMEOUT,
AMOUNT_MIN, AMOUNT_MAX,
SINGLE_LINKS_COUNT, UNLIMITED_LINKS_COUNT,
CABINET_DEBUG
```

---

## cron-bot

`notifier.js` — node-cron jobs:

| Cron | Function |
|------|----------|
| `0 * * * *` | `checkApiData()`, `checkNodes()` |
| `00 00 * * *` | `sendBackup()` — SQLite to Telegram |
| `05 * * * *` | `checkPaymentStatus()` → bestcase → `POST /api/mark-paid` |

Env: `API_URL` (e.g. `https://apibot.space/api/data`), `API_KEY`, `TELEGRAM_*`, `BESTCASE_IDS`, `PRIVATEFLARE_KEY`.

CLI: `--run`, `--backup`, `--check-pay`, `--check-nodes`.

---

## End-to-end flows

### Cabinet pay click (single link)

```
1. User clicks button ($292.50)
2. cabinet.js → api.php notify_pay { link_id, amount, system, mode: single }
3. api.php → apibot /api/save { event_type: pay, payment_link_id, amount, ... }
4. api.php → apibot /api/payment-links/claim { link_id, user_id }
5. on_paid: link stays active; on_issue: link deleted
6. Browser redirect to payment URL
```

### Payment confirmed

```
1. cron-bot or admin → POST /api/mark-paid { user_id } or { email } or both
2. consumePaidLinks:
   - Read latest pay event by user_id / email
   - DELETE payment_links WHERE id=...
3. is_paid=1 on matching events
```

### Cabinet options load

```
1. cabinet.js → api.php get_options
2. apibot GET /api/payment-systems/enabled
3. apibot POST issue-batch (limit 100, link_type single/unlimited)
4. api.php tier filter → 3-5 links
5. Render buttons
```

---

## Error codes (common)

| HTTP | Meaning |
|------|---------|
| 400 | Validation (missing fields, bad amount) |
| 403 | Invalid API key |
| 404 | No links / system / event |
| 409 | Link unavailable, system disabled, Square not configured |
| 429 | Rate limit / auth brute-force block |
| 502 | apibot/Square upstream failure |

Cabinet with `CABINET_DEBUG=true` appends internal detail to user-facing errors.

---

## Admin UI notes

`apibot/public/index.html` — single-page admin:

- Events table with filters, mark paid, payment timeline
- Payment systems CRUD + display flags
- Payment links CRUD, release issued (legacy)
- API key stored in localStorage for XHR

---

## Deployment checklist

1. apibot: set `.env`, `npm install`, run `node server.js` (or pm2)
2. nginx: proxy to apibot; serve `cabint/` as PHP
3. cabint: set `APIBOT_BASE_URL`, `APIBOT_API_KEY`, Telegram if needed
4. cron-bot: configure `.env`, schedule `node notifier.js`
5. Align `AMOUNT_MIN/MAX` (cabinet) with `SQUARE_AMOUNT_MIN/MAX` (apibot)
6. Set `CABINET_DEBUG=false` in production

---

## Extension points for AI agents

| Task | Primary files |
|------|---------------|
| Change link display/count | `cabint/config.php`, `cabint/api.php` (`pick_links_by_amount_tier`), `cabint/assets/cabinet.js` |
| Change link burn rules | `apibot/server.js` — `claim`, `issue`, `consumePaidLinks` |
| New payment mode | `payment_systems` flags + `api.php get_options` + `cabinet.js render*` |
| Track new event fields | `events` migration in `server.js`, `/api/save`, `cabint/api.php save_event` |
| New external sync | Pattern: `cron-bot/checkPaymentStatus` → `/api/mark-paid` |

### Invariants to preserve

- API key never exposed to browser
- `issue-batch` must not delete links (display only)
- `on_paid` links remain `active` until `consumePaidLinks`
- `payment_link_id` on pay events required for precise on_paid deletion
- Amount tier grouping uses numeric `amount`, not `label`

---

## Version notes (current behavior)

- `issue-batch` limit max: **100**
- Cabinet shows **3–5** links per type after tier dedup
- Button text: **exact amount** (`$292.50`), not `$200+`
- `single/on_paid`: visible to all users after claim until payment confirmed
- `/api/mark-paid` accepts `user_id`, `email`, or both (at least one required)
- Legacy `issued` status auto-migrated to `active` on apibot start
