Listings Deep Dive — Client History & Rankings (KB)
Per-subscriber forensic dataset for every account that ever billed for PMS - Saymore. One row per subscriber.
docs/saymore-deep-dive-kb.csv— the dataset. 1,648 rows × 25 cols. Regenerated by re-running the build (see below).docs/product-onboard-cohort-analysis.md— methodology underpinning the bridge alignment and SQL building blocks.
Schema (25 columns)
Section titled “Schema (25 columns)”Identity / account
Section titled “Identity / account”| Column | Source | Notes |
|---|---|---|
subscriber_id | Accounts_and_related_data.accountNumber | INT64 |
account_name | dim_listings_with_subscriber_id.AccountName | CRM-side name |
bizname | dim_listings_with_subscriber_id.bizname | Public GP listing name |
main_category | dim_listings_with_subscriber_id.main_category_name | GP primary category |
city / county | Accounts_and_related_data.address1City / address1County | Trimmed city; raw county |
locality | Derived | Metro if city matches Dublin/Cork/Galway/Limerick/Waterford (incl. Dublin postal codes), else Other |
am | Accounts_and_related_data.accountManager.fullName | Last/First format from CRM |
First SayMore line
Section titled “First SayMore line”| Column | Source | Notes |
|---|---|---|
cohort_year | EXTRACT(YEAR FROM MIN(firstInvoiceOn)) | Year of subscriber’s earliest SM billing start. Naïve cut — may put renewal-cycle subscribers in the wrong year. Cross-reference with bridge_cohort_year. |
first_sm_order_number | orders.orderNumber | The order containing the earliest-billing SM line |
first_sm_signed_date | orders.signedDate | When the order was signed |
first_sm_billing_start | ri.firstInvoiceOn | Canonical billing start |
first_sm_product_name | salesOrderDetails.product.name | E.g. Saymore - Recurring Maintenance, Saymore Ultimate |
first_sm_mrr | Proportional split from RI total | Just this line’s portion of the BG’s recurring invoice, not the whole bundle |
first_sm_line_cancellation | salesOrderDetails.dateOfCancellation | NULL if this line wasn’t cancelled |
SayMore lifecycle (across all SM lines for this subscriber)
Section titled “SayMore lifecycle (across all SM lines for this subscriber)”| Column | Source | Notes |
|---|---|---|
last_sm_end | MAX of latest expireOn/dateOfCancellation across all SM lines | Effective end of SayMore relationship. 2099-01-01 if any line has no end date set (open-ended). |
last_sm_cancellation | MAX of dateOfCancellation across SM lines | NULL if never cancelled. |
currently_alive_sm | Boolean | Has any SM line in latest active_clients snapshot. Authoritative for “still a SayMore client today”. |
Portfolio context
Section titled “Portfolio context”| Column | Source | Notes |
|---|---|---|
pre_sm_groups | ;-joined list of product groups | Distinct subscription product groups (≠ PMS - Saymore) the subscriber was actively billing for at first_sm_billing_start. Empty = no prior recurring billing. |
pre_sm_group_count | INT | Number of distinct pre-SM product groups |
pre_sm_mrr | EUR | Sum of proportional monthly value across all pre-SM active subscription lines at SM start |
post_sm_added_groups | ;-joined list | Product groups whose first ever firstInvoiceOn postdates first_sm_billing_start — i.e. new product groups added after SM |
post_sm_lost_groups | ;-joined list | Product groups (≠ SM) where all RIs have ended (no open-ended ongoing line) AND the last end was after SM start. Caveat: PMS - Snapsync may show up here for legacy clients who migrated from SnapSync to SayMore — that’s not a real product loss, it’s the same product under a new label. |
Bridge alignment
Section titled “Bridge alignment”| Column | Source | Notes |
|---|---|---|
bridge_cohort_year | First year the subscriber appeared in the revenue bridge’s inflow detail | Matches dashboard revenue-bridge “New + Renewed + Returned” classification. Use this for any analysis you want to reconcile against the dashboard UI. Empty = subscriber appeared only in opening, not inflow (likely a long-tenured cycle-renewal). |
bridge_class | new / renewed / returned | The classification the bridge gave them in their first inflow year. new = no prior FCR billing; renewed = existing FCR client (any group) added SM; returned = appeared, dropped, came back within the same year. |
Cohort summary (from this dataset)
Section titled “Cohort summary (from this dataset)”| Bridge cohort year | n | Alive today | Retention | New | Renewed | Returned |
|---|---|---|---|---|---|---|
| 2019 | 113 | 23 | 20% | 73 | 40 | 0 |
| 2020 | 181 | 67 | 37% | 72 | 109 | 0 |
| 2021 | 227 | 73 | 32% | 153 | 74 | 0 |
| 2022 | 136 | 51 | 38% | 96 | 40 | 0 |
| 2023 | 186 | 77 | 41% | 122 | 64 | 0 |
| 2024 | 152 | 82 | 54% | 83 | 69 | 0 |
| 2025 | 187 | 173 | 93% | 112 | 74 | 1 |
| 2026 YTD | 116 | 115 | 99% | 50 | 66 | 0 |
| No bridge match | 350 | 161 | 46% | – | – | – |
| Total | 1,648 | 822 | 50% | 761 | 536 | 1 |
The 350 “no bridge match” rows are subscribers whose firstInvoiceOn falls in some year but the bridge didn’t classify them as inflow — meaning the bridge already saw them in opening. Most likely cycle-renewals (replacement BG created in a later year, but bridge has them tracked from earlier). The 134 in this bucket whose naïve cohort_year=2020 is the biggest concentration — these are the COVID-era renewal-cycle entries.
How to rebuild
Section titled “How to rebuild”node .tmp_sm_kb_build.cjs # see git history for the script bodyTwo halves:
- Call
/dashboard-revenue-bridgefor 2018–2026, buildsubscriber → (bridge_year, bridge_class)map - Run the unified BQ query (template below), augment with bridge map, write CSV
Script automatically calls ?auto_csv=true&csv_title=saymore-deep-dive-knowledge-base on the worker so a copy also lands in R2 at a worker-served URL.
Unified SQL (rebuild from scratch)
Section titled “Unified SQL (rebuild from scratch)”WITH all_lines AS ( SELECT a.accountNumber AS subscriber_id, TRIM(a.address1City) AS city, a.address1County AS county, a.accountManager.fullName AS am, bg.id AS bg_id, o.orderNumber AS order_number, o.signedDate AS signed_date, ol.salesOrderDetailId AS sod_id, ol.product.productGroup.name AS pg, ol.product.name AS product_name, ol.baseAmount AS base_amount, ol.dateOfCancellation AS line_cancellation, ri.firstInvoiceOn AS billing_start, ri.expireOn AS expire_on, ri.totalLineItemAmount AS ri_total, ri.invoicePeriod AS ri_period, ri.recurringInvoiceDetails AS ri_details FROM `listingmanager-1529856313699.DYNAMICS_DATA.Accounts_and_related_data` a, UNNEST(a.orders) o, UNNEST(o.salesOrderDetails) ol, UNNEST([ol.billingGroup]) bg, UNNEST(bg.recurringInvoices) ri WHERE ol.product IS NOT NULL AND COALESCE(ol.product.subscription, FALSE) = TRUE AND bg.id IS NOT NULL AND COALESCE(ri.isDeposit, FALSE) = FALSE AND COALESCE(ri.totalLineItemAmount, 0) > 0 AND COALESCE(ri.invoicePeriod, 0) > 0 AND ri.firstInvoiceOn IS NOT NULL AND (ol.parentItemGuid IS NULL OR ol.baseAmount > 0)),lines_filtered AS ( SELECT * EXCEPT(ri_details) FROM all_lines WHERE ARRAY_LENGTH(ri_details) = 0 OR sod_id IN (SELECT rid.salesOrderDetailId FROM UNNEST(ri_details) rid)),bg_totals AS ( SELECT bg_id, billing_start, SUM(base_amount) total_base, COUNT(*) line_count FROM lines_filtered GROUP BY bg_id, billing_start),lines_mrr AS ( SELECT lf.*, CASE WHEN bt.total_base > 0 THEN lf.ri_total * (lf.base_amount / bt.total_base) / lf.ri_period WHEN bt.line_count > 0 THEN lf.ri_total / bt.line_count / lf.ri_period ELSE lf.ri_total / lf.ri_period END AS monthly_value FROM lines_filtered lf JOIN bg_totals bt ON lf.bg_id = bt.bg_id AND lf.billing_start = bt.billing_start),sm_subs AS ( SELECT subscriber_id, MIN(billing_start) first_sm_start FROM lines_mrr WHERE pg = 'PMS - Saymore' GROUP BY subscriber_id),first_sm_line AS ( SELECT s.subscriber_id, l.order_number, l.signed_date, l.billing_start, l.expire_on, l.line_cancellation, l.product_name, l.monthly_value, l.am, l.city, l.county FROM sm_subs s JOIN lines_mrr l ON s.subscriber_id = l.subscriber_id AND l.billing_start = s.first_sm_start AND l.pg = 'PMS - Saymore' QUALIFY ROW_NUMBER() OVER (PARTITION BY s.subscriber_id ORDER BY l.monthly_value DESC) = 1),all_sm AS ( SELECT subscriber_id, MIN(billing_start) first_sm_start, MAX(LEAST(IFNULL(expire_on, TIMESTAMP('2099-01-01')), IFNULL(line_cancellation, TIMESTAMP('2099-01-01')))) AS last_sm_end, MAX(line_cancellation) AS last_sm_cancellation FROM lines_mrr WHERE pg = 'PMS - Saymore' GROUP BY subscriber_id),pre_sm_groups AS ( SELECT l.subscriber_id, STRING_AGG(DISTINCT l.pg, '; ' ORDER BY l.pg) AS group_list, COUNT(DISTINCT l.pg) AS group_count, SUM(l.monthly_value) AS pre_sm_mrr_total FROM lines_mrr l JOIN sm_subs s ON l.subscriber_id = s.subscriber_id WHERE l.pg != 'PMS - Saymore' AND l.billing_start < s.first_sm_start AND (l.expire_on IS NULL OR l.expire_on > s.first_sm_start) AND (l.line_cancellation IS NULL OR l.line_cancellation > s.first_sm_start) GROUP BY l.subscriber_id),group_first_starts AS ( SELECT subscriber_id, pg, MIN(billing_start) AS first_pg_start FROM lines_mrr GROUP BY subscriber_id, pg),post_sm_added AS ( SELECT g.subscriber_id, STRING_AGG(DISTINCT g.pg, '; ' ORDER BY g.pg) AS added_groups FROM group_first_starts g JOIN sm_subs s ON g.subscriber_id = s.subscriber_id WHERE g.pg != 'PMS - Saymore' AND g.first_pg_start > s.first_sm_start GROUP BY g.subscriber_id),group_last_ends AS ( SELECT subscriber_id, pg, MAX(LEAST(IFNULL(expire_on, TIMESTAMP('2099-01-01')), IFNULL(line_cancellation, TIMESTAMP('2099-01-01')))) AS last_pg_end, LOGICAL_OR(expire_on IS NULL AND line_cancellation IS NULL) AS has_open_ended FROM lines_mrr GROUP BY subscriber_id, pg),post_sm_lost AS ( SELECT g.subscriber_id, STRING_AGG(DISTINCT g.pg, '; ' ORDER BY g.pg) AS lost_groups FROM group_last_ends g JOIN sm_subs s ON g.subscriber_id = s.subscriber_id WHERE g.pg != 'PMS - Saymore' AND NOT g.has_open_ended AND g.last_pg_end > s.first_sm_start AND g.last_pg_end < CURRENT_TIMESTAMP() GROUP BY g.subscriber_id),live_sm AS ( SELECT DISTINCT subscriber_id FROM `listingmanager-1529856313699.fcr_operations.active_clients` WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM `listingmanager-1529856313699.fcr_operations.active_clients`) AND product_group = 'PMS - Saymore'),biz AS ( SELECT SAFE_CAST(subscriber_id AS INT64) subscriber_id, ANY_VALUE(AccountName) account_name, ANY_VALUE(bizname) bizname, ANY_VALUE(main_category_name) main_category FROM `listingmanager-1529856313699.SUPERSET_MASTER.dim_listings_with_subscriber_id` WHERE subscriber_id IS NOT NULL GROUP BY subscriber_id)SELECT s.subscriber_id, b.account_name, b.bizname, b.main_category, fl.city, fl.county, CASE WHEN REGEXP_CONTAINS(LOWER(fl.city), r'^[ ]*(dublin([ ][0-9]+w?)?|dublin city|cork([ ]city)?|galway([ ]city)?|limerick([ ]city)?|waterford([ ]city)?)[ ]*$') THEN 'Metro' ELSE 'Other' END AS locality, fl.am, EXTRACT(YEAR FROM s.first_sm_start) AS cohort_year, fl.order_number AS first_sm_order_number, fl.signed_date AS first_sm_signed_date, fl.billing_start AS first_sm_billing_start, fl.product_name AS first_sm_product_name, ROUND(fl.monthly_value, 2) AS first_sm_mrr, fl.line_cancellation AS first_sm_line_cancellation, asml.last_sm_end, asml.last_sm_cancellation, IF(ls.subscriber_id IS NOT NULL, TRUE, FALSE) AS currently_alive_sm, IFNULL(psg.group_list, '') AS pre_sm_groups, IFNULL(psg.group_count, 0) AS pre_sm_group_count, ROUND(IFNULL(psg.pre_sm_mrr_total, 0), 2) AS pre_sm_mrr, IFNULL(psa.added_groups, '') AS post_sm_added_groups, IFNULL(psl.lost_groups, '') AS post_sm_lost_groupsFROM sm_subs sLEFT JOIN first_sm_line fl ON s.subscriber_id = fl.subscriber_idLEFT JOIN all_sm asml ON s.subscriber_id = asml.subscriber_idLEFT JOIN biz b ON s.subscriber_id = b.subscriber_idLEFT JOIN pre_sm_groups psg ON s.subscriber_id = psg.subscriber_idLEFT JOIN post_sm_added psa ON s.subscriber_id = psa.subscriber_idLEFT JOIN post_sm_lost psl ON s.subscriber_id = psl.subscriber_idLEFT JOIN live_sm ls ON s.subscriber_id = ls.subscriber_idORDER BY s.first_sm_start, s.subscriber_idThe Node augmenter then merges in bridge_cohort_year and bridge_class from per-year /dashboard-revenue-bridge?productGroup=PMS - Saymore calls (see docs/product-onboard-cohort-analysis.md for orchestrator skeleton).
Suggested deep-dive queries against this CSV
Section titled “Suggested deep-dive queries against this CSV”Open in pandas / Excel / BQ-uploaded table:
- Channel mix at SM signing: pivot
pre_sm_groupsto see which prior products correlate with SM survival. E.g.pre_sm_groups CONTAINS 'DMS - Websites'survival rate vs no-website. - Discount-cohort effect: filter
first_sm_mrr < 30and compare retention vsfirst_sm_mrr >= 50. Tests whether the 2020 €35-median MRR cohort churned worse than full-price cohorts. - AM accountability: group by
am, rank bycurrently_alive_sm / count(*)to see which AMs retain SM clients best (with sample-size guardrail). - Category × locality survival: pivot
main_category×locality×currently_alive_sm. Find categories where SayMore actually sticks. - Cross-sell paths: filter
post_sm_added_groups != ''and see what FCR products win SM clients later (DMS - SEA? DMS - SEO?). Compare retention of post-SM-upsold clients vs SM-only. - Lost-with-SayMore: filter
currently_alive_sm = FALSEand look atpost_sm_lost_groups— do clients usually churn SM alone or as part of a full pull-out? - Bridge vs naive cohort delta: filter
bridge_cohort_year IS NULL(the 350 unmatched) — these are renewal-cycle replacements not “new” signings. Useful to exclude from new-business analysis.
Gotchas
Section titled “Gotchas”- SnapSync ≠ SayMore loss.
pre_sm_groups CONTAINS 'PMS - Snapsync'orpost_sm_lost_groups CONTAINS 'PMS - Snapsync'typically reflects the legacy SnapSync → SayMore migration, not a real cross-sell or churn. Treat as same-product label change. The earlier memoryfeedback_saymore_snapsync.mdcovers this. pre_sm_mrr = 0doesn’t mean “new client”. It means no recurring billing was active exactly at SM start. Could be a delayed-start client who paid Setup/DEA pre-SM. Cross-reference withbridge_class—renewedwithpre_sm_mrr = 0= signed pre-CY but recurring delayed.first_sm_mrris the proportional split of that BG’s RI, not the full RI total. A €50 SM line bundled with a €30 DMS - Dir line on the same BG will show €50 infirst_sm_mrr, even though the BG bills €80/month.bridge_cohort_yearis sparse for older subscribers — 350 of 1,648 have no bridge classification. They’re not missing data, they’re real subscribers the bridge sees in opening (carryover from a year we didn’t run the bridge for, or replacement-BG cases). Their naïvecohort_yearis in the CSV — use that for those rows or exclude depending on the question.main_categoryis GP-listing-based, not CRM-based. Subscribers without an active GP listing won’t have a category —biznameandmain_categorywill be blank for those.ammay beSuccess, Customer Success— that’s the CS bucket (high-touch retention team), not orphan accounts. Don’t treat as untouched. See memoryfeedback_cs_bucket_high_care.md.