# Zoho CRM Integration — tasks, configuration and cron

How b2g registrations reach Zoho CRM, what runs when, and the traps that have
already cost debugging time. Verified against the J3 sandbox org
(`crmsandbox.zoho.com/crm/j3sandbox`) and `bgi_b2g_reg_dev` on **2026-09-02**.

---

## 1. Architecture

Two packs, one HTTP hop between them.

```
registration completes (b2g_usermgmt)
        │
        │  ZohoSync::pushInBackground()  — detached, returns in ~15ms
        ▼
  php yii zohosync/push <companyPk>      (console child)
        │
        │  builds the payload FROM THE DB, POSTs JSON
        ▼
  POST {zohoapiUrl}/apizoho/supplier/create   (b2g_zohoapi, JWT-protected)
        │
        ▼
  Zoho CRM  ──  Accounts + Contacts + Portal_Registration_Module
```

* **b2g_usermgmt** owns *what* to send: reads the DB, resolves master names,
  decides the company's origin, writes the audit rows.
* **b2g_zohoapi** owns *how* to send it: OAuth tokens, field mapping, record
  matching. It has **no DB component** at all — it is pure HTTP integration.

Registration never waits for Zoho. A failure there can never fail a
registration.

---

## 2. Credentials and configuration

### b2g_zohoapi — `config/params-{env}.php`, `zoho` block

| Key | Notes |
|---|---|
| `clientId` / `clientSecret` | From the Zoho API Console client. |
| `refreshToken` | The long-lived credential. Produced **once**, see below. |
| `redirectUri` | Must match the console-registered URL **character for character** — scheme, port, trailing slash. Wrong value ⇒ `invalid_redirect_uri`, which also burns the single-use grant code. Leave empty for a Self Client. |
| `accountsUrl` / `apiBaseUrl` | Data-centre specific. This tenant is `.com`. |
| `apiVersion` | `v8`. |
| `layouts` | Layout ids are **org-specific**; re-list them after switching orgs. |
| `sslVerify` | `false` locally, `true` elsewhere. |
| `logHttpBodies` | `true` logs full request/response bodies to the dated log. |

`fieldMap` is `require`d from [`config/zoho-fieldmap.php`](config/zoho-fieldmap.php).

### Bootstrapping the refresh token

Grant codes are **single-use** and expire in 3–10 minutes. A *failed* exchange
consumes the code too, so get the config right before generating one.

```
php yii zoho/exchange-grant <fresh grant code>
```

Then paste the printed refresh token into `params-{env}.php` and verify:

```
php yii zoho/clear-token && php yii zoho/test-auth
```

Scopes required: `ZohoCRM.modules.ALL,ZohoCRM.settings.ALL,ZohoCRM.users.READ,ZohoCRM.org.READ`.
Set the console's code-expiry dropdown to 10 minutes, not the default 3.

> `zoho/test-org` currently fails with `OAUTH_SCOPE_MISMATCH` because the live
> token was minted without `ZohoCRM.org.READ`. Nothing in the production path
> uses it; only that smoke command. Same for `zoho/test-portals`, which returns
> `api not supported in sandbox`.

### b2g_usermgmt — `config/params-{env}.php`, `zohoCRM` block

| Key | Default | Notes |
|---|---|---|
| `enabled` | — | Master on/off for the whole sync. |
| `baseModulePk` | `1` | Written to `zohoapidtls_tbl.zad_basemodulemst_fk`. |
| `phpBinary` | `php` | **Must be the full path.** See §6. |
| `dedupeWindowSeconds` | `300` | Suppresses a duplicate push, §5. |
| `httpTimeout` | `60` | cURL timeout to zohoapi. Must exceed a full re-push. |
| `connectTimeout` | `5` | |
| `staticNbfNumber` | `''` | **Keep empty.** See §4. |
| `syncBatchLimit` | `25` | Companies per `zohosync/process` run. |
| `syncLookbackDays` | `2` | How far back the sweep looks. |
| `syncMaxAttempts` | `5` | Gives up on a company after this many logged attempts. |

`zohoapiUrl` (root level) is the b2g_zohoapi base URL. `jwtSecret` **must
match** between the two packs or every call returns 401.

---

## 3. What gets written, and when

`source` decides whether an existing CRM record may be overwritten. It comes
from **`membercompanymst_tbl.mcm_regfrom`** — `1` = NIBE/JSRS, `2` = b2g direct.

