Weld logo

Zoho CRM Deal Flow by Month

Deals created, won and lost per month per rep, with win rate by count and by value. Built on a month spine so a quiet month returns zeros instead of vanishing from the chart.

Source
Zoho CRM
Level
Advanced
Reads
core.zoho_crm.deal_pipeline
1-- zoho_crm_deal_flow_by_month - deals created, won and lost per month per rep.
2-- Grain: month x owner. Currency: org reporting currency.
3-- Depends on: core.zoho_crm.deal_pipeline
4--
5-- Built on a month spine so a rep with no activity in a month still returns a row
6-- of zeros rather than disappearing from the series - otherwise a BI line chart
7-- interpolates straight over the quiet month and the gap is invisible.
8--
9-- WHICH DATE EACH EVENT LANDS ON:
10--   created  -> created_time, which is a real audit timestamp.
11--   won/lost -> closing_date, which is Zoho's EXPECTED close date.
12--
13-- That second one is the compromise this model cannot avoid. The connector syncs
14-- no actual close timestamp and no stage history, so closing_date is the only
15-- close date available. If a rep sets a deal to Closed Won in June while its
16-- closing_date still says March, this model books the win in March. modified_time
17-- would move it to June, but modified_time changes on ANY edit, so a note added in
18-- August would move the win again. A forecast date that is wrong once beats a date
19-- that moves every time somebody touches the record.
20--
21-- The fix is upstream, not here: materialise deal_pipeline daily and derive real
22-- close dates from when stage_status first changed.
23with
24    month_spine as (
25        select
26            month
27        from
28            unnest (
29                generate_date_array(
30                    -- Start the spine at the first deal rather than a hardcoded date, so the
31                    -- series never carries years of empty leading months.
32                    (
33                        select
34                            date_trunc(min(created_date), month)
35                        from
36                            {{core.zoho_crm.deal_pipeline}}
37                    )
38                  , -- End a year out: closing_date is a forecast, so pipeline legitimately
39                    -- sits in the future and must not be truncated away.
40                    date_trunc(date_add(current_date(), interval 1 year), month)
41                  , interval 1 month
42                )
43            ) as month
44    )
45  , owners as (
46        -- Only reps who actually appear on a deal. Spining every Zoho user against
47        -- every month would pad the output with support and admin logins.
48        select distinct
49            zoho_org
50          , owner_id
51          , owner_name
52        from
53            {{core.zoho_crm.deal_pipeline}}
54    )
55  , grid as (
56        select
57            s.month
58          , o.zoho_org
59          , o.owner_id
60          , o.owner_name
61        from
62            month_spine s
63            cross join owners o
64    )
65  , created as (
66        select
67            date_trunc(created_date, month) as month
68          , zoho_org
69          , owner_id
70          , count(*) as deals_created
71          , sum(amount) as deals_created_amount
72        from
73            {{core.zoho_crm.deal_pipeline}}
74        group by
75            1
76          , 2
77          , 3
78    )
79  , closed as (
80        select
81            date_trunc(closing_date, month) as month
82          , zoho_org
83          , owner_id
84          , countif(is_won) as deals_won
85          , sum(won_amount) as deals_won_amount
86          , countif(is_lost) as deals_lost
87          , sum(lost_amount) as deals_lost_amount
88          , -- Open deals forecast to close in this month. Not an outcome - a promise.
89            countif(is_open) as deals_forecast
90          , sum(pipeline_amount) as pipeline_amount
91        from
92            {{core.zoho_crm.deal_pipeline}}
93        where
94            closing_date is not null
95        group by
96            1
97          , 2
98          , 3
99    )
100select
101    g.month
102  , g.zoho_org
103  , g.owner_id
104  , g.owner_name
105  , coalesce(c.deals_created, 0) as deals_created
106  , coalesce(c.deals_created_amount, 0) as deals_created_amount
107  , coalesce(x.deals_won, 0) as deals_won
108  , coalesce(x.deals_won_amount, 0) as deals_won_amount
109  , coalesce(x.deals_lost, 0) as deals_lost
110  , coalesce(x.deals_lost_amount, 0) as deals_lost_amount
111  , coalesce(x.deals_forecast, 0) as deals_forecast
112  , coalesce(x.pipeline_amount, 0) as pipeline_amount
113  , -- Win rate by count, over decided deals only. Open deals are excluded from the
114    -- denominator: counting them as not-yet-won drags every current month down and
115    -- makes the trend look like a collapse.
116    safe_divide(
117        coalesce(x.deals_won, 0)
118      , coalesce(x.deals_won, 0) + coalesce(x.deals_lost, 0)
119    ) as win_rate
120  , safe_divide(
121        coalesce(x.deals_won_amount, 0)
122      , coalesce(x.deals_won_amount, 0) + coalesce(x.deals_lost_amount, 0)
123    ) as win_rate_by_value
124  , -- Average won deal size, the other half of any quota conversation.
125    safe_divide(
126        coalesce(x.deals_won_amount, 0)
127      , nullif(x.deals_won, 0)
128    ) as average_won_deal_size
129from
130    grid g
131    left join created c on c.month = g.month
132    and c.zoho_org = g.zoho_org
133    and c.owner_id = g.owner_id
134    left join closed x on x.month = g.month
135    and x.zoho_org = g.zoho_org
136    and x.owner_id = g.owner_id
137order by
138    g.month
139  , g.owner_name

Required models (5)

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}}
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_id
analytics.zoho_crm.deal_flow_by_monthThin BI-facing contract over the core model - bind dashboards here, not to core.
-- analytics.zoho_crm.deal_flow_by_month
-- BI-facing contract over the core model. See analytics/deal_pipeline.sql for why
-- this layer exists even when it is a passthrough.
select
    *
from
    {{core.zoho_crm.deal_flow_by_month}}

Example output

+ ------------+--------------+---------------+----------------------+-----------+------------------+------------+-------------------+----------+-----------------------+
| month | owner_name | deals_created | deals_created_amount | deals_won | deals_won_amount | deals_lost | deals_lost_amount | win_rate | average_won_deal_size | + ------------+--------------+---------------+----------------------+-----------+------------------+------------+-------------------+----------+-----------------------+
| 2026 -06 -01 | Amara Okafor | 14 | 410000 | 6 | 228000 | 4 | 96000 | 0.60 | 38000 | | 2026 -06 -01 | Jonas Beck | 9 | 187000 | 3 | 74000 | 5 | 121000 | 0.375 | 24667 | | 2026 -07 -01 | Amara Okafor | 11 | 362000 | 7 | 301000 | 2 | 48000 | 0.778 | 43000 | | 2026 -07 -01 | Jonas Beck | 12 | 244000 | 4 | 96000 | 3 | 61000 | 0.571 | 24000 | | 2026 -08 -01 | Amara Okafor | 8 | 215000 | 5 | 174000 | 1 | 19000 | 0.833 | 34800 | + ------------+--------------+---------------+----------------------+-----------+------------------+------------+-------------------+----------+-----------------------+

Turns the deal pipeline into a monthly series per rep: deals created and their value, deals won and lost with amounts, open deals forecast to close in the month, win rate by count and by value, and average won deal size. Open deals are excluded from the win-rate denominator, because counting them as not-yet-won drags every current month down and makes the trend look like a collapse. Wins and losses are booked on closing_date - Zoho's expected close date - because the connector syncs no actual close timestamp and no stage history; modified_time would be worse, since it moves every time anyone edits the record. The month spine starts at the first deal and runs a year past today, so forecast pipeline is not truncated away.

Browse every Zoho CRM SQL template, or all templates.