Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3082,6 +3082,68 @@ Response:
}
```

#### Profit/Loss Report

```
GET /api/admin/v1/reports/profit-loss
```

Aggregates paid revenue against tracked resource costs (see *Resource Cost
Tracking*) into per-period profit/loss rows. **All amounts are converted into a
single target currency** (the company's base currency by default) using current
exchange rates, so each period yields one row. Recurring costs are normalized to
each calendar month they are active; `ip_range` per-IP costs are multiplied by
the range's current assigned-IP count; one-time (capital) costs are booked in
the period containing their `billing_start`.

Query Parameters:

- `start_date`: string (required) - YYYY-MM-DD
- `end_date`: string (required) - YYYY-MM-DD
- `group_by`: `month` (default) | `year`
- `company_id`: number (optional) - filter the revenue side to one company; `0`/omitted = all companies. Costs are global (not company-scoped) in this version.
- `region_id`: number (optional) - filter both revenue (payment's VM region) and costs (host / IP-range region); `0`/omitted = all regions.
- `currency`: string (optional) - target currency (e.g. `EUR`). Defaults to the selected company's base currency; **required when `company_id` is omitted**.

Required Permission: `analytics::view`

Response:

```json
{
"data": {
"start_date": "2026-01-01",
"end_date": "2026-12-31",
"group_by": "month",
"currency": "EUR",
"periods": [
{
"period": "2026-01",
"revenue_net": 480000,
"revenue_tax": 96000,
"cost_recurring": 8000,
"cost_one_time": 250000,
"cost_total": 258000,
"profit": 222000
}
]
}
}
```

**Notes:**

- **Revenue uses each payment's stored historical `rate`** to reconstruct its
company-base-currency value (never live rates). A further base→report-currency
step only applies when aggregating companies with different base currencies.
- **Costs** have no stored rate, so they are converted into the report `currency`
using current exchange rates (BTC-pivoted).
- `profit = revenue_net - cost_total` and may be negative.
- Amounts are in smallest currency units (cents for fiat, millisats for BTC).
- Recurring-cost month normalization uses ~30.44 days/month for `day` intervals,
the interval count directly for `month`, and ×12 for `year`.
- Payments/costs in a currency with no available exchange rate are skipped.

## Error Responses

All error responses follow the format:
Expand Down Expand Up @@ -5154,3 +5216,124 @@ having newer active configurations.
"notes": "IPv6 PI allocation from ARIN"
}
```

---

## Resource Cost Tracking