| Origin | Account | Contact | Portal_Registration_Module |
|---|---|---|---|
| **b2g direct** (`regfrom=2`) | full sync — all mapped fields | create or update | created/updated |
| **NIBE/JSRS** (`regfrom=1`), record **found** | **restricted**: only `B2G_Registration_No` + `B2G_Registered_Date` | link if it exists, create if not — never overwritten | created/updated |
| **NIBE/JSRS**, record **not found** | full create (b2g owns what it creates) | create | created/updated |

The restriction exists because a JSRS/NIBE Account belongs to that system;
pushing b2g's sparser copy over it destroys their data. Everything b2g wants to
record instead lands on the portal-membership record.

> **Do not infer origin from `mcm_bridgecompid` or `mcm_suppliercode`.** Those
> mean "was pushed *to* the bridge", not "came *from* it": b2g pushes every
> registration to the bridge and stores the NIBE id it returns ~15s later. The
> old heuristic misclassified **884** companies.

### Record matching order

1. `recordId` — stored in `zohoapidtls_tbl`, exact and immune to search lag.
2. `nbfNumber` — the JSRS/NIBE key (CRM `NBF_Number`). **Currently dormant**, §4.
3. `crNumber` — fallback.

Both searches use `per_page=2` and return "not found" unless **exactly one**
record matches. Without that guard a shared CR wrote a b2g registration onto an
unrelated organisation (reproduced: CR `er343434` sits on 18 records).

`CR_Number` is **not unique** in this org — 817 distinct values across 1071
populated records. `NBF_Number` is (1060 across 1068).

### Known window — Zoho's search index lags creation by up to ~2 minutes

Two pushes seconds apart both miss the search and both insert. This is why the
dedupe guard (§5) exists. Marking `b2g_Registration_No` **unique** in the CRM
would let Zoho de-duplicate server-side; the insert already goes through
`/upsert` with `duplicate_check_fields`, so it starts working the day that
happens, with no code change.

---

## 4. The NBF number is not currently available

`ZohoSync::bridgeNbfNumber()` returns `''` for every company, so NBF matching
never engages.

The bridge's `api/v1/supplierIntegration/getSupplierDls` does **not** expose
`bmcm_jsrsregistrationno`. Its `bridgeMemberCompanyMst` section was dumped for
four live NIBE companies: 46 keys, none NBF-like.
`bridgeMemberRegistrationMst` carries only `jsrsVal*` statuses and dates. The
nearest candidate, `memberRegistrationId` (`11089` for companyPk 1480), is not
it — no Account carries NBF 11089 while that company's Account holds `3757`.

**To fix, one of:** have the bridge add the field to that endpoint, or store it
on b2g's company row at registration. The method already accepts several key
spellings, so it starts working as soon as the bridge returns any of them.

> **Never set `staticNbfNumber` to a real number.** A fixed value matches every
> JSRS/NIBE company to the single Account holding it. With `3767` set,
> companies 1478 and 1487 both mapped to Account `5035578000012383010`, and
> 1487's registration number overwrote 1478's on it.

---

## 5. Duplicate suppression

`pushCompany()` skips a push when the same registration event already succeeded
inside `dedupeWindowSeconds`. Identity is a fingerprint of DB-derived fields
only — companyPk, source, CR, registration no, registered date, NBF, company
name, contact email.

It deliberately excludes `recordId` (the first push stores it, so including it
would make the second half of a double-fire look different) and every
`masterName()` result: those return `''` when the master service is briefly
slow, and two pushes once differed *only* by `state: "" → "Al Batinah South"`,
letting a duplicate through.

Override with `force` when you genuinely need to re-send unchanged data:

```
php yii zohosync/push <companyPk> 2 1
```

---

## 6. `phpBinary` must be a full path

