Seth commited on
Commit
067e8d2
·
1 Parent(s): 6a96b23
backend/app/database.py CHANGED
@@ -190,6 +190,7 @@ class CrmDeal(Base):
190
  won_note_to_customer = Column(Text, default="")
191
  won_note_to_accounts = Column(Text, default="")
192
  won_line_items = Column(JSON, nullable=True)
 
193
  created_at = Column(DateTime, default=datetime.utcnow)
194
  updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
195
 
@@ -304,6 +305,13 @@ def run_migrations(connection_engine):
304
  conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_note_to_accounts TEXT DEFAULT ''"))
305
  if "won_line_items" not in dcols:
306
  conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_line_items TEXT"))
 
 
 
 
 
 
 
307
 
308
 
309
  # Create tables then migrate legacy SQLite schemas
 
190
  won_note_to_customer = Column(Text, default="")
191
  won_note_to_accounts = Column(Text, default="")
192
  won_line_items = Column(JSON, nullable=True)
193
+ won_at = Column(DateTime, nullable=True) # first transition to won (for quarterly attribution)
194
  created_at = Column(DateTime, default=datetime.utcnow)
195
  updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
196
 
 
305
  conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_note_to_accounts TEXT DEFAULT ''"))
306
  if "won_line_items" not in dcols:
307
  conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_line_items TEXT"))
308
+ if "won_at" not in dcols:
309
+ conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_at DATETIME"))
310
+ conn.execute(
311
+ text(
312
+ "UPDATE crm_deals SET won_at = updated_at WHERE stage = 'won' AND won_at IS NULL"
313
+ )
314
+ )
315
 
316
 
317
  # Create tables then migrate legacy SQLite schemas
