Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
280 linesCodeBlameRaw
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 /// Whose it is: the plan's included usage is each workspace's own.
37 #[serde(default)]
38 pub workspace: String,
39 pub source: String,
40 /// What it cost g1t so far.
41 #[serde(default)]
42 pub cost_micros: Option<f64>,
43 /// What it will be charged before the account's terms: at cost plus
44 /// the margin, or nothing for a source g1t covers on a free workspace
45 /// (`storage::set_pending`).
46 #[serde(default)]
47 pub charge_micros: Option<f64>,
48}
49
50impl PendingLine {
51 pub fn cost(&self) -> i64 {
52 self.cost_micros.unwrap_or(0.0).round().max(0.0) as i64
53 }
54
55 pub fn charge(&self) -> i64 {
56 self.charge_micros.unwrap_or(0.0).round().max(0.0) as i64
57 }
58}
59
60/// A pending line's parts, as the month's close will enter them: its
61/// price, what g1t covers of it, and what the discount takes off the rest.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
63pub(crate) struct PendingSplit {
64 pub price: i64,
65 pub covered: i64,
66 pub discount: i64,
67}
68
69impl PendingSplit {
70 /// What the workspace will be charged for it when the month closes.
71 pub fn charged(&self) -> i64 {
72 self.price - self.covered - self.discount
73 }
74}
75
76/// Splits a pending line: `cost` at cost plus `margin_percent` is its
77/// price; `charge` (what it will be charged before terms) below that is
78/// covered by g1t; `percent_off` comes off what is charged.
79pub(crate) fn pending_split(cost: i64, charge: i64, margin_percent: u32, percent_off: u32) -> PendingSplit {
80 let price = with_margin(cost.max(0), margin_percent);
81 let base = charge.clamp(0, price);
82 PendingSplit { price, covered: price - base, discount: base * i64::from(percent_off.min(100)) / 100 }
83}
84
85/// Pays pending lines from the plan's included usage still `left` this
86/// month, in order, as the close will (`draw` pays from it first): what it
87/// pays is included, not charged. Returns what it paid of each.
88pub(crate) fn cover_with_included(splits: &mut [PendingSplit], left: i64) -> Vec<i64> {
89 let mut left = left.max(0);
90 splits
91 .iter_mut()
92 .map(|split| {
93 let pay = split.charged().clamp(0, left);
94 split.covered += pay;
95 left -= pay;
96 pay
97 })
98 .collect()
99}
100
101/// Counts a pending line into the totals: at price (and as pending), with
102/// what g1t covers as included, the discount as discount, and what is
103/// left as what the close will charge.
104pub(crate) fn add_pending(totals: &mut UsageTotals, split: &PendingSplit) {
105 totals.price_micros += split.price;
106 totals.pending_micros += split.price;
107 totals.pending_charged_micros += split.charged();
108 totals.included_micros += split.covered;
109 totals.discount_micros += split.discount;
110}
111
112/// The month's totals from its ledger lines and pending usage, and what
113/// credit paid, with `charged_micros` worked out.
114pub(crate) fn month_totals(price: i64, discount: i64, covered: i64, credits: i64, pending: &[PendingSplit]) -> UsageTotals {
115 let mut totals = UsageTotals {
116 price_micros: price,
117 discount_micros: discount,
118 included_micros: covered,
119 credits_micros: credits.max(0),
120 ..UsageTotals::default()
121 };
122 for split in pending {
123 add_pending(&mut totals, split);
124 }
125 totals.charged_micros = charged(&totals);
126 totals
127}
128
129/// The pending lines of the workspaces bound at `marks` (then the month),
130/// net of what was metered before a test-mode detach.
131pub(crate) fn pending_sql(marks: &str) -> String {
132 format!(
133 "SELECT workspace, source, {} AS cost_micros, {} AS charge_micros FROM pending_usage
134 WHERE workspace IN ({marks}) AND month = ? AND charged_at IS NULL",
135 crate::storage::NET_COST,
136 crate::storage::NET_CHARGE
137 )
138}
139
140impl Billing {
141 /// What the workspaces have metered this month (`YYYY-MM`) that is
142 /// charged when it closes, not charged yet: less what was metered
143 /// before a test-mode detach (`pending_baselines`), which is never
144 /// charged, so the limit and the Usage page do not count it either.
145 pub(crate) async fn pending_this_month(&self, members: &[JsValue], month: &str) -> Result<Vec<PendingLine>> {
146 let marks = vec!["?"; members.len().max(1)].join(", ");
147 let mut args = members.to_vec();
148 args.push(month.into());
149 self.db
150 .prepare(pending_sql(&marks))
151 .bind(&args)?
152 .all()
153 .await?
154 .results::<PendingLine>()
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn pending_usage_is_valued_less_what_was_metered_before_a_detach() {
164 let sql = pending_sql("?");
165 for column in ["cost_micros", "charge_micros"] {
166 assert!(
167 sql.contains(&format!("MAX(0, COALESCE(pending_usage.{column}, 0) - COALESCE((SELECT b.{column} FROM pending_baselines b")),
168 "{column}: {sql}"
169 );
170 }
171 // Matched on the row's own workspace, source and month, so the
172 // FROM must stay unaliased.
173 assert!(sql.contains("FROM pending_usage\n"));
174 assert!(crate::storage::NET_COST.contains("b.month = pending_usage.month") && crate::storage::NET_COST.contains("b.source = pending_usage.source"));
175 }
176
177 const MARGIN: u32 = 20;
178
179 #[test]
180 fn a_full_discount_is_charged_exactly_nothing_pending_usage_included() {
181 // flagon-io, October 2026: $10.29 of usage at price on the ledger,
182 // every line discounted in full; and $0.0075 of storage metered so
183 // far, which the limit used to count at its charge, $0.009.
184 let pending = [pending_split(7_500, with_margin(7_500, MARGIN), MARGIN, 100)];
185 assert_eq!(pending[0], PendingSplit { price: 9_000, covered: 0, discount: 9_000 });
186 let totals = month_totals(10_290_000, 10_290_000, 0, 0, &pending);
187 assert_eq!((totals.pending_micros, totals.pending_charged_micros), (9_000, 0));
188 assert_eq!(totals.price_micros, 10_299_000);
189 assert_eq!(totals.discount_micros, 10_299_000);
190 assert_eq!(totals.charged_micros, 0);
191 assert_eq!(charged(&totals), 0);
192 }
193
194 #[test]
195 fn included_usage_pays_first_and_only_the_rest_is_charged() {
196 // The plan: $10 included. $12.50 of runs at price, $10 of it drawn
197 // from the included usage on the lines themselves; $0.60 of storage
198 // pending, which the close will charge in full (the included usage
199 // is spent).
200 let pending = [pending_split(500_000, with_margin(500_000, MARGIN), MARGIN, 0)];
201 assert_eq!(pending[0], PendingSplit { price: 600_000, covered: 0, discount: 0 });
202 let totals = month_totals(12_500_000, 0, 10_000_000, 0, &pending);
203 assert_eq!(totals.included_micros, 10_000_000);
204 assert_eq!(totals.charged_micros, 2_500_000 + 600_000);
205 // Credit from g1t pays after that.
206 let with_credit = month_totals(12_500_000, 0, 10_000_000, 1_000_000, &pending);
207 assert_eq!(with_credit.charged_micros, 2_100_000);
208 // Never below zero: more credit than usage charges nothing.
209 assert_eq!(month_totals(1_000_000, 0, 0, 5_000_000, &[]).charged_micros, 0);
210 // Spent: nothing left to pay pending usage, which stays charged.
211 let mut spent = pending.to_vec();
212 assert_eq!(cover_with_included(&mut spent, 0), vec![0]);
213 assert_eq!(spent, pending.to_vec());
214 }
215
216 #[test]
217 fn pending_usage_is_paid_from_the_included_usage_still_left() {
218 // The plan, $10 included, $9.80 of it drawn on the ledger: $0.20
219 // left. $0.60 of storage pending is paid $0.20 by it at the close,
220 // so only $0.40 counts against the spend limit now, not $0.60.
221 let mut pending = vec![pending_split(500_000, with_margin(500_000, MARGIN), MARGIN, 0)];
222 assert_eq!(cover_with_included(&mut pending, 200_000), vec![200_000]);
223 assert_eq!(pending[0], PendingSplit { price: 600_000, covered: 200_000, discount: 0 });
224 let totals = month_totals(9_800_000, 0, 9_800_000, 0, &pending);
225 assert_eq!(totals.charged_micros, 400_000);
226 assert_eq!(totals.included_micros, 10_000_000);
227 // Untouched included usage pays it all, in order, never past a line.
228 let mut two = vec![pending_split(500_000, 600_000, MARGIN, 0), pending_split(250, 300, MARGIN, 0)];
229 assert_eq!(cover_with_included(&mut two, 10_000_000), vec![600_000, 300]);
230 assert_eq!(month_totals(0, 0, 0, 0, &two).charged_micros, 0);
231 // It pays after the discount, as the close draws: 50% off $0.60
232 // leaves $0.30 for it to pay.
233 let mut half = vec![pending_split(500_000, 600_000, MARGIN, 50)];
234 assert_eq!(cover_with_included(&mut half, 10_000_000), vec![300_000]);
235 assert_eq!(half[0].charged(), 0);
236 // Nothing negative is ever paid or left.
237 assert_eq!(cover_with_included(&mut [pending_split(0, 0, MARGIN, 0)], -5), vec![0]);
238 }
239
240 #[test]
241 fn pay_as_you_go_is_charged_at_price() {
242 // No discount, nothing included, no credit: price is what is
243 // charged, pending usage counted at its price too.
244 let pending = [pending_split(1_000_000, with_margin(1_000_000, MARGIN), MARGIN, 0), pending_split(250, 300, MARGIN, 0)];
245 let totals = month_totals(4_000_000, 0, 0, 0, &pending);
246 assert_eq!(totals.pending_micros, 1_200_000 + 300);
247 assert_eq!(totals.charged_micros, 4_000_000 + 1_200_000 + 300);
248 }
249
250 #[test]
251 fn a_partial_discount_comes_off_pending_usage_as_the_close_will_take_it() {
252 // 30% off: $0.009 of pending storage is charged $0.0063.
253 let split = pending_split(7_500, 9_000, MARGIN, 30);
254 assert_eq!(split, PendingSplit { price: 9_000, covered: 0, discount: 2_700 });
255 assert_eq!(split.charged(), 6_300);
256 }
257
258 #[test]
259 fn what_g1t_covers_on_a_free_workspace_is_included_not_charged() {
260 // Security scans on a free workspace: metered at cost, charged
261 // nothing (`set_pending` writes a charge of 0), shown at price.
262 let split = pending_split(2_000, 0, MARGIN, 0);
263 assert_eq!(split, PendingSplit { price: 2_400, covered: 2_400, discount: 0 });
264 assert_eq!(split.charged(), 0);
265 let totals = month_totals(0, 0, 0, 0, &[split]);
266 assert_eq!(totals.price_micros, 2_400);
267 assert_eq!(totals.included_micros, 2_400);
268 assert_eq!(totals.charged_micros, 0);
269 // A charge above the price (an older margin) is held to the price.
270 assert_eq!(pending_split(1_000, 5_000, MARGIN, 0).covered, 0);
271 }
272
273 #[test]
274 fn a_pending_row_reads_whole_micros_and_never_less_than_nothing() {
275 let line = PendingLine { source: "storage".into(), cost_micros: Some(7_499.6), charge_micros: Some(-1.0), ..PendingLine::default() };
276 assert_eq!(line.cost(), 7_500);
277 assert_eq!(line.charge(), 0);
278 assert_eq!(PendingLine::default().cost(), 0);
279 }
280}