Products Module
The Products module provides a full-featured product management system including catalog management, categories, suppliers, purchase orders, collections, analytics, and import/export capabilities.
Overview
Products in Commstate support:
- Multi-type products: Physical, digital, and bundle products
- Category hierarchy: Nested categories with tree view
- Supplier management: Full vendor/supplier CRUD with purchase orders
- Inventory tracking: Real-time stock management with low stock alerts
- Approval workflow: Draft → Pending Review → Approved / Rejected
- Multi-channel visibility: Control which channels see each product
- Labels & badges: New, Featured, Sale, Bestseller
- Product collections: Manual and automated groupings
- Related products: Cross-sells, up-sells, and related products
- Price history: Track all price changes over time
- Activity log: Full audit trail of product changes
- Import/Export: CSV-based bulk operations
- Analytics: Best sellers, dead stock, category breakdown, inventory value
- Catalog sync: Super admin can force products to resellers via ResellerCatalog
Navigation
Catalog & Inventory
├── Products /catalog/products
├── Categories /catalog/categories
├── Collections /catalog/collections
├── Suppliers /catalog/suppliers
├── Stock Management /catalog/stock
├── Low Stock Alerts /catalog/alerts
├── Purchase Orders /catalog/purchase-orders
├── Analytics /catalog/analytics
└── Import / Export /catalog/import
Data Model
Product
| Field | Type | Description |
|---|---|---|
| id | UUID | Primary key |
| sku | string | Stock keeping unit |
| name | string | Product name |
| brand | string | Brand / vendor as reported by the source store |
| description | text | Product description |
| short_description | text | Short description (WooCommerce excerpt) |
| price | decimal | Selling price |
| compare_at_price | decimal | Original/compare price |
| cost_price | decimal | Cost/purchase price |
| currency | char(3) | Currency the prices are expressed in |
| category_id | UUID | Category FK |
| supplier_id | UUID | Supplier FK |
| product_type | enum | physical / digital / bundle |
| status | enum | active / draft / archived |
| approval_status | enum | draft / pending_review / approved / rejected |
| track_inventory | boolean | Track stock levels |
| stock_quantity | integer | Current stock |
| stock_status | string | Store-reported availability (instock / outofstock / onbackorder) |
| low_stock_threshold | integer | Alert threshold |
| weight / dimensions | decimal | Shipping info |
| barcode / hsn_code | string | Identification |
| gst_rate | decimal | Tax rate (0, 5, 12, 18, 28) |
| tax_status / tax_class | string | How the source store classifies the product for tax |
| labels | jsonb | Product badges |
| channel_visibility | jsonb | Visible channels |
| tags | jsonb | Product tags |
| meta_title / meta_description | string | SEO fields |
| slug | string | URL slug |
| external_created_at / external_updated_at | timestamp | The source store's own timeline |
| last_synced_at | timestamp | When Commstate last reconciled against the source |
created_at / updated_at record when Commstate first saw and last touched the
row. For a synced product that is not the same as when the store created or
last edited it, which is what external_created_at / external_updated_at
carry — and what an incremental sync compares against.
Related Tables
- categories: Nested tree with parent_id self-reference, plus
external_id+source_modulefor categories synced from a store - product_variants: SKU, price, stock per variant + weight/dimensions,
plus
external_id(unique per product) andposition - product_images: URL, alt text, position, optional variant_id, plus
external_id - product_attributes: Key-value pairs from any module
- product_bundles: Bundle components with quantity and discount
- related_products: Related / cross-sell / up-sell links
- product_collections: Manual and automated groupings
- suppliers: Full vendor info with lead time and payment terms
- purchase_orders: PO lifecycle with auto-inventory adjustment
- product_activity_log: Audit trail
- product_price_history: Price change tracking
API Endpoints
Products
| Method | Endpoint | Description |
|---|---|---|
| GET | /products | List with filters |
| POST | /products | Create product |
| GET | /products/{id} | Get product detail |
| PATCH | /products/{id} | Update product |
| DELETE | /products/{id} | Delete product |
| GET | /products/search?q= | Search products |
| GET | /products/low-stock | Low stock products |
| GET | /products/statistics | Product statistics |
| PATCH | /products/{id}/inventory | Adjust inventory |
| POST | /products/bulk/inventory | Bulk adjust |
| GET | /products/{id}/activity | Activity log |
| GET | /products/{id}/price-history | Price history |
| POST | /products/{id}/duplicate | Duplicate product |
| POST | /products/{id}/submit-review | Submit for review |
| POST | /products/{id}/approve | Approve product |
| POST | /products/{id}/reject | Reject product |
Bulk Operations
| Method | Endpoint | Description |
|---|---|---|
| POST | /products/bulk/status | Bulk update status |
| POST | /products/bulk/delete | Bulk delete |
| POST | /products/bulk/category | Bulk assign category |
| POST | /products/bulk/labels | Bulk update labels |
| POST | /products/bulk/channels | Bulk update channels |
| POST | /products/bulk/approval | Bulk update approval |
Categories
| Method | Endpoint | Description |
|---|---|---|
| GET | /categories | List (supports tree view) |
| POST | /categories | Create category |
| GET | /categories/{id} | Get category |
| PATCH | /categories/{id} | Update category |
| DELETE | /categories/{id} | Delete category |
| POST | /categories/reorder | Reorder categories |
Suppliers
| Method | Endpoint | Description |
|---|---|---|
| GET | /suppliers | List suppliers |
| POST | /suppliers | Create supplier |
| GET | /suppliers/{id} | Get supplier |
| PATCH | /suppliers/{id} | Update supplier |
| DELETE | /suppliers/{id} | Delete supplier |
| GET | /suppliers/{id}/products | Supplier's products |
Collections
| Method | Endpoint | Description |
|---|---|---|
| GET | /collections | List collections |
| POST | /collections | Create collection |
| GET | /collections/{id} | Get collection |
| PATCH | /collections/{id} | Update collection |
| DELETE | /collections/{id} | Delete collection |
| POST | /collections/{id}/products | Add products |
| DELETE | /collections/{id}/products | Remove products |
Purchase Orders
| Method | Endpoint | Description |
|---|---|---|
| GET | /purchase-orders | List POs |
| POST | /purchase-orders | Create PO |
| GET | /purchase-orders/{id} | Get PO |
| PATCH | /purchase-orders/{id} | Update PO |
| DELETE | /purchase-orders/{id} | Delete PO |
| POST | /purchase-orders/{id}/submit | Submit PO |
| POST | /purchase-orders/{id}/confirm | Confirm PO |
| POST | /purchase-orders/{id}/receive | Receive items |
| POST | /purchase-orders/{id}/cancel | Cancel PO |
| GET | /purchase-orders/suggestions | Reorder suggestions |
Analytics
| Method | Endpoint | Description |
|---|---|---|
| GET | /products/analytics/overview | Overview stats |
| GET | /products/analytics/best-sellers | Top sellers |
| GET | /products/analytics/dead-stock | Dead stock |
| GET | /products/analytics/categories | Category breakdown |
| GET | /products/analytics/inventory-value | Inventory value |
| GET | /products/analytics/price-changes | Price change report |
Import/Export
| Method | Endpoint | Description |
|---|---|---|
| POST | /products/import | Upload CSV |
| GET | /products/import/template | Download template |
| POST | /products/export | Export products |
| GET | /products/export/download/{file} | Download export |
Approval Workflow
Products support a 4-state approval workflow:
- Draft - Initial state, product not visible
- Pending Review - Submitted for review
- Approved - Approved by reviewer
- Rejected - Rejected with notes
Only draft or rejected products can be submitted for review. Reviewer ID and notes are tracked.
Catalog Products
Products with source_module = 'reseller-catalog' and external_id are synced from the master catalog. These products:
- Cannot be deleted
- Only status, labels, channels, and tags can be modified
- Show a "Catalog Product" lock badge in the UI
External Product Sources
A product that originated outside Commstate is identified by the pair
(source_module, external_id). The products table carries a unique index on
that pair, so it is the mechanism that prevents duplicates when the same
product arrives twice — once from a webhook and once from a manual sync.
Canonical source values
| Store | source_module |
|---|---|
| Shopify | shopify |
| WooCommerce | woocommerce |
| Reseller master catalog | reseller-catalog |
| CSV import | import |
| Created in Commstate | manual |
The store values are the provider identifiers — ShopifyProvider::IDENTIFIER
and WooCommerceProvider::IDENTIFIER — which is what the order ingest
pipeline already writes to orders.source_module. Using the same values for
products means a product and an order from the same store agree on where they
came from.
source_module is not the module alias. store-shopify and
store-woocommerce name an installable module in the central catalog —
they appear in module.json, ModuleGuard, and module_installations.
shopify and woocommerce name the system a record was synced from.
Never use one where the other belongs.
The canonical list lives in config/products.php:
'external_sources' => [
'shopify',
'woocommerce',
'reseller-catalog',
],
Product webhooks
A connected store keeps the catalog current by pushing product events. They arrive at the same ingest endpoint as orders and are routed by topic — see Order Ingest Pipeline for the full pipeline.
POST /api/webhooks/{tenant}/orders/{provider}/{topic}
| Provider | Create | Update | Delete | Restore |
|---|---|---|---|---|
| Shopify | products/create |
products/update |
products/delete |
— |
| WooCommerce | product.created |
product.updated |
product.deleted |
product.restored |
What reaches the catalog:
| Topic kind | Call | Effect |
|---|---|---|
| create / update / restore | ProductIngestService::upsert() |
Create or update, matched on (source_module, external_id) |
| delete | ProductIngestService::delete() |
Applies products.delete_policy — archive by default |
Three properties are worth knowing about:
- Registration and cleanup are one operation. Product topics are registered by the same pass that registers order topics, at the same delivery URL, and disconnect deletes everything pointing back at Commstate. A product webhook cannot outlive the store connection.
- Deliveries are idempotent. The provider's delivery id
(
X-Shopify-Webhook-Id,X-WC-Webhook-Delivery-ID) deduplicates retries; a redelivery answers200 duplicateand is not re-processed. A genuine second edit is not a duplicate. - Thin payloads never overwrite good data. A delete carries only an id,
and
IngestProductDTOdistinguishes "the store did not mention this field" from "the store cleared this field", so an archive leaves the name, price and stock intact.
Webhooks are the incremental path; the full catalog sync is what reconciles
anything a webhook could not carry. WooCommerce in particular does not inline
variations, so a product webhook deliberately sends no variants key at all —
the ingest layer then leaves the variants it already holds alone rather than
deleting rows that order_items.variant_id points at.
Scheduled and incremental sync
Three things keep a catalog current, in order of how often they run:
| Path | When | Reads |
|---|---|---|
| Webhooks | As the merchant edits | The one product that changed |
| Scheduled sweep | Every store's sync_interval_minutes |
Everything modified since the watermark |
| Manual sync | The Sync Products button | The whole catalog, unless "Changes only" is ticked |
The sweep is a repair mechanism rather than the primary path — webhooks carry changes as they happen, so a healthy store's sweep normally reads nothing. It exists for what a webhook could not deliver: a topic that was never registered, a delivery dropped while the app was down, an edit made during a deploy.
php artisan products:sync # every store, incremental
php artisan products:sync woocommerce --full # one store, whole catalog
php artisan products:sync --due # what the scheduler runs
php artisan products:sync --tenant=acme # one tenant
The scheduler runs products:sync --due every five minutes. Each store's own
interval is the real cadence, so five minutes is the resolution rather than the
frequency — a 15-minute interval fires on the first sweep after 15 minutes have
elapsed.
The watermark
An incremental run asks the store for everything modified since
MAX(products.external_updated_at) for that source_module, minus an overlap.
Derived from the rows actually held rather than from a bookmark kept beside
them, and that difference matters: a bookmark advances when a run claims to
have finished, so a run that died half way leaves it pointing past products
that were never written. A MAX() over what landed cannot fail in that
direction — a partial run simply leaves the bound lower and the next run picks
up the rest.
The overlap is not optional. A product edited while the previous run was walking the catalog carries a timestamp older than the newest that run recorded, so a filter set to exactly the maximum steps over it permanently. Ten minutes of overlap re-reads a handful of products — which upsert to the same rows — and closes the window. It also absorbs clock skew from a WordPress host nobody controls.
PRODUCTS_SYNC_OVERLAP_MINUTES=10
PRODUCTS_SYNC_INTERVAL_MINUTES=15 # fallback when a store sets none
PRODUCTS_SYNC_SCHEDULED=true # deployment-wide off switch
PRODUCTS_SYNC_BACKFILL_ON_CONNECT=true
A store with no products yet has no watermark, so an incremental request
degrades to a full read — and reports itself as full, because "incremental"
next to a count covering the whole catalog reads as a broken filter.
How each store is filtered
| Store | Parameter | Applied to |
|---|---|---|
| Shopify | updated_at_min |
The first page only — Shopify rejects any request carrying a filter alongside page_info, and the cursor already encodes the query that produced it |
| WooCommerce | modified_after + dates_are_gmt=true |
Every page — offset pagination carries no query, so a page that drops the filter silently widens the run |
dates_are_gmt is load-bearing. Without it WooCommerce reads the bound in the
WordPress site's timezone, so a store set to IST would skip the last five and a
half hours of edits on every run, indefinitely, while reporting success.
Per-store settings
auto_sync and sync_interval_minutes resolve module settings → store
config → platform default, so an operator's choice beats a deployment's and a
deployment's beats the platform's. PRODUCTS_SYNC_SCHEDULED=false overrides
everything, for a deployment that must make no outbound calls to customer
stores.
The operator layer is module_installations.settings — the row each store's
settings page writes through PUT /modules/{alias}/settings, and the same
place its module.json settings_schema describes. That is the only settings
store any UI writes a sync preference into; reading anywhere else is how you
ship a toggle that saves cleanly and changes nothing.
sync_interval_minutes is the canonical key. Installations saved before the
sweep existed carry sync_interval, which is still honoured so an upgrade does
not silently discard a saved preference.
A run is never started on top of one already in flight, and a failed run still counts as having run — otherwise a store with expired credentials would retry at the sweep's pace rather than its own interval.
On connect
Connecting a store queues one full catalog read. Orders already backfill on
connect; without the catalog equivalent the scheduled sweep would have no
watermark to work from, and the catalog would stay empty until somebody found
the Sync button. Set PRODUCTS_SYNC_BACKFILL_ON_CONNECT=false to opt out.
Delete policy
When a product is deleted in the upstream store, products.delete_policy
decides what happens locally:
| Value | Behaviour |
|---|---|
archive (default) |
Set status to archived, keep the row and its external_id, publish product.deleted |
soft_delete |
Soft-delete the row (Product uses SoftDeletes) |
ignore |
Do nothing |
PRODUCTS_DELETE_POLICY=archive
archive is the default for three reasons:
- It mirrors Orders.
WooCommerceProvider::mapDeletedOrder()maps a WooCommerce trash to statuscancelledrather than removing the order — an upstream delete becomes a terminal status change, never a row removal. order_itemsreferenceproduct_idandvariant_idwithout a foreign key, so removing a product degrades historical order detail.- Upstream deletes are frequently reversible. WooCommerce trashes and
restores; Shopify's
products/deletecarries nothing but an id. An archived row re-activates on the next update, whereas a soft-deleted one still occupies the unique(source_module, external_id)index — Postgres enforces uniqueness regardless ofdeleted_at— so a delete-then-recreate upstream would collide.
Backend Services
The Products module uses a service-layer architecture. All business logic lives in services, not controllers.
| Service | Responsibility |
|---|---|
ProductService |
Product CRUD, slug generation, image/variant/bundle sync, related products, statistics |
InventoryService |
Stock adjustments, low-stock queries, bulk inventory updates, reorder suggestions |
ProductActivityService |
Audit trail — logs create, update, status change, inventory adjustment, price change, approval actions |
PriceHistoryService |
Records price/cost changes with old/new values, provides paginated history per product/variant |
PurchaseOrderService |
PO creation, PO number generation, item receiving with auto-inventory adjustment |
ProductAnalyticsService |
Overview stats, best sellers, dead stock, category breakdown, inventory value, price change reports |
ProductImportService |
CSV parsing, row validation, bulk product creation via queue |
ProductExportService |
Cursor-based CSV export with filters |
CatalogSyncService |
Syncs mandatory master products to tenant DBs (in ResellerCatalog module) |
Activity Logging
Every significant product action is automatically logged:
create → "Product created"
update → "Product updated" + changed fields
status → "Status changed from draft to active"
inventory → "Inventory adjusted by +50 (restock)"
price → "Price changed from ₹999 to ₹899"
approval → "Submitted for review" / "Approved" / "Rejected: reason"
Logs are stored in product_activity_log with actor_id, action, description, and JSON changes.
Price History
Price changes are tracked in product_price_history for these fields:
price(selling price)compare_at_price(compare/MRP)cost_price(purchase cost)
Each record stores: product_id, variant_id (optional), field, old_value, new_value, changed_by, created_at.
Product Bundles
Bundle products (product_type = 'bundle') contain component products:
Bundle: "Audio Starter Kit" (₹4,999)
├── Wireless Headphones (qty: 1, 10% discount)
├── Portable Speaker (qty: 1, 5% discount)
└── Audio Cable (qty: 2, 0% discount)
| Method | Endpoint | Description |
|---|---|---|
| GET | /products/{id}/bundles | Get bundle components |
| PUT | /products/{id}/bundles | Set bundle components |
| GET | /products/{id}/bundle-price | Calculate bundle price from components |
Related Products
Products can be linked as related, cross-sell, or up-sell:
| Method | Endpoint | Description |
|---|---|---|
| GET | /products/{id}/related | Get related products |
| PUT | /products/{id}/related | Set related products |
| GET | /products/{id}/cross-sells | Get cross-sell products |
| PUT | /products/{id}/cross-sells | Set cross-sell products |
| GET | /products/{id}/up-sells | Get up-sell products |
| PUT | /products/{id}/up-sells | Set up-sell products |
Purchase Order Lifecycle
Purchase orders follow a state machine:
draft → submitted → confirmed → partially_received → received
└→ cancelled
- Draft: PO created, items can be edited
- Submitted: Sent to supplier, awaiting confirmation
- Confirmed: Supplier confirmed, awaiting delivery
- Partially Received: Some items received, inventory auto-adjusted
- Received: All items received, PO complete
- Cancelled: PO cancelled (only from submitted state)
When items are received via POST /purchase-orders/{id}/receive, product inventory is automatically adjusted upward by the received quantity.
Reorder Suggestions
GET /purchase-orders/suggestions returns products below their low_stock_threshold, grouped by supplier, with suggested order quantities.
Category Tree
Categories support unlimited nesting via parent_id self-reference.
GET /categories?tree=1returns root categories with recursively nestedchildrenPOST /categories/reorderaccepts[{ id, position, parent_id }]for drag-and-drop reordering- Deleting a category promotes its children to the parent level
- Categories with assigned products cannot be deleted
Digital Products
Products with product_type = 'digital' support:
digital_file_url— download URLdigital_file_name— display filenamedownload_limit— max downloads per order (null = unlimited)
Labels & Channel Visibility
Labels (jsonb array): ["new", "featured", "sale", "bestseller"]
- Used for filtering and badge display in the UI
Channel Visibility (jsonb array): ["manual", "shopify", "woocommerce", "storefront"]
- Controls which sales channels can see/list the product
Frontend Hooks
| Hook | Description |
|---|---|
useProducts() |
CRUD, filters, bulk ops, approval workflow |
useProduct(id) |
Single product, activity, price history |
useLowStockProducts() |
Low stock product list |
useCategories() |
Category CRUD and tree |
useSuppliers() |
Supplier CRUD |
useCollections() |
Collection CRUD |
usePurchaseOrders() |
PO lifecycle |
useProductAnalytics() |
Analytics data |
useProductImport() |
CSV import/export |
Frontend Pages
| Page | Route | Description |
|---|---|---|
| Products List | /catalog/products |
Filterable table with stats cards, bulk actions, approval workflow |
| Product Create | /catalog/products/new |
Multi-tab form: basic, pricing, inventory, images, variants, digital, SEO |
| Product Detail | /catalog/products/[id] |
Tabbed view: overview, variants, images, bundle, related, activity, price history |
| Product Edit | /catalog/products/[id]/edit |
Same form as create, pre-populated. Restricted editing for catalog products |
| Categories | /catalog/categories |
Tree view with expand/collapse, inline create/edit/delete |
| Collections | /catalog/collections |
Card grid with create/edit dialogs |
| Suppliers | /catalog/suppliers |
Table with full CRUD dialog (16 fields) |
| Stock Management | /catalog/stock |
Inventory table with quick +/- adjust and bulk adjust |
| Low Stock Alerts | /catalog/alerts |
Urgency-sorted list with deficit column and quick PO creation |
| Purchase Orders | /catalog/purchase-orders |
PO table with status filters and badges |
| PO Create | /catalog/purchase-orders/new |
Supplier select, items table, auto-fill from suggestions |
| PO Detail | /catalog/purchase-orders/[id] |
Order info, items with ordered vs received, receive dialog |
| Analytics | /catalog/analytics |
Overview cards, best sellers, dead stock, category breakdown |
| Import/Export | /catalog/import |
CSV upload with template download, export with filters |
Next Steps
- Orders Module - Order processing
- Customers Module - Customer management