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 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.0xmeans 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 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).

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.