backend/app/deal_revenue.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ARR/MRR in CAD from won deal line items — same methodology as the manual spreadsheet:
3
+ per-line amount × interval factor (×12 / ×4 / ×1), USD→CAD, sum → ARR; MRR = ARR/12.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta
11
+ from typing import Any, Optional
12
+
13
+ INTERVAL_ANNUAL_MULT = {
14
+ "monthly": 12,
15
+ "quarterly": 4,
16
+ "annual": 1,
17
+ }
18
+
19
+
20
+ def usd_to_cad_rate() -> float:
21
+ raw = os.environ.get("USD_TO_CAD_RATE", "1.38").strip()
22
+ try:
23
+ x = float(raw)
24
+ return x if x > 0 else 1.38
25
+ except (TypeError, ValueError):
26
+ return 1.38
27
+
28
+
29
+ def _norm_interval(raw: Any) -> str:
30
+ if not raw:
31
+ return "monthly"
32
+ s = str(raw).strip().lower()
33
+ if s in ("quarterly", "quarter", "every 3 months"):
34
+ return "quarterly"
35
+ if s in ("annual", "year", "yearly", "every year"):
36
+ return "annual"
37
+ return "monthly"
38
+
39
+
40
+ def _norm_currency(raw: Any) -> str:
41
+ if raw and str(raw).strip().upper() == "CAD":
42
+ return "CAD"
43
+ return "USD"
44
+
45
+
46
+ def line_amount_native(line: dict[str, Any]) -> float:
47
+ if line.get("amount") is not None:
48
+ try:
49
+ return round(float(line["amount"]), 2)
50
+ except (TypeError, ValueError):
51
+ pass
52
+ try:
53
+ q = float(line.get("qty") or 0)
54
+ r = float(line.get("rate") or 0)
55
+ return round(q * r, 2)
56
+ except (TypeError, ValueError):
57
+ return 0.0
58
+
59
+
60
+ def line_arr_cad(line: dict[str, Any], rate_usd_cad: float) -> float:
61
+ """Annual recurring revenue in CAD for one line."""
62
+ amt = line_amount_native(line)
63
+ interval = _norm_interval(line.get("billing_interval"))
64
+ mult = INTERVAL_ANNUAL_MULT.get(interval, 12)
65
+ annual_native = amt * mult
66
+ cur = _norm_currency(line.get("currency"))
67
+ if cur == "CAD":
68
+ return annual_native
69
+ return annual_native * rate_usd_cad
70
+
71
+
72
+ def deal_subtotal_cad_onetime(lines: list[dict[str, Any]], rate_usd_cad: float) -> float:
73
+ total = 0.0
74
+ for line in lines:
75
+ amt = line_amount_native(line)
76
+ cur = _norm_currency(line.get("currency"))
77
+ if cur == "CAD":
78
+ total += amt
79
+ else:
80
+ total += amt * rate_usd_cad
81
+ return round(total, 2)
82
+
83
+
84
+ def deal_recurring_arr_cad(revenue_type: str, lines: list[dict[str, Any]], rate_usd_cad: float) -> float:
85
+ rt = (revenue_type or "arr").strip().lower()
86
+ if rt == "one_time":
87
+ return 0.0
88
+ return round(sum(line_arr_cad(li, rate_usd_cad) for li in lines), 2)
89
+
90
+
91
+ def deal_recurring_mrr_cad(revenue_type: str, lines: list[dict[str, Any]], rate_usd_cad: float) -> float:
92
+ arr = deal_recurring_arr_cad(revenue_type, lines, rate_usd_cad)
93
+ return round(arr / 12.0, 2)
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class QuarterSpec:
98
+ year: int
99
+ quarter: int # 1–4
100
+
101
+ @property
102
+ def label(self) -> str:
103
+ return f"Q{self.quarter} {self.year}"
104
+
105
+ def start_dt(self) -> datetime:
106
+ m = (self.quarter - 1) * 3 + 1
107
+ return datetime(self.year, m, 1)
108
+
109
+ def end_dt(self) -> datetime:
110
+ sm = (self.quarter - 1) * 3 + 1
111
+ em = sm + 2
112
+ if em == 12:
113
+ return datetime(self.year, 12, 31, 23, 59, 59)
114
+ next_month = datetime(self.year, em + 1, 1)
115
+ return next_month - timedelta(microseconds=1)
116
+
117
+
118
+ def quarter_for_datetime(dt: datetime) -> QuarterSpec:
119
+ q = (dt.month - 1) // 3 + 1
120
+ return QuarterSpec(dt.year, q)
121
+
122
+
123
+ def iter_quarters(a: QuarterSpec, b: QuarterSpec) -> list[QuarterSpec]:
124
+ """Inclusive range from a to b (assumes a <= b)."""
125
+ out: list[QuarterSpec] = []
126
+ y, q = a.year, a.quarter
127
+ while (y, q) <= (b.year, b.quarter):
128
+ out.append(QuarterSpec(y, q))
129
+ q += 1
130
+ if q > 4:
131
+ q = 1
132
+ y += 1
133
+ return out
134
+
135
+
136
+ def _parse_won_at(val: Any) -> Optional[datetime]:
137
+ if val is None:
138
+ return None
139
+ if isinstance(val, datetime):
140
+ if val.tzinfo is not None:
141
+ return val.replace(tzinfo=None)
142
+ return val
143
+ if isinstance(val, str):
144
+ try:
145
+ s = val.strip()
146
+ if s.endswith("Z"):
147
+ s = s[:-1] + "+00:00"
148
+ dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
149
+ if dt.tzinfo is not None:
150
+ return dt.replace(tzinfo=None)
151
+ return dt
152
+ except ValueError:
153
+ return None
154
+ return None
155
+
156
+
157
+ def build_quarterly_board(
158
+ deals: list[dict[str, Any]],
159
+ rate_usd_cad: Optional[float] = None,
160
+ max_quarters: int = 10,
161
+ ) -> dict[str, Any]:
162
+ """
163
+ deals: each dict needs keys revenue_type, won_line_items (list), won_at (optional ISO/datetime).
164
+ """
165
+ r = rate_usd_cad if rate_usd_cad is not None else usd_to_cad_rate()
166
+
167
+ if not deals:
168
+ return {
169
+ "usd_to_cad_rate": r,
170
+ "currency_display": "CAD",
171
+ "quarters": [],
172
+ }
173
+
174
+ normalized: list[dict[str, Any]] = []
175
+ for d in deals:
176
+ lines = d.get("won_line_items") or []
177
+ if not isinstance(lines, list):
178
+ lines = []
179
+ rt = (d.get("revenue_type") or "arr").strip().lower()
180
+ if rt not in ("arr", "qrr", "mrr", "one_time"):
181
+ rt = "arr"
182
+ won_at = _parse_won_at(d.get("won_at"))
183
+ normalized.append(
184
+ {
185
+ "id": d.get("id"),
186
+ "name": d.get("name") or "",
187
+ "revenue_type": rt,
188
+ "won_line_items": lines,
189
+ "won_at": won_at,
190
+ }
191
+ )
192
+
193
+ now = datetime.utcnow()
194
+ current_q = quarter_for_datetime(now)
195
+
196
+ starts = [quarter_for_datetime(x["won_at"]) for x in normalized if x["won_at"]]
197
+ if starts:
198
+ first_q = min(starts, key=lambda q: (q.year, q.quarter))
199
+ else:
200
+ first_q = current_q
201
+
202
+ quarters = iter_quarters(first_q, current_q)
203
+ if len(quarters) > max_quarters:
204
+ quarters = quarters[-max_quarters:]
205
+
206
+ rows: list[dict[str, Any]] = []
207
+ prev_total_mrr = 0.0
208
+ prev_total_contracts = 0
209
+
210
+ for qs in quarters:
211
+ start = qs.start_dt()
212
+ end = qs.end_dt()
213
+
214
+ def in_quarter(won_at: Optional[datetime]) -> bool:
215
+ if not won_at:
216
+ return False
217
+ return start <= won_at <= end
218
+
219
+ new_mrr = 0.0
220
+ new_contracts = 0
221
+ one_time_cad = 0.0
222
+ one_time_deals: list[dict[str, Any]] = []
223
+
224
+ total_mrr_end = 0.0
225
+ total_contracts_end = 0
226
+
227
+ for deal in normalized:
228
+ lines = deal["won_line_items"]
229
+ rt = deal["revenue_type"]
230
+ won_at = deal["won_at"]
231
+
232
+ if rt == "one_time":
233
+ if won_at and won_at <= end:
234
+ ot = deal_subtotal_cad_onetime(lines, r)
235
+ if in_quarter(won_at):
236
+ one_time_cad += ot
237
+ one_time_deals.append(
238
+ {
239
+ "deal_id": deal["id"],
240
+ "name": deal["name"],
241
+ "amount_cad": ot,
242
+ "won_at": won_at.isoformat() if won_at else None,
243
+ }
244
+ )
245
+ continue
246
+
247
+ mrr = deal_recurring_mrr_cad(rt, lines, r)
248
+ if won_at is None or won_at > end:
249
+ continue
250
+ total_mrr_end += mrr
251
+ total_contracts_end += 1
252
+
253
+ if in_quarter(won_at):
254
+ new_mrr += mrr
255
+ new_contracts += 1
256
+
257
+ existing_mrr = prev_total_mrr
258
+ existing_contracts = prev_total_contracts
259
+
260
+ portfolio_check = round(existing_mrr + new_mrr, 2)
261
+ rows.append(
262
+ {
263
+ "label": qs.label,
264
+ "year": qs.year,
265
+ "quarter": qs.quarter,
266
+ "start": start.isoformat(),
267
+ "end": end.isoformat(),
268
+ "mrr": {
269
+ "existing": round(existing_mrr, 2),
270
+ "new": round(new_mrr, 2),
271
+ "total": round(total_mrr_end, 2),
272
+ },
273
+ "arr_cad": {
274
+ "existing": round(existing_mrr * 12, 2),
275
+ "new": round(new_mrr * 12, 2),
276
+ "total": round(total_mrr_end * 12, 2),
277
+ },
278
+ "contracts": {
279
+ "existing": existing_contracts,
280
+ "new": new_contracts,
281
+ "total": total_contracts_end,
282
+ },
283
+ "one_time_cad_quarter": round(one_time_cad, 2),
284
+ "one_time_deals": one_time_deals,
285
+ "portfolio_matches_rollforward": abs(portfolio_check - round(total_mrr_end, 2)) < 0.02,
286
+ }
287
+ )
288
+
289
+ prev_total_mrr = total_mrr_end
290
+ prev_total_contracts = total_contracts_end
291
+
292
+ return {
293
+ "usd_to_cad_rate": r,
294
+ "currency_display": "CAD",
295
+ "quarters": rows,
296
+ }
backend/app/main.py CHANGED
@@ -56,6 +56,7 @@ from .smartlead_client import SmartleadClient
56
  from .auth_routes import router as auth_router
