| 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 | 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 | |
| 47 | impl 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)] |
| 60 | pub(crate) struct PendingSplit { |
| 61 | pub price: i64, |
| 62 | pub covered: i64, |
| 63 | pub discount: i64, |
| 64 | } |
| 65 | |
| 66 | impl 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. |
| 76 | pub(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. |
| 85 | pub(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. |
| 95 | pub(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 | |
| 110 | impl 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)] |
| 130 | mod 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 | } |