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_module for categories synced from a store
  • product_variants: SKU, price, stock per variant + weight/dimensions, plus external_id (unique per product) and position
  • 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:

  1. Draft - Initial state, product not visible
  2. Pending Review - Submitted for review
  3. Approved - Approved by reviewer
  4. 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 answers 200 duplicate and 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 IngestProductDTO distinguishes "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 status cancelled rather than removing the order — an upstream delete becomes a terminal status change, never a row removal.
  • order_items reference product_id and variant_id without a foreign key, so removing a product degrades historical order detail.
  • Upstream deletes are frequently reversible. WooCommerce trashes and restores; Shopify's products/delete carries 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 of deleted_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=1 returns root categories with recursively nested children
  • POST /categories/reorder accepts [{ 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 URL
  • digital_file_name — display filename
  • download_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