# Product Visibility for Advisor Case Assignment --- ## CRED-6177 — 2026-09-04 ### Overview Product visibility lets authorised users remove a merchant product from the advisor-facing product selectors without deleting the product, its offer links, or its historical case data. `products.product_visibility = true` means the product is visible and can be newly assigned; `false` means it is hidden from new assignments. A product already assigned to a case remains available and selected when that case is reopened or edited, even after the product is hidden. The feature applies to the legacy admin product-management and case-assignment flows only. Merchant PWA, Consumer API, `cf_admin_2.0`, and the legacy `resources/views/admin/consumer-application/edit.blade.php` are outside this ticket's scope. --- ### Business Rules * **Visible product:** A product with `product_visibility = true` is returned in advisor product selectors and may be assigned to a case. * **Hidden product:** A product with `product_visibility = false` is excluded from new advisor assignments. It remains in the merchant product-management list and retains all existing offer links and case references. * **Existing case preservation:** If a case already stores a hidden product ID in `consumer_application_elements.product_name`, that product remains in the selector and selected for that case. The system does not remove it when the case is edited. * **New-assignment protection:** The server rejects a request that attempts to add a hidden product ID which was not already assigned to the case. This protects against stale browser pages and direct/crafted requests, not only the UI selector. * **Offer eligibility remains unchanged:** A product must still satisfy the existing `productOffer` relationship requirement before it is returned in the normal active-case selector. Product visibility is an additional condition, not a replacement for offer-linking, offer status, or soft-delete logic. * **Anchor copying:** Copying products from an anchor merchant copies each source product's visibility value. * **Historical product data:** Visibility does not affect product lookups for existing cases, calculations, documents, collections, or reports. The feature does not add a global Eloquent scope to `Products` or alter the `Merchant::products()` / `ConsumerApplication::products()` relationships. #### Role-Based Access Control * The `Visible` field in the Add/Update Product modal is displayed only to users who have `Merchant_Update|1-0-2`. * `MerchantController@addProduct` also checks `Merchant_Update|1-0-2` on the server before it writes a requested `product_visibility` value. * A user without this permission can still use existing product-management actions allowed by their existing permissions, but cannot supply a visibility value through this controller path. A new product then uses the database default of visible. --- ### Technical Flow #### 1. Manage a product's visibility The merchant product-management page lists every product, including hidden ones. It adds a `Visible` column and visually mutes hidden product rows. The shared Add/Update Product modal uses a Yes/No `product_visibility` field. JavaScript sets new products to `Yes` and loads the saved value when opening Update Product. `MerchantController@addProduct` creates or retrieves the `Products` record as before. When the current session has `Merchant_Update|1-0-2` and the request contains `product_visibility`, it saves that value. Product visibility is therefore not controlled exclusively in the browser. #### 2. Return products for advisor assignment Both legacy offer endpoints continue to load the merchant's products and linked offers. After their existing `whereHas('productOffer')` / finalised-case soft-delete handling, they restrict the product result to: ```text product_visibility = true OR product ID is already present in request.product_id[] ``` `request.product_id[]` represents the existing case selection supplied by the admin case-edit UI. This exception preserves a selected hidden product while preventing it from appearing in a new selection list. The two endpoints are kept aligned because the regular product selector and the initial-tenure selector each receive their own product list. #### 3. Render an existing hidden product in the current case-edit pages The current admin case-edit and BNPL templates split `application_element.product_name` into assigned product IDs. While constructing the initial `product_name[]` select options, each template renders a product only when it is visible or is in that assigned-ID set. Existing linked-offer and soft-delete conditions remain in place. This avoids showing hidden products before the AJAX selector refreshes, while retaining a hidden product that belongs to the edited case. #### 4. Enforce the rule on case creation and update Both case persistence paths inspect submitted numeric `product_name` IDs before saving them: 1. Read the product IDs currently assigned to the case, if any. 2. Find submitted products with `product_visibility = 0` that are not in that existing-ID list. 3. If one or more are found, flash `One or more selected products are hidden and cannot be newly assigned.` and return to the form with input. 4. Otherwise, save the submitted product IDs as before. Free-text `product_name_str` values are not evaluated as product IDs by this check. #### 5. Copy anchor-merchant products `MerchantController@copyAnchorProducts` builds an explicit array for `Products::insert()`. The copied array now includes `product_visibility`, so copied products keep the source anchor product's visible or hidden state. #### 6. External integrations (EMI Checkout, API Wrapper, Admin 2.0) - **EMI Checkout:** The product list endpoint in the `emi-checkout` service now queries for products using `product_visibility = 1` and `deleted_at is null` so hidden products do not appear as options. The legacy query is preserved specifically for historical lookups on existing applications. - **API Wrapper:** The `isProductValid` rule inside `api-wrapper/JSON/category/default/mconnect/default.json` (within the `loanDetails` API) and specific merchant JSON files enforces the visibility constraint by checking `product_visibility = 1` and `deleted_at is null` when evaluating submitted product IDs. - **WhatsApp Bot:** Inherits the filtered checkout product list automatically from the EMI Checkout endpoint without any direct changes. - **Admin 2.0 Backend:** The Python REST API (`cf_admin_2.0/creditfair_python_backend`) exposes the `product_visibility` flag in its schemas and SQLAlchemy models. This allows the modernized Flutter UI to read and write the visibility state during catalog management. --- ### Data Model and Migration | Table | Field | Purpose | | --- | --- | --- | | `products` | `product_visibility` | Boolean availability flag for new advisor product assignments. `true` is visible; `false` is hidden. | | `consumer_application_elements` | `product_name` | Existing comma-separated product IDs used to preserve a hidden product already assigned to a case. This ticket does not alter its schema or stored format. | [`database/migrations/2026_09_03_130438_add_product_visibility_to_products_table.php`](../database/migrations/2026_09_03_130438_add_product_visibility_to_products_table.php) adds the non-null boolean `product_visibility` column to `products` with a default of `true`. Its rollback drops the column. `App\Products` includes the field in `$fillable` and casts it to a boolean. --- ### Main Code Impact | File | Change | | --- | --- | | [`app/Products.php`](../app/Products.php) | Adds `product_visibility` to `$fillable` and casts it as a boolean. No global visibility scope is applied. | | [`app/Http/Controllers/Admin/MerchantController.php`](../app/Http/Controllers/Admin/MerchantController.php) | `getOffersByMerchant` and `getOffersByMerchantV1` return visible products plus IDs already selected by the current case. `addProduct` persists a submitted visibility value only for `Merchant_Update|1-0-2`. `copyAnchorProducts` copies the source visibility value. | | [`app/Http/Controllers/Admin/ConsumerApplicationController.php`](../app/Http/Controllers/Admin/ConsumerApplicationController.php) | `store` and `getEditPostConsumerApplication` reject newly submitted hidden product IDs, while allowing IDs already assigned to that case. | | [`resources/views/admin/merchant/product_list.blade.php`](../resources/views/admin/merchant/product_list.blade.php) | Displays a `Visible` column, visually distinguishes hidden products, and adds the permission-gated visible/hidden control to the shared Add/Update Product modal. | | [`resources/views/admin/consumer-application/edit.blade.php`](../resources/views/admin/consumer-application/edit.blade.php) | Shows visible products and existing assigned hidden products in the legacy admin case-edit selector. | | [`resources/views/admin/consumer-application/edit_v1.blade.php`](../resources/views/admin/consumer-application/edit_v1.blade.php) | Shows visible products and existing assigned hidden products in the current admin case-edit selector. | | [`resources/views/admin/bnpl-application/edit.blade.php`](../resources/views/admin/bnpl-application/edit.blade.php) | Applies the same visible-or-already-assigned rendering rule to the BNPL selector. | | [`database/migrations/2026_09_03_130438_add_product_visibility_to_products_table.php`](../database/migrations/2026_09_03_130438_add_product_visibility_to_products_table.php) | Adds the boolean visibility flag with a default of `true`. | | `emi-checkout/sql_query_config.py` & `emi_repository.py` | Adds `active_products_query` enforcing `product_visibility = 1` and `deleted_at is null` for the consumer checkout dropdown. | | `api-wrapper/JSON/**` | Updates `isProductValid` rule inside `default.json` and merchant-specific JSONs to require `product_visibility = 1` and `deleted_at is null`. | | `cf_admin_2.0/creditfair_python_backend/.../product_model.py` & `product.py` | Adds `product_visibility` boolean column to the SQLAlchemy model and domain entity. | | `cf_admin_2.0/creditfair_python_backend/.../schemas.py` | Adds `product_visibility` to `ProductResponse`, `ProductCreateRequest`, and `ProductUpdateRequest`. | | `cf_admin_2.0/creditfair_python_backend/.../sql_product_repository.py` | Maps and persists `product_visibility` during model conversion and creation. | --- ### Operational Verification Checklist 1. Run the CRED-6177 migration and confirm existing and new product rows have `product_visibility = 1` unless deliberately changed. 2. As a user with `Merchant_Update|1-0-2`, open a merchant's product-management page, set a product to `No`, save it, and confirm the table displays `Visible: No`. 3. As a user without `Merchant_Update|1-0-2`, confirm the visible/hidden field is not present in the Add/Update Product modal. 4. Create a new eligible case for the merchant and confirm the hidden product is absent from both the normal product selector and the initial-tenure selector. 5. Open a case that already has the now-hidden product assigned and confirm that product is still displayed and selected. 6. Attempt to submit a newly added hidden product ID through the case create/update request and confirm the form returns the visibility validation error. 7. Copy products from an anchor merchant containing both visible and hidden products; confirm each copied product retains its source visibility value. 8. Confirm a hidden product still resolves in existing case details, product amount checks, offer calculation, document generation, collection views, and reports.