|
87517
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
JAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormreplaydFirefoxCP Isolated Web Contentcef_server Helper (Renderer)WindowServerlanguage_server_macos_armscreenpipeFirefoxCP Isolated Web ContentcoreaudiodActivity MonitorFirefoxCP Isolated Web Contentbluetoothdcef_server Helper (GPU)launchservicesdFirefoxiTerm2FirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentWispr Flowcef__serverSlacksyspolicydWispr Flow Helper (Renderer)Slack Helper (Renderer)FirefoxCP Isolated Web Content149,1121,154,927,926,618,411,210,810,16,54,54,43,83,43,13,02,72,32,12,02,02,02,01,91,61,61,61,4CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:24:54,824:38:29,786:47:09,5844:19,7211:48,988:12:06,2113:35,863:54:30,7942:15,931:02:39,5210:02,7113:53,0621:33,399:06,291:04:54,131:50:45,051:05:15,5025:11,0520:20,5117:17,8932:50,014:40,114:43,8114:16,6517:36,055:55,001:01:20,1310:28,27559263System:User:Idle: ,78%47,79%3,42%CPUHomeDMsActivity+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova%: Todor Stamatov&. Steliyan Georgiev&. VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <78•Thu 28 May 18:42:42Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87517
|
|
87516
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedClosedDealStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
3/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
75
2
22
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
/** @var array<string, array<string>> keyed by config id */
private array $cachedOpportunitySyncableFields = [];
/** @var array<string, mixed> keyed by configId:ownerId */
private array $cachedOwnerProfiles = [];
/** @var array<string, mixed> keyed by configId:businessProcessId */
private array $cachedRecordTypes = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$startTime = microtime(true);
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
$strategyNames = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$strategyNames[] = $strategyName;
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' . $strategyName,
['team' => $this->team->getId()]
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$durationMs = round((microtime(true) - $startTime) * 1000, 2);
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'strategies' => implode(',', $strategyNames),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
'duration_ms' => $durationMs,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
$batchStart = microtime(true);
$slowDeals = [];
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssocStart = microtime(true);
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$companyAssocMs = (int) round((microtime(true) - $companyAssocStart) * 1000);
$contactAssocStart = microtime(true);
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$contactAssocMs = (int) round((microtime(true) - $contactAssocStart) * 1000);
$prepareStart = microtime(true);
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
$prepareTimings = [];
$associationsData = $this->prepareAssociatedEntities(
$companyAssociations,
$contactAssociations,
$prepareTimings
);
$prepareMs = (int) round((microtime(true) - $prepareStart) * 1000);
$missingCompanies = count(array_diff(
$allCompanyIds,
array_keys($associationsData['company_id_mappings'] ?? [])
));
$missingContacts = count(array_diff(
$allContactIds,
array_keys($associationsData['contact_id_mappings'] ?? [])
));
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
$loopStart = microtime(true);
foreach ($deals as $deal) {
$dealStart = microtime(true);
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
$dealMs = (int) round((microtime(true) - $dealStart) * 1000);
if ($dealMs > 1000) {
$slowDeals[] = ['crmId' => $deal['id'], 'ms' => $dealMs];
}
}
$loopMs = (int) round((microtime(true) - $loopStart) * 1000);
$totalMs = (int) round((microtime(true) - $batchStart) * 1000);
$this->logger->info('[' . $this->getDisplayName() . '] importOpportunityBatch timing', [
'teamId' => $this->team->getId(),
'deal_count' => count($deals),
'total_ms' => $totalMs,
'company_assoc_api_ms' => $companyAssocMs,
'contact_assoc_api_ms' => $contactAssocMs,
'prepare_entities_ms' => $prepareMs,
'prepare_accounts_ms' => $prepareTimings['accounts_ms'],
'prepare_contacts_ms' => $prepareTimings['contacts_ms'],
'total_companies' => count($allCompanyIds),
'missing_companies' => $missingCompanies,
'total_contacts' => count($allContactIds),
'missing_contacts' => $missingContacts,
'deals_loop_ms' => $loopMs,
'avg_deal_ms' => ! empty($deals) ? (int) round($loopMs / count($deals)) : 0,
'slow_deals_count' => count($slowDeals),
'slow_deals' => array_slice($slowDeals, 0, 10),
]);
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(
array $companyAssociations,
array $contactAssociations,
array &$timings = []
): array {
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
$accountsMs = 0;
$contactsMs = 0;
if (! empty($allCompanyIds)) {
$start = microtime(true);
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
$accountsMs = (int) round((microtime(true) - $start) * 1000);
}
if (! empty($allContactIds)) {
$start = microtime(true);
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
$contactsMs = (int) round((microtime(true) - $start) * 1000);
}
$timings = [
'accounts_ms' => $accountsMs,
'contacts_ms' => $contactsMs,
];
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist (lean covering-index lookup)
$existingAccountsData = $this->crmEntityRepository
->getExistingAccountIdsMap($this->config, $companyIds);
$missingCompanyIds = array_diff($companyIds, array_keys($existingAccountsData));
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingAccountsData),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist (lean covering-index lookup)
$existingContactsData = $this->crmEntityRepository
->getExistingContactIdsMap($this->config, $contactIds);
$missingContactIds = array_diff($contactIds, array_keys($existingContactsData));
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactsData),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$properties = $crmData['properties'];
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = (string) $properties['hubspot_owner_id'];
$profile = $this->getCachedOwnerProfile($ownerId);
}
$associations = $crmData['associations'] ?? [];
$accountId = $this->resolveAccountId($associations);
// Only fetch account if we need it for user_id fallback
$accountUserId = null;
if ($profile?->getUserId() === null && $accountId !== null) {
$accountUserId = $this->crmEntityRepository
->findAccountByConfigurationAndId(
$this->config,
$accountId
)?->getUserId();
}
$crmId = (string) $crmData['id'];
if (($profile?->getUserId() === null) && ($accountUserId === null)) {
$this->logger->error(
'[HubSpot] Skip import, no user_id found',
[
'id' => $crmId,
]
);
return null;
}
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity(
$crmId,
$properties,
$associations,
$accountUserId
);
}
return $this->createOpportunity(
$crmId,
$properties,
$associations,
$accountUserId,
);
}
/**
* Create new opportunity
*/
private function createOpportunity(
string $crmId,
array $properties,
array $associations,
?int $accountUserId = null
): ?Opportunity {
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData(
$properties,
$accountId,
$businessProcess,
$stage,
$accountUserId
);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(
string $crmId,
array $properties,
array $associations,
?int $accountUserId = null
): Opportunity {
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData(
$properties,
$accountId,
$businessProcess,
$stage,
$accountUserId
);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['account_id'])) {
return $associations['account_id'];
}
if (empty($associations)) {
return null;
}
// Fallback: use first company as account (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage,
?int $accountUserId = null
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->getCachedOwnerProfile((string) $ownerId);
}
$userId = $profile?->getUserId() ?? $accountUserId;
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $userId,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->getCachedBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function getCachedOwnerProfile(string $ownerId): ?Profile
{
$cacheKey = $this->config->getId() . ':' . $ownerId;
if (array_key_exists($cacheKey, $this->cachedOwnerProfiles)) {
return $this->cachedOwnerProfiles[$cacheKey];
}
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, $ownerId);
$this->cachedOwnerProfiles[$cacheKey] = $profile;
return $profile;
}
private function getCachedBusinessProcessRecordType(BusinessProcess $businessProcess): mixed
{
$cacheKey = $this->config->getId() . ':' . $businessProcess->getId();
if (array_key_exists($cacheKey, $this->cachedRecordTypes)) {
return $this->cachedRecordTypes[$cacheKey];
}
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
$this->cachedRecordTypes[$cacheKey] = $recordType;
return $recordType;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
$cacheKey = $this->getBusinessProcessCacheKey($pipelineId);
if (isset($this->cachedBusinessProcesses[$cacheKey])) {
return $this->cachedBusinessProcesses[$cacheKey];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$cacheKey] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function getBusinessProcessCacheKey(string $pipelineId): string
{
return $this->config->getId() . '_' . $pipelineId;
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $this->config->getId() . ':' . $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$this->importOpportunityCrmFieldData(
$properties,
$this->getCachedOpportunitySyncableFields(),
$opportunityId
);
}
private function getCachedOpportunitySyncableFields(): array
{
$cacheKey = (string) $this->config->getId();
if (! isset($this->cachedOpportunitySyncableFields[$cacheKey])) {
$this->cachedOpportunitySyncableFields[$cacheKey] = $this->getOpportunitySyncableFields();
}
return $this->cachedOpportunitySyncableFields[$cacheKey];
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
return $this->performContactAttachment($opportunity, $id, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, int $contactId, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contactId, [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contactId,
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Code changed:
Hide
Sync Changes
Hide This Notification
31
9
29
3
109
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM team_features where team_id = 1;
SELECT * FROM teams WHERE name LIKE '%Vixio%'; # 340,270,11922
SELECT * FROM users WHERE team_id = 340; # 12015
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 340
and sa.provider = 'salesforce';
# and sa.provider = 'salesloft';
select * from crm_fields where crm_configuration_id = 270 and object_type = 'event';
# 125558 - Event Type - Event_Type__c
# 125552 - Event Status - Event_Status__c
SELECT * FROM sidekick_settings WHERE team_id = 340;
SELECT * FROM crm_field_values WHERE crm_field_id in (125552);
select * from activities where crm_configuration_id = 270
and type = 'conference' and crm_provider_id IS NOT NULL
and actual_start_time > '2024-09-16 09:00:00' order by scheduled_start_time;
SELECT * FROM activities WHERE id = 20871677;
SELECT * FROM crm_field_data WHERE activity_id = 20871677;
select * from crm_layouts where crm_configuration_id = 270;
select * from crm_layout_entities where crm_layout_id in (886,887);
SELECT * FROM crm_configurations WHERE id = 270;
select * from playbooks where team_id = 340; # 1514
select * from groups where team_id = 340;
SELEC...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87516
|
|
87515
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
IAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormreplaydFirefoxCP Isolated Web ContentWindowServerlanguage_server_macos_armcef_server Helper (Renderer)screenpipeFirefoxCP Isolated Web ContentFirefoxCP Isolated Web Contentlaunchservicesdcoreaudiodcef_server Helper (GPU)Activity Monitorbluetoothdcef_serverFirefoxFirefoxCP Isolated Web ContentClaudeFirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web ContentSlack Helper (Renderer)iTerm2Control CentreNotion Helper (Renderer)Wispr Flow Helper (Renderer)FirefoxCP Isolated Web Content186,5155,657,235,029,529,428,614,713,410,06,76,45,65,04,23,73,42,82,12,12,11,91,81,71,71,71,61,5CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:24:15,134:37:52,886:46:56,1032:49,358:11:59,6113:29,8111:39,323:54:27,1525:10,3242:13,431:04:53,381:02:37,859:04,5910:01,7221:32,414:42,721:50:44,1117:17,2329:58,9844:17,284:39,5920:19,961:01:19,651:05:15,0220:57,2025:27,965:54,5913:52,47559268System:User:Idle: ,93%28,49%46,58%CPUHomeDMsActivity+ED→Jiminny...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi..Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka StoyanovaR. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova%: Todor Stamatov&. Steliyan Georgiev@ VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:42:11Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87515
|
|
87514
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedClosedDealStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
3/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
75
2
22
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
/** @var array<string, array<string>> keyed by config id */
private array $cachedOpportunitySyncableFields = [];
/** @var array<string, mixed> keyed by configId:ownerId */
private array $cachedOwnerProfiles = [];
/** @var array<string, mixed> keyed by configId:businessProcessId */
private array $cachedRecordTypes = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$startTime = microtime(true);
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
$strategyNames = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$strategyNames[] = $strategyName;
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' . $strategyName,
['team' => $this->team->getId()]
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$durationMs = round((microtime(true) - $startTime) * 1000, 2);
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'strategies' => implode(',', $strategyNames),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
'duration_ms' => $durationMs,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
$batchStart = microtime(true);
$slowDeals = [];
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssocStart = microtime(true);
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$companyAssocMs = (int) round((microtime(true) - $companyAssocStart) * 1000);
$contactAssocStart = microtime(true);
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$contactAssocMs = (int) round((microtime(true) - $contactAssocStart) * 1000);
$prepareStart = microtime(true);
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
$prepareTimings = [];
$associationsData = $this->prepareAssociatedEntities(
$companyAssociations,
$contactAssociations,
$prepareTimings
);
$prepareMs = (int) round((microtime(true) - $prepareStart) * 1000);
$missingCompanies = count(array_diff(
$allCompanyIds,
array_keys($associationsData['company_id_mappings'] ?? [])
));
$missingContacts = count(array_diff(
$allContactIds,
array_keys($associationsData['contact_id_mappings'] ?? [])
));
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
$loopStart = microtime(true);
foreach ($deals as $deal) {
$dealStart = microtime(true);
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
$dealMs = (int) round((microtime(true) - $dealStart) * 1000);
if ($dealMs > 1000) {
$slowDeals[] = ['crmId' => $deal['id'], 'ms' => $dealMs];
}
}
$loopMs = (int) round((microtime(true) - $loopStart) * 1000);
$totalMs = (int) round((microtime(true) - $batchStart) * 1000);
$this->logger->info('[' . $this->getDisplayName() . '] importOpportunityBatch timing', [
'teamId' => $this->team->getId(),
'deal_count' => count($deals),
'total_ms' => $totalMs,
'company_assoc_api_ms' => $companyAssocMs,
'contact_assoc_api_ms' => $contactAssocMs,
'prepare_entities_ms' => $prepareMs,
'prepare_accounts_ms' => $prepareTimings['accounts_ms'],
'prepare_contacts_ms' => $prepareTimings['contacts_ms'],
'total_companies' => count($allCompanyIds),
'missing_companies' => $missingCompanies,
'total_contacts' => count($allContactIds),
'missing_contacts' => $missingContacts,
'deals_loop_ms' => $loopMs,
'avg_deal_ms' => ! empty($deals) ? (int) round($loopMs / count($deals)) : 0,
'slow_deals_count' => count($slowDeals),
'slow_deals' => array_slice($slowDeals, 0, 10),
]);
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(
array $companyAssociations,
array $contactAssociations,
array &$timings = []
): array {
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
$accountsMs = 0;
$contactsMs = 0;
if (! empty($allCompanyIds)) {
$start = microtime(true);
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
$accountsMs = (int) round((microtime(true) - $start) * 1000);
}
if (! empty($allContactIds)) {
$start = microtime(true);
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
$contactsMs = (int) round((microtime(true) - $start) * 1000);
}
$timings = [
'accounts_ms' => $accountsMs,
'contacts_ms' => $contactsMs,
];
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist (lean covering-index lookup)
$existingAccountsData = $this->crmEntityRepository
->getExistingAccountIdsMap($this->config, $companyIds);
$missingCompanyIds = array_diff($companyIds, array_keys($existingAccountsData));
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingAccountsData),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist (lean covering-index lookup)
$existingContactsData = $this->crmEntityRepository
->getExistingContactIdsMap($this->config, $contactIds);
$missingContactIds = array_diff($contactIds, array_keys($existingContactsData));
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactsData),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$properties = $crmData['properties'];
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = (string) $properties['hubspot_owner_id'];
$profile = $this->getCachedOwnerProfile($ownerId);
}
$associations = $crmData['associations'] ?? [];
$accountId = $this->resolveAccountId($associations);
// Only fetch account if we need it for user_id fallback
$accountUserId = null;
if ($profile?->getUserId() === null && $accountId !== null) {
$accountUserId = $this->crmEntityRepository
->findAccountByConfigurationAndId(
$this->config,
$accountId
)?->getUserId();
}
$crmId = (string) $crmData['id'];
if (($profile?->getUserId() === null) && ($accountUserId === null)) {
$this->logger->error(
'[HubSpot] Skip import, no user_id found',
[
'id' => $crmId,
]
);
return null;
}
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity(
$crmId,
$properties,
$associations,
$accountUserId
);
}
return $this->createOpportunity(
$crmId,
$properties,
$associations,
$accountUserId,
);
}
/**
* Create new opportunity
*/
private function createOpportunity(
string $crmId,
array $properties,
array $associations,
?int $accountUserId = null
): ?Opportunity {
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData(
$properties,
$accountId,
$businessProcess,
$stage,
$accountUserId
);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(
string $crmId,
array $properties,
array $associations,
?int $accountUserId = null
): Opportunity {
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData(
$properties,
$accountId,
$businessProcess,
$stage,
$accountUserId
);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['account_id'])) {
return $associations['account_id'];
}
if (empty($associations)) {
return null;
}
// Fallback: use first company as account (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage,
?int $accountUserId = null
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->getCachedOwnerProfile((string) $ownerId);
}
$userId = $profile?->getUserId() ?? $accountUserId;
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $userId,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->getCachedBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function getCachedOwnerProfile(string $ownerId): ?Profile
{
$cacheKey = $this->config->getId() . ':' . $ownerId;
if (array_key_exists($cacheKey, $this->cachedOwnerProfiles)) {
return $this->cachedOwnerProfiles[$cacheKey];
}
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, $ownerId);
$this->cachedOwnerProfiles[$cacheKey] = $profile;
return $profile;
}
private function getCachedBusinessProcessRecordType(BusinessProcess $businessProcess): mixed
{
$cacheKey = $this->config->getId() . ':' . $businessProcess->getId();
if (array_key_exists($cacheKey, $this->cachedRecordTypes)) {
return $this->cachedRecordTypes[$cacheKey];
}
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
$this->cachedRecordTypes[$cacheKey] = $recordType;
return $recordType;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
$cacheKey = $this->getBusinessProcessCacheKey($pipelineId);
if (isset($this->cachedBusinessProcesses[$cacheKey])) {
return $this->cachedBusinessProcesses[$cacheKey];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$cacheKey] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function getBusinessProcessCacheKey(string $pipelineId): string
{
return $this->config->getId() . '_' . $pipelineId;
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $this->config->getId() . ':' . $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$this->importOpportunityCrmFieldData(
$properties,
$this->getCachedOpportunitySyncableFields(),
$opportunityId
);
}
private function getCachedOpportunitySyncableFields(): array
{
$cacheKey = (string) $this->config->getId();
if (! isset($this->cachedOpportunitySyncableFields[$cacheKey])) {
$this->cachedOpportunitySyncableFields[$cacheKey] = $this->getOpportunitySyncableFields();
}
return $this->cachedOpportunitySyncableFields[$cacheKey];
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
return $this->performContactAttachment($opportunity, $id, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, int $contactId, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contactId, [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contactId,
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Code changed:
Hide
Sync Changes
Hide This Notification
31
9
29
3
109...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87514
|
|
87513
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedClosedDealStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
3/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
75
2
22
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
/** @var array<string, array<string>> keyed by config id */
private array $cachedOpportunitySyncableFields = [];
/** @var array<string, mixed> keyed by configId:ownerId */
private array $cachedOwnerProfiles = [];
/** @var array<string, mixed> keyed by configId:businessProcessId */
private array $cachedRecordTypes = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$startTime = microtime(true);
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
$strategyNames = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$strategyNames[] = $strategyName;
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' . $strategyName,
['team' => $this->team->getId()]
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$durationMs = round((microtime(true) - $startTime) * 1000, 2);
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'strategies' => implode(',', $strategyNames),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
'duration_ms' => $durationMs,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
$batchStart = microtime(true);
$slowDeals = [];
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssocStart = microtime(true);
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$companyAssocMs = (int) round((microtime(true) - $companyAssocStart) * 1000);
$contactAssocStart = microtime(true);
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$contactAssocMs = (int) round((microtime(true) - $contactAssocStart) * 1000);
$prepareStart = microtime(true);
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
$prepareTimings = [];
$associationsData = $this->prepareAssociatedEntities(
$companyAssociations,
$contactAssociations,
$prepareTimings
);
$prepareMs = (int) round((microtime(true) - $prepareStart) * 1000);
$missingCompanies = count(array_diff(
$allCompanyIds,
array_keys($associationsData['company_id_mappings'] ?? [])
));
$missingContacts = count(array_diff(
$allContactIds,
array_keys($associationsData['contact_id_mappings'] ?? [])
));
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
$loopStart = microtime(true);
foreach ($deals as $deal) {
$dealStart = microtime(true);
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
$dealMs = (int) round((microtime(true) - $dealStart) * 1000);
if ($dealMs > 1000) {
$slowDeals[] = ['crmId' => $deal['id'], 'ms' => $dealMs];
}
}
$loopMs = (int) round((microtime(true) - $loopStart) * 1000);
$totalMs = (int) round((microtime(true) - $batchStart) * 1000);
$this->logger->info('[' . $this->getDisplayName() . '] importOpportunityBatch timing', [
'teamId' => $this->team->getId(),
'deal_count' => count($deals),
'total_ms' => $totalMs,
'company_assoc_api_ms' => $companyAssocMs,
'contact_assoc_api_ms' => $contactAssocMs,
'prepare_entities_ms' => $prepareMs,
'prepare_accounts_ms' => $prepareTimings['accounts_ms'],
'prepare_contacts_ms' => $prepareTimings['contacts_ms'],
'total_companies' => count($allCompanyIds),
'missing_companies' => $missingCompanies,
'total_contacts' => count($allContactIds),
'missing_contacts' => $missingContacts,
'deals_loop_ms' => $loopMs,
'avg_deal_ms' => ! empty($deals) ? (int) round($loopMs / count($deals)) : 0,
'slow_deals_count' => count($slowDeals),
'slow_deals' => array_slice($slowDeals, 0, 10),
]);
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(
array $companyAssociations,
array $contactAssociations,
array &$timings = []
): array {
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
$accountsMs = 0;
$contactsMs = 0;
if (! empty($allCompanyIds)) {
$start = microtime(true);
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
$accountsMs = (int) round((microtime(true) - $start) * 1000);
}
if (! empty($allContactIds)) {
$start = microtime(true);
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
$contactsMs = (int) round((microtime(true) - $start) * 1000);
}
$timings = [
'accounts_ms' => $accountsMs,
'contacts_ms' => $contactsMs,
];
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist (lean covering-index lookup)
$existingAccountsData = $this->crmEntityRepository
->getExistingAccountIdsMap($this->config, $companyIds);
$missingCompanyIds = array_diff($companyIds, array_keys($existingAccountsData));
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingAccountsData),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist (lean covering-index lookup)
$existingContactsData = $this->crmEntityRepository
->getExistingContactIdsMap($this->config, $contactIds);
$missingContactIds = array_diff($contactIds, array_keys($existingContactsData));
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactsData),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$properties = $crmData['properties'];
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = (string) $properties['hubspot_owner_id'];
$profile = $this->getCachedOwnerProfile($ownerId);
}
$associations = $crmData['associations'] ?? [];
$accountId = $this->resolveAccountId($associations);
// Only fetch account if we need it for user_id fallback
$accountUserId = null;
if ($profile?->getUserId() === null && $accountId !== null) {
$accountUserId = $this->crmEntityRepository
->findAccountByConfigurationAndId(
$this->config,
$accountId
)?->getUserId();
}
$crmId = (string) $crmData['id'];
if (($profile?->getUserId() === null) && ($accountUserId === null)) {
$this->logger->error(
'[HubSpot] Skip import, no user_id found',
[
'id' => $crmId,
]
);
return null;
}
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity(
$crmId,
$properties,
$associations,
$accountUserId
);
}
return $this->createOpportunity(
$crmId,
$properties,
$associations,
$accountUserId,
);
}
/**
* Create new opportunity
*/
private function createOpportunity(
string $crmId,
array $properties,
array $associations,
?int $accountUserId = null
): ?Opportunity {
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData(
$properties,
$accountId,
$businessProcess,
$stage,
$accountUserId
);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(
string $crmId,
array $properties,
array $associations,
?int $accountUserId = null
): Opportunity {
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData(
$properties,
$accountId,
$businessProcess,
$stage,
$accountUserId
);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['account_id'])) {
return $associations['account_id'];
}
if (empty($associations)) {
return null;
}
// Fallback: use first company as account (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage,
?int $accountUserId = null
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->getCachedOwnerProfile((string) $ownerId);
}
$userId = $profile?->getUserId() ?? $accountUserId;
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $userId,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->getCachedBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function getCachedOwnerProfile(string $ownerId): ?Profile
{
$cacheKey = $this->config->getId() . ':' . $ownerId;
if (array_key_exists($cacheKey, $this->cachedOwnerProfiles)) {
return $this->cachedOwnerProfiles[$cacheKey];
}
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, $ownerId);
$this->cachedOwnerProfiles[$cacheKey] = $profile;
return $profile;
}
private function getCachedBusinessProcessRecordType(BusinessProcess $businessProcess): mixed
{
$cacheKey = $this->config->getId() . ':' . $businessProcess->getId();
if (array_key_exists($cacheKey, $this->cachedRecordTypes)) {
return $this->cachedRecordTypes[$cacheKey];
}
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
$this->cachedRecordTypes[$cacheKey] = $recordType;
return $recordType;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
$cacheKey = $this->getBusinessProcessCacheKey($pipelineId);
if (isset($this->cachedBusinessProcesses[$cacheKey])) {
return $this->cachedBusinessProcesses[$cacheKey];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$cacheKey] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function getBusinessProcessCacheKey(string $pipelineId): string
{
return $this->config->getId() . '_' . $pipelineId;
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $this->config->getId() . ':' . $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$this->importOpportunityCrmFieldData(
$properties,
$this->getCachedOpportunitySyncableFields(),
$opportunityId
);
}
private function getCachedOpportunitySyncableFields(): array
{
$cacheKey = (string) $this->config->getId();
if (! isset($this->cachedOpportunitySyncableFields[$cacheKey])) {
$this->cachedOpportunitySyncableFields[$cacheKey] = $this->getOpportunitySyncableFields();
}
return $this->cachedOpportunitySyncableFields[$cacheKey];
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
return $this->performContactAttachment($opportunity, $id, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, int $contactId, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contactId, [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contactId,
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Execute
Explain Plan...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87513
|
|
87512
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedClosedDealStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
3/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
75
2
22
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
/** @var array<string, array<string>> keyed by config id */
private array $cachedOpportunitySyncableFields = [];
/** @var array<string, mixed> keyed by configId:ownerId */
private array $cachedOwnerProfiles = [];
/** @var array<string, mixed> keyed by configId:businessProcessId */
private array $cachedRecordTypes = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$startTime = microtime(true);
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
$strategyNames = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$strategyNames[] = $strategyName;
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' . $strategyName,
['team' => $this->team->getId()]
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$durationMs = round((microtime(true) - $startTime) * 1000, 2);
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'strategies' => implode(',', $strategyNames),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
'duration_ms' => $durationMs,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
$batchStart = microtime(true);
$slowDeals = [];
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssocStart = microtime(true);
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$companyAssocMs = (int) round((microtime(true) - $companyAssocStart) * 1000);
$contactAssocStart = microtime(true);
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$contactAssocMs = (int) round((microtime(true) - $contactAssocStart) * 1000);
$prepareStart = microtime(true);
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
$prepareTimings = [];
$associationsData = $this->prepareAssociatedEntities(
$companyAssociations,
$contactAssociations,
$prepareTimings
);
$prepareMs = (int) round((microtime(true) - $prepareStart) * 1000);
$missingCompanies = count(array_diff(
$allCompanyIds,
array_keys($associationsData['company_id_mappings'] ?? [])
));
$missingContacts = count(array_diff(
$allContactIds,
array_keys($associationsData['contact_id_mappings'] ?? [])
));
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
$loopStart = microtime(true);
foreach ($deals as $deal) {
$dealStart = microtime(true);
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
$dealMs = (int) round((microtime(true) - $dealStart) * 1000);
if ($dealMs > 1000) {
$slowDeals[] = ['crmId' => $deal['id'], 'ms' => $dealMs];
}
}
$loopMs = (int) round((microtime(true) - $loopStart) * 1000);
$totalMs = (int) round((microtime(true) - $batchStart) * 1000);
$this->logger->info('[' . $this->getDisplayName() . '] importOpportunityBatch timing', [
'teamId' => $this->team->getId(),
'deal_count' => count($deals),
'total_ms' => $totalMs,
'company_assoc_api_ms' => $companyAssocMs,
'contact_assoc_api_ms' => $contactAssocMs,
'prepare_entities_ms' => $prepareMs,
'prepare_accounts_ms' => $prepareTimings['accounts_ms'],
'prepare_contacts_ms' => $prepareTimings['contacts_ms'],
'total_companies' => count($allCompanyIds),
'missing_companies' => $missingCompanies,
'total_contacts' => count($allContactIds),
'missing_contacts' => $missingContacts,
'deals_loop_ms' => $loopMs,
'avg_deal_ms' => ! empty($deals) ? (int) round($loopMs / count($deals)) : 0,
'slow_deals_count' => count($slowDeals),
'slow_deals' => array_slice($slowDeals, 0, 10),
]);
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(
array $companyAssociations,
array $contactAssociations,
array &$timings = []
): array {
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
$accountsMs = 0;
$contactsMs = 0;
if (! empty($allCompanyIds)) {
$start = microtime(true);
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
$accountsMs = (int) round((microtime(true) - $start) * 1000);
}
if (! empty($allContactIds)) {
$start = microtime(true);
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
$contactsMs = (int) round((microtime(true) - $start) * 1000);
}
$timings = [
'accounts_ms' => $accountsMs,
'contacts_ms' => $contactsMs,
];
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist (lean covering-index lookup)
$existingAccountsData = $this->crmEntityRepository
->getExistingAccountIdsMap($this->config, $companyIds);
$missingCompanyIds = array_diff($companyIds, array_keys($existingAccountsData));
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingAccountsData),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist (lean covering-index lookup)
$existingContactsData = $this->crmEntityRepository
->getExistingContactIdsMap($this->config, $contactIds);
$missingContactIds = array_diff($contactIds, array_keys($existingContactsData));
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactsData),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$properties = $crmData['properties'];
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = (string) $properties['hubspot_owner_id'];
$profile = $this->getCachedOwnerProfile($ownerId);
}
$associations = $crmData['associations'] ?? [];
$accountId = $this->resolveAccountId($associations);
// Only fetch account if we need it for user_id fallback
$accountUserId = null;
if ($profile?->getUserId() === null && $accountId !== null) {
$accountUserId = $this->crmEntityRepository
->findAccountByConfigurationAndId(
$this->config,
$accountId
)?->getUserId();
}
$crmId = (string) $crmData['id'];
if (($profile?->getUserId() === null) && ($accountUserId === null)) {
$this->logger->error(
'[HubSpot] Skip import, no user_id found',
[
'id' => $crmId,
]
);
return null;
}
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity(
$crmId,
$properties,
$associations,
$accountUserId
);
}
return $this->createOpportunity(
$crmId,
$properties,
$associations,
$accountUserId,
);
}
/**
* Create new opportunity
*/
private function createOpportunity(
string $crmId,
array $properties,
array $associations,
?int $accountUserId = null
): ?Opportunity {
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData(
$properties,
$accountId,
$businessProcess,
$stage,
$accountUserId
);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(
string $crmId,
array $properties,
array $associations,
?int $accountUserId = null
): Opportunity {
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData(
$properties,
$accountId,
$businessProcess,
$stage,
$accountUserId
);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['account_id'])) {
return $associations['account_id'];
}
if (empty($associations)) {
return null;
}
// Fallback: use first company as account (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage,
?int $accountUserId = null
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->getCachedOwnerProfile((string) $ownerId);
}
$userId = $profile?->getUserId() ?? $accountUserId;
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $userId,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->getCachedBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function getCachedOwnerProfile(string $ownerId): ?Profile
{
$cacheKey = $this->config->getId() . ':' . $ownerId;
if (array_key_exists($cacheKey, $this->cachedOwnerProfiles)) {
return $this->cachedOwnerProfiles[$cacheKey];
}
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, $ownerId);
$this->cachedOwnerProfiles[$cacheKey] = $profile;
return $profile;
}
private function getCachedBusinessProcessRecordType(BusinessProcess $businessProcess): mixed
{
$cacheKey = $this->config->getId() . ':' . $businessProcess->getId();
if (array_key_exists($cacheKey, $this->cachedRecordTypes)) {
return $this->cachedRecordTypes[$cacheKey];
}
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
$this->cachedRecordTypes[$cacheKey] = $recordType;
return $recordType;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
$cacheKey = $this->getBusinessProcessCacheKey($pipelineId);
if (isset($this->cachedBusinessProcesses[$cacheKey])) {
return $this->cachedBusinessProcesses[$cacheKey];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$cacheKey] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function getBusinessProcessCacheKey(string $pipelineId): string
{
return $this->config->getId() . '_' . $pipelineId;
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $this->config->getId() . ':' . $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$this->importOpportunityCrmFieldData(
$properties,
$this->getCachedOpportunitySyncableFields(),
$opportunityId
);
}
private function getCachedOpportunitySyncableFields(): array
{
$cacheKey = (string) $this->config->getId();
if (! isset($this->cachedOpportunitySyncableFields[$cacheKey])) {
$this->cachedOpportunitySyncableFields[$cacheKey] = $this->getOpportunitySyncableFields();
}
return $this->cachedOpportunitySyncableFields[$cacheKey];
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
return $this->performContactAttachment($opportunity, $id, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, int $contactId, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contactId, [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contactId,
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Code changed:
Hide
Sync Changes
Hide This Notification
31...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87512
|
|
87511
|
rapstomCoocFV faVsco.|s ~e t2t2t at-nhosa.foae› D rapstomCoocFV faVsco.|s ~e t2t2t at-nhosa.foae› D RedisD Service TraitsUimacurayscrvict.onoOppontunisinettenswsuncetmahorusncrieeoi20.1125 Kovalkuwwecmrto> D UtilsJ—WeonooKUUeO NIKO©barchsynceo ecror.pipUeo NIKOCBacSVnckewwoe© elent.phgC @osed DealStagesserv.ce8.Ws Koval8ais KovalikDeallFieldsService ohdC) Decorate,ctimtv cho18.11.25 Kovalk8XtwSKovalk18.11.25 Kovalkc) Fediivoeconverter.onorosooteentintertace.on17.04.26 ilianCtosootTokenVansoer.o704226 ienC Pavlosd Bullder ono17.04.26 ilianCRAmordemarseiiha oiktn thKou« PesnoncaNormalize nhocCanes nool© SyncFieldAction.php18.11.25 Kovalik© SyncRelatedActivityManas 18.11.25Kowstre Wanhod Cuneiotthoe18.11.25 Kovalik> B IntegrationApoMlictonore>@ Metadata> Migration& PipedrivevM Salostorce18.11.25Kowstr18.11.25 Kovalik18.11.25 Kovali18.11.25 Kovali18.11.25 Kovali18.11.25 KovalirieldsOoonunyoneTo.lilo Koval18.11.25 KovaltOooonuRWWnod18.11.25 Koval18.11.25 KovalikPANEMCHKGI18.11.25 Koval©) DecorateActivity.choRRIWeKOWceld detinitions ono18.11.25 KovaRc PaviosdBullder.ono8a15 KovskC Profile.ohoC Oueryet der ono© QuervHandler.oho82125 Kovsik18.11.25 KovaRc) Ouwviterator.choc Ouwyeeet teono17.04.26 ilianc) Semice.ond@SwneBatchRodieSonice.ol1740426 HihneRhedsllont nhnoPoeocon.no nhr197118721A77018781091189211841R11DL KouatAotity ll Mew oull reouest trodas 18:10)=custom.loglaravel.lodA SF jiminny@localhostHSJocal jiminny@localhost© RecordSelector.phgA console (PRODOpportunitySyncTrait.php x C CrmEntityRepository.phgT. T :trait OpportunitySynciraitA75 V2Y22 A v 1691private function getCachedBusinessProcessRecordTvpe(BusinessProcess SbusinessProcess)= 178%onivate funciion reso vebusinessprocess@strina Soloelanelid* rBusinessProcessSif (Spipelineid zaz nulb) "E1703=1704=1706=1707ScacheKey = Sthis->getBusinessProcesscachekey(Spipelineld):tossetsthis-scache.busiiness?rocessesScachekevlobcreturn Sthis-scachedBusinessProcesses/ScacheKev1=l17101711-4124—1713Shuesnaeeprocsee =WoieosoatRuetnseepnacneelosnasnetidit ( SbusinessProcess instanceof BusinessProcess) 1Sthis->inpontStagesO;SbusinessProcess = Sthis->getBusinessProcess(Spipelineld)=1714-17191716171717181722if (l $businessProcess instanceof BusinessProcess) {Sthis->logger->info('[HubSpot) Deal is not attached to a pipeline'."pipeline' = Spinelineidl172s17271729msoscachesusnesswrocessesrrachexeulsousnesscrocesss—1751eurnsousnessrorsh1739private function getBusinessProcess(string SpipelineId): ?BusánessProcess.1700private function getBusinessProcessCacheKey(string Spipelineld): stringf...}private function resolveStage(BusinessProcess SbusinessProcess, ?string Sstageld): ?StaC) Salesforce/Service.phgA console (EU) X uin users (EU# console [STAGINGTc AutowPlaygroundOojiminnyORDER BY sms count DESC031 49 A29 У 3 У 109 A 1satecr ustner u.nd, U.enazt, U.nane, u.rcan zo, T.nang as xcam-namct.twilio_sns_sid. t.twilfo.messaging.sidFRON users uINNER JOIN teams t 1.n<->1: ON u.tean_id = t.idWHERE EWEUOSEs SIdS NOTNULL URT.rwRoonessagino.sids NoT NuuwAND U.status = 1RoEk dynare MhenasiSELECT * FRON teans WHERE nane LIKE "stounlanex; # 187, 20%, 8158, salesforce-adningtSELECTCONCAT(U.1d, CASE WHEN U.10 = t.owner_1d THEN' (ouner)" ELSE" END) AS user_10.U.emailsa.*t.owner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teams t 1.nc->1: on t.id = u.tean_1cWHERE u.team_id = 187 and sa.provider = 'salesfonce':select * from activities where id = 31264367.select * from contacts where id = 6331639:select * from accounts where id = 4156632:select * from opportunities where id = 4843618:*Uocre cewieesset 'account_id' = 4156632, "contactid' = 6331639, 'opportunity'activitios'. "undated_at* = 2826-85-22 07:16:17 where $1d* = 3-20select * from text relays where created at > *2826-05-01*;select * rom acrvimes order oy dd descaselect * from users where nane Like "ySubrak',SELECT * FROM opportunitsies WHERE wuid to_bin(*B4a9cfad-2c87-4453-9e72-28aeb78ccf8d*)_select * from teans where 1d = 555;select * from stages where tean. 1d = 555%SELECTCONCAT(u.id, CASE WHEN u.1d = t.owner id THEN • (oaner)' ELSE ** END) AS USen 3alt.owner_id FROM social_accounts sahitin neene non nar ea socsahileJOIN teams t1..n<->1: on t.id = u.tean_ioWHERE u.team_id = 108 and sa.provider = 'hubspot":100% 142• Thu 28 May 18:41:28ServiceTestvmeesdales Orcnnworeeoeionhwmwlino ooo+0.ну з созо о совсо висиени тсоол слиик солешесск моwілеnо досик олжепооосатся кслалсни шк мiсанно wоотсокеsувeunьLet me check what properties are returned by getOpportunitiesByIds:Now I need to check the FieldTransforner - it was listed as applying to dealstage. This could be modifying properties ["dealstage'] before it reaches tuildOpportunitybata:This is just for Ul field definitions, not for syncing. Now the critical question - does anything transtorm properties ["dealstage"] before buildOpportunitywraswsklermechhconwtrtestwhch.mw.naweainoloccsvnc.o.thNow let me look at SyncCreEntitiesTrait to seeit there's a processOpportunityBatch that diverges from buf ldOpportunityDataAll paths converge. Now, let me look at what HubSpot's batch read API actuallly returns for dealstage. I need to check il there's something in processApiRsults or if the HubSpot SDK maps the property differently - specifically, could dealstage be absent from the response when it's a standard property,Actualy = let me re-read the whoic probiem with tresh cyes. The opportunity hasstage. 1d: 20616 (stage with crn provider 1d: closedlost", probability 100)•orchobi titve dea (set by resolvedealProbabfUity("g"))is closed: 1For is closed = 1 AND is von = e to both be true, we need islost = true and isMon = false.For Islost = true, *closedlost" must be in lost [)(probability 0 stages). But stage 20616 has probabllty 100, so "closedlost" is in wontl not lost!l.UNLESS - stage. 1d was set from a different stage lookup than what was used for is won . Let me check resolveStage and bui ldOpportunityDatanonine atada ta comes trom satonssd which is resolwod from reso veStage ShusiinessProcess, Gorocert esl("denilstaon* . Andi stt won lk fromRothlisa Corocartiesttactoa"l Coi shon 14- 20616 /em oroufder 1a. #elocedioct*), than orocartteet"denctana"l = "elocediloste at sunyhernte ond mom noscibity lhavent chockedawha tnhouoncheddlncedhenSthanwne noculnnd belom thn ctann nnhah lity wac undated to 100 lrIsvnc iob? Let me check when staocs were Inet undatcdThe stages show "updated.at*: 2826-05-84 14:55:35* for stages 20612, 20613, and 20616. The opportunity updated at is 2826-05-20 18:36:59*thars 16 davs latee a como erely terent sune inn — co the cache wouldibe rech, Put wa —let me check whether moor taces is callled durineopportunity sync when a stage is missing, and whether that resets the cacheFound it. Look at line 1078 and 1125,WihenbimoortStageslois ca d durina an ooportunity sunc ob bccausa a buginass procass or stace is mise nollrt calle Seryiice:: iroort Staoes whichdoes an updateOrCreate on stages - overwriting probability with HubSpot's value. But critically - Sthis-cachedClosedDealStages is NOT reset after inBut this would only matter il the probability changed during the same lob run. Let me now look at this from a comoletely different anoleTha nenhahatktus 1ah ontha aooortunity ie etorad corroatly Tha de elocedo iecot Golu de mae allet ma chack i4 sbacate anathor coda nath thatcould set 1s,von « 8 on an already-synced opportunity after the sync, e.g. via an observer or event:Now let me re-read the hs_deal_stage_erobab1lity property history very carefully. The most recent value is *T* at timestamp 1779381965775 . Let meconvert that: 1779391965775 1089 = 1779201965,775 seconds since cooch = May 20. 2026 v (matchas rodated at.)I Wodtur Teams 10784 UTE-s Aind...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87511
|
|
87510
|
IAlFirefoxFileEditViewHistoryBookmarksProfilesActi IAlFirefoxFileEditViewHistoryBookmarksProfilesActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormscreenpipereplaydcef_server Helper (Renderer)WindowServerlanguage_server_macos_armFirefoxCP Isolated Web ContentcoreaudiodlaunchservicesdActivity Monitorcef_server Helper (GPU)Firefoxbluetoothdcef_serverFirefoxCP Isolated Web ContentPerfPowerServicesFirefoxCP Isolated Web ContentCursorUlViewService (Not Responding)FirefoxCP Isolated Web Contentio.kandji.KandjiAgent.ESF-ExtensioniTerm2Wispr FlowFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentControl CentreWispr Flow Helper (Renderer)Notion Helper (Renderer)175,699,289,459,553,724,918,49,16,35,44,94,94,23,63,52,82,62,42,32,32,22,22,21,71,71,61,51,4ToolsWindowHelpCPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:22:52,674:36:47,263:54:13,966:46:29,9011:22,578:11:39,3413:18,5742:08,541:02:34,651:04:51,139:59,649:01,901:50:42,2921:30,484:40,7317:16,012:01,9625:05,906:08,3325:39,3832:09,271:05:14,154:38,6444:12,6320:18,9220:56,565:53,8425:27, System:User:Idle:45,71%51,56%2,73%CPUHomeDMsActivityFilesLater..•More+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasileva. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova. Todor Stamatov&. Steliyan Georgiev@ VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:41:28Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87510
|
|
87509
|
rnpstomCoocFv faVsco.|s› D RedisD Service TraitsOp rnpstomCoocFv faVsco.|s› D RedisD Service TraitsOppontunisinetteeswsuncchmthwrdusncrieeoiuwwecmrto> D Utils•WeOnOOK©barchsynceo ecror.pipCBaCSVnckewoec e entohoC @osed DealStacesserv.ceDeallFieldsService ohdC) Decorate,ctimtv choc) Fediivoeconverter.onoCtosootTokenVansoer.oC Pavlosd Bullder onoCRAmoreemac selino onl« PesnoncaNormalize nhoc Canes non© SyncFieldAction.php©) SyncRelatedActivityManase Wanhod Cunetotthoe> B IntegrationApoMlictonore& Metadata>@ Migration& PipedriveMColoctmedrieldsla OpportunitvMatcherOooonuRWWnodPANEMCHKGIC Clent chd©) DecorateActivity.cho€ FieldDefinitions.choc PawlosdBullider.onoC Pronle.ondC Oueryet der ono© QuervHandler.ohoc) Ouwviterator.choc Ouwyeeet teonoc) [EMAIL] nhnoPoeocon.no nhrUmacurayscrict.on13.02.26 Kovalik20xI25 Kowsk20.11.25 Kovalik20rts Kovsik20.11.25 Kovalik20.11.25 Kovalik20.11.25 Kovali20.11.25 Kovalik20.11.25 KovaliLUlitS KoVaLUAlies KovaZUalies KovaUal.cs KovalZUAl.co Koval13.02.26 Kovali18.11.25 KovalRaRIWeKOW26.02228 Konslr76.02228 Kovslr76.02226 Konhlr76.07226 Komslr76.02226 Kovni76.02-26 Koval26.02.26 Kovalt76.02.26 Kovalk2A02 2R KouSN2A02 2A Kousty2A02 28 Koupt23.02.26 KovallkMRUTOE KAupty18.11.25 Kovalik18.11.25 Kovalik18.11.25 Kovalik18.11.25 Kovalik23.02.26 KovalikOO AA DA NHAIA22822$ trodas 18-10=custom.loglaravel.lodA SF jiminny@localhostHSJocal jiminny@localhost© RecordSelector.phg© Activity.phdOpportunitySyncTrait.php x C CrmEntityRepository.phgA console (PRODD 6.esalastorce/Service.onlA console (EU) X iin users (EU# console [STAGINGTC AutopiawarouodvOojiminny1699trait innortuns tySynctratA75 Y2222 A v 1695private function getClosedDealStages: arraysstanes=sthis-scnnanttyreoos.cony-soeciooorcunvulosedstagessthisescontomSdata = L"Lost" => [1'won' => !.E1703= 1700foreach (Sstages as Sstage) 1if (Sstage→>probability == 0.00) 1Sdatal "Lost' = Sstage->crnproviderid;if (Sstage->probability == 100.00) €Sdata['won']( = Sstage->crm provider id;=1713=1214suhisoscasoata.171317191725* Inoort deals sinto the database with ore-fetched assoctatsions)17221722* APT calls here (cetAssociatsionsData. detBxistsing@oportunftubealds) are NoT* caught - if they thron, the exception propagates to ImportOpportunityßatch: :handle() 1725* where Lanavel retries the mole sos wich beckots. Atter au megries exhoustech* failed() requeues all IDs to Redis.17261728* The per-deal Zoop catches exceptions individually. A deal can end up in three states.1725*• succose:imoortedlluodeted succnsstun* • failed ids: exception thrown (DB constraint violation, corrupt data, etc..These are permanent issues - retrying won't fix them.* • skipped (null): nissing dependencies (no account, unknown pipeline/stage).thee arrontahlee tho don monnt ho innontod uint thaco ouet17272 usagesprivate function inportOpportunityßatch(array $deals): arraySsynced0pportunitles ='success' a> 0]'failed ids' => 0soealros = array colunn soeals,ShotAhStonte eierotoneldas tiost truesSslonDeals = fl.column_key: "id');031 49 A29 У 3 У109 A VSELECT DISTINCT u.íd, u.cnail, u.name, u.tcam id, t.nane as team namet.twilio_sns_sid. t.twilfo.nessaging.sidFROM users uINNER JOTN toams t 1..n<->1: ON u.tean id = t.idWHERE (t.twiliosns sid IS NOT NULL OR t.twilio_nessaging_sid TS NOT NULLDORDER BY tunane, u.enaineSFLFCT * FROM teans NHERE nane LTKF "Touclanok*. = 187. 280. 8154. salesfonce-adninatSELECTCONCAT(u.id, CASE WHEN u.id = t.owner_id THEN • (ouner)' ELSE •* END) AS user_id.SAr*townen ¿a FPOM socdal accounts senTM eene uon mfd= sarsochable oJOIN teams t 1.n<->1: on t.id = u.tean_1cWHERE u.team_id = 187 and sa.provider = 'salesfonce":select * from activities where id = 31264367.select * from contacts where id = 6331639:Thoos 4d = 145AA72.select * from opportunities where id = 4843618:accountid' = 4156632,"contactid' = 6331639, 'opportunity#"stage_id' = 13273actsvitios'. "updated.at* = 2926-85-22 87:16:17 where "1d* = 3172select * from text relays where created at > 2826-85-81*÷select * from activities order by id desciselect * from users where nane Like "ySubrak',SELECT * FROM opportunitsies WHERE wuid to_bin(*B4a9cfad-2c87-4453-9e72-28aeb78ccf8d*)select * from teans nhere 1d = 555:select * from stages nhere tean $id = 555.CONCAT(NESA, GASE NHEN 1.3d= +.ONnen 1a THEM " (Oren)" FISE I* FNO) AS user 3alt.ouner_id FROM social_accounts sahtin neene ton le ea socsahilel.nxo>l: on t.id = u.tean_idWHEDE 1tose 4d = 100 and ea nnousdon = thihenatteTO0% L7Thu 28 May 18:41:26sevoelesymeesdales Orcnnworeeoionhwmwlino ooeo+0.ну з созо о совсо висиени тсоол слиик солештсск поwілеnо досик олжепооосатся кслалсни шк мiсанно wоотсокеsувenьLet me check what properties are returned by getOpportunitiesByIds :Now I need to check the FieldTransforner - it was listed as applying to dealstage. This could be modifying properties ["dealstage'] before it reaches tuildOpportunitybatasThis is just for Ul field definitions, not for syncing. Now the critical question - does anything transtorm properties ["dealstage"] before buildOpportunitwraswsklermechhcontrrestwhch.mw.naweainoloccsvnc.o.thNow let me look at SyncCreEntitiesTrait to seeit there's a processOpportunityBatch that diverges from buf ldOpportunityDataAll paths converge. Now, let me look at what HubSpot's batch read API actuallly returns for dealstage. I need to check il there's something in processApiRsults or if the HubSpot SDK maps the property differently - specifically, could dealstage be absent from the response when it's a standard property,Actualy = let me re-read the wholc probicm with tresh cyes. The opportunity hasstage. 1d: 20616 (stage with cr provider 1d: closedlost" probability 100)•orchobi titve dea (set by resolvedealProbabfUity("g"))is closed: 1For is closed = 1 AND is von = e to both be true, we need islost = true and isMon = false.For Islost = true, *closedlost" must be in lost [)(probability 0 stages). But stage 20616 has probabllty 100, so "closedlost" is in wontl not lost!l.UNLESS - stage. 1d was set from a different stage lookup than what was used for is won . Let me check resolveStage and bui ldOpportunityDatanonine atada ta comes trom satonssd which is resolwod from reso lveStage ShusiinessProcess, Gorocert esl("denilstaon* . Andi stt won lk fromRothlisa Corocartiesttactoa"l Coi shon 14- 20616 /em oroufder 1a. #elocedioct»), than orocartteet"denctana"l = "elocediloste at sunyhernte ond mor noscibity lhavent chockedawha tnhouoncheddlncedhenSthanwne noculnnd belom thn ctann nnhah lity wac undated to 100 lrIsvnc iob? Let me check when staocs were Inet undatcdThe stages show "updated.at*: 2826-05-84 14:55:35* for stages 20612, 20613, and 20616. The opportunity updated at is 2826-05-20 18:36:59*thars 16 davs latee a como erely terent sune inn — co the cache wouldibe rech, Put wa —let me check whether moor taces is callled durineopportunity sync when a stage is missing, and whether that resets the cachennnartunstCunctrat.mha:1e7t-112Found it. Look at line 1078 and 1125When inoortStages() is callad durina an opportunity svne iob (becauge a business procees or stage is missina), it calla Servicers iroortStaoes (0 whichdoes an updateOrCreate on stages - overwriting probability with HubSpot's value. But critically - Sthis-cachedClosedDealStages is NOT reset after inBut this would only matter il the probability changed during the same lob run. Let me now look at this from a comoletely different anoleTha nenhahatktus 1ah ontha aooortunity ie etorad corroatly Tha de elocede iecot Golu de mae allet ma chack ie sbacate anathor coda nath thatcould set 1s,von « 8 on an already-synced opportunity after the sync, e.g. via an observer or event:Now let me re-read the hs_deal_stage_erobab1lity property history very carfully. The most recent value is *T* at timestamp 1779381965775 . Let meconvert that: 1779391965775 1089 = 1779201965,775 seconds since cooch = May 20. 2026 v (matchas rodated at.)• Teame 2AR•R UTE....
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87509
|
|
87508
|
[Line] [:column]:
1078
Cancel
OK
Go to Line:Column [Line] [:column]:
1078
Cancel
OK
Go to Line:Column
IAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormscreenpipereplaydcef_server Helper (Renderer)WindowServerlanguage_server_macos_armFirefoxCP Isolated Web ContentcoreaudiodlaunchservicesdActivity Monitorcef_server Helper (GPU)Firefoxbluetoothdcef_serverFirefoxCP Isolated Web ContentPerfPowerServicesFirefoxCP Isolated Web ContentCursorUlViewService (Not Responding)FirefoxCP Isolated Web Contentio.kandji.KandjiAgent.ESF-ExtensioniTerm2Wispr FlowFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentControl CentreWispr Flow Helper (Renderer)Notion Helper (Renderer)175,699,289,459,553,724,918,49,16,35,44,94,94,23,63,52,82,62,42,32,32,22,22,21,71,71,61,51,4CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:22:52,674:36:47,263:54:13,966:46:29,9011:22,578:11:39,3413:18,5742:08,541:02:34,651:04:51,139:59,649:01,901:50:42,2921:30,484:40,7317:16,012:01,9625:05,906:08,3325:39,3832:09,271:05:14,154:38,6444:12,6320:18,9220:56,565:53,8425:27, System:User:Idle:32,37%32,47%35,15%CPUHomeDMsActivityFilesLater..•More+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova. Todor Stamatov&. Steliyan Georgiev@ VesG. MiraH. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <]8•Thu 28 May 18:41:23Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
Go to Line:Column
|
NULL
|
87508
|
|
87507
|
[Line] [:column]:
1078
Cancel
OK
Go to Line:Column [Line] [:column]:
1078
Cancel
OK
Go to Line:Column
rnpstomProjectvCooc› C Redis~ I ServiceTraitsOppontunivsincllereswsuncetmahorusncrieeoiuwwecmrto› @Utils•WeOnOOK© BatchSyncCollector.phpCBaCSVnckewoe© Cllent.phpC @osed DealStacesserv.ceDeallFieldsService ohdC) Decorate,ctimtv choc) Fediivoeconverter.onoCtosootTokenVansoer.o© PayloadBuilder.phpCRAmoreemac selino onlResponseNormalize.php@ Service.php© SyncFieldAction.php© SyncRelatedActivityManas© WebhookSyncBatchProce> B IntegrationApo> @ listeners>@ Metadata› E MigrationPipedrive~ E SalesforcerieldsOpportunityMatcherOooonuRWWnodPANEMCHKGI© Clent.php©DecorateActivity.phpUmacurayscrict.on13.02.26 Kovallk20.11.25 Kovalik20.11.25 Kovalk20.11.25 Kovalik20.11.25 Kovalk20.11.25 Kovalik20.11.25 Kovallk20.11.25 Kovallk20.11.25 Kovalk20.11.25 KovallkLUlitS KoVaLUAlies KovaZUalies KovaUal.cs KovalZUAl.co Koval13.02.26 Kovallk18.11.25 KovalkRaRIWeKOW26.02.26 Kovalik26.02.26 Kovalik26.02228 Konslr26.02.26 Kovalik76.02226 Konhlr26.02.26 Kovalik76.02226 Kovni26.02.26 Kovalik©FieldDefinitions.phpc PawlosdBullider.onoC Pronle.ond26.02.26 Kovallk26.02.26 Kovalik26.02.26 Kovalik26.02.26 Kovalik26.02.26 KovalikC Oueryet der ono©QueryHandler.php23.02.26 Kovallk18.11.25 Kovalikc) Ouwviterator.cho@QueryResults.php18.11.25 Kovalikc) Semice.ond18.11.25 Kovalik©SyncBatchRedisService.pt18.11.25 Kovaiik18.11.25 Kovalik23.02.26 KovalkeRnedsllont nhnoPoeocon.no nhr (today 16:12)©MatchActivityCrmData.php€ RecordSelector.php© OpportunitySyncTrait.php xCrmEntityRepository.phptrait OpportunitySyncTrait475 ×2 222 Aprivate function getClosedDealStages(): arraySstages = Sthis->crnEnt{tyRepository->get0pportunityClosedStages(Sthis->config):Sdata = ["lost' => []."won' »> [1.-1702E1703=17041785)=1706foreach (Sstages as Sstage) €1f (Sstage->probability == 0.00) €Sdata{ 'lost']() = Sstage->crn_provider_id;117091f (Sstage->probability = 100.00) €Sdata['won'](] = Sstage->crn_provider_id;suhisoscasodta=1713)=17141715"[CREDIT_CARD]*vonaesnodwdntreucheo ossodueronis1722* AP caus here cer.ssoctacionstare. detax.iscineiopontuntuirmos ore Mon—1724* caught - If they throu, the exception propagates to ImportOpportunityßatch: :handle() 1725* where Laravel retries the whole job with backoff. After all retries exhaustedE1726* failed() cequeues all I0s to Redis.1727* The per-deal Lo0p catches exceptions indávidually. A deal can end up in three states.1729*• succose:imoortedlluodeted succnsstun* - failed_ids: exception throun (OB constraint violation, corrupt data, etc.)These are pernanent issues - retrying won't fix them.* - skipped (null): nissing dependencies (no account, unknown pipeline/stage).hee arrontahe the ton mannt ho innontod unt thaco oyet-1732=1733 v17342 usagesprivate function inportOpportunityßatch(array $deals): array1736SsyncedOpportunities = ['success' »> [].'faited_ids' »> [],1738soealros = array colunn soeals.column_key: "id');SbatchStart = nicrotine(as.float: true):SsLonDeals = []:E custom.logA console (PROD)E laravel.logA SF (iminny@localhost)© Salesforce/Service.phpTc AutowORDER BY sms_count DESC;PiaygroundHSJocal ([iminny@localhost]A console (EU] * (B users (EU)A console [STAGING)d8 jiminny~031 49 A29 X3 X109 A VSELECT DISTINCT u.íd, u.enail, u.name, u.tcan_id, t.nane as team_namet. twilio_sns_sid, t.twilio_nessaging_sidFRON users uINNER JOIN teans t (1.n<->1: ON U.tean,id = t.idWHERE (t.twilio sns sid IS NOTNULL OR t.twilio_nessaging_sid TS NOT NULLDAND U.status = 1RoEk dynare MhenasieSELECT * FRON teans WHERE nane LIKE "stounlanex; # 187, 20%, 8158, salesforce-adningtSELECTCONCAT(u.id, GASE WHEN U.id = t.owner_id THEN * (owner)' ELSE "* END) AS user_id_v.emait,sa.*,t.owner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t 1.n<->1: on t.id = u.tean_idWHERE v.team_id = 187 and sa-provider = 'salesfonce':Go to Line:Column(Line) [:column):3610;156632,conoeoo- 05107 0000rUhyvpdated_at* = 2826-85-22 87:16:17 where "id* = 3172select * from text_relays where created_at > 2826-85-01';select * from activities order by id desc;select * from users where nane like "XSubrat":SELECT * FROM opportunities WHERE uvid_to_bin('84a9cfad-2c87-4453-9e72-20aeb78ccf8d*)select * from teans where id = 555;select * from stages where tean.d = 555%SELECTCONCAT(U.1d, CASE WHEN U.id = t.ouner_1d THEN * (onner)' ELSE "* END) AS user_1a,sa.*,t.owner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t (1.n<->1: on t.id = v.tean_idWHERE v.team_id = 100 and sa-provider = 'hubspot":$0 M100% K/2Thu 28 May 18:41:23sevoelesyCascadeles Orcnnworeebeionhomwso1ino Ooeoну з созо о совсо висиени тсоол слиик солештсск поwілеnо досик олжепооосатся кслалсни шк мiсанно wоотсокеsувenьLet me check what properties are returned by getOpportunitiesBylds :+0.Now i noed to check the FieldTransforner - it was listed as applying to dealstage. This could be modifying propertiest"dealstage') before it reaches buildOpportunityOata:This is just for UI field definitions, not for syncing. Now the critical question - does arything transform properties ('dealstage'] before buildOpportunitywraswskletmechacknswtwestwhich.mw.nawe.ain.olocc.sune.osthNow let me look at SyncCrafntitiesTrait to see if there's a processOpportunityßatch that diverges from buildOpportunityData:All paths converge. Now, let me look at what HubSpot's batch read API actually returns for deaistage. I need to check il there's something in processApiResults or if the HubSpot SDK maps the property differently - spocifically, could dealstage be absent from the response when it's a standard property?Actualy = let me re-read the wholc probicm with tresh cyes. The opportunity hasstage_ld: 28616 (stage with crn_provider_1d: "closedlost", probability 100)•probability: 188 (set by resolveDealProbability(*I"))is_closed:1For 1s_closed = 1 AND i5,von • 8 to both be true, we need (Istost - true and IsMon • false. For Istost • true, "Closedlost* must be in lost(](probability 0 stages). But stage 20616 has probabllty 100, so "closedlost" is in wontl not lost!l.UNLESS - stage_id was set from a different stage lookup than what was used for is_von . Let me check resolveStage and bui ldOpportunityDatanonine atada ta comes trom satonssd which is resolwod from reso lveStage ShusiinessProcess, Gorocert esl("denilstaon* . Andi stt won lk from3oth use Sproperties ["dealstage") . So if stage_id = 20616 (crn_provider_1d: "Closedlost* ), then properties ("dealstape"] = *closedlost* at syncThere's one more possibility I haven't checked: what if the cachedClosedDealStages was populated before the stage probability was updated to 100 ini sync job? Let me check when stages were last updated:The stages show "updated_at*: "2826-85-e4 14:55:35* for stages 20612, 20613, and 20616. The opportunity updated_at is ~2826-05-20 18:36:59*.thars 16 davs latee a como erely terent sune inn — co the cache wouldibe rech, Put wa —let me check whether moor taces is callled durineopportunity sync when a stage is missing, and whether that resets the cache:OpportunitySyncTrait.php:1075-1126Found it. Look at line 1078 and 1125:When inoortStages() is callad durina an opportunity sune iob (because a business procass or stago is mitsina), it calla Servicers iroortStaoes (0 whichdoes an updatedrtreate on stages - overwriting probability with HubSpot's vatue. But critically - Sthis-scachedClosedDealStages is NOT reset after inBut this would onty matter if the probability changed during the same job run. Let me now look at this from a completely different angie.The probability: 188 on the opportunity is stored correctly. The (is closed: 1 is set. Only 1s ven: 8. Let me check if there's another code path thatcould set 1s,von « 8 on an already-synced opportunity after the sync, e.g. via an observer or event:Now let me re-read the hs_deal_stage_erobab1lity property history very carfully. The most recent value is *T* at timestamp 1779381965775 . Let mecorvert that: 177930196577S / 1088 - 1779301965.775 seconds since epoch = May 20, 2026 v (matches updated_at )."eooh# Teams DARR UTE....
|
PhpStorm
|
Go to Line:Column
|
NULL
|
87507
|
|
87506
|
[Line] [:column]:
10
Cancel
OK
Go to Line:Column
J [Line] [:column]:
10
Cancel
OK
Go to Line:Column
JAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormscreenpipereplaydFirefoxCP Isolated Web ContentWindowServercef_server Helper (Renderer)language_server_macos_armFirefoxCP Isolated Web ContentFirefoxcoreaudiodActivity MonitorFirefoxCP Isolated Web ContentbluetoothdSlackcef_server Helper (GPU)FirefoxCP Isolated Web ContentClaudeSlack Helper (Renderer)iTerm2FirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentNotion Helper (Renderer)cef_serverControl CentreWispr Flow Helper (Renderer)163,9147,868,837,737,429,320,319,58,87,06,44,43,93,83,73,53,12,72,32,12,12,02,01,71,71,61,61,6CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:22:43,474:36:42,073:54:09,276:46:26,7832:46,378:11:38,0311:19,7613:17,6142:08,061:50:42,071:02:34,329:59,3925:39,2621:30,2914:15,709:01,6425:05,7729:57,201:01:17,561:05:14,0317:15,864:38,5220:18,8344:12,5425:27,204:40,5420:56,485:53,76559264System:User:Idle: ,48%38,44%26,08% CPUHomeDMsActivityFilesLater..•More+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova. Todor Stamatov&. Steliyan Georgiev@ VesG. MiraH. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <]8•Thu 28 May 18:41:22Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
Go to Line:Column
|
NULL
|
87506
|
|
87505
|
[Line] [:column]:
10
Cancel
OK
Go to Line:Column
r [Line] [:column]:
10
Cancel
OK
Go to Line:Column
rnpstomProjectvCooc› C Redis~ I ServiceTraitsOppontunivsincllereswsuncetmahorusncrieeoiuwwecmrto› @Utils•WeOnOOK© BatchSyncCollector.phpCBaCSVnckewoe© Cllent.phpC @osed DealStacesserv.ceDeallFieldsService ohdC) Decorate,ctimtv choc) Fediivoeconverter.onoCtosootTokenVansoer.o© PayloadBuilder.phpCRAmoreemac selino onlResponseNormalize.php@ Service.php© SyncFieldAction.php©) SyncRelatedActivityManas© WebhookSyncBatchProce> B IntegrationApo> @ listeners>@ Metadata› E MigrationPipedrive~ E SalesforcerieldsOpportunityMatcherOooonuRWWnodPANEMCHKGI© Clent.php©DecorateActivity.phpUmacurayscrict.on13.02.26 Kovallk20.11.25 Kovalik20.11.25 Kovalk20.11.25 Kovalik20.11.25 Kovalk20.11.25 Kovalik20.11.25 Kovallk20.11.25 Kovallk20.11.25 Kovalk20.11.25 KovallkLUlitS KoVaLUAlies KovaZUalies KovaUal.cs KovalZUAl.co Koval13.02.26 Kovallk18.11.25 KovalkRaRIWeKOW26.02.26 Kovalik26.02.26 Kovalik26.02228 Konslr26.02.26 Kovalik76.02226 Konhlr26.02.26 Kovalik76.02226 Kovni26.02.26 Kovalik©FieldDefinitions.phpc PawlosdBullider.onoC Pronle.ond26.02.26 Kovallk26.02.26 Kovalik26.02.26 Kovalik26.02.26 Kovalik26.02.26 KovalikC Oueryet der ono©QueryHandler.php23.02.26 Kovallk18.11.25 Kovalikc) Ouwviterator.cho@QueryResults.php18.11.25 Kovalikc) Semice.ond18.11.25 Kovalik©SyncBatchRedisService.pt18.11.25 Kovaiikwhtel18.11.25 KovalikeRnedsllont nhn23.02.26 KovalkeRocoCon.nonhr (today 16:12)©MatchActivityCrmData.phpE custom.logA console (PROD)€ RecordSelector.php© OpportunitySyncTrait.php xCrmEntityRepository.phptrait OpportunitySyncTrait475 ×2 222 Av 1671private function getClosedDealStages(): arraySstages = Sthis->crnEnt{tyRepository->get0pportunityClosedStages(Sthis->config):Sdata = ["lost' => []."won' »> [1.foreach (Sstages as Sstage) €1f (Sstage->probability == 0.00) €Sdata{ 'lost']() = Sstage->crn_provider_id;-1702E1703=17041785)=1706=170711[PHONE]101f (Sstage->probability = 100.00) €Sdata['won'](] = Sstage->crn_provider_id;suhisoscasoata.1712=1713)=17141715"[CREDIT_CARD]*vonaesnodwdntreucheo ossodueronis1722* AP caus here cer.ssoctacionstare. detax.iscineiopontuntuirmos ore Mon=1724* caught - If they throu, the exception propagates to ImportOpportunityßatch: :handle() 1725* where Laravel retries the whole job with backoff. After all retries exhausted,E1726* failed() cequeues all I0s to Redis.1727* The per-deal Lo0p catches exceptions indávidually. A deal can end up in three states.1729*• succose:imoortedlluodeted succnsstun1730* - failed_ids: exception throun (OB constraint violation, corrupt data, etc.)These are pernanent issues - retrying won't fix them.* - skipped (null): nissing dependencies (no account, unknown pipeline/stage).hee arrontahe the ton mannt ho innontod unt thaco oyet-1732=1733 v1734-173S17362 usagesprivate function inportOpportunityßatch(array Sdeals): array17381739SsyncedOpportunities = ['success' »> [].'faited_ids' »> [],soealros = array colunn soeals,column_key: "id');SbatchStart = nicrotine(as.float: true):SsLonDeals = []:E laravel.logA SF (iminny@localhost)@ Salesforce/Service.phpTx: AutovORDER BY sms_count DESC;PiaygroundHSJocal ([jminny@localhost)A console (EU] * (B users (EU)A console [STAGING)d8 jiminny~031 49 A29 X3 X109 A VSELECT DISTINCT u.íd, u.enail, u.name, u.tcan_id, t.nane as team_namet. twilio_sns_sid, t.twilio_nessaging_sidFRON users uINNER JOIN teans t (1.n<->1: ON U.tean,id = t.idWHERE EWEUO SEs SIdS NOTNULL OR t.twilio_nessaging_sid TS NOT NULLDAND U.status = 1RoEk dynare MhenastSELECT * FROM teans WHERE nane LIKE "XTourlane%"; # 187, 209, 8150, salesforce-adnin@tiSELECTCONCAT(u.id, GASE WHEN U.id = t.owner_id THEN * (owner)' ELSE "* END) AS user_id_v.emait,sa.*,t.owner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t 1.n<->1: on t.id = u.tean_idWHERE v.team_id = 187 and sa-provider = 'salesfonce':•• .Go to Line:Column(Line) [:column):3610;156632,conoeoo- 05107 0000rUhyvpdated_at* = 2826-85-22 87:16:17 where "id* = 3172select * from text_relays where created_at > 2826-85-01';select * from activities order by id desc;select * from usens where nane oike "SubraSELECT * FROM opportunities WHERE uvid_to_bin('84a9cfad-2c87-4453-9e72-20aeb78ccf8d')select * from teams where id = 555;select * from stages where tean.d = 555%SELECTCONCAT(U.1d, CASE WHEN U.id = t.ouner_1d THEN * (onner)' ELSE "* END) AS user_1a,sa.*,t.owner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t (1.n<->1: on t.id = v.tean_idWHERE v.team_id = 100 and sa-provider = 'hubspot":$0 M100% K/2Thu 28 May 18:41:21sevoelesyCascadeles Orcnnworeeoionhwmwlino ooeoну з созо о совсо висиени тсоол слиик солештсск поwілеnо досик олжепооосатся кслалсни шк мiсанно wоотсокеsувenьLet me check what properties are returned by getOpportunitiesBylds :+0.Now i noed to check the FieldTransforser - it was listed as applying to dealstage. This could be moditying propertiest"dealstage'] before it reaches buildOpportunityOata:This is just for Ul field definitions, not for syncing. Now the critical question - does amything transform properties ['dealstage'] before buildOpportunitywraswskletmechacknswtwestwhich.mw.nawe.ain.olocc.sune.osthNow let me look at SyncCrafntitiesTrait to see if there's a processOpportunityßatch that diverges from buildOpportunityData:All paths converge. Now, let me look at what HubSpot's batch read API actually returns for dealstage . I need to check it there's something in processApiResults or if the HubSpot SDK maps the property differently - spocifically, could dealstage be absent from the response when it's a standard property?Actualy = let me re-read the wholc probicm with tresh cyes. The opportunity hasstage_ld: 28616 (stage with crn_provider,1d: "closedlost", probablity 100)•probability: 188 (set by resolveDealProbability(*I"))is_closed: 1For 1s_closed = 1 AND i5,von • 8 to both be true, we need (Istost - true and IsMon • false. For Istost • true, "Closedlost* must be in lost(](probability 0 stages). But stage 20616 has probabllty 100, so "closedlost" is in wontl not lost!l.UNLESS - stage_id was set from a different stage lookup than what was used for is_von . Let me check resolveStage and bui ldOpportunityDatanonine atada ta comes trom satonssd which is resolwod from reso lveStage ShusiinessProcess, Gorocert esl("denilstaon* . Andi stt won lk from3oth use Sproperties ["dealstage") . So if stage_id = 20616 (crn_provider_1d: *Closedlost*), then properties ('dealstape"] = *closedlost" at syncThere's one more possibility I haven't checked: what if the cachedClosedDealStages was populated before the stage probability was updated to 100 ini sync job? Let me check when stages were last updated:The stages show "updated_at*: "2826-85-e4 14:55:35* for stages 20612, 20613, and 20616. The opportunity updated_at is ~2826-05-20 18:36:59*.thars 16 davs latee a como erely terent sune inn — co the cache wouldibe rech, Put wa —let me check whether moor taces is callled durineopportunity sync when a stage is missing, and whether that resets the cache:OpportunitySyncTrait.php:1075-1126Found it. Look at line 1078 and 1125:When inoortStages() is callad durina an opportunity sune iob (because a business procass or stago is mitsina), it calla Servicers iroortStaoes (0 whichdoes an updatedrtreate on stages - overwriting probability with HubSpot's vatue. But critically - Sthis-scachedClosedDealStages is NOT reset after inBut this would onty matter if the probability changed during the same job run. Let me now look at this from a completely different angie.The probability: 188 on the opportunity is stored correctly. The (is closed: 1 is set. Only 1s ven: 8. Let me check if there's another code path thatcould set 1s,von « 8 on an already-synced opportunity after the sync, e.g. via an observer or event:Now let me re-read the hs_deal_stage_erobab1lity property history very carfully. The most recent value is *T* at timestamp 1779381965775 . Let mecorvert that: 177930196577S / 1088 - 1779301965.775 seconds since epoch = May 20, 2026 v (matches updated_at ).# Teams DARR UTE....
|
PhpStorm
|
Go to Line:Column
|
NULL
|
87505
|
|
87504
|
[Line] [:column]:
246:6
Cancel
OK
Go to Line:Colum [Line] [:column]:
246:6
Cancel
OK
Go to Line:Column
JAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormscreenpipereplaydFirefoxCP Isolated Web ContentWindowServercef_server Helper (Renderer)language_server_macos_armFirefoxCP Isolated Web ContentFirefoxcoreaudiodActivity MonitorFirefoxCP Isolated Web ContentbluetoothdSlackcef_server Helper (GPU)FirefoxCP Isolated Web ContentClaudeSlack Helper (Renderer)iTerm2FirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentNotion Helper (Renderer)cef_serverControl CentreWispr Flow Helper (Renderer)163,9147,868,837,737,429,320,319,58,87,06,44,43,93,83,73,53,12,72,32,12,12,02,01,71,71,61,61,6CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:22:43,474:36:42,073:54:09,276:46:26,7832:46,378:11:38,0311:19,7613:17,6142:08,061:50:42,071:02:34,329:59,3925:39,2621:30,2914:15,709:01,6425:05,7729:57,201:01:17,561:05:14,0317:15,864:38,5220:18,8344:12,5425:27,204:40,5420:56,485:53,76559264System:User:Idle: ,48%44,71%16,80% CPUHomeDMsActivityFilesLater..•More+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova. Todor Stamatov&. Steliyan Georgiev@ VesG. MiraH. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <]8.Thu 28 May 18:41:20Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
Go to Line:Column
|
NULL
|
87504
|
|
87503
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
IAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUPhpStormkernel_taskscreenpipeWindowServerreplaydcef_server Helper (Renderer)Slack Helper (Renderer)language_server_macos_armlaunchservicesdFirefoxCP Isolated Web ContentcoreaudiodClaudeActivity Monitorbluetoothdlaunchdcef_server Helper (GPU)FirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxcef_serverFirefoxCP Isolated Web ContentNotion Helper (Renderer)Wispr FlowiTerm2Karabiner-Core-ServiceFirefoxCP Isolated Web ContentWispr Flow Helper (Renderer)222,7200,480,757,244,726,423,820,110,710,67,15,35,14,44,34,23,32,72,62,62,62,22,12,01,91,81,71,6CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp4:36:34,2124:22:34,763:54:05,628:11:36,486:46:24,7811:18,671:01:17,4413:16,571:04:50,8142:07,601:02:33,9829:57,069:59,1521:30,0819:44,749:01,4517:15,7532:44,3825:05,611:50:41,704:40,4644:12,4525:27,114:38,411:05:13,9221:01,294:45,125:53, System:User:Idle:32,95%30,58%36,47% CPUHomeDMsActivityFilesLater..•More+ED→Jiminny...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova%: Todor Stamatov&. Steliyan Georgiev@ VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:41:17Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87503
|
|
87502
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomCoocFV faVsco.|s ~e "tat2t mt-snota.fd› D RedisD Service TraitsOppontunivsincllereswsuncetmahorusncrieeoiuwwecmrto> D Utils•WeOnOOK©barchsynceo ecror.pipCBacSVnckewwoec e entohoC @osed DealStagesserv.ceDeallFieldsService ohdC) Decorate,ctimtv choc Feldiivoeconverter.onoCtosootTokenVansoer.oC PawlosdBullder.onoCRAmoreemac selino ol« PesnoncaNormalize nhoc Canes non© SyncFieldAction.php© SyncRelatedActivityManase Wanhod Cuneiotthoe> B IntegrationApoMlictonore& Metadata>@ Migration& Pipedrivev MGoloctmedrieldsa OpportunitvMatcherOooonuRWWnodPANEMCHKGIC Clent chd©) DecorateActivity.cho€ FieldDefinitions.choc PaviosdBullder.onoC Pronle.ondC Oueryet der ono© QuervHandler.ohoc) Ouwviterator.choc Ouwyeeet teonoc) [EMAIL] nhnoPoeocon.no nhrUmacurayscrict.on13.02.26 Kovalik20Rt25 Kovslk20.11.25 Kovalik20ats Kovsik20.11.25 Kovalik20.11.25 Kovalik20.11.25 Kovali20.11.25 Kovalik20.11.25 KovaliLUlitS KoVaLUAlits KovaZUalies KovaZUalis KovalZUAl.co Koval13.02.26 Kovali18.11.25 Koval18.11.25 KovalRRIWeKOW26.02228 Konslr76.02228 Kovslr76.02226 Konhlr76.07226 Kovslr76.02226 Komni76.02-26 Koval26.02.26 Kovali76.02.26 Kovalk2A02 2R KouSN2A02 2A Koustin2A02 28 Koupt23.02.26 KovallkMRUTOE KAupty18.11.25 Kovalik18.11.25 Kovalik18.11.25 Kovalik18.11.25 Kovalik23.02.26 Kovalik22822$ Ctty lwew ou teoueet today lhay=custom.loglaravel.lodA SF jiminny@localhostHSJocal jiminny@localhost© RecordSelector.phg© Activity.phd© CrmEntityRepository.phgA console (PRODD 6.esalastorce/Service.onlA console (EU) X uin users (EU# console [STAGINGTC AutopiawarouodvOojiminny1699trait innortuns tySynctiratA75 12222 A v 1695private function getClosedDealStages: arraysstanes=sthis-scnnanttyeoos.cony-soeciooorcunvulosedstagessthisescontomSdata = L"Lost" => [1'won' => !.E1703= 1780foreach (Sstages as Sstage) 1if (Sstage→>probability == 0.00) 1Sdatal "Lost' = Sstage->crnproviderid;if (Sstage->probability == 100.00) €Sdata['won']( = Sstage->crm provider id;=1713=1214suhasescdsoata.171317191725* Inoort deals sinto the database with ore-fetched assoctatsions)172₴1722* APT calls here (cetAssociatzionsData. detBxistsing@oportunftubealds) are NoT* caught - if they thron, the exception propagates to ImportOpportunityßatch: :handle() 1725* where Lanavel retries the mole sos with beckots. Atter au negrias exhoustech1726* failedo) cequeues all 10s to Redis.1728* The per-deal Zoop catches exceptions individually. A deal can end up in three states.1725*• succose:imoortedlluodeteo succhsstuu* • failed ids: exception thrown (DB constraint violation, corrupt data, etc..These are permanent issues - retrying won't fix them.* • skipped (null): nissing dependencies (no account, unknown pipeline/stage).thee arrontahlee tho don monnt ho innontod uint thaco ouet17272 usagesprivate function inportOpportunityßatch(array $deals): arraySsynced0pportunitles ='success' a> 0]'failed ids' => 0soealros = array colunn soeals,ShotAhStonte eierotoneldas tiost truesSslonDeals = fl.column_key: "id');031 49 A29 У 3 У109 A VSELECT DISTINCT u.íd, u.cnail, u.name, u.tcam id, t.nane as team namet.twilio_sns_sid. t.twilfo.messaging.sidFROM users uINNER JOTN toams t 1..n<->1: ON u.tean id = t.idWHERE (t.twiliosns sid IS NOT NULL OR t.twilio_nessaging_sid TS NOT NULLDORDER BY tunane, u.enaineSFLFCT * FROM teans NHERE nane LTKF "Touclanok*: = 187. 280. 8154. salesfonce-adninatSFLFCTCONCAT(u.id, CASE WHEN u.id = t.owner_id THEN • (ouner)' ELSE •* END) AS user_id.SAr*townen ¿a FPOM socdal accounts senTM eene uon mfd= sarsochable oJOIN teams t 1.n<->1: on t.id = u.tean_1cWHERE u.team_id = 187 and sa.provider = 'salesfonce":select * from activities where id = 31264367.select * from contacts where id = 6331639:Thoos 4d = 145AA72.select * from opportunities where id = 4843618:accountid' = 4156632."contactid' = 6331639, opportunity#"stage_id' = 13273actsvitios'. "updated.at* = 2926-85-22 87:16:17 where "1d* = 3172select * from text relays where created at > 2826-85-01*÷select * from activities order by id desciselect * from users where nane Like "ySubrak',SELECT * FROM opportunitsies WHERE wuid to_bin(*B4a9cfad-2c87-4453-9e72-28aeb78ccf8d*)select * from teans nhere 1d = 555:select * from stages nhere tean $id = 555.CONCAT(NESA, GASE NHEN 1.3d= +.ONnen 1A THEM " (OrDeN)" FISE I* FNO) AS user 3alt.ouner_id FROM social_accounts sahitin neene ton le ea socsahilel.nxo>l: on t.id = u.tean_idWHEDE 1tose 4d = 100 and ea nnousdon = thihenatte100% 142• Thu 28 May 18:41:17ServiceTestvmeesdales Orcnnworeeoeionhwmwlino ooo+0.ну з созо о совсо висиени тсоол слиик солешесск моwілеnо досик олжепооосатся кслалсни шк мiсанно wоотсокеsувeunьLet me check what properties are returned by getOpportunitiesByIds :Now I need to check the FieldTransforner - it was listed as applying to dealstage. This could be modifying properties ["dealstage"] before it reaches tuildOpportunitybatasThis is just for Ul field definitions, not for syncing. Now the critical question - does anything transtorm properties ["dealstage"] before buildOpportunitywraswsklermechhconwtrtestwhch.mw.naweainoloccsvnc.o.thNow let me look at SyncCreEntitiesTrait to seeit there's a processOpportunityBatch that diverges from buf ldOpportunityDataAll paths converge. Now, let me look at what HubSpot's batch read API actuallly returns for dealstage. I need to check il there's something in processApiRsults or if the HubSpot SDK maps the property differently - specifically, could dealstage be absent from the response when it's a standard property,Actually = let me re-read the wholc probicm with tresh cyes. The opportunity hasstage. 1d: 20616 (stage with cr provider 1d: closedlost" probability 100)•orchobi titve dea (set by resolvedealProbabfUity("g"))is closed: 1For is closed = 1 AND is von = e to both be true, we need islost = true and isMon = false.For Islost = true, *closedlost" must be in lost [)(probability 0 stages). But stage 20616 has probabllty 100, so "closedlost" is in wontl not lost!l.UNLESS - stage. 1d was set from a different stage lookup than what was used for is won . Let me check resolveStage and bui ldOpportunityDatanonine atada ta comes trom satonssd which is resolwod from reso veStage ShusiinessProcess, Gorocert esl("denilstaon* . Andi stt won lk fromRothlisa Corocartiesttactoa"l Coi shon 14- 20616 /em oroufder 1a. #elocedioct*), than orocartteet"denctana"l = "elocediloste at sunyhernte ond mom noscibity lhavent chockedawha tnhouoncheddlncedhenSthanwne noculnnd belom thn ctann nnhah lity wac undated to 100 lrIsvnc iob? Let me check when staocs were Inet undatcdThe stages show "updated.at*: 2826-05-84 14:55:35* for stages 20612, 20613, and 20616. The opportunity updated at is 2826-05-20 18:36:59*thars 16 davs latee a como erely terent sune inn — co the cache wouldibe rech, Put wa —let me check whether moor taces is callled durineopportunity sync when a stage is missing, and whether that resets the cacheFound it. Look at line 1078 and 1125WihenbimoortStageslois ca d durina an ooportunity sunc ob bccausa a buginass procass or stace is mise nollrt calle Seryiice:: iroort Staoes whichdoes an updateOrCreate on stages - overwriting probability with HubSpot's value. But critically - Sthis-cachedClosedDealStages is NOT reset after inBut this would only matter il the probability changed during the same lob run. Let me now look at this from a comoletely different anoleTha nenhahatktus 1ah ontha aooortunity ie etorad corroatly Tha de elocedo iecot Golu de mae allet ma chack i4 sbacate anathor coda nath thatcould set 1s,von « 8 on an already-synced opportunity after the sync, e.g. via an observer or event:Now let me re-read the hs_deal_stage_erobab1lity property history very carefully. The most recent value is *T* at timestamp 1779381965775 . Let meconvert that: 1779391965775 1089 = 1779201965,775 seconds since cooch = May 20. 2026 v (matchas rodated at.)N Wodtur Teams DARR UTER OAd...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87502
|
|
87501
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
rapstomCoocFV faVsco.|s ~e "tat2t mt-snota.fd› D RedisD Service TraitsOppontunivsincllereswsuncetmahorusncrieeoiuwwecmrto> D Utils•WeOnOOK©barchsynceo ecror.pipCBacSVnckewwoec e entohoC @osed DealStagesserv.ceDeallFieldsService ohdC) Decorate,ctimtv choc Feldiivoeconverter.onoCtosootTokenVansoer.oC PawlosdBullder.onoCRAmoreemac selino ol« PesnoncaNormalize nhoc Canes non© SyncFieldAction.php© SyncRelatedActivityManase Wanhod Cuneiotthoe> B IntegrationApoMlictonore& Metadata>@ Migration& Pipedrivev MGoloctmedrieldsa OpportunitvMatcherOooonuRWWnodPANEMCHKGIC Clent chd©) DecorateActivity.cho€ FieldDefinitions.choc PaviosdBullder.onoC Pronle.ondC Oueryet der ono© QuervHandler.ohoc) Ouwviterator.choc Ouwyeeet teonoc) [EMAIL] nhnoPoeocon.no nhrUmacurayscrict.on13.02.26 Kovalik20Rt25 Kovslk20.11.25 Kovalik20ats Kovsik20.11.25 Kovalik20.11.25 Kovalik20.11.25 Kovali20.11.25 Kovalik20.11.25 KovaliLUlitS KoVaLUAlits KovaZUalies KovaZUalis KovalZUAl.co Koval13.02.26 Kovali18.11.25 Koval18.11.25 KovalRRIWeKOW26.02228 Konslr76.02228 Kovslr76.02226 Konhlr76.07226 Kovslr76.02226 Komni76.02-26 Koval26.02.26 Kovali76.02.26 Kovalk2A02 2R KouSN2A02 2A Koustin2A02 28 Koupt23.02.26 KovallkMRUTOE KAupty18.11.25 Kovalik18.11.25 Kovalik18.11.25 Kovalik18.11.25 Kovalik23.02.26 Kovalik22822$ Ctty lwew ou teoueet today lhay=custom.loglaravel.lodA SF jiminny@localhostHSJocal jiminny@localhost© RecordSelector.phg© Activity.phd© CrmEntityRepository.phgA console (PRODD 6.esalastorce/Service.onlA console (EU) X uin users (EU# console [STAGINGTC AutO MpiawarouodvOo liminny v1699trait innortuns tySynctiratA75 V2222 A v 1695private function getClosedDealStages: arraysstanes=sthis-scnnanttyeoos.cony-soeciooorcunvulosedstagessthisescontomSdata = L"Lost" => [1'won' => !.E1703= 1780foreach (Sstages as Sstage) 1if (Sstage→>probability == 0.00) 1Sdatal "Lost' = Sstage->crnproviderid;if (Sstage->probability == 100.00) €Sdata['won']( = Sstage->crm provider id;=1713=1214suhasescdsoata.171317191725* Inoort deals sinto the database with ore-fetched assoctatsions)172₴1722* APT calls here (cetAssociatzionsData. detBxistsing@oportunftubealds) are NoT* caught - if they thron, the exception propagates to ImportOpportunityßatch: :handle() 1725* where Lanavel retries the mole sos with beckots. Atter au negrias exhoustech1726* failedO) cequeues all 10s to Redis.1728* The per-deal Zoop catches exceptions individually. A deal can end up in three states.1725*• succose:imoortedlluodeteo succhsstuu* • failed ids: exception thrown (DB constraint violation, corrupt data, etc..These are permanent issues - retrying won't fix them.* • skipped (null): nissing dependencies (no account, unknown pipeline/stage).thee arrontahlee tho don monnt ho innontod uint thaco ouet17272 usagesprivate function inportipportunityBatch(array Sdeals): arraySsynced0pportunitles ='success' a> 0]'failed ids' => 0soealros = array colunn soeals,ShotAhStonte eierotoneldas tiost truesSslonDeals = fl.column_key: "id');031 49 A29 У 3 У109 A VSELECT DISTINCT u.íd, u.cnail, u.name, u.tcam id, t.nane as team namet.twilio_sns_sid. t.twilfo.messaging.sidFROM users uINNER JOTN toams t 1..n<->1: ON u.tean_id = t.idWHERE (t.twiliosns sid IS NOT NULL OR t.twilio_nessaging_sid TS NOT NULLDORDER BY tunane, u.enaineSFLFCT * FROM teans NHERE nane LTKF "Touclanok*: = 187. 280. 8154. salesfonce-adninatSFLFCTCONCAT(u.id, CASE WHEN u.id = t.owner_id THEN • (ouner)' ELSE •* END) AS user_id.SAr*townen ¿a FPOM socdal accounts senTM eene uon mfd= sarsochable oJOIN teams t 1.n<->1: on t.id = u.tean_1cWHERE u.team_id = 187 and sa.provider = 'salesfonce":select * from activities where id = 31264367.select * from contacts where id = 6331639:Thoos 4d = 145AA72.select * from opportunities where id = 4843618:amarat#"stage_id' = 13273accountid' = 4156632."contactid' = 6331639, opportunityactsvitios'. "updated.at* = 2926-85-22 87:16:17 where "1d* = 3172select * from text relays where created at > *2826-05-01*;select * from activities order by id desciselect * from users where nane Like "ySubrak',SELECT * FROM opportunitsies WHERE wuid to_bin(*B4a9cfad-2c87-4453-9e72-28aeb78ccf8d*)select * from teans nhere 1d = 555:select * from stages nhere tean $id = 555.CONCAT(NESA, GASE NHEN 1.3d= +.ONnen 1A THEM " (OrDeN)" FISE I* FNO) AS user 3alt.ouner_id FROM social_accounts sahitin neene ton le ea socsahilel.nxo>l: on t.id = u.tean_idWHEDE 1tose 4d = 100 and ea nnousdon = thihenatte100% 142• Thu 28 May 18:41:11ServiceTestvmeesdales Orcnnworeeoeionhwmwlino ooo+0.ну з созо о совсо висиени тсоол слиик солешесск моwілеnо досик олжепооосатся кслалсни шк мiсанно wоотсокеsувeunьLet me check what properties are returned by getOpportunitiesByIds:Now I need to check the FieldTransforner - it was listed as applying to dealstage. This could be modifying properties ["dealstage"] before it reaches tuildOpportunitybatasThis is just for Ul field definitions, not for syncing. Now the critical question - does anything transtorm properties ["dealstage"] before buildOpportunitywraswsklermechhconwtrtestwhch.mw.naweainoloccsvnc.o.thNow let me look at SyncCreEntitiesTrait to seeit there's a processOpportunityBatch that diverges from buf ldOpportunityDataAll paths converge. Now, let me look at what HubSpot's batch read API actuallly returns for dealstage. I need to check il there's something in processApiRsults or if the HubSpot SDK maps the property differently - specifically, could dealstage be absent from the response when it's a standard property,Actually = let me re-read the wholc probicm with tresh cyes. The opportunity hasstage. 1d: 20616 (stage with cr provider 1d: closedlost" probability 100)•orchobi titve dea (set by resolvedealProbabfUity("g"))is closed: 1For is closed = 1 AND is von = e to both be true, we need islost = true and isMon = false.For Islost = true, *closedlost" must be in lost [)(probability 0 stages). But stage 20616 has probabllty 100, so "closedlost" is in wontl not lost!l.UNLESS - stage. 1d was set from a different stage lookup than what was used for is won . Let me check resolveStage and bui ldOpportunityDatanonine atada ta comes trom satonssd which is resolwod from reso veStage ShusiinessProcess, Gorocert esl("denilstaon* . Andi stt won lk fromRothlisa Corocartiesttactoa"l Coi shon 14- 20616 /em oroufder 1a. #elocedioct*), than orocartteet"denctana"l = "elocediloste at sunyhernte ond mom noscibity lhavent chockedawha tnhouoncheddlncedhenSthanwne noculnnd belom thn ctann nnhah lity wac undated to 100 lrIsvnc iob? Let me check when staocs were Inet undatcdThe stages show "updated.at*: 2826-05-84 14:55:35* for stages 20612, 20613, and 20616. The opportunity updated at is 2826-05-20 18:36:59*thars 16 davs latee a como erely terent sune inn — co the cache wouldibe rech, Put wa —let me check whether moor taces is callled durineopportunity sync when a stage is missing, and whether that resets the cacheFound it. Look at line 1078 and 1125WihenbimoortStageslois ca d durina an ooportunity sunc ob bccausa a buginass procass or stace is mise nollrt calle Seryiice:: iroort Staoes whichdoes an updateOrCreate on stages - overwriting probability with HubSpot's value. But critically - Sthis-cachedClosedDealStages is NOT reset after inBut this would only matter il the probability changed during the same lob run. Let me now look at this from a comoletely different anoleTha nenhahatktus 1ah ontha aooortunity ie etorad corroatly Tha de elocedo iecot Golu de mae allet ma chack i4 sbacate anathor coda nath thatcould set 1s,von « 8 on an already-synced opportunity after the sync, e.g. via an observer or event:Now let me re-read the hs_deal_stage_erobab1lity property history very carefully. The most recent value is *T* at timestamp 1779381965775 . Let meconvert that: 1779391965775 1089 = 1779201965,775 seconds since cooch = May 20. 2026 v (matchas rodated at.)N Wodtur Teams DARR UTER OAd...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87501
|
|
87500
|
rapstomCoocWindowFV faVsco.s ~#12121 on JY-20963-f rapstomCoocWindowFV faVsco.s ~#12121 on JY-20963-fx-lproidetcteamoremo tewineResponseNormalize.phoMmacuryochyict.ongsevice.on:esunchiorcdon.onGSUNCKOlOhCWWWKeweonooinlneochhoe>D IntearationAod>O Listeners0a Metadatala Pipedrivev SalestorceaFeldsa opoortunitvVatchenOpportunitySyncStrategy> ProspectsearchStrategyeSemicetiraits334 61Ceentone© DecorateActivity.choDeleteObiectsTrait.phpoead natioitionenno336337© PayloadBuilder.phpc) Profile.php© QueryBuilder.pho© QueryHandler.php383386 61ewuenywerator.on© QueryResults.phpe Sarnce oho© SyncBatchRedisService.pthTinltel© BaseClient.php© BaseService.phpe Coshorerm ContnohoAmntdconiooekesowod() CrmActivityProviderinteorateCCINTCMINMOronCDeauitostAe3indsProscectinterace.onC) LavoutMansoer ono3MatchDomsinsvamsinteraeC Ooportur vActvtwwatche3oportun tySyncStratsovintc @nportun tySyncStratcoyeec Prosoectenche.ondci DrnenontCostchCtttamDrnenontCostrhCtratomcntoe orawtordo ctr nhre DonnriColontor nho© RecordSelector.phgOpportunitySyncTrait.php*soaras anmaucanrauiid:string.labelistring,value?: string* }> Soptions* Rthrows CrmException* Breturn FieldData()public function inportPicklistValuesdField Sfioldannay sodcons-wog: array f...* onhertdodpublic function importStages(?array Stypes = null, ?string SmissingStageNane = null): ?StageSaissinoStage = nuuie// Use the HubSpot APT client instead of the SDK cr-Pinelines() nethodCandndint = sa f.-cerieci spretnecho.coso?oeninellnacrasnonse = hisesei bent-scertoctance n-Soerik b entidesredulest tmethod: "GET", Sendpoint): .Casinabinae = Sosnalsnackaeconss.saar.esraeulltecatchrcaaunersycantsoollRacbeonset Caycantoonlthrow Sexcept.onh17281729foreach (Spipelines as Spipeline) 4Cetnade =(e-173)-1733 м// We create a business process to contain the pipeline, and store all stages against it$p = ResponseNornalize::nonmalizePipeline(Spipeline):— 173// Create/uodate business process for this pipelineSbusinessProcess = Sthis->config-sbusinessProcesses@->update0rCreateci"con.oroyider id' a Sofsid'=175€start:0,width: 150)= BusiinessProcess:TYPE_OPPORTUNTTY.=> Solactve"=custom.loglaravel.lodA SF jiminny@localhostA console (PRODaswasforce/Service.oni01 A7 A149 Y1 Y33 21 A v 1691167%17321788H4MAA1716A console (EU) X uin users (EU# console [STAGINGpiiwarouodvORDER BY sms count DESCOojiminny031 49 A29 V 3 У 109 A 1SELECT DISTINCT u.íd, u.enail, u.name, u.tcan_id, t.nane as team_namet.twilio_sns.-sid. t.twilfo.nessaging.sidFROM users uINNER JOIN teans t (1.n<->1: ON U.tean,id = t.idWHERE (t.twiliosns sid IS NOT NULL OR t.twilfo_nessaging_sid TS NOT NULLDAND U.status = 1RoEk dynare MhenasiSELECT * FROM teams WHERE nane LIKE '%TounLane%': # 187, 209, 8150, salesforce-adnin@tSELECTCONCAT(u.id, CASE WHEN u.id = t.owner_id THEN • (ouner)' ELSE •* END) AS user_id.sa.*t.owner_id FROM social_accounts saJOIN users u on u.id = sa.soclable_icJOIN teams t 1.n<->1: on t.id = u.tean_1cWHERE u.team_id = 187 and sa.provider = 'salesfonce':select * from activities where id = 31264367.select * from contacts where id = 6331639:select * from accounts where id = 4156632:select * from opportunities where id = 4843618:# update "activitios" set 'account_id' = 4156632"contactid' = 6331639, 'opportunity# "stage-id" = 13273'activitios'. "undated_at* = 2826-85-22 07:16:17 where $1d* = 3-20select * from text relays where created at > *2826-05-01*;select * from activities order by id desciselect * from usens where nane Like "ySubrak"SELECT * FROM opportunitsies WHERE wuid to_bin(*B4a9cfad-2c87-4453-9e72-28aeb78ccf8d*)_select * from teans nhere sid = sss:select * from stages nhere tean 1d = 555;CONCAT(u.id, CASE WHEN u.1d = t.owner_id THEN • (onner)' ELSE •* END) AS user_idt.ouner_id FROM social_accounts sahitin neene ton le ea socsahile1..n<->1: on t.id = u.tean_ioWHEDE 1tose 4d = 100and ea noousdon = thuhenotte100% 142• Thu 28 May 18:41:06ServiceTestvmeesdales Orcnnworeebeeionhomwso1ino ooeo+0.ну з созо о совсо висиени тсоол слиик солешесск моwілеnо досик олжепооосатся кслалсни шк мiсанно wоотсокеsувeunьLet me check what properties are returned by getOpportunitiesByIds:Now I need to check the FieldTransforner - it was listed as applying to dealstage. This could be modifying properties ["dealstage'] before it reaches tuildOpportunitybatasThis is just for Ul field definitions, not for syncing. Now the critical question - does anything transtorm properties ["dealstage"] before buildOpportunitywraswskletmechacknswtwestwhich.m.nawe.ainolocc.sune.osthNow let me look at SyncCreEntitiesTrait to seeit there's a processOpportunityBatch that diverges from buf ldOpportunityDataAll paths converge. Now, let me look at what HubSpot's batch read API actuallly returns for dealstage. I need to check il there's something in processApiRsults or if the HubSpot SDK maps the property differently - specifically, could dealstage be absent from the response when it's a standard property,Actualy = let me re-read the whoic probiem with tresh cyes. The opportunity hasstage. 1d: 20616 (stage with crn provider 1d: closedlost", probability 100)•orchobi titve dea (set by resolvedea1ProbabfUty("g"))is closed: 1For is closed = 1 AND is von = e to both be true, we need islost = true and isMon = false.For Islost = true, *closedlost" must be in lost [)(probability 0 stages). But stage 20616 has probabllty 100, so "closedlost" is in wontl not lost!l.UNLESS - stage. 1d was set from a different stage lookup than what was used for is won . Let me check resolveStage and bui idOpportunityDatanonine atada ta comes trom satonssd which is resolwod from reso veStage ShusiinessProcess, Gorocert esl("denilstaon* . Andi stt won lk fromRothlisa Corocartiesttactoa"l Coi shon 14- 20616 /em oroufder 1a. #elocedioct*), than orocartteet"denctana"l = "elocediloste at sunyhernte ond mom noscibity lhavent chockedawha tnhouoncheddlncedhenSthanwne noculnnd belom thn ctann nnhah lity wac undated to 100 lrIsvnc iob? Let me check when staocs were Inet undatcdThe stages show "updated.at*: 2826-05-84 14:55:35* for stages 20612, 20613, and 20616. The opportunity updated at is 2826-05-20 18:36:59*thars 16 davs latee a como erely terent sune inn — co the cache wouldibe rech, Put wa —let me check whether moor taces is callled durineopportunity sync when a stage is missing, and whether that resets the cacheFound it. Look at line 1078 and 1125,WihenbimoortStageslois ca d durina an ooportunity sunc ob bccausa a buginass procass or stace is mise nollrt calle Seryiice:: iroort Staoes whichdoes an updateOrCreate on stages - overwriting probability with HubSpot's value. But critically - Sthis-cachedClosedDealStages is NOT reset after inBut this would only matter il the probability changed during the same lob run. Let me now look at this from a comoletely different anoleTha nenhahatktus 1ah ontha aooortunity ie etorad corroatly Tha de elocedo iecot Golu de mae allet ma chack i4 sbacate anathor coda nath thatcould set 1s,von « 8 on an already-synced opportunity after the sync, e.g. via an observer or event:Now let me re-read the hs_deal_stage_erobab1lity property history very carefully. The most recent value is *T* at timestamp 1779381965775 . Let meconvert that: 1779391965775 1089 = 1779201965,775 seconds since cooch = May 20. 2026 v (matchas nodated at ))wtt//Wiawoutteoueettodaw 1ReyI Mndeud Thams 2R0M UTE AiAd...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87500
|
|
87499
|
JAlFirefoxFileEditViewHistoryBookmarksProfilesTool JAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormreplaydWindowServercef_server Helper (Renderer)language_server_macos_armscreenpipeFirefoxCP Isolated Web ContentFirefoxCP Isolated Web Contentcef__server Helper (GPU)Firefoxcoreaudiodcef_serverActivity MonitorClaudebluetoothdSlack Helper (Renderer)launchservicesdFirefoxCP Isolated Web ContentFirefoxCP Isolated Web Contentierm2FirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentNotion Helper (Renderer)FirefoxCP Isolated Web ContentWispr FlowKarabiner-Core-ServiceWispr Flow Helper (Renderer)176,5162,360,248,531,625,716,19,89,18,86,96,66,24,54,44,23,83,52,92,82,52,42,32,22,22,01,71,6CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:22:14,194:36:15,656:46:19,688:11:30,0811:15,8113:13,103:53:58,2142:06,5144:12,259:00,991:50:41,261:02:33,254:40,199:58,5629:56,5021:29,631:01:16,071:04:50,2325:05,3420:18,531:05:13,7317:15,4732:44,1125:26,9313:51,294:38,2121:01,115:53,51559260System:User:Idle: ,52%44,31%12,17%CPUHomeDMsActivity+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova%: Todor Stamatov&. Steliyan Georgiev&. VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <78•Thu 28 May 18:41:06Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
NULL
|
87499
|
|
87498
|
JAlFirefoxFileEditViewHistoryBookmarksProfilesTool JAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormreplaydWindowServercef_server Helper (Renderer)language_server_macos_armscreenpipeFirefoxCP Isolated Web ContentFirefoxCP Isolated Web Contentcef__server Helper (GPU)Firefoxcoreaudiodcef_serverActivity MonitorClaudebluetoothdSlack Helper (Renderer)launchservicesdFirefoxCP Isolated Web ContentFirefoxCP Isolated Web Contentierm2FirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentNotion Helper (Renderer)FirefoxCP Isolated Web ContentWispr FlowKarabiner-Core-ServiceWispr Flow Helper (Renderer)176,5162,360,248,531,625,716,19,89,18,86,96,66,24,54,44,23,83,52,92,82,52,42,32,22,22,01,71,6CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:22:14,194:36:15,656:46:19,688:11:30,0811:15,8113:13,103:53:58,2142:06,5144:12,259:00,991:50:41,261:02:33,254:40,199:58,5629:56,5021:29,631:01:16,071:04:50,2325:05,3420:18,531:05:13,7317:15,4732:44,1125:26,9313:51,294:38,2121:01,115:53,51559260System:User:Idle: ,65%46,12%2,23%CPUHomeDMsActivity+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi..Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka StoyanovaR. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova%: Todor Stamatov&. Steliyan Georgiev&. VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:41:04Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87498
|
|
87497
|
Project: faVsco.js, menu
rapstomViewNeweNNCCoocKet Project: faVsco.js, menu
rapstomViewNeweNNCCoocKetucioWindowFV faVsco.|s ~#12121 on JY-20963-fx-incSamcetescon=custom.loglaravel.lodA SF jiminny@localhostHSJocal jiminny@localhostcteamoremo tewineResponseNormalize.phoUmacurayscrict.oneeachmeimserexooecoroto.onegsevice.on:esunchiorcdon.onclass service excenos baseservace aolesencs© RecordSelector.phg© Activity.pho(C) Hubspot/Service.php X OpportunitySyncTrait.php© CrmEntityRepository.phdGSUNCKOlOhCWWWKpubuzc tunccion savelranscrzpczonsuaryasnotelPworcubnect Snoreubneci = nultyeweonooinlneochhoe): Pstring f...}>D IntearationAod>O Listeners7usaoes>& Metadatala Pipedrivev SalestorceaFeldsaopoortunityMatchenOpportunitySyncStrategy> ProspectsearchStrategyeSemicetiraitsC) e entondC Decorate ctimm cno4076 00)21992101lnaldtsor weetihooo2187oead natioitionenno2188© PayloadBuilder.php2109alorotis noo2110© QueryBuilder.php© QueryHandler.php2112© Queryiterator.pho2113© QueryResults.phpe Sarnce oho© SyncBatchRedisService.pt 2115TraitelRasedlientond2132© BaseService.phpC CachedCrmServiceDecoratorconiooekesowod€ CrmActivitvProviderinteorateCCINTCMINMOronoweauiwostenee3FindsProscectnterace.onC) LavoutMansoer ono3MatchDomsinsvamsinteraeC Ooportur vActvtwwatche2141 003Ooportun tySyncStratscr ntec Opportun tySyncStratcoreeec Prosoectenche.ond« ProsocctSearchScone.ohoc OrnenontCostchCttamA PrnenontSonrch Gtetondotoe orawtordo ctr nhrDorarriColontor nho01 47 A149 /1 /33 11 A V 1690A console (PRODC) Salesforce/Service.phgA console (EU) X uin users (EU# console [STAGINGD 6.TC AutoORDER BY sms count DESCpihwarouodyOo liminny v031 49 A29 У 3 У109 A V167%1780178)public function attachSunnaryToActivitv(ActivityContract Sactivity, string SsunnaryTitle, string SsunnarvC 17851 usageprivate function buildMetadataForSunnaryUpdate(Activity Sactivity, string Ssunnary): arrayf...public function fetchAndAssociateRelatedActivity(Activity Sactivity): ?Activity(...}public function fetchRelatedActivity(Activity Sactivity): arrayf...}5 usagespublic function getDealsInBulk(array SdealIds): arrayi...)* Extract deal IDs fron HubSpot search response* Boanam arrau ShubspotResponse The ran HubSpot search API response.* @pacam bool SincludeArchived Whether to include archived deals (default: false).* Bratumn stringul Arrau of deal IDs as stringspublic function extractDealIds(array ShubspotResponse, bool SincludeArchived = false): arravf.....usaoepublic function natchActivityEngagementTvpe(Activity Sactivity): string...,private function assignCenüuner(User Suser. ActivityContract Sactivity): 2Prosilef....1ussotprivate static function getbealsPipelinesendpoint: stringreturn self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OJECT_TYPE_DEALSpublic function verifytaskexists(Activity Sactivity): boolt...1716=1722_17251724=172717281740SELECT DISTINCT u.íd, u.cnail, u.name, u.tcam id, t.nane as team namet.twilio_sns_sid. t.twilfo.messaging.sidFROM users uINNER JOTN toams t 1..n<->1: ON u.tean_id = t.idWHERE (t.twiliosns sid IS NOT NULL OR t.twilio_nessaging_sid TS NOT NULLDRoEk dynare MhenasiSFLFCT * FROM teans NHERE nane LTKF "Touclanok. = 187. 280. 8154. salesfonce-adninat.SFLFCTCONCAT(u.id, CASE WHEN u.id = t.owner_id THEN • (ouner)' ELSE •* END) AS user_id.sa.*t.owner_id FROM social_accounts sanTM eene uon mfd= sarsochable oJOIN teams t 1.n<->1: on t.id = u.tean_1cWHERE u.team_id = 187 and sa.provider = 'salesfonce':select * from activities where id = 31264367.select * from contacts where id = 6331639:calart. * foom accounte nhons 4d = 1454432.select * from opportunities where id = 4843618:# update "activitios""AAAIAt A' C AGUZA"contactid' = 6331639, opportunity#"stagc_id" = 13273activities".'uodated..at"= 2826-85-22 07:16:17 where $1d* = 320select * from text relays where created at > *2826-05-01*;select * from activities order by id desciselect * from users where nane Like "ySubrak',SELECT * FROM opportunitsies WHERE wuid to_bin(*B4a9cfad-2c87-4453-9e72-28aeb78ccf8d*)_select * from teans nhere 1d = 555:select * from stages nhere tean 1d = 555;SFLFCTCONCAT(NESA, GASE NHEN 1.3d= +.ONnen 1A THEM " (OrDeN)" FISE I* FNO) AS user 3alt.ouner_id FROM social_accounts sahitin neene ton le ea socsahilel.nxo>l: on t.id = u.tean_idWHEDE 1 tose 4d = 100 and en noovsdon = thuhenotte1 MILottywews outrentaettoday lRey1O0% LXThu 28 May 18:41:01ServiceTestvmeesdales Orcnnworeeoeionhomwso1ino ooeo+0.ну з созо о совсо висиени тсоол слиик солешесск моwілеnо досик олжепооосатся кслалсни шк мiсанно wоотсокеsувeunьLet me check what properties are returned by getOpportunitiesByIds:Now I need to check the FieldTransforner - it was listed as applying to dealstage. This could be modifying properties ["dealstage'] before it reaches tuildOpportunity0at.This is just for Ul field definitions, not for syncing. Now the critical question - does anything transtorm properties ["dealstage"] before buildOpportunitywraswskletmechacknswtwestwhich.m.nawe.ainolocc.sune.osthNow let me look at SyncCreEntitiesTrait to seeit there's a processOpportunityBatch that diverges from buf ldOpportunityDataAll paths converge. Now, let me look at what HubSpot's batch read API actuallly returns for dealstage. I need to check it there's something in processApiRsults or if the HubSpot SDK maps the property differently - specifically, could dealstage be absent from the response when it's a standard property,Actualy = let me re-read the whoic probiem with tresh cyes. The opportunity hasstage. 1d: 20616 (stage with crn provider 1d: closedlost", probability 100)•orchobi titve dea (set by resolvedealProbabfUity("g"))is closed: 1For is closed = 1 AND is von = e to both be true, we need islost = true and isMon = false.For Islost = true, *closedlost" must be in lost [)(probability 0 stages). But stage 20616 has probabllty 100, so "closedlost" is in wontl not lost!l.UNLESS - stage. 1d was set from a different stage lookup than what was used for is won . Let me check resolveStage and bui idOpportunityDatanonine atada ta comes trom satonssd which is resolwod from reso veStage ShusiinessProcess, Gorocert esl("denilstaon* . Andi stt won lk fromRothlisa Corocartiesttactoa"l Coi shon 14- 20616 /em oroufder 1a. #elocedioct*), than orocartteet"denctana"l = "elocediloste at sunyhernte ond mom noscibity lhavent chockedawha tnhouoncheddlncedhenSthanwne noculnnd belom thn ctann nnhah lity wac undated to 100 lrIsvnc iob? Let me check when staocs were Inet undatcdThe stages show "updated.at*: 2826-05-84 14:55:35* for stages 20612, 20613, and 20616. The opportunity updated at is 2826-05-20 18:36:59*thars 16 davs latee a como erely terent sune inn — co the cache wouldibe rech, Put wa —let me check whether moor taces is callled durineopportunity sync when a stage is missing, and whether that resets the cachennnartunstCunctrat.mha.1e7t-112Read OpportunitySyncTrait.php #L1055-1174spp/Services/Crm/Hubspot/Service.php.gWhen imoortStages() is callad durina an opportunity svne iob (becauge a business procees or stage is mistina), it calla Servicers iroortStaoes (0 whichdoes an updateOrCreate on stages - overwriting probability with HubSpot's value. But critically - Sthis-cachedClosedDealStages is NOT reset after inBut this would only matter il the probability changed during the same lob run. Let me now look at this from a comoletely different anoleTha nenhahatktus 1ah ontha aooortunity ie etorad corroatly Tha de elocedo iecot Golu de mae allet ma chack i4 sbacate anathor coda nath thatcould set 1s,von « 8 on an already-synced opportunity after the sync, e.g. via an observer or event:Now let me re-read the hs_deal_stage_erobab1lity property history very carefully. The most recent value is *T* at timestamp 1779381965775 . Let meconvert that: 1779391965775 1089 = 1779201965,775 seconds since cooch = May 20. 2026 v (matchas nodated at ))NN Windsurf Teams 2158:75 UTF-8 24 s...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87497
|
|
87496
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
FirefoxFileEditViewHistoryBookmarksProfilesActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormreplaydcef_server Helper (Renderer)language_server_macos_armWindowServerscreenpipeFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentcoreaudiodFirefoxFirefoxCP Isolated Web ContentActivity Monitorcef_server Helper (GPU)FirefoxCP Isolated Web ContentlaunchservicesdsyspolicydbluetoothdtccdFirefoxCP Isolated Web Contentcef_serverSlack Helper (Renderer)FirefoxCP Isolated Web ContentSlackWispr FlowWispr Flow Helper (Renderer)BitwardenFirefoxCP Isolated Web Content138,398,253,719,919,017,517,317,28,06,56,23,93,83,73,23,13,13,03,02,82,22,12,02,02,01,51,51,5ToolsWindowHelpCPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:22:04,844:36:07,066:46:16,4911:14,1313:11,748:11:27,513:53:57,3625:05,1842:06,001:02:32,891:50:40,892:43,569:58,329:00,5220:18,381:04:50,0417:34,9721:29,409:23,3332:43,994:39,861:01:15,8717:15,3414:15,344:38,105:53,422:36,4410:26,87559256System:User:Idle: ,94%44,93%13,13%CPUHomeDMsActivityFilesLater..•More+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova. Todor Stamatov&. Steliyan Georgiev&. VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:41:01Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87496
|
|
87495
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity ...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87495
|
|
87494
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity ...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87494
|
|
87493
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity ...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87493
|
|
87492
|
IAlFirefoxFileEditViewHistoryBookmarksProfilesActi IAlFirefoxFileEditViewHistoryBookmarksProfilesActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormreplaydlanguage_server_macos_armcef_server Helper (Renderer)WindowServerFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentscreenpipeFirefoxCP Isolated Web Contentcef_server Helper (GPU)coreaudiodlaunchservicesdcef_serverlaunchdActivity MonitorFirefoxbluetoothdFirefoxCP Isolated Web ContentiTerm2SlackPostman Helper (Renderer)Wispr FlowClaude Helper (Renderer)Notion Helper (Renderer)ClaudeWispr Flow Helper (Renderer)logd194,9134,543,041,135,634,722,921,114,7ToolsWindowHelpCPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:20:52,414:35:11,436:45:53,7813:01,4310:57,588:11:13,9724:58,2944:11,383:53:51,0342:02,038:57,581:02:30,101:04:48,374:37,9119:43,949:56,531:50:38,7821:27,8117:14,311:05:12,8514:14,777:50,164:37,2611:32,3125:26,1629:55,425:52,7617:23,23559266System:User:Idle: ,15%34,27%8,58%CPUHomeDMsActivity+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaGo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova. Todor Stamatov&. Steliyan Georgiev@ VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <78•Thu 28 May 18:40:16Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87492
|
|
87491
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity ...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87491
|
|
87490
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormreplaydscreenpipeFirefoxCP Isolated Web Contentlanguage_server_macos_armcef_server Helper (Renderer)WindowServerFirefoxCP Isolated Web ContentcoreaudiodFirefoxlaunchservicesdcef_server Helper (GPU)FirefoxCP Isolated Web ContenttccdFirefoxCP Isolated Web ContentActivity MonitorbluetoothdsyspolicydWispr Flow Helper (Renderer)FirefoxCP Isolated Web Contentcef_serverFirefoxCP Isolated Web ContentSlack Helper (Renderer)iTerm2FirefoxCP Isolated Web ContentBitwardentrustd192,1137,557,138,930,225,922,518,410,17,26,65,84,74,54,53,93,63,53,32,92,82,52,42,22,02,02,01,9CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:20:12,854:34:44,566:45:43,273:53:47,6424:54,1112:54,5810:48,358:11:07,0341:59,871:02:28,691:50:37,961:04:47,508:55,8132:40,559:22,8017:13,719:55,5621:26,9617:34,385:52,4213:50,114:36,8120:16,981:01:13,441:05:12,416:38,702:36,025:23,42559264System:User:Idle: ,62%35,11%36,28% HomeDMsActivityFilesLater..•More+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova. Todor Stamatov&. Steliyan Georgiev@ VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:39:58Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87490
|
|
87489
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity ...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87489
|
|
87488
|
JAlFirefoxFileEditViewHistoryBookmarksProfilesWind JAlFirefoxFileEditViewHistoryBookmarksProfilesWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormreplaydFirefoxCP Isolated Web Contentlanguage_server_macos_armcef_server Helper (Renderer)WindowServerFirefoxCP Isolated Web Contentmds_storesscreenpipeFirefoxCP Isolated Web ContentFirefoxcoreaudiodcef_server Helper (GPU)Activity MonitorbluetoothdlaunchservicesdFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentSlack Helper (Renderer)cef_serveriTerm2FirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentWispr Flowsyspolicydtccd184,9124,755,529,622,522,419,116,211,011,08,97,16,54,23,63,43,03,02,92,92,62,32,22,22,22,11,71,7ToolsCPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:20:02,744:34:37,336:45:40,2624:52,5212:53,2210:47,178:11:06,0713:49,961:10:46,483:53:45,5941:59,341:50:37,621:02:28,328:55,579:55,3621:26,771:04:47,1925:38,2632:40,3120:16,851:01:13,324:36,681:05:12,3044:07,9417:13,504:36,7317:34,219:22,56559267System:User:Idle: ,37%48,69%7,93% HomeDMsActivityFilesLater..•More+ED→Jiminny...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova. Todor Stamatov&. Steliyan Georgiev@ VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:39:52Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87488
|
|
87487
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity ...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87487
|
|
87486
|
rapstomViewNeweNNCCoocKetucioWindowFV faVsco.s ~#1 rapstomViewNeweNNCCoocKetucioWindowFV faVsco.s ~#12121 on JY-20963-fx-inCSamviceTesconr=custom.loglaravel.lodA SF jiminny@localhostHSJocal jiminny@localhosteTPAmoremo newehoResponseNormalize.phoUmacurayscrict.oneeachmeimserexooecoroto.onegsevice.on:esunchiorcdon.onclass service excenos baseservace aolesencs© RecordSelector.phg© Activity.phd(C) Hubspot/Service.php X OpportunitySyncTrait.php© CrmEntityRepository.phgGSUNCKOlOhCWWWKpubuzc tunccion savelranscrzpczonsuaryasnotelPworcubnect Snoreubneci = nultyeweonooinlneochhoe): Pstring f...}>D IntearationAod>O Listeners7usaoes>& Metadatala Pipedrivev SalestorceaFeldsaopoortunityMatchenOpportunitySyncStrategy> ProspectsearchStrategyeSemicetiraitsC) e entondC Decorate ctimm cno4076 00)21992101lnaldtsor weetihooo2187oead natioitionenno2188© PayloadBuilder.php2109alorotis noo2110© QueryBuilder.php© QueryHandler.php2112© Queryiterator.pho2113© QueryResults.phpe Sarnce oho© SyncBatchRedisService.pt 2115TraitelRasedlientond2132© BaseService.phpC CachedCrmServiceDecoratorconiooekesowod€ CrmActivitvProviderinteorateCCINTCMINMOronoweauiwostenee3Findsproscectinterace.onC) LavoutMansoer ono3MatchDomsinsvamsinteraeC Ooportur vActvtwwatche2141 003Ooportun tySyncStratscr ntec Opportun tySyncStratcoreeec Prosoectenche.ond« ProsocctSearchScone.ohoc OrnenontCostchCttamA PrnenontSonrch Gtetondotoe orawtordo ctr nhrDorarriColontor nho01 47 A149 /1 /33 11 A V 1690A console (PRODC) Salesforce/Service.phgA console (EU) X uin users (EU# console [STAGINGD 6.TC AutoORDER BY sms count DESCpiawarouodvOojiminny031 49 A29 У 3 У109 A V167%1780178)public function attachSunnaryToActivitv(ActivityContract Sactivity, string SsunnaryTitle, string SsunnarvC 17851 usageprivate function buildMetadataForSunnaryUpdate(Activity Sactivity, string Ssunnary): arrayf...public function fetchAndAssociateRelatedActivity(Activity Sactivity): ?Activity(...}public function fetchRelatedActivity(Activity Sactivity): arrayf...)5 usagespublic function getDealsInBulk(array SdealIds): arrayi...)* Extract deal IDs fron HubSpot search response.* Boanam arrau ShubspotResponse The ran HubSpot search API response.* @pacam bool SincludeArchived Whether to include archived deals (default: false).* Bratumn stringul Arrau of deal IDs as stringspublic function extractDealIds(array ShubspotResponse, bool SincludeArchived = false): arravf.....usaoepublic function natchActivityEngagementTvpe(Activity Sactivity): stringi...,private function assignCenüuner(User Suser. ActivityContract Sactivity): 2Prosilef....1ussotprivate static function getbealsPipelinesendpoint: stringreturn self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OJECT_TYPE_DEALSpublic function verifytaskexists(Activity Sactivity): boolf...1716=1722_17251724=172717281740SELECT DISTINCT u.íd, u.cnail, u.name, u.tcam id, t.nane as team namet.twilio_sns_sid. t.twilfo.messaging.sidFROM users uINNER JOTN toams t 1..n<->1: ON u.tean_id = t.idWHERE (t.twiliosns sid IS NOT NULL OR t.twilfo_nessaging_sid TS NOT NULLDRoEk dynare MhenasiSFLFCT * FROM teans NHERE nane LTKF "Touclanok. = 187. 280. 8154. salesfonce-adninatSFLFCTCONCAT(u.id, CASE WHEN u.id = t.owner_id THEN • (ouner)' ELSE •* END) AS user_id.sa.*t.owner_id FROM social_accounts sanTM eene non miad= sarsochable oJOIN teams t 1.n<->1: on t.id = u.tean_1cWHERE u.team_id = 187 and sa.provider = 'salesfonce':select * from activities where id = 31264367.select * from contacts where id = 6331639:calart * foom accounte nhors 1d = 1454432.select * from opportunities where id = 4843618:# update "activitios""AAAIAt A' C AGUZA"contactid' = 6331639, opportunity#"stagc_id" = 13273activities".'uodated..at"= 2826-85-22 87:16:17 where "id' = 312select * from text relays where created at > *2826-05-01*;select * from activities order by id desciselect * from users where nane Like "ySubrak',SELECT * FROM opportunitsies WHERE wuid_ to_bin(*B4a9cfad-2c87-4453-9e72-28aeb78ccf8d^)_select * from teans nhere 1d = 555:select * from stages nhere tean 1d = 555;SFLFCTCONCAT(NESA, GASE NHEN 1.3d= +.ONnen 1A THEM " (OrDeN)" FISE I* ENO) AS usen 3al111t.ouner_id FROM social_accounts sahitin neene non nar ea socsahilel.nxo>l: on t.id = u.tean_idWHEDE 1 tose 4d = 100 and ea noousdon = thuhenotteServiceTestTO0У L7• Thu 28 May 18:39:26+0.Cecsdales Orcnnworeeoeionhwmwlino ooo= S5.probab1l1ly * 1603. $s['probability*) comes from ResponseNormalize::noralizeDealStageTa Kesoorbenotiaihe tho.o"probability" = (float) (Sdata("metadata'] ["probability'] 2? 1).Mubsookraume rercitworchobattyaPior closcollontsosalonomon tylnoeeaPuanisreseiesiooc 200101 orobabillv backe o oankin4. Now getClosedDealStages() puts "closedlost" into lost[] (probability 0), not woni]o, Unoeaiwegwnaacans Closcdetmatthe sequenee of euenteMay 4Stage 20616 probability manually changed to 100 in Jiminny DEMtar MawinportStages () runs → resets stage 20616 probability back to 0 (from HubSpot)May 20Deal synos » "closedlost" is in lost[] (prob=0) → is won = falseThe opportunity's stage. 1d is still 20616 (the stage record ID never changed), but by the time it synced on May 20, importStages had already reset theorobab ty ot that sthos badk to 0. So whon buftld0poortuni tybata rune. "clocadlost iein lostfllhnot voaflThe core issue: inportStages always overwrites the DB probabillity with whatever HubSpot says. Any manual probabillty customisation in Jiminny get:wiped out the next time stages are synced from HubSpot. Stage 20616 was manually set to 100 but the label says "7. Won (Contract Signed)* while theHubscot era provider a is cosaciost - HuoSoors native orobablity for that is o, so it aets resetGal -conscia sol -115a0/4ookcation S.ecer/et8/rains/PhesStorm2026.1/kconnoles/6V/32804614-[PHONE]-eoatcrCc0145"labei*: *7. Won (Contract Signed);This is very revealing. Now I have the full picture. Let me trace through exactly what happenedThe HubSpot pipeline data shows closedlost stage with "probability": *1.0" - so importStages would set it to 1.0 • 100 = 100 in the DB. Thatmatches the current D8 state, So my orevious theory was wrongLet me look more carefully at resolveDealProbability and the property history,ww.ssertoskthu.cicawl.calotbs.consooepro.brtty=uernos.necen.a.werswlt.esmobth.dgosrzbsh.ecne caockWiethat leue the aoatir tenhth1779301965775 ms May 20, 2026 (this aligns with updated_at: "2026-85-20 18:36:59")Ask anything (XOLceodhAdhettwttwew outteonaettodaw Ray• ONwndeuleimehreswhiroh*4 spa...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87486
|
|
87485
|
JAlFirefoxFileEditViewHistoryBookmarksProfilesTool JAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormWindowServerFirefoxCP Isolated Web Contentreplaydscreenpipecef_server Helper (Renderer)language_server_macos_armcef_server Helper (GPU)FirefoxCP Isolated Web Contentcoreaudiodcef_serverFirefoxActivity MonitorCursorUlViewService (Not Responding)ClaudebluetoothdtccdFirefoxCP Isolated Web ContentSlack Helper (Renderer)syspolicydFirefoxCP Isolated Web ContentSlackWispr FlowiTerm2FirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentNotion Helper (Renderer)228,1110,073,145,543,033,433,325,518,110,18,37,97,86,35,55,24,64,64,44,44,13,72,92,52,52,42,22,1CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:19:13,474:34:05,098:10:59,9924:47,436:45:25,473:53:39,6910:40,7312:47,078:54,0541:57,021:02:26,644:35,851:50:35,909:54,246:07,8529:54,6921:25,809:22,4144:07,211:01:11,4217:34,0217:12,8714:14,094:36,181:05:11,7810:25,1932:39,7325:25,36559268System:User:Idle: ,16%41,83%4,00%CPU:HomeDMsActivityFilesLater..•More+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka StoyanovaR. Stoyan Tomov&o Petko Kashinski. Galya Dimitrova. Todor Stamatov&. Steliyan Georgiev@ VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:39:24Describe what you are looking for®Jira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87485
|
|
87484
|
JAlFirefoxFileEditViewHistoryBookmarksProfilesTool JAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormWindowServerreplaydFirefoxCP Isolated Web Contentscreenpipecef_server Helper (Renderer)cef_server Helper (GPU)FirefoxFirefoxCP Isolated Web Contentcef_serverlanguage_server_macos_armlaunchservicesdcoreaudiodFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentActivity MonitorSlack Helper (Renderer)Firefox GPU HelperbluetoothdlaunchdFirefoxCP Isolated Web ContentClaudeFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentiTerm2Wispr Flow212,088,688,062,152,429,225,021,618,110,89,68,88,48,16,76,66,55,75,25,14,54,53,63,43,32,82,72,3CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:19:01,29124174:33:59,228:10:56,096:45:23,1724:45,003:53:37,9110:38,958:53,081:50:35,4841:56,484:35,4312:45,701:04:45,991:02:26,1944:06,9732:39,611:08,919:53,901:01:11,192:24:05,6921:25,5619:43,1620:15,5929:54,4217:12,6813:48,431:05:11,654:36,04559267System:User:Idle: ,58%41,90%3,52% CPU:HomeDMsActivityFilesLater..•More+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi...Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka Stoyanova. Stoyan Tomov&o Petko Kashinski. Galya Dimitrova%: Todor Stamatov&. Steliyan Georgiev@ VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:39:22Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
87484
|
|
87483
|
FircroxViewhsttonPwovscors#12121 on JY-20963-fox-h FircroxViewhsttonPwovscors#12121 on JY-20963-fox-hroie.WindowHelp© ServiceTest.phpeTPAmoremo neweho© ResponseNormalize.phpgsevice.on:esunchicracoon.onGSUNCKOlOhCWWWKeweonooinlneochhoe2133› E IntegrationApp>O Listeners› E Metadata> D Migration› E Pipedrivev SalesforceaFelds2157a opoortunitvVatchenOpportunitySyncStrategy2159> ProspectsearchStrategyServiceTraits2161 0 >C) e entond©[EMAIL]© PayloadBuilder.php© Profile.php© QueryBullder.phpMmacurayochyict.on© DeleteObjects Trait.phpRecordSelector.php© OpportunitySyncTrait.php© Activity.php©CrmEntityRepository.php1038g0public function natchActivityEngagementType(Activity Sactivity): stringf...1 usageprivate function assignCrnOwner(User Suser, ActivityContract Sactivity): ?Rcof$le(...}prevore sroee Tuncezon gecuestsrape einescnoposnclde odrangreturn self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OJECT_TYPE_DEALS;public function venifyTaskExists(Activity Sactivity): bool...Servicasvaaamsa4 console 1 s 650 msEUSOrSS5% mev A|iminny@localhostHSllocallASFv pRODA consoleSTAGINGA consoleDocker7o0sLXinu co moy to.ssilE custom.logA console (PROD)DMO001725E laravel.logA SF (iminny@localhost)HSJocal ([jminny@localhost)A console (EU] * (B users (EU]A console [STAGING)createo,at > 2020-09-2d8 jiminny~031 49 A29 X3 X109 A Vsallect *mm acutes onden hy sadese17271728,17291 000 0 100M00 1800 000 00 1 8001 8117331733 vBEE21746sallec * mm llsang nhene nene uke "tona.sSELECT * FROM opportunities WHERE uuid_to_Din('04a9cfad-2C87-4453-9e72-20aeb78ccf8d*)=select * from teams where id = 555;select * from stages nhere tean_id = 555;SELECTCONCAT(U.$d, GASE WHEN U.1d = t.omner_id THEN • (ouner) ' ELSE "* END) AS user_1d=sa.*t.ouner_id FROM social_accounts saJOIN users on u.id = sa.sociable_idJOTN teams + 1..n<->1: on toid = u.tean idWHERE v.team_id = 100 and sa-provider = 'hubspot":1I11ПВТЕ ПІРеНCascadeles OrcnnworeebeeionhNow let me check the syncopportunitiesßatch path - specifically whether this deal could have been synced via a different code path that doesn't use Opporcunzs sincirotintouetous pofconto doco, uot me look du tio oe Tindoe portunaoies puu in soivind ono srouno uie ouuKino serviceonoThis code path only reads, docsn't wnite iswon to D8. Now let me check the syncoccortunstlesbatch path = socctically the webhook handier. uhcopportunity updated_at is 2826-85-28 18:36:59, so it was last updated by a webhook sync. Let me check that path:Ity → buildOpportunityOuta. The code path is confirmed. Now let me focus on what properties ("deatstage") actually is when the webhook syne runs.Saarched catdooonuetM.wdero1oo.wan. coolttm Htosootelent.ohoiSearched batchReadObjects in app/Services/Crm/Hubspot/Client.php (4)>+ < Code$ AdaptiveOutpujiminny.opportunities 4tii jiminny stagesGin PauuttBagaseDuvid (UUID with time-low and time-high swapped)( tean_id@crn_configuration_idHr account& stage_idstage_updated_at@record_type_idcrn_provider_idousenToDouner_idnanevaluecurrency_codeDis_closediis nooMelose dateprobability100etodau 1Rey768142384a9cfad-2c87-4453-9e72-20aeb78ccf8d5SS475BB3aT8:8428612826-85-20 18:36: Centiva Capital - EU HY/IG28608.88¿4→0Audensd...
|
iTerm2
|
NULL
|
NULL
|
87483
|
|
87482
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
rircrox•••пcoltViewhsttonooountrnPotLe.ToolsWindowHelpmyaulassonineu orowacioko-oool8) JIMINNYBE uporade librariesreauots now ownersroe toeText relayDeleted object erroryyorokFix orcion key wiolntluminate/Database|Query&xceplCJY-20983 fix deleted obiect imolooin t SiastorcaFeed - Jiminny - SentryMi inbox (1.735) - lokns.kowalikSiim(JY-20979) Rescive PHP 8.5.5 depoomnepihttoem Son dthloy - Pintormtranscript ss issue8 Jiminny(SOD:6881) f0n demandll Transd8 Sona Subramacian at 27/05/20268 Ilivana Netsova at 27/05/2026, 18)8 Jiminey8 JimicrySRD-6881/On demandil Tran XNew TabQ SearchSpaces / [] Service-Desk / 30E SRD-6881[On demand] Transcription in saved search disappears# Link work ttemAdd form$ Add design CreateN Iliyana Netseva raised this request via JiraVem ronne in norsiHide detailsUescriottonI have tested the nudges (saved searches) with a word in the transcription field and the word disappears every time on EU, and a user has reported the same on US:the user session: [l LogRocketHere is a link to my session: [ LogRocketData CentreUsSteos to reoroduce1. open Jiminny on EU2. Create a search with a word in the transcript field3. Save the search and create a nudge.4. Open something eise and then return to the search, the wordCustomer typeSMBActual outcomeThe word disappearsExpected outcomeThe word in the transcript field should appear when opening an already saved search with it.Severity levelImpactNoneRoot causeTranscript filter was not used, just typed but not searched by.Q SOMOX 100%K3 8• Thu 28 May 18:39:08O ASK ROVO A ® $+ CreateNot a bug~ DetailsAssigneeReporterRequest TypeKnowledge basePriority levelDev TeamCanny Links® Lukas Kovalik@ Illyana Netseva• Reporta bugE View related articlesP2 MediumPlatform teamOpen Canny Links> More fields Labels, Time tracking, Type of InfoSec incident, Components(InfoSec), Client, Alfected u...> Automation 4 Rule executions> featureOS = Open featureos~IntercomShowing 1 out of 1 linked conversations& Sona Subramanian..>> Sentry Sll Linked IssuesCreated yesterdayUpdated 12 minutes ago@ Configure...
|
Firefox
|
[SRD-6881] [On demand] Transcription in saved sear [SRD-6881] [On demand] Transcription in saved search disappears - Jira — Work...
|
jiminny.atlassian.net/browse/SRD-6881
|
87482
|
|
87481
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
BE upgrade libraries
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
Text relay
Deleted object error
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
IAlIAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskWindowServercef_server Helper (Renderer)PhpStormreplaydlanguage_server_macos_armFirefoxCP Isolated Web ContentFirefoxscreenpipeFirefox GPU Helpercef_server Helper (GPU)coreaudiodFirefoxCP Isolated Web Contentcef_serverActivity MonitorbluetoothdFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxWispr FlowiTerm2FirefoxCP Isolated Web ContentSlack Helper (Renderer)Wispr Flow Helper (Renderer)Wispr FlowFirefoxCP Isolated Web ContentPostman Helper (Renderer)178,267,141,440,037,133,529,917,717,115,311,37,17,05,84,53,93,53,12,12,01,81,81,71,61,51,51,31,3CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:18:27,458:10:42,3310:33,944:33:48,066:45:14,0112:42,9624:38,161:50:32,433:53:30,562:24:04,088:49,651:02:24,8941:54,964:33,929:52,8921:24,8015,0920:15,0717:12,2214:31,734:35,691:05:11,2432:38,811:01:10,645:51,471:45,4744:06,277:49,515592217267System:User:Idle: ,46%42,87%5,67%CPUHomeDMsActivityFilesLater..•More+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi..Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka StoyanovaR. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova%: Todor StamatovRo Steliyan Georgiev@ VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:39:06Describe what you are looking for®Jira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
Firefox
|
[SRD-6881] [On demand] Transcription in saved sear [SRD-6881] [On demand] Transcription in saved search disappears - Jira — Work...
|
jiminny.atlassian.net/browse/SRD-6881
|
87481
|
|
87480
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
rircrox•••пcoltViewhsttonooountrnroiuesToolsWindowHelpmyaulassonineu orowacioko-oool8) JIMINNYQ SearchBE uporade librariesrezvors now ownersrore toleText relay]Deleted object erroryyorokFix orcion key wiolntluminate/Database|Query&xceplCJY-20983 fix deleted obiect imotooin t ShlastorcnFeed - Jiminny - SentryMi inbox (1.735) - lokns.kowalikSlim(JY-20979) Rescive PHP 8.5.5 depoomnepihttoem Son dthloy - Pintormtranscript ss issue8 JiminnySOD:68811 (0n demandll Trans8 Sona Subramacian at 27/05/20268 Ilivana Netsova at 27/05/2026, 18)8 Jiminey8 JimicrySRD-6881/On demandil Tran XNew Tab[On demand] Transcription in saved search disappears@ Link work itemAdd formD Add designCreatev*.EN) Illyana Netseva raised this request via JiraHide detailsDescriptionI have tested the nudges (saved searches) with a word in the transcription field and the word disappears every time on EU, and a user has reported the same on US:the user session: [l LogRocketHere is a link to my session: [Jl LogRocket(UsSteps to reproduce1. open Jiminny on EU2. Create a search with a word in the transcript field5. Save the search and create a nudge.4. Open something else and then return to the search, the wordCustomeroeSMBAcrual outcomeThe word disappearsexpected outcomelThe word in the transcript field should appear when opening an already saved search with it.severtyeyelS2ImpactNoneRoot causeTranscript filter was not used, just typed but not searched by.AttachmentsQASOMОД+ CreateNota bug~ DetailsAssigneeReporterRequest TypeKnowledge basePriority levelDov TeamCanny Links@ Lukas Kovalik® Illyana Netsevaó Report a bugE View related articlesP2 MediumPlatform teamOpen Canny Links> More fields Labels, Time tracking, Type of InfoSec incident, Compor> Automation 4 Rule executions> featureOS = Open featureOS~IntercomShowing 1 out of 1 linked conversations& Sona Subramanian> Sentry l Linked IssuesCreated yesterdayUpdated 12 minutes agoNotifications100% KSa: 8- Thu 28 May 18:39:05OASKROV A 0 €Only show unread @ &:...
|
Firefox
|
[SRD-6881] [On demand] Transcription in saved sear [SRD-6881] [On demand] Transcription in saved search disappears - Jira — Work...
|
jiminny.atlassian.net/browse/SRD-6881
|
87480
|
|
87479
|
rircrox•••пcoltViewhsttonooountrnPotLe.ToolsWindow rircrox•••пcoltViewhsttonooountrnPotLe.ToolsWindowHelpmyaulassonineu orowacioko-oool8) JIMINNYBE uporade librariesrezvors now ownersrore toleText relayDeleted object erroryyorokFix orcion key wiolntluminate/Database|Query&xceplCJY-20983 fix deleted obiect imolooin t SiastorcaFeed - Jiminny - SentryMi inbox (1.735) - lukon.kownlikGlim(JY-20979) Rescive PHP 8.5.5 depoomnepihttoem Son dthloy - Pintormtranscript ss issue8 JiminnySOD:68811 f0n demandll Transd8 Sona Subramacian at 27/05/20268 Ilivana Netsova at 27/05/2026, 18)8 Jiminey8 JimicrySRD-6881/On demandil Tran XNew TabQ SearchSpaces / [] Service-Desk / 30E SRD-6881[On demand] Transcription in saved search disappears# Link work ttemAdd form$ Add design CreateN Iliyana Netseva raised this request via JiraVem ronne in norsiHide detailsUescriottonI have tested the nudges (saved searches) with a word in the transcription field and the word disappears every time on EU, and a user has reported the same on US:the user session: [l LogRocketHere is a link to my session: [ LogRocketData CentreUsSteos to reoroduce1. open Jiminny on EU2. Create a search with a word in the transcript field3. Save the search and create a nudge.4. Open something eise and then return to the search, the wordCustomer typeSMBActual outcomeThe word disappearsExpected outcomeThe word in the transcript field should appear when opening an already saved search with it.Severity levelImpactNoneRoot causeTranscript filter was not used, just typed but not searched by.Q SOMOX 100%K3 8• Thu 28 May 18:39:02OASKROV A 0 €+ CreateNot a bug~ DetailsAssigneeReporterRequest TypeKnowiedge basePriority levelDev TeamCanny Links® Lukas Kovalik® Illyana Netseva• Reporta bugE View related articlesP2 MediumPlatform teamOpen Canny Links> More fields Labels, Time tracking, Type of InfoSec incident, Compon> Automation 4 Rule executions> featureOS = Open featureos~IntercomShowing 1 out of 1 linked conversations& Sona Subramanian> Sentry Sll Linked IssuesCreated yesterdayUpdated 12 minutes ago...
|
Firefox
|
[SRD-6881] [On demand] Transcription in saved sear [SRD-6881] [On demand] Transcription in saved search disappears - Jira — Work...
|
jiminny.atlassian.net/browse/SRD-6881
|
87479
|
|
87478
|
rircroxhsttonooountrnroie.ToolsWindowHelp8) JIMINN rircroxhsttonooountrnroie.ToolsWindowHelp8) JIMINNY* (JY-20613) Allow owner's role to tText relayDeleted object erroryaorok Fix torcion key violntluminate/Database|Query&xceplCJY-20983 fix deleted obiect imotooin t ShlastorcnFeed - Jiminny - SentryMi inbox (1.735) - lukon.kownlikGlim(JY-20979) Rescive PHP 8.5.5 depPiatfoem Sondt0y - Ointormetranscript ss issue8 Jiminny(SOD:6881) fOn demandll Trans8 Sona Subramacian at 27/05/202€8 Ilivana Netsova at 27/05/2026, 18)8 Jiminey8 JimicrySRD-6881 (On demandil TranNew TabQ SearchSpaces / [] Service-Desk / 30E SRD-6881[On demand] Transcription in saved search disappears# Link work itemAdd form8 Add designCreate vN Iliyana Netseva raised this request via JiraMem raauaet in nortstHioe detitUescriottonI have tested the nudges (saved searches) with a word in the transcription field and the word disappears every time on EU, and a user has reported the same on US:the user session:https://app.logrocket.com/ponxaf/platform-production/s/6-019e69e6-70d1-7ad6-a7f6-169a3efeee17/0?dashboardID=NaN&filterIntent=%2578%2522type%2522%253A%2522all%2522%252C%2522children%2522%253A%255B%2578%2522level%2522%253A%2522session%2522%252C%2522type%2|522%253A%2522email%2522%252C%2522where%2522%253A%2578%2522type%2522%253A%2522all%2522%252C%2522children%2522%253A%255B%257|8%2522type%2522%253A%2522email%2522%252C%2522operator%2522%253A%2522IS%2522%252C%2522values%2522%253A%2558%2522sona.subramalnian /2540clanz.0%2572%2550% fr0mlab=koersictForm=trueRt=17798928433777224Here is a link to my session:https://app.logrocket.com/ponxaf/platform-eu/s/6-019e6a1f-49d8-7739-a3ff-19380383e3ed/0?dashboardID=NaN8filterIntent=%2578%2522type%2522%253A%2522all%2522%252C%2522children%2522%253A%255B%2578%2522level%2522%253A%2522session%2522%252C%2522type%2|SYINALTVALYIEMAIVALYYYALVIWALYUIRVALYYNALKIVALTI:VALYINOOVALYYUALTIUALYURIVALYYNALVIWALYWCHINRENVALYINALTAVALST:VALS8%2522type%2522%253A%2522email%2522%252C%2522operator%2522%253A%2522IS%2522%252C%2522values%2522%253A%2558%2522iliyana.netseva%2540jiminny.com%2522%255D%257D%255D%2570%2570%255D%2570&fromTab=&network_id#12-xhr-9&network_root.tab_id=06da3165-4fdd-4000-acdd-961a62018343&network_tab_id=06da3165-4fdd-4000-acdd-961a620183438persistForm=true8t=1779897946748.365Dala ContteUsSteps to reproduceonenatminnv on +2. Create a search with a word in the transcript field3. Save the search and create a nudge.4. Open something else and then return to the search, the wordCustomeroeSMBActual outcomeThe word disappearsexoecied ouicomelThe word in the transcript field should appear when opening an already saved search with it.Severity levelS2Imoac.NontQSOMOX 100%K3 8• Thu 28 May 18:38:56O ASK ROV A 0 € 0+ CreateNot a bug ~ 4DetailsAssigneeReporterRequest TypeKnowiedge basePriority levelDev TeamCanny Links® Lukas Kovalik® Illyana Netseva• Reporta bugE View related articlesP2 MediumPlatform teamOpen Canny Links> More fields Labels, Time tracking, Type of Infosec incident, Compon> Automation 4 Rule executions> featureOS = Open featureosIntercom> Sentry l Linked IssuesCreated yesterdayUpdated 12 minutes ago...
|
Firefox
|
[SRD-6881] [On demand] Transcription in saved sear [SRD-6881] [On demand] Transcription in saved search disappears - Jira — Work...
|
jiminny.atlassian.net/browse/SRD-6881
|
87478
|
|
87477
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
BE upgrade libraries
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
Text relay
Deleted object error
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
Login | Salesforce
Login | Salesforce
Feed — jiminny — Sentry
Feed — jiminny — Sentry
Inbox (1,735) - [EMAIL] - Jiminny Mail
Inbox (1,735) - [EMAIL] - Jiminny Mail
[JY-20979] Resolve PHP 8.5.5 deprications - Jira
[JY-20979] Resolve PHP 8.5.5 deprications - Jira
Jiminny
Jiminny
Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
transcript ss issue
Jiminny
Jiminny
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Sona Subramanian at 27/05/2026, 17:46:08 - Session Replay - LogRocket
Sona Subramanian at 27/05/2026, 17:46:08 - Session Replay - LogRocket
Iliyana Netseva at 27/05/2026, 18:48:18 - Session Replay - LogRocket
Iliyana Netseva at 27/05/2026, 18:48:18 - Session Replay - LogRocket
Jiminny...
|
Firefox
|
[SRD-6881] [On demand] Transcription in saved sear [SRD-6881] [On demand] Transcription in saved search disappears - Jira — Work...
|
jiminny.atlassian.net/browse/SRD-6881
|
87477
|
|
87476
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
BE upgrade libraries
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
Text relay
Deleted object error
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app...
|
Firefox
|
[SRD-6881] [On demand] Transcription in saved sear [SRD-6881] [On demand] Transcription in saved search disappears - Jira — Work...
|
jiminny.atlassian.net/browse/SRD-6881
|
87476
|
|
87475
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
BE upgrade libraries
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
Text relay
Deleted object error
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
Login | Salesforce
Login | Salesforce
Feed — jiminny — Sentry
Feed — jiminny — Sentry
Inbox (1,735) - [EMAIL] - Jiminny Mail
Inbox (1,735) - [EMAIL] - Jiminny Mail
[JY-20979] Resolve PHP 8.5.5 deprications - Jira
[JY-20979] Resolve PHP 8.5.5 deprications - Jira
Jiminny
Jiminny
Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
transcript ss issue...
|
Firefox
|
Jira — Work
|
jiminny.atlassian.net/browse/SRD-6881
|
87475
|
|
87474
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
BE upgrade libraries
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
Text relay
Deleted object error
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
Login | Salesforce
Login | Salesforce
Feed — jiminny — Sentry
Feed — jiminny — Sentry
Inbox (1,735) - [EMAIL] - Jiminny Mail
Inbox (1,735) - [EMAIL] - Jiminny Mail
[JY-20979] Resolve PHP 8.5.5 deprications - Jira
[JY-20979] Resolve PHP 8.5.5 deprications - Jira
Jiminny
Jiminny
Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
transcript ss issue
Jiminny
Jiminny
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Sona Subramanian at 27/05/2026, 17:46:08 - Session Replay - LogRocket
Sona Subramanian at 27/05/2026, 17:46:08 - Session Replay - LogRocket
Iliyana Netseva at 27/05/2026, 18:48:18 - Session Replay - LogRocket
Iliyana Netseva at 27/05/2026, 18:48:18 - Session Replay - LogRocket
Jiminny
Jiminny
Jiminny...
|
Firefox
|
Jira — Work
|
jiminny.atlassian.net/browse/SRD-6881
|
87474
|
|
87473
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
BE upgrade libraries
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
Text relay
Deleted object error
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
Login | Salesforce
Login | Salesforce
Feed — jiminny — Sentry
Feed — jiminny — Sentry
Inbox (1,735) - [EMAIL] - Jiminny Mail
Inbox (1,735) - [EMAIL] - Jiminny Mail
[JY-20979] Resolve PHP 8.5.5 deprications - Jira...
|
Firefox
|
Jira — Work
|
jiminny.atlassian.net/browse/SRD-6881
|
87473
|
|
87472
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
BE upgrade libraries
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
Text relay
Deleted object error
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
IAlFirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskWindowServerPhpStormreplaydscreenpipecef_server Helper (Renderer)FirefoxCP Isolated Web ContentFirefoxlanguage_server_macos_armFirefox GPU Helpercef_server Helper (GPU)Slack Helper (Renderer)FirefoxCP Isolated Web Contentcoreaudiodcef_servertccdActivity MonitorlaunchservicesdsyspolicydbluetoothdFirefoxCP Isolated Web ContentSlacktrustdFirefoxCP Isolated Web ContentClaudeFirefoxCP Isolated Web ContentWispr Flowtrustd217,182,270,248,035,934,632,526,625,023,518,618,210,09,38,46,05,25,04,84,74,03,32,62,52,42,22,12,0CPUMemoryEnelCPU TimeThreadsIdle Wake-UpsKp24:17:03,338:10:09,854:33:16,966:44:51,563:53:23,1110:21,4824:23,561:50:23,7012:32,992:23:56,238:42,191:01:09,3841:51,261:02:21,584:30,479:21,709:50,701:04:43,3017:33,3321:22,9844:05,5314:12,985:22,9417:11,1729:53,2132:38,194:34,8215:07, System:User:Idle:40,99%39,66%19,35%CPU|HomeDMsActivity+ED→Jiminny ...jummy v5# platform-team# platform-tickets# product_launches# random# releases# support# thank-yous# the_people_of jimi..Direct messages&. Iliyana NetsevaEo Vasil Vasilev8. Stefka StoyanovaR. Stoyan Tomov&o Petko Kashinski% Galya Dimitrova%: Todor Stamatov&. Steliyan Georgiev@ VesG. MiraR. Nikolay Yankov2o James GrahamLukas Kovalik y... OAppsJira CloudToast(all100% <7Thu 28 May 18:38:22Describe what you are looking forJira CloudHomeMessagesAboutSF tokens for CYesterdayStatus: BacklogAssignee: Lukas KovalikType: StoryPriority: MediumTransitionMore actions...Jira Cloud APP10:57 AM@Galya Dimitrova assigned a Story fromUnassigned → youJY-20500 Batch initial sync for SalesforceStatus: BacklogAssignee: Lukas KovalikType: StoryT Priority: MediumTransitionMore actions...Today ~NewJira Cloud APP6:26 PM@Galya Dimitrova transitioned a Bug you areassigned to from Ready for customer→ Not abugSRD-6881 [On demand] Transcription in savedsearch disappearsStatus: Not a bugAssignee: Lukas KovalikType: BugCommentMore actions...Message Jira Cloud...
|
Firefox
|
Jira — Work
|
jiminny.atlassian.net/browse/SRD-6881
|
87472
|
|
87471
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
BE upgrade libraries
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
Text relay
Deleted object error
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira...
|
Firefox
|
Jira — Work
|
jiminny.atlassian.net/browse/SRD-6881
|
87471
|
|
87470
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
BE upgrade libraries
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
Text relay
Deleted object error
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
Login | Salesforce
Login | Salesforce
Feed — jiminny — Sentry
Feed — jiminny — Sentry
Inbox (1,735) - [EMAIL] - Jiminny Mail
Inbox (1,735) - [EMAIL] - Jiminny Mail
[JY-20979] Resolve PHP 8.5.5 deprications - Jira
[JY-20979] Resolve PHP 8.5.5 deprications - Jira
Jiminny
Jiminny
Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira...
|
Firefox
|
Jira — Work
|
jiminny.atlassian.net/browse/SRD-6881?atlOrigin=ey jiminny.atlassian.net/browse/SRD-6881?atlOrigin=eyJpIjoiZDI4YTYwMzk5YTZkNGM1MTllMzg2NmRlODE0MzE2YWEiLCJwIjoiamlyYS1zbGFjay1pbnQifQ...
|
87470
|
|
87469
|
FirefoxViewhsttonooountrnPotLe.ToolsWindownel:my.a FirefoxViewhsttonooountrnPotLe.ToolsWindownel:my.atlassian.net/browse/SRD-68817atiOrigin=eyJpljoiZDI4YTYwMzK5YTZKNGM1MTIIMzg2NmRIODEOMZE2YWEiLC/wfjoiamlyYS1zbGFjay1pbnQHQ8) JIMINNYBE uporade librariesrezuots now ownersroe toeText relay]Deleted object erroryyorokFix orcion key wiolntluminate/Database|Query&xceplCJY-20983 fix deleted obiect imolooin t SiastorcaFeed - Jiminny - SentryM inbox (1,735) - lukas.kovalik@jimin(JY-20979) Rescive PHP 8.5.5 depoomnepihttoem Son dthloy - Pintormtranscript ss issue8 JiminnySOD:68811 f0n demandll Transd8 Sona Subramacian at 27/05/20268 Ilivana Netsova at 27/05/2026, 18)8 Jimieny8 Jiminry•IralNew TabQ SearchSpaces/ Service-Desk #* SRD-688[On demand] Transcription in saved search disappearsLink work nemE Add formCreate..IN Iliyana Netseva raised this request via JiraUescetoiData Centresteos to reproducь1. open Jiminny on EU2. Create a search with a word in the transcript field4. Spve the geatin and anate e nutiun to the search, the wordCustomer typeActual outcomeExoected outcomelSoverity lovelImpactNondRoot causeTranscript filter was not used, just typed but not searched by.Hide detailsusSMBThe word dicaooearsiThe word in the transcript field should appear when opening an already saved search with it.s2290678230285108Addinternal note/ Reply to customerOESOMОД100% K/2 8 • Thu 28 May 18:38:16O ASk Rovo 5 0+ CreateNot a bug~ DetailsAssigneeReporterRequest TypeKnowiedge basePriority levelDev TeamCanny Links® Lukas Kovalik@ Illyana Netseva• Reporta bugE View related articlesP2 MediumPlatform teamOpen Canny Links> More fields Labels, Time tracking, Type of InfoSec incident, Components (InfoSec), Client, Affected u...> Automation 4 Rule executions> featureOsOpen featureOSIntercom> Sentry sll Linked IssuesCreated yesterdayUpdated 11 minutes ago§ Configure...
|
Firefox
|
Jira — Work
|
jiminny.atlassian.net/browse/SRD-6881?atlOrigin=ey jiminny.atlassian.net/browse/SRD-6881?atlOrigin=eyJpIjoiZDI4YTYwMzk5YTZkNGM1MTllMzg2NmRlODE0MzE2YWEiLCJwIjoiamlyYS1zbGFjay1pbnQifQ...
|
87469
|
|
87468
|
Platform Sprint 5 Q2 - Platform Team - Scrum Board Platform Sprint 5 Q2 - Platform Team - Scrum Board - Jira
[SRD-6881] [On demand] Transcription in saved search disappears - Jira
Pipelines - jiminny/app
Pipelines - jiminny/app
BE upgrade libraries
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
[JY-20613] Allow owner's role to be selected when setting up a trial - Jira
Text relay
Deleted object error
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_contact_id_foreign` FOREIGN KEY (`contact_id`) REFERE
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app...
|
Firefox
|
Jira — Work
|
jiminny.atlassian.net/browse/SRD-6881?atlOrigin=ey jiminny.atlassian.net/browse/SRD-6881?atlOrigin=eyJpIjoiZDI4YTYwMzk5YTZkNGM1MTllMzg2NmRlODE0MzE2YWEiLCJwIjoiamlyYS1zbGFjay1pbnQifQ...
|
87468
|