PRD: Menu organizer & channel visibility
| Module | Products | PRD ID | PRD-SCH-001 |
| Status | Ready to dev | FEAT | SCH |
| Epic | — | Plane | BANA-1552 |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/commerce · @nx/core · @nx/search | URD | SCH |
| Surface | Client · Owner/MgrSale · POS | ||
| Owner | Phát Nguyễn | ||
What menu organizer & channel visibility is
The catalogue (PRD-PRD-001) defines what a merchant sells, but not where each product is offered. This increment organizes what a merchant sells into sale channels - named, multilingual, hierarchical menu surfaces (counter, takeaway, delivery, kiosk) - and controls which products appear in each. Assigning a product to a channel is the visibility switch, and every channel and product list is served already filtered to the caller's access, so a device only ever sees its own merchant's menu.
Two gaps channels close
A merchant rarely sells the same list everywhere: dine-in differs from delivery, a kiosk shows a subset, a seasonal pop-up needs its own surface. Without channels, every device sees one flat catalogue and the merchant can't turn a product on for delivery while keeping it off the counter.
Two gaps follow. A channel must know where its stock comes from, with a sensible default when several locations qualify. And the menu a device receives must be scoped to who is asking - an owner of two merchants sees those two merchants' channels, an employee only their assigned merchant, an admin everything - without anyone hand-filtering.
One café, two channels
An owner with a café creates a Delivery channel and a Counter channel. New products go to Counter only; a subset is also assigned to Delivery, so the courier app shows a smaller menu. Delivery's chain lists the back-merchant location first - but today its orders source stock from the location recorded directly on the channel, not from the head of the priority chain just saved; if no location is recorded directly, it falls back to the merchant default. When the owner tries to deactivate Counter while a table still has an open order, the system refuses until that order closes.
Where this goes wrong
The priority-ordered inventory chain isn't read at runtime
A channel can record a whole priority-ordered inventory-location chain - and that makes it easy to assume the system walks the chain to find a stock source. It doesn't: fulfillment today only looks at the single location recorded directly on the channel; if that's empty, it falls straight back to the merchant's default location. The priority chain is stored and reconciled correctly, but it is never read at fulfillment.
Don't read FR-009's 🔶 as "the fallback logic is nearly done" - the part that's done is storage; the part that isn't built is the walk itself.
1. Goals & Non-Goals
Goals
- Let an owner create sale channels in a merchant - multilingual name/description, a system identifier, a per-merchant-unique slug.
- Make product-to-channel assignment the visibility control: a product appears in a channel only when assigned to it.
- Assign products to channels inside the atomic product create, so a product ships visible on day one.
- Give each channel a lifecycle status (activated / deactivated / archived) and refuse deactivation while active orders remain.
- Record a priority-ordered inventory-location chain per channel (lowest priority = intended default). Note: stored and reconciled, but not yet consumed at fulfillment - stock today comes from the location recorded on the channel, falling back to the merchant default.
- Support a parent-child channel hierarchy for grouping menu surfaces. Note: the parent link exists and is exposed, but it has no cycle / self-parent / cross-merchant guards yet (unlike categories).
- Serve every channel and product list filtered by the caller's access grants; admins bypass the filter.
Non-Goals
- Product, variant, and identifier definition - see PRD-PRD-001.
- Per-channel pricing tiers - handled by channel-context fares (FAR).
- Stock levels and movement - owned by Inventory; a channel only references a location, it never holds stock.
- Walking the inventory chain as a multi-step fallback at fulfillment - the priority chain isn't read at runtime at all today; stock comes from the location recorded on the channel with a merchant-default fallback, and the ordered chain is kept for future use.
- Order processing and checkout through a channel - owned by Orders.
2. Success Metrics
| Metric | Target / signal |
|---|---|
| Visibility control | A product appears in a channel only when assigned; unassigning removes it |
| Scoped serving | A caller's channel/product list returns only their merchants' rows; admins see all |
| Deactivation safety | No channel with active orders can be deactivated |
| Stock-source clarity | Every channel resolves a single default location, no ambiguity (today from the channel's recorded location + merchant default, not yet the priority chain) |
| Slug integrity | No two undeleted channels in a merchant share a slug, regardless of lifecycle status |
3. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner / Manager | Build per-surface menus, turn products on/off per channel, set the channel's stock source |
| Cashier / Device | Receive the menu scoped to the current merchant and channel |
| Admin / Super Admin | Inspect channels across all merchants without role filtering |
Core scenario: an owner with a café creates a Delivery channel and a Counter channel. New products go to Counter only; a subset is also assigned to Delivery, so the courier app shows a smaller menu. Delivery's chain lists the back-merchant location first, so its orders auto-source from there. When the owner tries to deactivate Counter while a table still has an open order, the system refuses until that order closes.
4. User Stories
| # | As a | I want | So that |
|---|---|---|---|
| 01 | owner | to create a sale channel per selling surface | each device shows the right menu |
| 02 | owner | to assign a product to the channels it belongs in | visibility is a single switch |
| 03 | owner | to set a channel's inventory-location chain | its orders auto-source from the right place |
| 04 | owner | to not accidentally deactivate a channel with live orders | I never strand an open ticket |
| 05 | employee | to only see channels and products for merchants I'm assigned to | my menu is never another merchant's |
| 06 | admin | to read every merchant's channels without role filtering | I can support any tenant |
5. Functional Requirements
| # | Requirement | Status | URD ref |
|---|---|---|---|
FR-001 | Owner can create sale channels within a merchant, each with a multilingual name/description and an auto-generated system identifier. | ✅ | URD-SCH-001 |
FR-002 | A channel slug is unique within a merchant. • The constraint today is enforced across every undeleted channel regardless of lifecycle status. • A Deactivated or Archived channel (as long as it isn't deleted) still holds its slug, not only Activated ones. | 🔶 | URD-SCH-002 |
FR-003 | A channel has a lifecycle status: activated, deactivated, archived. | ✅ | URD-SCH-003 |
FR-004 | Owner can update a channel. An empty update (no fields) is refused. | ✅ | URD-SCH-004 |
FR-005 | Deactivating a channel is refused while it still has active sale orders. | ✅ | URD-SCH-005 |
FR-006 | Channels support a parent-child hierarchy. • The parent link exists and is exposed. • No cycle, self-parent, or cross-merchant guard is enforced today, unlike categories. | 🔶 | URD-SCH-006 |
FR-007 | A product is assigned to one or more channels. The assignment controls whether it appears in that channel. | ✅ | URD-SCH-007 · URD-PRD-011 |
FR-008 | Product-to-channel assignments are written as part of the atomic product create. | ✅ | URD-SCH-008 |
FR-009 | A channel records a priority-ordered inventory-location chain (lowest priority = intended default). • Reconciled on update, with each location appearing at most once. • This chain isn't read at fulfillment today. • An order sources stock from a single location recorded directly on the channel, falling back to the merchant's default location when none is recorded. | 🔶 | URD-SCH-009 |
FR-010 | Deleting a channel unlinks its products and reversibly deletes the channel. • The stock-source chain's links aren't cleaned up. • Child channels of the deleted channel aren't handled. | 🔶 | URD-SCH-010 |
FR-011 | Channel and product lists/counts are served filtered by the caller's access grants. Admins bypass the filter. | ✅ | URD-SCH-011 · URD-ACC-001..004 |
FR-012 | Channels and products are searchable, scoped to the caller's merchants. | ✅ | URD-SCH-012 |
FR-013 | Creating a merchant's first channel completes the merchant's channel onboarding step. | ✅ | URD-SCH-013 |
5.1 Acceptance Criteria
- Create a channel with a bilingual name/description and no slug entered → the channel is created with an auto-generated slug and system identifier.
- Create channel A with the slug "delivery", deactivate channel A, then create channel B with the same slug "delivery" in the same merchant → refused, because channel A is deactivated but not deleted, so it still holds that slug; delete channel A outright and create channel B with the same slug again → succeeds.
- Create a new channel → its default status is Activated; move it to Deactivated then to Archived → each transition follows the lifecycle correctly.
- Send an update request for a channel with no fields at all → refused.
- A channel still has one order in processing; try deactivating it → refused; close or cancel that order, then retry → deactivation succeeds.
- Create a child channel under an existing parent channel → the parent-child link is stored and reads back correctly; point a channel's parent back at itself, or form a closed loop across several channels → the system does not block this today, the operation still saves.
- Assign a product to the Delivery channel → the product appears in the Delivery channel; unassign it → the product disappears from that channel while its other channels are untouched.
- Create a new product with assignments to two channels in the same call → the product and both channel assignments are written in one operation; a mid-way failure rolls back everything, leaving neither the product nor any assignment half-saved.
- Set a channel's inventory-location chain to [Warehouse A, Warehouse B] → two rows are written in that priority order; update the chain to [Warehouse B, Warehouse C] → Warehouse A is unlinked, Warehouse B keeps its row but gets a new priority, Warehouse C is newly added. Create a sale order through that channel → stock is sourced from the location recorded directly on the channel, not from the head of the priority chain just saved; a channel with no location recorded directly → stock falls back to the merchant's default location.
- Delete a channel that has products assigned and an inventory-location chain → its product assignments are unlinked, the channel is soft-deleted (reversible); the rows in that channel's inventory-location chain remain untouched, and any child channels of the deleted channel keep their old parent link.
- Merchant X's owner logs in and lists channels → only merchant X's channels come back; an admin lists channels → every merchant's channels come back.
- Search for a channel or product by keyword using merchant Y's owner account → results are scoped to merchant Y's merchants.
- A merchant that has never created a channel → its "Sale channels" onboarding step reads as incomplete; create that merchant's first channel → the step reads as complete as soon as it's read again.
6. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Atomicity | Channel create with its inventory chain is one operation; product create writes its channel assignments in the same atomic step - partial failure rolls back fully |
| Tenancy & access | All operations confined to the user's own merchant and gated by sale-channel permissions |
| Access-scoped reads | Lists, counts, and search resolve the caller's merchant grants and filter to them; admins bypass |
| Uniqueness | Channel slug unique per merchant among undeleted rows, regardless of lifecycle status; a location appears at most once per channel |
| Reversible delete | Channels and assignments are reversibly deleted; nothing is physically removed |
| Languages | Channel name and description are bilingual (English and Vietnamese) |
7. UX & Flows
The channel surface lets the owner name a channel, set its slug, order its inventory-location chain (top = default source), and place it under a parent. The product surface lets the owner pick the channels a product appears in. Devices request the menu and get back only the channels and products their access grants allow.
8. Data & Domain
| Concept | Role |
|---|---|
| Sale channel | A menu surface within a merchant - name, slug, status, optional parent |
| Channel-product link | The visibility link - a product appears in a channel only while this link is live |
| Channel inventory location | One location in the channel's priority-ordered stock-source chain (lowest priority = intended default); recorded but not yet read at fulfillment |
Conceptual only - full detail and invariants live in the commerce domain model. Links are soft references; integrity is enforced in the channel and product logic, not by database foreign keys.
9. Dependencies & Assumptions
Depends on
| # | Feature | What it depends on |
|---|---|---|
| 01 | Product catalogue (PRD-PRD-001, PRD) | Products are what a channel makes visible. |
| 02 | Merchant (Commerce) | Channels are confined to a merchant; the first channel completes a merchant onboarding step. |
| 03 | Inventory (Inventory) | The chain references inventory locations; a location must exist before it is added. |
| 04 | Orders (Orders) | The deactivation guard reads a channel's active orders. |
| 05 | Search | Channels and products are indexed and served scoped to the caller's merchants. |
Assumptions
| # | Assumption | What breaks if wrong |
|---|---|---|
| 01 | A merchant exists and the caller's access grants resolve to the merchants they may see. | Missing grants or a nonexistent merchant blocks a channel create or list right at the validation step, before it reaches the data layer. |
| 02 | Inventory locations referenced by a channel's chain belong to the same merchant. | A location belonging to a different merchant slipping into the chain could point a channel's stock source at data it doesn't own. |
10. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P2 - SCH in the URD feature catalog |
| Rollout | All merchants; no feature flag |
| Migration | None - channels and assignments run on the existing catalogue foundation |
| Launch criteria | A product appears in a channel only when assigned; channel create records its inventory-location chain (runtime default resolved from the channel's recorded location + merchant default); deactivation with active orders is refused; every channel/product list is filtered to the caller's merchants; admins bypass |
| Monitoring | Deactivation-refusal rate, channels without a default location, cross-merchant list-scope checks |
References
- URD: Product - SCH · PRD · ACC
- Related PRDs: Product catalogue, variants & identifiers · Fare system & pricing
- Module: Product - URD
- Developer: @nx/commerce · @nx/core · @nx/search
Risks & Open Questions
| # | Risk / question | Mitigation / status |
|---|---|---|
| 01 | A channel left without a stock source | The chain's lowest-priority location is the intended default and input duplicates collapse to the first occurrence; at runtime, though, stock comes from the location recorded on the channel with a merchant-default fallback (the chain isn't read yet) |
| 02 | Deactivating a channel mid-service | Refused while active orders remain; the owner must close or cancel them first |
| 03 | Cross-merchant menu leakage | Every list/count/search resolves the caller's grants and filters to them; only admins bypass |
| 04 | Slug collision within a merchant | Refused before saving; slug is unique per merchant among undeleted channels regardless of lifecycle status - a Deactivated or Archived channel still holds its slug |
| 05 | Inventory chain walked as a fallback | Not today - the priority chain isn't read at runtime at all; stock comes from the location recorded on the channel with a merchant-default fallback, and the ordered chain is kept for future fulfillment logic |
FAQ
| # | Question | Answer |
|---|---|---|
| 01 | How do I hide a product from one channel but not another? | Assign it only to the channels it should appear in - the assignment is the visibility switch. Unassigning removes it from that channel and leaves the others untouched. |
| 02 | What is a channel's "default" inventory location? | Conceptually, the first one in its chain - the lowest priority value, reorderable on update. Today, though, the chain isn't read at fulfillment: orders source stock from the location recorded on the channel, falling back to the merchant default; the recorded chain is reserved for future fulfillment logic. |
| 03 | Why can't I deactivate a channel? | It still has active orders. Close or cancel them first, then deactivate. |
| 04 | Does an admin see every merchant's channels? | Yes - admins and super-admins bypass role filtering; everyone else sees only the channels of merchants they're granted. |
| 05 | Can channels be nested? | Yes - a channel can have a parent, so menu surfaces can be grouped. |