Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 103 additions & 11 deletions backend/Actions/BitCrm/BitCrmActionHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static function createLead($fieldData)

$payload = [
'systemDefinedFieldsValues' => $systemValues,
'customFieldsValues' => BitCrmCustomField::values($fieldData, 'lead'),
'tagIds' => self::toIntArray($fieldData['tag_ids'] ?? []),
];

Expand All @@ -53,6 +54,7 @@ public static function updateLead($fieldData)
$payload = [
'id' => (int) $fieldData['lead_id'],
'systemDefinedFieldsValues' => $systemValues,
'customFieldsValues' => BitCrmCustomField::values($fieldData, 'lead'),
];

return self::result((new \BitApps\Crm\Services\LeadService())->update($payload), __('Lead updated successfully.', 'bit-integrations'));
Expand Down Expand Up @@ -131,6 +133,7 @@ public static function createContact($fieldData)

$payload = [
'systemDefinedFieldsValues' => $systemValues,
'customFieldsValues' => BitCrmCustomField::values($fieldData, 'contact'),
'tagIds' => self::toIntArray($fieldData['tag_ids'] ?? []),
];

Expand All @@ -152,6 +155,7 @@ public static function updateContact($fieldData)
$payload = [
'id' => (int) $fieldData['contact_id'],
'systemDefinedFieldsValues' => $systemValues,
'customFieldsValues' => BitCrmCustomField::values($fieldData, 'contact'),
];

return self::result((new \BitApps\Crm\Services\ContactService())->update($payload), __('Contact updated successfully.', 'bit-integrations'));
Expand Down Expand Up @@ -230,6 +234,7 @@ public static function createCompany($fieldData)

$payload = [
'systemDefinedFieldsValues' => $systemValues,
'customFieldsValues' => BitCrmCustomField::values($fieldData, 'company'),
'tagIds' => self::toIntArray($fieldData['tag_ids'] ?? []),
];

Expand All @@ -251,6 +256,7 @@ public static function updateCompany($fieldData)
$payload = [
'id' => (int) $fieldData['company_id'],
'systemDefinedFieldsValues' => $systemValues,
'customFieldsValues' => BitCrmCustomField::values($fieldData, 'company'),
];

return self::result((new \BitApps\Crm\Services\CompanyService())->update($payload), __('Company updated successfully.', 'bit-integrations'));
Expand Down Expand Up @@ -329,6 +335,7 @@ public static function createDeal($fieldData)

$payload = [
'systemDefinedFieldsValues' => $systemValues,
'customFieldsValues' => BitCrmCustomField::values($fieldData, 'deal'),
'tagIds' => self::toIntArray($fieldData['tag_ids'] ?? []),
];

Expand All @@ -350,6 +357,7 @@ public static function updateDeal($fieldData)
$payload = [
'id' => (int) $fieldData['deal_id'],
'systemDefinedFieldsValues' => $systemValues,
'customFieldsValues' => BitCrmCustomField::values($fieldData, 'deal'),
];

return self::result((new \BitApps\Crm\Services\DealService())->update($payload), __('Deal updated successfully.', 'bit-integrations'));
Expand Down Expand Up @@ -428,6 +436,7 @@ public static function createProduct($fieldData)

$payload = [
'systemDefinedFieldsValues' => $systemValues,
'customFieldsValues' => BitCrmCustomField::values($fieldData, 'product'),
'tagIds' => self::toIntArray($fieldData['tag_ids'] ?? []),
];

Expand Down Expand Up @@ -473,6 +482,10 @@ public static function updateProduct($fieldData)
return ['success' => false, 'message' => __('Failed to update product.', 'bit-integrations')];
}

// Writing the model directly skips the service that would normally store
// these, so they are saved here instead.
BitCrmCustomField::save('product', $productId, BitCrmCustomField::values($fieldData, 'product'));

do_action('bit_crm/product_updated', $product);

return self::success(__('Product updated successfully.', 'bit-integrations'), self::normalizeData($product));
Expand Down Expand Up @@ -552,11 +565,35 @@ public static function updateDealStage($fieldData)
return ['success' => false, 'message' => __('Deal not found!', 'bit-integrations')];
}