Optional, admin-only cost tracking (issue #82) used to compute profit/loss.
Costs are stored in a single generic `resource_cost` table weakly linked to any
resource via `(resource_type, resource_id)` — no schema change is needed to add
new cost-bearing resource kinds, and a single resource may carry multiple cost
records (e.g. a host with recurring rent **and** a one-time hardware investment).

Cost data is **never** exposed to end users. All amounts are in the smallest
currency units (cents for fiat, millisats for BTC). For an `ip_range` recurring
cost, `amount` is the cost per single IP.

The `generic` resource type covers costs **not tied to any internal entity**
(e.g. a colo cross-connect or upstream transit subscription): it carries a
free-form `label` and ignores `resource_id`.

**Permission resource:** `resource_cost` (Create/View/Update/Delete)

### List Resource Costs

`GET /api/admin/v1/resource_costs`

Query parameters:

- `limit` (default 50, max 100), `offset` (default 0)
- `resource_type` (optional): `vm_host` | `ip_range`
- `resource_id` (optional): filter to a single resource

### Get Resource Cost

`GET /api/admin/v1/resource_costs/{id}`

### Create Resource Cost

`POST /api/admin/v1/resource_costs`

```json
{
"resource_type": "vm_host",
"resource_id": 12,
"cost_type": "recurring",
"amount": 8000,
"currency": "EUR",
"interval_amount": 1,
"interval_type": "month",
"billing_start": "2026-01-01T00:00:00Z",
"billing_end": null
}
```

Generic (unlinked) subscription cost — `resource_id` is ignored, `label` required:

```json
{
"resource_type": "generic",
"label": "Upstream transit (Cogent)",
"cost_type": "recurring",
"amount": 15000,
"currency": "USD",
"interval_amount": 1,
"interval_type": "month"
}
```

One-time capital cost (break-even) example:

```json
{
"resource_type": "vm_host",
"resource_id": 12,
"cost_type": "one_time",
"amount": 250000,
"currency": "EUR",
"billing_start": "2025-11-15T00:00:00Z"
}
```

### Update Resource Cost

`PATCH /api/admin/v1/resource_costs/{id}`

All fields optional. Interval/billing fields use PATCH-clear semantics: omit a
field to leave it unchanged, or send `null` to clear it.

```json
{ "amount": 9000, "billing_end": "2026-06-01T00:00:00Z" }
```

### Delete Resource Cost

`DELETE /api/admin/v1/resource_costs/{id}`

### Data Types

**AdminResourceCostDetail**

| Field | Type | Notes |
|-----------------|----------------------------|---------------------------------------------------|
| id | u64 | |
| resource_type | `vm_host`\|`ip_range`\|`generic` | Weak link kind |
| resource_id | u64 | Id within the resource's table (no FK); 0 for `generic` |
| label | string \| null | Free-form label; required for `generic` |
| cost_type | `recurring` \| `one_time` | |
| amount | u64 | Smallest currency units; per-IP for ip_range |
| currency | string | e.g. `USD`, `EUR` |
| interval_amount | u64 \| null | Required for `recurring`; null for `one_time` |
| interval_type | `day`\|`month`\|`year`\|null | Billing interval unit |
| billing_start | datetime \| null | Cost start / one-time purchase date |
| billing_end | datetime \| null | Cost end date; null = still active/ongoing |
| created | datetime | |
| updated | datetime | |

**Notes:**

- All cost fields are optional; a resource with no `resource_cost` rows simply
has no cost data (no behaviour change).
- Currency conversion (when costs and revenues differ in currency) is deferred
to a follow-up issue.
13 changes: 13 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **2026-07-16** - Cost tracking (P/L groundwork, issue #82)
- Optional, admin-only cost data stored in a new generic `resource_cost` table, weakly linked to any resource via `(resource_type, resource_id)` — no schema change needed to add new cost-bearing resource kinds, and a single resource can hold multiple cost records (e.g. a host's recurring rent plus a one-time hardware investment).
- `GET /api/admin/v1/resource_costs` — paginated list; optional `resource_type` (`vm_host`|`ip_range`) and `resource_id` filters (`resource_cost::view`).
- `GET /api/admin/v1/resource_costs/{id}` — fetch one (`resource_cost::view`).
- `POST /api/admin/v1/resource_costs` — create (`resource_cost::create`).
- `PATCH /api/admin/v1/resource_costs/{id}` — update; interval/billing fields use PATCH-clear semantics (omit = unchanged, `null` = clear) (`resource_cost::update`).
- `DELETE /api/admin/v1/resource_costs/{id}` — remove (`resource_cost::delete`).
- Each cost record has `cost_type` (`recurring`|`one_time`), `amount` (smallest currency units; per-IP for `ip_range` recurring), `currency`, an optional billing interval (`interval_amount` + `interval_type`), and optional `billing_start`/`billing_end` dates (`billing_end` null = still active).
- `resource_type` supports `vm_host`, `ip_range`, and `generic` — the latter is not tied to any internal entity and is identified by a free-form `label` (e.g. a colo/transit subscription); `resource_id` is ignored for `generic` costs.
- Cost data is never exposed to end users. Currency conversion between costs and revenues is deferred to a follow-up.
- Adds the `AdminResource::ResourceCost` (24) RBAC resource; a migration grants the full permission set to the default `super_admin` role.
- `GET /api/admin/v1/reports/profit-loss` — per-period (month/year) profit/loss report netting paid revenue against tracked resource costs. Reported in a single target currency (`currency` param, or the selected company's base currency), so each period is one row. Revenue uses each payment's stored historical exchange rate to value it in its company base currency; costs (no stored rate) use current exchange rates. Recurring costs are normalized per active calendar month, `ip_range` per-IP costs scale by the range's current assigned-IP count, and one-time costs are booked in their `billing_start` period. Optional `group_by` (`month`|`year`), `company_id` and `region_id` filters (`currency` required when `company_id` omitted). Requires `analytics::view`.

- **2026-07-15** - Admin management of users' saved payment methods
- New admin endpoints to list/inspect/edit/delete the payment methods users save for automatic renewals (NWC connections and off-session Revolut cards). Distinct from the existing `payment_methods` provider-config endpoints.
- `GET /api/admin/v1/user_payment_methods` — paginated list across all users; optional `user_id` filter (`user_payment_method::view`).
Expand Down
Loading
Loading