| 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 | |
| 18 | use g1t_contracts::billing::UsageTotals; |
| 19 | use serde::Deserialize; |
| 20 | use worker::wasm_bindgen::JsValue; |
| 21 | use worker::Result; |
| 22 | |
| 23 | use crate::Billing; |
| 24 | use 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". |
| 28 | pub(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)] |
| 35 | pub(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 | |
| 50 | impl 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)] |
| 63 | pub(crate) struct PendingSplit { |
| 64 | pub price: i64, |
| 65 | pub covered: i64, |
| 66 | pub discount: i64, |
| 67 | } |
| 68 | |
| 69 | impl 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. |
| 79 | pub(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. |
| 88 | pub(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. |
| 104 | pub(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. |
| 114 | pub(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. |
| 131 | pub(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 | |
| 140 | impl 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)] |
| 159 | mod 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 | } |