57
  from .tenant_deps import TenantContext, get_tenant_context
58
  from .tenant_routes import router as tenant_router
 
59
 
60
  app = FastAPI()
61
 
@@ -592,6 +593,7 @@ def _deal_to_dict(d: CrmDeal) -> dict:
592
  "won_note_to_customer": _safe_str(getattr(d, "won_note_to_customer", None) or ""),
593
  "won_note_to_accounts": _safe_str(getattr(d, "won_note_to_accounts", None) or ""),
594
  "won_line_items": _won_line_items_list(d),
 
595
  }
596
 
597
 
@@ -619,6 +621,7 @@ def _clear_won_billing_fields(row: CrmDeal) -> None:
619
  row.won_note_to_customer = ""
620
  row.won_note_to_accounts = ""
621
  row.won_line_items = None
 
622
 
623
 
624
  async def _notify_tenant_admins_deal_won(
@@ -2755,6 +2758,44 @@ async def list_deals(
2755
  return {"total": total, "deals": deals_out}
2756
 
2757
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2758
  @app.post("/api/deals")
2759
  async def create_deal(body: CrmDealCreateRequest, t: TenantContext = Depends(get_tenant_context)):
2760
  stage = (body.stage or "new").strip().lower() or "new"
@@ -2973,6 +3014,8 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, t: TenantContext =
2973
  "qty": float(li.qty),
2974
  "rate": float(li.rate),
2975
  "amount": amt,
 
 
2976
  }
2977
  )
2978
  row.won_po_number = _safe_str(wb.po_number).strip()
