Billing Rules Comparison: All Dashboard Views
All four views query the same BQ table: DYNAMICS_DATA.Accounts_and_related_data (nested CRM snapshot).
This document compares how each view handles the same billing concepts.
1. Base Filters (What rows get included)
Order-Level Cancellation
| View | Filter | Notes |
|---|---|---|
| AM Portfolio (list/portfolio CY) | o.stateCodeName != 'Canceled' |
Hard exclude. Cancelled orders never counted. |
| AM Portfolio (PY queries) | o.stateCodeName != 'Canceled' OR ol.dateOfCancellation >= TIMESTAMP('{PY}-01-01') |
Softer - keeps recently cancelled to see PY churn. |
| Revenue Bridge | o.stateCodeName != 'Canceled' OR ol.dateOfCancellation >= TIMESTAMP('{PY}-12-01') |
Softer - includes cancelled if cancellation is recent (needed to track churn month). |
| Solution Portfolio (CY) | o.stateCodeName != 'Canceled' |
Hard exclude for CY. |
| Solution Portfolio (PY) | Omits filter entirely | Most permissive - captures churned accounts for PY comparison. |
| Company View (suspension) | o.stateCodeName != 'Canceled' |
Hard exclude. |
INCONSISTENCY: AM Portfolio PY uses
{PY}-01-01as cancellation cutoff, Revenue Bridge uses{PY}-12-01. These should probably be the same. Solution PY drops the filter entirely, which is the most permissive.
Order Line Cancellation
| View | Filter |
|---|---|
| AM Portfolio (CY) | COALESCE(ol.statusName, '') != 'Canceled' |
| AM Portfolio (PY) | COALESCE(ol.statusName, '') != 'Canceled' OR ol.dateOfCancellation >= TIMESTAMP('{PY}-01-01') |
| Revenue Bridge | COALESCE(ol.statusName, '') != 'Canceled' OR ol.dateOfCancellation >= TIMESTAMP('{PY}-12-01') |
| Solution Portfolio (CY) | COALESCE(ol.statusName, '') != 'Canceled' |
| Solution Portfolio (PY) | Omits filter entirely |
Same pattern: Bridge is softer on line cancellation to track churn timing. Solution PY is most permissive.
Signed Date Minimum
| View | Filter |
|---|---|
| All views | o.signedDate IS NULL OR o.signedDate >= TIMESTAMP('2017-09-04') |
CONSISTENT across all views. Excludes legacy pre-migration orders.
Product Exclusions
| View | Filter |
|---|---|
| All views | COALESCE(ol.product.productGroup.name, '') != 'DIR - PRINT' |
| All views | ol.product IS NOT NULL |
| All views (subs) | COALESCE(ol.product.subscription, FALSE) = TRUE |
CONSISTENT. DIR - PRINT always excluded, null products excluded, subscription flag respected.
2. Recurring Invoice (RI) Filters
Deposit Exclusion
| View | Filter |
|---|---|
| All views | COALESCE(ri.isDeposit, FALSE) = FALSE |
CONSISTENT. Deposit RIs never count toward revenue.
RI Amount and Period
| View | Filter |
|---|---|
| All views (except bundled) | COALESCE(ri.totalLineItemAmount, 0) > 0 AND COALESCE(ri.invoicePeriod, 0) > 0 |
| AM Portfolio (bundled PMS) | COALESCE(ri.totalLineItemAmount, 0) = 0 AND COALESCE(ri.invoicePeriod, 0) > 0 |
CONSISTENT. Zero-amount RIs excluded from revenue (except bundled PMS tracking in AM Portfolio).
RI Date Window (Active/Expired)
This is where the biggest differences are.
| View | CY Active Filter | PY Active Filter |
|---|---|---|
| AM Portfolio (best_ri) | ri.expireOn IS NULL OR ri.expireOn > CURRENT_TIMESTAMP() |
ri.firstInvoiceOn <= TIMESTAMP('{sameDatePY}') AND (ri.expireOn IS NULL OR ri.expireOn > TIMESTAMP('{sameDatePY}')) |
| AM Portfolio (active_lines) | ri.firstInvoiceOn <= CURRENT_TIMESTAMP() AND (ri.expireOn IS NULL OR ri.expireOn > CURRENT_TIMESTAMP()) |
Same pattern with PY date |
| Revenue Bridge | ri.firstInvoiceOn < TIMESTAMP('{CY+1}-01-01') AND (ri.expireOn IS NULL OR ri.expireOn > TIMESTAMP('{PY}-12-01')) |
N/A (wide window, monthly filtering in SQL) |
| Solution Portfolio (CY) | ri.nextInvoiceOn >= NOW() AND (ri.expireOn IS NULL OR ri.expireOn > NOW()) + suspension check |
ri.firstInvoiceOn <= Dec 31 of year AND (ri.expireOn IS NULL OR ri.expireOn > Jan 1 of year) |
| Company View (suspension) | ri.expireOn IS NULL OR ri.expireOn > CURRENT_TIMESTAMP() + suspendTill > CURRENT_TIMESTAMP() |
N/A |
KEY DIFFERENCES:
- AM Portfolio best_ri does NOT check
firstInvoiceOnfor CY (relies on ROW_NUMBER to pick best RI). This means future-dated RIs that have not started billing yet WILL be included.- Revenue Bridge uses the widest window (
firstInvoiceOn < next year start, expireOn > prior Dec) then does monthly filtering in SQL withCROSS JOIN months.- Solution Portfolio CY uses
nextInvoiceOn >= NOW()which is stricter than AM Portfolio - it requires the RI to have a future invoice scheduled.- AM Portfolio active_lines adds
firstInvoiceOn <= NOW()which best_ri does NOT have.
3. Best RI Selection (ROW_NUMBER Dedup)
Each billing group can have multiple RIs. All views pick "the best one":
| View | PARTITION BY | ORDER BY |
|---|---|---|
| AM Portfolio (best_ri) | bg.id |
IF(ri.nextInvoiceOn >= NOW(), 0, 1), ri.createdOn DESC |
| AM Portfolio (py_best_ri) | bg.id |
ri.createdOn DESC |
| Revenue Bridge (bg_ri) | bg.id, ol.salesOrderDetailId |
CY: IF(ri.nextInvoiceOn >= NOW(), 0, 1), ri.createdOn DESC; Historical: ri.createdOn DESC |
| Solution Portfolio | bg.id |
Same pattern as AM Portfolio |
KEY DIFFERENCE: Revenue Bridge partitions by
(bgId, salesOrderDetailId)- meaning it picks one RI per BG per order line. AM Portfolio and Solution partition bybgIdalone - one RI per BG regardless of how many lines share that BG.Impact: If a BG has 3 order lines, Revenue Bridge gets 3 rows (one per line), AM Portfolio gets 1 row (for the whole BG). Revenue Bridge then distributes the RI amount across lines via proportional split, so the math should converge, but the intermediate structures differ.
4. MRR Calculation
| View | Method |
|---|---|
| AM Portfolio (list) | riAmount / riPeriod - simple BG-level, no line allocation |
| AM Portfolio (portfolio) | riAmount * (ol.baseAmount / totalBase) / riPeriod - proportional split per line |
| Revenue Bridge | riAmount * (baseAmount / totalBase) / riPeriod - proportional split per line, with lineCount fallback |
| Solution Portfolio | Same proportional split |
INCONSISTENCY: AM Portfolio LIST mode uses simple
riAmount / riPeriodper BG. This is fine for AM-level totals (one BG = one value), but the PORTFOLIO mode uses the proportional split to show per-account product breakdowns. The two modes may not agree at the margins due to rounding.
5. Suspended vs Active Distinction
| View | Suspended = | Active = |
|---|---|---|
| AM Portfolio | suspendTill IS NOT NULL AND suspendTill > NOW() |
nextInvoiceOn IS NOT NULL AND nextInvoiceOn >= NOW() |
| Revenue Bridge | suspendTill captured but monthly filtering is by firstInvoiceOn/expireOn/dateOfCancellation - suspension NOT explicitly filtered in SQL |
|
| Solution Portfolio | suspendTill > cutoff as separate bridge category |
|
| Company View | suspendTill > NOW() (dedicated suspension query) |
INCONSISTENCY: Revenue Bridge does NOT exclude suspended accounts from "active" MRR per month. If a BG is suspended but its RI window covers the month, it appears as active revenue. The bridge JS may handle this, but the SQL does not.
AM Portfolio separates suspended MRR from active MRR cleanly at the SQL level.
6. Setup/DEA Revenue
Setup Identification
| View | Method |
|---|---|
| All views | ol.product.setup = TRUE OR product.name LIKE '%setup%/%set up%/%set-up%' OR product.number IN ('site st', 'sitepro multi') |
CONSISTENT across all views.
Setup/DEA Year Window
| View | Year Filter |
|---|---|
| AM Portfolio | Complex 3-way OR: (1) fromDate/toDate overlap with CY + has non-deposit RI, (2) no dates + RI firstInvoiceOn in CY, (3) no RI + signedDate in CY |
| Revenue Bridge | ri.firstInvoiceOn in selected year (simpler) |
| Solution Portfolio | Same 3-way OR as AM Portfolio (prorated by month overlap) |
INCONSISTENCY: Revenue Bridge uses a simpler filter (just firstInvoiceOn in year). AM Portfolio and Solution have the full 3-way OR that handles edge cases (lines with service periods but no RI, lines with RI but no dates).
Impact: Revenue Bridge may miss setup/DEA lines that have fromDate/toDate spanning the year but whose RI firstInvoiceOn is in a different year.
Setup/DEA Proration
| View | Method |
|---|---|
| AM Portfolio | Prorated: baseAmount * (months_in_year / total_months) using SAFE_DIVIDE with DATE_DIFF |
| Revenue Bridge | No proration - full baseAmount attributed to the month of firstInvoiceOn |
| Solution Portfolio | Same proration as AM Portfolio |
INCONSISTENCY: A 24-month setup fee of E2,400 spanning 2025-2026 would show as:
- AM Portfolio: E1,200 (12/24 months in CY)
- Revenue Bridge: E2,400 (full amount in the firstInvoiceOn month)
- Solution Portfolio: E1,200 (prorated)
7. Prior Year Comparison
| View | PY MRR Snapshot Date | Method |
|---|---|---|
| AM Portfolio | {sameDatePY} (same month/day last year) |
Point-in-time: RI active on that exact date |
| Revenue Bridge | Dec of prior year | Opening balance = Dec PY monthly values |
| Solution Portfolio | CY: same month/day PY; Historical: full year window | Depends on whether selected year is current |
INCONSISTENCY: AM Portfolio compares to same-day-last-year, Revenue Bridge compares to Dec 31 prior year. For an AM looking at March 23 2026:
- AM Portfolio PY MRR = what was active on March 23 2025
- Revenue Bridge Opening = what was active in Dec 2025
8. BG Replacement Proxy
| View | Uses bg_replacement_proxy? | How? |
|---|---|---|
| AM Portfolio | Yes | PY MRR and PY active lines include proxy rows via UNION ALL |
| Revenue Bridge | Yes | Synthetic Dec PY rows injected in JS for opening balance |
| Solution Portfolio | Yes | PY comparison includes proxy via UNION ALL |
| Company View | No | Only shows current suspended BGs |
CONSISTENT (except Company View which does not need PY comparison).
9. Summary of Inconsistencies to Fix
| # | Issue | Impact | Priority |
|---|---|---|---|
| 1 | Cancellation cutoff dates differ: AM PY uses {PY}-01-01, Bridge uses {PY}-12-01, Solution PY drops filter |
Could count/miss recently cancelled accounts differently | Medium |
| 2 | AM best_ri does not check firstInvoiceOn for CY | Future-dated RIs (not yet billing) included in current MRR | High |
| 3 | Revenue Bridge does not filter suspension in SQL | Suspended accounts appear as "active" revenue in bridge months | High |
| 4 | Setup/DEA proration: Bridge does not prorate, AM Portfolio and Solution do | Setup revenue can be 2x in Bridge vs AM Portfolio for multi-year setup fees | Medium |
| 5 | Setup/DEA year filter: Bridge uses simple firstInvoiceOn, others use 3-way OR | Bridge may miss setup lines with service periods in CY but RI in PY | Low |
| 6 | PY snapshot date: AM uses same-day-PY, Bridge uses Dec PY | YoY comparison means different things in each view | Low (by design) |
| 7 | ROW_NUMBER partition: Bridge uses (bgId, lineId), AM uses (bgId) only |
Should not affect totals but creates different intermediate structures | Low |
10. Shared Rules (Canonical - should be the same everywhere)
These are the "golden rules" that should be identical across all views:
1. Exclude DIR - PRINT product group
2. Exclude null products
3. Exclude orders signed before 2017-09-04
4. Exclude deposit RIs (ri.isDeposit = TRUE)
5. Exclude zero-amount RIs (ri.totalLineItemAmount = 0)
6. Exclude zero-period RIs (ri.invoicePeriod = 0)
7. MRR = riAmount / riPeriod (per BG), allocated by baseAmount proportion across lines
8. Setup = product.setup flag OR name pattern matching
9. DEA = non-subscription, non-setup one-time fees
10. BG Replacement: use bg_replacement_proxy for PY comparison
11. Where Each View Lives
| View | SQL Location | Runtime |
|---|---|---|
| AM Portfolio | worker/src/handlers/am-portfolio.js (lines 27-845) |
Cloudflare Worker |
| Revenue Bridge | worker/src/handlers/revenue-bridge.js |
Cloudflare Worker |
| Solution Portfolio | n8n-workflows/dashboard-solution-portfolio.json (Build Query Code node) |
n8n (Worker falls back) |
| Company View | src/screens/CompanyView.jsx (inline SQL, lines 363-440) |
Frontend (BQ execute) |
FCR Dashboard documentation · generated from docs/ · keep counts verified, not guessed.