| 76 | 76 | | /// says they are the same units. |
| 77 | 77 | | pub cf_quantity: f64, |
| 78 | 78 | | pub own_quantity: f64, |
| 79 | + | /// Of `cost()`, what went on usage g1t gave away (the workspaces' |
| 80 | + | /// `WorkspaceDay::given`, added up). |
| 81 | + | pub given_micros: i64, |
| 79 | 82 | | } |
| 80 | 83 | | |
| 81 | 84 | | impl ProductDay { |
| ⋯ |
| 116 | 119 | | pub value: i64, |
| 117 | 120 | | pub cash: i64, |
| 118 | 121 | | pub cost: i64, |
| 122 | + | /// Of `value`, what g1t gave away: all of it for g1t's own (comped) |
| 123 | + | /// workspaces and in a free period, else what the trial and the pools |
| 124 | + | /// paid. The Team plan's credit was paid for, so it is not given. |
| 125 | + | pub given: i64, |
| 119 | 126 | | } |
| 120 | 127 | | |
| 121 | 128 | | /// One workspace's share of a product's cost on one day. |
| ⋯ |
| 129 | 136 | | pub revenue: i64, |
| 130 | 137 | | /// What its usage was priced at, whoever paid for it. |
| 131 | 138 | | pub value: i64, |
| 139 | + | /// Of `cost`, the part g1t gave away: the cost times the share of the |
| 140 | + | /// workspace's usage that day that g1t paid for (see `UsageRow::given`). |
| 141 | + | pub given: i64, |
| 132 | 142 | | } |
| 133 | 143 | | |
| 134 | 144 | | fn micros(dollars: f64) -> i64 { |
| ⋯ |
| 206 | 216 | | let mut revenue: BTreeMap<(String, String, String), i64> = BTreeMap::new(); |
| 207 | 217 | | let mut valued: BTreeMap<(String, String, String), i64> = BTreeMap::new(); |
| 208 | 218 | | let mut active: BTreeMap<String, Vec<(String, f64)>> = BTreeMap::new(); |
| 219 | + | let mut gave: BTreeMap<(String, String), (i64, i64)> = BTreeMap::new(); |
| 209 | 220 | | for u in usage { |
| 221 | + | let g = gave.entry((u.day.clone(), u.workspace.clone())).or_default(); |
| 222 | + | g.0 += u.given; |
| 223 | + | g.1 += u.value; |
| 210 | 224 | | let bucket = bucket_of(&u.key); |
| 211 | 225 | | let key = (u.day.clone(), bucket.clone()); |
| 212 | 226 | | let row = days.entry(key.clone()).or_insert_with(|| entry(&key.0, &key.1)); |
| ⋯ |
| 238 | 252 | | } |
| 239 | 253 | | } |
| 240 | 254 | | let keys: BTreeSet<(String, String, String)> = shares.keys().chain(revenue.keys()).cloned().collect(); |
| 241 | | − | let workspaces = keys |
| 255 | + | let workspaces: Vec<WorkspaceDay> = keys |
| 242 | 256 | | .into_iter() |
| 243 | | − | .map(|(day, workspace, bucket)| WorkspaceDay { |
| 244 | | − | cost: shares.get(&(day.clone(), workspace.clone(), bucket.clone())).copied().unwrap_or(0), |
| 245 | | − | revenue: revenue.get(&(day.clone(), workspace.clone(), bucket.clone())).copied().unwrap_or(0), |
| 246 | | − | value: valued.get(&(day.clone(), workspace.clone(), bucket.clone())).copied().unwrap_or(0), |
| 247 | | − | day, |
| 248 | | − | workspace, |
| 249 | | − | bucket, |
| 257 | + | .map(|(day, workspace, bucket)| { |
| 258 | + | let cost = shares.get(&(day.clone(), workspace.clone(), bucket.clone())).copied().unwrap_or(0); |
| 259 | + | // The day's share given away applies to every bucket, so a |
| 260 | + | // comped workspace's part of running g1t is given too. |
| 261 | + | let given = match gave.get(&(day.clone(), workspace.clone())) { |
| 262 | + | Some(&(given, value)) if value > 0 => (cost as i128 * given.clamp(0, value) as i128 / value as i128) as i64, |
| 263 | + | _ => 0, |
| 264 | + | }; |
| 265 | + | WorkspaceDay { |
| 266 | + | cost, |
| 267 | + | revenue: revenue.get(&(day.clone(), workspace.clone(), bucket.clone())).copied().unwrap_or(0), |
| 268 | + | value: valued.get(&(day.clone(), workspace.clone(), bucket.clone())).copied().unwrap_or(0), |
| 269 | + | given, |
| 270 | + | day, |
| 271 | + | workspace, |
| 272 | + | bucket, |
| 273 | + | } |
| 250 | 274 | | }) |
| 251 | 275 | | .collect(); |
| 276 | + | for w in &workspaces { |
| 277 | + | if let Some(row) = days.get_mut(&(w.day.clone(), w.bucket.clone())) { |
| 278 | + | row.given_micros += w.given; |
| 279 | + | } |
| 280 | + | } |
| 252 | 281 | | (days.into_values().collect(), workspaces) |
| 253 | 282 | | } |
| 254 | 283 | | |
| ⋯ |
| 269 | 298 | | }; |
| 270 | 299 | | let (cost, charge) = ((cost - before_cost).max(0), (charge - before_charge).max(0)); |
| 271 | 300 | | if cost > 0 || charge > 0 { |
| 272 | | − | out.push(UsageRow { day: day.clone(), workspace: workspace.clone(), key: source.clone(), value: charge, cash: charge, cost }); |
| 301 | + | out.push(UsageRow { day: day.clone(), workspace: workspace.clone(), key: source.clone(), value: charge, cash: charge, cost, given: 0 }); |
| 273 | 302 | | } |
| 274 | 303 | | previous = Some(snap); |
| 275 | 304 | | } |
| ⋯ |
| 566 | 595 | | cash_micros: i64, |
| 567 | 596 | | cf_quantity: f64, |
| 568 | 597 | | own_quantity: f64, |
| 598 | + | #[serde(default)] |
| 599 | + | given_micros: Option<i64>, |
| 569 | 600 | | } |
| 570 | 601 | | |
| 571 | 602 | | impl From<MarginRow> for ProductDay { |
| ⋯ |
| 579 | 610 | | cash_micros: r.cash_micros, |
| 580 | 611 | | cf_quantity: r.cf_quantity, |
| 581 | 612 | | own_quantity: r.own_quantity, |
| 613 | + | given_micros: r.given_micros.unwrap_or(0), |
| 582 | 614 | | } |
| 583 | 615 | | } |
| 584 | 616 | | } |
| ⋯ |
| 655 | 687 | | own_provider: i64, |
| 656 | 688 | | cash: Option<i64>, |
| 657 | 689 | | drawn: Option<i64>, |
| 690 | + | credit: Option<i64>, |
| 658 | 691 | | cost: Option<i64>, |
| 659 | 692 | | } |
| 660 | 693 | | let charged_here = crate::storage::CHARGED_HERE.iter().map(|s| format!("'{s}'")).collect::<Vec<_>>().join(", "); |
| ⋯ |
| 668 | 701 | | CASE WHEN billed_to = 'workspace' THEN 1 ELSE 0 END AS own_provider, |
| 669 | 702 | | -SUM(amount_micros) AS cash, |
| 670 | 703 | | SUM(COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0) + COALESCE(given_micros, 0)) AS drawn, |
| 704 | + | SUM(COALESCE(credit_micros, 0)) AS credit, |
| 671 | 705 | | SUM(COALESCE(cost_micros, 0)) AS cost |
| 672 | 706 | | FROM ledger |
| 673 | 707 | | WHERE kind = 'usage' AND created_at >= ?1 AND created_at <= ?2 AND COALESCE(task, '') NOT IN ({charged_here}) |
| ⋯ |
| 678 | 712 | | .all() |
| 679 | 713 | | .await? |
| 680 | 714 | | .results::<Row>()?; |
| 715 | + | let mut internal = BTreeSet::new(); |
| 681 | 716 | | let mut out: Vec<UsageRow> = rows |
| 682 | 717 | | .into_iter() |
| 683 | 718 | | .map(|r| { |
| ⋯ |
| 687 | 722 | | let cash = r.cash.unwrap_or(0); |
| 688 | 723 | | let paid = cash + r.drawn.unwrap_or(0); |
| 689 | 724 | | let value = usage_value(r.internal == 1, cost, paid, self.margin_percent); |
| 690 | | − | UsageRow { day: r.day, workspace: r.workspace, key: r.key, value, cash, cost } |
| 725 | + | let given = if r.internal == 1 { value } else { (value - cash - r.credit.unwrap_or(0)).max(0) }; |
| 726 | + | if r.internal == 1 { |
| 727 | + | internal.insert(r.workspace.clone()); |
| 728 | + | } |
| 729 | + | UsageRow { day: r.day, workspace: r.workspace, key: r.key, value, cash, cost, given } |
| 691 | 730 | | }) |
| 692 | 731 | | .collect(); |
| 693 | 732 | | // Month-end sources, from their daily snapshots. |
| ⋯ |
| 710 | 749 | | .filter(|s| crate::storage::CHARGED_HERE.contains(&s.source.as_str()) || s.source == "domains") |
| 711 | 750 | | .map(|s| (s.day, s.workspace, s.source, s.cost_micros, s.charge_micros)) |
| 712 | 751 | | .collect::<Vec<_>>(); |
| 713 | | − | out.extend(pending_deltas(&snaps).into_iter().filter(|u| u.day.as_str() >= since)); |
| 752 | + | out.extend(pending_deltas(&snaps).into_iter().filter(|u| u.day.as_str() >= since).map(|mut u| { |
| 753 | + | if internal.contains(&u.workspace) { |
| 754 | + | u.given = u.value; |
| 755 | + | } |
| 756 | + | u |
| 757 | + | })); |
| 714 | 758 | | // The plan's price, spread over the 30 days it pays for, so a month's |
| 715 | 759 | | // payment does not read as one very good day and 29 bad ones. |
| 716 | 760 | | #[derive(Deserialize)] |
| ⋯ |
| 732 | 776 | | for p in plans { |
| 733 | 777 | | for (day, micros) in spread(&p.day, p.micros.unwrap_or(0), PLAN_DAYS) { |
| 734 | 778 | | if day.as_str() >= since && day.as_str() <= until { |
| 735 | | − | out.push(UsageRow { day, workspace: p.workspace.clone(), key: "plan".into(), value: micros, cash: micros, cost: 0 }); |
| 779 | + | out.push(UsageRow { day, workspace: p.workspace.clone(), key: "plan".into(), value: micros, cash: micros, cost: 0, given: 0 }); |
| 736 | 780 | | } |
| 737 | 781 | | } |
| 738 | 782 | | } |
| ⋯ |
| 785 | 829 | | statements.push( |
| 786 | 830 | | self.db |
| 787 | 831 | | .prepare( |
| 788 | | − | "INSERT OR REPLACE INTO margin_days (day, bucket, cf_cost_micros, own_cost_micros, value_micros, cash_micros, cf_quantity, own_quantity, computed_at) |
| 789 | | − | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 832 | + | "INSERT OR REPLACE INTO margin_days (day, bucket, cf_cost_micros, own_cost_micros, value_micros, cash_micros, cf_quantity, own_quantity, given_micros, computed_at) |
| 833 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 790 | 834 | | ) |
| 791 | 835 | | .bind(&[ |
| 792 | 836 | | d.day.as_str().into(), |
| ⋯ |
| 797 | 841 | | (d.cash_micros as f64).into(), |
| 798 | 842 | | d.cf_quantity.into(), |
| 799 | 843 | | d.own_quantity.into(), |
| 844 | + | (d.given_micros as f64).into(), |
| 800 | 845 | | now.as_str().into(), |
| 801 | 846 | | ])?, |
| 802 | 847 | | ); |
| ⋯ |
| 808 | 853 | | for w in chunk { |
| 809 | 854 | | statements.push( |
| 810 | 855 | | self.db |
| 811 | | − | .prepare("INSERT OR REPLACE INTO workspace_costs (day, workspace, bucket, cost_micros, revenue_micros, value_micros) VALUES (?, ?, ?, ?, ?, ?)") |
| 856 | + | .prepare("INSERT OR REPLACE INTO workspace_costs (day, workspace, bucket, cost_micros, revenue_micros, value_micros, given_micros) VALUES (?, ?, ?, ?, ?, ?, ?)") |
| 812 | 857 | | .bind(&[ |
| 813 | 858 | | w.day.as_str().into(), |
| 814 | 859 | | w.workspace.as_str().into(), |
| ⋯ |
| 816 | 861 | | (w.cost as f64).into(), |
| 817 | 862 | | (w.revenue as f64).into(), |
| 818 | 863 | | (w.value as f64).into(), |
| 864 | + | (w.given as f64).into(), |
| 819 | 865 | | ])?, |
| 820 | 866 | | ); |
| 821 | 867 | | } |
| ⋯ |
| 988 | 1034 | | let mut all: BTreeMap<String, (i64, i64)> = BTreeMap::new(); |
| 989 | 1035 | | for d in &days { |
| 990 | 1036 | | let overall = all.entry(d.day.clone()).or_default(); |
| 1037 | + | // What g1t gave away (comped workspaces, free periods, the |
| 1038 | + | // trial and the pools) is a budget it chose to spend, watched on |
| 1039 | + | // its own (budget.rs): not part of whether what is sold pays. |
| 991 | 1040 | | overall.0 += d.cash_micros; |
| 992 | | − | overall.1 += d.cost(); |
| 1041 | + | overall.1 += (d.cost() - d.given_micros).max(0); |
| 993 | 1042 | | if !OVERHEAD.contains(&d.bucket.as_str()) && d.bucket != UNMAPPED { |
| 994 | 1043 | | by.entry(d.bucket.clone()).or_default().push((d.day.clone(), d.value_micros, d.cost())); |
| 995 | 1044 | | } |
| ⋯ |
| 1004 | 1053 | | format!("{}: margin under {floor:.0}% for {n} days running, as low as {worst:.1}%.", costs::bucket_title(bucket)), |
| 1005 | 1054 | | from, |
| 1006 | 1055 | | )); |
| 1007 | | − | } |
| 1008 | | − | } |
| 1009 | | − | // Comped workspaces' share is a budget g1t chose to spend, watched |
| 1010 | | − | // on its own (budget.rs): not part of whether what is sold pays. |
| 1011 | | − | for (day, cost) in self.comped_costs(&since, until).await? { |
| 1012 | | − | if let Some(overall) = all.get_mut(&day) { |
| 1013 | | − | overall.1 = (overall.1 - cost).max(0); |
| 1014 | 1056 | | } |
| 1015 | 1057 | | } |
| 1016 | 1058 | | let series: Vec<(String, i64, i64)> = all.into_iter().map(|(day, (revenue, cost))| (day, revenue, cost)).collect(); |
| ⋯ |
| 1112 | 1154 | | |
| 1113 | 1155 | | /// Workspaces costing g1t more than they pay over 30 days, not g1t's own. |
| 1114 | 1156 | | /// Each day's cost shared out to comped workspaces. |
| 1115 | | − | async fn comped_costs(&self, since: &str, until: &str) -> Result<Vec<(String, i64)>> { |
| 1116 | | − | #[derive(Deserialize)] |
| 1117 | | − | struct Row { |
| 1118 | | − | day: String, |
| 1119 | | − | cost: Option<i64>, |
| 1120 | | − | } |
| 1121 | | − | Ok(self |
| 1122 | | − | .db |
| 1123 | | − | .prepare(format!( |
| 1124 | | − | "SELECT day, SUM(cost_micros) AS cost FROM workspace_costs |
| 1125 | | − | WHERE day >= ?1 AND day <= ?2 AND workspace IN ({}) GROUP BY day", |
| 1126 | | − | crate::sales::INTERNAL_SQL |
| 1127 | | − | )) |
| 1128 | | − | .bind(&[since.into(), until.into()])? |
| 1129 | | − | .all() |
| 1130 | | − | .await? |
| 1131 | | − | .results::<Row>()? |
| 1132 | | − | .into_iter() |
| 1133 | | − | .map(|r| (r.day, r.cost.unwrap_or(0))) |
| 1134 | | − | .collect()) |
| 1135 | | − | } |
| 1136 | | − | |
| 1137 | 1157 | | async fn workspace_anomalies(&self, until: &str, settings: &CostSettings) -> Result<Vec<(String, i64, i64)>> { |
| 1138 | 1158 | | #[derive(Deserialize)] |
| 1139 | 1159 | | struct Row { |
| ⋯ |
| 1314 | 1334 | | p.value_micros += d.value_micros; |
| 1315 | 1335 | | p.cost_micros += d.cost(); |
| 1316 | 1336 | | overall.cost_micros += d.cost(); |
| 1337 | + | overall.given_micros += d.given_micros; |
| 1317 | 1338 | | if d.bucket == "platform" { |
| 1318 | 1339 | | overall.plans_micros += d.cash_micros; |
| 1319 | 1340 | | } else { |
| ⋯ |
| 1327 | 1348 | | let revenue = overall.usage_micros + overall.plans_micros; |
| 1328 | 1349 | | overall.margin_micros = revenue - overall.cost_micros; |
| 1329 | 1350 | | overall.margin_percent = margin_percent(revenue, overall.cost_micros); |
| 1351 | + | let sold = (overall.cost_micros - overall.given_micros).max(0); |
| 1352 | + | overall.sold_margin_micros = revenue - sold; |
| 1353 | + | overall.sold_margin_percent = margin_percent(revenue, sold); |
| 1330 | 1354 | | let mut products: Vec<ProductMargin> = products.into_values().collect(); |
| 1331 | 1355 | | products.sort_by_key(|p| std::cmp::Reverse(p.cost_micros.max(p.value_micros))); |
| 1332 | 1356 | | |
| ⋯ |
| 1364 | 1388 | | workspace: String, |
| 1365 | 1389 | | cost: Option<i64>, |
| 1366 | 1390 | | revenue: Option<i64>, |
| 1391 | + | given: Option<i64>, |
| 1367 | 1392 | | internal: i64, |
| 1368 | 1393 | | } |
| 1369 | 1394 | | let top_workspaces = self |
| 1370 | 1395 | | .db |
| 1371 | 1396 | | .prepare(format!( |
| 1372 | | − | "SELECT workspace, SUM(cost_micros) AS cost, SUM(revenue_micros) AS revenue, |
| 1397 | + | "SELECT workspace, SUM(cost_micros) AS cost, SUM(revenue_micros) AS revenue, SUM(given_micros) AS given, |
| 1373 | 1398 | | CASE WHEN workspace IN ({}) THEN 1 ELSE 0 END AS internal |
| 1374 | 1399 | | FROM workspace_costs WHERE day >= ?1 AND day <= ?2 GROUP BY workspace ORDER BY cost DESC LIMIT 15", |
| 1375 | 1400 | | crate::sales::INTERNAL_SQL |
| ⋯ |
| 1379 | 1404 | | .await? |
| 1380 | 1405 | | .results::<Top>()? |
| 1381 | 1406 | | .into_iter() |
| 1382 | | − | .map(|t| WorkspaceCost { workspace: t.workspace, cost_micros: t.cost.unwrap_or(0), revenue_micros: t.revenue.unwrap_or(0), internal: t.internal == 1 }) |
| 1407 | + | .map(|t| WorkspaceCost { workspace: t.workspace, cost_micros: t.cost.unwrap_or(0), revenue_micros: t.revenue.unwrap_or(0), given_micros: t.given.unwrap_or(0), internal: t.internal == 1 }) |
| 1383 | 1408 | | .collect(); |
| 1384 | 1409 | | |
| 1385 | 1410 | | #[derive(Deserialize)] |
| ⋯ |
| 1534 | 1559 | | } |
| 1535 | 1560 | | |
| 1536 | 1561 | | fn usage(day: &str, workspace: &str, key: &str, value: i64, cash: i64, cost: i64) -> UsageRow { |
| 1537 | | − | UsageRow { day: day.into(), workspace: workspace.into(), key: key.into(), value, cash, cost } |
| 1562 | + | UsageRow { day: day.into(), workspace: workspace.into(), key: key.into(), value, cash, cost, given: 0 } |
| 1538 | 1563 | | } |
| 1539 | 1564 | | |
| 1540 | 1565 | | #[test] |
| ⋯ |
| 1626 | 1651 | | } |
| 1627 | 1652 | | |
| 1628 | 1653 | | fn day(bucket: &str, cf: i64, own: i64, value: i64, cfq: f64, ownq: f64) -> ProductDay { |
| 1629 | | − | ProductDay { day: "2026-10-15".into(), bucket: bucket.into(), cf_cost_micros: cf, own_cost_micros: own, value_micros: value, cash_micros: value, cf_quantity: cfq, own_quantity: ownq } |
| 1654 | + | ProductDay { day: "2026-10-15".into(), bucket: bucket.into(), cf_cost_micros: cf, own_cost_micros: own, value_micros: value, cash_micros: value, cf_quantity: cfq, own_quantity: ownq, given_micros: 0 } |
| 1630 | 1655 | | } |
| 1631 | 1656 | | |
| 1632 | 1657 | | #[test] |
| ⋯ |
| 1681 | 1706 | | } |
| 1682 | 1707 | | |
| 1683 | 1708 | | #[test] |
| 1709 | + | fn what_g1t_gives_away_is_kept_apart_from_what_it_sells() { |
| 1710 | + | let map = BTreeMap::new(); |
| 1711 | + | // A comped workspace (all of it given), one in its trial (half paid |
| 1712 | + | // by the trial) and one paying in cash, all on models. |
| 1713 | + | let mut comped = usage("2026-10-15", "flagon", "agent", 1_200_000, 0, 1_000_000); |
| 1714 | + | comped.given = comped.value; |
| 1715 | + | let mut trial = usage("2026-10-15", "acme", "agent", 1_200_000, 600_000, 1_000_000); |
| 1716 | + | trial.given = 600_000; |
| 1717 | + | let paying = usage("2026-10-15", "beta", "agent", 1_200_000, 1_200_000, 1_000_000); |
| 1718 | + | let (days, workspaces) = fold(&[], &map, &[], &[], &[comped, trial, paying]); |
| 1719 | + | let models = days.iter().find(|d| d.bucket == "models").unwrap(); |
| 1720 | + | assert_eq!((models.cost(), models.given_micros), (3_000_000, 1_500_000)); |
| 1721 | + | let given = |w: &str| workspaces.iter().find(|x| x.workspace == w).unwrap().given; |
| 1722 | + | assert_eq!((given("flagon"), given("acme"), given("beta")), (1_000_000, 500_000, 0)); |
| 1723 | + | } |
| 1724 | + | |
| 1725 | + | #[test] |
| 1684 | 1726 | | fn a_workspace_that_costs_more_than_it_pays_is_flagged() { |
| 1685 | 1727 | | let rows = vec![("acme".to_string(), 5_000_000, 1_000_000), ("beta".to_string(), 900_000, 0), ("gamma".to_string(), 2_000_000, 3_000_000)]; |
| 1686 | 1728 | | let found = anomalies(&rows, 1.0, 1_000_000); |