@@ -2987,6 +3030,8 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, t: TenantContext =
2987
  row.lost_price_discount_pct = None
2988
  row.lost_chose_product_name = ""
2989
  row.stage = "won"
 
 
2990
  did_mark_won_this_request = True
2991
  else:
2992
  if prev_stage == "lost":
 
56
  from .auth_routes import router as auth_router
57
  from .tenant_deps import TenantContext, get_tenant_context
58
  from .tenant_routes import router as tenant_router
59
+ from .deal_revenue import build_quarterly_board, usd_to_cad_rate
60
 
61
  app = FastAPI()
62
 
 
593
  "won_note_to_customer": _safe_str(getattr(d, "won_note_to_customer", None) or ""),
594
  "won_note_to_accounts": _safe_str(getattr(d, "won_note_to_accounts", None) or ""),
595
  "won_line_items": _won_line_items_list(d),
596
+ "won_at": d.won_at.isoformat() if getattr(d, "won_at", None) else None,
597
  }
598
 
599
 
 
621
  row.won_note_to_customer = ""
622
  row.won_note_to_accounts = ""
623
  row.won_line_items = None
624
+ row.won_at = None
625
 
626
 
627
  async def _notify_tenant_admins_deal_won(
 
2758
  return {"total": total, "deals": deals_out}
2759
 
2760
 
2761
+ @app.get("/api/deals/quarterly-recurring-metrics")
2762
+ async def quarterly_recurring_metrics(
2763
+ owner_user_id: Optional[int] = Query(None),
2764
+ max_quarters: int = Query(12, ge=1, le=24),
2765
+ t: TenantContext = Depends(get_tenant_context),
2766
+ ):
2767
+ """
2768
+ Won deals → recurring ARR/MRR (CAD) by calendar quarter: Existing / New / Total roll-forward,
2769
+ plus one-time CAD recognized in quarter (revenue_type one_time). Uses USD_TO_CAD_RATE env (default 1.38).
2770
+ """
2771
+ db = t.db
2772
+ q = db.query(CrmDeal).filter(
2773
+ CrmDeal.tenant_id == t.tenant_id,
2774
+ CrmDeal.stage == "won",
2775
+ )
2776
+ if t.role != "admin":
2777
+ q = q.filter(or_(CrmDeal.owner_user_id == t.user_id, CrmDeal.owner_user_id.is_(None)))
2778
+ elif owner_user_id is not None:
2779
+ q = q.filter(CrmDeal.owner_user_id == int(owner_user_id))
2780
+ rows = q.all()
2781
+ deals_payload = []
2782
+ for r in rows:
2783
+ deals_payload.append(
2784
+ {
2785
+ "id": r.id,
2786
+ "name": r.name or "",
2787
+ "revenue_type": (getattr(r, "revenue_type", None) or "arr"),
2788
+ "won_line_items": _won_line_items_list(r),
2789
+ "won_at": getattr(r, "won_at", None),
2790
+ }
2791
+ )
2792
+ return build_quarterly_board(
2793
+ deals_payload,
2794
+ rate_usd_cad=usd_to_cad_rate(),
2795
+ max_quarters=max_quarters,
2796
+ )
2797
+
2798
+
2799
  @app.post("/api/deals")
2800
  async def create_deal(body: CrmDealCreateRequest, t: TenantContext = Depends(get_tenant_context)):
2801
  stage = (body.stage or "new").strip().lower() or "new"
 
3014
  "qty": float(li.qty),
3015
  "rate": float(li.rate),
3016
  "amount": amt,
3017
+ "billing_interval": li.billing_interval,
3018
+ "currency": li.currency,
3019
  }
3020
  )
3021
  row.won_po_number = _safe_str(wb.po_number).strip()
 
3030
  row.lost_price_discount_pct = None
3031
  row.lost_chose_product_name = ""
3032
  row.stage = "won"
3033
+ if prev_stage != "won":
3034
+ row.won_at = datetime.utcnow()
3035
  did_mark_won_this_request = True
3036
  else:
3037
  if prev_stage == "lost":
backend/app/models.py CHANGED
@@ -1,5 +1,5 @@
1
  from pydantic import BaseModel, Field, field_validator
2
- from typing import Optional, List, Dict
3
  from datetime import datetime
4
 
5
 
@@ -102,6 +102,9 @@ 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
  @field_validator("product_service", mode="before")
107
  @classmethod
@@ -117,6 +120,28 @@ class WonLineItemIn(BaseModel):
117
  return ""
118
  return v.strip() if isinstance(v, str) else v
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
  class WonBillingPayload(BaseModel):
122
  """Billing / PO details required when setting a deal to Won (invoicing)."""
 
1
  from pydantic import BaseModel, Field, field_validator
2
+ from typing import Optional, List, Dict, Literal
3
  from datetime import datetime
4
 
