Skip to content
208 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 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 {
67 /// What the workspace will be charged for it when the month closes.
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
83/// what g1t covers as included, the discount as discount, and what is
84/// left as what the close will charge.
85pub(crate) fn add_pending(totals: &mut UsageTotals, split: &PendingSplit) {
86 totals.price_micros += split.price;
87 totals.pending_micros += split.price;
88 totals.pending_charged_micros += split.charged();
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);
143 assert_eq!((totals.pending_micros, totals.pending_charged_micros), (9_000, 0));
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}