Skip to content
208 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.1//! What a workspace is charged in a month: one definition, used by the
2//! spend limit (`limits::limit_with`, the `spent_micros` every signed-in
3//! page shows), the Usage page and the Billing page's plan card
4//! (`report::usage_report`), and so by Spend's "Charged" tile and the top
5//! bar, which read the limit.
6//!
7//! **Charged** is usage at price, less the account's discount, less what
8//! the plan's included usage, the trial, the open-source pool or g1t paid,
9//! less what credit paid; never below zero. Every ledger line carries each
10//! part (`statement::PRICE_SQL` reads the price back from them), and usage
11//! metered through the month and charged when it closes (`pending_usage`:
12//! storage, git operations, scans, embeddings, domains, app traffic) counts
13//! now on the same terms it will be entered on: at cost plus the margin,
14//! with the discount off, and nothing for a source g1t covers on a free
15//! workspace. So a workspace with a 100% discount is charged exactly
16//! nothing, however much is pending.
17
18use g1t_contracts::billing::UsageTotals;
19use serde::Deserialize;
20use worker::wasm_bindgen::JsValue;
21use worker::Result;
22
23use crate::Billing;
24use crate::credits::with_margin;
25
26/// What is left to pay: usage at price less the discount, included usage
27/// and credit. The one arithmetic for "charged".
28pub(crate) fn charged(totals: &UsageTotals) -> i64 {
29 (totals.price_micros - totals.discount_micros - totals.included_micros - totals.credits_micros).max(0)
30}
31
32/// A month-end source's usage so far this month, metered and not charged
33/// yet (`pending_usage`).
34#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
35pub(crate) struct PendingLine {
36 pub source: String,
37 /// What it cost g1t so far.
38 #[serde(default)]
39 pub cost_micros: Option<f64>,
40 /// What it will be charged before the account's terms: at cost plus
41 /// the margin, or nothing for a source g1t covers on a free workspace
42 /// (`storage::set_pending`).
43 #[serde(default)]
44 pub charge_micros: Option<f64>,
45}
46
47impl PendingLine {
48 pub fn cost(&self) -> i64 {
49 self.cost_micros.unwrap_or(0.0).round().max(0.0) as i64
50 }
51
52 pub fn charge(&self) -> i64 {
53 self.charge_micros.unwrap_or(0.0).round().max(0.0) as i64
54 }
55}
56
57/// A pending line's parts, as the month's close will enter them: its
58/// price, what g1t covers of it, and what the discount takes off the rest.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60pub(crate) struct PendingSplit {
61 pub price: i64,
62 pub covered: i64,
63 pub discount: i64,
64}
65
66impl PendingSplit {
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.67 /// What the workspace will be charged for it when the month closes.
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.68 pub fn charged(&self) -> i64 {
69 self.price - self.covered - self.discount
70 }
71}
72
73/// Splits a pending line: `cost` at cost plus `margin_percent` is its
74/// price; `charge` (what it will be charged before terms) below that is
75/// covered by g1t; `percent_off` comes off what is charged.
76pub(crate) fn pending_split(cost: i64, charge: i64, margin_percent: u32, percent_off: u32) -> PendingSplit {
77 let price = with_margin(cost.max(0), margin_percent);
78 let base = charge.clamp(0, price);
79 PendingSplit { price, covered: price - base, discount: base * i64::from(percent_off.min(100)) / 100 }
80}
81
82/// Counts a pending line into the totals: at price (and as pending), with
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.83/// what g1t covers as included, the discount as discount, and what is
84/// left as what the close will charge.
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.85pub(crate) fn add_pending(totals: &mut UsageTotals, split: &PendingSplit) {
86 totals.price_micros += split.price;
87 totals.pending_micros += split.price;
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.88 totals.pending_charged_micros += split.charged();
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.89 totals.included_micros += split.covered;
90 totals.discount_micros += split.discount;
91}
92
93/// The month's totals from its ledger lines and pending usage, and what
94/// credit paid, with `charged_micros` worked out.
95pub(crate) fn month_totals(price: i64, discount: i64, covered: i64, credits: i64, pending: &[PendingSplit]) -> UsageTotals {
96 let mut totals = UsageTotals {
97 price_micros: price,
98 discount_micros: discount,
99 included_micros: covered,
100 credits_micros: credits.max(0),
101 ..UsageTotals::default()
102 };
103 for split in pending {
104 add_pending(&mut totals, split);
105 }
106 totals.charged_micros = charged(&totals);
107 totals
108}
109
110impl Billing {
111 /// What the workspaces have metered this month (`YYYY-MM`) that is
112 /// charged when it closes, not charged yet.
113 pub(crate) async fn pending_this_month(&self, members: &[JsValue], month: &str) -> Result<Vec<PendingLine>> {
114 let marks = vec!["?"; members.len().max(1)].join(", ");
115 let mut args = members.to_vec();
116 args.push(month.into());
117 self.db
118 .prepare(format!(
119 "SELECT source, cost_micros, charge_micros FROM pending_usage
120 WHERE workspace IN ({marks}) AND month = ? AND charged_at IS NULL"
121 ))
122 .bind(&args)?
123 .all()
124 .await?
125 .results::<PendingLine>()
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 const MARGIN: u32 = 20;
134
135 #[test]
136 fn a_full_discount_is_charged_exactly_nothing_pending_usage_included() {
137 // flagon-io, October 2026: $10.29 of usage at price on the ledger,
138 // every line discounted in full; and $0.0075 of storage metered so
139 // far, which the limit used to count at its charge, $0.009.
140 let pending = [pending_split(7_500, with_margin(7_500, MARGIN), MARGIN, 100)];
141 assert_eq!(pending[0], PendingSplit { price: 9_000, covered: 0, discount: 9_000 });
142 let totals = month_totals(10_290_000, 10_290_000, 0, 0, &pending);
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.143 assert_eq!((totals.pending_micros, totals.pending_charged_micros), (9_000, 0));
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.144 assert_eq!(totals.price_micros, 10_299_000);
145 assert_eq!(totals.discount_micros, 10_299_000);
146 assert_eq!(totals.charged_micros, 0);
147 assert_eq!(charged(&totals), 0);
148 }
149
150 #[test]
151 fn included_usage_pays_first_and_only_the_rest_is_charged() {
152 // The plan: $10 included. $12.50 of runs at price, $10 of it drawn
153 // from the included usage on the lines themselves; $0.60 of storage
154 // pending, which the close will charge in full (the included usage
155 // is spent).
156 let pending = [pending_split(500_000, with_margin(500_000, MARGIN), MARGIN, 0)];
157 assert_eq!(pending[0], PendingSplit { price: 600_000, covered: 0, discount: 0 });
158 let totals = month_totals(12_500_000, 0, 10_000_000, 0, &pending);
159 assert_eq!(totals.included_micros, 10_000_000);
160 assert_eq!(totals.charged_micros, 2_500_000 + 600_000);
161 // Credit from g1t pays after that.
162 let with_credit = month_totals(12_500_000, 0, 10_000_000, 1_000_000, &pending);
163 assert_eq!(with_credit.charged_micros, 2_100_000);
164 // Never below zero: more credit than usage charges nothing.
165 assert_eq!(month_totals(1_000_000, 0, 0, 5_000_000, &[]).charged_micros, 0);
166 }
167
168 #[test]
169 fn pay_as_you_go_is_charged_at_price() {
170 // No discount, nothing included, no credit: price is what is
171 // charged, pending usage counted at its price too.
172 let pending = [pending_split(1_000_000, with_margin(1_000_000, MARGIN), MARGIN, 0), pending_split(250, 300, MARGIN, 0)];
173 let totals = month_totals(4_000_000, 0, 0, 0, &pending);
174 assert_eq!(totals.pending_micros, 1_200_000 + 300);
175 assert_eq!(totals.charged_micros, 4_000_000 + 1_200_000 + 300);
176 }
177
178 #[test]
179 fn a_partial_discount_comes_off_pending_usage_as_the_close_will_take_it() {
180 // 30% off: $0.009 of pending storage is charged $0.0063.
181 let split = pending_split(7_500, 9_000, MARGIN, 30);
182 assert_eq!(split, PendingSplit { price: 9_000, covered: 0, discount: 2_700 });
183 assert_eq!(split.charged(), 6_300);
184 }
185
186 #[test]
187 fn what_g1t_covers_on_a_free_workspace_is_included_not_charged() {
188 // Security scans on a free workspace: metered at cost, charged
189 // nothing (`set_pending` writes a charge of 0), shown at price.
190 let split = pending_split(2_000, 0, MARGIN, 0);
191 assert_eq!(split, PendingSplit { price: 2_400, covered: 2_400, discount: 0 });
192 assert_eq!(split.charged(), 0);
193 let totals = month_totals(0, 0, 0, 0, &[split]);
194 assert_eq!(totals.price_micros, 2_400);
195 assert_eq!(totals.included_micros, 2_400);
196 assert_eq!(totals.charged_micros, 0);
197 // A charge above the price (an older margin) is held to the price.
198 assert_eq!(pending_split(1_000, 5_000, MARGIN, 0).covered, 0);
199 }
200
201 #[test]
202 fn a_pending_row_reads_whole_micros_and_never_less_than_nothing() {
203 let line = PendingLine { source: "storage".into(), cost_micros: Some(7_499.6), charge_micros: Some(-1.0) };
204 assert_eq!(line.cost(), 7_500);
205 assert_eq!(line.charge(), 0);
206 assert_eq!(PendingLine::default().cost(), 0);
207 }
208}