1-- zoho_crm_deal_pipeline - every deal, classified, with its account and owner.
2-- Grain: one row per deal. Currency: org reporting currency (see scope note).
3-- Depends on: staging.zoho_crm.deal, .account, .user
4--
5-- This is the deal-grain model the other reports read. Classify once here rather
6-- than repeating the stage logic in every downstream query.
7--
8-- SCOPE: current state, not history. Zoho CRM has no history tables in Weld, and
9-- this connector syncs no stage-change audit, so there is no way to ask what the
10-- pipeline looked like last Tuesday or how long a deal sat in Negotiation. Every
11-- deal shows only where it stands now. If you need stage velocity or a pipeline
12-- snapshot over time, materialise this model daily and keep the runs.
13--
14-- Joins are written with explicit ON rather than USING: both deal and account
15-- carry owner_id, so a USING(owner_id) join further down would be ambiguous.
16with
17 deal as (
18 select
19 d.*
20 , -- Zoho ships no is_won / is_closed boolean, so won and lost have to be
21 -- read out of the stage string. Matched on a pattern, not an equality
22 -- list, because Zoho's own defaults include "Closed-Lost to Competition"
23 -- and most orgs add their own stages on top.
24 --
25 -- Won is tested first so a stage that somehow contains both words is
26 -- counted once. tests/assert_deal_stages_are_classified.sql lists every
27 -- stage falling through to Open - read it before trusting the split.
28 case
29 when lower(d.stage) like '%won%' then 'Won'
30 when lower(d.stage) like '%lost%' then 'Lost'
31 else 'Open'
32 end as stage_status
33 from
34 {{staging.zoho_crm.deal}} d
35 )
36select
37 d.zoho_org
38 , d.deal_id
39 , d.deal_name
40 , d.stage
41 , d.stage_status
42 , d.stage_status = 'Open' as is_open
43 , d.stage_status = 'Won' as is_won
44 , d.stage_status = 'Lost' as is_lost
45 , d.amount
46 , -- Split out so BI can sum a column instead of writing the CASE again. An open
47 -- deal contributes to pipeline_amount only; a closed one to won or lost.
48 case
49 when d.stage_status = 'Open' then d.amount
50 end as pipeline_amount
51 , case
52 when d.stage_status = 'Won' then d.amount
53 end as won_amount
54 , case
55 when d.stage_status = 'Lost' then d.amount
56 end as lost_amount
57 , date(d.created_time) as created_date
58 , d.closing_date
59 , d.created_time
60 , d.modified_time
61 , -- Age of the deal. For a closed deal this is how long it took; for an open one
62 -- how long it has been sitting. closing_date is Zoho's EXPECTED close date and
63 -- it is not cleared when a deal closes, so on a won or lost deal it is the
64 -- forecast that was in place, not necessarily the day money changed hands.
65 date_diff(
66 coalesce(
67 case
68 when d.stage_status <> 'Open' then d.closing_date
69 end
70 , current_date()
71 )
72 , date(d.created_time)
73 , day
74 ) as age_days
75 , -- Open deals whose expected close date has already passed: the cheapest
76 -- pipeline-hygiene number there is, and usually the first thing a sales lead
77 -- asks for.
78 --
79 -- COALESCE, because closing_date can be NULL and NULL < CURRENT_DATE() is
80 -- NULL, not FALSE. Without it the flag is tri-valued and a downstream
81 -- WHERE NOT is_overdue quietly drops every deal with no expected close date.
82 -- No close date means not overdue.
83 coalesce(
84 d.stage_status = 'Open'
85 and d.closing_date < current_date()
86 , false
87 ) as is_overdue
88 , case
89 when d.stage_status = 'Open' then date_diff(d.closing_date, current_date(), day)
90 end as days_to_expected_close
91 , d.account_id
92 , a.account_name
93 , a.industry
94 , d.owner_id
95 , u.full_name as owner_name
96 , u.email as owner_email
97 , u.role_name as owner_role
98 , -- A deal owned by a deactivated rep is unmanaged pipeline. NULL here means the
99 -- owner_id did not resolve at all - see tests/assert_owner_ids_resolve.sql.
100 u.is_active as owner_is_active
101from
102 deal d
103 left join {{staging.zoho_crm.account}} a on a.zoho_org = d.zoho_org
104 and a.account_id = d.account_id
105 -- LEFT, not INNER: an owner who has been deleted from Zoho no longer appears in
106 -- the user module, and an inner join would silently drop their deals from the
107 -- pipeline total.
108 left join {{staging.zoho_crm.user}} u on u.zoho_org = d.zoho_org
109 and u.user_id = d.owner_idRequired models (4)
This model reads from staging models rather than raw tables. Deploy these first — Weld resolves the order for you.
staging.zoho_crm.dealDeals with types cast and Zoho's Account_Name lookup renamed to account_id.
-- staging.zoho_crm.deal
-- Thin wrapper over raw `deal`. Casts, renames, normalises blanks. Stage
-- classification is business logic and lives in core.zoho_crm.deal_pipeline.
--
-- ACCOUNT KEY: as on contact, Zoho's lookup is called Account_Name, so the raw
-- column is `account_name_id`. It holds the account's id and is renamed here.
--
-- WHAT IS NOT HERE, because the connector does not sync it: no is_won / is_closed
-- boolean, no probability, no expected_revenue, no currency, no lead_source, no
-- contact_id, no campaign_id and no pipeline name. Won/lost has to be derived from
-- the stage string, and amount is in whatever single currency the org reports in.
select
'org_1' as zoho_org
, cast(id as string) as deal_id
, nullif(trim(cast(deal_name as string)), '') as deal_name
, nullif(trim(cast(stage as string)), '') as stage
, -- No currency column exists on the stream, so this is org-reporting currency.
-- A multi-currency Zoho org cannot be summed correctly from this connector.
cast(amount as numeric) as amount
, cast(closing_date as date) as closing_date
, cast(account_name_id as string) as account_id
, cast(owner_id as string) as owner_id
, cast(created_time as timestamp) as created_time
, cast(modified_time as timestamp) as modified_time
from
{{raw.zoho_crm.deal}}staging.zoho_crm.accountAccounts with blanks normalised and the denormalised owner name dropped.
-- staging.zoho_crm.account
-- Thin wrapper over raw `account`. Casts, renames, normalises blanks to NULL.
--
-- Single Zoho org. To add another, UNION ALL a second block below pointing at that
-- org's connector with a different zoho_org label.
select
'org_1' as zoho_org
, cast(id as string) as account_id
, nullif(trim(cast(account_name as string)), '') as account_name
, nullif(trim(cast(industry as string)), '') as industry
, nullif(trim(cast(phone as string)), '') as phone
, nullif(trim(cast(website as string)), '') as website
, -- Zoho flattens its lookup fields into _id / _name / _email triples. Keep the
-- id for joining and drop the denormalised name - staging.zoho_crm.user is the
-- single source of truth for what a user is called, so a rep who is renamed in
-- Zoho does not leave stale labels scattered across every module.
cast(owner_id as string) as owner_id
, cast(created_time as timestamp) as created_time
, cast(modified_time as timestamp) as modified_time
from
{{raw.zoho_crm.account}}staging.zoho_crm.userThe only dimension the other modules can join to. Derives is_active.
-- staging.zoho_crm.user
-- Thin wrapper over raw `user`. Casts, renames, derives is_active. No other logic.
--
-- This is the only dimension every other stream can join to: every record-owning
-- module carries owner_id, and nothing else in the Zoho schema carries a foreign
-- key. Sync this stream even if you think you do not need it.
--
-- Single Zoho org. To add another, UNION ALL a second block below pointing at that
-- org's connector with a different zoho_org label. Keep it in staging so the core
-- models never have to know how many orgs there are.
select
'org_1' as zoho_org
, cast(id as string) as user_id
, nullif(trim(cast(full_name as string)), '') as full_name
, cast(first_name as string) as first_name
, cast(last_name as string) as last_name
, lower(nullif(trim(cast(email as string)), '')) as email
, cast(status as string) as status
, -- Zoho keeps deactivated users in the module rather than deleting them, so
-- filtering on this is how you get "reps who could take a deal today".
lower(cast(status as string)) = 'active' as is_active
, cast(role_name as string) as role_name
, cast(profile_name as string) as profile_name
, cast(created_time as timestamp) as created_time
, cast(modified_time as timestamp) as modified_time
from
{{raw.zoho_crm.user}}analytics.zoho_crm.deal_pipelineThin BI-facing contract over the core model - bind dashboards here, not to core.
-- analytics.zoho_crm.deal_pipeline
-- BI-facing contract over the core model. Dashboards, scheduled reports and
-- reverse-ETL syncs bind HERE, never to core, so core stays free to be renamed
, -- re-grained or split without breaking anything downstream.
--
-- A passthrough is the correct content for this layer. Put BI-specific shaping
-- (renames for a semantic layer, row filters for a workspace) in this file rather
-- than in core.
select
*
from
{{core.zoho_crm.deal_pipeline}}Example output
+ ---------+--------------------+----------------------------+--------------+--------+-----------------+--------------+----------+------------+-------------------+--------------+
| deal_id | deal_name | stage | stage_status | amount | pipeline_amount | closing_date | age_days | is_overdue | account_name | owner_name | + ---------+--------------------+----------------------------+--------------+--------+-----------------+--------------+----------+------------+-------------------+--------------+
| 4210 | Northwind renewal | Negotiation / Review | Open | 48000 | 48000 | 2026 -09 -30 | 64 | false | Northwind Traders | Amara Okafor | | 4187 | Contoso platform | Closed Won | Won | 125000 | | 2026 -08 -14 | 112 | false | Contoso Ltd | Amara Okafor | | 4155 | Fabrikam pilot | Proposal / Price Quote | Open | 22500 | 22500 | 2026 -08 -01 | 139 | true | Fabrikam Inc | Jonas Beck | | 4102 | Tailspin expansion | Closed-Lost to Competition | Lost | 64000 | | 2026 -07 -18 | 167 | false | Tailspin Toys | Wei Zhang | + ---------+--------------------+----------------------------+--------------+--------+-----------------+--------------+----------+------------+-------------------+--------------+The deal-grain model the rest of the Zoho CRM set reads. It classifies every deal as Open, Won or Lost from the stage string, because Zoho CRM exposes no is_won or is_closed boolean and no probability field. Amount is split into pipeline_amount, won_amount and lost_amount so BI can sum a column instead of repeating the CASE, and each deal carries its age, an is_overdue flag for open deals past their expected close date, its account and industry, and its owner including whether that rep is still active. Deals whose owner has been deleted from Zoho are kept rather than dropped, so the pipeline total stays complete.