if (!self::isKnownDealStage($fieldData['stage'])) {
$stages = self::dealStages();
$definition = $stages[$fieldData['stage']] ?? null;

if (!empty($stages) && $definition === null) {
return ['success' => false, 'message' => __('This deal stage does not exist in Bit CRM.', 'bit-integrations')];
}

if (!$deal->update(['stage' => $fieldData['stage'], 'updated_by' => get_current_user_id()])) {
$update = ['stage' => $fieldData['stage'], 'updated_by' => get_current_user_id()];

// Bit CRM rewrites the probability on every stage change; leaving it would
// keep the odds of the stage before.
if (isset($definition['probability'])) {
$update['probability'] = $definition['probability'];
}

// Required on a stage that closes the deal, asked for on no other. When the
// stages cannot be read $definition is null and the category reads as '', so
// this is skipped along with the stage check above.
if (\in_array($definition['deal_category'] ?? '', ['closed_won', 'closed_lost'], true)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things:

  1. Confirm. Won/lost stages now hard-require closed_at (L588). Any pre-existing update_deal_stage flow that targets a closing stage without a mapped closing date will start failing at run time. This rests on the "shipped in no release tag → no saved flows" assumption from the PR description — please confirm it holds for update_deal_stage specifically.
  2. When stages can't be read, dealStages() returns [] and $definition is null; $definition['deal_category'] ?? '' silently yields '', so the closing-date requirement is skipped. That's the intended fallback but it's implicit — a one-line comment would prevent a future "why doesn't this validate" head-scratch.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Right to push on it — the assumption does not hold. update_deal_stage shipped in 2.10.1 (released 30 Jul 2026): git show 2.10.1:frontend/src/components/AllIntegrations/BitCrm/staticData.js carries it at line 47, and backend/Actions/BitCrm/ is present in that tag. Saved flows can exist, so the hard requirement is dropped — an unmapped closing date now leaves the column alone instead of failing a stage change that used to work:

if (\in_array($definition['deal_category'] ?? '', ['closed_won', 'closed_lost'], true)) {
    $closedAt = self::dealClosingDate($fieldData['closed_at'] ?? '');

    if ($closedAt !== null) {
        $update['closed_at'] = $closedAt;
    }
}

closingDateField stays required: true on the frontend, so a flow built or edited from now on is still steered to map it — only the run-time hard stop is gone. The PR description's "shipped in no release tag" line will be corrected too.

2. Comment added:

// When the stages cannot be read $definition is null and the category reads
// as '', so this is skipped along with the stage check above rather than
// guessing at a site whose stage list is unreachable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting my previous reply on point 1 — the hard requirement stays. A won or lost stage without a closing date is not a state worth writing, so the action fails rather than closing a deal with no closing date on it:

if (\in_array($definition['deal_category'] ?? '', ['closed_won', 'closed_lost'], true)) {
    $closedAt = self::dealClosingDate($fieldData['closed_at'] ?? '');

    if ($closedAt === null) {
        return ['success' => false, 'message' => __('A closing date is required to move a deal to a won or lost stage.', 'bit-integrations')];
    }

    $update['closed_at'] = $closedAt;
}

The finding behind your question still stands and the PR description will be corrected: update_deal_stage did ship in 2.10.1 (released 30 Jul 2026 — backend/Actions/BitCrm/ is in the tag, and staticData.js:47 carries the action). Its update_deal_stage offered only deal_id, so no 2.10.1 flow can have a closing date mapped, and any of them pointed at a won or lost stage will now fail until the flow is opened and the row filled in. That is accepted deliberately rather than by assumption.

Point 2 is addressed as asked:

// Required on a stage that closes the deal, asked for on no other. When the
// stages cannot be read $definition is null and the category reads as '', so
// this is skipped along with the stage check above.

$closedAt = self::dealClosingDate($fieldData['closed_at'] ?? '');

if ($closedAt === null) {
return ['success' => false, 'message' => __('A closing date is required to move a deal to a won or lost stage.', 'bit-integrations')];
}

$update['closed_at'] = $closedAt;
}

if (!$deal->update($update)) {
return ['success' => false, 'message' => __('Failed to update deal stage.', 'bit-integrations')];
}

Expand All @@ -578,7 +615,13 @@ public static function convertLead($fieldData)
}

$leadId = (int) $fieldData['lead_id'];
$convertTo = self::csvList($fieldData['convert_to']);

// LeadConvertService always creates the contact and the company; only the
// deal is gated on convertTo. Older flows could store either one out.
$convertTo = array_values(array_unique(array_merge(
['contact', 'company'],
self::csvList($fieldData['convert_to'])
)));
$options = [
'convertTo' => $convertTo,
'moveRelatedDataTo' => $fieldData['move_related_data_to'],
Expand Down Expand Up @@ -1410,23 +1453,68 @@ private static function required($field)
*
* @return bool
*/
private static function isKnownDealStage($stage)
/**
* The site's deal stages keyed by stage key. Empty when they cannot be read,
* which leaves the stage unvalidated rather than rejected.
*/
private static function dealStages()
{
if (!class_exists('BitApps\Crm\Services\DealStageService')) {
return true;
return [];
}

$stages = (new \BitApps\Crm\Services\DealStageService())->getStagesAsOptions();
$stages = [];

foreach ((array) $stages as $option) {
$option = (array) $option;
foreach ((array) (new \BitApps\Crm\Services\DealStageService())->getAllStages() as $stage) {
$stage = (array) $stage;
$key = (string) ($stage['key'] ?? '');

if ((string) ($option['value'] ?? '') === (string) $stage) {
return true;
if ($key !== '') {
$stages[$key] = $stage;
}
}

return false;
return $stages;
}

/**
* Every branch returns a bare site-local `Y-m-d H:i:s`, the shape Bit CRM's
* own stage modal submits. Formatting one input as local and another as UTC
* would store the same instant two ways. Null when nothing usable was mapped.
*
* @param mixed $value
*/
private static function dealClosingDate($value)
{
$value = trim((string) $value);

if ($value === '') {
return;
}

if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return $value . ' 00:00:00';
}

if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $value)) {
return $value . ':00';
}

if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $value)) {
return $value;
}