5
 
 
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
+ # Recurring revenue / spreadsheet columns: amount per interval × annualization (matches manual ARR sheet).
106
+ billing_interval: Literal["monthly", "quarterly", "annual"] = "monthly"
107
+ currency: Literal["CAD", "USD"] = "USD"
108
 
109
  @field_validator("product_service", mode="before")
110
  @classmethod
 
120
  return ""
121
  return v.strip() if isinstance(v, str) else v
122
 
123
+ @field_validator("billing_interval", mode="before")
124
+ @classmethod
125
+ def norm_interval(cls, v):
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"):
132
+ return "quarterly"
133
+ if s in ("year", "annual", "yearly", "every year", "a"):
134
+ return "annual"
135
+ return "monthly"
136
+
137
+ @field_validator("currency", mode="before")
138
+ @classmethod
139
+ def norm_currency(cls, v):
140
+ if v is None:
141
+ return "USD"
142
+ s = v.strip().upper() if isinstance(v, str) else "USD"
143
+ return s if s in ("CAD", "USD") else "USD"
144
+
145
 
146
  class WonBillingPayload(BaseModel):
147
  """Billing / PO details required when setting a deal to Won (invoicing)."""
frontend/src/components/workspace/WonBillingModal.jsx CHANGED
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronUp, GripVertical, Loader2, Trash2 } from 'lucide-re
3
  import { Button } from '@/components/ui/button';
4
  import { Input } from '@/components/ui/input';
5
  import { Textarea } from '@/components/ui/textarea';
 
6
 
7
  function newLineRow() {
8
  return {
@@ -11,6 +12,8 @@ function newLineRow() {
11
  description: '',
12
  qty: '1',
13
  rate: '0',
 
 
14
  };
15
  }
16
 
@@ -137,6 +140,8 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
137
  description: row.description.trim(),
138
  qty: q,
139
  rate: r,
 
 
140
  });
141
  }
142
  onSubmit({
@@ -274,13 +279,15 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
274
  </div>
275
  </div>
276
  <div className="overflow-x-auto rounded-lg border border-slate-200">
277
- <table className="w-full min-w-[640px] border-collapse text-sm">
278
  <thead>
279
  <tr className="border-b border-slate-200 bg-slate-50 text-left text-xs font-semibold text-slate-600">
280
  <th className="w-8 px-1 py-2" aria-hidden />
281
  <th className="w-8 px-1 py-2 text-center">#</th>
282
  <th className="min-w-[140px] px-2 py-2">Product / service</th>
283
  <th className="min-w-[120px] px-2 py-2">Description</th>
 
 
284
  <th className="w-20 px-2 py-2 text-right">Qty</th>
285
  <th className="w-24 px-2 py-2 text-right">Rate</th>
286
  <th className="w-28 px-2 py-2 text-right">Amount</th>
@@ -342,6 +349,45 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
342
  placeholder="Description"
343
  />
344
  </td>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  <td className="px-2 py-1">
346
  <Input
347
  value={row.qty}
 
3
  import { Button } from '@/components/ui/button';
4
  import { Input } from '@/components/ui/input';
5
  import { Textarea } from '@/components/ui/textarea';
6
+ import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
7
 
8
  function newLineRow() {
9
  return {
 
12
  description: '',
13
  qty: '1',
14
  rate: '0',
15
+ billing_interval: 'monthly',
16
+ currency: 'USD',
17
  };
18
  }
19
 
 
140
  description: row.description.trim(),
141
  qty: q,
142
  rate: r,
143
+ billing_interval: row.billing_interval || 'monthly',
144
+ currency: row.currency || 'USD',
145
  });
146
  }
