Dentolize · CRM Module Walkthrough
On this pageBusiness viewTechnical view

Marketing Hub & ROI

Business view

The Marketing Hub answers the question every clinic owner actually cares about: "Is my ad spend making me money?" It pulls campaigns, ad sets, ads, and daily performance from the connected platforms, and lines them up against the leads and revenue those campaigns actually produced inside Dentolize.

The dashboard is organized as tabs — Dashboard, Campaigns, Creatives, WhatsApp Blasts, ROI, Reconciliation — and can be filtered by platform (All / Meta / TikTok / Snapchat / Google) and date range.

The Marketing Hub dashboard: spend, impressions, clicks, CTR, platform vs CRM leads, converted patients, and cost/return metrics, with a spend-trend chart.
The Marketing Hub dashboard: spend, impressions, clicks, CTR, platform vs CRM leads, converted patients, and cost/return metrics, with a spend-trend chart.

The numbers, in plain language

  • Booked Spend vs Platform Spend — "Booked Spend" is what's been posted to the

clinic's accounting ledger (note the "Posted to GL" badge); "Platform Spend" is the raw figure the ad platform reports. The hub prefers booked spend when it exists, so ROI is measured against real accounting figures, not platform estimates.

  • CPL — Cost per Lead — spend ÷ leads captured in the CRM.
  • CPA / CAC — Cost per Acquisition / Customer Acquisition Cost — spend ÷ leads that

converted to patients.

  • ROAS — Return on Ad Spend — collected revenue ÷ spend. 2.0x means every currency

unit of ad spend brought back two.

  • Contribution / Contribution Margin — revenue after subtracting ad spend, and that as a

percentage.

  • LTV, Net LTV, LTV:CAC — lifetime-value framing, so a clinic can see whether a patient

acquired for X is worth 3× that over time (LTV:CAC ≥ 3 is the healthy benchmark the UI flags green).

Two honesty features stand out:

  • Platform Leads vs CRM Leads. The hub shows both the platform's lead count and

Dentolize's own — and a Reconciliation report highlights the gap, so a clinic can see when leads were dropped or double-counted.

  • Revenue is real, and first-touch. ROI revenue comes from actual invoices for patients

who converted from a lead — credited to the campaign that first produced them, and only counting invoices raised on or after the lead was captured (so a returning patient's old billing history doesn't get miscredited to a new ad).

The Campaigns tab: one synced Meta campaign with spend, impressions, clicks, and CTR.
The Campaigns tab: one synced Meta campaign with spend, impressions, clicks, and CTR.

The accounting bridge

A clinic can optionally post daily ad spend to the general ledger, so marketing cost shows up in real financial statements. The settings let them choose VAT treatment (recoverable / not), a VAT rate, the expense account ad spend posts to (default Advertising & Marketing, 5250), and whether the ad account is postpaid (billed later, accrues as a payable) or prepaid (load credit up front, spend draws it down).

Ad-spend accounting settings: post-to-GL toggle, VAT treatment, VAT rate, target expense account, and postpaid/prepaid funding mode.
Ad-spend accounting settings: post-to-GL toggle, VAT treatment, VAT rate, target expense account, and postpaid/prepaid funding mode.
Note on scope: this GL bridge is where the CRM module touches the larger accounting module that also rides along in this PR. See Scope, Gaps & Honest Notes for how those two deliverables relate.

Technical view

The ad hierarchy and insights

Synced platform objects: AdCampaign (schema.prisma:9234) → AdSet (:9267) → Ad (:9288), each @@unique([companyId, platform, externalId]) for idempotent upsert. AdInsight (:9316) is one row per entity per day (@@unique([companyId, platform, level, entityKey, date])), and tracks glPostedSpend (:9333) so restatements re-post correctly. CampaignAttributionSnapshot (:9344) is the nightly-materialized ROI row per (campaignId, date): crmLeads, wonLeads, and Decimal revenue / revenueNet / collectedRevenue.

Sync is driven by crons (cronJobs.js): socialCampaignSync (15 */6 * * *, :168), hourly and daily insight syncs (:172, :176), with the actual fetch performed by platforms/marketing-sync.worker.ts.

The metric math

Metrics join three sources by external campaign id (resolvers/queries/actions/marketing/marketingUtils.js:1): AdInsight (spend / impressions / clicks / platform leads), LeadCaptureEvent (CRM leads), and CampaignAttributionSnapshot (won leads + revenue). safeRatio(n, d) returns null when d <= 0 (:67) — which is why the UI shows instead of a misleading 0 for CPL/CPA when there are no leads yet.

Per-campaign ROI (campaignRoiReport.js:15) picks effectiveSpend = booked GL spend if posted, else platform spend (:48), then:

  • CPL = safeRatio(effectiveSpend, crmLeads)
  • CPA / CAC = safeRatio(effectiveSpend, wonLeads)
  • ROAS = safeRatio(collectedRevenue, effectiveSpend)
  • contribution = revenueNet − effectiveSpend, margin = contribution / revenueNet
  • LTV / Net LTV / LTV:CAC come from ltvCac.js (:63).

The account-level overview (marketingOverview.js:15) sums the same three sources and computes the same set, surfacing currency: null for mixed-currency accounts (:93) — the UI then shows plain numbers and a mixed-currency warning rather than adding SAR to AED.

Revenue attribution (first-touch, revenue-bounded)

packages/server/src/cronJobs/crm/campaignAttributionCron.js (nightly, 30 2 * * *, cronJobs.js:185) recomputes a 180-day rolling window every run because payments lag (:5). Per campaign × capture-day it counts CRM leads and won leads (the lead's patient converted off type 2), and sums the converted patients' invoice revenue — bounded to invoices created on/after the lead's capture date (:41): revenue = Σtotal, revenueNet = Σ(total − tax), collectedRevenue = Σpaid. Results upsert into CampaignAttributionSnapshot.

The GL bridge

The web/mobile Ad-spend Accounting screen edits MarketingPostingConfig (schema.prisma:9383): the post-to-GL toggle, AdSpendVatMode (:9366), VAT rate, the target advertising account, and AdFundingMode (:9373, postpaid/prepaid). The adSpendPosting cron (45 2 * * *, cronJobs.js:181) books daily spend; prepaid credit and platform invoices are modelled by AdCreditTopUp (:9406) and AdPlatformInvoice (:9435). The posting logic itself lives in the accounting module (packages/server/src/accounting/posting/adSpend.js).

Permissions

marketingOverview, campaigns, insights, blasts, creatives, and leadReconciliationReport require VIEW_MARKETING; campaignRoiReport and the GL-adjacent posting/credit/invoice queries require VIEW_MARKETING_ROI (permissions.js:2816). All require the FEATURE_CRM_MARKETING_HUB flag. sendWhatsAppBlast requires SEND_WA + VIEW_MARKETING. On the front end the ROI figures are additionally hidden unless the user holds VIEW_MARKETING_ROI, so a receptionist can see lead volume without seeing revenue.