Skip to content

ENGR-11553: Position Update 500 Error - Missing custom_security.type

  • Jira: ENGR-11553
  • Title: Aggregated Account is Causing Unexpected Error
  • Status: In Progress
  • Assignee: Yan Hu
  • Created: 2026-02-12

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.

  1. Position update request successfully saves to database
  2. API response construction throws exception: RuntimeException: "Standalone position [xxx] doesn't have type specified."
  3. Returns 500 Internal Server Error to client

The frontend sends an empty customSecurity object ({}) in specific scenarios, causing the backend to fail when accessing the security_type attribute during response construction.

When switching from “Input detailed allocation” mode to “Use another security” mode, the frontend clears customSecurity:

File: frontend/app/src/application/shared/components/financial-item-drawer/accounts/investment/asset-classification-drawer/helper.ts:238-239

if (checkIfCustomSecurityIsCustomized(position.customSecurity)) {
position.customSecurity = {}; // Sets to empty object
}
draft.currentAllocationApproach = 'use_another_security';

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 = null to database

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

User Operation Path:

  1. Position originally uses “Input detailed allocation” mode
    • custom_security contains type, categoryId, or percentagesByCategoryId
  2. User opens Asset Classification Drawer
  3. Switches to “Use another security” mode (Input Symbol)
  4. Frontend clears customSecurity to {}
  5. Saves position
  6. Backend saves successfully, but response fails due to missing type field

Location: frontend/…/helper.ts:238-239

if (checkIfCustomSecurityIsCustomized(position.customSecurity)) {
position.customSecurity = null; // Use null instead of {}
}

Benefits:

  • Clearer semantics: null means “no custom properties”, {} is ambiguous
  • Avoids saving meaningless empty objects
  • Aligns with API schema definition (custom_security can be null)

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;
}

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.");
}
}

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.'
);
}
}
});
}
}
  1. Immediate (Frontend): Solution 1 - Change empty object to null
  2. Defensive Hardening (Backend): Solution 2.1 + 2.3 - Handle at both save and validation stages
  1. Create a position using “Input detailed allocation” mode
  2. Switch to “Use another security” mode
  3. Save without selecting any security
  4. Verify:
    • API request contains customSecurity: null
    • Save succeeds without 500 error
  1. Unit test: PositionControllerTest::testUpdateWithEmptyCustomSecurity
  2. Verify edge cases:
    • custom_security = null
    • custom_security = {} ❌ Should reject or convert to null
    • custom_security = { type: null } ❌ Should reject
    • custom_security = { type: 'fund', ... }
  • Local MySQL: Confirmed positions table structure

    • custom_security field type: JSON, NULLABLE
    • security_id field type: mediumint unsigned, NULLABLE
    • reference field type: varchar(255), NULLABLE
  • Snowflake: No positions table data found in ANALYTICS database (data not synced)

  1. API entry: PositionController::update() (Updatable trait)
  2. Validation: withJsonRequestValidator()
  3. Save: Position::setCustomSecurityAttribute()
  4. Response: PositionResource::make() → accesses $position->security_type
  5. Exception: Position::getSecurityTypeAttribute() throws RuntimeException
  • Frontend ICustomSecurity interface has all optional fields
  • Frontend actively clears customSecurity during mode switch
  • Backend setter removes null values but doesn’t reject empty objects
  • Backend getter requires type field for standalone positions
  • May affect other scenarios: bulk imports, third-party integration data sync handling of custom_security