147
  onSubmit({
 
279
  </div>
280
  </div>
281
  <div className="overflow-x-auto rounded-lg border border-slate-200">
282
+ <table className="w-full min-w-[960px] border-collapse text-sm">
283
  <thead>
284
  <tr className="border-b border-slate-200 bg-slate-50 text-left text-xs font-semibold text-slate-600">
285
  <th className="w-8 px-1 py-2" aria-hidden />
286
  <th className="w-8 px-1 py-2 text-center">#</th>
287
  <th className="min-w-[140px] px-2 py-2">Product / service</th>
288
  <th className="min-w-[120px] px-2 py-2">Description</th>
289
+ <th className="min-w-[130px] px-2 py-2">Billing interval</th>
290
+ <th className="w-24 px-2 py-2">Currency</th>
291
  <th className="w-20 px-2 py-2 text-right">Qty</th>
292
  <th className="w-24 px-2 py-2 text-right">Rate</th>
293
  <th className="w-28 px-2 py-2 text-right">Amount</th>
 
349
  placeholder="Description"
350
  />
351
  </td>
352
+ <td className="px-2 py-1">
353
+ <Select
354
+ value={row.billing_interval || 'monthly'}
355
+ onValueChange={(v) =>
356
+ updateLine(row.id, { billing_interval: v })
357
+ }
358
+ >
359
+ <SelectTrigger className="h-8 bg-white text-xs">
360
+ <span className="truncate">
361
+ {row.billing_interval === 'quarterly'
362
+ ? 'Every 3 mo'
363
+ : row.billing_interval === 'annual'
364
+ ? 'Every year'
365
+ : 'Every month'}
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>
375
+ <td className="px-2 py-1">
376
+ <Select
377
+ value={row.currency || 'USD'}
378
+ onValueChange={(v) =>
379
+ updateLine(row.id, { currency: v })
380
+ }
381
+ >
382
+ <SelectTrigger className="h-8 bg-white text-xs">
383
+ {row.currency || 'USD'}
384
+ </SelectTrigger>
385
+ <SelectContent>
386
+ <SelectItem value="USD">USD</SelectItem>
387
+ <SelectItem value="CAD">CAD</SelectItem>
388
+ </SelectContent>
389
+ </Select>
390
+ </td>
391
  <td className="px-2 py-1">
392
  <Input
393
  value={row.qty}
frontend/src/pages/SalesDashboard.jsx CHANGED
@@ -106,6 +106,17 @@ function fmtMoneyCompact(n) {
106
  }).format(n);
107
  }
108
 
 
 
 
 
 
 
 
 
 
 
 
109
  function monthKeyFromDealDate(iso) {
110
  if (!iso || typeof iso !== 'string') return null;
111
  const m = iso.slice(0, 7);
@@ -342,6 +353,8 @@ export default function SalesDashboard() {
342
  const [tenantMembers, setTenantMembers] = useState([]);
343
  const [ownerFilter, setOwnerFilter] = useState('all');
344
  const [dashboardPeriod, setDashboardPeriod] = useState(DASHBOARD_PERIOD_ALL);
 
 
345
 
346
  const isAdmin = me?.current_role === 'admin';
347
 
@@ -387,6 +400,30 @@ export default function SalesDashboard() {
387
  fetchDeals();
388
  }, [fetchDeals]);
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  useEffect(() => {
391
  if (!isAdmin) setOwnerFilter('all');
392
  }, [isAdmin]);
@@ -571,6 +608,12 @@ export default function SalesDashboard() {
571
  ? 'This year'
572
  : 'All time';
573
 
 
 
 
 
 
 
574
  const dateRangeSub =
575
  dashboardPeriod === DASHBOARD_PERIOD_ALL
576
  ? 'All deals in view'
@@ -676,6 +719,193 @@ export default function SalesDashboard() {
676
  />
677
  </div>
678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  <CardShell
680
  title="Pipeline conversion"
681
  hint="Uses the global date range (deal created date) and team scope from the top of the page. Bars: blue through Negotiation, green for Won. Shaded bands connect stages. Step % = next ÷ previous. Conversion to Won = Won ÷ New."
 
106
  }).format(n);
107
  }
108
 
109
+ /** Dashboard quarterly board (matches spreadsheet ARR methodology; amounts in CAD). */
110
+ function fmtCad(n) {
111
+ if (n == null || n === '' || Number.isNaN(n)) return '—';
112
+ return new Intl.NumberFormat('en-CA', {
113
+ style: 'currency',
114
+ currency: 'CAD',
115
+ minimumFractionDigits: 0,
116
+ maximumFractionDigits: 0,
117
+ }).format(n);
118
+ }
119
+
120
  function monthKeyFromDealDate(iso) {
121
  if (!iso || typeof iso !== 'string') return null;
122
  const m = iso.slice(0, 7);
 
353
  const [tenantMembers, setTenantMembers] = useState([]);
354
  const [ownerFilter, setOwnerFilter] = useState('all');
355
  const [dashboardPeriod, setDashboardPeriod] = useState(DASHBOARD_PERIOD_ALL);
356
+ const [quarterlyBoard, setQuarterlyBoard] = useState(null);
357
+ const [quarterlyLoading, setQuarterlyLoading] = useState(true);
358
 
359
  const isAdmin = me?.current_role === 'admin';
360
 
 
400
  fetchDeals();
401
  }, [fetchDeals]);
402
 
403
+ useEffect(() => {
404
+ let cancelled = false;
405
+ const params = new URLSearchParams();
406
+ params.set('max_quarters', '12');
407
+ if (isAdmin && ownerFilter !== 'all') {
408
+ params.set('owner_user_id', ownerFilter);
409
+ }
410
+ setQuarterlyLoading(true);
411
+ apiFetch(`/api/deals/quarterly-recurring-metrics?${params.toString()}`)
412
+ .then((r) => (r.ok ? r.json() : null))
413
+ .then((data) => {
414
+ if (!cancelled) setQuarterlyBoard(data);
415
+ })
416
+ .catch(() => {
417
+ if (!cancelled) setQuarterlyBoard(null);
418
+ })
419
+ .finally(() => {
420
+ if (!cancelled) setQuarterlyLoading(false);
421
+ });
422
+ return () => {
423
+ cancelled = true;
424
+ };
425
+ }, [isAdmin, ownerFilter, me?.user_id]);
426
+
427
  useEffect(() => {
428
  if (!isAdmin) setOwnerFilter('all');
429
  }, [isAdmin]);
 
