Portal, CRM & Inventory API
Endpoint inventories for the three business-side modules, with one deep-dive each. Conventions: conventions.md; full contracts: Swagger (README.md).
Customer portal — /api/portal/**
The portal is a separate auth system: customers authenticate via
/api/portal/auth and receive a standalone 24-hour JWT whose only
authority is PORTAL_CUSTOMER — an ERP token cannot call these endpoints
and a portal token cannot call the ERP. The one exception is
/api/portal/admin/**, which is the ERP-side back office for portal orders
and uses a normal ERP token with inventory.portal.manage.
PortalCatalogController.java, PortalOrderController.java, PortalProfileController.java, PortalAdminController.java
| Method | Path | Authority | Purpose |
|---|---|---|---|
| GET | /api/portal/catalog/items | PORTAL_CUSTOMER | Browse sellable items (paged) |
| GET | /api/portal/catalog/items/{itemId} | PORTAL_CUSTOMER | Item detail |
| GET | /api/portal/catalog/categories | PORTAL_CUSTOMER | Category list |
| POST | /api/portal/orders | PORTAL_CUSTOMER | Place an order (deep-dive below) |
| GET | /api/portal/orders | PORTAL_CUSTOMER | Own orders (raw Spring Page JSON, newest first) |
| GET | /api/portal/orders/{orderId} | PORTAL_CUSTOMER | Own single order |
| DELETE | /api/portal/orders/{orderId} | PORTAL_CUSTOMER | Cancel own order (204) |
| GET | /api/portal/profile | PORTAL_CUSTOMER | Own profile |
| PATCH | /api/portal/profile | PORTAL_CUSTOMER | Update profile |
| POST | /api/portal/profile/change-password | PORTAL_CUSTOMER | Change password (current password required) |
| GET | /api/portal/admin/orders | ERP: inventory.portal.manage | All customer orders (paged) |
| GET | /api/portal/admin/orders/{orderId} | ERP: inventory.portal.manage | One customer order |
| PUT | /api/portal/admin/orders/{orderId}/shipment | ERP: inventory.portal.manage | Update shipment/fulfillment status |
Deep-dive: POST /api/portal/orders
The customer is derived from the portal JWT (portalUserId claim) — the body
carries only the cart:
{
"items": [
{ "itemId": 42, "quantity": 3 },
{ "itemId": 17, "quantity": 1 }
],
"notes": "Deliver to the Makati branch"
}
Response: 201 Created with the CustomerOrderDto (order id, line items
with prices captured at order time, status, timestamps). Order retrieval and
cancellation are ownership-checked against the token's portalUserId — you
cannot read or cancel another customer's order. Fulfillment then happens on
the ERP side via /api/portal/admin/orders/{id}/shipment.
CRM — /api/crm/**
Read access everywhere with crm.view; writes are split per record type.
CrmLeadController.java,
CrmContactController.java,
CrmOrganizationController.java,
CrmDealController.java,
CrmActivityController.java,
CrmSettingsController.java,
CrmPicklistController.java
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/crm/leads · /{id} | crm.view | Paged list / one lead |
POST · PUT /{id} · PATCH /{id}/status · DELETE /{id} | /api/crm/leads... | crm.leads.manage | Lead lifecycle |
| POST | /api/crm/leads/{id}/convert | crm.deals.manage | Convert a lead into a deal |
| GET | /api/crm/contacts · /{id} · /by-org/{orgId} | crm.view | Contacts (paged / one / per org) |
POST · PUT /{id} · DELETE /{id} | /api/crm/contacts... | crm.contacts.manage | Contact CRUD |
| GET | /api/crm/organizations · /{id} | crm.view | Organizations |
POST · PUT /{id} · DELETE /{id} | /api/crm/organizations... | crm.contacts.manage | Organization CRUD |
| GET | /api/crm/deals · /pipeline · /{id} · /{id}/activities · /{id}/notes | crm.view | Deals list / kanban / detail |
| GET | /api/crm/deals/analytics | crm.reports.view | Funnel & win-rate aggregates |
POST · PUT /{id} · DELETE /{id} | /api/crm/deals... | crm.deals.manage | Deal CRUD |
| PATCH | /api/crm/deals/{id}/stage | crm.deals.manage | Move a deal through the pipeline (deep-dive) |
| POST | /api/crm/deals/{id}/notes | crm.activities.manage | Add a note |
| GET | /api/crm/activities · /{id} | crm.view | Activities/tasks |
POST · PATCH /{id}/complete · DELETE /{id} | /api/crm/activities... | crm.activities.manage | Schedule / complete / remove |
| GET | /api/crm/settings | crm.settings.manage or crm.view | CRM behavior settings (singleton) |
| PUT | /api/crm/settings | crm.settings.manage | Update settings (versioned, lockable, field-diff audited) |
| GET | /api/crm/settings/changes | crm.settings.manage | Settings change log, newest first |
| GET | /api/crm/picklists | crm.settings.manage or crm.view | Paged picklist items (kind = DEAL_STAGE · LEAD_SOURCE · ACTIVITY_TYPE) |
POST · PUT /{id} · DELETE /{id} | /api/crm/picklists... | crm.settings.manage | Picklist CRUD (rename propagates to existing records; in-use items can't be deleted) |
Deep-dive: PATCH /api/crm/deals/{id}/stage
{ "stage": "Closed Lost", "lostReason": "Chose a competitor" }
stage is required and must be an active pipeline stage from CRM Settings
(400 otherwise); lostReason is optional unless the target stage is flagged
lost and the requireLostReason setting is on. Verified in
CrmDealServiceImpl.updateStage:
moving stage also (1) resets the deal's probability to the stage's default
probability (falling back to the defaultDealProbability setting), (2) records
lostReason if given, and (3) stamps actualCloseDate = today when the target
stage is flagged won or lost and autoCloseDateOnWonLost is enabled. Stage
names, order, colors, and won/lost flags are managed at /crm/settings.
Response: the updated CrmDealDto.
Inventory / warehouse — /api/inventory/**
The largest surface — grouped by area. Read/write permission pairs follow one
pattern: inventory.<area>.view for GETs, inventory.<area>.manage for
writes.
InventoryController.java
(dashboard/categories/items/stock/suppliers/warehouses/locations/receiving),
InventoryReportController.java,
BarcodeController.java,
SerialLotController.java,
WarehouseTransferController.java,
StoreController.java,
OrderController.java,
ProfitController.java,
ItemPriceController.java
| Area | Endpoints (under /api/inventory) | view permission | manage permission |
|---|---|---|---|
| Dashboard | GET /dashboard | inventory.dashboard.view | — |
| Categories | GET · POST · PUT/DELETE /categories/{id} | inventory.items.view | inventory.items.manage |
| Items | GET /items (paged/filtered) · /items/{id} · /items/sku/{sku} · /items/{id}/barcode; POST/PUT/DELETE | inventory.items.view | inventory.items.manage |
| Stock | GET /stock · /stock/low · /stock/transactions; POST /stock/transactions (adjustments/receipts/issues) | inventory.stock.view | inventory.stock.manage |
| Suppliers | GET (paged) · /{id}; POST/PUT/DELETE | inventory.suppliers.view | inventory.suppliers.manage |
| Warehouses & bin locations | GET /warehouses · /locations · /warehouses/{id}/locations · /warehouses/{id}/location-tree · /warehouses/{id}/stock-by-location; POST/PUT/DELETE both, PATCH /locations/{id}/deactivate | inventory.locations.view | inventory.locations.manage |
| Receiving (POs) | GET (paged) · /{id}; POST; PATCH /receiving/{id}/complete · /cancel | inventory.receiving.view | inventory.receiving.manage |
| Reports | GET /reports/ledger · /reports/valuation · /reports/movement-summary | inventory.reports.view | — |
| Serials & lots | GET /serials · /lots; POST both; PATCH /serials/{id}/status | inventory.serials.view | inventory.serials.manage |
| Transfers | GET (paged); POST (deep-dive) | inventory.transfers.view | inventory.transfers.manage |
| Prices | GET /prices · /items/{itemId}/prices · /prices/history; POST/PUT/DELETE /prices... | inventory.prices.view | inventory.prices.manage |
| Stores | GET (paged) · /{id} · /{id}/inventory; POST/PUT/DELETE; PUT /{id}/inventory/{itemId} | inventory.stores.view | inventory.stores.manage |
| Sales orders | GET (paged) · /orders/{id}; POST; PUT; POST /orders/{id}/advance; DELETE | inventory.orders.view | inventory.orders.manage |
| Profit | GET /profit/summary · /profit/costing-method; PUT /profit/costing-method | inventory.profit.view | inventory.profit.manage |
This module is slated for extraction into the standalone event-driven inventory microservices project (../architecture.md §8).
Deep-dive: POST /api/inventory/transfers
Moves stock between two bin locations in one transaction
(WarehouseTransferServiceImpl.java).
Requires inventory.transfers.manage.
{
"itemId": 42,
"fromLocationId": 7,
"toLocationId": 12,
"quantity": 25,
"notes": "Rebalance for store pickup"
}
Rules (all 400 unless noted): source and destination must differ; item and
both locations must exist (404 otherwise); the source location must have a
stock record for the item and at least quantity on hand — otherwise
"Insufficient stock at source. Available: <n>". On success the source is
decremented, the destination incremented (created if absent), the transfer is
recorded with the acting user, and the response is 201 with the
WarehouseTransferDto.