$timestamp = strtotime($value);

if ($timestamp === false) {
return;
}

// WordPress pins PHP's default timezone to UTC, so a string without one of
// its own parses to a UTC instant here. get_date_from_gmt() carries it back
// to site-local through the site's timezone, which follows daylight saving;
// the raw gmt_offset option does not.
return get_date_from_gmt(gmdate('Y-m-d H:i:s', $timestamp), 'Y-m-d H:i:s');
}

private static function csvList($value)
Expand Down Expand Up @@ -1455,6 +1543,10 @@ private static function systemValues($fieldData, array $drop)
{
$values = array_diff_key((array) $fieldData, array_flip($drop));

// Custom fields travel in their own payload key, so they must not reach
// the entity's own columns.
$values = BitCrmCustomField::withoutCustomKeys($values);

return array_filter($values, static function ($v) {
return $v !== null && $v !== '' && $v !== [];
});
Expand Down
49 changes: 40 additions & 9 deletions backend/Actions/BitCrm/BitCrmController.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,31 @@ public static function refreshCurrencies()
wp_send_json_success(['options' => self::normalize((new \BitApps\Crm\Services\CurrencyService())->getOtherCurrenciesAsOptions())]);
}

// Read whole rather than as bare options: the layout decides a closing date
// by the stage's category.
public static function refreshDealStages()
{
self::ensureClass('BitApps\Crm\Services\DealStageService');
$stages = (new \BitApps\Crm\Services\DealStageService())->getStagesAsOptions(\BitApps\Crm\Services\DealStageService::STATUS_ACTIVE);
wp_send_json_success(['options' => self::normalize($stages)]);

$stages = (new \BitApps\Crm\Services\DealStageService())->getAllStages(\BitApps\Crm\Services\DealStageService::STATUS_ACTIVE);
$options = [];

foreach ((array) $stages as $stage) {
$stage = (array) $stage;
$value = (string) ($stage['key'] ?? '');

if ($value === '') {
continue;
}

$options[] = [
'label' => (string) ($stage['name'] ?? $value),
'value' => $value,
'category' => (string) ($stage['deal_category'] ?? ''),
];
}

wp_send_json_success(['options' => $options]);
}

public static function refreshInvoiceTerms()
Expand All @@ -48,17 +68,17 @@ public static function refreshCompanies()
wp_send_json_success(['options' => self::normalize((new \BitApps\Crm\Services\CompanyService())->getEntitiesAsOptions())]);
}

/**
* Records of one module, for the pickers that follow a module select.
*
* @param object $data
*/
public static function refreshUsers()
{
self::ensureClass('BitApps\Crm\Services\UserService');
wp_send_json_success(['options' => self::normalize((new \BitApps\Crm\Services\UserService())->getUsersAsOptions())]);
}

/**
* Records of one module, for the pickers that follow a module select.
*
* @param object $data
*/
public static function refreshEntities($data)
{
self::isExists();
Expand All @@ -81,6 +101,18 @@ public static function refreshEntities($data)
wp_send_json_success(['options' => self::normalize((new $service())->getEntitiesAsOptions())]);
}

/**
* @param object $data
*/
public static function refreshFields($data)
{
self::isExists();

$module = isset($data->module) ? sanitize_text_field($data->module) : '';

wp_send_json_success(['fields' => BitCrmFieldService::fields($module)]);
}

public static function refreshLeadTags()
{
wp_send_json_success(['options' => self::tagOptions('lead')]);
Expand Down Expand Up @@ -111,13 +143,12 @@ public function execute($integrationData, $fieldValues)
$integrationDetails = $integrationData->flow_details;
$integId = $integrationData->id;
$fieldMap = $integrationDetails->field_map;
$utilities = isset($integrationDetails->utilities) ? $integrationDetails->utilities : [];

if (empty($fieldMap)) {
return new WP_Error('field_map_empty', __('Field map is empty', 'bit-integrations'));
}

return (new RecordApiHelper($integrationDetails, $integId))->execute($fieldValues, $fieldMap, $utilities);
return (new RecordApiHelper($integrationDetails, $integId))->execute($fieldValues, $fieldMap);
}

private static function ensureClass($class)
Expand Down
Loading