Seth commited on
Commit ·
1a77d3d
1
Parent(s): 1c32e69
update
Browse files- backend/app/deal_revenue.py +105 -59
- backend/app/main.py +44 -3
- backend/app/models.py +4 -2
- frontend/src/components/workspace/WonBillingModal.jsx +8 -5
- frontend/src/pages/SalesDashboard.jsx +24 -41
backend/app/deal_revenue.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
"""
|
| 2 |
-
Quarterly
|
| 3 |
|
| 4 |
-
|
| 5 |
-
-
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
- one_time
|
| 9 |
|
| 10 |
-
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
@@ -35,28 +35,83 @@ def deal_line_subtotal(lines: list[dict[str, Any]]) -> float:
|
|
| 35 |
return round(sum(line_amount_native(li) for li in lines), 2)
|
| 36 |
|
| 37 |
|
| 38 |
-
def
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
if rt == "one_time":
|
| 43 |
return 0.0
|
|
|
|
| 44 |
if rt == "mrr":
|
| 45 |
return round(sub * 12.0, 2)
|
| 46 |
if rt == "qrr":
|
| 47 |
return round(sub * 4.0, 2)
|
| 48 |
if rt == "arr":
|
| 49 |
return round(sub, 2)
|
| 50 |
-
return round(sub, 2)
|
| 51 |
|
| 52 |
|
| 53 |
-
def
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
-
def
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
@dataclass(frozen=True)
|
|
@@ -123,9 +178,6 @@ def build_quarterly_board(
|
|
| 123 |
deals: list[dict[str, Any]],
|
| 124 |
max_quarters: int = 10,
|
| 125 |
) -> dict[str, Any]:
|
| 126 |
-
"""
|
| 127 |
-
deals: each dict needs revenue_type, won_line_items (list), won_at (optional).
|
| 128 |
-
"""
|
| 129 |
if not deals:
|
| 130 |
return {
|
| 131 |
"currency_display": "USD",
|
|
@@ -165,7 +217,7 @@ def build_quarterly_board(
|
|
| 165 |
quarters = quarters[-max_quarters:]
|
| 166 |
|
| 167 |
rows: list[dict[str, Any]] = []
|
| 168 |
-
|
| 169 |
prev_total_contracts = 0
|
| 170 |
|
| 171 |
for qs in quarters:
|
|
@@ -177,12 +229,12 @@ def build_quarterly_board(
|
|
| 177 |
return False
|
| 178 |
return start <= won_at <= end
|
| 179 |
|
| 180 |
-
|
| 181 |
new_contracts = 0
|
| 182 |
-
|
| 183 |
one_time_deals: list[dict[str, Any]] = []
|
| 184 |
|
| 185 |
-
|
| 186 |
total_contracts_end = 0
|
| 187 |
|
| 188 |
for deal in normalized:
|
|
@@ -190,35 +242,34 @@ def build_quarterly_board(
|
|
| 190 |
rt = deal["revenue_type"]
|
| 191 |
won_at = deal["won_at"]
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
ot = deal_onetime_usd(lines)
|
| 196 |
-
if in_quarter(won_at):
|
| 197 |
-
one_time_usd += ot
|
| 198 |
-
one_time_deals.append(
|
| 199 |
-
{
|
| 200 |
-
"deal_id": deal["id"],
|
| 201 |
-
"name": deal["name"],
|
| 202 |
-
"amount_usd": ot,
|
| 203 |
-
"won_at": won_at.isoformat() if won_at else None,
|
| 204 |
-
}
|
| 205 |
-
)
|
| 206 |
-
continue
|
| 207 |
|
| 208 |
-
mrr = deal_recurring_mrr_usd(rt, lines)
|
| 209 |
if won_at is None or won_at > end:
|
| 210 |
continue
|
| 211 |
-
total_mrr_end += mrr
|
| 212 |
-
total_contracts_end += 1
|
| 213 |
-
|
| 214 |
-
if in_quarter(won_at):
|
| 215 |
-
new_mrr += mrr
|
| 216 |
-
new_contracts += 1
|
| 217 |
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
existing_contracts = prev_total_contracts
|
| 220 |
|
| 221 |
-
portfolio_check = round(
|
| 222 |
rows.append(
|
| 223 |
{
|
| 224 |
"label": qs.label,
|
|
@@ -226,28 +277,23 @@ def build_quarterly_board(
|
|
| 226 |
"quarter": qs.quarter,
|
| 227 |
"start": start.isoformat(),
|
| 228 |
"end": end.isoformat(),
|
| 229 |
-
"
|
| 230 |
-
"existing": round(
|
| 231 |
-
"new": round(
|
| 232 |
-
"total": round(
|
| 233 |
-
},
|
| 234 |
-
"arr_usd": {
|
| 235 |
-
"existing": round(existing_mrr * 12, 2),
|
| 236 |
-
"new": round(new_mrr * 12, 2),
|
| 237 |
-
"total": round(total_mrr_end * 12, 2),
|
| 238 |
},
|
| 239 |
"contracts": {
|
| 240 |
"existing": existing_contracts,
|
| 241 |
"new": new_contracts,
|
| 242 |
"total": total_contracts_end,
|
| 243 |
},
|
| 244 |
-
"one_time_usd_quarter": round(
|
| 245 |
"one_time_deals": one_time_deals,
|
| 246 |
-
"portfolio_matches_rollforward": abs(portfolio_check - round(
|
| 247 |
}
|
| 248 |
)
|
| 249 |
|
| 250 |
-
|
| 251 |
prev_total_contracts = total_contracts_end
|
| 252 |
|
| 253 |
return {
|
|
|
|
| 1 |
"""
|
| 2 |
+
Quarterly ARR (USD) roll-forward from won deals, using each PO line's billing interval.
|
| 3 |
|
| 4 |
+
Per line:
|
| 5 |
+
- monthly → ARR contribution = amount × 12
|
| 6 |
+
- quarterly → ARR contribution = amount × 4
|
| 7 |
+
- annual → ARR contribution = amount × 1
|
| 8 |
+
- one_time → excluded from ARR; summed into one-time revenue for the win quarter
|
| 9 |
|
| 10 |
+
Legacy won snapshots without line billing_interval fall back to deal revenue_type (previous behavior).
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 35 |
return round(sum(line_amount_native(li) for li in lines), 2)
|
| 36 |
|
| 37 |
|
| 38 |
+
def _lines_have_explicit_interval(lines: list[dict[str, Any]]) -> bool:
|
| 39 |
+
return any(isinstance(li, dict) and "billing_interval" in li for li in lines)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _norm_interval(raw: Any) -> str:
|
| 43 |
+
if raw is None:
|
| 44 |
+
return "monthly"
|
| 45 |
+
s = str(raw).strip().lower()
|
| 46 |
+
if s in ("one_time", "onetime", "one-time", "non-recurring"):
|
| 47 |
+
return "one_time"
|
| 48 |
+
if s in ("month", "monthly", "every month", "m"):
|
| 49 |
+
return "monthly"
|
| 50 |
+
if s in ("quarter", "quarterly", "every 3 months", "q"):
|
| 51 |
+
return "quarterly"
|
| 52 |
+
if s in ("year", "annual", "yearly", "every year", "a"):
|
| 53 |
+
return "annual"
|
| 54 |
+
return "monthly"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _legacy_deal_recurring_arr(lines: list[dict[str, Any]], deal_revenue_type: str) -> float:
|
| 58 |
+
"""Old rows with no per-line billing_interval; use deal-level revenue type."""
|
| 59 |
+
rt = (deal_revenue_type or "arr").strip().lower()
|
| 60 |
if rt == "one_time":
|
| 61 |
return 0.0
|
| 62 |
+
sub = deal_line_subtotal(lines)
|
| 63 |
if rt == "mrr":
|
| 64 |
return round(sub * 12.0, 2)
|
| 65 |
if rt == "qrr":
|
| 66 |
return round(sub * 4.0, 2)
|
| 67 |
if rt == "arr":
|
| 68 |
return round(sub, 2)
|
| 69 |
+
return round(sub * 12.0, 2)
|
| 70 |
|
| 71 |
|
| 72 |
+
def _legacy_deal_onetime(lines: list[dict[str, Any]], deal_revenue_type: str) -> float:
|
| 73 |
+
rt = (deal_revenue_type or "arr").strip().lower()
|
| 74 |
+
if rt == "one_time":
|
| 75 |
+
return deal_line_subtotal(lines)
|
| 76 |
+
return 0.0
|
| 77 |
|
| 78 |
|
| 79 |
+
def deal_recurring_arr_usd(lines: list[dict[str, Any]], deal_revenue_type: str = "arr") -> float:
|
| 80 |
+
"""Annual recurring revenue (USD) from recurring lines only."""
|
| 81 |
+
if not lines:
|
| 82 |
+
return 0.0
|
| 83 |
+
if not _lines_have_explicit_interval(lines):
|
| 84 |
+
return _legacy_deal_recurring_arr(lines, deal_revenue_type)
|
| 85 |
+
|
| 86 |
+
total = 0.0
|
| 87 |
+
for li in lines:
|
| 88 |
+
inv = _norm_interval(li.get("billing_interval"))
|
| 89 |
+
if inv == "one_time":
|
| 90 |
+
continue
|
| 91 |
+
amt = line_amount_native(li)
|
| 92 |
+
if inv == "monthly":
|
| 93 |
+
total += amt * 12.0
|
| 94 |
+
elif inv == "quarterly":
|
| 95 |
+
total += amt * 4.0
|
| 96 |
+
elif inv == "annual":
|
| 97 |
+
total += amt
|
| 98 |
+
else:
|
| 99 |
+
total += amt * 12.0
|
| 100 |
+
return round(total, 2)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def deal_onetime_usd(lines: list[dict[str, Any]], deal_revenue_type: str = "arr") -> float:
|
| 104 |
+
"""Non-recurring USD from one-time lines (and legacy all–one-time deals)."""
|
| 105 |
+
if not lines:
|
| 106 |
+
return 0.0
|
| 107 |
+
if not _lines_have_explicit_interval(lines):
|
| 108 |
+
return round(_legacy_deal_onetime(lines, deal_revenue_type), 2)
|
| 109 |
+
|
| 110 |
+
total = 0.0
|
| 111 |
+
for li in lines:
|
| 112 |
+
if _norm_interval(li.get("billing_interval")) == "one_time":
|
| 113 |
+
total += line_amount_native(li)
|
| 114 |
+
return round(total, 2)
|
| 115 |
|
| 116 |
|
| 117 |
@dataclass(frozen=True)
|
|
|
|
| 178 |
deals: list[dict[str, Any]],
|
| 179 |
max_quarters: int = 10,
|
| 180 |
) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
| 181 |
if not deals:
|
| 182 |
return {
|
| 183 |
"currency_display": "USD",
|
|
|
|
| 217 |
quarters = quarters[-max_quarters:]
|
| 218 |
|
| 219 |
rows: list[dict[str, Any]] = []
|
| 220 |
+
prev_total_arr = 0.0
|
| 221 |
prev_total_contracts = 0
|
| 222 |
|
| 223 |
for qs in quarters:
|
|
|
|
| 229 |
return False
|
| 230 |
return start <= won_at <= end
|
| 231 |
|
| 232 |
+
new_arr = 0.0
|
| 233 |
new_contracts = 0
|
| 234 |
+
one_time_sum = 0.0
|
| 235 |
one_time_deals: list[dict[str, Any]] = []
|
| 236 |
|
| 237 |
+
total_arr_end = 0.0
|
| 238 |
total_contracts_end = 0
|
| 239 |
|
| 240 |
for deal in normalized:
|
|
|
|
| 242 |
rt = deal["revenue_type"]
|
| 243 |
won_at = deal["won_at"]
|
| 244 |
|
| 245 |
+
arr_r = deal_recurring_arr_usd(lines, rt)
|
| 246 |
+
ot_amt = deal_onetime_usd(lines, rt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
|
|
|
| 248 |
if won_at is None or won_at > end:
|
| 249 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
+
if arr_r > 0:
|
| 252 |
+
total_arr_end += arr_r
|
| 253 |
+
total_contracts_end += 1
|
| 254 |
+
if in_quarter(won_at):
|
| 255 |
+
new_arr += arr_r
|
| 256 |
+
new_contracts += 1
|
| 257 |
+
|
| 258 |
+
if ot_amt > 0 and in_quarter(won_at):
|
| 259 |
+
one_time_sum += ot_amt
|
| 260 |
+
one_time_deals.append(
|
| 261 |
+
{
|
| 262 |
+
"deal_id": deal["id"],
|
| 263 |
+
"name": deal["name"],
|
| 264 |
+
"amount_usd": ot_amt,
|
| 265 |
+
"won_at": won_at.isoformat() if won_at else None,
|
| 266 |
+
}
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
existing_arr = prev_total_arr
|
| 270 |
existing_contracts = prev_total_contracts
|
| 271 |
|
| 272 |
+
portfolio_check = round(existing_arr + new_arr, 2)
|
| 273 |
rows.append(
|
| 274 |
{
|
| 275 |
"label": qs.label,
|
|
|
|
| 277 |
"quarter": qs.quarter,
|
| 278 |
"start": start.isoformat(),
|
| 279 |
"end": end.isoformat(),
|
| 280 |
+
"arr": {
|
| 281 |
+
"existing": round(existing_arr, 2),
|
| 282 |
+
"new": round(new_arr, 2),
|
| 283 |
+
"total": round(total_arr_end, 2),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
},
|
| 285 |
"contracts": {
|
| 286 |
"existing": existing_contracts,
|
| 287 |
"new": new_contracts,
|
| 288 |
"total": total_contracts_end,
|
| 289 |
},
|
| 290 |
+
"one_time_usd_quarter": round(one_time_sum, 2),
|
| 291 |
"one_time_deals": one_time_deals,
|
| 292 |
+
"portfolio_matches_rollforward": abs(portfolio_check - round(total_arr_end, 2)) < 0.02,
|
| 293 |
}
|
| 294 |
)
|
| 295 |
|
| 296 |
+
prev_total_arr = total_arr_end
|
| 297 |
prev_total_contracts = total_contracts_end
|
| 298 |
|
| 299 |
return {
|
backend/app/main.py
CHANGED
|
@@ -18,6 +18,7 @@ import asyncio
|
|
| 18 |
import math
|
| 19 |
import re
|
| 20 |
from datetime import datetime, timedelta
|
|
|
|
| 21 |
|
| 22 |
from .database import (
|
| 23 |
get_db,
|
|
@@ -550,6 +551,41 @@ def _deal_access_or_403(row: CrmDeal, t: TenantContext) -> None:
|
|
| 550 |
raise HTTPException(status_code=403, detail="You do not have access to this deal")
|
| 551 |
|
| 552 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
def _deal_to_dict(d: CrmDeal) -> dict:
|
| 554 |
fv = None
|
| 555 |
if d.deal_value is not None and d.close_probability is not None:
|
|
@@ -2795,11 +2831,12 @@ async def list_deals(
|
|
| 2795 |
async def quarterly_recurring_metrics(
|
| 2796 |
owner_user_id: Optional[int] = Query(None),
|
| 2797 |
max_quarters: int = Query(12, ge=1, le=24),
|
|
|
|
| 2798 |
t: TenantContext = Depends(get_tenant_context),
|
| 2799 |
):
|
| 2800 |
"""
|
| 2801 |
-
Won deals →
|
| 2802 |
-
|
| 2803 |
"""
|
| 2804 |
db = t.db
|
| 2805 |
q = db.query(CrmDeal).filter(
|
|
@@ -2811,15 +2848,19 @@ async def quarterly_recurring_metrics(
|
|
| 2811 |
elif owner_user_id is not None:
|
| 2812 |
q = q.filter(CrmDeal.owner_user_id == int(owner_user_id))
|
| 2813 |
rows = q.all()
|
|
|
|
| 2814 |
deals_payload = []
|
| 2815 |
for r in rows:
|
|
|
|
|
|
|
|
|
|
| 2816 |
deals_payload.append(
|
| 2817 |
{
|
| 2818 |
"id": r.id,
|
| 2819 |
"name": r.name or "",
|
| 2820 |
"revenue_type": (getattr(r, "revenue_type", None) or "arr"),
|
| 2821 |
"won_line_items": _won_line_items_list(r),
|
| 2822 |
-
"won_at":
|
| 2823 |
}
|
| 2824 |
)
|
| 2825 |
return build_quarterly_board(deals_payload, max_quarters=max_quarters)
|
|
|
|
| 18 |
import math
|
| 19 |
import re
|
| 20 |
from datetime import datetime, timedelta
|
| 21 |
+
from calendar import monthrange
|
| 22 |
|
| 23 |
from .database import (
|
| 24 |
get_db,
|
|
|
|
| 551 |
raise HTTPException(status_code=403, detail="You do not have access to this deal")
|
| 552 |
|
| 553 |
|
| 554 |
+
def _dashboard_period_bounds_utc(period: str) -> Optional[tuple[datetime, datetime]]:
|
| 555 |
+
"""Bounds for filtering won deals by won_at (dashboard date presets). None = all time."""
|
| 556 |
+
p = (period or "all").strip().lower()
|
| 557 |
+
if p in ("", "all"):
|
| 558 |
+
return None
|
| 559 |
+
now = datetime.utcnow()
|
| 560 |
+
if p == "month":
|
| 561 |
+
start = datetime(now.year, now.month, 1)
|
| 562 |
+
last = monthrange(now.year, now.month)[1]
|
| 563 |
+
end = datetime(now.year, now.month, last, 23, 59, 59)
|
| 564 |
+
return start, end
|
| 565 |
+
if p == "quarter":
|
| 566 |
+
qi = (now.month - 1) // 3
|
| 567 |
+
sm = qi * 3 + 1
|
| 568 |
+
start = datetime(now.year, sm, 1)
|
| 569 |
+
em = sm + 2
|
| 570 |
+
if em == 12:
|
| 571 |
+
end = datetime(now.year, 12, 31, 23, 59, 59)
|
| 572 |
+
else:
|
| 573 |
+
end = datetime(now.year, em + 1, 1) - timedelta(microseconds=1)
|
| 574 |
+
return start, end
|
| 575 |
+
if p == "year":
|
| 576 |
+
return datetime(now.year, 1, 1), datetime(now.year, 12, 31, 23, 59, 59)
|
| 577 |
+
return None
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def _won_at_within_bounds(won_at: Optional[datetime], bounds: Optional[tuple[datetime, datetime]]) -> bool:
|
| 581 |
+
if bounds is None:
|
| 582 |
+
return True
|
| 583 |
+
if won_at is None:
|
| 584 |
+
return False
|
| 585 |
+
wdt = won_at.replace(tzinfo=None) if getattr(won_at, "tzinfo", None) else won_at
|
| 586 |
+
return bounds[0] <= wdt <= bounds[1]
|
| 587 |
+
|
| 588 |
+
|
| 589 |
def _deal_to_dict(d: CrmDeal) -> dict:
|
| 590 |
fv = None
|
| 591 |
if d.deal_value is not None and d.close_probability is not None:
|
|
|
|
| 2831 |
async def quarterly_recurring_metrics(
|
| 2832 |
owner_user_id: Optional[int] = Query(None),
|
| 2833 |
max_quarters: int = Query(12, ge=1, le=24),
|
| 2834 |
+
period: str = Query("all"),
|
| 2835 |
t: TenantContext = Depends(get_tenant_context),
|
| 2836 |
):
|
| 2837 |
"""
|
| 2838 |
+
Won deals → ARR (USD) roll-forward by calendar quarter from PO lines (interval per line + one-time lines).
|
| 2839 |
+
Optional period=all|month|quarter|year filters by won_at (same presets as the sales dashboard).
|
| 2840 |
"""
|
| 2841 |
db = t.db
|
| 2842 |
q = db.query(CrmDeal).filter(
|
|
|
|
| 2848 |
elif owner_user_id is not None:
|
| 2849 |
q = q.filter(CrmDeal.owner_user_id == int(owner_user_id))
|
| 2850 |
rows = q.all()
|
| 2851 |
+
bounds = _dashboard_period_bounds_utc(period)
|
| 2852 |
deals_payload = []
|
| 2853 |
for r in rows:
|
| 2854 |
+
wa = getattr(r, "won_at", None)
|
| 2855 |
+
if not _won_at_within_bounds(wa, bounds):
|
| 2856 |
+
continue
|
| 2857 |
deals_payload.append(
|
| 2858 |
{
|
| 2859 |
"id": r.id,
|
| 2860 |
"name": r.name or "",
|
| 2861 |
"revenue_type": (getattr(r, "revenue_type", None) or "arr"),
|
| 2862 |
"won_line_items": _won_line_items_list(r),
|
| 2863 |
+
"won_at": wa,
|
| 2864 |
}
|
| 2865 |
)
|
| 2866 |
return build_quarterly_board(deals_payload, max_quarters=max_quarters)
|
backend/app/models.py
CHANGED
|
@@ -102,8 +102,8 @@ class WonLineItemIn(BaseModel):
|
|
| 102 |
description: str = Field(default="", max_length=4000)
|
| 103 |
qty: float = Field(..., gt=0, le=1_000_000)
|
| 104 |
rate: float = Field(..., ge=0, le=1_000_000_000)
|
| 105 |
-
#
|
| 106 |
-
billing_interval: Literal["monthly", "quarterly", "annual"] = "monthly"
|
| 107 |
currency: Literal["CAD", "USD"] = "USD"
|
| 108 |
|
| 109 |
@field_validator("product_service", mode="before")
|
|
@@ -126,6 +126,8 @@ class WonLineItemIn(BaseModel):
|
|
| 126 |
if v is None:
|
| 127 |
return "monthly"
|
| 128 |
s = v.strip().lower() if isinstance(v, str) else "monthly"
|
|
|
|
|
|
|
| 129 |
if s in ("month", "monthly", "every month", "m"):
|
| 130 |
return "monthly"
|
| 131 |
if s in ("quarter", "quarterly", "every 3 months", "q"):
|
|
|
|
| 102 |
description: str = Field(default="", max_length=4000)
|
| 103 |
qty: float = Field(..., gt=0, le=1_000_000)
|
| 104 |
rate: float = Field(..., ge=0, le=1_000_000_000)
|
| 105 |
+
# Per-line cadence for ARR vs one-time (dashboard uses these; legacy deals may omit).
|
| 106 |
+
billing_interval: Literal["monthly", "quarterly", "annual", "one_time"] = "monthly"
|
| 107 |
currency: Literal["CAD", "USD"] = "USD"
|
| 108 |
|
| 109 |
@field_validator("product_service", mode="before")
|
|
|
|
| 126 |
if v is None:
|
| 127 |
return "monthly"
|
| 128 |
s = v.strip().lower() if isinstance(v, str) else "monthly"
|
| 129 |
+
if s in ("one_time", "onetime", "one-time", "non-recurring"):
|
| 130 |
+
return "one_time"
|
| 131 |
if s in ("month", "monthly", "every month", "m"):
|
| 132 |
return "monthly"
|
| 133 |
if s in ("quarter", "quarterly", "every 3 months", "q"):
|
frontend/src/components/workspace/WonBillingModal.jsx
CHANGED
|
@@ -358,17 +358,20 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
|
|
| 358 |
>
|
| 359 |
<SelectTrigger className="h-8 bg-white text-xs">
|
| 360 |
<span className="truncate">
|
| 361 |
-
{row.billing_interval === '
|
| 362 |
-
? '
|
| 363 |
-
: row.billing_interval === '
|
| 364 |
-
? 'Every
|
| 365 |
-
:
|
|
|
|
|
|
|
| 366 |
</span>
|
| 367 |
</SelectTrigger>
|
| 368 |
<SelectContent>
|
| 369 |
<SelectItem value="monthly">Every month</SelectItem>
|
| 370 |
<SelectItem value="quarterly">Every 3 months</SelectItem>
|
| 371 |
<SelectItem value="annual">Every year</SelectItem>
|
|
|
|
| 372 |
</SelectContent>
|
| 373 |
</Select>
|
| 374 |
</td>
|
|
|
|
| 358 |
>
|
| 359 |
<SelectTrigger className="h-8 bg-white text-xs">
|
| 360 |
<span className="truncate">
|
| 361 |
+
{row.billing_interval === 'one_time'
|
| 362 |
+
? 'One-time'
|
| 363 |
+
: row.billing_interval === 'quarterly'
|
| 364 |
+
? 'Every 3 mo'
|
| 365 |
+
: row.billing_interval === 'annual'
|
| 366 |
+
? 'Every year'
|
| 367 |
+
: 'Every month'}
|
| 368 |
</span>
|
| 369 |
</SelectTrigger>
|
| 370 |
<SelectContent>
|
| 371 |
<SelectItem value="monthly">Every month</SelectItem>
|
| 372 |
<SelectItem value="quarterly">Every 3 months</SelectItem>
|
| 373 |
<SelectItem value="annual">Every year</SelectItem>
|
| 374 |
+
<SelectItem value="one_time">One-time</SelectItem>
|
| 375 |
</SelectContent>
|
| 376 |
</Select>
|
| 377 |
</td>
|
frontend/src/pages/SalesDashboard.jsx
CHANGED
|
@@ -48,10 +48,11 @@ function startOfQuarter(d) {
|
|
| 48 |
return new Date(m.getFullYear(), q * 3, 1, 0, 0, 0, 0);
|
| 49 |
}
|
| 50 |
|
| 51 |
-
/**
|
| 52 |
function dealInDashboardPeriod(deal, period) {
|
| 53 |
if (period === DASHBOARD_PERIOD_ALL) return true;
|
| 54 |
-
const iso =
|
|
|
|
| 55 |
if (!iso) return false;
|
| 56 |
const dt = new Date(iso);
|
| 57 |
if (Number.isNaN(dt.getTime())) return false;
|
|
@@ -393,6 +394,7 @@ export default function SalesDashboard() {
|
|
| 393 |
let cancelled = false;
|
| 394 |
const params = new URLSearchParams();
|
| 395 |
params.set('max_quarters', '12');
|
|
|
|
| 396 |
if (isAdmin && ownerFilter !== 'all') {
|
| 397 |
params.set('owner_user_id', ownerFilter);
|
| 398 |
}
|
|
@@ -411,7 +413,7 @@ export default function SalesDashboard() {
|
|
| 411 |
return () => {
|
| 412 |
cancelled = true;
|
| 413 |
};
|
| 414 |
-
}, [isAdmin, ownerFilter, me?.user_id]);
|
| 415 |
|
| 416 |
useEffect(() => {
|
| 417 |
if (!isAdmin) setOwnerFilter('all');
|
|
@@ -606,7 +608,7 @@ export default function SalesDashboard() {
|
|
| 606 |
const dateRangeSub =
|
| 607 |
dashboardPeriod === DASHBOARD_PERIOD_ALL
|
| 608 |
? 'All deals in view'
|
| 609 |
-
: '
|
| 610 |
|
| 611 |
const subtitle = isAdmin
|
| 612 |
? ownerFilter === 'all'
|
|
@@ -625,11 +627,13 @@ export default function SalesDashboard() {
|
|
| 625 |
<div className="space-y-6">
|
| 626 |
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 627 |
<p className="text-sm text-slate-600 max-w-3xl">
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
<strong className="font-medium text-slate-800">
|
| 632 |
-
|
|
|
|
|
|
|
| 633 |
</p>
|
| 634 |
<div
|
| 635 |
className="flex shrink-0 flex-wrap items-center justify-end gap-2"
|
|
@@ -708,23 +712,6 @@ export default function SalesDashboard() {
|
|
| 708 |
/>
|
| 709 |
</div>
|
| 710 |
|
| 711 |
-
<div className="rounded-xl border border-indigo-100 bg-indigo-50/50 px-4 py-3 text-sm text-slate-700">
|
| 712 |
-
<p className="font-semibold text-slate-900">Quarterly recurring revenue ($ USD)</p>
|
| 713 |
-
<p className="mt-1 text-xs leading-relaxed text-slate-600">
|
| 714 |
-
Uses <strong className="font-medium text-slate-800">won</strong> deals and the same PO line
|
| 715 |
-
subtotals as the win popover. Recurring math follows deal{' '}
|
| 716 |
-
<strong className="font-medium text-slate-800">Revenue</strong>:{' '}
|
| 717 |
-
<strong className="font-medium text-slate-800">MRR</strong> = lines are monthly totals;
|
| 718 |
-
<strong className="font-medium text-slate-800"> QRR</strong> = quarterly totals;
|
| 719 |
-
<strong className="font-medium text-slate-800"> ARR</strong> = annual totals;
|
| 720 |
-
<strong className="font-medium text-slate-800"> One-time</strong> = non-recurring (sum of
|
| 721 |
-
lines). Portfolio <strong className="font-medium text-slate-800">MRR</strong> is monthly
|
| 722 |
-
equivalent; <strong className="font-medium text-slate-800">Existing</strong> carries from
|
| 723 |
-
the prior quarter; <strong className="font-medium text-slate-800">New</strong> is closed in
|
| 724 |
-
that quarter. Attribution uses the <strong className="font-medium text-slate-800">won date</strong>.
|
| 725 |
-
</p>
|
| 726 |
-
</div>
|
| 727 |
-
|
| 728 |
{quarterlyLoading ? (
|
| 729 |
<div className="flex items-center justify-center gap-2 py-10 text-slate-600">
|
| 730 |
<Loader2 className="h-5 w-5 animate-spin shrink-0" aria-hidden />
|
|
@@ -734,8 +721,8 @@ export default function SalesDashboard() {
|
|
| 734 |
<div className="space-y-4">
|
| 735 |
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
| 736 |
<CardShell
|
| 737 |
-
title="
|
| 738 |
-
hint="
|
| 739 |
className="min-h-0"
|
| 740 |
>
|
| 741 |
<div className="overflow-x-auto -m-1 p-1">
|
|
@@ -755,7 +742,7 @@ export default function SalesDashboard() {
|
|
| 755 |
<td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
|
| 756 |
{quarterColumns.map((q) => (
|
| 757 |
<td key={q.label} className="px-2 py-2 text-right font-semibold">
|
| 758 |
-
{fmtMoney(q.
|
| 759 |
</td>
|
| 760 |
))}
|
| 761 |
</tr>
|
|
@@ -763,7 +750,7 @@ export default function SalesDashboard() {
|
|
| 763 |
<td className="py-2 pr-3 text-slate-600">EXISTING</td>
|
| 764 |
{quarterColumns.map((q) => (
|
| 765 |
<td key={q.label} className="px-2 py-2 text-right text-slate-700">
|
| 766 |
-
{fmtMoney(q.
|
| 767 |
</td>
|
| 768 |
))}
|
| 769 |
</tr>
|
|
@@ -771,21 +758,17 @@ export default function SalesDashboard() {
|
|
| 771 |
<td className="py-2 pr-3 text-slate-600">NEW</td>
|
| 772 |
{quarterColumns.map((q) => (
|
| 773 |
<td key={q.label} className="px-2 py-2 text-right text-slate-700">
|
| 774 |
-
{fmtMoney(q.
|
| 775 |
</td>
|
| 776 |
))}
|
| 777 |
</tr>
|
| 778 |
</tbody>
|
| 779 |
</table>
|
| 780 |
</div>
|
| 781 |
-
<p className="mt-3 text-xs text-slate-500">
|
| 782 |
-
ARR (USD) = MRR × 12 (API field <span className="font-mono text-slate-600">arr_usd</span>
|
| 783 |
-
).
|
| 784 |
-
</p>
|
| 785 |
</CardShell>
|
| 786 |
<CardShell
|
| 787 |
title="Recurring contracts"
|
| 788 |
-
hint="
|
| 789 |
className="min-h-0"
|
| 790 |
>
|
| 791 |
<div className="overflow-x-auto -m-1 p-1">
|
|
@@ -831,8 +814,8 @@ export default function SalesDashboard() {
|
|
| 831 |
</CardShell>
|
| 832 |
</div>
|
| 833 |
<CardShell
|
| 834 |
-
title="One-time revenue
|
| 835 |
-
hint="
|
| 836 |
className="min-h-0"
|
| 837 |
>
|
| 838 |
<div className="overflow-x-auto -m-1 p-1">
|
|
@@ -886,14 +869,14 @@ export default function SalesDashboard() {
|
|
| 886 |
</div>
|
| 887 |
) : (
|
| 888 |
<p className="rounded-xl border border-dashed border-slate-200 bg-slate-50/50 px-4 py-8 text-center text-sm text-slate-600">
|
| 889 |
-
No closed-won deals
|
| 890 |
-
|
| 891 |
</p>
|
| 892 |
)}
|
| 893 |
|
| 894 |
<CardShell
|
| 895 |
title="Pipeline conversion"
|
| 896 |
-
hint="Uses
|
| 897 |
headerExtra={
|
| 898 |
<div
|
| 899 |
className="flex flex-wrap items-center gap-1 sm:gap-1.5"
|
|
|
|
| 48 |
return new Date(m.getFullYear(), q * 3, 1, 0, 0, 0, 0);
|
| 49 |
}
|
| 50 |
|
| 51 |
+
/** Dashboard date filter: open pipeline deals by created_at; Won deals by won_at when present. */
|
| 52 |
function dealInDashboardPeriod(deal, period) {
|
| 53 |
if (period === DASHBOARD_PERIOD_ALL) return true;
|
| 54 |
+
const iso =
|
| 55 |
+
normalizeStage(deal.stage) === 'won' && deal.won_at ? deal.won_at : deal.created_at;
|
| 56 |
if (!iso) return false;
|
| 57 |
const dt = new Date(iso);
|
| 58 |
if (Number.isNaN(dt.getTime())) return false;
|
|
|
|
| 394 |
let cancelled = false;
|
| 395 |
const params = new URLSearchParams();
|
| 396 |
params.set('max_quarters', '12');
|
| 397 |
+
params.set('period', dashboardPeriod);
|
| 398 |
if (isAdmin && ownerFilter !== 'all') {
|
| 399 |
params.set('owner_user_id', ownerFilter);
|
| 400 |
}
|
|
|
|
| 413 |
return () => {
|
| 414 |
cancelled = true;
|
| 415 |
};
|
| 416 |
+
}, [isAdmin, ownerFilter, me?.user_id, dashboardPeriod]);
|
| 417 |
|
| 418 |
useEffect(() => {
|
| 419 |
if (!isAdmin) setOwnerFilter('all');
|
|
|
|
| 608 |
const dateRangeSub =
|
| 609 |
dashboardPeriod === DASHBOARD_PERIOD_ALL
|
| 610 |
? 'All deals in view'
|
| 611 |
+
: 'Open deals by created date · Won deals by won date';
|
| 612 |
|
| 613 |
const subtitle = isAdmin
|
| 614 |
? ownerFilter === 'all'
|
|
|
|
| 627 |
<div className="space-y-6">
|
| 628 |
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 629 |
<p className="text-sm text-slate-600 max-w-3xl">
|
| 630 |
+
Date range and team filters apply to the charts below. Open pipeline deals use{' '}
|
| 631 |
+
<strong className="font-medium text-slate-800">created date</strong>;{' '}
|
| 632 |
+
<strong className="font-medium text-slate-800">won</strong> deals use{' '}
|
| 633 |
+
<strong className="font-medium text-slate-800">won date</strong> when present (same as the
|
| 634 |
+
ARR boards). Actual revenue sums deal value on won deals in view. Revenue-by-month uses{' '}
|
| 635 |
+
<strong className="font-medium text-slate-800">expected close</strong> when set, otherwise
|
| 636 |
+
created date.
|
| 637 |
</p>
|
| 638 |
<div
|
| 639 |
className="flex shrink-0 flex-wrap items-center justify-end gap-2"
|
|
|
|
| 712 |
/>
|
| 713 |
</div>
|
| 714 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
{quarterlyLoading ? (
|
| 716 |
<div className="flex items-center justify-center gap-2 py-10 text-slate-600">
|
| 717 |
<Loader2 className="h-5 w-5 animate-spin shrink-0" aria-hidden />
|
|
|
|
| 721 |
<div className="space-y-4">
|
| 722 |
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
| 723 |
<CardShell
|
| 724 |
+
title="Annual recurring revenue (ARR)"
|
| 725 |
+
hint="USD ($). From won PO lines: monthly ×12, quarterly ×4, annual ×1; one-time lines excluded. Total / Existing / New roll-forward by quarter. Respects team + date range filters."
|
| 726 |
className="min-h-0"
|
| 727 |
>
|
| 728 |
<div className="overflow-x-auto -m-1 p-1">
|
|
|
|
| 742 |
<td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
|
| 743 |
{quarterColumns.map((q) => (
|
| 744 |
<td key={q.label} className="px-2 py-2 text-right font-semibold">
|
| 745 |
+
{fmtMoney(q.arr?.total)}
|
| 746 |
</td>
|
| 747 |
))}
|
| 748 |
</tr>
|
|
|
|
| 750 |
<td className="py-2 pr-3 text-slate-600">EXISTING</td>
|
| 751 |
{quarterColumns.map((q) => (
|
| 752 |
<td key={q.label} className="px-2 py-2 text-right text-slate-700">
|
| 753 |
+
{fmtMoney(q.arr?.existing)}
|
| 754 |
</td>
|
| 755 |
))}
|
| 756 |
</tr>
|
|
|
|
| 758 |
<td className="py-2 pr-3 text-slate-600">NEW</td>
|
| 759 |
{quarterColumns.map((q) => (
|
| 760 |
<td key={q.label} className="px-2 py-2 text-right text-slate-700">
|
| 761 |
+
{fmtMoney(q.arr?.new)}
|
| 762 |
</td>
|
| 763 |
))}
|
| 764 |
</tr>
|
| 765 |
</tbody>
|
| 766 |
</table>
|
| 767 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
</CardShell>
|
| 769 |
<CardShell
|
| 770 |
title="Recurring contracts"
|
| 771 |
+
hint="Won deals with at least one recurring PO line (not one-time only). Respects team + date filters."
|
| 772 |
className="min-h-0"
|
| 773 |
>
|
| 774 |
<div className="overflow-x-auto -m-1 p-1">
|
|
|
|
| 814 |
</CardShell>
|
| 815 |
</div>
|
| 816 |
<CardShell
|
| 817 |
+
title="One-time revenue"
|
| 818 |
+
hint="Sum of PO lines marked One-time in the win popover; recognized in the quarter the deal was marked won. USD ($). Same filters as above."
|
| 819 |
className="min-h-0"
|
| 820 |
>
|
| 821 |
<div className="overflow-x-auto -m-1 p-1">
|
|
|
|
| 869 |
</div>
|
| 870 |
) : (
|
| 871 |
<p className="rounded-xl border border-dashed border-slate-200 bg-slate-50/50 px-4 py-8 text-center text-sm text-slate-600">
|
| 872 |
+
No matching closed-won deals for this scope — ARR / contracts / one-time update when deals
|
| 873 |
+
are won with PO lines (billing interval per line).
|
| 874 |
</p>
|
| 875 |
)}
|
| 876 |
|
| 877 |
<CardShell
|
| 878 |
title="Pipeline conversion"
|
| 879 |
+
hint="Uses date range and team from the top right (open deals by created date; won deals by won date). Bars: blue through Negotiation, green for Won. Step % = next ÷ previous."
|
| 880 |
headerExtra={
|
| 881 |
<div
|
| 882 |
className="flex flex-wrap items-center gap-1 sm:gap-1.5"
|