Reviewed-on: #514 Reviewed-by: maurerit <maurer.it@gmail.com> Reviewed-by: gippity <gippity@r3r4um.net> Reviewed-by: gollum <7+gollum@noreply.git.r3r4um.online>
EVE Market Calculator
Real-time market analysis and manufacturing planner for EVE Online — discovers profitable build opportunities across regions, plans recursive bills-of-materials down to raw minerals, and coordinates work assignments across a team of characters.
Features
- Market opportunity discovery — Fetches live ESI market orders for all known regions, computes build costs, and surfaces items where manufacturing profit exceeds a configurable margin threshold
- Full manufacturing cost model — Materials cost, job install fee (EIV × system cost index × facility tax + SCC surcharge + alpha clone tax), and optional opportunity cost
- Invention cost modeling — T2 items include amortized invention cost (datacores, T1 BPC, install fee, failure probability, optional decryptor). Invented items show a badge in the UI with per-run cost breakdown
- Recursive BOM expansion — Walks the full manufacturing tree (T2 → T1 components → reactions → raw minerals/moon goo), factoring invention and decryptors. Identifies bottleneck nodes by ISK contribution and surfaces multi-tier recursive opportunities
- On-demand build calculator — Request a one-off build cost calculation with overrides: custom facility, runs, decryptor choice for invention
- 2-phase assignment flow — Corp members are managed as named People; opportunities are assigned to them from a cart workflow with drag-and-drop support
- Assignment task types — Manufacture, react, invent, buy, haul. Expand buildable assignments into their sub-component tasks recursively
- Invention task generation — Aggregates T2 assignments and creates invention tasks with success probability + configurable overage, structured per-candidate results, and idempotent lifecycle preservation
- Purchase lists — Aggregates raw material quantities needed across all active assignments, grouped by region. JSON or TSV export
- Share assignments — Grant other EVE characters admin or member-level access to view/edit your assignment lists
- Assigned to Me view — Characters see their own assignments with assigner name and portrait resolved via ESI
- CSV export — Assignments exportable as CSV for external tooling
- Build blocklist — Exclude unwanted items from the calculation pipeline
- Market group filtering — Opportunities filterable by SDE market group
- Build cost history — Time-bucketed build cost and margin trends per item/region with pruned-list transition tracking
- Dashboard — Live stats: opportunity count (today/yesterday), average margin, top region, user assignment count, top 10 opportunities by margin
Architecture
- Market Fetcher — CronJob: fetches ESI market orders for all known regions, refreshes stale ESI industry data (adjusted prices, system cost indices, facilities), stores in TimescaleDB, signals API server when done
eve-calc-fetcher— every 5 hours (0 */5 * * *)
- API Server — Go HTTP service: queues compute tasks via RabbitMQ, serves opportunities, assignments, people, cart, purchase lists, shares, recursive BOM, and on-demand build requests
- Compute Workers — 3 replicas consuming from RabbitMQ: calculates build costs through the recursive BOM engine, fetches adjusted prices and system cost indices, stores recursive snapshots plus legacy-compatible
build_costsrows - Pare-Down Job — CronJob: filters build_costs to ≥
PROFIT_THRESHOLD_MARGIN(default 10%), populates pruned_listeve-calc-paredown— every 5 hours at :30 (30 */5 * * *)
- SDE Import Job — One-shot Job: downloads CCP's latest Static Data Export YAML zip and reloads all
sde_*tables (types, groups, categories, blueprints, materials, regions, constellations, solar systems, stations) - SSO Backend — Node.js (Koa) EVE SSO OAuth2 proxy; handles login/callback/logout, fetches character + corp info from ESI, signs a session cookie, and reverse-proxies
/api/*to the Go API server while injectingX-Member-ID - Frontend — React 19 + TypeScript + Vite 6 + MUI 7 + ECharts + dnd-kit SPA
Dependencies
- TimescaleDB (PostgreSQL with TimescaleDB extension)
- RabbitMQ
- EVE ESI API (public endpoints + authenticated character endpoints via SSO backend)
ESI Scopes
The SSO backend requests scopes per flow. Public data (market orders, type names, etc.) uses no authentication.
| Flow | Scopes requested | Purpose |
|---|---|---|
| Login | esi-search.search_structures.v1 |
Authenticate character identity |
| Industry tracking | esi-industry.read_corporation_jobs.v1 |
Sync corporation industry jobs |
| Asset tracking | esi-assets.read_corporation_assets.v1 esi-markets.read_corporation_orders.v1 |
Sync in-hangar assets and open sell orders for corp stock |
| Wallet tracking | esi-wallet.read_corporation_wallets.v1 esi-corporations.read_divisions.v1 |
Sync corporation wallet divisions and ledger data |
| Skills | esi-skills.read_skills.v1 |
Validate assignment eligibility |
| Integration | Classification | Authorization ownership |
|---|---|---|
| Assets and corporation market orders | Corporation-wide | One authorizer per corporation |
| Corporation wallets and divisions | Corporation-wide | One authorizer per corporation |
| Corporation industry jobs | Corporation-wide | One authorizer per corporation |
| Character skills | Character-owned | Per-character tokens |
Future scoped integrations must choose a classification before implementation.
Development Formatting
The repository-level development tools require Node.js 20.17 or newer. Install them after cloning:
npm install
The install configures a Husky pre-commit hook. It runs Prettier only for staged
TypeScript files under eve-market-calc-ui/frontend/src/ and
eve-market-calc-ui/sso-backend/src/, then re-stages the formatted results.
Format or check both complete source trees manually with:
npm run format
npm run format:check
Quick Start
# Run migrations
make migrate PG_DSN="postgres://user:pass@host:5432/eve_calc?sslmode=disable"
# Build all 9 binaries (api-server, market-fetcher, compute-worker, pare-down, sde-import, job-sync, asset-sync, skill-sync, wallet-sync)
make build
# Build Docker images
make docker-build
# Push to registry
make docker-push
# Deploy to K8s
./k8s/rollout.sh dev --dry-run # dev lane (:latest-pr, namespace eve-calc-dev)
./k8s/rollout.sh dev
./k8s/rollout.sh uat --dry-run # UAT lane (:latest, namespace eve-calc)
./k8s/rollout.sh uat
# Deployment docs: docs/deployment/gitflow.md, environments.md, runbook.md
# Manually trigger CronJobs
./scripts/trigger-jobs.sh uat # UAT fetcher + paredown + jobsync
./scripts/trigger-jobs.sh dev fetcher # dev fetcher only
./scripts/trigger-jobs.sh uat paredown # UAT paredown only
API Endpoints
All endpoints prefixed at the API server's API_LISTEN_ADDR (default :8080).
X-Member-ID is the authenticated EVE character ID, injected by the SSO backend reverse proxy.
System
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
— | DB connection health check; returns {"status":"ok"} or 503 |
| POST | /fetch_finished |
— | Internal webhook from market fetcher; queues compute tasks for buildable items in the region |
Asset Tracking And Stockpile
Asset sync publishes one complete corporation snapshot each hour. Published snapshots, their locations, stacks, and diagnostics are retained for 14 days. Physical Stockpile totals use every row of the current complete ESI snapshot, including unresolved evidence. Configured offices and hangars are future operational metadata and do not change current totals. Open sell-order escrow is deliberately excluded from physical totals, even though the asset authorization also requests the market-orders scope for corporation market-stock features.
Player-owned structures that cannot be resolved are recorded as unresolved_player_structure diagnostics
and remain included in corporation-wide totals. Resolving those structures is outside the current #376 boundary;
the diagnostics are the source of truth until that work is available.
The integration owner is the character that authorized the corporation token. Only that owner can configure observed offices, select their observed hangars, and grant or revoke Asset Managers. Asset Managers can view the Stockpile and the configured office list, but cannot see grant metadata, candidates, or mutation controls. Ordinary members cannot access Stockpile administration. The server-provided access booleans are the source of truth for every UI and API authorization decision. Aggregate builder availability may use corporation stock, but it does not reveal individual asset stacks, office choices, token diagnostics, or manager grants.
| Method | Path | Access | Description |
|---|---|---|---|
| GET | /asset-tracking/status |
authenticated member | Integration status and server authorization booleans |
| POST | /asset-tracking/disable |
integration owner | Disable the corporation asset integration |
| POST | /asset-tracking/reset-errors |
integration owner | Reset integration error state |
| GET | /asset-tracking/snapshot/health |
Asset Manager | Current snapshot health |
| GET | /asset-tracking/snapshots |
Asset Manager | Retained snapshot list |
| GET | /asset-tracking/snapshot-failures |
Asset Manager | Retained snapshot failure list |
| GET | /asset-tracking/snapshots/{snapshot_id}/locations |
Asset Manager | Locations for one snapshot |
| GET | /asset-tracking/snapshots/{snapshot_id}/stacks |
Asset Manager | Filterable physical stacks for one snapshot |
| GET | /asset-tracking/snapshots/{snapshot_id}/diagnostics |
Asset Manager | Resolution diagnostics for one snapshot |
| GET | /asset-tracking/offices |
Asset Manager | Configured office list; non-owners receive read-only state |
| GET | /asset-tracking/office-options |
Asset Manager | Observed location and hangar choices |
| POST | /asset-tracking/offices |
integration owner | Add an observed office and hangars |
| PATCH | /asset-tracking/offices/{office_id} |
integration owner | Update an observed office and hangars |
| DELETE | /asset-tracking/offices/{office_id} |
integration owner | Remove an observed office |
| GET | /asset-tracking/managers |
integration owner | Manager grant metadata |
| GET | /asset-tracking/manager-candidates |
integration owner | Known same-corporation grant candidates |
| POST | /asset-tracking/managers |
integration owner | Grant an Asset Manager |
| DELETE | /asset-tracking/managers/{character_id} |
integration owner | Revoke an Asset Manager |
Asset snapshot migration and integration tests require TEST_PG_DSN pointing to a disposable PostgreSQL/
TimescaleDB database. Do not treat a skipped database test as migration verification.
Opportunities
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /opportunities |
— | Paginated profitable items from today's pruned list |
| GET | /groups |
— | SDE market groups with opportunity counts |
| GET | /recursive-bom/opportunities |
— | Multi-tier opportunities where building recursively beats buying the finished good |
GET /opportunities query params
| Param | Default | Description |
|---|---|---|
region |
(all) | Filter by region name (e.g. The Forge) |
min_margin |
0.20 |
Minimum margin (decimal, e.g. 0.10 = 10%) |
sort_by |
profit |
One of: profit, margin, sell_price, build_cost |
limit |
50 |
Page size (max 50) |
offset |
0 |
Pagination offset |
group_id |
(all) | Filter by SDE market group ID |
exclude_assigned_for_person_id |
(none) | Mark items already assigned to this person |
Opportunity response shape
{
"opportunities": [
{
"item_id": 2456,
"item_name": "Hobgoblin II",
"group_id": 100,
"group_name": "Combat Drone",
"region": "The Forge",
"build_cost": 293183.54,
"sell_price": 310700.0,
"profit": 17516.46,
"margin": 0.0564,
"buildable_at": "2026-04-26T00:02:13Z",
"assigned": false,
"materials_cost_isk": 223110.0,
"manufacturing_job_cost_isk": 22301.89,
"manufacturing_time_cost_isk": 0.0,
"adjusted_manufacturing_time_seconds": 1224,
"system_cost_index": 0.1725,
"is_invented": true,
"invention_cost_per_t2_run": 47771.65
}
],
"total": 1331
}
Dashboard & Analytics
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /dashboard |
optional X-Member-ID |
Aggregated stats and top 10 opportunities. Header used for user_assignment_count when present |
| GET | /market/{item_id}/{region} |
— | Time-bucketed buy/sell price history |
| GET | /market/regions |
— | Distinct region names for which market data exists |
| GET | /history/{item_id}/{region} |
— | Time-bucketed build cost history + pruned-list transitions |
| GET | /items/search?q=&limit= |
— | Item name autocomplete against SDE (min 2 chars) |
| GET | /items/{item_id}/is-buildable |
— | Whether an item has a manufacturing blueprint |
Dashboard response shape
{
"opportunity_count": 1331,
"opportunity_count_yesterday": 1199,
"avg_margin": 0.312,
"top_region": "The Forge",
"top_region_count": 667,
"user_assignment_count": 5,
"top_opportunities": [ { "item_id": 2456, "item_name": "Hobgoblin II", "..." : "..." } ]
}
Market history params
| Param | Default | Options |
|---|---|---|
from |
24h ago | ISO 8601 |
to |
now | ISO 8601 |
interval |
5m |
5m, 1h, 1d |
Build cost history params
| Param | Default | Options |
|---|---|---|
from |
7d ago | ISO 8601 |
to |
now | ISO 8601 |
interval |
1h |
1h, 1d |
Regions, Systems & Facilities
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /regions |
— | List all SDE regions |
| GET | /regions/{region_id}/systems |
— | Solar systems in a region |
| GET | /systems/search?q=&limit= |
— | Solar system autocomplete (min 2 chars); returns id, name, region, security |
| GET | /facilities |
— | List industry facilities; optional filters: region_id, system_id, facility_id |
Builds & Invention Decryptors
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /builds/request |
X-Member-ID |
Request an on-demand build calc with overrides (facility, runs, decryptor); returns 202 |
| GET | /builds/{request_id} |
— | Retrieve a completed build calc by request id |
| GET | /invention/decryptors |
— | List all invention decryptors with probability/runs/ME/TE modifiers |
POST /builds/request body:
{
"request_id": "uuid-from-client",
"item_id": 2456,
"region": "The Forge",
"invention": { "decryptor_type_id": 34204 },
"mfg": { "facility_id": 60003760, "runs": 10 }
}
Recursive BOM
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /recursive-bom/{type_id}?region_id=N |
— | Cached recursive BOM snapshot for an item+region |
| POST | /recursive-bom/{type_id}/refresh |
— | On-demand recursive expansion; persists result |
| GET | /recursive-bom/{type_id}/bottlenecks?region_id=N&limit=N |
— | Top-N costliest sub-components |
Assignments
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /assign |
— | Create a single assignment (legacy) |
| GET | /assignments/{member_id} |
— | Assignments for a corp member (legacy) |
| DELETE | /assign/{member_id}/{item_id}/{region} |
— | Delete by tuple (legacy) |
| POST | /assignments/batch |
X-Member-ID |
Batch-assign cart items to people; clears cart |
| POST | /assignments/plan/preview |
X-Member-ID |
Preview eligibility, queue, and compensation before creating assignments |
| POST | /assignments/plan/confirm |
X-Member-ID |
Revalidate a reviewed plan and create its assignments |
| DELETE | /assignments |
X-Member-ID |
Delete assignments for the authenticated user; optional ?filter=held|released|all (default all) scopes by release state |
| PATCH | /assignments/{assignment_id} |
X-Member-ID |
Update runs, task_type, or region |
| PATCH | /assignments/{assignment_id}/complete |
X-Member-ID |
Complete an assignment as a handoff: transactionally create a sell obligation receipt for eligible manufacture/react work, then delete the assignment row. Returns the completion result (deleted, obligation_created, obligation_id). 409 when eligible work has no build-cost snapshot to price the receipt |
| DELETE | /assignments/{assignment_id} |
X-Member-ID |
Delete a single assignment |
| POST | /assignments/expand |
X-Member-ID |
Expand buildable assignments into sub-component assignments (dry_run, recursive, and optional exclusion fields; see below) |
| POST | /assignments/from-build |
X-Member-ID |
Create an assignment directly from a completed build calc |
| POST | /assignments/invention-tasks |
X-Member-ID |
Generate aggregated invention tasks with success probability + overage |
| GET | /assignments/assigned-to-me |
X-Member-ID |
Assignments targeting the authenticated character, with assigner name + portrait |
| GET | /assignments/export |
X-Member-ID |
Download assignments as CSV |
POST /assignments/expand exclusions
The optional exclude_fuel_blocks, exclude_t1_hulls, and exclude_t1_tools boolean fields independently control expansion filtering. An explicitly supplied true or false is authoritative; an omitted field falls back to its corresponding EXPAND_EXCLUDE_* environment default (or false when that variable is absent).
Assignment eligibility and invention tasks
An invented-product manufacture assignment is eligible only when the assignee
meets both its manufacturing requirements and the configured invention floors
(INV_ENCRYPTION_SKILL, INV_DATACORE1_SKILL, and
INV_DATACORE2_SKILL). These configured floors remain authoritative for
assignment eligibility and invention modeling. Non-invented manufacturing,
reaction, and explicit invention assignments retain their respective existing
requirements.
Existing manufacturing assignments are accepted commitments. POST /assignments/invention-tasks does not re-check their current invention
eligibility; it accounts for every (person_id, item_id, region) candidate as
created, updated, or a structured skip. Regeneration may refresh mutable task
inputs, but preserves existing assigned_at and released_at timestamps.
POST /assignments/batch request
{
"assignments": [
{ "item_id": 2456, "region": "The Forge", "person_id": 7 }
]
}
Assignment planning
POST /assignments/plan/preview accepts one or more assignment intents. A preview may omit person_id to compare eligible people. Confirmations send all intents for fingerprint validation, but require at least one staged intent with a non-null person_id; only staged intents are inserted as assignments and removed from the cart, so unstaged cart items remain. Monetary values, percentages, and ISK-per-slot-hour values are decimal strings so clients must not parse them as floating-point values. Nullable preview fields mean the corresponding market, blueprint, queue, or policy input is unavailable.
workspace_owner_id is optional: omitted, null, or blank selects the caller's workspace. An explicit owner is allowed only to that owner or an active admin share recipient; member shares cannot plan or confirm work. Confirmations return 409 with code: "duplicate_confirmation" when the same manual work was already confirmed.
{
"workspace_owner_id": "owner-id",
"assignments": [
{ "item_id": 2456, "region": "The Forge", "person_id": null, "runs": 10 }
]
}
The preview response includes items, totals, fingerprint, generated_at, requires_acknowledgement, and diagnostics. To create assignments, submit the same intents with selected people and the returned fingerprint to POST /assignments/plan/confirm. Set assign_without_projection only after explicitly acknowledging a preview that reports requires_acknowledgement: true; release controls whether created assignments are immediately released.
Representative successful 200 preview response:
{
"items": [
{
"intent": { "item_id": 2456, "region": "The Forge", "person_id": null, "runs": 10 },
"item_name": "Hobgoblin I",
"current_margin": "18.250000",
"trend": "flat",
"output_quantity_per_run": 1,
"adjusted_seconds_per_run": null,
"market_volume_30d": 1250.5,
"market_volume_as_of": "2026-08-30",
"build_snapshot_at": "2026-08-30T12:00:00Z",
"equivalent_market_volume_days": "0.01",
"economics": {
"projected_output_quantity": 10,
"unit_sell_price": "12500.00",
"unit_build_cost": "9800.00",
"gross_revenue": "125000.00",
"cost_basis": "98000.00",
"gross_profit": "27000.00",
"gross_profit_per_output_unit": "2700.00",
"transaction_tax": "1250.00",
"broker_fee": "1250.00",
"distributable": "24500.00",
"slot_seconds": null
},
"assignees": [
{
"person_id": 7,
"character_name": "Builder One",
"character_portrait": "",
"eligibility": "unknown",
"active_assignment_count": 3,
"queue_before_seconds": 43200,
"proposed_added_seconds": null,
"queue_after_seconds": null,
"builder_percentage": "25.000000",
"builder_cut": "6125.00",
"isk_per_slot_hour": null,
"policy_id": 12,
"policy_version": 3,
"policy_effective_at": "2026-08-01T00:00:00Z",
"diagnostics": [
{ "code": "unknown_skills", "severity": "blocking", "message": "unknown_skills" }
]
}
],
"diagnostics": [
{ "code": "unknown_duration", "severity": "warning", "message": "unknown_duration" }
]
}
],
"totals": {
"capital": null,
"gross_sale_value": null,
"gross_profit": null,
"builder_cut": null,
"output_quantity": null,
"slot_seconds": null,
"equivalent_market_volume_days": null
},
"fingerprint": "7b6c5f2e6db352513ef63e0a75da36b4cf749a8428c39535f0d4d1e924f81c3b",
"generated_at": "2026-08-30T12:00:00Z",
"requires_acknowledgement": false,
"diagnostics": []
}
{
"assignments": [
{ "item_id": 2456, "region": "The Forge", "person_id": 7, "runs": 10 }
],
"fingerprint": "preview-fingerprint",
"assign_without_projection": true,
"release": false
}
Successful confirmation returns 201 with created, released, and assignment_ids:
{
"created": 1,
"released": false,
"assignment_ids": [4821]
}
Conflicts return typed JSON. If authoritative inputs have changed, confirmation returns 409 with a replacement preview to review and submit again:
{
"code": "projection_changed",
"message": "Assignment inputs changed; review the refreshed projection.",
"preview": {}
}
GET /assignments/assigned-to-me response shape
{
"assignments": [
{
"assignment_id": 82,
"item_id": 64034,
"item_name": "Boobook",
"region": "Sinq Laison",
"assigned_at": "2026-04-26T02:28:09Z",
"runs": 5,
"task_type": "manufacture",
"assigned_by": "93461829",
"assigned_by_name": "Matthew Maurer",
"assigned_by_portrait": "https://images.evetech.net/characters/93461829/portrait?size=64",
"assigned_to_name": "Alt Character"
}
]
}
Dividend Policies
Dividend policy configuration is authenticated with X-Member-ID. Every route resolves the caller's current corporation server-side; clients cannot select or override the corporation scope. Current corporation members may read policies and active administrator grants. The corporation wallet integration owner and active policy administrators may create, replace, preview, and publish drafts, while only the wallet integration owner may grant or revoke policy administrators.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/dividend/policies |
X-Member-ID |
List the current draft, when present, and published policy summaries newest first |
| GET | /api/dividend/policies/{id} |
X-Member-ID |
Get a corporation policy version and its ordered buckets |
| POST | /api/dividend/policies/draft |
X-Member-ID (policy editor) |
Return the existing draft or create one from the latest publication or minimal remainder-only template |
| PUT | /api/dividend/policies/draft |
X-Member-ID (policy editor) |
Atomically replace the complete draft description and bucket list using expected_revision |
| POST | /api/dividend/policies/draft/publish |
X-Member-ID (policy editor) |
Immediately publish the draft using expected_revision; the resulting version is immutable |
| POST | /api/dividend/policies/preview |
X-Member-ID (policy editor) |
Validate and allocate an unsaved policy and amount without persisting either |
| GET | /api/dividend/policy-admins |
X-Member-ID |
List active policy administrator grants for the caller's corporation |
| POST | /api/dividend/policy-admins |
X-Member-ID (wallet owner) |
Grant policy administration to a current corporation member by character_id |
| DELETE | /api/dividend/policy-admins/{characterID} |
X-Member-ID (wallet owner) |
Revoke an active policy administrator grant while retaining its history |
Percentages such as bucket percentage values are canonical decimal strings with six fractional digits, for example "25.000000". Money fields such as preview distributable_amount and allocation amount are canonical decimal strings with two fractional digits, for example "1250.00"; clients must not send JSON numbers for these exact values.
Every policy payload and preview includes builder_distribution_mode, either "direct" or "shared_pool". A draft replacement includes it with the complete bucket list:
{
"expected_revision": 4,
"builder_distribution_mode": "shared_pool",
"buckets": []
}
direct creates the existing builder-recipient liabilities. Under shared_pool, every positive role:builder allocation becomes an immutable corporation-wide builder-pool source instead. The mode is copied into drafts, previewed but not persisted by policy preview, and frozen when published; changing it never changes earlier liabilities or pool sources.
Draft replacement and publication requests include the draft's current expected_revision. A stale revision returns 409 Conflict and leaves the newer draft unchanged. Publication takes effect immediately: effective_at equals published_at, with no scheduling or backdating. Preview operates only on the supplied unsaved configuration and never creates or updates a policy, allocation event, balance, or liability.
This API is configuration-only. Profit derivation, persisted allocation events, payout liabilities and batches, reserve or budget balances, surplus distribution, and wallet transfers remain outside this issue's scope.
Dividend Payouts
Dividend payout routes require X-Member-ID and resolve the actor and corporation server-side. The current wallet authorizer may grant or revoke payout administrators and has payout-administrator access. Active payout-administrator delegates may create batches and resolve ambiguous matches, but cannot grant or revoke authority. Recipients can read only their own liabilities, batches, transfer evidence, and carry-forward; payout administrators can read corporation-wide payout records.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/dividend/payout/status |
X-Member-ID |
Return the caller's corporation, payout authority, and match-window metadata |
| GET | /api/dividend/payout-admins |
X-Member-ID |
List active payout administrators |
| POST | /api/dividend/payout-admins |
X-Member-ID (wallet authorizer) |
Grant payout administration to a current corporation character by character_id |
| DELETE | /api/dividend/payout-admins/{characterID} |
X-Member-ID (wallet authorizer) |
Revoke an active payout-administrator grant |
| GET | /api/dividend/payout/liabilities |
X-Member-ID (recipient or payout admin) |
List server-scoped liabilities with derived payout state |
| POST | /api/dividend/payout/batches |
X-Member-ID (payout admin) |
Claim selected liabilities and create recipient transfers for a wallet division |
| GET | /api/dividend/payout/batches |
X-Member-ID (recipient or payout admin) |
List server-scoped batches and aggregate states |
| GET | /api/dividend/payout/batches/{batchID} |
X-Member-ID (recipient or payout admin) |
Return scoped lines, transfers, carry applications, and settlement evidence |
| GET | /api/dividend/payout/transfers/{transferID}/candidates |
X-Member-ID (payout admin) |
List current unconsumed qualifying candidates for an ambiguous transfer |
| POST | /api/dividend/payout/transfers/{transferID}/match |
X-Member-ID (payout admin) |
Select and immutably consume one qualifying journal entry |
All money values are canonical decimal JSON strings, not JSON numbers. Payouts are settled from retained wallet evidence; there is no manual mark-paid action. A qualifying entry is an unconsumed journal row for the batch corporation and wallet division with ref_type corporation_account_withdrawal, a negative amount, and the recipient in second_party_id. Its date must be within the batch's stored inclusive match window and its absolute amount must be within the inclusive 10 percent tolerance of the expected transfer. Wallet sync automatically settles exactly one qualifying entry after successful journal pagination; multiple candidates remain ambiguous for an administrator to select, and expired unresolved transfers remain stale with their liabilities claimed.
Shared builder pool
Shared-pool routes use the caller's current corporation derived from X-Member-ID and require payout-administrator authority.
| Method | Path | Access | Description |
|---|---|---|---|
| GET | /api/dividend/payout/builder-pool |
payout admin | Return all currently unclaimed pool sources, their exact total, count, oldest accrual time, and source_set_token |
| GET | /api/dividend/payout/builder-pool/characters?q=&limit=&offset= |
payout admin | Search eligible app-linked recipient characters; limit defaults to 50 and must be 1 through 50, and offset defaults to 0 |
| GET | /api/dividend/payout/builder-pool/people |
payout admin | List the caller's People entries with shared-pool eligibility and available direct-liability totals |
| POST | /api/dividend/payout/builder-pool/preview |
payout admin | Review a complete-pool distribution without writing state |
| POST | /api/dividend/payout/builder-pool/distributions |
payout admin | Atomically claim and distribute the reviewed complete pool, creating a regular payout batch |
An eligible recipient is any character with exactly one current user_characters owner link. The search does not require corporation membership, builder role, assignment or payout history, or policy participation. Both preview and distribution require wallet_division from 1 through 7, a non-empty source_set_token, and one or more unique positive recipient_character_ids; distribution also requires the non-empty preview_token returned by preview.
The pool is the full set of unclaimed positive shared-pool builder sources across historical policy versions, even when the current policy is direct. Preview sorts recipients by character ID and divides the entire pool equally in cents, assigning remainder cents in that order. It also includes every currently available non-pool liability for each selected character and applies that character's carry to calculate the expected transfer. Distribution rechecks the source set, selected recipients' ownership, automatically included liabilities, and carry before committing the source claims, one generated pool liability per recipient, and the consolidated payout batch together. A changed source set or reviewed fact returns 409 Conflict with {"code":"builder_pool_stale"}; clients must reload the summary and preview rather than retrying stale tokens.
Dividend Earnings
Dividend earnings routes require X-Member-ID and always resolve the actor and current corporation server-side. Recipients receive only records owned by their authenticated member identity. A recipient_character_id selector is forwarded to the database's authoritative payout-administrator check; it is not authorized by route parsing. Payout administrators may use that selector to inspect one recipient's records, while ordinary recipients cannot use it.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/dividend/earnings/obligations |
X-Member-ID (recipient or payout admin) |
List recipient-scoped sale obligations and aggregate earnings |
| GET | /api/dividend/earnings/obligations/{obligationID} |
X-Member-ID (recipient or payout admin) |
Return one scoped obligation with chronological sale, allocation, liability, settlement, and diagnostic evidence |
| GET | /api/dividend/earnings/paychecks |
X-Member-ID (recipient or payout admin) |
List recipient-scoped payout transfers newest first |
| GET | /api/dividend/earnings/paychecks/{transferID} |
X-Member-ID (recipient or payout admin) |
Return one scoped transfer grouped by obligation and fixed sources, with carry-forward evidence |
Obligation lists accept view=active|history (active by default), from, to, limit, cursor, and optional recipient_character_id. Paycheck lists accept from, to, limit, cursor, and optional recipient_character_id; either detail route accepts optional recipient_character_id. Dates use YYYY-MM-DD in America/New_York: from is inclusive at New York midnight and to is exclusive at the following New York midnight. limit defaults to 50 and must be 1 through 100; cursor is opaque and must be returned unchanged.
An obligation is open until a recipient allocation settles, partially_paid after some allocation settles, and paid only when it is fulfilled, fully posted, and every recipient liability settles. Paychecks represent grouped payout transfers rather than new earnings: their expected amount includes the selected liability total less any carry application, and settlement, ambiguity, and stale status are based on retained wallet evidence. All monetary values are decimal JSON strings, never JSON numbers.
Sell Obligations
A sell obligation is a durable receipt created when an eligible final-product assignment (manual manufacture/react) is completed — see PATCH /assignments/{assignment_id}/complete. It records produced quantity (runs × SDE output quantity per run), the per-unit and total build cost / projected sell price locked at completion, and split builder/owner/assigner/completer provenance. assignment_id is provenance only (no FK); receipt creation is idempotent, so duplicate/concurrent completions never create duplicate receipts.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/sell-obligations?status=outstanding|fulfilled&limit=50&cursor=... |
X-Member-ID |
List obligations visible to the caller (owner + existing share model). Also supports open, partially_filled, all, and filters for corporation_id, item_id, region, search, wallet_division, fulfilled_from, and fulfilled_to |
| GET | /api/sell-obligations/filter-options?status=fulfilled |
X-Member-ID |
List visibility-scoped corporation, region, and wallet-division options for fulfilled history |
| GET | /api/sell-obligations/{id} |
X-Member-ID |
Single obligation if visible to the caller, else 404 |
| GET | /api/sell-obligations/{id}/sales |
X-Member-ID |
Immutable sale receipts for a visible obligation, ordered oldest sale first; an invisible or unknown obligation returns 404 |
outstanding combines open and partially filled obligations. Active results are ordered by created_at DESC, id DESC; fulfilled history is ordered by fulfilled_at DESC, id DESC. Paginated responses may include an opaque, status-scoped next_cursor; pass it back unchanged with the same status and limit to request the next page.
Fulfilled date filters use UTC calendar dates: fulfilled_from is inclusive at 00:00 UTC, while fulfilled_to includes the whole specified date by using the following midnight as an exclusive bound. wallet_division selects obligations having at least one receipt from that division, but obligation totals continue to include every receipt across all divisions.
Dividend Allocation Posting
After wallet sale reconciliation commits, wallet sync posts each retained sale receipt independently to one immutable dividend allocation event. Posting does not wait for the parent obligation to be fulfilled. It snapshots gross revenue, receipt quantity, locked unit build cost, receipt cost basis, taxes, broker fee, policy, allocation lines, and recipient identity; later receipts or configuration changes do not rewrite an existing event.
The applicable policy is the latest published version whose effective_at is at or before the receipt's sold_at, not the time wallet sync posts it. Positive resolved person and role allocations become payout liabilities. Reserve, budget-account, and remainder allocations become internal balance events instead; they are not payout liabilities. A negative distributable amount becomes a signed remainder loss event and never a negative human liability.
Receipts that cannot post remain retained and are retried in a later wallet-sync pass. dividend_allocation_diagnostics records the current retry state (policy_not_found, person_recipient_unresolved, source_data_invalid, or posting_failed), attempt count, and timestamps. A successful post removes its active diagnostic. An unresolved role is instead recorded on its immutable allocation line as role_recipient_unresolved and its amount is routed to remainder.
The fee-rate settings below are temporary estimated fallbacks pending actual transaction-tax correlation (#439) and broker-fee attribution (#440). Posted events snapshot both the configured rate and estimated provenance, so later actual-fee work applies only to future receipts.
For existing corporations where receipts predate policy version 1, the optional one-off script previews the timestamp shift without changing data:
PG_DSN="postgres://user:pass@host:5432/eve_calc?sslmode=disable" \
./scripts/backdate-dividend-policy-timeline.sh
After reviewing the preview, apply the same per-corporation shift explicitly:
PG_DSN="postgres://user:pass@host:5432/eve_calc?sslmode=disable" \
./scripts/backdate-dividend-policy-timeline.sh --apply
The script shifts every published version's published_at and effective_at by the same offset so version 1 aligns with the oldest receipt while policy ordering and intervals remain unchanged. It is never run by migrations, application startup, wallet sync, or Kubernetes workloads.
People
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /people |
X-Member-ID |
Add an EVE character to the user's people list |
| GET | /people |
X-Member-ID |
List all people for the authenticated user (with portrait URL and is_reactor) |
| PATCH | /people/{person_id} |
X-Member-ID |
Toggle the is_reactor role flag (caller must own the person) |
| DELETE | /people/{person_id} |
X-Member-ID |
Remove a person |
| GET | /people/{person_id}/assignments |
X-Member-ID |
Assignments for a person with runs and computed manufacturing hours |
POST /people request
{ "character_id": 93461829, "character_name": "Alt Character" }
PATCH /people/{person_id} request
{ "is_reactor": true }
Flagging a person as a reactor opts their corporation into the shared reaction
pool (issue #235): Fill Buildables routes reaction materials to the corp's single
implicit pool (assignments.pool_corp_id, person_id NULL) instead of inheriting the
parent person, and the reactor's Assigned to Me (/assignments/assigned-to-me/job-progress)
gains the pool rows with progress aggregated across all corp members' reaction jobs.
Cart
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /cart |
X-Member-ID |
Add a single item to cart |
| POST | /cart/batch |
X-Member-ID |
Add multiple items to cart |
| GET | /cart |
X-Member-ID |
Cart contents enriched with item names and facility names |
| GET | /cart/count |
X-Member-ID |
Cart item count |
| DELETE | /cart/{item_id}/{region} |
X-Member-ID |
Remove a single item from cart |
| DELETE | /cart |
X-Member-ID |
Clear cart |
Purchase List
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /purchase-list |
X-Member-ID |
Aggregated raw materials needed for all active assignments, grouped by region (JSON). Each item carries a routed source (market/corp) per the routing rules below |
| GET | /purchase-list/tsv |
X-Member-ID |
Full purchase list (both sources) as a TSV download (purchase-list.tsv) |
| GET | /purchase-list/market/tsv |
X-Member-ID |
Market-routed items only, EVE multibuy-friendly TSV (purchase-list-market.tsv) |
| GET | /purchase-list/corp-mail |
X-Member-ID |
Corp-routed items as readable plain text for an EVE mail / corp chat (purchase-list-corp.txt) |
All four endpoints accept optional boolean query parameters exclude_fuel_blocks,
exclude_t1_hulls, and exclude_t1_tools. Matching manufactured intermediates
become purchase leaves and their descendants are omitted. Reaction children remain
exempt from these exclusions, and normal purchase routing still applies to the leaves.
For manufacture, react, and invent assignments, demand uses assigned runs
not yet represented by correlated industry jobs in active, ready, or
delivered status. Installed job runs are counted in full and clamped at zero;
buy and haul behavior is unchanged.
Purchase Routing (issue #231)
Configurable rules that split remaining purchase demand into a Market path
(buy in a trade hub / multibuy) and a Corp path (request from corp mates).
Routing runs after corp asset-stock subtraction, so only the leftover to_buy
quantity is routed. The effective source is resolved by precedence: type rule >
group rule > category rule > user default rule > code-backed SDE seed default >
Market fallback. Rules are user-scoped; a rule marked shared is reusable by
people granted access via user_shares (sharing rules does not expand which
assignments appear in a purchase list).
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /purchase-routing/rules |
X-Member-ID |
Effective rules (own + shared-in, with resolved scope names, disabled flag, and the sharer's owner_name/owner_portrait), the seed defaults, and the Market fallback |
| POST | /purchase-routing/rules |
X-Member-ID |
Create/upsert a rule {scope_kind, scope_id, source, shared} (scope_kind: type/group/category/default; scope_id omitted for default) |
| PATCH | /purchase-routing/rules/{id} |
X-Member-ID |
Update an owned rule's {source, shared} |
| DELETE | /purchase-routing/rules/{id} |
X-Member-ID |
Delete an owned rule |
| PUT | /purchase-routing/rules/{id}/enabled |
X-Member-ID |
Recipient-side enable/disable of a shared-in rule {enabled}. Disabling mutes it for the caller only (it no longer affects their routing); the owner's rule is untouched |
| GET | /purchase-routing/groups?q= |
X-Member-ID |
SDE group search for the rule picker |
| GET | /purchase-routing/categories |
X-Member-ID |
Full SDE category list for the rule picker |
Blocklist
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /blocklist |
— | Paginated blocklist entries |
| POST | /blocklist |
X-Member-ID |
Add an item: type_id, reason |
| DELETE | /blocklist/{type_id} |
X-Member-ID |
Remove an item from the blocklist |
Shares (Cross-User Access)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /users/{target_user_id}/shares |
X-Member-ID (owner) |
Grant access to another character: grantee_character_id, role (admin/member) |
| GET | /users/{target_user_id}/shares |
X-Member-ID (owner) |
List shares granted by this user |
| PATCH | /users/{target_user_id}/shares/{grantee_user_id} |
X-Member-ID (owner) |
Update a share's role |
| DELETE | /users/{target_user_id}/shares/{grantee_user_id} |
X-Member-ID (owner) |
Revoke a share |
| GET | /users/my-shares |
X-Member-ID |
List shares granted to the caller |
| GET | /users/{target_user_id}/people |
X-Member-ID (gated) |
View another user's people list (requires share) |
| GET | /users/{target_user_id}/assignments |
X-Member-ID (gated) |
View another user's assignments grouped by person |
| GET | /me/lists-i-can-edit |
X-Member-ID |
People+IDs where caller has admin share |
| GET | /me/lists-i-can-view |
X-Member-ID |
People+IDs where caller has member or admin share |
Frontend Routes
| Path | Description |
|---|---|
/ |
Dashboard — stats, margins, top opportunities |
/opportunities |
Browse profitable build opportunities with filters |
/assignments |
Assignment management: cart, people, AssignedToMe, MyAssignments, Shares, Blocklist, PurchaseList tabs |
/market |
Item/region market price viewer with charts |
/history |
Build-cost history charts |
/calculator |
On-demand build cost calculator with recursive BOM engine, facility/decryptor/invention overrides, and assignment creation |
/recursive-bom |
Redirects to /calculator (preserves query params) |
/blocklist |
Manage items excluded from build calculations |
/settings |
User preferences, region selection, theme |
/callback |
EVE SSO redirect landing page |
Configuration
All config is driven by environment variables. See k8s/base/configmap.yaml.
Key variables
| Variable | Default | Description |
|---|---|---|
PG_DSN |
postgres://localhost:5432/eve_calc?sslmode=disable |
TimescaleDB connection string |
RMQ_URL |
amqp://guest:guest@localhost:5672/ |
RabbitMQ connection string |
API_LISTEN_ADDR |
:8080 |
API server listen address |
API_URL |
http://eve-calc-api:8080 |
Market fetcher → API server URL |
PROFIT_THRESHOLD_MARGIN |
0.10 |
Minimum margin for pare-down (10%) |
HOME_SYSTEM |
30000142 |
Solar system ID for manufacturing cost index lookup (Jita) |
HOME_FACILITY_ID |
60003760 |
Facility ID for facility tax lookup (Jita 4-4) |
MFG_BLUEPRINT_TE |
20 |
Blueprint time efficiency level assumed for T1 items |
MFG_INDUSTRY_SKILL_LEVEL |
5 |
Industry skill level |
MFG_ADVANCED_INDUSTRY_SKILL_LEVEL |
5 |
Advanced Industry skill level |
MFG_FACILITY_TIME_MULTIPLIER |
1.0 |
Facility time multiplier (structure rig bonus) |
MFG_FACILITY_COST_MULTIPLIER |
1.0 |
Facility cost multiplier (structure rig bonus) |
MFG_OPPORTUNITY_ISK_PER_HOUR |
0 |
App-level opportunity cost ISK/hr (not an EVE mechanic) |
INV_ENCRYPTION_SKILL |
4 |
Encryption methods skill level for invention |
INV_DATACORE1_SKILL |
4 |
Datacore skill 1 level |
INV_DATACORE2_SKILL |
4 |
Datacore skill 2 level |
INV_SCC_SURCHARGE |
0.015 |
SCC surcharge rate for invention install fee (1.5%) |
INV_AMORTIZE_FAILED |
true |
Amortize failed invention attempts into cost |
INV_SYSTEM_ID |
30000142 |
Solar system for invention cost index lookup |
INV_FACILITY_ID |
60003760 |
Facility for invention facility tax lookup |
INVENTION_TASK_OVERAGE |
0.10 |
Overage applied when generating invention tasks (10%) |
EXPAND_EXCLUDE_FUEL_BLOCKS |
false |
Exclude Fuel Blocks during assignment expansion; initial UI default and omitted-request fallback; browser/API values may override. |
EXPAND_EXCLUDE_T1_HULLS |
false |
Exclude direct Tech I base inputs to Tech II recipes (ships, modules, drones, and equivalents) during assignment expansion; initial UI default and omitted-request fallback; browser/API values may override. |
EXPAND_EXCLUDE_T1_TOOLS |
false |
Exclude R.A.M./R.Db tools during assignment expansion; initial UI default and omitted-request fallback; browser/API values may override. |
ESI_TIMEOUT_SECONDS |
10 |
Per-request ESI HTTP timeout |
MARKET_FETCHER_TIMEOUT |
240 |
Market fetcher max run time in seconds |
DIVIDEND_PAYOUT_MATCH_WINDOW_SECONDS |
1209600 |
Payout journal match window in seconds (14 days); missing, malformed, or nonpositive values use this default |
DIVIDEND_ESTIMATED_TRANSACTION_TAX_RATE |
0.033750 |
Temporary estimated transaction-tax fallback pending #439; canonical decimal fraction with exactly six fractional digits, from 0.000000 through 1.000000; invalid explicit values fail startup |
DIVIDEND_ESTIMATED_BROKER_FEE_RATE |
0.015000 |
Temporary estimated broker-fee fallback pending #440; canonical decimal fraction with exactly six fractional digits, from 0.000000 through 1.000000; invalid explicit values fail startup |
Operators may set the expansion exclusion variables in deployment overlays to choose intentional defaults, but must not add them to k8s/base/configmap.yaml merely to record the false defaults; absent variables preserve those defaults.
Secrets (not in configmap): k8s/overlays/uat/sealed-secrets.yaml / k8s/overlays/dev/sealed-secrets.yaml — encrypted via Sealed Secrets before committing.
Database Migrations
Migrations are embedded in the migrate binary (via //go:embed) and run automatically at deploy time via the eve-calc-migrate Job. Powered by golang-migrate.
| # | Description |
|---|---|
| 001 | Initial schema: market_orders, build_costs hypertables, pruned_list, assignments, 30d retention policies |
| 002 | SDE tables: types, groups, categories, blueprints, blueprint_materials |
| 003 | order_id promoted to BIGINT (EVE order IDs exceed INT4) |
| 004 | 2-phase select: people, cart tables; person_id on assignments |
| 005 | assignments: +runs, +task_type, +assigned_by_user_id |
| 006 | sde_industry_activity_times table |
| 007 | build_costs: +manufacturing time columns |
| 008 | ESI cache tables (esi_industry_system_cost_indices, esi_industry_facilities, esi_market_prices); full build-cost breakdown columns |
| 009 | build_costs: +is_invented, +invention_cost_per_t2_run |
| 010 | esi_market_history daily-cache table |
| 011 | assignments: task_type NOT NULL with default manufacture (backfilled) |
| 012 | build_costs: +overrides_json, +requested_by |
| 013 | SDE universe: sde_regions, sde_constellations, sde_solar_systems, sde_stations |
| 014 | assignments: +facility_id, +decryptor_type_id, +source_request_id |
| 015 | build_costs: dedicated request_id column + unique partial index |
| 016 | build_blocklist table with auto-updated_at trigger |
| 017 | Allow coexisting Manufacture + Invention assignments for same (person, item, region) |
| 018 | Recursive BOM (spec 008): expanded_bom_nodes, build_cost_recursive; legacy watchlist table removed by migration 033 |
| 019 | Fix casing bug: rename 'Manufacturing' task_type rows to 'manufacture' |
| 020 | user_shares table for cross-user assignment sharing (admin/member roles) |
| 021 | market_summary aggregate table; rebuilds market_orders with a (order_id, time) unique key + volume |
| 022 | compute_market_summary() SQL function (weighted avg / median / 5% tail stats per region snapshot) |
| 023 | Character cache: characters + user_characters tables (backfilled from people) |
| 024 | assignments: +released_at (held/released two-phase flow); backfilled to assigned_at |
| 025 | Industry-job tracking: corporation_tracking, character_scoped_tokens (encrypted ESI tokens) + update_updated_at_column() trigger |
| 026 | Assignment lifecycle: +completed_at, +status; industry_jobs +product_type_id |
| 027 | industry_jobs: +started_at (backfilled from created_at) |
| 028 | industry_jobs: rename runs_remaining→runs, drop percentage_complete, +duration/end_date/successful_runs |
| 029 | industry_job_errors log table (per corporation) |
| 030 | Drop legacy assignments (corp_member_id, item_id, region) unique constraint |
| 031 | industry_jobs: +activity_id (manufacture/invent/react), backfilled to 1 |
| 032 | Corp asset tracking: corporation_assets + corporation_asset_config |
| 033 | Drop legacy recursive_bom_watchlist table |
| 034 | Drop build_blocklist.type_id FK (allow blocking non-SDE types) |
| 035 | Skill validation: character_skills + sde_blueprint_skills |
| 036 | purchase_routing_rules (type/group/category/default → market/corp source) |
| 037 | purchase_routing_rule_mutes (per-user rule muting) |
| 038 | Index sde_blueprints (product_type_id, activity) |
| 039 | Shared per-corp reaction pool: people +is_reactor, assignments +pool_corp_id + target/dedup constraints |
| 040 | assignments: +assignment_origin (manual/system_expand_buildables/system_invention_tasks) |
| 041 | character_scoped_tokens +has_orders_scope; corporation_market_stock table |
| 042 | warm_runs table (one-per-day compute warm-up guard) |
| 043 | sell_obligations receipt table: no FK to assignments; unique partial index on assignment_id for idempotent receipt creation |
| 044 | SDE market-group hierarchy and closure tables; sde_types.market_group_id |
| 045 | Retained corporation-wallet divisions, transactions, journal, sync progress/diagnostics, and sell-obligation sale allocations |
| 046 | Corporation integration ownership registry, deterministic asset/wallet backfill, duplicate cleanup, and token uniqueness |
| 047 | Corporation industry ownership backfill, corporation-token duplicate cleanup, and per-corporation token uniqueness |
| 048 | Corporation-scoped, versioned Dividend policies and buckets; retained policy-administrator grant history; published-policy immutability |
| 049 | Dividend payout ledger: payout administrators, liabilities, batches, transfers, and settlement evidence |
| 050 | Immutable receipt-level dividend sale-profit allocation events, lines, internal balance events, and retry diagnostics |
| 051 | Retained hourly asset snapshots, Stockpile access grants, observed offices, and asset-resolution diagnostics |
| 052 | Permit durable SDE metadata failure attempts |