Skip to content
206 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.
68 #[cfg(test)]
69 pub fn charged(&self) -> i64 {
70 self.price - self.covered - self.discount
71 }
72}
73
74/// Splits a pending line: `cost` at cost plus `margin_percent` is its
75/// price; `charge` (what it will be charged before terms) below that is
76/// covered by g1t; `percent_off` comes off what is charged.
77pub(crate) fn pending_split(cost: i64, charge: i64, margin_percent: u32, percent_off: u32) -> PendingSplit {
78 let price = with_margin(cost.max(0), margin_percent);
79 let base = charge.clamp(0, price);
80 PendingSplit { price, covered: price - base, discount: base * i64::from(percent_off.min(100)) / 100 }
81}
82
83/// Counts a pending line into the totals: at price (and as pending), with
84/// what g1t covers as included and the discount as discount.
85pub(crate) fn add_pending(totals: &mut UsageTotals, split: &PendingSplit) {
86 totals.price_micros += split.price;
87 totals.pending_micros += split.price;
88 totals.included_micros += split.covered;
89 totals.discount_micros += split.discount;
90}
91
92/// The month's totals from its ledger lines and pending usage, and what
93/// credit paid, with `charged_micros` worked out.
94pub(crate) fn month_totals(price: i64, discount: i64, covered: i64, credits: i64, pending: &[PendingSplit]) -> UsageTotals {
95 let mut totals = UsageTotals {
96 price_micros: price,
97 discount_micros: discount,
98 included_micros: covered,
99 credits_micros: credits.max(0),
100 ..UsageTotals::default()
101 };
102 for split in pending {
103 add_pending(&mut totals, split);
104 }
105 totals.charged_micros = charged(&totals);
106 totals
107}
108
109impl Billing {
110 /// What the workspaces have metered this month (`YYYY-MM`) that is
111 /// charged when it closes, not charged yet.
112 pub(crate) async fn pending_this_month(&self, members: &[JsValue], month: &str) -> Result<Vec<PendingLine>> {
113 let marks = vec!["?"; members.len().max(1)].join(", ");
114 let mut args = members.to_vec();
115 args.push(month.into());
116 self.db
117 .prepare(format!(
118 "SELECT source, cost_micros, charge_micros FROM pending_usage
119 WHERE workspace IN ({marks}) AND month = ? AND charged_at IS NULL"
120 ))
121 .bind(&args)?
122 .all()
123 .await?
124 .results::<PendingLine>()
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 const MARGIN: u32 = 20;
133
134 #[test]
135 fn a_full_discount_is_charged_exactly_nothing_pending_usage_included() {
136 // flagon-io, October 2026: $10.29 of usage at price on the ledger,
137 // every line discounted in full; and $0.0075 of storage metered so
138 // far, which the limit used to count at its charge, $0.009.
139 let pending = [pending_split(7_500, with_margin(7_500, MARGIN), MARGIN, 100)];
140 assert_eq!(pending[0], PendingSplit { price: 9_000, covered: 0, discount: 9_000 });
141 let totals = month_totals(10_290_000, 10_290_000, 0, 0, &pending);
142 assert_eq!(totals.price_micros, 10_299_000);
143 assert_eq!(totals.discount_micros, 10_299_000);
144 assert_eq!(totals.charged_micros, 0);
145 assert_eq!(charged(&totals), 0);
146 }
147
148 #[test]
149 fn included_usage_pays_first_and_only_the_rest_is_charged() {
150 // The plan: $10 included. $12.50 of runs at price, $10 of it drawn
151 // from the included usage on the lines themselves; $0.60 of storage
152 // pending, which the close will charge in full (the included usage
153 // is spent).
154 let pending = [pending_split(500_000, with_margin(500_000, MARGIN), MARGIN, 0)];
155 assert_eq!(pending[0], PendingSplit { price: 600_000, covered: 0, discount: 0 });
156 let totals = month_totals(12_500_000, 0, 10_000_000, 0, &pending);
157 assert_eq!(totals.included_micros, 10_000_000);
158 assert_eq!(totals.charged_micros, 2_500_000 + 600_000);
159 // Credit from g1t pays after that.
160 let with_credit = month_totals(12_500_000, 0, 10_000_000, 1_000_000, &pending);
161 assert_eq!(with_credit.charged_micros, 2_100_000);
162 // Never below zero: more credit than usage charges nothing.
163 assert_eq!(month_totals(1_000_000, 0, 0, 5_000_000, &[]).charged_micros, 0);
164 }
165
166 #[test]
167 fn pay_as_you_go_is_charged_at_price() {
168 // No discount, nothing included, no credit: price is what is
169 // charged, pending usage counted at its price too.
170 let pending = [pending_split(1_000_000, with_margin(1_000_000, MARGIN), MARGIN, 0), pending_split(250, 300, MARGIN, 0)];
171 let totals = month_totals(4_000_000, 0, 0, 0, &pending);
172 assert_eq!(totals.pending_micros, 1_200_000 + 300);
173 assert_eq!(totals.charged_micros, 4_000_000 + 1_200_000 + 300);
174 }
175
176 #[test]
177 fn a_partial_discount_comes_off_pending_usage_as_the_close_will_take_it() {
178 // 30% off: $0.009 of pending storage is charged $0.0063.
179 let split = pending_split(7_500, 9_000, MARGIN, 30);
180 assert_eq!(split, PendingSplit { price: 9_000, covered: 0, discount: 2_700 });
181 assert_eq!(split.charged(), 6_300);
182 }
183
184 #[test]
185 fn what_g1t_covers_on_a_free_workspace_is_included_not_charged() {
186 // Security scans on a free workspace: metered at cost, charged
187 // nothing (`set_pending` writes a charge of 0), shown at price.
188 let split = pending_split(2_000, 0, MARGIN, 0);
189 assert_eq!(split, PendingSplit { price: 2_400, covered: 2_400, discount: 0 });
190 assert_eq!(split.charged(), 0);
191 let totals = month_totals(0, 0, 0, 0, &[split]);
192 assert_eq!(totals.price_micros, 2_400);
193 assert_eq!(totals.included_micros, 2_400);
194 assert_eq!(totals.charged_micros, 0);
195 // A charge above the price (an older margin) is held to the price.
196 assert_eq!(pending_split(1_000, 5_000, MARGIN, 0).covered, 0);
197 }
198
199 #[test]
200 fn a_pending_row_reads_whole_micros_and_never_less_than_nothing() {
201 let line = PendingLine { source: "storage".into(), cost_micros: Some(7_499.6), charge_micros: Some(-1.0) };
202 assert_eq!(line.cost(), 7_500);
203 assert_eq!(line.charge(), 0);
204 assert_eq!(PendingLine::default().cost(), 0);
205 }
206}