608
  ? 'This year'
609
  : 'All time';
610
 
611
+ const quarterColumns = useMemo(() => {
612
+ const qs = quarterlyBoard?.quarters || [];
613
+ if (qs.length <= 6) return qs;
614
+ return qs.slice(-6);
615
+ }, [quarterlyBoard]);
616
+
617
  const dateRangeSub =
618
  dashboardPeriod === DASHBOARD_PERIOD_ALL
619
  ? 'All deals in view'
 
719
  />
720
  </div>
721
 
722
+ <div className="rounded-xl border border-indigo-100 bg-indigo-50/50 px-4 py-3 text-sm text-slate-700">
723
+ <p className="font-semibold text-slate-900">Quarterly recurring revenue (board)</p>
724
+ <p className="mt-1 text-xs leading-relaxed text-slate-600">
725
+ Uses <strong className="font-medium text-slate-800">won</strong> deals only. Each PO line
726
+ contributes MRR in CAD from amount × interval (monthly ×12, quarterly ×4, yearly ×1) and
727
+ USD→CAD using the server rate below.{' '}
728
+ <strong className="font-medium text-slate-800">Existing</strong> MRR is the recurring total
729
+ carried from the prior quarter; <strong className="font-medium text-slate-800">New</strong>{' '}
730
+ is won in that quarter. Deals with type{' '}
731
+ <strong className="font-medium text-slate-800">One-time</strong> are listed separately.
732
+ Attribution uses the <strong className="font-medium text-slate-800">won date</strong>{' '}
733
+ (set when the deal is marked won).
734
+ </p>
735
+ </div>
736
+
737
+ {quarterlyLoading ? (
738
+ <div className="flex items-center justify-center gap-2 py-10 text-slate-600">
739
+ <Loader2 className="h-5 w-5 animate-spin shrink-0" aria-hidden />
740
+ Loading quarterly metrics…
741
+ </div>
742
+ ) : quarterColumns.length > 0 ? (
743
+ <div className="space-y-4">
744
+ <p className="text-xs text-slate-500">
745
+ USD→CAD rate (env):{' '}
746
+ <span className="font-mono tabular-nums">
747
+ {quarterlyBoard?.usd_to_cad_rate ?? '—'}
748
+ </span>
749
+ </p>
750
+ <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
751
+ <CardShell
752
+ title="Recurring MRR (CAD)"
753
+ hint="Roll-forward like your deck: Total = portfolio at quarter end; Existing = prior quarter total; New = sum of MRR from deals won in the quarter."
754
+ className="min-h-0"
755
+ >
756
+ <div className="overflow-x-auto -m-1 p-1">
757
+ <table className="w-full min-w-[340px] text-sm">
758
+ <thead>
759
+ <tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
760
+ <th className="py-2 pr-3"> </th>
761
+ {quarterColumns.map((q) => (
762
+ <th key={q.label} className="px-2 py-2 text-right whitespace-nowrap">
763
+ {q.label}
764
+ </th>
765
+ ))}
766
+ </tr>
767
+ </thead>
768
+ <tbody className="tabular-nums">
769
+ <tr className="border-b border-slate-100">
770
+ <td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
771
+ {quarterColumns.map((q) => (
772
+ <td key={q.label} className="px-2 py-2 text-right font-semibold">
773
+ {fmtCad(q.mrr?.total)}
774
+ </td>
775
+ ))}
776
+ </tr>
777
+ <tr className="border-b border-slate-100">
778
+ <td className="py-2 pr-3 text-slate-600">EXISTING</td>
779
+ {quarterColumns.map((q) => (
780
+ <td key={q.label} className="px-2 py-2 text-right text-slate-700">
781
+ {fmtCad(q.mrr?.existing)}
782
+ </td>
783
+ ))}
784
+ </tr>
785
+ <tr>
786
+ <td className="py-2 pr-3 text-slate-600">NEW</td>
787
+ {quarterColumns.map((q) => (
788
+ <td key={q.label} className="px-2 py-2 text-right text-slate-700">
789
+ {fmtCad(q.mrr?.new)}
790
+ </td>
791
+ ))}
792
+ </tr>
793
+ </tbody>
794
+ </table>
795
+ </div>
796
+ <p className="mt-3 text-xs text-slate-500">
797
+ ARR (CAD) = MRR × 12 (same rows available as{' '}
798
+ <span className="font-medium text-slate-700">arr_cad</span> in the API).
799
+ </p>
800
+ </CardShell>
801
+ <CardShell
802
+ title="Recurring contracts"
803
+ hint="Count of recurring (non–one-time) won deals in the portfolio at quarter end; New = deals whose won date falls in the quarter."
804
+ className="min-h-0"
805
+ >
806
+ <div className="overflow-x-auto -m-1 p-1">
807
+ <table className="w-full min-w-[340px] text-sm">
808
+ <thead>
809
+ <tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
810
+ <th className="py-2 pr-3"> </th>
811
+ {quarterColumns.map((q) => (
812
+ <th key={q.label} className="px-2 py-2 text-right whitespace-nowrap">
813
+ {q.label}
814
+ </th>
815
+ ))}
816
+ </tr>
817
+ </thead>
818
+ <tbody className="tabular-nums">
819
+ <tr className="border-b border-slate-100">
820
+ <td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
821
+ {quarterColumns.map((q) => (
822
+ <td key={q.label} className="px-2 py-2 text-right font-semibold">
823
+ {q.contracts?.total ?? '—'}
824
+ </td>
825
+ ))}
826
+ </tr>
827
+ <tr className="border-b border-slate-100">
828
+ <td className="py-2 pr-3 text-slate-600">EXISTING</td>
829
+ {quarterColumns.map((q) => (
830
+ <td key={q.label} className="px-2 py-2 text-right text-slate-700">
831
+ {q.contracts?.existing ?? '—'}
832
+ </td>
833
+ ))}
834
+ </tr>
835
+ <tr>
836
+ <td className="py-2 pr-3 text-slate-600">NEW</td>
837
+ {quarterColumns.map((q) => (
838
+ <td key={q.label} className="px-2 py-2 text-right text-slate-700">
839
+ {q.contracts?.new ?? '—'}
840
+ </td>
841
+ ))}
842
+ </tr>
843
+ </tbody>
844
+ </table>
845
+ </div>
846
+ </CardShell>
847
+ </div>
848
+ <CardShell
849
+ title="One-time revenue (CAD)"
850
+ hint="Deals with revenue type One-time: invoice subtotal converted to CAD, recognized in the quarter the deal was marked won."
851
+ className="min-h-0"
852
+ >
853
+ <div className="overflow-x-auto -m-1 p-1">
854
+ <table className="w-full min-w-[280px] text-sm">
855
+ <thead>
856
+ <tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
857
+ <th className="py-2 pr-3">Quarter</th>
858
+ <th className="py-2 text-right">One-time (CAD)</th>
859
+ </tr>
860
+ </thead>
861
+ <tbody className="tabular-nums">
862
+ {quarterColumns.map((q) => (
863
+ <tr key={q.label} className="border-b border-slate-100">
864
+ <td className="py-2 pr-3 text-slate-800">{q.label}</td>
865
+ <td className="py-2 text-right">
866
+ {fmtCad(q.one_time_cad_quarter)}
867
+ </td>
868
+ </tr>
869
+ ))}
870
+ </tbody>
871
+ </table>
872
+ </div>
873
+ {(() => {
874
+ const last = quarterColumns[quarterColumns.length - 1];
875
+ const deals = last?.one_time_deals || [];
876
+ if (!deals.length) {
877
+ return (
878
+ <p className="mt-3 text-xs text-slate-500">
879
+ No one-time deals in the latest shown quarter.
880
+ </p>
881
+ );
882
+ }
883
+ return (
884
+ <div className="mt-4 rounded-lg border border-slate-100 bg-slate-50/80 px-3 py-2 text-xs text-slate-700">
885
+ <p className="font-semibold text-slate-800">
886
+ Latest quarter detail ({last?.label})
887
+ </p>
888
+ <ul className="mt-2 list-disc space-y-1 pl-5">
889
+ {deals.map((d) => (
890
+ <li key={d.deal_id}>
891
+ <span className="font-medium">{d.name || 'Deal'}</span>
892
+ {' · '}
893
+ {fmtCad(d.amount_cad)}
894
+ </li>
895
+ ))}
896
+ </ul>
897
+ </div>
898
+ );
899
+ })()}
900
+ </CardShell>
901
+ </div>
902
+ ) : (
903
+ <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">
904
+ No closed-won deals yet — quarterly MRR / contracts will appear after you mark deals won
905
+ (with billing lines and billing interval per line).
906
+ </p>
907
+ )}
908
+
909
  <CardShell
910
  title="Pipeline conversion"
911
  hint="Uses the global date range (deal created date) and team scope from the top of the page. Bars: blue through Negotiation, green for Won. Shaded bands connect stages. Step % = next ÷ previous. Conversion to Won = Won ÷ New."