`pushInBackground()` spawns
`start /B "" <phpBinary> yii zohosync/push <pk>`. When `phpBinary` is unset the
code falls back to a bare `php`, which is **not on PATH** for the Apache/mod_php
process (`where php` finds nothing; Apache's own PATH has no `D:\xampp8\php`).

The cost is paid **inline by the registration request** — Windows spends
1–7 seconds failing to resolve the missing binary before `popen()` returns
(measured 2402 / 1032 / 7355 ms, once 29522 ms) — **and the sync never runs**.
With the full path: 21 / 14 / 15 ms, and the child executes.

That single missing param was the cause of both "registration is slow" and
"registrations aren't syncing".

---

## 7. Commands

### b2g_usermgmt

| Command | Purpose |
|---|---|
| `yii zohosync/push <pk> [type] [force]` | Push one company. What registration spawns. `force=1` bypasses the dedupe guard. |
| `yii zohosync/process [limit]` | Sweep everything still unsynced. The cron job. |
| `yii zohosync/token [companyPk] [userPk] [days]` | Print a Bearer token for Postman/curl. |

### b2g_zohoapi

| Command | Purpose |
|---|---|
| `yii zoho/test-auth` | Validate config, acquire/cache a token. |
| `yii zoho/exchange-grant <code>` | One-time refresh-token bootstrap. |
| `yii zoho/clear-token` | Drop the cached access token. |
| `yii zoho/fields <Module> [filter]` | Live field API names, types, picklist values. **Run before editing the field map.** |
| `yii zoho/layouts <Module>` | Layout ids for `params.zoho.layouts`. |
| `yii zoho/push-payload <file.json>` | Replay an exact payload, no JWT needed. Accepts a whole `zam_data` row. |
| `yii zoho/test-supplier` | Live create of a marked `B2G TEST` account. |

---

## 8. Cron

Registration pushes on its own, in the background. Cron is the **safety net**
for anything that missed: a failed spawn, a server restart, the sync disabled at
the time, or a transient Zoho outage.

### The job

[`b2g_usermgmt/cron/zohosyncprocess.bat`](../b2g_usermgmt/cron/zohosyncprocess.bat)

```bat
D:\xampp8\php\php.exe D:\xampp8\htdocs\b2g\b2g_usermgmt\yii zohosync/process >> D:\xampp8\htdocs\b2g\b2g_usermgmt\runtime\logs\cron_zohosync.log 2>&1
```

**Recommended schedule: every 15 minutes.** The work list is derived, not
stored, so an empty run costs one indexed query. Bounded three ways —
`syncBatchLimit` (25/run), `syncLookbackDays` (2), `syncMaxAttempts` (5) — so a
permanently-failing company cannot spin.

Register it in Windows Task Scheduler: *Create Task* → run whether the user is
logged on or not → *Action: Start a program* → the `.bat` above → *Trigger:
daily, repeat every 15 minutes for 1 day*.

Verified run:

```
Window: last 2 day(s), max 5 attempts, up to 25 per run.
Pending: 7 company(s) — 1474, 1475, 1482, 1484, 1494, 1495, 1496
Done. 7 synced, 0 failed.
```

### The other cron .bat files in that folder are broken

Not part of this integration, but they fail for the reason in §6 and someone
should fix them:

| File | Problem |
|---|---|
| `quarterwiseemissionsync.bat` | bare `php` — not on PATH |
| `remaindermail.bat` | bare `php` — not on PATH |
| `repushbridgedata.bat` | bare `php` **and** a relative `yii`; a scheduled task starts in `%SystemRoot%\system32` |
| `updateuserpassword.bat` | points at `D:\xampp8\htdocs\b2g_usermgmt\console\yii`, which does not exist |

---

## 9. Audit trail

| Table | Holds |
|---|---|
| `zohoapimain_tbl` | One row per attempt. `zam_data` = method + URL + payload (the Authorization header is **never** stored), `zam_api_res` = the response envelope, `zam_status` 1=success 2=failure, `zam_createdby` = companyPk. |
| `zohoapidtls_tbl` | One row per company: `zad_recordID` (Account), `zad_contactid` (Contact). |

Dated logs: `b2g_zohoapi/runtime/logs/zoho_api_<date>.log` has the **raw Zoho
request and response bodies**; `b2g_usermgmt/runtime/logs/zoho_sync_<date>.log`
has the payload and envelope per attempt.

Manual re-push from Postman: see
[`postman/b2g-zoho-repush.postman_collection.json`](postman/b2g-zoho-repush.postman_collection.json).
The endpoint is JWT-protected, so a `zam_data` row cannot be replayed without a
token from `zohosync/token`.

### Push by company PK from Postman (no JWT, no console) — added 2026-10-05

For backfilling companies that already exist in the CRM (JSRS/NIBE) on a
server where nobody can run `zohosync/token` (ECS/AWS), b2g_usermgmt's
`apium/zohosync/push` also accepts a **static shared secret**:

```
GET {usermgmt}/apium/zohosync/push?companypk=<membercompmst_pk>[&force=1]
X-Zoho-Sync-Secret: <params['zohoSyncSecret']>
```

* The secret lives in `b2g_usermgmt/config/params-{env}.php` as
  `zohoSyncSecret`. Empty value = header auth off, JWT only. Change it per
  environment; it can create/update CRM records.
* Same code path as registration: payload built from the DB, existing records
  updated, `zohoapimain_tbl` + `zohoapidtls_tbl` written automatically on
  success. Nothing to update by hand.
* Collection:
  [`postman/b2g-zoho-push-by-companypk.postman_collection.json`](postman/b2g-zoho-push-by-companypk.postman_collection.json)
  — includes a Collection Runner setup for a CSV of PKs.
* Pick the companies with:

  ```sql
  SELECT c.membercompmst_pk, c.mcm_companyname, c.mcm_registrationno
  FROM membercompanymst_tbl c
  LEFT JOIN zohoapidtls_tbl d ON d.zad_membercompanymst_fk = c.membercompmst_pk
  WHERE c.mcm_regfrom = 1 AND d.zad_recordID IS NULL
  ORDER BY c.membercompmst_pk;
  ```

* For `regfrom=1` companies the match is by CR number only (§4), so check
  `data.existing` / `data.matchedBy` in every response and keep a list of any
  `existing: false` — those created a new Account and may need merging.

### Troubleshooting a 401 from `apizoho/*`

`{"name":"Unauthorized",...}` is **Yii rejecting the JWT** — it happens before
any Zoho call, so it is never a Zoho problem, and `zoho/test-auth` passing is
irrelevant (that command exercises the *refresh token*, a different
credential entirely).

Run the diagnostic **on the box that answers 401** — it reads the deployed
params of both packs, compares secrets, mints a known-good token and calls the
live endpoint, then names the broken link:

```
cd <deploy>\zohoapi\tools
php diag-demo-auth.php --token=<the token your client sends>
```

([`tools/diag-demo-auth.php`](tools/diag-demo-auth.php) — standalone, no
vendor/, no DB.) The usual culprits, in the order the tool checks them:

1. `env.php` resolving the host to the wrong environment (unknown hosts fall
   back to **local**, silently loading `params-local.php`).
2. `jwtSecret` differing between the deployed packs.
3. An expired or wrongly-signed client token (`jwtExpiry` default is 24 h).
4. Apache dropping the `Authorization` header under CGI/FastCGI — mod_php gets
   it for free, CGI does not, so the same request works locally and 401s on
   the server. `web/.htaccess` carries the passthrough rewrite since
   2026-09-03; a deploy older than that needs it copied across.

---

## 10. Field mapping traps

All of these were paid for once already. Full detail in
[`config/zoho-fieldmap.php`](config/zoho-fieldmap.php).

* **Zoho silently drops unknown field names on create** — no error. Always
  confirm with `zoho/fields` before adding a mapping.
* **An unknown *picklist value* is different**: it returns `INVALID_DATA` and
  fails the **whole record**. The portal mapping therefore has an `allowed`
  list and drops anything outside it with a warning.
* `Designation1` is the API name of the Contacts field labelled "Designation".
  `Designation` is silently ignored.
* Mobile and landline are separate in b2g *and* in the CRM — don't merge them.
  Sending the mobile as `phone` left `Mobile` empty and mislabelled the number.
* The classification master's name column is `clm_classificationtype`.
* Accounts' `Registration_Source` picklist has a **typo** (a missing closing
  parenthesis) that the portal module's `Source_of_knowing_b2g` does not, so
  the two modules cannot share a value map.
