1-- zoho_crm_account_360 - one row per account, with its people, pipeline and notes.
2-- Grain: account. Currency: org reporting currency.
3-- Depends on: staging.zoho_crm.account, .contact, .user, .note
4 , -- core.zoho_crm.deal_pipeline
5--
6-- The account-level view the rest of the schema can actually support: who works
7-- there, what is open, what has been won, and when anybody last wrote anything
8-- down. This is the model to reverse-ETL back into Zoho, or to join to product
9-- usage and billing data for a real customer view.
10--
11-- NOT IN HERE: calls, meetings and tasks. They carry no account key in this
12-- connector - see core.zoho_crm.rep_activity for why. last_note_at is the closest
13-- honest proxy for account engagement the connector allows, and it only reflects
14-- what reps bothered to write down.
15with
16 contacts as (
17 select
18 zoho_org
19 , account_id
20 , count(*) as contacts
21 , countif(email is not null) as contacts_with_email
22 , max(created_time) as last_contact_added_at
23 from
24 {{staging.zoho_crm.contact}}
25 where
26 account_id is not null
27 group by
28 1
29 , 2
30 )
31 , deals as (
32 select
33 zoho_org
34 , account_id
35 , count(*) as deals_total
36 , countif(is_open) as deals_open
37 , countif(is_won) as deals_won
38 , countif(is_lost) as deals_lost
39 , countif(is_overdue) as deals_overdue
40 , sum(pipeline_amount) as open_pipeline_amount
41 , sum(won_amount) as won_amount
42 , sum(lost_amount) as lost_amount
43 , min(
44 case
45 when is_open then closing_date
46 end
47 ) as next_expected_close
48 , max(created_time) as last_deal_created_at
49 from
50 {{core.zoho_crm.deal_pipeline}}
51 where
52 account_id is not null
53 group by
54 1
55 , 2
56 )
57 , -- Notes reach an account three ways: written on the account itself, on one of its
58 -- deals, or on one of its contacts. Zoho ids are globally unique across modules
59 , -- so one join per route resolves the parent without needing $se_module.
60 note_targets as (
61 select
62 a.zoho_org
63 , a.account_id
64 , a.account_id as target_id
65 from
66 {{staging.zoho_crm.account}} a
67 union all
68 select
69 d.zoho_org
70 , d.account_id
71 , d.deal_id
72 from
73 {{core.zoho_crm.deal_pipeline}} d
74 where
75 d.account_id is not null
76 union all
77 select
78 c.zoho_org
79 , c.account_id
80 , c.contact_id
81 from
82 {{staging.zoho_crm.contact}} c
83 where
84 c.account_id is not null
85 )
86 , notes as (
87 select
88 t.zoho_org
89 , t.account_id
90 , count(*) as notes
91 , max(n.created_time) as last_note_at
92 from
93 {{staging.zoho_crm.note}} n
94 join note_targets t on t.zoho_org = n.zoho_org
95 and t.target_id = n.parent_id
96 group by
97 1
98 , 2
99 )
100select
101 a.zoho_org
102 , a.account_id
103 , a.account_name
104 , a.industry
105 , a.website
106 , a.phone
107 , a.created_time as account_created_time
108 , a.owner_id
109 , u.full_name as owner_name
110 , u.email as owner_email
111 , u.is_active as owner_is_active
112 , coalesce(c.contacts, 0) as contacts
113 , coalesce(c.contacts_with_email, 0) as contacts_with_email
114 , coalesce(d.deals_total, 0) as deals_total
115 , coalesce(d.deals_open, 0) as deals_open
116 , coalesce(d.deals_won, 0) as deals_won
117 , coalesce(d.deals_lost, 0) as deals_lost
118 , coalesce(d.deals_overdue, 0) as deals_overdue
119 , coalesce(d.open_pipeline_amount, 0) as open_pipeline_amount
120 , coalesce(d.won_amount, 0) as won_amount
121 , coalesce(d.lost_amount, 0) as lost_amount
122 , d.next_expected_close
123 , coalesce(n.notes, 0) as notes
124 , n.last_note_at
125 , -- Latest of anything datable on the account. Note the absence of calls and
126 -- meetings: this is "last recorded touch", not "last contact".
127 greatest(
128 coalesce(n.last_note_at, timestamp '1970-01-01')
129 , coalesce(d.last_deal_created_at, timestamp '1970-01-01')
130 , coalesce(c.last_contact_added_at, timestamp '1970-01-01')
131 , a.created_time
132 ) as last_recorded_activity_at
133 , -- An account with open pipeline and nothing written on it in a quarter is the
134 -- report this model exists to produce.
135 -- Wrapped for the same reason as is_overdue above: if there is no note and no
136 -- account created_time, the comparison is NULL rather than FALSE, and the flag
137 -- stops being a clean boolean. Nothing to go on means not stale.
138 coalesce(
139 coalesce(d.deals_open, 0) > 0
140 and coalesce(n.last_note_at, a.created_time) < timestamp_sub(current_timestamp(), interval 90 day)
141 , false
142 ) as is_stale_with_open_pipeline
143from
144 {{staging.zoho_crm.account}} a
145 left join contacts c on c.zoho_org = a.zoho_org
146 and c.account_id = a.account_id
147 left join deals d on d.zoho_org = a.zoho_org
148 and d.account_id = a.account_id
149 left join notes n on n.zoho_org = a.zoho_org
150 and n.account_id = a.account_id
151 left join {{staging.zoho_crm.user}} u on u.zoho_org = a.zoho_org
152 and u.user_id = a.owner_id
153order by
154 open_pipeline_amount descRequired models (6)
This model reads from staging models rather than raw tables. Deploy these first — Weld resolves the order for you.
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.contactContacts with a built full_name and the account foreign key renamed.
-- staging.zoho_crm.contact
-- Thin wrapper over raw `contact`. Casts, renames, builds full_name.
--
-- NOTE ON THE ACCOUNT KEY: Zoho's lookup field is called Account_Name, so the
-- connector emits `account_name_id` (the account's id) and `account_name_name`
-- (its label). The _id column is the real foreign key despite the name. It is
-- renamed to account_id here so core reads like a normal star schema.
select
'org_1' as zoho_org
, cast(id as string) as contact_id
, cast(first_name as string) as first_name
, cast(last_name as string) as last_name
, nullif(
trim(
concat(
coalesce(cast(first_name as string), '')
, ' '
, coalesce(cast(last_name as string), '')
)
)
, ''
) as full_name
, lower(nullif(trim(cast(email as string)), '')) as email
, 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.contact}}staging.zoho_crm.noteThe one activity stream that keeps its parent record id.
-- staging.zoho_crm.note
-- Thin wrapper over raw `note`. Casts, renames, normalises blanks.
--
-- THE ONE STREAM WITH A REAL PARENT LINK. Every other activity module (call, task
, -- event) loses its What_Id / Who_Id in this connector, but note keeps its parent:
-- Zoho's Parent_Id lookup arrives as `parent_id_id`, and it is the only way to tie
-- anything a rep wrote down back to the deal, account, contact or lead it was
-- about.
--
-- WHAT MODULE IS THE PARENT? Zoho's API exposes $se_module to say which one, and
-- the connector does not sync it. Record ids are globally unique across Zoho
-- modules though, so the module can be recovered by joining parent_id to each
-- module in turn and seeing which one matches - that is what
-- core.zoho_crm.account_360 does. `parent_name` is the label Zoho denormalised
-- onto the note and is kept only as a fallback for a parent that no longer exists.
select
'org_1' as zoho_org
, cast(id as string) as note_id
, nullif(trim(cast(note_title as string)), '') as note_title
, nullif(trim(cast(note_content as string)), '') as note_content
, cast(parent_id_id as string) as parent_id
, nullif(trim(cast(parent_id_name as string)), '') as parent_name
, 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.note}}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}}core.zoho_crm.deal_pipelineDeal-grain model the other three reports read. Holds the stage classification.
-- zoho_crm_deal_pipeline - every deal, classified, with its account and owner.
-- Grain: one row per deal. Currency: org reporting currency (see scope note).
-- Depends on: staging.zoho_crm.deal, .account, .user
--
-- This is the deal-grain model the other reports read. Classify once here rather
-- than repeating the stage logic in every downstream query.
--
-- SCOPE: current state, not history. Zoho CRM has no history tables in Weld, and
-- this connector syncs no stage-change audit, so there is no way to ask what the
-- pipeline looked like last Tuesday or how long a deal sat in Negotiation. Every
-- deal shows only where it stands now. If you need stage velocity or a pipeline
-- snapshot over time, materialise this model daily and keep the runs.
--
-- Joins are written with explicit ON rather than USING: both deal and account
-- carry owner_id, so a USING(owner_id) join further down would be ambiguous.
with
deal as (
select
d.*
, -- Zoho ships no is_won / is_closed boolean, so won and lost have to be
-- read out of the stage string. Matched on a pattern, not an equality
-- list, because Zoho's own defaults include "Closed-Lost to Competition"
-- and most orgs add their own stages on top.
--
-- Won is tested first so a stage that somehow contains both words is
-- counted once. tests/assert_deal_stages_are_classified.sql lists every
-- stage falling through to Open - read it before trusting the split.
case
when lower(d.stage) like '%won%' then 'Won'
when lower(d.stage) like '%lost%' then 'Lost'
else 'Open'
end as stage_status
from
{{staging.zoho_crm.deal}} d
)
select
d.zoho_org
, d.deal_id
, d.deal_name
, d.stage
, d.stage_status
, d.stage_status = 'Open' as is_open
, d.stage_status = 'Won' as is_won
, d.stage_status = 'Lost' as is_lost
, d.amount
, -- Split out so BI can sum a column instead of writing the CASE again. An open
-- deal contributes to pipeline_amount only; a closed one to won or lost.
case
when d.stage_status = 'Open' then d.amount
end as pipeline_amount
, case
when d.stage_status = 'Won' then d.amount
end as won_amount
, case
when d.stage_status = 'Lost' then d.amount
end as lost_amount
, date(d.created_time) as created_date
, d.closing_date
, d.created_time
, d.modified_time
, -- Age of the deal. For a closed deal this is how long it took; for an open one
-- how long it has been sitting. closing_date is Zoho's EXPECTED close date and
-- it is not cleared when a deal closes, so on a won or lost deal it is the
-- forecast that was in place, not necessarily the day money changed hands.
date_diff(
coalesce(
case
when d.stage_status <> 'Open' then d.closing_date
end
, current_date()
)
, date(d.created_time)
, day
) as age_days
, -- Open deals whose expected close date has already passed: the cheapest
-- pipeline-hygiene number there is, and usually the first thing a sales lead
-- asks for.
--
-- COALESCE, because closing_date can be NULL and NULL < CURRENT_DATE() is
-- NULL, not FALSE. Without it the flag is tri-valued and a downstream
-- WHERE NOT is_overdue quietly drops every deal with no expected close date.
-- No close date means not overdue.
coalesce(
d.stage_status = 'Open'
and d.closing_date < current_date()
, false
) as is_overdue
, case
when d.stage_status = 'Open' then date_diff(d.closing_date, current_date(), day)
end as days_to_expected_close
, d.account_id
, a.account_name
, a.industry
, d.owner_id
, u.full_name as owner_name
, u.email as owner_email
, u.role_name as owner_role
, -- A deal owned by a deactivated rep is unmanaged pipeline. NULL here means the
-- owner_id did not resolve at all - see tests/assert_owner_ids_resolve.sql.
u.is_active as owner_is_active
from
deal d
left join {{staging.zoho_crm.account}} a on a.zoho_org = d.zoho_org
and a.account_id = d.account_id
-- LEFT, not INNER: an owner who has been deleted from Zoho no longer appears in
-- the user module, and an inner join would silently drop their deals from the
-- pipeline total.
left join {{staging.zoho_crm.user}} u on u.zoho_org = d.zoho_org
and u.user_id = d.owner_idanalytics.zoho_crm.account_360Thin BI-facing contract over the core model - bind dashboards here, not to core.
-- analytics.zoho_crm.account_360
-- BI-facing contract over the core model. See analytics/deal_pipeline.sql for why
-- this layer exists even when it is a passthrough.
--
-- This is the natural source for a reverse-ETL sync back into Zoho: writing
-- open_pipeline_amount or is_stale_with_open_pipeline onto the Account record puts
-- the warehouse's view in front of the reps who need it.
select
*
from
{{core.zoho_crm.account_360}}Example output
+ ------------+-------------------+---------------+----------+------------+-----------+----------------------+------------+-------+--------------+-----------------------------+
| account_id | account_name | industry | contacts | deals_open | deals_won | open_pipeline_amount | won_amount | notes | last_note_at | is_stale_with_open_pipeline | + ------------+-------------------+---------------+----------+------------+-----------+----------------------+------------+-------+--------------+-----------------------------+
| 881 | Contoso Ltd | Technology | 14 | 3 | 5 | 186000 | 412000 | 62 | 2026 -08 -25 | false | | 874 | Northwind Traders | Logistics | 9 | 2 | 3 | 121000 | 228000 | 41 | 2026 -08 -19 | false | | 863 | Fabrikam Inc | Manufacturing | 6 | 4 | 1 | 98500 | 64000 | 7 | 2026 -04 -02 | true | | 859 | Tailspin Toys | Retail | 3 | 0 | 2 | 0 | 96000 | 18 | 2026 -07 -30 | false | + ------------+-------------------+---------------+----------+------------+-----------+----------------------+------------+-------+--------------+-----------------------------+The account-level view the Zoho schema can actually support - contacts and how many are reachable by email, deals open, won, lost and overdue with amounts, the next expected close date, and note activity. Notes reach an account three ways: written on the account itself, on one of its deals, or on one of its contacts. Zoho does not sync the field that says which module a note's parent belongs to, but record ids are globally unique across Zoho modules, so one join per route resolves it. Calls, meetings and tasks are deliberately absent because they carry no account key in this connector, which makes last_note_at the closest honest proxy for engagement available. is_stale_with_open_pipeline flags the accounts worth chasing, and the model is a natural source for a reverse-ETL sync writing pipeline value back onto the Zoho Account record.