Skip to main content

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

MethodPathAuthorityPurpose
GET/api/portal/catalog/itemsPORTAL_CUSTOMERBrowse sellable items (paged)
GET/api/portal/catalog/items/{itemId}PORTAL_CUSTOMERItem detail
GET/api/portal/catalog/categoriesPORTAL_CUSTOMERCategory list
POST/api/portal/ordersPORTAL_CUSTOMERPlace an order (deep-dive below)
GET/api/portal/ordersPORTAL_CUSTOMEROwn orders (raw Spring Page JSON, newest first)
GET/api/portal/orders/{orderId}PORTAL_CUSTOMEROwn single order
DELETE/api/portal/orders/{orderId}PORTAL_CUSTOMERCancel own order (204)
GET/api/portal/profilePORTAL_CUSTOMEROwn profile
PATCH/api/portal/profilePORTAL_CUSTOMERUpdate profile
POST/api/portal/profile/change-passwordPORTAL_CUSTOMERChange password (current password required)
GET/api/portal/admin/ordersERP: inventory.portal.manageAll customer orders (paged)
GET/api/portal/admin/orders/{orderId}ERP: inventory.portal.manageOne customer order
PUT/api/portal/admin/orders/{orderId}/shipmentERP: inventory.portal.manageUpdate 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

MethodPathPermissionPurpose
GET/api/crm/leads · /{id}crm.viewPaged list / one lead
POST · PUT /{id} · PATCH /{id}/status · DELETE /{id}/api/crm/leads...crm.leads.manageLead lifecycle
POST/api/crm/leads/{id}/convertcrm.deals.manageConvert a lead into a deal
GET/api/crm/contacts · /{id} · /by-org/{orgId}crm.viewContacts (paged / one / per org)
POST · PUT /{id} · DELETE /{id}/api/crm/contacts...crm.contacts.manageContact CRUD
GET/api/crm/organizations · /{id}crm.viewOrganizations
POST · PUT /{id} · DELETE /{id}/api/crm/organizations...crm.contacts.manageOrganization CRUD
GET/api/crm/deals · /pipeline · /{id} · /{id}/activities · /{id}/notescrm.viewDeals list / kanban / detail
GET/api/crm/deals/analyticscrm.reports.viewFunnel & win-rate aggregates
POST · PUT /{id} · DELETE /{id}/api/crm/deals...crm.deals.manageDeal CRUD
PATCH/api/crm/deals/{id}/stagecrm.deals.manageMove a deal through the pipeline (deep-dive)
POST/api/crm/deals/{id}/notescrm.activities.manageAdd a note
GET/api/crm/activities · /{id}crm.viewActivities/tasks
POST · PATCH /{id}/complete · DELETE /{id}/api/crm/activities...crm.activities.manageSchedule / complete / remove
GET/api/crm/settingscrm.settings.manage or crm.viewCRM behavior settings (singleton)
PUT/api/crm/settingscrm.settings.manageUpdate settings (versioned, lockable, field-diff audited)
GET/api/crm/settings/changescrm.settings.manageSettings change log, newest first
GET/api/crm/picklistscrm.settings.manage or crm.viewPaged picklist items (kind = DEAL_STAGE · LEAD_SOURCE · ACTIVITY_TYPE)
POST · PUT /{id} · DELETE /{id}/api/crm/picklists...crm.settings.managePicklist 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

AreaEndpoints (under /api/inventory)view permissionmanage permission
DashboardGET /dashboardinventory.dashboard.view
CategoriesGET · POST · PUT/DELETE /categories/{id}inventory.items.viewinventory.items.manage
ItemsGET /items (paged/filtered) · /items/{id} · /items/sku/{sku} · /items/{id}/barcode; POST/PUT/DELETEinventory.items.viewinventory.items.manage
StockGET /stock · /stock/low · /stock/transactions; POST /stock/transactions (adjustments/receipts/issues)inventory.stock.viewinventory.stock.manage
SuppliersGET (paged) · /{id}; POST/PUT/DELETEinventory.suppliers.viewinventory.suppliers.manage
Warehouses & bin locationsGET /warehouses · /locations · /warehouses/{id}/locations · /warehouses/{id}/location-tree · /warehouses/{id}/stock-by-location; POST/PUT/DELETE both, PATCH /locations/{id}/deactivateinventory.locations.viewinventory.locations.manage
Receiving (POs)GET (paged) · /{id}; POST; PATCH /receiving/{id}/complete · /cancelinventory.receiving.viewinventory.receiving.manage
ReportsGET /reports/ledger · /reports/valuation · /reports/movement-summaryinventory.reports.view
Serials & lotsGET /serials · /lots; POST both; PATCH /serials/{id}/statusinventory.serials.viewinventory.serials.manage
TransfersGET (paged); POST (deep-dive)inventory.transfers.viewinventory.transfers.manage
PricesGET /prices · /items/{itemId}/prices · /prices/history; POST/PUT/DELETE /prices...inventory.prices.viewinventory.prices.manage
StoresGET (paged) · /{id} · /{id}/inventory; POST/PUT/DELETE; PUT /{id}/inventory/{itemId}inventory.stores.viewinventory.stores.manage
Sales ordersGET (paged) · /orders/{id}; POST; PUT; POST /orders/{id}/advance; DELETEinventory.orders.viewinventory.orders.manage
ProfitGET /profit/summary · /profit/costing-method; PUT /profit/costing-methodinventory.profit.viewinventory.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.