ENGR-11553: Position Update 500 Error - Missing custom_security.type
Ticket Information
Section titled “Ticket Information”- Jira: ENGR-11553
- Title: Aggregated Account is Causing Unexpected Error
- Status: In Progress
- Assignee: Yan Hu
- Created: 2026-02-12
Problem Description
Section titled “Problem Description”The API endpoint PUT /v2/advisors/{advisor_id}/households/{household_id}/positions/{position_id} returns a 500 Internal Server Error. The error occurs during response construction when the position’s custom_security field is missing the type property.
Observed Behavior
Section titled “Observed Behavior”- Position update request successfully saves to database
- API response construction throws exception:
RuntimeException: "Standalone position [xxx] doesn't have type specified." - Returns 500 Internal Server Error to client
Root Cause
Section titled “Root Cause”The frontend sends an empty customSecurity object ({}) in specific scenarios, causing the backend to fail when accessing the security_type attribute during response construction.
Detailed Flow
Section titled “Detailed Flow”1. Frontend Behavior
Section titled “1. Frontend Behavior”When switching from “Input detailed allocation” mode to “Use another security” mode, the frontend clears customSecurity:
if (checkIfCustomSecurityIsCustomized(position.customSecurity)) { position.customSecurity = {}; // Sets to empty object}draft.currentAllocationApproach = 'use_another_security';2. Backend Save Logic
Section titled “2. Backend Save Logic”File: retail-api/app/Models/Position.php:279-324
The setCustomSecurityAttribute() method processes the input:
if (is_array($value)) { // Remove NULLs $value = array_filter($value, function (mixed $v): bool { return $v !== null; });}
$this->attributes[self::COLUMN_CUSTOM_SECURITY] = ($value !== null && $value !== []) ? \Safe\json_encode($value) : null;- Receives empty object
{} - After filtering, still an empty array
[] - Ultimately saves
custom_security = nullto database
3. Response Construction Failure
Section titled “3. Response Construction Failure”File: retail-api/app/Models/Position.php:172-190
When PositionResource constructs the response, it accesses $position->security_type, triggering getSecurityTypeAttribute():
public function getSecurityTypeAttribute(): SecurityType{ if ($this->isLinkedSecurityAssigned()) { return $this->getUnderlyingSecurity()->type; } elseif ($this->isSecuritized()) { // ... security_id related logic } elseif (isset($this->custom_security[Security::COLUMN_TYPE])) { return SecurityType::from($this->custom_security[Security::COLUMN_TYPE]); } else { throw new RuntimeException("Standalone position [$this->id] doesn't have type specified."); }}For standalone positions (security_id = null, reference = null):
- Checks if
custom_security['type']exists - If not, throws
RuntimeException - Results in 500 error
Trigger Scenario
Section titled “Trigger Scenario”User Operation Path:
- Position originally uses “Input detailed allocation” mode
custom_securitycontainstype,categoryId, orpercentagesByCategoryId
- User opens Asset Classification Drawer
- Switches to “Use another security” mode (Input Symbol)
- Frontend clears
customSecurityto{} - Saves position
- Backend saves successfully, but response fails due to missing
typefield
Code Locations
Section titled “Code Locations”Frontend Code
Section titled “Frontend Code”-
Type Definition: frontend/app/src/application/shared/components/financial-item-drawer/accounts/investment/asset-classification-drawer/types.ts:6-13
export interface ICustomSecurity {categoryId?: string;id?: string;name?: string;percentagesByCategoryId?: IAllocation;type?: IHoldingTypeOptionType; // All fields are optionalprice?: number;} -
export const useInputSymbolFormValuesRef = (formValues) => {// ...if (checkIfCustomSecurityIsCustomized(position.customSecurity)) {position.customSecurity = {}; // Issue: sets to empty object}draft.currentAllocationApproach = 'use_another_security';}
Backend Code
Section titled “Backend Code”- Setter: retail-api/app/Models/Position.php:279-324 -
setCustomSecurityAttribute() - Getter (throws exception): retail-api/app/Models/Position.php:172-190 -
getSecurityTypeAttribute() - Controller: retail-api/app/Http/Controllers/Advisors/Households/PositionController.php
- Resource: retail-api/app/Http/Resources/PositionResource.php
Solutions
Section titled “Solutions”Solution 1: Frontend Fix (Recommended)
Section titled “Solution 1: Frontend Fix (Recommended)”Location: frontend/…/helper.ts:238-239
if (checkIfCustomSecurityIsCustomized(position.customSecurity)) { position.customSecurity = null; // Use null instead of {}}Benefits:
- Clearer semantics:
nullmeans “no custom properties”,{}is ambiguous - Avoids saving meaningless empty objects
- Aligns with API schema definition (
custom_securitycan benull)
Solution 2: Backend Defensive Fix
Section titled “Solution 2: Backend Defensive Fix”Option 2.1: Handle Empty Object in Setter
Section titled “Option 2.1: Handle Empty Object in Setter”Location: retail-api/app/Models/Position.php:279-324
public function setCustomSecurityAttribute(array|null $value): void{ // ... existing code ...
if (is_array($value)) { $value = array_filter($value, function (mixed $v): bool { return $v !== null; });
// New: convert empty object to null if ($value === []) { $value = null; } }
$this->attributes[self::COLUMN_CUSTOM_SECURITY] = ($value !== null && $value !== []) ? \Safe\json_encode($value) : null;}Option 2.2: Provide Fallback in Getter
Section titled “Option 2.2: Provide Fallback in Getter”Location: retail-api/app/Models/Position.php:172-190
public function getSecurityTypeAttribute(): SecurityType{ // ... existing if/elseif logic ...
} elseif (isset($this->custom_security[Security::COLUMN_TYPE])) { return SecurityType::from($this->custom_security[Security::COLUMN_TYPE]); } else { // New: provide better error handling if ($this->custom_security === null || $this->custom_security === []) { throw new UnprocessableEntityHttpException( "Position [$this->id] has incomplete custom_security configuration." ); } throw new RuntimeException("Standalone position [$this->id] doesn't have type specified."); }}Option 2.3: Validate in Controller
Section titled “Option 2.3: Validate in Controller”Location: retail-api/app/Http/Controllers/Advisors/Households/PositionController.php:90-136
Add validation logic in withJsonRequestValidator():
public function withJsonRequestValidator(Validator $validator, JsonRequest $request): void{ if (in_array($request->method(), [Request::METHOD_POST, Request::METHOD_PUT], true)) { $validator->after(function (Validator $validator): void { $data = $validator->getData();
// ... existing validation ...
// New: validate standalone position custom_security if (!isset($data[Position::COLUMN_SECURITY_ID]) && !isset($data[Position::COLUMN_REFERENCE])) { $custom_security = $data[Position::COLUMN_CUSTOM_SECURITY] ?? null;
// Reject empty object if ($custom_security === [] || (is_array($custom_security) && !isset($custom_security[Security::COLUMN_TYPE]))) { $validator->errors()->add( Position::COLUMN_CUSTOM_SECURITY, 'Standalone position must have a valid custom_security.type.' ); } } }); }}Recommended Fix Priority
Section titled “Recommended Fix Priority”- Immediate (Frontend): Solution 1 - Change empty object to
null - Defensive Hardening (Backend): Solution 2.1 + 2.3 - Handle at both save and validation stages
Related Documentation
Section titled “Related Documentation”- Position Model Documentation (if exists)
- Asset Classification Drawer Flow (if exists)
Testing Recommendations
Section titled “Testing Recommendations”Frontend Tests
Section titled “Frontend Tests”- Create a position using “Input detailed allocation” mode
- Switch to “Use another security” mode
- Save without selecting any security
- Verify:
- API request contains
customSecurity: null - Save succeeds without 500 error
- API request contains
Backend Tests
Section titled “Backend Tests”- Unit test:
PositionControllerTest::testUpdateWithEmptyCustomSecurity - Verify edge cases:
custom_security = null✅custom_security = {}❌ Should reject or convert tonullcustom_security = { type: null }❌ Should rejectcustom_security = { type: 'fund', ... }✅
Investigation Process
Section titled “Investigation Process”Database Queries
Section titled “Database Queries”-
Local MySQL: Confirmed positions table structure
custom_securityfield type: JSON, NULLABLEsecurity_idfield type: mediumint unsigned, NULLABLEreferencefield type: varchar(255), NULLABLE
-
Snowflake: No positions table data found in ANALYTICS database (data not synced)
Code Tracing
Section titled “Code Tracing”- API entry:
PositionController::update()(Updatable trait) - Validation:
withJsonRequestValidator() - Save:
Position::setCustomSecurityAttribute() - Response:
PositionResource::make()→ accesses$position->security_type - Exception:
Position::getSecurityTypeAttribute()throws RuntimeException
Key Findings
Section titled “Key Findings”- Frontend
ICustomSecurityinterface has all optional fields - Frontend actively clears
customSecurityduring mode switch - Backend setter removes null values but doesn’t reject empty objects
- Backend getter requires
typefield for standalone positions
Related Issues
Section titled “Related Issues”- May affect other scenarios: bulk imports, third-party integration data sync handling of custom_security