Commit

Costs: margin is measured on what was sold; comped workspaces, free periods, the trial and the pools are given away, a budget shown beside it

syntaqxcommitted Parentd250ef4Browse files
6 files+155−600/6 viewed
+17−5
143143 // Margin under the floor at the top; drift and leaks are in their own table, workspaces on Reach out.
144144 const banner = report.alerts.filter((a) => a.kind === "overall" || a.kind === "margin");
145145 const elsewhere = report.alerts.length - banner.length;
146+ // What g1t gave away on purpose (comped workspaces, free periods, the
147+ // trial, the pools) is a budget, watched under g1t's own spend; the
148+ // margin is measured on what was sold. Older reports lack the fields.
149+ const given = report.overall.givenMicros ?? 0;
150+ const soldMicros = report.overall.soldMarginMicros ?? report.overall.marginMicros;
151+ const soldPercent = report.overall.soldMarginPercent !== undefined ? report.overall.soldMarginPercent : report.overall.marginPercent;
146152 return (
147153 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
148154 <PageHeader
199205 value={usd(report.overall.usageMicros + report.overall.plansMicros)}
200206 hint={`${usd(report.overall.usageMicros)} usage, ${usd(report.overall.plansMicros)} plans`}
201207 />
202− <Stat label="Cloudflare cost" value={usd(report.overall.costMicros)} hint={`${report.since} to ${report.until}`} />
203208 <Stat
204− label="Margin"
205− value={percentLabel(report.overall.marginPercent)}
206− hint={usd(report.overall.marginMicros)}
207− tone={marginTone(report.overall.marginPercent, floor)}
209+ label="Cost"
210+ value={usd(report.overall.costMicros)}
211+ hint={given > 0 ? `${usd(given)} of it given away` : `${report.since} to ${report.until}`}
212+ />
213+ <Stat
214+ label="Margin on what was sold"
215+ value={percentLabel(soldPercent)}
216+ hint={given > 0 ? `${usd(soldMicros)}; ${percentLabel(report.overall.marginPercent)} with what was given` : usd(soldMicros)}
217+ tone={marginTone(soldPercent, floor)}
208218 />
209219 <Stat
210220 label="Proposals waiting"
401411 <tr className="border-b border-line text-left text-xs text-muted">
402412 <th className="px-4 py-2 font-medium sm:px-5">Workspace</th>
403413 <th className="px-4 py-2 text-right font-medium">Cost to g1t</th>
414+ <th className="px-4 py-2 text-right font-medium">Given away</th>
404415 <th className="px-4 py-2 text-right font-medium">Paid</th>
405416 <th className="px-4 py-2 text-right font-medium sm:pr-5">Net</th>
406417 </tr>
417428 {w.internal && <span className="ml-2 text-xs text-faint">g1t's own</span>}
418429 </td>
419430 <td className="tabular px-4 py-2.5 text-right">{usd(w.costMicros)}</td>
431+ <td className="tabular px-4 py-2.5 text-right text-fg-soft">{usd(w.givenMicros ?? 0)}</td>
420432 <td className="tabular px-4 py-2.5 text-right">{usd(w.revenueMicros)}</td>
421433 <td className={`tabular px-4 py-2.5 text-right sm:pr-5 ${net < 0 && !w.internal ? "text-danger" : "text-fg-soft"}`}>{usd(net)}</td>
422434 </tr>
+14−1
23862386 pub overhead: bool,
23872387 }
23882388
2389−/// All of g1t over the range: money in against every cost.
2389+/// All of g1t over the range: money in against every cost, and against
2390+/// the cost of what was sold (every cost less what g1t gave away).
23902391 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
23912392 #[serde(rename_all = "camelCase")]
23922393 pub struct OverallMargin {
23962397 pub cost_micros: i64,
23972398 pub margin_micros: i64,
23982399 pub margin_percent: Option<f64>,
2400+ /// Of `cost_micros`, what went on usage g1t gave away on purpose:
2401+ /// comped workspaces, free periods, the trial and the open-source pool.
2402+ #[serde(default)]
2403+ pub given_micros: i64,
2404+ /// Money in against `cost_micros - given_micros`.
2405+ #[serde(default)]
2406+ pub sold_margin_micros: i64,
2407+ #[serde(default)]
2408+ pub sold_margin_percent: Option<f64>,
23992409 }
24002410
24012411 /// A count, cost or leak that does not add up.
24812491 pub workspace: String,
24822492 pub cost_micros: i64,
24832493 pub revenue_micros: i64,
2494+ /// Of `cost_micros`, what g1t gave away.
2495+ #[serde(default)]
2496+ pub given_micros: i64,
24842497 /// One of g1t's own (comped) workspaces.
24852498 pub internal: bool,
24862499 }
+14−3
110110 what the plan's included usage, a trial, the open-source pool or g1t paid.
111111 g1t's own (comped) workspaces are valued at cost plus the margin.
112112 - **Cash** = what workspaces paid: `-amount_micros`, and the plan's price.
113+- **Given away** = the part of the cost that went on usage g1t paid for
114+ itself on purpose: all of a comped workspace's, all of a free period's,
115+ and what the trial and the open-source pool paid. Each workspace's day
116+ is split by the share of its value at price that was given (value less
117+ cash less the plan's included usage, which the plan's price paid for),
118+ and that share of each of its buckets' cost is given, its part of
119+ running g1t included. Stored as `given_micros` on `margin_days` and
120+ `workspace_costs`.
113121 - **Month-end meters**: a day's figure is that day's `pending_days`
114122 snapshot less the day before's, within a month. Their month-end ledger
115123 entries are left out, so nothing is counted twice.
116−- **Product margin** = (value − cost) / value. **Overall margin** =
117− (Σ cash − Σ cost) / Σ cash.
124+- **Product margin** = (value − cost) / value. **Margin on what was
125+ sold**, sudo's headline and the overall alert = (Σ cash − (Σ cost − Σ
126+ given)) / Σ cash. The margin with what was given, (Σ cash − Σ cost) / Σ
127+ cash, shows under it. What was given is a budget, watched under g1t's own
128+ spend, not a price below cost.
118129 - **Quantities**: where a mapping names an `own_meter`, Cloudflare's
119130 billed quantity of those lines (or, without one, Artifacts' operation
120131 events) against g1t's own count.
204215 | Alert | Raised when | First steps |
205216 | --- | --- | --- |
206217 | Margin under the floor | A product's value against cost under `margin_floor_percent` (10%) for `alert_days` (3) days running, each with at least `min_daily_cost` | Open the product on Costs & margin. Cost up? Check proposals (approve a rise; it waits out the notice). Value down? A mapping or `revenue_map` may have moved. |
207−| All of g1t under the floor | The same for money in against every cost, comped workspaces' share left out (their spend is a budget, watched in budget.rs). While less than $1 a day comes in, it says the dollars, not a percentage | Look at which products moved; check `platform` (it has no revenue of its own and grows with traffic). Before launch, with little paid usage, expect it. |
218+| All of g1t under the floor | The same for money in against the cost of what was sold: every cost less what was given away (comped workspaces, free periods, the trial, the pools), which is a budget watched in budget.rs. While less than $1 a day comes in, it says the dollars, not a percentage | Look at which products moved; check `platform` (it has no revenue of its own and grows with traffic). Before launch, with little paid usage, expect it. |
208219 | Leak | Drift of kind leak | Map the meter, or decide it is overhead. |
209220 | Drift | Count drift | See Drift above. Cloudflare's definitions change in beta: ask them in writing ([ARTIFACTS.md](ARTIFACTS.md), §7). |
210221 | Costs more than it pays | A workspace's shared cost over 30 days above what its usage was priced at (`value_micros`, whoever paid: card, trial, gift or included usage) × `anomaly_factor`, at least `anomaly_floor`; not comped workspaces | Shown on Reach out as "Costs more than it pays": its usage is priced below what it costs. Abuse (Abuse & fraud page) or a gap in pricing. Not emailed. A trial or gift paying for usage does not raise it. |
+13−2
10561056 };
10571057
10581058 /** All of g1t: money in (usage and the plan) against every cost. */
1059−export type OverallMargin = { usageMicros: number; plansMicros: number; costMicros: number; marginMicros: number; marginPercent: number | null };
1059+export type OverallMargin = {
1060+ usageMicros: number;
1061+ plansMicros: number;
1062+ costMicros: number;
1063+ marginMicros: number;
1064+ marginPercent: number | null;
1065+ /** Of costMicros, what went on usage g1t gave away on purpose: comped workspaces, free periods, the trial and the open-source pool. */
1066+ givenMicros?: number;
1067+ /** Money in against costMicros - givenMicros. */
1068+ soldMarginMicros?: number;
1069+ soldMarginPercent?: number | null;
1070+};
10601071
10611072 /** A count, cost or leak that does not add up. */
10621073 export type CostDrift = {
11171128 appliedAt: string | null;
11181129 };
11191130
1120−export type WorkspaceCost = { workspace: string; costMicros: number; revenueMicros: number; internal: boolean };
1131+export type WorkspaceCost = { workspace: string; costMicros: number; revenueMicros: number; givenMicros?: number; internal: boolean };
11211132
11221133 export type CostLineSummary = {
11231134 product: string;
+6−0
1+-- What g1t gave away on purpose: the part of each day's cost that went on
2+-- usage nobody paid for (g1t's own comped workspaces, a free period, the
3+-- trial and the open-source pool). Sudo measures margin on what was sold,
4+-- with this shown beside it, so comping does not read as lost money.
5+ALTER TABLE margin_days ADD COLUMN given_micros INTEGER NOT NULL DEFAULT 0;
6+ALTER TABLE workspace_costs ADD COLUMN given_micros INTEGER NOT NULL DEFAULT 0;
+91−49
7676 /// says they are the same units.
7777 pub cf_quantity: f64,
7878 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,
7982 }
8083
8184 impl ProductDay {
116119 pub value: i64,
117120 pub cash: i64,
118121 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,
119126 }
120127
121128 /// One workspace's share of a product's cost on one day.
129136 pub revenue: i64,
130137 /// What its usage was priced at, whoever paid for it.
131138 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,
132142 }
133143
134144 fn micros(dollars: f64) -> i64 {
206216 let mut revenue: BTreeMap<(String, String, String), i64> = BTreeMap::new();
207217 let mut valued: BTreeMap<(String, String, String), i64> = BTreeMap::new();
208218 let mut active: BTreeMap<String, Vec<(String, f64)>> = BTreeMap::new();
219+ let mut gave: BTreeMap<(String, String), (i64, i64)> = BTreeMap::new();
209220 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;
210224 let bucket = bucket_of(&u.key);
211225 let key = (u.day.clone(), bucket.clone());
212226 let row = days.entry(key.clone()).or_insert_with(|| entry(&key.0, &key.1));
238252 }
239253 }
240254 let keys: BTreeSet<(String, String, String)> = shares.keys().chain(revenue.keys()).cloned().collect();
241− let workspaces = keys
255+ let workspaces: Vec<WorkspaceDay> = keys
242256 .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+ }
250274 })
251275 .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+ }
252281 (days.into_values().collect(), workspaces)
253282 }
254283
269298 };
270299 let (cost, charge) = ((cost - before_cost).max(0), (charge - before_charge).max(0));
271300 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 });
273302 }
274303 previous = Some(snap);
275304 }
566595 cash_micros: i64,
567596 cf_quantity: f64,
568597 own_quantity: f64,
598+ #[serde(default)]
599+ given_micros: Option<i64>,
569600 }
570601
571602 impl From<MarginRow> for ProductDay {
579610 cash_micros: r.cash_micros,
580611 cf_quantity: r.cf_quantity,
581612 own_quantity: r.own_quantity,
613+ given_micros: r.given_micros.unwrap_or(0),
582614 }
583615 }
584616 }
655687 own_provider: i64,
656688 cash: Option<i64>,
657689 drawn: Option<i64>,
690+ credit: Option<i64>,
658691 cost: Option<i64>,
659692 }
660693 let charged_here = crate::storage::CHARGED_HERE.iter().map(|s| format!("'{s}'")).collect::<Vec<_>>().join(", ");
668701 CASE WHEN billed_to = 'workspace' THEN 1 ELSE 0 END AS own_provider,
669702 -SUM(amount_micros) AS cash,
670703 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,
671705 SUM(COALESCE(cost_micros, 0)) AS cost
672706 FROM ledger
673707 WHERE kind = 'usage' AND created_at >= ?1 AND created_at <= ?2 AND COALESCE(task, '') NOT IN ({charged_here})
678712 .all()
679713 .await?
680714 .results::<Row>()?;
715+ let mut internal = BTreeSet::new();
681716 let mut out: Vec<UsageRow> = rows
682717 .into_iter()
683718 .map(|r| {
687722 let cash = r.cash.unwrap_or(0);
688723 let paid = cash + r.drawn.unwrap_or(0);
689724 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 }
691730 })
692731 .collect();
693732 // Month-end sources, from their daily snapshots.
710749 .filter(|s| crate::storage::CHARGED_HERE.contains(&s.source.as_str()) || s.source == "domains")
711750 .map(|s| (s.day, s.workspace, s.source, s.cost_micros, s.charge_micros))
712751 .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+ }));
714758 // The plan's price, spread over the 30 days it pays for, so a month's
715759 // payment does not read as one very good day and 29 bad ones.
716760 #[derive(Deserialize)]
732776 for p in plans {
733777 for (day, micros) in spread(&p.day, p.micros.unwrap_or(0), PLAN_DAYS) {
734778 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 });
736780 }
737781 }
738782 }
785829 statements.push(
786830 self.db
787831 .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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
790834 )
791835 .bind(&[
792836 d.day.as_str().into(),
797841 (d.cash_micros as f64).into(),
798842 d.cf_quantity.into(),
799843 d.own_quantity.into(),
844+ (d.given_micros as f64).into(),
800845 now.as_str().into(),
801846 ])?,
802847 );
808853 for w in chunk {
809854 statements.push(
810855 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 (?, ?, ?, ?, ?, ?, ?)")
812857 .bind(&[
813858 w.day.as_str().into(),
814859 w.workspace.as_str().into(),
816861 (w.cost as f64).into(),
817862 (w.revenue as f64).into(),
818863 (w.value as f64).into(),
864+ (w.given as f64).into(),
819865 ])?,
820866 );
821867 }
9881034 let mut all: BTreeMap<String, (i64, i64)> = BTreeMap::new();
9891035 for d in &days {
9901036 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.
9911040 overall.0 += d.cash_micros;
992− overall.1 += d.cost();
1041+ overall.1 += (d.cost() - d.given_micros).max(0);
9931042 if !OVERHEAD.contains(&d.bucket.as_str()) && d.bucket != UNMAPPED {
9941043 by.entry(d.bucket.clone()).or_default().push((d.day.clone(), d.value_micros, d.cost()));
9951044 }
10041053 format!("{}: margin under {floor:.0}% for {n} days running, as low as {worst:.1}%.", costs::bucket_title(bucket)),
10051054 from,
10061055 ));
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);
10141056 }
10151057 }
10161058 let series: Vec<(String, i64, i64)> = all.into_iter().map(|(day, (revenue, cost))| (day, revenue, cost)).collect();
11121154
11131155 /// Workspaces costing g1t more than they pay over 30 days, not g1t's own.
11141156 /// 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−
11371157 async fn workspace_anomalies(&self, until: &str, settings: &CostSettings) -> Result<Vec<(String, i64, i64)>> {
11381158 #[derive(Deserialize)]
11391159 struct Row {
13141334 p.value_micros += d.value_micros;
13151335 p.cost_micros += d.cost();
13161336 overall.cost_micros += d.cost();
1337+ overall.given_micros += d.given_micros;
13171338 if d.bucket == "platform" {
13181339 overall.plans_micros += d.cash_micros;
13191340 } else {
13271348 let revenue = overall.usage_micros + overall.plans_micros;
13281349 overall.margin_micros = revenue - overall.cost_micros;
13291350 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);
13301354 let mut products: Vec<ProductMargin> = products.into_values().collect();
13311355 products.sort_by_key(|p| std::cmp::Reverse(p.cost_micros.max(p.value_micros)));
13321356
13641388 workspace: String,
13651389 cost: Option<i64>,
13661390 revenue: Option<i64>,
1391+ given: Option<i64>,
13671392 internal: i64,
13681393 }
13691394 let top_workspaces = self
13701395 .db
13711396 .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,
13731398 CASE WHEN workspace IN ({}) THEN 1 ELSE 0 END AS internal
13741399 FROM workspace_costs WHERE day >= ?1 AND day <= ?2 GROUP BY workspace ORDER BY cost DESC LIMIT 15",
13751400 crate::sales::INTERNAL_SQL
13791404 .await?
13801405 .results::<Top>()?
13811406 .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 })
13831408 .collect();
13841409
13851410 #[derive(Deserialize)]
15341559 }
15351560
15361561 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 }
15381563 }
15391564
15401565 #[test]
16261651 }
16271652
16281653 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 }
16301655 }
16311656
16321657 #[test]
16811706 }
16821707
16831708 #[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]
16841726 fn a_workspace_that_costs_more_than_it_pays_is_flagged() {
16851727 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)];
16861728 let found = anomalies(&rows, 1.0, 1_000_000);