Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running
6 files+390−1020/6 viewed
| 1 | 1 | import type { ReactNode } from "react"; | |
| 2 | − | import { Link, data, redirect } from "react-router"; | |
| 2 | + | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { CostsReport, PriceProposal } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 143 | 143 | // Margin under the floor at the top; drift and leaks are in their own table, workspaces on Reach out. | |
| 144 | 144 | const banner = report.alerts.filter((a) => a.kind === "overall" || a.kind === "margin"); | |
| 145 | 145 | 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; | |
| 152 | 146 | return ( | |
| 153 | 147 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 154 | 148 | <PageHeader | |
| 155 | 149 | title="Costs & margin" | |
| 156 | − | description="What Cloudflare charged g1t, day by day, against what g1t charged for the same things. Prices are cost plus 20%; this is where that is checked against the bill, where drift and leaks show, and where price changes wait for a decision." | |
| 157 | − | actions={ | |
| 158 | − | <form method="post"> | |
| 159 | − | <input type="hidden" name="intent" value="run" /> | |
| 160 | − | <Button variant="quiet" type="submit"> | |
| 161 | − | Run the analysis now | |
| 162 | − | </Button> | |
| 163 | − | </form> | |
| 164 | − | } | |
| 150 | + | description="What g1t cost to run (Cloudflare's bill and the model providers') against what workspaces paid. Prices are cost plus 20%; this is where that is checked against the bill, where drift and leaks show, and where price changes wait for a decision." | |
| 151 | + | actions={<RunButton />} | |
| 165 | 152 | /> | |
| 166 | 153 | <nav aria-label="Range" className="mt-5 flex flex-wrap gap-2"> | |
| 167 | 154 | {RANGES.map((days) => ( | |
| 199 | 186 | )} | |
| 200 | 187 | </div> | |
| 201 | 188 | ||
| 202 | − | <div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 203 | − | <Stat | |
| 204 | − | label="Money in" | |
| 205 | − | value={usd(report.overall.usageMicros + report.overall.plansMicros)} | |
| 206 | − | hint={`${usd(report.overall.usageMicros)} usage, ${usd(report.overall.plansMicros)} plans`} | |
| 207 | − | /> | |
| 208 | − | <Stat | |
| 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)} | |
| 218 | − | /> | |
| 219 | − | <Stat | |
| 220 | − | label="Proposals waiting" | |
| 221 | − | value={String(open.length)} | |
| 222 | − | hint={report.fetchedAt ? <>Bill read <When at={report.fetchedAt} time /></> : "The bill has not been read yet"} | |
| 223 | − | tone={open.length > 0 ? "warn" : undefined} | |
| 224 | − | /> | |
| 225 | − | </div> | |
| 189 | + | <Statement report={report} floor={floor} range={range} proposals={open.length} /> | |
| 226 | 190 | ||
| 227 | 191 | <SpendSection caps={report.caps} error={failed?.section === "lift" ? failed.error : null} /> | |
| 228 | 192 | ||
| 649 | 613 | } | |
| 650 | 614 | ||
| 651 | 615 | /** g1t's own spend: the daily breaker, comped budgets, and this month by what paid. */ | |
| 616 | + | /** The button that runs the nightly analysis now, saying so while it runs. */ | |
| 617 | + | function RunButton() { | |
| 618 | + | const navigation = useNavigation(); | |
| 619 | + | const running = navigation.state !== "idle" && navigation.formData?.get("intent") === "run"; | |
| 620 | + | return ( | |
| 621 | + | <Form method="post" className="flex items-center gap-3"> | |
| 622 | + | <input type="hidden" name="intent" value="run" /> | |
| 623 | + | <Button variant="quiet" type="submit" disabled={running}> | |
| 624 | + | {running ? "Running the analysis…" : "Run the analysis now"} | |
| 625 | + | </Button> | |
| 626 | + | {running && <span className="text-xs text-muted">Reading the bill and reconciling 31 days; about a minute.</span>} | |
| 627 | + | </Form> | |
| 628 | + | ); | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | /** | |
| 632 | + | * Where the money went, as a short statement: usage sold and running g1t | |
| 633 | + | * (each against what paid for it), what g1t gave away on purpose (a budget, | |
| 634 | + | * never a loss), and who was paid. | |
| 635 | + | */ | |
| 636 | + | function Statement({ report, floor, range, proposals }: { report: CostsReport; floor: number; range: number; proposals: number }) { | |
| 637 | + | const o = report.overall; | |
| 638 | + | // Reports from before the statement have only the totals. | |
| 639 | + | const given = o.givenMicros ?? 0; | |
| 640 | + | const usageCost = o.usageCostMicros ?? o.costMicros - given; | |
| 641 | + | const usageMargin = o.usageMarginMicros ?? o.usageMicros - usageCost; | |
| 642 | + | const usagePercent = o.usageMarginPercent !== undefined ? o.usageMarginPercent : null; | |
| 643 | + | const running = o.runningCostMicros ?? 0; | |
| 644 | + | const unmapped = o.unmappedCostMicros ?? 0; | |
| 645 | + | // Cloudflare's subscriptions are not on the usage bill: the estimate, over the range. | |
| 646 | + | const subscriptions = Math.round((report.caps.fixedMonthlyMicros * range) / 30); | |
| 647 | + | const moneyIn = o.usageMicros + o.plansMicros; | |
| 648 | + | const spent = o.costMicros + subscriptions; | |
| 649 | + | const net = moneyIn - spent; | |
| 650 | + | const givenParts = [ | |
| 651 | + | ["comped", o.givenCompedMicros ?? 0], | |
| 652 | + | ["free use", o.givenFreeMicros ?? 0], | |
| 653 | + | ["trial", o.givenTrialMicros ?? 0], | |
| 654 | + | ["open-source pool", o.givenPoolMicros ?? 0], | |
| 655 | + | ].filter(([, micros]) => (micros as number) > 0) as [string, number][]; | |
| 656 | + | const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [ | |
| 657 | + | { | |
| 658 | + | title: "Usage sold", | |
| 659 | + | note: "What workspaces paid for usage, against what that usage cost", | |
| 660 | + | in: o.usageMicros, | |
| 661 | + | cost: usageCost, | |
| 662 | + | result: usageMargin, | |
| 663 | + | tone: usageMargin < 0 ? "danger" : undefined, | |
| 664 | + | }, | |
| 665 | + | { | |
| 666 | + | title: "Running g1t", | |
| 667 | + | note: "Plans, against Workers, D1, KV, Queues and the rest of the platform", | |
| 668 | + | in: o.plansMicros, | |
| 669 | + | cost: running, | |
| 670 | + | result: o.plansMicros - running, | |
| 671 | + | tone: o.plansMicros - running < 0 ? "warn" : undefined, | |
| 672 | + | }, | |
| 673 | + | { | |
| 674 | + | title: "Cloudflare subscriptions", | |
| 675 | + | note: "Fixed, an estimate (CLOUDFLARE_FIXED_MONTHLY_MICROS), not on the usage bill", | |
| 676 | + | in: null, | |
| 677 | + | cost: subscriptions, | |
| 678 | + | result: -subscriptions, | |
| 679 | + | tone: "muted", | |
| 680 | + | }, | |
| 681 | + | ...(unmapped > 0 | |
| 682 | + | ? [{ title: "Not mapped", note: "Billed by Cloudflare, charged for by nothing yet", in: null, cost: unmapped, result: -unmapped, tone: "danger" as const }] | |
| 683 | + | : []), | |
| 684 | + | { | |
| 685 | + | title: "Given away", | |
| 686 | + | note: givenParts.length > 0 ? givenParts.map(([why, micros]) => `${why} ${usd(micros)}`).join(" · ") : "Nothing this range", | |
| 687 | + | in: null, | |
| 688 | + | cost: given, | |
| 689 | + | result: null, | |
| 690 | + | tone: "muted", | |
| 691 | + | }, | |
| 692 | + | ]; | |
| 693 | + | const resultClass = (tone?: string) => (tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : tone === "muted" ? "text-muted" : "text-fg"); | |
| 694 | + | return ( | |
| 695 | + | <> | |
| 696 | + | <div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 697 | + | <Stat | |
| 698 | + | label="Margin on usage sold" | |
| 699 | + | value={percentLabel(usagePercent)} | |
| 700 | + | hint={`${usd(o.usageMicros)} paid for usage that cost ${usd(usageCost)}`} | |
| 701 | + | tone={marginTone(usagePercent, floor)} | |
| 702 | + | /> | |
| 703 | + | <Stat | |
| 704 | + | label="Given away on purpose" | |
| 705 | + | value={usd(given)} | |
| 706 | + | hint={givenParts.length > 0 ? givenParts.map(([why, micros]) => `${usd(micros)} ${why}`).join(", ") : "Nothing given this range"} | |
| 707 | + | /> | |
| 708 | + | <Stat | |
| 709 | + | label="Who g1t paid" | |
| 710 | + | value={usd(o.costMicros)} | |
| 711 | + | hint={`Cloudflare ${usd(o.cloudflareCostMicros ?? o.costMicros - (o.modelsCostMicros ?? 0))}, model providers ${usd(o.modelsCostMicros ?? 0)}`} | |
| 712 | + | /> | |
| 713 | + | <Stat | |
| 714 | + | label="Proposals waiting" | |
| 715 | + | value={String(proposals)} | |
| 716 | + | hint={report.fetchedAt ? <>Bill read <When at={report.fetchedAt} time /></> : "The bill has not been read yet"} | |
| 717 | + | tone={proposals > 0 ? "warn" : undefined} | |
| 718 | + | /> | |
| 719 | + | </div> | |
| 720 | + | <div className="mt-3 overflow-x-auto rounded-lg border border-line bg-surface"> | |
| 721 | + | <table className="w-full min-w-[36rem] text-sm"> | |
| 722 | + | <thead> | |
| 723 | + | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 724 | + | <th className="px-4 py-2 font-medium sm:px-5"> | |
| 725 | + | {report.since} to {report.until} | |
| 726 | + | </th> | |
| 727 | + | <th className="px-4 py-2 text-right font-medium">Paid in</th> | |
| 728 | + | <th className="px-4 py-2 text-right font-medium">Cost</th> | |
| 729 | + | <th className="px-4 py-2 text-right font-medium sm:pr-5">Result</th> | |
| 730 | + | </tr> | |
| 731 | + | </thead> | |
| 732 | + | <tbody> | |
| 733 | + | {rows.map((row) => ( | |
| 734 | + | <tr key={row.title} className="border-b border-line"> | |
| 735 | + | <td className="px-4 py-2.5 sm:px-5"> | |
| 736 | + | <span className="text-fg">{row.title}</span> | |
| 737 | + | <span className="block text-xs text-faint">{row.note}</span> | |
| 738 | + | </td> | |
| 739 | + | <td className="tabular px-4 py-2.5 text-right">{row.in == null ? <span className="text-faint">—</span> : usd(row.in)}</td> | |
| 740 | + | <td className="tabular px-4 py-2.5 text-right">{usd(row.cost)}</td> | |
| 741 | + | <td className={`tabular px-4 py-2.5 text-right sm:pr-5 ${resultClass(row.tone)}`}> | |
| 742 | + | {row.result == null ? "a budget" : usd(row.result, { signed: true })} | |
| 743 | + | </td> | |
| 744 | + | </tr> | |
| 745 | + | ))} | |
| 746 | + | <tr> | |
| 747 | + | <td className="px-4 py-2.5 sm:px-5"> | |
| 748 | + | <span className="font-medium text-fg">All in</span> | |
| 749 | + | <span className="block text-xs text-faint"> | |
| 750 | + | {given > 0 ? `${usd(given)} of the cost was given away on purpose; without it, ${usd(net + given, { signed: true })}` : "Everything above"} | |
| 751 | + | </span> | |
| 752 | + | </td> | |
| 753 | + | <td className="tabular px-4 py-2.5 text-right font-medium">{usd(moneyIn)}</td> | |
| 754 | + | <td className="tabular px-4 py-2.5 text-right font-medium">{usd(spent)}</td> | |
| 755 | + | <td className="tabular px-4 py-2.5 text-right font-medium text-fg sm:pr-5">{usd(net, { signed: true })}</td> | |
| 756 | + | </tr> | |
| 757 | + | </tbody> | |
| 758 | + | </table> | |
| 759 | + | </div> | |
| 760 | + | </> | |
| 761 | + | ); | |
| 762 | + | } | |
| 763 | + | ||
| 652 | 764 | function SpendSection({ caps, error }: { caps: CostsReport["caps"]; error: string | null }) { | |
| 653 | 765 | const { rows, totalMicros } = spendRows(caps); | |
| 654 | 766 | const net = caps.revenueMicros - totalMicros; |
| 2406 | 2406 | pub sold_margin_micros: i64, | |
| 2407 | 2407 | #[serde(default)] | |
| 2408 | 2408 | pub sold_margin_percent: Option<f64>, | |
| 2409 | + | /// What was sold, apart: usage (`usage_micros` against what that usage | |
| 2410 | + | /// cost, less what was given), running g1t (`plans_micros` against the | |
| 2411 | + | /// platform's cost, less its given share) and what no mapping names. | |
| 2412 | + | #[serde(default)] | |
| 2413 | + | pub usage_cost_micros: i64, | |
| 2414 | + | #[serde(default)] | |
| 2415 | + | pub usage_margin_micros: i64, | |
| 2416 | + | #[serde(default)] | |
| 2417 | + | pub usage_margin_percent: Option<f64>, | |
| 2418 | + | #[serde(default)] | |
| 2419 | + | pub running_cost_micros: i64, | |
| 2420 | + | #[serde(default)] | |
| 2421 | + | pub unmapped_cost_micros: i64, | |
| 2422 | + | /// `given_micros` by why: comped workspaces, free use (free periods, | |
| 2423 | + | /// free allowances, overruns g1t covered), the trial, the open-source pool. | |
| 2424 | + | #[serde(default)] | |
| 2425 | + | pub given_comped_micros: i64, | |
| 2426 | + | #[serde(default)] | |
| 2427 | + | pub given_free_micros: i64, | |
| 2428 | + | #[serde(default)] | |
| 2429 | + | pub given_trial_micros: i64, | |
| 2430 | + | #[serde(default)] | |
| 2431 | + | pub given_pool_micros: i64, | |
| 2432 | + | /// `cost_micros` by who g1t pays: Cloudflare's bill (billed amounts, | |
| 2433 | + | /// after the included allowances), and model providers (the ledger's | |
| 2434 | + | /// cost of the tokens, which Cloudflare's bill does not show). | |
| 2435 | + | #[serde(default)] | |
| 2436 | + | pub cloudflare_cost_micros: i64, | |
| 2437 | + | #[serde(default)] | |
| 2438 | + | pub models_cost_micros: i64, | |
| 2409 | 2439 | } | |
| 2410 | 2440 | ||
| 2411 | 2441 | /// A count, cost or leak that does not add up. |
| 101 | 101 | ||
| 102 | 102 | For each day and bucket: | |
| 103 | 103 | ||
| 104 | − | - **Cloudflare cost** = Σ the bucket's lines' cost. `models` uses the | |
| 105 | − | ledger's cost instead. | |
| 104 | + | - **Cloudflare cost** = Σ the bucket's lines' cost, as billed: after the | |
| 105 | + | included allowances, so a month inside them costs $0 here as on | |
| 106 | + | Cloudflare's Billable usage page. `models` uses the ledger's cost of the | |
| 107 | + | tokens instead; that is paid to the model providers and is not on | |
| 108 | + | Cloudflare's bill. | |
| 106 | 109 | - **Own cost** = Σ the ledger's `cost_micros` for the bucket's keys (the | |
| 107 | 110 | price book's cost when charged), plus month-end deltas. A workspace's own | |
| 108 | 111 | model provider is no cost to g1t. | |
| 111 | 114 | g1t's own (comped) workspaces are valued at cost plus the margin. | |
| 112 | 115 | - **Cash** = what workspaces paid: `-amount_micros`, and the plan's price. | |
| 113 | 116 | - **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`. | |
| 117 | + | itself on purpose, by why: | |
| 118 | + | - **comped**: all of a comped workspace's cost, every bucket; | |
| 119 | + | - **free use**: a free period's usage, the overruns g1t covered | |
| 120 | + | (`ledger.given_micros`), and all of a workspace's cost on a day it had | |
| 121 | + | nothing priced (free allowances); | |
| 122 | + | - **trial** and **open-source pool**: what `trial_micros` and | |
| 123 | + | `oss_micros` paid. | |
| 124 | + | ||
| 125 | + | Otherwise a workspace's day is split by those shares of its value at | |
| 126 | + | price, and the same shares of each of its buckets' cost are given, its | |
| 127 | + | part of running g1t included. The Team plan's included usage is sold: | |
| 128 | + | the plan's price paid for it. Stored on `margin_days` (`given_micros` | |
| 129 | + | and `given_<why>_micros`) and `workspace_costs` (`given_micros`). | |
| 121 | 130 | - **Month-end meters**: a day's figure is that day's `pending_days` | |
| 122 | 131 | snapshot less the day before's, within a month. Their month-end ledger | |
| 123 | 132 | entries are left out, so nothing is counted twice. | |
| 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. | |
| 133 | + | - **Product margin** = (value − cost) / value. | |
| 134 | + | - **Sudo's statement** keeps apart: | |
| 135 | + | - **Usage sold**: cash for usage against the cost of the usage buckets | |
| 136 | + | less what was given. Its margin is the headline; at cost plus 20% it | |
| 137 | + | sits near 16.7%. | |
| 138 | + | - **Running g1t**: the plan's price against `platform` less its given | |
| 139 | + | share. | |
| 140 | + | - **Cloudflare subscriptions**: `CLOUDFLARE_FIXED_MONTHLY_MICROS` over | |
| 141 | + | the range, an estimate, since they are not on the usage bill. | |
| 142 | + | - **Not mapped**: billed, charged for by nothing. | |
| 143 | + | - **Given away**: by why. A budget, watched under g1t's own spend, never | |
| 144 | + | shown as a loss. | |
| 145 | + | - **All in**: money in against all of it, with the figure without what | |
| 146 | + | was given beside it. **Who g1t paid** splits the cost into Cloudflare | |
| 147 | + | and the model providers. | |
| 148 | + | ||
| 149 | + | The overall alert is (Σ cash − (Σ cost − Σ given)) / Σ cash. | |
| 129 | 150 | - **Quantities**: where a mapping names an `own_meter`, Cloudflare's | |
| 130 | 151 | billed quantity of those lines (or, without one, Artifacts' operation | |
| 131 | 152 | events) against g1t's own count. | |
| 133 | 154 | **Shared costs to workspaces.** A bucket's cost is shared in proportion | |
| 134 | 155 | to, first available: Cloudflare's own per-workspace count | |
| 135 | 156 | (`cloudflare_<bucket>`, today the Artifacts events by repository), g1t's | |
| 136 | − | own count, what each was charged for it, what its usage cost. `platform` | |
| 157 | + | own count, what its usage cost (so free use carries its own cost), what | |
| 158 | + | each was charged for it. `platform` | |
| 137 | 159 | and `unmapped` are shared by each workspace's share of all usage that | |
| 138 | 160 | day. Shares are whole micros that add up to the bill exactly (largest | |
| 139 | 161 | remainder). |
| 1067 | 1067 | /** Money in against costMicros - givenMicros. */ | |
| 1068 | 1068 | soldMarginMicros?: number; | |
| 1069 | 1069 | soldMarginPercent?: number | null; | |
| 1070 | + | /** Usage sold: usageMicros against what that usage cost, less what was given. */ | |
| 1071 | + | usageCostMicros?: number; | |
| 1072 | + | usageMarginMicros?: number; | |
| 1073 | + | usageMarginPercent?: number | null; | |
| 1074 | + | /** Running g1t: plansMicros against the platform's cost, less its given share. */ | |
| 1075 | + | runningCostMicros?: number; | |
| 1076 | + | /** Cost no mapping names, less its given share. */ | |
| 1077 | + | unmappedCostMicros?: number; | |
| 1078 | + | /** givenMicros by why. Free is free periods, free allowances and overruns g1t covered. */ | |
| 1079 | + | givenCompedMicros?: number; | |
| 1080 | + | givenFreeMicros?: number; | |
| 1081 | + | givenTrialMicros?: number; | |
| 1082 | + | givenPoolMicros?: number; | |
| 1083 | + | /** costMicros by who g1t pays: Cloudflare's bill (billed, after included allowances) and model providers (tokens, not on Cloudflare's bill). */ | |
| 1084 | + | cloudflareCostMicros?: number; | |
| 1085 | + | modelsCostMicros?: number; | |
| 1070 | 1086 | }; | |
| 1071 | 1087 | ||
| 1072 | 1088 | /** A count, cost or leak that does not add up. */ |
| 1 | + | -- What was given away, by why: comped workspaces, free use (free periods, | |
| 2 | + | -- free allowances, overruns g1t covered), the trial and the open-source | |
| 3 | + | -- pool. given_micros stays their total. | |
| 4 | + | ALTER TABLE margin_days ADD COLUMN given_comped_micros INTEGER NOT NULL DEFAULT 0; | |
| 5 | + | ALTER TABLE margin_days ADD COLUMN given_free_micros INTEGER NOT NULL DEFAULT 0; | |
| 6 | + | ALTER TABLE margin_days ADD COLUMN given_trial_micros INTEGER NOT NULL DEFAULT 0; | |
| 7 | + | ALTER TABLE margin_days ADD COLUMN given_pool_micros INTEGER NOT NULL DEFAULT 0; |
| 78 | 78 | pub own_quantity: f64, | |
| 79 | 79 | /// Of `cost()`, what went on usage g1t gave away (the workspaces' | |
| 80 | 80 | /// `WorkspaceDay::given`, added up). | |
| 81 | − | pub given_micros: i64, | |
| 81 | + | pub given: Given, | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | impl ProductDay { | |
| 109 | 109 | pub quantity: f64, | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | + | /// What g1t gave away, by why: its own comped workspaces, free use (a | |
| 113 | + | /// free period, free allowances, overruns g1t covered), the trial, and the | |
| 114 | + | /// open-source pool. The Team plan's included usage is paid for by the | |
| 115 | + | /// plan's price, so it is sold, not given. | |
| 116 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 117 | + | pub(crate) struct Given { | |
| 118 | + | pub comped: i64, | |
| 119 | + | pub free: i64, | |
| 120 | + | pub trial: i64, | |
| 121 | + | pub pool: i64, | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | impl Given { | |
| 125 | + | pub fn total(&self) -> i64 { | |
| 126 | + | self.comped + self.free + self.trial + self.pool | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | fn add(&mut self, other: &Given) { | |
| 130 | + | self.comped += other.comped; | |
| 131 | + | self.free += other.free; | |
| 132 | + | self.trial += other.trial; | |
| 133 | + | self.pool += other.pool; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// The same shares of `cost` as these are of `value`, at most all of it. | |
| 137 | + | fn of(&self, cost: i64, value: i64) -> Given { | |
| 138 | + | let total = self.total(); | |
| 139 | + | if value <= 0 || cost <= 0 || total <= 0 { | |
| 140 | + | return Given::default(); | |
| 141 | + | } | |
| 142 | + | let given = cost as i128 * total.min(value) as i128 / value as i128; | |
| 143 | + | let part = |x: i64| (given * x.max(0) as i128 / total as i128) as i64; | |
| 144 | + | Given { comped: part(self.comped), free: part(self.free), trial: part(self.trial), pool: part(self.pool) } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 112 | 148 | /// What a workspace was charged for one key on one day. | |
| 113 | 149 | #[derive(Clone, Debug, Default, PartialEq)] | |
| 114 | 150 | pub(crate) struct UsageRow { | |
| 120 | 156 | pub cash: i64, | |
| 121 | 157 | pub cost: i64, | |
| 122 | 158 | /// 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, | |
| 159 | + | /// workspaces and in a free period, else what the trial and the pool | |
| 160 | + | /// paid and the overruns g1t covered. | |
| 161 | + | pub given: Given, | |
| 126 | 162 | } | |
| 127 | 163 | ||
| 128 | 164 | /// One workspace's share of a product's cost on one day. | |
| 136 | 172 | pub revenue: i64, | |
| 137 | 173 | /// What its usage was priced at, whoever paid for it. | |
| 138 | 174 | 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, | |
| 175 | + | /// Of `cost`, the part g1t gave away: all of it for a comped workspace | |
| 176 | + | /// or one with nothing priced that day (free use), else the cost times | |
| 177 | + | /// the shares of its usage that day that g1t paid for. | |
| 178 | + | pub given: Given, | |
| 142 | 179 | } | |
| 143 | 180 | ||
| 144 | 181 | fn micros(dollars: f64) -> i64 { | |
| 154 | 191 | lines: &[LineRow], | |
| 155 | 192 | own: &[OwnRow], | |
| 156 | 193 | usage: &[UsageRow], | |
| 194 | + | internal: &BTreeSet<String>, | |
| 157 | 195 | ) -> (Vec<ProductDay>, Vec<WorkspaceDay>) { | |
| 158 | 196 | let mut days: BTreeMap<(String, String), ProductDay> = BTreeMap::new(); | |
| 159 | 197 | let entry = |day: &str, bucket: &str| -> ProductDay { | |
| 216 | 254 | let mut revenue: BTreeMap<(String, String, String), i64> = BTreeMap::new(); | |
| 217 | 255 | let mut valued: BTreeMap<(String, String, String), i64> = BTreeMap::new(); | |
| 218 | 256 | let mut active: BTreeMap<String, Vec<(String, f64)>> = BTreeMap::new(); | |
| 219 | − | let mut gave: BTreeMap<(String, String), (i64, i64)> = BTreeMap::new(); | |
| 257 | + | let mut gave: BTreeMap<(String, String), (Given, i64)> = BTreeMap::new(); | |
| 220 | 258 | for u in usage { | |
| 221 | 259 | let g = gave.entry((u.day.clone(), u.workspace.clone())).or_default(); | |
| 222 | − | g.0 += u.given; | |
| 260 | + | g.0.add(&u.given); | |
| 223 | 261 | g.1 += u.value; | |
| 224 | 262 | let bucket = bucket_of(&u.key); | |
| 225 | 263 | let key = (u.day.clone(), bucket.clone()); | |
| 234 | 272 | active.entry(u.day.clone()).or_default().push((u.workspace.clone(), u.value.max(u.cost) as f64)); | |
| 235 | 273 | } | |
| 236 | 274 | // Each bucket's cost shared out: by Cloudflare's own count per | |
| 237 | − | // workspace, else by g1t's own count of its units, else | |
| 238 | − | // by what each workspace was charged for it, else by what its usage | |
| 239 | − | // cost; running g1t, and what no one mapped, by each workspace's share | |
| 240 | − | // of all usage that day. | |
| 275 | + | // workspace, else by g1t's own count of its units, else by what its | |
| 276 | + | // usage cost (so free use carries its own cost), else by what it was | |
| 277 | + | // charged; running g1t, and what no one mapped, by each workspace's | |
| 278 | + | // share of all usage that day. | |
| 241 | 279 | let mut shares: BTreeMap<(String, String, String), i64> = BTreeMap::new(); | |
| 242 | 280 | for ((day, bucket), row) in &days { | |
| 243 | 281 | let key = (day.clone(), bucket.clone()); | |
| 245 | 283 | let weights = if OVERHEAD.contains(&bucket.as_str()) || bucket == UNMAPPED { | |
| 246 | 284 | active.get(day).cloned() | |
| 247 | 285 | } else { | |
| 248 | − | weigh(&cf_by).or_else(|| weigh(&own_by)).or_else(|| weigh(&value_by)).or_else(|| weigh(&cost_by)).or_else(|| active.get(day).cloned()) | |
| 286 | + | weigh(&cf_by).or_else(|| weigh(&own_by)).or_else(|| weigh(&cost_by)).or_else(|| weigh(&value_by)).or_else(|| active.get(day).cloned()) | |
| 249 | 287 | }; | |
| 250 | 288 | for (workspace, micros) in attribute(row.cost(), &weights.unwrap_or_default()) { | |
| 251 | 289 | *shares.entry((day.clone(), workspace, bucket.clone())).or_default() += micros; | |
| 256 | 294 | .into_iter() | |
| 257 | 295 | .map(|(day, workspace, bucket)| { | |
| 258 | 296 | 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, | |
| 297 | + | // The day's shares given away apply to every bucket, so a | |
| 298 | + | // comped workspace's part of running g1t is given too. A | |
| 299 | + | // workspace with nothing priced that day used g1t for free. | |
| 300 | + | let given = if internal.contains(&workspace) { | |
| 301 | + | Given { comped: cost, ..Given::default() } | |
| 302 | + | } else { | |
| 303 | + | match gave.get(&(day.clone(), workspace.clone())) { | |
| 304 | + | Some((given, value)) if *value > 0 => given.of(cost, *value), | |
| 305 | + | _ => Given { free: cost.max(0), ..Given::default() }, | |
| 306 | + | } | |
| 264 | 307 | }; | |
| 265 | 308 | WorkspaceDay { | |
| 266 | 309 | cost, | |
| 275 | 318 | .collect(); | |
| 276 | 319 | for w in &workspaces { | |
| 277 | 320 | if let Some(row) = days.get_mut(&(w.day.clone(), w.bucket.clone())) { | |
| 278 | − | row.given_micros += w.given; | |
| 321 | + | row.given.add(&w.given); | |
| 279 | 322 | } | |
| 280 | 323 | } | |
| 281 | 324 | (days.into_values().collect(), workspaces) | |
| 298 | 341 | }; | |
| 299 | 342 | let (cost, charge) = ((cost - before_cost).max(0), (charge - before_charge).max(0)); | |
| 300 | 343 | if cost > 0 || charge > 0 { | |
| 301 | − | out.push(UsageRow { day: day.clone(), workspace: workspace.clone(), key: source.clone(), value: charge, cash: charge, cost, given: 0 }); | |
| 344 | + | out.push(UsageRow { day: day.clone(), workspace: workspace.clone(), key: source.clone(), value: charge, cash: charge, cost, given: Given::default() }); | |
| 302 | 345 | } | |
| 303 | 346 | previous = Some(snap); | |
| 304 | 347 | } | |
| 596 | 639 | cf_quantity: f64, | |
| 597 | 640 | own_quantity: f64, | |
| 598 | 641 | #[serde(default)] | |
| 599 | − | given_micros: Option<i64>, | |
| 642 | + | given_comped_micros: Option<i64>, | |
| 643 | + | #[serde(default)] | |
| 644 | + | given_free_micros: Option<i64>, | |
| 645 | + | #[serde(default)] | |
| 646 | + | given_trial_micros: Option<i64>, | |
| 647 | + | #[serde(default)] | |
| 648 | + | given_pool_micros: Option<i64>, | |
| 600 | 649 | } | |
| 601 | 650 | ||
| 602 | 651 | impl From<MarginRow> for ProductDay { | |
| 610 | 659 | cash_micros: r.cash_micros, | |
| 611 | 660 | cf_quantity: r.cf_quantity, | |
| 612 | 661 | own_quantity: r.own_quantity, | |
| 613 | − | given_micros: r.given_micros.unwrap_or(0), | |
| 662 | + | given: Given { | |
| 663 | + | comped: r.given_comped_micros.unwrap_or(0), | |
| 664 | + | free: r.given_free_micros.unwrap_or(0), | |
| 665 | + | trial: r.given_trial_micros.unwrap_or(0), | |
| 666 | + | pool: r.given_pool_micros.unwrap_or(0), | |
| 667 | + | }, | |
| 614 | 668 | } | |
| 615 | 669 | } | |
| 616 | 670 | } | |
| 687 | 741 | own_provider: i64, | |
| 688 | 742 | cash: Option<i64>, | |
| 689 | 743 | drawn: Option<i64>, | |
| 690 | − | credit: Option<i64>, | |
| 744 | + | trial: Option<i64>, | |
| 745 | + | oss: Option<i64>, | |
| 746 | + | covered: Option<i64>, | |
| 691 | 747 | cost: Option<i64>, | |
| 692 | 748 | } | |
| 693 | 749 | let charged_here = crate::storage::CHARGED_HERE.iter().map(|s| format!("'{s}'")).collect::<Vec<_>>().join(", "); | |
| 701 | 757 | CASE WHEN billed_to = 'workspace' THEN 1 ELSE 0 END AS own_provider, | |
| 702 | 758 | -SUM(amount_micros) AS cash, | |
| 703 | 759 | 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, | |
| 760 | + | SUM(COALESCE(trial_micros, 0)) AS trial, | |
| 761 | + | SUM(COALESCE(oss_micros, 0)) AS oss, | |
| 762 | + | SUM(COALESCE(given_micros, 0)) AS covered, | |
| 705 | 763 | SUM(COALESCE(cost_micros, 0)) AS cost | |
| 706 | 764 | FROM ledger | |
| 707 | 765 | WHERE kind = 'usage' AND created_at >= ?1 AND created_at <= ?2 AND COALESCE(task, '') NOT IN ({charged_here}) | |
| 722 | 780 | let cash = r.cash.unwrap_or(0); | |
| 723 | 781 | let paid = cash + r.drawn.unwrap_or(0); | |
| 724 | 782 | let value = usage_value(r.internal == 1, cost, paid, self.margin_percent); | |
| 725 | − | let given = if r.internal == 1 { value } else { (value - cash - r.credit.unwrap_or(0)).max(0) }; | |
| 783 | + | let given = if r.internal == 1 { | |
| 784 | + | Given { comped: value, ..Given::default() } | |
| 785 | + | } else if paid == 0 && cost > 0 { | |
| 786 | + | Given { free: value, ..Given::default() } | |
| 787 | + | } else { | |
| 788 | + | Given { free: r.covered.unwrap_or(0), trial: r.trial.unwrap_or(0), pool: r.oss.unwrap_or(0), comped: 0 } | |
| 789 | + | }; | |
| 726 | 790 | if r.internal == 1 { | |
| 727 | 791 | internal.insert(r.workspace.clone()); | |
| 728 | 792 | } | |
| 751 | 815 | .collect::<Vec<_>>(); | |
| 752 | 816 | out.extend(pending_deltas(&snaps).into_iter().filter(|u| u.day.as_str() >= since).map(|mut u| { | |
| 753 | 817 | if internal.contains(&u.workspace) { | |
| 754 | − | u.given = u.value; | |
| 818 | + | u.given = Given { comped: u.value, ..Given::default() }; | |
| 755 | 819 | } | |
| 756 | 820 | u | |
| 757 | 821 | })); | |
| 776 | 840 | for p in plans { | |
| 777 | 841 | for (day, micros) in spread(&p.day, p.micros.unwrap_or(0), PLAN_DAYS) { | |
| 778 | 842 | if day.as_str() >= since && day.as_str() <= until { | |
| 779 | − | out.push(UsageRow { day, workspace: p.workspace.clone(), key: "plan".into(), value: micros, cash: micros, cost: 0, given: 0 }); | |
| 843 | + | out.push(UsageRow { day, workspace: p.workspace.clone(), key: "plan".into(), value: micros, cash: micros, cost: 0, given: Given::default() }); | |
| 780 | 844 | } | |
| 781 | 845 | } | |
| 782 | 846 | } | |
| 815 | 879 | .await? | |
| 816 | 880 | .results::<OwnRow>()?; | |
| 817 | 881 | let usage = self.usage_rows(since, until).await?; | |
| 818 | − | let (days, workspaces) = fold(&rules, &revenue_map, &lines, &own, &usage); | |
| 882 | + | #[derive(Deserialize)] | |
| 883 | + | struct Internal { | |
| 884 | + | workspace: String, | |
| 885 | + | } | |
| 886 | + | let internal: BTreeSet<String> = self | |
| 887 | + | .db | |
| 888 | + | .prepare(format!("WITH i(workspace) AS ({}) SELECT DISTINCT workspace FROM i", crate::sales::INTERNAL_SQL)) | |
| 889 | + | .all() | |
| 890 | + | .await? | |
| 891 | + | .results::<Internal>()? | |
| 892 | + | .into_iter() | |
| 893 | + | .map(|i| i.workspace) | |
| 894 | + | .collect(); | |
| 895 | + | let (days, workspaces) = fold(&rules, &revenue_map, &lines, &own, &usage, &internal); | |
| 819 | 896 | let now = rfc3339(now_ms()); | |
| 820 | 897 | self.db | |
| 821 | 898 | .batch(vec![ | |
| 829 | 906 | statements.push( | |
| 830 | 907 | self.db | |
| 831 | 908 | .prepare( | |
| 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 909 | + | "INSERT OR REPLACE INTO margin_days (day, bucket, cf_cost_micros, own_cost_micros, value_micros, cash_micros, cf_quantity, own_quantity, given_micros, given_comped_micros, given_free_micros, given_trial_micros, given_pool_micros, computed_at) | |
| 910 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 834 | 911 | ) | |
| 835 | 912 | .bind(&[ | |
| 836 | 913 | d.day.as_str().into(), | |
| 841 | 918 | (d.cash_micros as f64).into(), | |
| 842 | 919 | d.cf_quantity.into(), | |
| 843 | 920 | d.own_quantity.into(), | |
| 844 | − | (d.given_micros as f64).into(), | |
| 921 | + | (d.given.total() as f64).into(), | |
| 922 | + | (d.given.comped as f64).into(), | |
| 923 | + | (d.given.free as f64).into(), | |
| 924 | + | (d.given.trial as f64).into(), | |
| 925 | + | (d.given.pool as f64).into(), | |
| 845 | 926 | now.as_str().into(), | |
| 846 | 927 | ])?, | |
| 847 | 928 | ); | |
| 861 | 942 | (w.cost as f64).into(), | |
| 862 | 943 | (w.revenue as f64).into(), | |
| 863 | 944 | (w.value as f64).into(), | |
| 864 | − | (w.given as f64).into(), | |
| 945 | + | (w.given.total() as f64).into(), | |
| 865 | 946 | ])?, | |
| 866 | 947 | ); | |
| 867 | 948 | } | |
| 1038 | 1119 | // trial and the pools) is a budget it chose to spend, watched on | |
| 1039 | 1120 | // its own (budget.rs): not part of whether what is sold pays. | |
| 1040 | 1121 | overall.0 += d.cash_micros; | |
| 1041 | − | overall.1 += (d.cost() - d.given_micros).max(0); | |
| 1122 | + | overall.1 += (d.cost() - d.given.total()).max(0); | |
| 1042 | 1123 | if !OVERHEAD.contains(&d.bucket.as_str()) && d.bucket != UNMAPPED { | |
| 1043 | 1124 | by.entry(d.bucket.clone()).or_default().push((d.day.clone(), d.value_micros, d.cost())); | |
| 1044 | 1125 | } | |
| 1334 | 1415 | p.value_micros += d.value_micros; | |
| 1335 | 1416 | p.cost_micros += d.cost(); | |
| 1336 | 1417 | overall.cost_micros += d.cost(); | |
| 1337 | − | overall.given_micros += d.given_micros; | |
| 1338 | − | if d.bucket == "platform" { | |
| 1418 | + | overall.given_micros += d.given.total(); | |
| 1419 | + | if NOT_CLOUDFLARE.contains(&d.bucket.as_str()) { | |
| 1420 | + | overall.models_cost_micros += d.cost(); | |
| 1421 | + | } else { | |
| 1422 | + | overall.cloudflare_cost_micros += d.cost(); | |
| 1423 | + | } | |
| 1424 | + | overall.given_comped_micros += d.given.comped; | |
| 1425 | + | overall.given_free_micros += d.given.free; | |
| 1426 | + | overall.given_trial_micros += d.given.trial; | |
| 1427 | + | overall.given_pool_micros += d.given.pool; | |
| 1428 | + | let sold = (d.cost() - d.given.total()).max(0); | |
| 1429 | + | if OVERHEAD.contains(&d.bucket.as_str()) { | |
| 1339 | 1430 | overall.plans_micros += d.cash_micros; | |
| 1431 | + | overall.running_cost_micros += sold; | |
| 1432 | + | } else if d.bucket == UNMAPPED { | |
| 1433 | + | overall.usage_micros += d.cash_micros; | |
| 1434 | + | overall.unmapped_cost_micros += sold; | |
| 1340 | 1435 | } else { | |
| 1341 | 1436 | overall.usage_micros += d.cash_micros; | |
| 1437 | + | overall.usage_cost_micros += sold; | |
| 1342 | 1438 | } | |
| 1343 | 1439 | } | |
| 1344 | 1440 | for p in products.values_mut() { | |
| 1351 | 1447 | let sold = (overall.cost_micros - overall.given_micros).max(0); | |
| 1352 | 1448 | overall.sold_margin_micros = revenue - sold; | |
| 1353 | 1449 | overall.sold_margin_percent = margin_percent(revenue, sold); | |
| 1450 | + | overall.usage_margin_micros = overall.usage_micros - overall.usage_cost_micros; | |
| 1451 | + | overall.usage_margin_percent = margin_percent(overall.usage_micros, overall.usage_cost_micros); | |
| 1354 | 1452 | let mut products: Vec<ProductMargin> = products.into_values().collect(); | |
| 1355 | 1453 | products.sort_by_key(|p| std::cmp::Reverse(p.cost_micros.max(p.value_micros))); | |
| 1356 | 1454 | ||
| 1559 | 1657 | } | |
| 1560 | 1658 | ||
| 1561 | 1659 | fn usage(day: &str, workspace: &str, key: &str, value: i64, cash: i64, cost: i64) -> UsageRow { | |
| 1562 | − | UsageRow { day: day.into(), workspace: workspace.into(), key: key.into(), value, cash, cost, given: 0 } | |
| 1660 | + | UsageRow { day: day.into(), workspace: workspace.into(), key: key.into(), value, cash, cost, given: Given::default() } | |
| 1563 | 1661 | } | |
| 1564 | 1662 | ||
| 1565 | 1663 | #[test] | |
| 1583 | 1681 | usage("2026-10-15", "acme", "implement", 120_000, 120_000, 100_000), | |
| 1584 | 1682 | usage("2026-10-15", "beta", "plan", 20_000_000, 20_000_000, 0), | |
| 1585 | 1683 | ]; | |
| 1586 | − | let (days, workspaces) = fold(&rules(), &revenue_map(), &lines, &own, &usage); | |
| 1684 | + | let (days, workspaces) = fold(&rules(), &revenue_map(), &lines, &own, &usage, &BTreeSet::new()); | |
| 1587 | 1685 | let get = |bucket: &str| days.iter().find(|d| d.bucket == bucket).unwrap(); | |
| 1588 | 1686 | let sandboxes = get("sandboxes"); | |
| 1589 | 1687 | assert_eq!((sandboxes.cf_cost_micros, sandboxes.own_cost_micros, sandboxes.value_micros, sandboxes.cash_micros), (2_000_000, 3_000_000, 3_600_000, 2_200_000)); | |
| 1614 | 1712 | line("2026-10-05", SOURCE_ARTIFACTS, "artifacts", "events_push", 30.0, 0.0), | |
| 1615 | 1713 | line("2026-10-05", SOURCE_ARTIFACTS, "artifacts", "events_ratelimited", 9.0, 0.0), | |
| 1616 | 1714 | ]; | |
| 1617 | − | let (days, _) = fold(&rules(), &revenue_map(), &lines, &[], &[]); | |
| 1715 | + | let (days, _) = fold(&rules(), &revenue_map(), &lines, &[], &[], &BTreeSet::new()); | |
| 1618 | 1716 | assert_eq!(days[0].cf_quantity, 150.0); | |
| 1619 | 1717 | assert_eq!(days[0].cf_cost_micros, 0); | |
| 1620 | 1718 | } | |
| 1651 | 1749 | } | |
| 1652 | 1750 | ||
| 1653 | 1751 | fn day(bucket: &str, cf: i64, own: i64, value: i64, cfq: f64, ownq: f64) -> ProductDay { | |
| 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 } | |
| 1752 | + | 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: Given::default() } | |
| 1655 | 1753 | } | |
| 1656 | 1754 | ||
| 1657 | 1755 | #[test] | |
| 1710 | 1808 | let map = BTreeMap::new(); | |
| 1711 | 1809 | // A comped workspace (all of it given), one in its trial (half paid | |
| 1712 | 1810 | // 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; | |
| 1811 | + | let comped = usage("2026-10-15", "flagon", "agent", 1_200_000, 0, 1_000_000); | |
| 1715 | 1812 | let mut trial = usage("2026-10-15", "acme", "agent", 1_200_000, 600_000, 1_000_000); | |
| 1716 | − | trial.given = 600_000; | |
| 1813 | + | trial.given = Given { trial: 600_000, ..Given::default() }; | |
| 1717 | 1814 | 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]); | |
| 1815 | + | // Nothing priced that day: free use. | |
| 1816 | + | let free = usage("2026-10-15", "gamma", "agent", 0, 0, 1_000_000); | |
| 1817 | + | let internal = BTreeSet::from(["flagon".to_string()]); | |
| 1818 | + | let (days, workspaces) = fold(&[], &map, &[], &[], &[comped, trial, paying, free], &internal); | |
| 1719 | 1819 | 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)); | |
| 1820 | + | assert_eq!(models.cost(), 4_000_000); | |
| 1821 | + | assert_eq!(models.given, Given { comped: 1_000_000, free: 1_000_000, trial: 500_000, pool: 0 }); | |
| 1822 | + | let given = |w: &str| workspaces.iter().find(|x| x.workspace == w).unwrap().given.total(); | |
| 1823 | + | assert_eq!((given("flagon"), given("acme"), given("beta"), given("gamma")), (1_000_000, 500_000, 0, 1_000_000)); | |
| 1723 | 1824 | } | |
| 1724 | 1825 | ||
| 1725 | 1826 | #[test] |