* CRM `date` fields reject a datetime — the `date` transform normalises to
  `Y-m-d`.
* `Objective_to_be_in_blu2green_Framework` is a **single** picklist while b2g
  sends an array; only the first value is kept.

---

## 11. Open items

**Needs a CRM admin**

* Add `Non - SME` (146 companies) and `Large` (91) to the portal module's
  `Classification` picklist. Until then `Classification` stays blank for those
  237 companies — deliberately not folded into a near-miss value.
* Mark `b2g_Registration_No` **unique** on `Portal_Registration_Module`, so
  Zoho de-duplicates server-side (§3).
* Re-mint the refresh token with `ZohoCRM.org.READ` if `zoho/test-org` is wanted.

**Needs the bridge team**

* Expose `bmcm_jsrsregistrationno` on `getSupplierDls` (§4).

**Data to clean**

* Duplicate portal records for `b2g-IN00006`, `b2g-OM00075`, `b2g-OM00076`,
  `b2g-OM00077` — 2 each. Until cleared, those companies hit the ambiguity
  guard and a re-push inserts a **third**.
* Account `5035578000012383010` (`SDFFDFSSDF`) carries `b2g-OM00081`, which
  belongs to company 1487, and 1487 is mapped to that same Account — both
  caused by `staticNbfNumber`.
* Company 1483 (`regfrom=1`) was full-synced under the old misclassification,
  so b2g's values were written over a NIBE-owned Account.

**Deployment**

* `phpBinary` is set only in `params-local.php`. `params-dev/demo/uat/prod`
  need each host's own php path or they hit §6.
