From 9c5ecbd62c0e3fd52ffa44653bc6a8536b2452b0 Mon Sep 17 00:00:00 2001 From: "Matthew Wire (MJW)" Date: Mon, 6 Apr 2026 11:33:53 +0100 Subject: [PATCH 1/2] Support PriceSet as ManagedEntity --- Civi/Api4/PriceField.php | 1 + Civi/Api4/PriceFieldValue.php | 1 + Civi/Api4/PriceSet.php | 1 + Civi/Api4/PriceSetEntity.php | 1 + 4 files changed, 4 insertions(+) diff --git a/Civi/Api4/PriceField.php b/Civi/Api4/PriceField.php index 8b1aba7ecf23..956a1a6c72ea 100644 --- a/Civi/Api4/PriceField.php +++ b/Civi/Api4/PriceField.php @@ -21,5 +21,6 @@ */ class PriceField extends Generic\DAOEntity { use Generic\Traits\SortableEntity; + use Generic\Traits\ManagedEntity; } diff --git a/Civi/Api4/PriceFieldValue.php b/Civi/Api4/PriceFieldValue.php index 59249021686f..633f5d89c485 100644 --- a/Civi/Api4/PriceFieldValue.php +++ b/Civi/Api4/PriceFieldValue.php @@ -21,5 +21,6 @@ */ class PriceFieldValue extends Generic\DAOEntity { use Generic\Traits\SortableEntity; + use Generic\Traits\ManagedEntity; } diff --git a/Civi/Api4/PriceSet.php b/Civi/Api4/PriceSet.php index 5b09ef830ed1..bf372e73cd44 100644 --- a/Civi/Api4/PriceSet.php +++ b/Civi/Api4/PriceSet.php @@ -18,5 +18,6 @@ * @package Civi\Api4 */ class PriceSet extends Generic\DAOEntity { + use Generic\Traits\ManagedEntity; } diff --git a/Civi/Api4/PriceSetEntity.php b/Civi/Api4/PriceSetEntity.php index e040e81519f0..cea3c68aef66 100644 --- a/Civi/Api4/PriceSetEntity.php +++ b/Civi/Api4/PriceSetEntity.php @@ -18,5 +18,6 @@ * @package Civi\Api4 */ class PriceSetEntity extends Generic\DAOEntity { + use Generic\Traits\ManagedEntity; } From f3e5d6aa1dc1035708d6e558d3158d346c538273 Mon Sep 17 00:00:00 2001 From: "Matthew Wire (MJW)" Date: Mon, 27 Jul 2026 16:40:24 +0100 Subject: [PATCH 2/2] PriceSet - Export `extends` field as portable component names PriceSet.extends stores a serialized array of civicrm_component ids, which are not portable across sites. ExportAction previously excluded all callback-based pseudoconstants from `:name` export on the assumption their option lists are unstable, but PriceSet's getExtendsOptions() callback returns stable component names (CiviEvent, CiviContribute, CiviMember) that are safe to rely on. Add an explicit 'portable' => TRUE opt-in flag that a field's pseudoconstant can set to override the default exclusion, and use it for PriceSet.extends so PriceSet.export() emits `extends:name` instead of a raw, non-portable component id. --- Civi/Api4/Generic/ExportAction.php | 6 ++- schema/Price/PriceSet.entityType.php | 2 + .../api/v4/Entity/ManagedEntityTest.php | 38 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/Civi/Api4/Generic/ExportAction.php b/Civi/Api4/Generic/ExportAction.php index 4561afa86076..abf3b3265986 100644 --- a/Civi/Api4/Generic/ExportAction.php +++ b/Civi/Api4/Generic/ExportAction.php @@ -227,8 +227,10 @@ private function shouldUsePseudoconstant(string $entityType, array $field) { return TRUE; } // Options generated by a callback function tend to be stable, - // and the :name property may not be reliable. Use plain value. - if ($daoName && !empty($daoName::getSupportedFields()[$field['name']]['pseudoconstant']['callback'])) { + // and the :name property may not be reliable. Use plain value, + // unless the field has explicitly opted in as portable. + $pseudoconstant = $daoName ? ($daoName::getSupportedFields()[$field['name']]['pseudoconstant'] ?? []) : []; + if (!empty($pseudoconstant['callback']) && empty($pseudoconstant['portable'])) { return FALSE; } // Options with numeric keys probably refer to auto-increment keys diff --git a/schema/Price/PriceSet.entityType.php b/schema/Price/PriceSet.entityType.php index 7dfaaaff75ed..7535b6d40f40 100644 --- a/schema/Price/PriceSet.entityType.php +++ b/schema/Price/PriceSet.entityType.php @@ -135,6 +135,8 @@ 'serialize' => CRM_Core_DAO::SERIALIZE_SEPARATOR_BOOKEND, 'pseudoconstant' => [ 'callback' => ['CRM_Price_BAO_PriceSet', 'getExtendsOptions'], + // Set portable=TRUE if pseudoconstants in callback don't change across different sites + 'portable' => TRUE, ], ], 'financial_type_id' => [ diff --git a/tests/phpunit/api/v4/Entity/ManagedEntityTest.php b/tests/phpunit/api/v4/Entity/ManagedEntityTest.php index f36aa5fbcd71..1abcf29e71e8 100644 --- a/tests/phpunit/api/v4/Entity/ManagedEntityTest.php +++ b/tests/phpunit/api/v4/Entity/ManagedEntityTest.php @@ -24,6 +24,7 @@ use Civi\Api4\Navigation; use Civi\Api4\OptionGroup; use Civi\Api4\OptionValue; +use Civi\Api4\PriceSet; use Civi\Api4\SavedSearch; use Civi\Test; use Civi\Test\CiviEnvBuilder; @@ -736,6 +737,43 @@ public function testExportAndCreateGroup(): void { $this->assertGreaterThan($original['id'], $created['id']); } + /** + * PriceSet.extends stores a serialized array of civicrm_component ids, which are not + * portable across sites. Exporting should use the `:name` pseudoconstant (component + * names like `CiviEvent`) instead, and importing should resolve those names back to + * the correct local component ids. + * + * @throws \CRM_Core_Exception + */ + public function testExportAndCreatePriceSetWithPortableExtends(): void { + $original = PriceSet::create(FALSE) + ->addValue('name', 'my_managed_price_set') + ->addValue('title', 'My Managed Price Set') + ->addValue('extends:name', ['CiviEvent']) + ->addValue('financial_type_id:name', 'Donation') + ->execute()->single(); + + $export = PriceSet::export(FALSE) + ->setId($original['id']) + ->execute()->single(); + + // The export should use the portable `:name` syntax, not the raw (non-portable) component id. + $this->assertEquals(['CiviEvent'], $export['params']['values']['extends:name']); + $this->assertArrayNotHasKey('extends', $export['params']['values']); + + PriceSet::delete(FALSE)->addWhere('id', '=', $original['id'])->execute(); + + $this->_managedEntities = [ + ['module' => 'civicrm'] + $export, + ]; + CRM_Core_ManagedEntities::singleton(TRUE)->reconcile(); + + $created = $this->getTestRecord('PriceSet', ['name' => $original['name']]); + + $this->assertEquals($original['extends'], $created['extends']); + $this->assertEquals($original['financial_type_id'], $created['financial_type_id']); + } + /** * Tests a scenario where a record may already exist and we want to make it a managed entity. *